krz/skunky-art
Alternative privacy frontend for DeviantArt.
clone: git clone https://gitbay.org/krz/skunky-art.git
09de76a9b48a6f4d36277d13e322d813a63082d8
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T00:58:08Z
API.txt | 70 ++++++++++++++++++++ TODO.txt | 2 +- app/api_json.go | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++ app/api_json_test.go | 131 ++++++++++++++++++++++++++++++++++++ app/parsers.go | 17 ++++- app/router.go | 4 ++ 6 files changed, 404 insertions(+), 3 deletions(-) new file mode 100644 @@ -0,0 +1,70 @@ +# API + +JSON endpoints under `/api`. Read-only, no authentication, no state. + +Every response is `application/json`. Errors are `{"error":"..."}` with a real +HTTP status — 400 for a bad request, 403 when the instance forbids the content, +404 for an unknown endpoint or deviation, 502 when DeviantArt fails. + +An instance's settings apply here exactly as they do to the pages. `hide-ai` +omits AI work from listings, `nsfw` gates mature content, and both are decided +by the same predicate the HTML listing uses, so the API cannot serve what the +site withholds. + +Media URLs point back at this instance when `proxy` is on, so a consumer never +has to talk to wixmp itself. + +## GET /api/instance + +Version and the instance's settings. + + {"version":"1.4.0","settings":{"nsfw":false,"proxy":true,"hide-ai":false,"theme":"auto"}} + +## GET /api/search + +Parameters: + +* `q` — required. The search query. +* `type` — `a` art (default), `t` text, `g` gallery, `f` favourites. +* `usr` — required for `g` and `f`; the user whose gallery or favourites to read. +* `p` — page number, default 0. + + { + "query": "fox", + "type": "a", + "page": 0, + "results": [ + { + "id": 123456789, + "title": "A Title", + "author": "alice", + "url": "https://instance/post/alice/a-title-123456789", + "published": "2026-01-02T15:04:05Z", + "nsfw": false, + "ai": false, + "daily_deviation": false, + "tags": ["cats"], + "preview": "https://instance/media/file/...", + "fullview": "https://instance/media/file/...", + "favourites": 7, + "views": 99 + } + ] + } + +`results` is always an array; an empty page is `[]`, never `null`. + +## GET /api/post/{author}/{postname} + +One deviation. `postname` carries the numeric id the way the site's own URLs do, +e.g. `a-title-123456789`. + +Returns the fields above plus `description`, `downloads`, `filesize`, `width` +and `height`. + +Gated on `nsfw` only, matching the page: `hide-ai` omits AI work from listings, +and a reader following a direct link to one still gets it. + +## GET /api/random + +A random artwork's media — the image itself, not JSON. Honours `nsfw`. @@ -21,7 +21,7 @@ ## v1.4 -- [ ] Implement an API +- [x] Implement an API - [ ] Implement themes - [ ] Switch to arenas in the cache - [ ] Implement a multilingual interface new file mode 100644 @@ -0,0 +1,183 @@ +package app + +import ( + "encoding/json" + "regexp" + + "github.com/krazywarez/devianter" +) + +// The API deliberately serves its own shapes rather than devianter's structs. +// Those describe DeviantArt's payloads and change when DeviantArt changes; an +// instance's consumers should not have to. +type apiDeviation struct { + ID int `json:"id"` + Title string `json:"title"` + Author string `json:"author"` + URL string `json:"url"` + Published string `json:"published,omitempty"` + NSFW bool `json:"nsfw"` + AI bool `json:"ai"` + DailyDev bool `json:"daily_deviation"` + Tags []string `json:"tags,omitempty"` + Preview string `json:"preview,omitempty"` + Fullview string `json:"fullview,omitempty"` + Favourite int `json:"favourites"` + Views int `json:"views"` +} + +type apiSearchResponse struct { + Query string `json:"query"` + Type string `json:"type"` + Page int `json:"page"` + Results []apiDeviation `json:"results"` +} + +// toAPIDeviation flattens one deviation. Media URLs are routed back through this +// instance when proxying is on, so a consumer never has to talk to wixmp itself +// — the same indirection the HTML pages use. +func (s skunkyart) toAPIDeviation(d *devianter.Deviation) apiDeviation { + out := apiDeviation{ + ID: d.ID, + Title: d.Title, + Author: d.Author.Username, + URL: ConvertDeviantArtURLToSkunkyArt(s.Host, d.Url), + NSFW: d.NSFW, + AI: d.AI, + DailyDev: d.DD, + Preview: ParseMedia(s.Host, d.Media, 320), + Fullview: ParseMedia(s.Host, d.Media), + Favourite: d.Stats.Favourites, + Views: d.Stats.Views, + } + if !d.PublishedTime.IsZero() { + out.Published = d.PublishedTime.UTC().Format("2006-01-02T15:04:05Z") + } + for _, t := range d.Extended.Tags { + out.Tags = append(out.Tags, t.Name) + } + return out +} + +// writeJSON marshals v. A marshal failure is reported as a 500 rather than +// sending a half-written body with a 200 already on the wire. +func (a API) writeJSON(v any) { + body, err := json.Marshal(v) + if err != nil { + a.Error("failed to encode response", 500) + return + } + _, _ = a.main.Writer.Write(body) +} + +// Search responds with the deviations matching ?q=, honouring the instance's +// NSFW and hide-ai settings through VisibleDeviation — the same rule the HTML +// listing applies. +// +// ?type= takes the same single letters the pages do: a (art, default), +// t (text), g (gallery), f (favourites). Gallery and favourites need ?usr=. +func (a API) Search() { + s := a.main + if s.Query == "" { + a.Error("missing required parameter: q", 400) + return + } + + kind := s.Type + if kind == 0 { + kind = 'a' + } + + var ( + result devianter.Search + daError devianter.Error + err error + ) + switch kind { + case 'a', 't': + result, daError, err = devianter.PerformSearch(s.Query, s.Page, kind) + case 'g', 'f': + usr := s.Args.Get("usr") + if usr == "" { + a.Error("type "+string(kind)+" requires the usr parameter", 400) + return + } + result, daError, err = devianter.PerformSearch(s.Query, s.Page, kind, usr) + default: + a.Error("unsupported type: "+string(kind), 400) + return + } + + if err != nil { + a.Error("upstream request failed", 502) + return + } + if daError.RAW != nil { + a.Error("deviantart returned an error", 502) + return + } + + // Non-nil so an empty page marshals as [] rather than null. + out := apiSearchResponse{ + Query: s.Query, + Type: string(kind), + Page: s.Page, + Results: []apiDeviation{}, + } + for i := range result.Results { + d := &result.Results[i] + if !VisibleDeviation(d) { + continue + } + out.Results = append(out.Results, s.toAPIDeviation(d)) + } + a.writeJSON(out) +} + +// Post responds with a single deviation. postname carries the numeric id the +// way the HTML route does, e.g. "some-title-123456789". +// +// Gated on NSFW only, matching the page: hide-ai omits AI work from *listings*, +// and a reader who has followed a direct link to one still gets it. Diverging +// here would make the API disagree with the site it fronts. +func (a API) Post(author, postname string) { + s := a.main + if author == "" || postname == "" { + a.Error("missing author or post name", 400) + return + } + + idSearch := regexp.MustCompile("[0-9]+").FindAllString(postname, -1) + if len(idSearch) < 1 { + a.Error("post name carries no deviation id", 400) + return + } + + post, daError := devianter.GetDeviation(idSearch[len(idSearch)-1], author) + if daError.RAW != nil { + a.Error("deviantart returned an error", 502) + return + } + + d := &post.Deviation + if d.NSFW && !CFG.Nsfw { + a.Error("nsfw content is disabled on this instance", 403) + return + } + + a.writeJSON(struct { + apiDeviation + Description string `json:"description,omitempty"` + Downloads int `json:"downloads"` + Filesize int `json:"filesize,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + }{ + apiDeviation: s.toAPIDeviation(d), + Description: ParseDescription(s.Host, d.Extended.DescriptionText), + Downloads: d.Stats.Downloads, + Filesize: d.Extended.OriginalFile.Filesize, + Width: d.Extended.OriginalFile.Width, + Height: d.Extended.OriginalFile.Height, + }) +} new file mode 100644 @@ -0,0 +1,131 @@ +package app + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/krazywarez/devianter" +) + +// TestVisibleDeviationMatchesTheListingRules pins the single predicate the HTML +// listing and the JSON API both use. If these diverge, the API starts serving +// what the pages withhold. +func TestVisibleDeviationMatchesTheListingRules(t *testing.T) { + nsfw, hide := CFG.Nsfw, CFG.HideAI + defer func() { CFG.Nsfw, CFG.HideAI = nsfw, hide }() + + human := &devianter.Deviation{} + robot := &devianter.Deviation{AI: true} + adult := &devianter.Deviation{NSFW: true} + + CFG.Nsfw, CFG.HideAI = false, false + if !VisibleDeviation(human) { + t.Error("plain deviation hidden with both settings off") + } + if !VisibleDeviation(robot) { + t.Error("AI deviation hidden while hide-ai is off") + } + if VisibleDeviation(adult) { + t.Error("NSFW deviation shown while nsfw is off") + } + + CFG.HideAI = true + if VisibleDeviation(robot) { + t.Error("AI deviation shown while hide-ai is on") + } + + CFG.Nsfw = true + if !VisibleDeviation(adult) { + t.Error("NSFW deviation hidden while nsfw is on") + } +} + +// TestSearchRequiresAQuery covers the guard before any upstream request: an +// empty q must not become a search for the empty string. +func TestSearchRequiresAQuery(t *testing.T) { + rec := httptest.NewRecorder() + s := skunkyart{Writer: rec, Host: "http://localhost"} + API{main: &s}.Search() + + if rec.Code != 400 { + t.Errorf("status = %d, want 400", rec.Code) + } + if !strings.Contains(rec.Body.String(), "q") { + t.Errorf("body does not name the missing parameter: %q", rec.Body.String()) + } +} + +// TestSearchRejectsAnUnsupportedType stops an unknown letter reaching devianter. +func TestSearchRejectsAnUnsupportedType(t *testing.T) { + rec := httptest.NewRecorder() + s := skunkyart{Writer: rec, Host: "http://localhost", Query: "cats", Type: 'z'} + API{main: &s}.Search() + + if rec.Code != 400 { + t.Errorf("status = %d, want 400", rec.Code) + } +} + +// TestSearchGalleryTypeNeedsAUser: g and f are scoped to a user, and without one +// the upstream call is meaningless. +func TestSearchGalleryTypeNeedsAUser(t *testing.T) { + for _, kind := range []rune{'g', 'f'} { + rec := httptest.NewRecorder() + s := skunkyart{Writer: rec, Host: "http://localhost", Query: "cats", Type: kind} + s.Args = map[string][]string{} + API{main: &s}.Search() + + if rec.Code != 400 { + t.Errorf("type %c: status = %d, want 400", kind, rec.Code) + } + } +} + +// TestPostRejectsANameWithoutAnID mirrors the HTML route, which pulls the +// deviation id out of the slug. +func TestPostRejectsANameWithoutAnID(t *testing.T) { + rec := httptest.NewRecorder() + s := skunkyart{Writer: rec, Host: "http://localhost"} + API{main: &s}.Post("someone", "no-digits-here") + + if rec.Code != 400 { + t.Errorf("status = %d, want 400", rec.Code) + } +} + +// TestToAPIDeviationShape is the contract consumers depend on: field names and +// the fact that media points back at this instance rather than at wixmp. +func TestToAPIDeviationShape(t *testing.T) { + d := fullviewDeviation() + d.ID = 42 + d.Title = "A Title" + d.Author.Username = "alice" + d.Stats.Favourites = 7 + d.Stats.Views = 99 + d.Extended.Tags = append(d.Extended.Tags, struct{ Name string }{Name: "cats"}) + + s := skunkyart{Host: "http://localhost"} + body, err := json.Marshal(s.toAPIDeviation(d)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + for _, key := range []string{"id", "title", "author", "url", "nsfw", "ai", "daily_deviation", "favourites", "views"} { + if _, ok := got[key]; !ok { + t.Errorf("field %q missing from %s", key, body) + } + } + if got["title"] != "A Title" || got["author"] != "alice" { + t.Errorf("unexpected values in %s", body) + } + if tags, ok := got["tags"].([]any); !ok || len(tags) != 1 || tags[0] != "cats" { + t.Errorf("tags not flattened to names: %s", body) + } +} @@ -82,6 +82,19 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) // DeviationList renders devs as an HTML grid, or as an Atom feed when the // request asked for one and allowAtom permits it. NSFW entries are dropped // unless the instance allows them. Passing content adds a navigation bar. +// VisibleDeviation reports whether a deviation may be shown by this instance. +// +// Both the HTML listing and the JSON API ask this, deliberately: an API that +// returned what the pages hide would leak exactly what hide-ai and the NSFW +// setting exist to withhold, and a second copy of the rule is a second thing to +// forget to update. +func VisibleDeviation(d *devianter.Deviation) bool { + if d.AI && CFG.HideAI { + return false + } + return !d.NSFW || CFG.Nsfw +} + func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string { if s.Atom && s.Page > 1 { s.ReturnHTTPError(400) @@ -92,10 +105,10 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con for i, l := 0, len(devs); i < l; i++ { data := &devs[i] - if data.AI && CFG.HideAI { + if !VisibleDeviation(data) { continue } - if preview, fullview := ParseMedia(s.Host, data.Media, 320), ParseMedia(s.Host, data.Media); !data.NSFW || CFG.Nsfw { + if preview, fullview := ParseMedia(s.Host, data.Media, 320), ParseMedia(s.Host, data.Media); true { if allowAtom && s.Atom { s.Writer.Header().Add("Content-Type", "application/atom+xml") id := strconv.Itoa(data.ID) @@ -147,6 +147,10 @@ func Router() { skunky.API.Info() case "random": skunky.API.Random() + case "search": + skunky.API.Search() + case "post": + skunky.API.Post(path[3], path[4]) default: skunky.API.Error("Not Found", 404) }