# 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 `">` 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 `` or ``, bare integer entry ids, RFC 1123 `` 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 `

` 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.