Repository navigation
Lanpaper 0.12.0
Everything in this release is off by default or backwards compatible: an
existing data/ directory, an existing wallpapers.json and every existing URL
keep working unchanged, and a link that uses none of the new features is stored
and served exactly as before.
Added
- Version history and rollback. Replacing a file archives the one it
replaced indata/history/{link}/{version}.{ext}. Archived versions stay
reachable as/{name}?v=2, are listed byGET /api/link/{name}/history, can
be restored withPOST /api/link/{name}/rollbackand dropped one by one with
DELETE /api/link/{name}/history/{version}. A rollback archives the file it
displaces, so restoring version 2 of a link at version 4 makes the live file
version 5 and keeps version 4 — a rollback can always be rolled back. The
thumbnail is regenerated from the restored file.- Defaults:
HISTORY_LIMIT=3versions per link andHISTORY_MAX_MB=512as a
budget shared by all links.HISTORY_LIMIT=0restores the previous
replace-only behaviour and writes no archive at all. - When the budget is exceeded, the oldest archives across every link are
dropped first, in the background pass that already pruned media. The pass
recomputes the total from the metadata before deciding, so a missed counter
update heals itself. - Renaming a link moves its archive; deleting a link deletes it.
- Defaults:
- Playlists and rotation. One URL can now serve several files.
POST /api/uploadwithmode=appendstores a file as a playlist item in
data/items/{link}/{id}.{ext}and leaves the live file untouched, so every
existing embed keeps showing what it showed. Items are addressable as
/{name}?i=2(?i=0is always the live file) and removable with
PATCH /api/link/{name}and{"removeItem":2}.PLAYLIST_MAX(default 8,
maximum 64) caps the list and answers409when it is full.PATCH /api/link/{name}also accepts
{"rotate":{"enabled":true,"interval":300,"order":"random"}}. Fields are
merged, so togglingenabledkeeps the interval and the order;
interval: 0restores the default of 60 s, and any other value must be
5–86400 s.- Rotation is stateless: the position is derived from
floor(now / interval)(sequential) or a stable FNV hash of the link name
and that window (random). No goroutine, no persisted cursor, no extra
request-time I/O, the image cannot flicker inside one window, and two
replicas reading the samedata/directory always agree. - Playlist items get no thumbnail on purpose: a WebP per item would multiply
upload CPU and disk for metadata nobody looks at.
- Publishing without the admin login.
PUBLISH_KEYS(comma-separated,
16+ characters, at most 32 keys) authorizesPOST /api/uploadand
POST /api/linkonly, viaX-Api-KeyorAuthorization: Bearer. Renaming,
re-scoping, pinning, listing, rolling back and deleting still need Basic Auth,
so a leaked key can publish but cannot read or destroy the library. autoCreate=1on upload creates the link and pushes its first file in one
request — what a webhook or a cron job needs. Optionalcategoryand
accessLevelfields are validated before the link is created. Without the
flag an unknown name is still400, so a typo cannot silently create a link.- CORS for public media.
CORS_ORIGINS(comma-separated,*for any
origin) addsAccess-Control-Allow-Origin,Vary: Originand
Access-Control-Expose-Headers(ETag,Last-Modified,Content-Length,
Content-Range,Content-Type) and answersOPTIONSpreflights with204,
Access-Control-Allow-Methods: GET, HEAD, OPTIONS,Max-Age: 600and the
requested headers filtered to an allowlist. A browser canvas orfetch()on
another origin can finally read a public link. ALLOW_EMBED=truedropsX-Frame-Optionsand the CSPsandboxfor
public media only, so a kiosk page or a dashboard can frame/{name}.
/adminand/api/*stay unframable whatever this flag says.- URL aliases.
/{name}.jpg,/{name}.png,/{name}/latestand
combinations of the two resolve to/{name}, for clients that insist on a
file extension. Only extensions from the supported media list are aliases, so
/manifest.json,/favicon.ico,/robots.txtand/sw.jskeep answering
404. The stored extension still decidesContent-Typeand
Content-Disposition, never the alias. - Per-link access counters.
statsin the link object reports requests,
bytes and the last hit since the process started, in memory only: no visitor
identity, no storage, no cost after a restart.304andHEADcount as a hit
with zero bytes; refused requests are not counted. - Admin panel: a per-link Versions dialog lists archived versions
(open / restore / delete), playlist items (open / remove), rotation settings
and the access counters. The upload menu gained Add to playlist, and a card
showsv5 · 2 in playlist · rotating · 42 hitsonly when the link uses those
features.
Changed
PATCH /api/link/{name}withnewLinkNamemovesdata/history/{name}/and
data/items/{name}/along with the media, and rolls the renames back if the
metadata save fails.DELETE /api/link/{name}removes both directories and
the link's counters.data/items/is created at startup,data/history/only when history is
enabled. Both are also created on demand, so a failure to create them is a
warning and not a reason to refuse to start.- The service worker cache is bumped to
lanpaper-static-v5so clients pick up
the new panel assets.
Security
- Publish keys are compared as SHA-256 digests in constant time, are never
written toconfig.jsonand appear in logs only as an 8-hex-character
fingerprint. A wrong key shares the brute-force budget with admin logins
(10 failures per client per 15 minutes, then429withRetry-After), and
the lockout is not an oracle: the right key is refused while it lasts. On a
server withoutPUBLISH_KEYSa strayX-Api-Keyheader is ignored entirely
and cannot lock anybody out. CORS_ORIGINSentries are parsed as URLs: a scheme ofhttp/https, no path,
no credentials and no wildcard subdomains. An origin that is not listed gets
no CORS headers at all, andOPTIONSkeeps answering405until CORS is
configured, so the default deployment behaves exactly as before.- An unusable
?v=or?i=selector is a404instead of a silent fallback to
other bytes, and the access check of the link always runs before any selector
is resolved, so an alias or a version cannot bypasstoken,localorauth. - Archived versions and playlist items are stored under validated link names and
validated extensions only, in the samedata/tree as the live media: nothing
new is reachable from the static web root.
Performance
- Archiving is a rename of the backup that an upload already created, so a
replacement costs no extra copy and no extra disk read. - Rotation and version selection are pure arithmetic on data that is already in
memory; a request for/{name}does the same singleServeContentcall as
before, and media still usessendfile(the stats counter wraps the response
writer with aReadFromdelegate). - Playlist appends skip thumbnail generation, and the item cap is checked before
any media is downloaded or decoded. - The history budget runs in the existing prune worker instead of adding a
timer, and the byte counter is a single atomic.
Production readiness
- A panicking handler costs one request, not the process. A recovery layer
between the gzip middleware and the router logs the method, the path and the
stack, and answers a clean500when the response has not started yet.
Nothing from the panic reaches the client, and the query string is never
logged because token links carry their secret there.http.ErrAbortHandler
is re-raised untouched, so a deliberately abandoned response still behaves
the waynet/httpdocuments. The two background goroutines nobody restarts
(the prune worker and the rate-limit cleaner) recover as well. - Optional built-in TLS.
TLS_CERT_FILE+TLS_KEY_FILEserve HTTPS from
the process itself with a TLS 1.2 floor; Go's current cipher suite and curve
defaults apply, and HTTP/2 is negotiated automatically. Setting only one of
the two is a startup error: refusing to start beats silently serving admin
credentials and token URLs in plaintext. /robots.txtanswersDisallow: /instead of404, so crawlers stop
walking mutable media URLs (and stop retrying a path that will never exist).- The probes reject write methods with
405instead of answering aPOST
to/healthas if it were aGET. - Startup warnings for
INSECURE_SKIP_VERIFY=true,CORS_ORIGINS=*and an
ADMIN_PASSshorter than 12 characters, so an internet-facing mistake is
visible in the log on day one. - Package documentation for
main,config,storage,handlers,
middlewareandutils, spelling out the invariants each package relies on
(copy-on-write snapshots, lock ordering, transparent response writers, no
global reads on the request path). - Licence hygiene: every first-party source file carries an
SPDX-License-Identifier: MITheader, and
THIRD-PARTY-NOTICES.md lists all bundled
components (BSD-3-Clause, MIT, SIL OFL 1.1 — no copyleft) with the texts a
redistribution must reproduce. - Release automation: pushing a version tag runs the full test matrix,
publisheslinux/amd64+linux/arm64images under the semver tags and
creates the GitHub release, after checking that the tag matchesVERSION. - docs/DEPLOYMENT.md documents the production
topology, a hardened systemd unit, log lines worth alerting on, capacity
planning, backups and a pre-launch checklist.