ROADMAP.md

v1.5.2
skunky-art/ROADMAP.md rendered · source · history · blame · raw

378 lines · 13478 bytes

Roadmap

Findings from a full review on 2026-09-10, ordered for work. Each item is sized to be one gitbay issue and one merge request. Sizes: S is under an hour, M is a few hours, L needs a design pass first.

Verified on: local build from main at 028903a, live instance art.krz.sh (v1.4.0), devianter v0.3.4. DeviantArt's WAF blocks the reviewing machine's egress IP, so DA-backed pages were checked on the live instance.

Existing gitbay issues: #1 cleanup, #2 search filters, #3 description parsing, #4 instance checker, #5 Makefile, #6 emote bug. They are mapped below where they overlap.

Status

Everything below except 5.3 and 5.5 shipped in v1.5.0 (2026-09-11), with v1.5.1 fixing the release build. Merge requests !6 through !25 on gitbay. Still open: #33 (LibRedirect submission), #1, #2, #3 and #6 from the original list, which need real DeviantArt payloads to work from.

Ordering principle

Frontend proxies get blocked upstream. The existing throttle (app/httpclient.go) spreads requests out; it does not reduce them. Tier 1 reduces them. Tier 0 lands first because everything after it needs CI. Tier 2's escaping fix lands before other template work because it touches every template and builder.

Upstream cost today

Nothing from DeviantArt's JSON API is cached. Only wixmp media is cached, and only when cache.enabled is on, which defaults to off.

Page Upstream calls per view
Post 2 API (deviation, comments) + 1 avatar per commenter
User about 2 API (gruser, comments) + avatars
Gallery, favourites, search, DD 1 API, plus thumbnails when the media cache is off
/api/random up to 3 searches
Atom feed 1 API per poll, per reader

Avatars come from a.deviantart.net and are fetched fresh on every view. Concurrent requests for the same page each go upstream. No response carries Cache-Control. There is no robots.txt and no per-client rate limit.

Tier 0: process

0.1 CI on merge requests (S, #7)

Done in !6.

No pipeline runs go vet, go test or golangci-lint. The only workflow is the GitHub release build; gitbay has zero builds. .golangci.yml exists but is never run.

Fix: add a gitbay build that runs vet, test with -race, and golangci-lint on every MR. Check gitbay build --help and ssh git@gitbay.org help build for the pipeline format. Optionally mirror the same job to .github/workflows/ so the GitHub mirror shows status too.

Verify: an MR with a failing test shows a failed build.

Tier 1: reduce upstream requests

1.1 Cache DeviantArt API responses (L, #8)

Done in !8.

Add an in-memory cache in front of every devianter call: DD, search, deviation, gruser, gallery, favourites, comments. Keyed by endpoint plus arguments. Bounded by entry count or bytes. Singleflight so concurrent requests for one key share a single upstream call. Per-endpoint TTLs, in config with defaults on the order of: DD and search a few minutes, deviations longer, comments shorter.

This is the item the TODO at app/cache.go:3 names. It makes feed polling and /api/random close to free.

Design first: cache interface, key derivation, TTL config keys, eviction, what the hit/miss log looks like. Write the spec to docs/superpowers/specs/ and review before code.

Files: new app/apicache.go, call sites in app/wrapper.go, app/api.go, app/api_json.go, config in app/config.go, docs in SETUP.md.

Verify: unit tests for TTL expiry, singleflight, and bounded size. Two sequential requests for one page make one upstream call.

1.2 Cache avatars and emotes, add Cache-Control (M, #9)

Done in !9.

Emojitar in app/wrapper.go fetches from a.deviantart.net or e.deviantart.net on every request and never stores the result. Route it through the same disk and memory cache path as DownloadAndSendMedia.

Add Cache-Control headers: long max-age with immutable on /media/file (token-signed wixmp URLs do not change), a day on avatars and emotes, a day on /stylesheet and /favicon.ico, a short max-age on HTML matching the API cache TTL.

Depends on: nothing, but pairs with 1.1.

Verify: second avatar request is served without an upstream fetch; headers present in curl -I output.

1.3 robots.txt and per-client rate limit (S, #10)

Done in !10.

Serve /robots.txt disallowing /search, /api, /group_user, /media and any path with ?p=. Add a per-client-IP token bucket ahead of the upstream throttle so one crawler cannot consume the whole DA budget and turn it into latency for everyone else. Honour X-Forwarded-For only when the request came from a configured trusted proxy.

Files: app/router.go, new app/ratelimit.go, app/config.go, SETUP.md.

Verify: test that N+1 requests from one address within the window get 429.

1.4 Fewer calls per page (M, #11)

Done in !11.

Post view is two API calls because comments are fetched inline. Move comments behind a link (/post/{author}/{name}/comments or ?comments=1) so the default post view is one call. Same for the user about page.

Rebuild /api/random to pick from cached DD or search results (1.1) instead of issuing up to three fresh searches per hit.

Depends on: 1.1.

Verify: post view makes exactly one upstream call in a test with a fake transport.

1.5 Media cache on by default (S, #12)

Done in !12.

A proxying instance with no cache re-fetches every image from wixmp on every view. Set cache.enabled: true in the built-in defaults in app/config.go and in config.example.json, with a sane lifetime and max-size. Keep memcache off. Document the change in SETUP.md.

Verify: fresh start with no config writes to the cache directory.

Tier 2: security and correctness

2.1 Escape template output (M, #13)

Done in !7.

app/util.go imports text/template. Nothing interpolated is escaped: the search query in static/html/search.htm and head.htm, and every DA username, title and description written by DeviationList, ParseComments, BuildUserPlate and ParseDescription. The CSP blocks scripts but not markup, inline styles, meta refresh or injected forms; any title containing < corrupts the page.

Fix: switch to html/template; wrap the pre-built HTML fragments in template.HTML; escape strings in the Go builders with html.EscapeString and attribute-escape URLs. Add tests that a query and a title containing "><b> render as text.

Land before other template work.

2.2 Restore the user About branch (S, #14)

Done in !13.

app/wrapper.go:35 has else if false, inherited from upstream commit 048bb47. Registration date, interests, social links and bio never render for users. Find out why it was disabled (likely a devianter struct change), restore the branch, add a test with a fixture.

2.3 Group search pagination (S, #15)

Done in !13.

app/wrapper.go:274 increments the page and requests offset 10*page, so page two starts at result 20 and results 10 to 19 are never shown. The nav bar also shows the incremented number. Use 10*(page-1) and do not mutate s.Page before NavBase.

2.4 Emojitar writes a body after 404 (S, #16)

Done in !9.

app/wrapper.go:344 lacks a return after ReturnHTTPError(404).

2.5 Valid Atom feed (S, #17)

Done in !15.

DeviationList in app/parsers.go emits no feed-level <id> or <updated>, bare integer entry ids, RFC 1123 <published> instead of RFC 3339, and media:thumbinal. Verified on the live feed. Fix all five and add a test that parses the output with an Atom library or checks the required elements.

2.6 -c bounds check (S, #18)

Done in !13.

app/cli.go:29 checks len(a) >= 2 instead of n+1 < len(a); skunkyart -x -c panics.

2.7 Sanitize the 502 page (S, #19)

Done in !7.

Error in app/util.go writes the upstream error, including the full CloudFront block page, into an <h3> unescaped. Truncate to one line and escape. Folds into 2.1 if done together.

2.8 Parse templates once (S, #20)

Done in !13.

ExecuteTemplate calls ParseFS on every request. Parse at startup; supply the per-request T function through the data struct or a per-request Funcs clone. Template errors then fail at boot instead of as 500s.

Tier 3: config, docs, i18n

3.1 Config-less start and default alignment (S, #21)

Done in !16.

ExecuteConfig exits if config.json is missing even though defaults exist. Start with defaults when no -c is given and the default file is absent. Align the built-in nsfw: true with the example's false, or document why they differ.

3.2 Cache documentation (S, #22)

Done in !16.

SETUP.md: update-interval is in seconds (the example scans every 5s); the d unit works but is unlisted; y is 360 days; exceeding max-size deletes the whole cache directory; lifetime: null in the example.

3.3 API and search type docs (S, #23)

Done in !16.

API.md says t is text search; devianter defines it as tag. The "Folders" option in static/html/gruser.htm maps to f, which is favourites. Fix the doc and rename or remove the option.

3.4 i18n coverage (M, #24)

Done in !17.

Go-built HTML hardcodes English: comment headers, "In reply to", pagination, folder and content headings, "No results", "[ TEXT ]". gruser.htm section headings and the index blurb are untranslated. Every template declares lang="en". Languages() in app/i18n.go is unused. Move the strings into the catalogues, set lang from the resolved language, and either use or remove Languages().

3.5 systemd unit (S, #25)

Done in !18.

services/skunkyart.example.service uses Directory= (not a valid key), placeholder paths, and says it was never tested. Write a working unit with WorkingDirectory, User, DynamicUser or a dedicated user, NoNewPrivileges, and Restart=on-failure. Test it once on a Linux host.

3.6 SETUP.md structure (S, #26)

Done in !16.

The nginx section sits between config keys; theme and language come after it. Reorder: config keys, units, reverse proxy.

3.7 README (S, #27)

Done in !18.

Add: endpoints and what they do, running the binary without Docker with the service files, what REDIRECTS.md is for (redirector rules), and a screenshot.

Tier 4: UI

4.1 Viewport and mobile CSS (S, #28)

Done in !19.

static/html/head.htm and index.htm use initial-scale=0.4 and height=device-height; skunky.css then compensates with * { font-size: 120% } in portrait. Use width=device-width, initial-scale=1 and adjust the portrait rules to match. Check on a phone width before and after.

4.2 Accessibility (S, #29)

Done in !19.

Listing and avatar images in DeviationList, ParseComments and BuildUserPlate have no alt. The post page has no heading element for the title. Add both.

4.3 Index stylesheet (S, #30)

Done in !19.

static/html/index.htm carries an inline stylesheet duplicating layout rules. Move it into skunky.css.

Tier 5: identity and reach

5.1 One canonical forge (S, #31)

Done in !20.

Origin and issues are on gitbay; releases, the image, Dependabot, the instances.json fetch at app/util.go:64, the About page "Report an issue" link, the index source link, and the --add-instance exit message all point at GitHub. Decide which is canonical. If gitbay: fetch instances.json from gitbay, point the links there, keep the GitHub mirror for the image build only. If GitHub stays the public face: say so in the README and leave the links.

5.2 Instance checker (M, issue #4, #32)

Done in !21.

A scheduled job that fetches each instance's /api/instance and marks dead ones in INSTANCES.md, or a CI job that fails when one is down.

5.3 LibRedirect listing (S, #33)

Open. The two upstream pull requests are written up on #33.

REDIRECTS.md already describes the URL mapping. Check whether LibRedirect lists SkunkyArt with the dead upstream instances and submit the fork and art.krz.sh. This is the cheapest way to get users.

5.4 Makefile and binary releases (S, issue #5, #34)

Done in !22.

Targets for build with the embed tag and version stamp, test, lint. Publish binaries alongside the image on release tags.

5.5 Existing issues

  • #1 cleanup: the TODOs at app/parsers.go:222 and app/cache.go:3; the second is 1.1. sendMedia in app/api.go duplicates ParseMedia's magic string offsets ([21:], dot+11); share one function.
  • #2 search filters: blocked on what devianter exposes; scope after 1.1.
  • #3 description parsing: ParseDescription drops header-two, ordered lists and nested styles. Needs fixtures from real descriptions.
  • #6 emote bug: the a.Val[8:9] == "e" and [37:len-4] offsets in the HTML branch of ParseDescription. Parse the URL instead of slicing.

Merge record

Merged into main in this order on 2026-09-11, each stacked on the one before: !6 (0.1), !7 (2.1, 2.7), !8 (1.1), !9 (1.2, 2.4), !10 (1.3), !11 (1.4), !12 (1.5), !13 (2.2, 2.3, 2.6, 2.8), !15 (2.5), !16 (3.1, 3.2, 3.3, 3.6), !17 (3.4), !18 (3.5, 3.7), !19 (4.1, 4.2, 4.3), !20 (5.1), !21 (5.2), !22 (5.4). Then !23 (release string), !24 (Go 1.26 in the image and binaries builds) and !25 (no VCS stamping) for the release itself.

Two things the stack taught: lint on macOS never compiles the Linux-only files, so run GOOS=linux golangci-lint run before pushing; and go get can raise the go directive in go.mod, so check the Dockerfile and workflow images still match it.