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 | ## v1.4 | 22 | ## v1.4 |
| 23 | 23 | ||
| 24 | - [ ] Implement an API | 24 | - [x] Implement an API |
| 25 | - [ ] Implement themes | 25 | - [ ] Implement themes |
| 26 | - [ ] Switch to arenas in the cache | 26 | - [ ] Switch to arenas in the cache |
| 27 | - [ ] Implement a multilingual interface | 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 | // DeviationList renders devs as an HTML grid, or as an Atom feed when the | 82 | // DeviationList renders devs as an HTML grid, or as an Atom feed when the |
| 83 | // request asked for one and allowAtom permits it. NSFW entries are dropped | 83 | // request asked for one and allowAtom permits it. NSFW entries are dropped |
| 84 | // unless the instance allows them. Passing content adds a navigation bar. | 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 | func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string { | 98 | func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string { |
| 86 | if s.Atom && s.Page > 1 { | 99 | if s.Atom && s.Page > 1 { |
| 87 | s.ReturnHTTPError(400) | 100 | s.ReturnHTTPError(400) |
| @@ -92,10 +105,10 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con | |||
| 92 | 105 | ||
| 93 | for i, l := 0, len(devs); i < l; i++ { | 106 | for i, l := 0, len(devs); i < l; i++ { |
| 94 | data := &devs[i] | 107 | data := &devs[i] |
| 95 | if data.AI && CFG.HideAI { | 108 | if !VisibleDeviation(data) { |
| 96 | continue | 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 | if allowAtom && s.Atom { | 112 | if allowAtom && s.Atom { |
| 100 | s.Writer.Header().Add("Content-Type", "application/atom+xml") | 113 | s.Writer.Header().Add("Content-Type", "application/atom+xml") |
| 101 | id := strconv.Itoa(data.ID) | 114 | id := strconv.Itoa(data.ID) |
app/router.go +4
| @@ -147,6 +147,10 @@ func Router() { | |||
| 147 | skunky.API.Info() | 147 | skunky.API.Info() |
| 148 | case "random": | 148 | case "random": |
| 149 | skunky.API.Random() | 149 | skunky.API.Random() |
| 150 | case "search": | ||
| 151 | skunky.API.Search() | ||
| 152 | case "post": | ||
| 153 | skunky.API.Post(path[3], path[4]) | ||
| 150 | default: | 154 | default: |
| 151 | skunky.API.Error("Not Found", 404) | 155 | skunky.API.Error("Not Found", 404) |
| 152 | } | 156 | } |