krz/skunky-art

Alternative privacy frontend for DeviantArt.

clone: git clone https://gitbay.org/krz/skunky-art.git

v1.4.0: app/api_json.go · raw

  1package app
  2
  3import (
  4	"encoding/json"
  5	"regexp"
  6
  7	"github.com/krazywarez/devianter"
  8)
  9
 10// The API deliberately serves its own shapes rather than devianter's structs.
 11// Those describe DeviantArt's payloads and change when DeviantArt changes; an
 12// instance's consumers should not have to.
 13type apiDeviation struct {
 14	ID        int      `json:"id"`
 15	Title     string   `json:"title"`
 16	Author    string   `json:"author"`
 17	URL       string   `json:"url"`
 18	Published string   `json:"published,omitempty"`
 19	NSFW      bool     `json:"nsfw"`
 20	AI        bool     `json:"ai"`
 21	DailyDev  bool     `json:"daily_deviation"`
 22	Tags      []string `json:"tags,omitempty"`
 23	Preview   string   `json:"preview,omitempty"`
 24	Fullview  string   `json:"fullview,omitempty"`
 25	Favourite int      `json:"favourites"`
 26	Views     int      `json:"views"`
 27}
 28
 29type apiSearchResponse struct {
 30	Query   string         `json:"query"`
 31	Type    string         `json:"type"`
 32	Page    int            `json:"page"`
 33	Results []apiDeviation `json:"results"`
 34}
 35
 36// toAPIDeviation flattens one deviation. Media URLs are routed back through this
 37// instance when proxying is on, so a consumer never has to talk to wixmp itself
 38// — the same indirection the HTML pages use.
 39func (s skunkyart) toAPIDeviation(d *devianter.Deviation) apiDeviation {
 40	out := apiDeviation{
 41		ID:        d.ID,
 42		Title:     d.Title,
 43		Author:    d.Author.Username,
 44		URL:       ConvertDeviantArtURLToSkunkyArt(s.Host, d.Url),
 45		NSFW:      d.NSFW,
 46		AI:        d.AI,
 47		DailyDev:  d.DD,
 48		Preview:   ParseMedia(s.Host, d.Media, 320),
 49		Fullview:  ParseMedia(s.Host, d.Media),
 50		Favourite: d.Stats.Favourites,
 51		Views:     d.Stats.Views,
 52	}
 53	if !d.PublishedTime.IsZero() {
 54		out.Published = d.PublishedTime.UTC().Format("2006-01-02T15:04:05Z")
 55	}
 56	for _, t := range d.Extended.Tags {
 57		out.Tags = append(out.Tags, t.Name)
 58	}
 59	return out
 60}
 61
 62// writeJSON marshals v. A marshal failure is reported as a 500 rather than
 63// sending a half-written body with a 200 already on the wire.
 64func (a API) writeJSON(v any) {
 65	body, err := json.Marshal(v)
 66	if err != nil {
 67		a.Error("failed to encode response", 500)
 68		return
 69	}
 70	_, _ = a.main.Writer.Write(body)
 71}
 72
 73// Search responds with the deviations matching ?q=, honouring the instance's
 74// NSFW and hide-ai settings through VisibleDeviation — the same rule the HTML
 75// listing applies.
 76//
 77// ?type= takes the same single letters the pages do: a (art, default),
 78// t (text), g (gallery), f (favourites). Gallery and favourites need ?usr=.
 79func (a API) Search() {
 80	s := a.main
 81	if s.Query == "" {
 82		a.Error("missing required parameter: q", 400)
 83		return
 84	}
 85
 86	kind := s.Type
 87	if kind == 0 {
 88		kind = 'a'
 89	}
 90
 91	var (
 92		result  devianter.Search
 93		daError devianter.Error
 94		err     error
 95	)
 96	switch kind {
 97	case 'a', 't':
 98		result, daError, err = devianter.PerformSearch(s.Query, s.Page, kind)
 99	case 'g', 'f':
100		usr := s.Args.Get("usr")
101		if usr == "" {
102			a.Error("type "+string(kind)+" requires the usr parameter", 400)
103			return
104		}
105		result, daError, err = devianter.PerformSearch(s.Query, s.Page, kind, usr)
106	default:
107		a.Error("unsupported type: "+string(kind), 400)
108		return
109	}
110
111	if err != nil {
112		a.Error("upstream request failed", 502)
113		return
114	}
115	if daError.RAW != nil {
116		a.Error("deviantart returned an error", 502)
117		return
118	}
119
120	// Non-nil so an empty page marshals as [] rather than null.
121	out := apiSearchResponse{
122		Query:   s.Query,
123		Type:    string(kind),
124		Page:    s.Page,
125		Results: []apiDeviation{},
126	}
127	for i := range result.Results {
128		d := &result.Results[i]
129		if !VisibleDeviation(d) {
130			continue
131		}
132		out.Results = append(out.Results, s.toAPIDeviation(d))
133	}
134	a.writeJSON(out)
135}
136
137// Post responds with a single deviation. postname carries the numeric id the
138// way the HTML route does, e.g. "some-title-123456789".
139//
140// Gated on NSFW only, matching the page: hide-ai omits AI work from *listings*,
141// and a reader who has followed a direct link to one still gets it. Diverging
142// here would make the API disagree with the site it fronts.
143func (a API) Post(author, postname string) {
144	s := a.main
145	if author == "" || postname == "" {
146		a.Error("missing author or post name", 400)
147		return
148	}
149
150	idSearch := regexp.MustCompile("[0-9]+").FindAllString(postname, -1)
151	if len(idSearch) < 1 {
152		a.Error("post name carries no deviation id", 400)
153		return
154	}
155
156	post, daError := devianter.GetDeviation(idSearch[len(idSearch)-1], author)
157	if daError.RAW != nil {
158		a.Error("deviantart returned an error", 502)
159		return
160	}
161
162	d := &post.Deviation
163	if d.NSFW && !CFG.Nsfw {
164		a.Error("nsfw content is disabled on this instance", 403)
165		return
166	}
167
168	a.writeJSON(struct {
169		apiDeviation
170		Description string `json:"description,omitempty"`
171		Downloads   int    `json:"downloads"`
172		Filesize    int    `json:"filesize,omitempty"`
173		Width       int    `json:"width,omitempty"`
174		Height      int    `json:"height,omitempty"`
175	}{
176		apiDeviation: s.toAPIDeviation(d),
177		Description:  ParseDescription(s.Host, d.Extended.DescriptionText),
178		Downloads:    d.Stats.Downloads,
179		Filesize:     d.Extended.OriginalFile.Filesize,
180		Width:        d.Extended.OriginalFile.Width,
181		Height:       d.Extended.OriginalFile.Height,
182	})
183}