Commit 09de76a9b4
Verified · cmc
Layout: unified · split
API.txt added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | # API | |
| 2 | ||
| 3 | JSON endpoints under `/api`. Read-only, no authentication, no state. | |
| 4 | ||
| 5 | Every response is `application/json`. Errors are `{"error":"..."}` with a real | |
| 6 | HTTP status — 400 for a bad request, 403 when the instance forbids the content, | |
| 7 | 404 for an unknown endpoint or deviation, 502 when DeviantArt fails. | |
| 8 | ||
| 9 | An instance's settings apply here exactly as they do to the pages. `hide-ai` | |
| 10 | omits AI work from listings, `nsfw` gates mature content, and both are decided | |
| 11 | by the same predicate the HTML listing uses, so the API cannot serve what the | |
| 12 | site withholds. | |
| 13 | ||
| 14 | Media URLs point back at this instance when `proxy` is on, so a consumer never | |
| 15 | has to talk to wixmp itself. | |
| 16 | ||
| 17 | ## GET /api/instance | |
| 18 | ||
| 19 | Version 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 | ||
| 25 | Parameters: | |
| 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 | ||
| 59 | One deviation. `postname` carries the numeric id the way the site's own URLs do, | |
| 60 | e.g. `a-title-123456789`. | |
| 61 | ||
| 62 | Returns the fields above plus `description`, `downloads`, `filesize`, `width` | |
| 63 | and `height`. | |
| 64 | ||
| 65 | Gated on `nsfw` only, matching the page: `hide-ai` omits AI work from listings, | |
| 66 | and a reader following a direct link to one still gets it. | |
| 67 | ||
| 68 | ## GET /api/random | |
| 69 | ||
| 70 | A random artwork's media — the image itself, not JSON. Honours `nsfw`. | |
TODO.txt +1 −1
| @@ -21,7 +21,7 @@ | ||
| 21 | 21 | |
| 22 | 22 | ## v1.4 |
| 23 | 23 | |
| 24 | - [ ] Implement an API | |
| 24 | - [x] Implement an API | |
| 25 | 25 | - [ ] Implement themes |
| 26 | 26 | - [ ] Switch to arenas in the cache |
| 27 | 27 | - [ ] Implement a multilingual interface |
app/api_json.go added +183
| @@ -0,0 +1,183 @@ | ||
| 1 | package app | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 13 | type 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 | ||
| 29 | type 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. | |
| 39 | func (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. | |
| 64 | func (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=. | |
| 79 | func (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. | |
| 143 | func (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 @@ | ||
| 1 | package app | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 15 | func 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. | |
| 47 | func 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. | |
| 61 | func 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. | |
| 73 | func 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. | |
| 88 | func 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. | |
| 100 | func 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) | ||
| 82 | 82 | // DeviationList renders devs as an HTML grid, or as an Atom feed when the |
| 83 | 83 | // request asked for one and allowAtom permits it. NSFW entries are dropped |
| 84 | 84 | // 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. | |
| 91 | func VisibleDeviation(d *devianter.Deviation) bool { | |
| 92 | if d.AI && CFG.HideAI { | |
| 93 | return false | |
| 94 | } | |
| 95 | return !d.NSFW || CFG.Nsfw | |
| 96 | } | |
| 97 | ||
| 85 | 98 | func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string { |
| 86 | 99 | if s.Atom && s.Page > 1 { |
| 87 | 100 | s.ReturnHTTPError(400) |
| @@ -92,10 +105,10 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con | ||
| 92 | 105 | |
| 93 | 106 | for i, l := 0, len(devs); i < l; i++ { |
| 94 | 107 | data := &devs[i] |
| 95 | if data.AI && CFG.HideAI { | |
| 108 | if !VisibleDeviation(data) { | |
| 96 | 109 | continue |
| 97 | 110 | } |
| 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 { | |
| 99 | 112 | if allowAtom && s.Atom { |
| 100 | 113 | s.Writer.Header().Add("Content-Type", "application/atom+xml") |
| 101 | 114 | id := strconv.Itoa(data.ID) |
app/router.go +4
| @@ -147,6 +147,10 @@ func Router() { | ||
| 147 | 147 | skunky.API.Info() |
| 148 | 148 | case "random": |
| 149 | 149 | skunky.API.Random() |
| 150 | case "search": | |
| 151 | skunky.API.Search() | |
| 152 | case "post": | |
| 153 | skunky.API.Post(path[3], path[4]) | |
| 150 | 154 | default: |
| 151 | 155 | skunky.API.Error("Not Found", 404) |
| 152 | 156 | } |