Files
photog/module.nix
foxandClaude Fable 5.1 3f719359de feat(manage): add /manage pages to edit and delete photos, behind -manage
/manage lists every photo; /manage/edit/{slug} rewrites the photo's
.toml sidecar (caption, date), /manage/delete/{slug} asks for
confirmation and then removes the image, its sidecar and everything
generated from them. Builds now drop day pages of days without photos
and pager pages past the last one, so a delete or a date change leaves
nothing behind.

The pages are off by default: the routes are only registered with
-manage, so an install that does not opt in has no such code path and
answers 404. NixOS gets services.photogallery.manage.enable, which
also adds the nginx location only when on. Like /upload, the pages
carry no auth of their own and are not linked from the public site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 08:17:17 +02:00

208 lines
6.9 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.";
};
defaultView = lib.mkOption {
type = lib.types.enum [ "timeline" "grid" ];
default = "timeline";
description = "View published at the site root. The other one is published under /timeline/ or /grid/.";
};
manage.enable = lib.mkEnableOption "the /manage pages for editing and deleting published photos (served behind the same htpasswd as /upload)";
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.";
};
htpasswdFile = lib.mkOption {
type = lib.types.path;
example = "/etc/nginx/.htpasswd-gallery";
description = "Path to htpasswd file protecting the /upload and /manage endpoints.";
};
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)
"--default-view" cfg.defaultView
] ++ lib.optional cfg.manage.enable "--manage");
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";
};
locations."/upload" = {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery";
basicAuthFile = cfg.nginx.htpasswdFile;
extraConfig = "client_max_body_size 20M;";
};
locations."/manage" = lib.mkIf cfg.manage.enable {
proxyPass = "http://127.0.0.1:${toString cfg.port}";
basicAuth = "gallery";
basicAuthFile = cfg.nginx.htpasswdFile;
};
};
};
})
]);
}