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

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.
 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(-)

diff --git a/API.txt b/API.txt
new file mode 100644
index 0000000..e85e472
--- /dev/null
+++ b/API.txt
@@ -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`.
diff --git a/TODO.txt b/TODO.txt
index e5b372e..2b68b76 100755
--- a/TODO.txt
+++ b/TODO.txt
@@ -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
diff --git a/app/api_json.go b/app/api_json.go
new file mode 100644
index 0000000..a8ee97c
--- /dev/null
+++ b/app/api_json.go
@@ -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,
+	})
+}
diff --git a/app/api_json_test.go b/app/api_json_test.go
new file mode 100644
index 0000000..d3cc274
--- /dev/null
+++ b/app/api_json_test.go
@@ -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)
+	}
+}
diff --git a/app/parsers.go b/app/parsers.go
index 2027503..6bee78b 100755
--- a/app/parsers.go
+++ b/app/parsers.go
@@ -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)
diff --git a/app/router.go b/app/router.go
index 4b68282..96fcec9 100755
--- a/app/router.go
+++ b/app/router.go
@@ -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)
 			}