ROADMAP.md
466 lines · 17288 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 shipped in v1.5.0 (2026-09-11), with v1.5.1 fixing the release build. Six patch releases followed on 2026-09-11 and 2026-09-12 from running the public instance; see "After v1.5.0" at the end. Of the original six issues, #1, #3, #4, #5 and #6 are closed by merged work and #2 is closed as not possible for a guest session. The one open issue is #33, the LibRedirect submission, which needs the maintainer's account.
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:222andapp/cache.go:3; the second is 1.1.sendMediainapp/api.goduplicatesParseMedia'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:
ParseDescriptiondropsheader-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 ofParseDescription. 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.
After v1.5.0
Everything here came out of deploying v1.5 to art.krz.sh and watching it.
v1.5.1 (!24, !25)
go.mod had moved to Go 1.26 when x/sync came in while the Dockerfile and
the binaries job still used 1.25, so the v1.5.0 tag built no image. Both
now match go.mod. The tarball job also failed on VCS stamping inside the
build container, fixed with -buildvcs=false.
v1.5.2 (!27, !28)
The instance's VPN exit was banned by DeviantArt's WAF and every DeviantArt-backed page 502d until someone restarted the stack.
- API cache entries past their TTL are kept for
api-cache.stale(default 1h) and served when upstream fails or answers 403 or 429. A block starts a one minute backoff during which the instance stops asking. /api/randomanswered 401 with proxying on: the media signing token was inside the path. Fixed, andDownloadsits behind a seam.- The session bootstrap runs before the listener opens, so the first seconds after a restart no longer 502.
Operationally: the instance moved off the VPN to its own address, which
was clean at the time, and the container got a real log driver (it had
none, which hid every error line).
v1.5.3 (!29)
The direct address was banned within an hour. upstream.min-interval-ms
and upstream.max-concurrent replace the source constants; the
instance runs at 1000 ms and one in flight. The ban lifted after about
seventy minutes. The instance also raised api-cache.ttl to 30
minutes and stale to a day, and a Cloudflare managed-challenge rule
now covers non-browser clients on search, post and profile paths.
v1.5.4 (!30, !31)
Cache rotation trims the oldest files down to max-size instead of
emptying the directory, and never touches the directory itself, which
on a bind mount logged unlinkat and mkdir errors every pass (#36).
The per-platform stat files went with it. x/net bumped (#35).
v1.5.5 (!32)
A crawler walking post pages at 70 a minute produced 733 upstream
timeouts in ten minutes while the address stayed unbanned: each queued
request still took its interval turn after its client had timed out.
The throttle now honours the request context, and sheds a request that
would queue longer than 20 seconds with a 503 and Retry-After. The
instance's rate-limit dropped to 20 per minute, burst 10.
v1.5.6 (!33, !34)
The real cause of #3: DeviantArt's editor stores descriptions and
comments as a document tree ({"version":1,"document":...}), not
Draft.js blocks, so every current description rendered empty. A new
renderer covers the node set seen in 92 live payloads: paragraphs,
headings, lists, quotes, code, breaks, rules, text marks, emotes (#6),
embedded artworks, GIF embeds and mentions. Media and post URLs are
parsed instead of sliced by offset (#1), and old HTML emotes map by
image name.
Closed without code
#2 search filters: DeviantArt's guest search ignores every order
value the site itself uses, and the deviations endpoint redirects
guests. Nothing to expose. #4 and #5 were covered by the instance
checker and the Makefile.
Lessons
- DeviantArt bans an address on volume, not on whether it is a VPN. Pacing, caching and shedding at the instance are what keep it clean; rotating exits only buys an hour.
- A restart empties the in-memory API cache, so a ban right after a deploy has nothing stale to serve. Deploy when the instance is quiet.
- Log driver
noneis a trap. Every incident here was diagnosed from lines that driver would have dropped. - Fetch real payloads from a host DeviantArt accepts before touching a parser; the format had changed under the old one.