Files
photog/module.nix
T
puttaalu a7f2afc3ad Add a /manage page to edit, delete and restore posts
A new auth-gated web UI at /manage lists every post for inline editing of
caption, date and tags, with per-row and bulk delete and bulk tag add/remove.
It is backed by a small JSON API (all behind the same htpasswd Basic auth as
/upload, and unlinked from the public site):

  GET  /api/photos        list posts (newest first)
  POST /api/edit          rewrite one sidecar (caption, date, tags)
  POST /api/tags          bulk add/remove tags across posts
  POST /api/delete        move posts to content/.trash/ (reversible)
  GET  /api/trash         list trashed items
  POST /api/restore       move a trashed item back (409 on live-slug clash)
  POST /api/trash/empty   hard delete trashed items (id… or all=1)

Design notes:
- Editing only rewrites the .toml; the slug (and URL) never changes.
- Delete is reversible: image + sidecar move to content/.trash/ with a
  timestamp suffix, excluded from the site (loadPhotos skips dirs). The only
  hard delete is emptying the trash.
- Build now prunes: it drops the derived HTML trees (photo/ day/ album/ page/
  grid/page/) and regenerates them, and pruneRenditions removes orphaned
  images/thumbs/medium/og. Without this a deleted post would linger at its old
  URL and on stale day/album pages; this also cleans up after a hand-removed
  sidecar.
- Sidecars are written atomically (temp + rename) so a concurrent build never
  observes a half-written file.
- Slugs and trash ids are validated as bare filenames, closing path traversal
  and dotfile access.
- Every state-changing POST passes a sameOrigin (CSRF) check, since Basic-auth
  credentials are replayed by the browser on every request to the host. It
  honours Sec-Fetch-Site, falls back to Origin vs base-url/Host, and allows
  header-less non-browser clients. Ported from the independent /manage on
  plex/trunk-ng.

module.nix proxies /manage and /api/ to the app (nginx otherwise serves them
as missing static files). The apps are unchanged; the API is there for them to
adopt edit/delete later.
2026-10-18 21:52:11 +02:00

219 lines
7.4 KiB
Nix

self:
{ config, lib, pkgs, ... }:
let
cfg = config.services.photogallery;
pkg = self.packages.${pkgs.system}.default;
in {
options.services.photogallery = {
enable = lib.mkEnableOption "photogallery static photo gallery";
contentDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/photogallery/content";
description = "Directory containing images and .toml sidecars.";
};
outputDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/photogallery/public";
description = "Directory for the generated static site.";
};
baseURL = lib.mkOption {
type = lib.types.str;
example = "https://photos.example.com";
description = "Public base URL, no trailing slash.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Upload API port. Always bound to 127.0.0.1.";
};
author = lib.mkOption {
type = lib.types.str;
default = "Ashik Salahudeen";
description = "Author name shown on the profile header, photo pages and in the copyright footer.";
};
handle = lib.mkOption {
type = lib.types.str;
default = "@puttaalu@inflo.ws";
description = "Handle line shown under the author name on the profile header.";
};
bio = lib.mkOption {
type = lib.types.str;
default = "Personal photo journal.";
description = "Primary bio line shown on the profile header.";
};
bioAlt = lib.mkOption {
type = lib.types.str;
default = "എന്തിനോ വേണ്ടി തിളയ്ക്കുന്ന സാമ്പാർ";
description = "Secondary bio line rendered on a new line in the alt-script font. Set to \"\" to omit.";
};
fontsURL = lib.mkOption {
type = lib.types.str;
default = "https://fonts.googleapis.com/css2?family=Crimson+Pro:ital,wght@0,400;0,600;0,700;1,400&family=Manjari:wght@100;400;700&display=swap";
description = "Stylesheet URL that loads the primary serif font and the alt-script font.";
};
serifFamily = lib.mkOption {
type = lib.types.str;
default = "Crimson Pro";
description = "CSS font-family for the primary serif text. Must match a family loaded by fontsURL.";
};
mlFamily = lib.mkOption {
type = lib.types.str;
default = "Manjari";
description = "CSS font-family for the alt-script (Malayalam) bio line. Must match a family loaded by fontsURL.";
};
pageSize = lib.mkOption {
type = lib.types.ints.positive;
default = 60;
description = "Photos per page on the timeline and grid views. The timeline packs whole days, so pages vary slightly around this.";
};
htpasswdFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/secrets/photogallery-htpasswd";
description = ''
Path to an Apache htpasswd file (bcrypt only — generate with
`htpasswd -B`) guarding the /upload endpoint. The app performs the
auth itself, so nginx no longer needs basic_auth. The file is handed
to the service via systemd LoadCredential, so it may be root-owned and
0400 (e.g. an agenix/sops secret). Leave null to disable auth entirely
— then /upload is open and anyone who can reach it can post.
'';
};
nginx = {
enable = lib.mkEnableOption "nginx virtual host for photogallery";
domain = lib.mkOption {
type = lib.types.str;
example = "photos.example.com";
description = "Domain for the nginx virtual host.";
};
enableACME = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable ACME/Let's Encrypt for TLS.";
};
};
};
config = lib.mkIf cfg.enable (lib.mkMerge [
# ── Core service ──────────────────────────────────────────────────────
{
users.users.photogallery = {
isSystemUser = true;
group = "photogallery";
home = "/var/lib/photogallery";
createHome = true;
description = "photogallery service user";
};
users.groups.photogallery = {};
systemd.tmpfiles.rules = [
"d ${cfg.contentDir} 0755 photogallery photogallery -"
"d ${cfg.outputDir} 0755 photogallery photogallery -"
];
systemd.services.photogallery = {
description = "photogallery static site generator";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = "photogallery";
Group = "photogallery";
ExecStart = lib.escapeShellArgs ([
"${pkg}/bin/photogallery"
"--content" cfg.contentDir
"--output" cfg.outputDir
"--base-url" cfg.baseURL
"--port" (toString cfg.port)
"--author" cfg.author
"--handle" cfg.handle
"--bio" cfg.bio
"--bio-alt" cfg.bioAlt
"--fonts-url" cfg.fontsURL
"--serif-family" cfg.serifFamily
"--ml-family" cfg.mlFamily
"--page-size" (toString cfg.pageSize)
] ++ lib.optionals (cfg.htpasswdFile != null) [
# %d is systemd's credentials directory; LoadCredential below drops
# the htpasswd file there, readable only by this service.
"--htpasswd" "%d/htpasswd"
]);
LoadCredential = lib.optional (cfg.htpasswdFile != null)
"htpasswd:${cfg.htpasswdFile}";
Restart = "on-failure";
RestartSec = "5s";
# Hardening
NoNewPrivileges = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.contentDir cfg.outputDir ];
};
};
}
# ── Optional nginx vhost ──────────────────────────────────────────────
(lib.mkIf cfg.nginx.enable {
services.nginx = {
enable = true;
virtualHosts.${cfg.nginx.domain} = {
enableACME = cfg.nginx.enableACME;
forceSSL = cfg.nginx.enableACME;
root = cfg.outputDir;
locations."/" = {
tryFiles = "$uri $uri/ =404";
};
# Auth is handled by the app (see services.photogallery.htpasswdFile),
# so nginx just proxies the dynamic endpoints: the upload form/API,
# the manage (edit/delete) page and its JSON API. Everything else is
# the static site served from root above.
locations."/upload" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
extraConfig = "client_max_body_size 20M;";
};
locations."/manage" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
};
locations."/api/" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
extraConfig = "client_max_body_size 20M;";
};
};
};
})
]);
}