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.
219 lines
7.4 KiB
Nix
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;";
|
|
};
|
|
};
|
|
};
|
|
})
|
|
|
|
]);
|
|
}
|