Native single-photo uploader for POST /upload. Lives in android/ with its own flake, so the root flake stays Go-only and NixOS consumers of nixosModules.default do not pull the Android SDK into their lock. The server is untouched, so the app closes every gap client side: - The slug is the enqueue-time timestamp, persisted in the work input. server.go derives the stored slug from the uploaded filename, so reusing that filename across WorkManager retries overwrites the same post rather than creating a duplicate. That stands in for an idempotency key. - EXIF is stripped before upload. The gallery publishes the uploaded file verbatim as its full-size download, and phone photos carry GPS. JPEG and PNG drop metadata at marker and chunk level with the compressed pixels untouched; HEIC is decoded to JPEG because the extension check rejects it. - Photos flagged for rotation are rotated into the pixels instead of relying on the orientation tag, which cannot survive the strip and which the thumbnailer ignores regardless. - Anything over 18 MiB steps quality, then resolution, to stay inside nginx's client_max_body_size 20M. - Credentials are sealed with an AES-GCM key in the Android Keystore. The key deliberately does not require user authentication, or background retries could not read it. Basic auth is attached by an interceptor rather than an Authenticator, which reacts to a 401 by replaying a multipart body that is not reliably replayable. - The connection test uses GET /upload. /health is unreachable from outside because the nginx vhost only proxies location /upload. WorkManager's SystemForegroundService needs foregroundServiceType declared on the service entry, not just as a permission, or setForeground throws on Android 14+. Only lintVitalRelease catches that, never a debug build. Also anchors the .gitignore patterns. Unanchored, "photogallery" matched the Kotlin package directory ws/inflo/photogallery/ and silently swallowed every source file on git add.
photogallery
A minimal static photo gallery with an Atom feed and an upload API.
How it works
- Drop
photo.jpg+photo.tomlinto the content directory (or upload via the web UI at/upload). - The binary watches the directory and rebuilds the static site on any change.
- nginx serves
public/and proxies/uploadto the local API, secured with htpasswd.
For each photo the generator produces a 600×600 square thumbnail for the grid and an aspect-preserved medium JPEG (longest side 1600) for the detail page. The original file stays reachable from a download link on the detail page. Regeneration is skipped when the destination is at least as new as the source, so editing one sidecar doesn't reprocess every photo.
The upload page reads DateTimeOriginal from a JPEG's EXIF (or falls back to
the file's modification time) and prefills the date field, which stays
editable before submitting.
Sidecar format
caption = "Tempelhof at sunrise"
date = "2026-06-14"
If no .toml is present the filename is used as the caption and today's date as the date.
Using as a NixOS flake module
1. Add to your flake inputs
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
photogallery.url = "github:youruser/photogallery";
};
2. Import the module
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
inputs.photogallery.nixosModules.default
./configuration.nix
];
};
3. Configure
services.photogallery = {
enable = true;
baseURL = "https://photos.yourdomain.tld";
# Optional: identity shown on the header and copyright footer
author = "Ada Lovelace";
handle = "@ada@example.com";
bio = "Photos from the field.";
bioAlt = ""; # optional secondary line in the alt-script font
# Optional: swap fonts
fontsURL = "https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400;600;700&family=Manjari:wght@400;700&display=swap";
serifFamily = "EB Garamond";
mlFamily = "Manjari";
# Optional: built-in nginx vhost
nginx = {
enable = true;
domain = "photos.yourdomain.tld";
htpasswdFile = "/etc/nginx/.htpasswd-gallery";
};
};
Create the htpasswd file:
nix-shell -p apacheHttpd --run \
"htpasswd -c /etc/nginx/.htpasswd-gallery youruser"
Available options
| Option | Default | Description |
|---|---|---|
enable |
false |
Enable the service |
baseURL |
— | Public URL, no trailing slash |
contentDir |
/var/lib/photogallery/content |
Images and .toml sidecars |
outputDir |
/var/lib/photogallery/public |
Generated site output |
port |
8080 |
Upload API port (localhost only) |
author |
Ashik Salahudeen |
Name shown on the header, photo pages and copyright footer |
handle |
@puttaalu@inflo.ws |
Handle line under the author name |
bio |
Personal photo journal. |
Primary bio line |
bioAlt |
Malayalam default | Secondary bio line in the alt-script font ("" to omit) |
fontsURL |
Crimson Pro + Manjari | Stylesheet URL loading both web fonts |
serifFamily |
Crimson Pro |
CSS font-family for the primary serif |
mlFamily |
Manjari |
CSS font-family for the alt-script bio |
nginx.enable |
false |
Configure an nginx vhost |
nginx.domain |
— | Domain for the vhost |
nginx.htpasswdFile |
— | htpasswd file for /upload |
nginx.enableACME |
true |
Enable Let's Encrypt |
Bootstrap (first build)
buildGoModule needs a go.sum and the correct vendorHash.
# 1. Generate go.sum
go mod tidy
# 2. Commit go.sum
git add go.sum && git commit -m "add go.sum"
# 3. Get the vendorHash — this will fail and print the correct hash
nix build
# error: hash mismatch ... got: sha256-XXXXX...
# 4. Paste the hash into flake.nix, then build again
nix build
Uploading via curl
curl -u user:password \
-F "image=@photo.jpg" \
-F "caption=Tempelhof at sunrise" \
-F "date=2026-06-14" \
https://photos.yourdomain.tld/upload
Or open https://photos.yourdomain.tld/upload in a browser.