Skip to content

Repository files navigation

Random Image

A Go server plus a static client. It shows one random image at a time from a configured folder, fitted to the screen, no chrome. That is the whole app.

Adapted from RandomImageViewer, a SwiftUI iOS app that did the same thing from a user-chosen folder.

The folder is configured on the server, not chosen in the browser. That one decision is what makes this work everywhere: a browser-side folder needs the File System Access API, which is Chromium-desktop only and absent from every browser on iOS. With a server-side path, this runs in Safari, on a phone, and as an installable PWA.

Running it

cp config.example.json config.json   # then edit "folder"
go build -o randomimageviewer .
./randomimageviewer -config config.json

Go 1.22+. One dependency, golang.org/x/image, for the resampling kernel. The client is embedded in the binary, so the built binary is the whole deployment — no runtime, no asset directory, no npm.

go test ./...    # server: listing filters, natural sort, EXIF, caps, config
node --test      # client: caps, fitting, placement, gestures, random draw

static/apple-touch-icon.png is generated from static/icon.svg, not drawn separately — iOS ignores SVG icons for "Add to Home Screen" and falls back to a screenshot of the page, so the PNG is the only thing that yields a real icon. Regenerate it after editing the SVG:

magick -background black -density 576 static/icon.svg -resize 180x180 \
  -alpha remove -alpha off -colorspace sRGB -strip PNG24:static/apple-touch-icon.png

-alpha remove matters: iOS composites a transparent icon onto black and the artwork is already black, so an icon with alpha comes out as a dark smear.

Running it as a service

Nothing in the dependency tree uses cgo, so the binary links statically and the deployment is one file — no libc version to match, no runtime, no asset directory. /usr/local/sbin rather than /opt, because /opt is for packages that bring their own directory tree and this one has nothing to put in it.

As a .deb

make deb                 # writes dist/randomimageviewer_<version>_amd64.deb
make deb ARCH=arm64
scp dist/randomimageviewer_*.deb server:
ssh server 'sudo apt install ./randomimageviewer_0.1.0_amd64.deb'

Packaging needs nothing installed beyond the Go toolchain: nfpm is pinned and run through go run, and it cross-builds a .deb from any host, macOS included. Version comes from the nearest vX.Y.Z tag, or override it with make deb VERSION=1.2.3 — Debian versions have to start with a digit, so there is no falling back to a commit hash.

The package creates the service user, installs the unit, and enables it without starting it, since the shipped config names a folder that will not exist. Two steps are left:

sudo nano /etc/randomimageviewer/config.json     # set "folder"
sudo systemctl start randomimageviewer

/etc/randomimageviewer/config.json is registered as a dpkg conffile, which is most of the reason to package at all: upgrades never silently overwrite a config naming this machine's photo folder. apt remove stops and disables the service; apt purge additionally removes the cache directory and the service user. The image folder is never touched.

By hand

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' \
  -o randomimageviewer-linux-amd64 .

sudo install -o root -g root -m 0755 randomimageviewer-linux-amd64 \
  /usr/local/sbin/randomimageviewer
sudo useradd --system --no-create-home --shell /usr/sbin/nologin randomimageviewer
sudo install -d -o root -g randomimageviewer -m 0750 /etc/randomimageviewer
sudo install -o root -g randomimageviewer -m 0640 deploy/config.default.json \
  /etc/randomimageviewer/config.json

sudo install -m 0644 deploy/randomimageviewer.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now randomimageviewer

cacheDir cannot be left empty under systemd. Empty means "ask os.UserCacheDir()", which reads $HOME — and a service running as a home-less system user has none, so the server exits at startup with locate cache directory: $HOME is not defined and Restart=on-failure loops on it. Set it to /var/cache/randomimageviewer, which is what the unit's CacheDirectory= creates and chowns.

The unit sandboxes tightly, since the process reads one folder and writes one cache directory and needs nothing else. It names no machine-specific path, so site-specific settings go in a drop-in rather than in the unit itself:

# /etc/systemd/system/randomimageviewer.service.d/override.conf
[Unit]
# Only if the images sit on a separate mount — stops the server starting
# against an empty mountpoint and reporting the folder as missing.
RequiresMountsFor=/srv/photos

[Service]
# Only if the images live under /home, which ProtectHome=yes otherwise hides.
ProtectHome=read-only

ProtectSystem=strict already makes the whole hierarchy read-only, so the image folder needs no ReadOnlyPaths entry — but check the service user can actually read it, which no amount of sandboxing grants:

sudo -u randomimageviewer ls /srv/photos
journalctl -u randomimageviewer -f

TLS

Not optional in practice. Reached by anything other than localhost over plain HTTP the page is not a secure context, so Wake Lock and Fullscreen are both refused — the screen sleeps partway through a slideshow and the first image cannot go fullscreen. There are two shapes, and the right one depends on whether this is the only application on the host.

One application: terminate TLS here

Point the config at a cert and key. config.go rejects one without the other, so a half-configured pair fails at startup rather than silently serving plain HTTP:

"listen": ":6969",
"tls": { "cert": "/etc/randomimageviewer/tls.crt",
         "key":  "/etc/randomimageviewer/tls.key" }

Key at 0640 root:randomimageviewer. HTTP/2 comes free — Go negotiates it on any TLS listener, which suits a viewer that fires two requests per image.

The certificate is read exactly once, at startup. ListenAndServeTLS opens the files and nothing reloads them, so a renewal at day 60 keeps serving the old certificate until the service restarts — and you find out at day 90 when the browser starts refusing. Renewal has to restart it:

# /etc/letsencrypt/renewal-hooks/deploy/randomimageviewer.sh
#!/bin/sh
install -o root -g randomimageviewer -m 0644 \
  "$RENEWED_LINEAGE/fullchain.pem" /etc/randomimageviewer/tls.crt
install -o root -g randomimageviewer -m 0640 \
  "$RENEWED_LINEAGE/privkey.pem" /etc/randomimageviewer/tls.key
systemctl restart randomimageviewer

Copying rather than symlinking into /etc/letsencrypt/live/ is deliberate: /etc/letsencrypt/archive is 0700 root, so the service user cannot follow the symlink chain. Restarting is cheap — the derivative cache is on disk and survives it.

Several applications: terminate in front, bind to loopback

Once more than one service on the host needs TLS, handing each of them the same private key stops being sensible. Terminate once and let this bind loopback:

"listen": "127.0.0.1:6969",
"tls": { "cert": "", "key": "" }

The application never sees the key, the reload-on-renewal problem above stops being this program's problem, and services get names instead of port numbers. Secure context is judged on the browser-facing origin, so Wake Lock and Fullscreen still work — the plain-HTTP hop never leaves the machine.

Check the bind actually moved. A viewer still on *:6969 stays reachable over plain HTTP alongside the TLS one, and a phone with a bookmark will go on using it:

ss -ltnp | grep 6969        # want 127.0.0.1:6969, not *:6969

Certificates for a LAN-only host

A certificate binds to a hostname; there is no port in one, so a single certificate covers every service on the host whatever port each listens on.

Two constraints worth knowing before issuing. A publicly-trusted certificate cannot be had for a made-up TLD — .lan, .local, .internal are not delegated, so no CA can validate them and none will issue. Use a name under a domain you own, resolved internally by your own resolver. And the DNS-01 challenge validates a TXT record at _acme-challenge.<name>, which means the host itself needs no public A record and never has to be internet-facing. Nothing has to reach port 80 either.

A wildcard is worth preferring. Every issued certificate is published to Certificate Transparency, so naming hosts individually publishes an inventory of them; *.apps.example.com publishes only the wildcard, and covers every service added later without reissuing. Note that a wildcard matches exactly one label — photos.apps.example.com yes, a.b.apps.example.com no.

Only publicly-trusted certificates avoid the client-side trust dance. A private CA works, but iOS needs the root installed and separately enabled under General → About → Certificate Trust Settings, which is a different screen and the step usually missed — on every device, forever.

A worked example: Caddy, DNS-01 and split-horizon DNS

What the above looks like in practice, with the parts that are easy to get wrong called out. Substitute your own domain; apps.example.com is the internal subdomain and 10.0.0.23 the host.

The name resolves privately and does not exist publicly. In unbound:

    private-domain: "apps.example.com"
    local-zone: "apps.example.com." transparent
    local-data: "photos.apps.example.com. IN A 10.0.0.23"

transparent, not static. A static zone answers NXDOMAIN for every name it holds no data for, and that includes _acme-challenge.… — which breaks issuance in a way nothing in the logs attributes to your own resolver. private-domain is needed because private-address: 10.0.0.0/8 otherwise strips RFC1918 answers for public names as rebinding protection, and that protection is worth keeping for everything else. No local-data-ptr: the address already reverses to the machine's own name, and a second PTR only makes reverse lookups ambiguous.

DNS providers are Caddy plugins, so the stock binary cannot solve DNS-01:

caddy add-package github.com/caddy-dns/godaddy
caddy list-modules | grep godaddy          # dns.providers.godaddy

add-package swaps the binary in place and leaves the apt packaging intact. An xcaddy build works too, but then apt-mark hold caddy, or the next upgrade restores a binary with no provider module and renewals stop silently.

The API credential comes from the unit, not the Caddyfile, so it is not sitting in a world-readable config:

# /etc/systemd/system/caddy.service.d/override.conf
[Service]
EnvironmentFile=/etc/caddy/caddy.env
# /etc/caddy/caddy.env — 0640 root:caddy. GoDaddy wants key and secret as one
# colon-joined value.
GODADDY_API_TOKEN=key:secret
photos.apps.example.com {
    tls {
        dns godaddy {env.GODADDY_API_TOKEN}
        resolvers 1.1.1.1 8.8.8.8
        propagation_delay 5m
        propagation_timeout 20m
    }
    reverse_proxy 127.0.0.1:6969
}

Each of the following cost time to work out:

Caddy substitutes an empty string for an unset {env.*} without complaining. Forget the drop-in and the provider sends an empty Authorization header, which GoDaddy answers with a bare 401 and no body — indistinguishable at a glance from a bad key. The packaged unit's ExecStart carries --environ, which dumps the whole environment at startup, so journalctl -u caddy | grep GODADDY is the first thing to check when a placeholder misbehaves.

resolvers is load-bearing on this host. The propagation check otherwise uses the system resolver, which is the unbound above serving its own split-horizon view of the domain — so Caddy would be asking the one resolver on the network guaranteed not to know what the CA sees.

propagation_delay has to be in minutes. GoDaddy enforces a 600-second minimum TTL and syncs its anycast fleet on roughly that cadence; 15 seconds fails every time. A DNS problem: NXDOMAIN prefixed During secondary validation: is the useful diagnostic — it means Let's Encrypt's primary vantage point found the record and a second one had not seen it yet, so the record is being created correctly and only the wait is too short.

Failed attempts are self-sustaining. The last field of the zone's SOA is the negative-cache TTL — 3600 at GoDaddy, so an hour. Caddy deletes the TXT record after each failure, so every attempt that runs while a resolver has cached NXDOMAIN re-poisons it for another hour. Point acme_ca at https://acme-staging-v02.api.letsencrypt.org/directory while sorting this out: production allows five failed validations per hostname per hour, and a tight retry loop burns through that before you have read the first error.

Reload for a Caddyfile change; restart for anything in the unit. systemctl reload caddy runs caddy reload and swaps the config gracefully, but EnvironmentFile is read once at process start — so a credential change needs systemctl daemon-reload && systemctl restart caddy. Reloading after adding the drop-in reproduces the original 401 exactly.

Renewal is the one path that never gets tested. It fires unattended in 60 days and depends on that propagation window still holding. Prove it from cold rather than finding out from a browser warning:

systemctl stop caddy
rm -rf /var/lib/caddy/.local/share/caddy/certificates/acme-v02*
systemctl start caddy

If GoDaddy proves too slow to leave in the renewal path, CNAME the challenge name at a zone with faster propagation — acme-dns or a free Cloudflare zone — and set dns_challenge_override_domain. One manual record per name, set once, and the registrar's API stops mattering.

What it does

GET /api/images returns the manifest; the client picks a random image from it and requests GET /api/image/<id>?w=<cap>. The server decodes, applies EXIF orientation, resizes to the cap, re-encodes, and caches the result on disk.

Resizing server-side is the direct analogue of what CGImageSourceCreateThumbnail did on iOS, just relocated — and it is strictly better here, because it also means a 12 MP original never crosses the network. A cold render of a 4000×3000 JPEG takes ~480 ms; the cached hit is ~0.5 ms.

Supported formats are JPEG and PNG. Adding more is a matter of an entry in imageExtensions plus a decoder Go can register — but note the iOS app's HEIC and TIFF support does not carry over, since neither ships in the standard library.

The interface

An entry screen naming the folder and its image count, then the viewer:

  • swipe left or right, or press or — another image at random. Direction carries no meaning, since the next image is random either way, so both do the same thing and only the exit animation differs.
  • swipe down, or press — back to the entry screen.

The keys are there because a mouse has no swipe. Dragging with one works, but the thresholds are written for a finger — a fifth of the viewport, or a flick at a finger's speed — and neither is quite what a mouse produces. A key press and a swipe resolve to the same two outcomes and run through the same code (keyOutcome in core.js, Viewer.commit in app.js), so the two cannot drift apart.

draggable="false" on the image is load-bearing. An <img> is natively draggable, so without it a mouse press-and-move starts an HTML5 drag-and-drop, which fires pointercancel and springs the image back a few pixels into the gesture — mouse drags simply do not work. Touch never starts a native drag, which is why this was invisible on a phone.

↑ is unbound, but not for the reason swipe-up is: a keyboard has no system gesture to collide with. There is simply nothing for it to mean, since ← and → already say "another image".

Swiping up is deliberately unused, and should stay that way. On a phone an upward drag near the bottom edge belongs to the system, so anything bound to it intermittently dismisses the app instead. Only downward travel is read, which is why swipeOutcome tests travel.y > … rather than a magnitude.

A file that lists but will not render — a truncated download, a mislabelled file — is marked broken for the session and skipped, and another image is drawn immediately, so one bad file cannot strand the viewer. Only a 4xx does this: a 5xx or a dead network would fail identically for every other file, so that case stops rather than grinding through the whole folder. "Rescan Folder" forgives the broken set.

Things worth knowing

The entry screen is what makes the first image fullscreen. The Fullscreen API only works inside a user gesture, so without a button to click there is no way to enter fullscreen before an image is already on screen. That is a real constraint, not a stylistic preference — going straight to an image means the first one cannot be fullscreen. Installing the page as a PWA sidesteps it entirely; the manifest declares "display": "fullscreen".

Wake Lock and Fullscreen need a secure context. http://localhost counts; http://192.168.1.x does not. Both fail soft, so the viewer works either way — see TLS for the two ways to give it one.

Screen brightness is gone. The iOS app forced full brightness in the viewer; no web API exposes brightness at any privilege level. ScreenBoost in app.js is the surviving half.

The client never names a file. IDs are hashes the server minted and still holds in its listing, and they are never joined onto a path. There is no string a client can send that reaches the filesystem, which is what makes traversal impossible rather than merely filtered.

?w= is clamped server-side to the same 128 px bucket grid the client uses. Without that, ?w=999999 is a resize to nine hundred thousand pixels on the long edge. metrics.go and core.js hold the same rules and share an expectation table across their two test suites.

Layout

File Adapted from
library.go FolderStore — minus the bookmark, scope and adopt/release cycle
images.go ImageLoader — the disk cache replaces the two NSCache tiers
metrics.go DisplayMetrics — also doubles as input validation
exif.go kCGImageSourceCreateThumbnailWithTransform, which Go has no equivalent of
naturalsort.go localizedStandardCompare
static/core.js The pure arithmetic of DisplayMetrics and ViewerView
static/app.js ContentView, EntryView, ViewerView

Behaviour carried over from the iOS app and worth not undoing: no zoom, no pan, no folder browser, no thumbnails or grid, no captions or metadata, and one folder only. The home indicator is deliberately not treated as an obstruction — it is a translucent overlay, not a cutout, so an image may sit under it, and counting it in the inset would shrink every landscape image for no visual gain.

Not done

  • No service worker, so this does not run offline.

Working on it

main is protected: direct pushes are rejected and changes land through a pull request that CI has passed. Set the local half up once per clone, which makes git refuse the push before it reaches GitHub:

git config core.hooksPath .githooks

CI (.github/workflows/ci.yml) runs what make test runs, plus gofmt -l, go vet and a packaging build. Everything it does works locally:

make test     # go test ./... and node --test
gofmt -l .    # prints nothing when clean
go vet ./...
make deb      # proves nfpm.yaml still packages

Cutting a release

The version in the package comes from git describe, so the tag is the first step rather than the last — build before tagging and the .deb is named after the previous release.

git checkout main && git pull                 # from a clean tree: --dirty
                                              # would land in the version
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z

make deb                                      # amd64
make deb ARCH=arm64                           # and arm64
( cd dist && shasum -a 256 *.deb > SHA256SUMS )

gh release create vX.Y.Z --prerelease \
  --title "vX.Y.Z" --notes "" \
  dist/randomimageviewer_X.Y.Z_amd64.deb \
  dist/randomimageviewer_X.Y.Z_arm64.deb \
  dist/SHA256SUMS

Both architectures, even though the deployment this was written for is amd64: make deb alone quietly produces an amd64-only release, and a Pi is an obvious place to run this.

Drop --prerelease once a version has run somewhere other than the machine it was written on.

License

MIT — see LICENSE.

About

One random image at a time from a server-configured folder. Go server, static client. Adapted from the RandomImageViewer iOS app.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages