Commit 09de76a9b4

09de76a9b48a6f4d36277d13e322d813a63082d8

parent: b5d702053e

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 00:58 UTC

Add a JSON API for search and deviations

/api had instance and random; random returns an image rather than JSON, so
instance was the whole of it. Adds /api/search and /api/post, documented in
API.txt.

The responses are this project's own shapes, not devianter's structs. Those
describe DeviantArt's payloads and change when DeviantArt does; an instance's
consumers should not inherit that.

The listing rules are now one function. VisibleDeviation holds the hide-ai and
NSFW checks that were inline in DeviationList, and both the HTML listing and the
API call it — an API that returned what the pages hide would leak precisely what
those settings exist to withhold, and a second copy of the rule is a second
thing to forget.

/api/post gates on NSFW only, matching the page it fronts: hide-ai omits AI work
from listings, and a reader following a direct link still gets it.

Verified: the error paths against a running instance, and the response shape,
filtering and parameter validation by unit test. The success path is unverified
here — DeviantArt returns 403 to this machine, so the HTML search cannot fetch
either.

Layout: unified · split

API.txt added +70
@@ -0,0 +1,70 @@
1# API
2
3JSON endpoints under `/api`. Read-only, no authentication, no state.
4
5Every response is `application/json`. Errors are `{"error":"..."}` with a real
6HTTP status — 400 for a bad request, 403 when the instance forbids the content,
7404 for an unknown endpoint or deviation, 502 when DeviantArt fails.
8
9An instance's settings apply here exactly as they do to the pages. `hide-ai`
10omits AI work from listings, `nsfw` gates mature content, and both are decided
11by the same predicate the HTML listing uses, so the API cannot serve what the
12site withholds.
13
14Media URLs point back at this instance when `proxy` is on, so a consumer never
15has to talk to wixmp itself.
16
17## GET /api/instance
18
19Version and the instance's settings.
20
21 {"version":"1.4.0","settings":{"nsfw":false,"proxy":true,"hide-ai":false,"theme":"auto"}}
22
23## GET /api/search
24
25Parameters:
26
27* `q` — required. The search query.
28* `type` — `a` art (default), `t` text, `g` gallery, `f` favourites.
29* `usr` — required for `g` and `f`; the user whose gallery or favourites to read.
30* `p` — page number, default 0.
31
32 {
33 "query": "fox",
34 "type": "a",
35 "page": 0,
36 "results": [
37 {
38 "id": 123456789,
39 "title": "A Title",
40 "author": "alice",
41 "url": "https://instance/post/alice/a-title-123456789",
42 "published": "2026-01-02T15:04:05Z",
43 "nsfw": false,
44 "ai": false,
45 "daily_deviation": false,
46 "tags": ["cats"],
47 "preview": "https://instance/media/file/...",
48 "fullview": "https://instance/media/file/...",
49 "favourites": 7,
50 "views": 99
51 }
52 ]
53 }
54
55`results` is always an array; an empty page is `[]`, never `null`.
56
57## GET /api/post/{author}/{postname}
58
59One deviation. `postname` carries the numeric id the way the site's own URLs do,
60e.g. `a-title-123456789`.
61
62Returns the fields above plus `description`, `downloads`, `filesize`, `width`
63and `height`.
64
65Gated on `nsfw` only, matching the page: `hide-ai` omits AI work from listings,
66and a reader following a direct link to one still gets it.
67
68## GET /api/random
69
70A random artwork's media — the image itself, not JSON. Honours `nsfw`.
TODO.txt +1 −1
@@ -21,7 +21,7 @@
2121
2222## v1.4
2323
24- [ ] Implement an API
24- [x] Implement an API
2525- [ ] Implement themes
2626- [ ] Switch to arenas in the cache
2727- [ ] Implement a multilingual interface
app/api_json.go added +183
@@ -0,0 +1,183 @@
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}
app/api_json_test.go added +131
@@ -0,0 +1,131 @@
1package app
2
3import (
4 "encoding/json"
5 "net/http/httptest"
6 "strings"
7 "testing"
8
9 "github.com/krazywarez/devianter"
10)
11
12// TestVisibleDeviationMatchesTheListingRules pins the single predicate the HTML
13// listing and the JSON API both use. If these diverge, the API starts serving
14// what the pages withhold.
15func TestVisibleDeviationMatchesTheListingRules(t *testing.T) {
16 nsfw, hide := CFG.Nsfw, CFG.HideAI
17 defer func() { CFG.Nsfw, CFG.HideAI = nsfw, hide }()
18
19 human := &devianter.Deviation{}
20 robot := &devianter.Deviation{AI: true}
21 adult := &devianter.Deviation{NSFW: true}
22
23 CFG.Nsfw, CFG.HideAI = false, false
24 if !VisibleDeviation(human) {
25 t.Error("plain deviation hidden with both settings off")
26 }
27 if !VisibleDeviation(robot) {
28 t.Error("AI deviation hidden while hide-ai is off")
29 }
30 if VisibleDeviation(adult) {
31 t.Error("NSFW deviation shown while nsfw is off")
32 }
33
34 CFG.HideAI = true
35 if VisibleDeviation(robot) {
36 t.Error("AI deviation shown while hide-ai is on")
37 }
38
39 CFG.Nsfw = true
40 if !VisibleDeviation(adult) {
41 t.Error("NSFW deviation hidden while nsfw is on")
42 }
43}
44
45// TestSearchRequiresAQuery covers the guard before any upstream request: an
46// empty q must not become a search for the empty string.
47func TestSearchRequiresAQuery(t *testing.T) {
48 rec := httptest.NewRecorder()
49 s := skunkyart{Writer: rec, Host: "http://localhost"}
50 API{main: &s}.Search()
51
52 if rec.Code != 400 {
53 t.Errorf("status = %d, want 400", rec.Code)
54 }
55 if !strings.Contains(rec.Body.String(), "q") {
56 t.Errorf("body does not name the missing parameter: %q", rec.Body.String())
57 }
58}
59
60// TestSearchRejectsAnUnsupportedType stops an unknown letter reaching devianter.
61func TestSearchRejectsAnUnsupportedType(t *testing.T) {
62 rec := httptest.NewRecorder()
63 s := skunkyart{Writer: rec, Host: "http://localhost", Query: "cats", Type: 'z'}
64 API{main: &s}.Search()
65
66 if rec.Code != 400 {
67 t.Errorf("status = %d, want 400", rec.Code)
68 }
69}
70
71// TestSearchGalleryTypeNeedsAUser: g and f are scoped to a user, and without one
72// the upstream call is meaningless.
73func TestSearchGalleryTypeNeedsAUser(t *testing.T) {
74 for _, kind := range []rune{'g', 'f'} {
75 rec := httptest.NewRecorder()
76 s := skunkyart{Writer: rec, Host: "http://localhost", Query: "cats", Type: kind}
77 s.Args = map[string][]string{}
78 API{main: &s}.Search()
79
80 if rec.Code != 400 {
81 t.Errorf("type %c: status = %d, want 400", kind, rec.Code)
82 }
83 }
84}
85
86// TestPostRejectsANameWithoutAnID mirrors the HTML route, which pulls the
87// deviation id out of the slug.
88func TestPostRejectsANameWithoutAnID(t *testing.T) {
89 rec := httptest.NewRecorder()
90 s := skunkyart{Writer: rec, Host: "http://localhost"}
91 API{main: &s}.Post("someone", "no-digits-here")
92
93 if rec.Code != 400 {
94 t.Errorf("status = %d, want 400", rec.Code)
95 }
96}
97
98// TestToAPIDeviationShape is the contract consumers depend on: field names and
99// the fact that media points back at this instance rather than at wixmp.
100func TestToAPIDeviationShape(t *testing.T) {
101 d := fullviewDeviation()
102 d.ID = 42
103 d.Title = "A Title"
104 d.Author.Username = "alice"
105 d.Stats.Favourites = 7
106 d.Stats.Views = 99
107 d.Extended.Tags = append(d.Extended.Tags, struct{ Name string }{Name: "cats"})
108
109 s := skunkyart{Host: "http://localhost"}
110 body, err := json.Marshal(s.toAPIDeviation(d))
111 if err != nil {
112 t.Fatalf("marshal: %v", err)
113 }
114
115 var got map[string]any
116 if err := json.Unmarshal(body, &got); err != nil {
117 t.Fatalf("unmarshal: %v", err)
118 }
119
120 for _, key := range []string{"id", "title", "author", "url", "nsfw", "ai", "daily_deviation", "favourites", "views"} {
121 if _, ok := got[key]; !ok {
122 t.Errorf("field %q missing from %s", key, body)
123 }
124 }
125 if got["title"] != "A Title" || got["author"] != "alice" {
126 t.Errorf("unexpected values in %s", body)
127 }
128 if tags, ok := got["tags"].([]any); !ok || len(tags) != 1 || tags[0] != "cats" {
129 t.Errorf("tags not flattened to names: %s", body)
130 }
131}
app/parsers.go +15 −2
@@ -82,6 +82,19 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
8282// DeviationList renders devs as an HTML grid, or as an Atom feed when the
8383// request asked for one and allowAtom permits it. NSFW entries are dropped
8484// unless the instance allows them. Passing content adds a navigation bar.
85// VisibleDeviation reports whether a deviation may be shown by this instance.
86//
87// Both the HTML listing and the JSON API ask this, deliberately: an API that
88// returned what the pages hide would leak exactly what hide-ai and the NSFW
89// setting exist to withhold, and a second copy of the rule is a second thing to
90// forget to update.
91func VisibleDeviation(d *devianter.Deviation) bool {
92 if d.AI && CFG.HideAI {
93 return false
94 }
95 return !d.NSFW || CFG.Nsfw
96}
97
8598func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string {
8699 if s.Atom && s.Page > 1 {
87100 s.ReturnHTTPError(400)
@@ -92,10 +105,10 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
92105
93106 for i, l := 0, len(devs); i < l; i++ {
94107 data := &devs[i]
95 if data.AI && CFG.HideAI {
108 if !VisibleDeviation(data) {
96109 continue
97110 }
98 if preview, fullview := ParseMedia(s.Host, data.Media, 320), ParseMedia(s.Host, data.Media); !data.NSFW || CFG.Nsfw {
111 if preview, fullview := ParseMedia(s.Host, data.Media, 320), ParseMedia(s.Host, data.Media); true {
99112 if allowAtom && s.Atom {
100113 s.Writer.Header().Add("Content-Type", "application/atom+xml")
101114 id := strconv.Itoa(data.ID)
app/router.go +4
@@ -147,6 +147,10 @@ func Router() {
147147 skunky.API.Info()
148148 case "random":
149149 skunky.API.Random()
150 case "search":
151 skunky.API.Search()
152 case "post":
153 skunky.API.Post(path[3], path[4])
150154 default:
151155 skunky.API.Error("Not Found", 404)
152156 }