From fd2828f1e739879d0beaca2c40fb926a3c93b6c7 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Thu, 2 Jul 2026 00:53:17 +0200 Subject: [PATCH] Initial commit --- .gitignore | 8 ++ CLAUDE.md | 101 ++++++++++++++ README.md | 112 +++++++++++++++ assets.go | 6 + flake.lock | 61 ++++++++ flake.nix | 44 ++++++ generator.go | 318 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 11 ++ go.sum | 8 ++ main.go | 38 +++++ module.nix | 135 ++++++++++++++++++ server.go | 152 ++++++++++++++++++++ static/style.css | 270 +++++++++++++++++++++++++++++++++++ templates/index.html | 46 ++++++ templates/photo.html | 29 ++++ templates/upload.html | 213 ++++++++++++++++++++++++++++ watcher.go | 75 ++++++++++ 17 files changed, 1627 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 assets.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 generator.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 module.nix create mode 100644 server.go create mode 100644 static/style.css create mode 100644 templates/index.html create mode 100644 templates/photo.html create mode 100644 templates/upload.html create mode 100644 watcher.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a16ea06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +content/ +public/ +photogallery +result +result-* +*.tar.gz +.envrc +.direnv/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ad011be --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,101 @@ +# photogallery + +Personal static photo gallery with Atom feed and upload API. Single-user. +No database, no framework — just a Go binary that watches a directory and regenerates a static site. + +## Architecture + +``` +content/ # images + .toml sidecars (source of truth) +public/ # generated output (served by nginx / --serve flag) +templates/ # embedded HTML templates +static/ # embedded CSS +``` + +The binary does three things at once: +- Watches `content/` via fsnotify and rebuilds on any change +- Serves a multipart upload API at `POST /upload` +- In dev mode (`--serve`), also serves `public/` as static files + +In production nginx sits in front: serves `public/` directly, proxies `/upload` to localhost, htpasswd on the upload endpoint. + +## Stack + +- **Go** — stdlib + `BurntSushi/toml`, `fsnotify/fsnotify`, `golang.org/x/image` +- **Templates** — `html/template`, embedded via `embed.FS` +- **Images** — centre-crop to square, Catmull-Rom resize to 600×600 JPEG thumbnails +- **Feed** — Atom (`encoding/xml`) +- **NixOS** — `flake.nix` + `module.nix`, exports `nixosModules.default` + +## Sidecar format + +```toml +caption = "Tempelhof at sunrise" +date = "2026-06-14" +``` + +Missing sidecar → filename as caption, today as date. + +## Design + +- Gruvbox dark hard colour scheme +- Vollkorn (serif) for headings and captions +- Manjari for Malayalam text (bio line) +- 3-column square grid → 2-column on mobile +- Single photo page with sidebar + +## Running locally + +```bash +nix develop +go mod tidy +go run . --serve +# gallery: http://localhost:8080 +# upload: http://localhost:8080/upload +``` + +## NixOS module usage + +```nix +inputs.photogallery.url = "github:youruser/photogallery"; +# in modules: +inputs.photogallery.nixosModules.default +# in configuration.nix: +services.photogallery = { + enable = true; + baseURL = "https://photos.yourdomain.tld"; + nginx = { + enable = true; + domain = "photos.yourdomain.tld"; + htpasswdFile = "/etc/nginx/.htpasswd-gallery"; + }; +}; +``` + +## Bootstrap (first nix build) + +```bash +go mod tidy # generates go.sum — commit it +nix build # fails, prints correct vendorHash +# paste hash into flake.nix +nix build # succeeds +``` + +## Key files + +| File | Purpose | +|---|---| +| `main.go` | Flags, wires generator + watcher + server | +| `generator.go` | Scans content dir, renders HTML + Atom feed, generates thumbs | +| `server.go` | Upload API + optional static file serving | +| `watcher.go` | fsnotify watcher with 500ms debounce | +| `assets.go` | `//go:embed` for templates and static | +| `module.nix` | NixOS module (systemd service, tmpfiles, nginx vhost) | +| `flake.nix` | `buildGoModule` package + devShell + nixosModules | + +## What's not done yet + +- ActivityPub federation (planned later — start with RSS/Atom) +- Multi-image upload +- Delete/edit via UI +- Any auth beyond nginx htpasswd diff --git a/README.md b/README.md new file mode 100644 index 0000000..be96289 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# photogallery + +A minimal static photo gallery with an Atom feed and an upload API. + +## How it works + +1. Drop `photo.jpg` + `photo.toml` into the content directory (or upload via the web UI at `/upload`). +2. The binary watches the directory and rebuilds the static site on any change. +3. nginx serves `public/` and proxies `/upload` to the local API, secured with htpasswd. + +## Sidecar format + +```toml +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 + +```nix +inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + photogallery.url = "github:youruser/photogallery"; +}; +``` + +### 2. Import the module + +```nix +nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + modules = [ + inputs.photogallery.nixosModules.default + ./configuration.nix + ]; +}; +``` + +### 3. Configure + +```nix +services.photogallery = { + enable = true; + baseURL = "https://photos.yourdomain.tld"; + + # Optional: built-in nginx vhost + nginx = { + enable = true; + domain = "photos.yourdomain.tld"; + htpasswdFile = "/etc/nginx/.htpasswd-gallery"; + }; +}; +``` + +Create the htpasswd file: + +```bash +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) | +| `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`. + +```bash +# 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 + +```bash +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. diff --git a/assets.go b/assets.go new file mode 100644 index 0000000..6d58109 --- /dev/null +++ b/assets.go @@ -0,0 +1,6 @@ +package main + +import "embed" + +//go:embed templates static +var assets embed.FS diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..7dffba2 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1782723713, + "narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..fffd5cd --- /dev/null +++ b/flake.nix @@ -0,0 +1,44 @@ +{ + description = "Minimal static photo gallery with Atom feed and upload API"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in { + packages.default = pkgs.buildGoModule { + pname = "photogallery"; + version = "0.1.0"; + src = self; + + # Run `go mod tidy` to generate go.sum, then `nix build`. + # Nix will fail and print the correct hash — paste it here. + vendorHash = "sha256-SxQDRProQtauWXPvPzbZZChLQd0iey9hnXFLiPYTS98="; + + meta = with pkgs.lib; { + description = "Minimal static photo gallery"; + license = licenses.mit; + mainProgram = "photogallery"; + platforms = platforms.linux; + }; + }; + + devShells.default = pkgs.mkShell { + packages = [ pkgs.go pkgs.gopls pkgs.gotools ]; + }; + } + ) // { + nixosModules.default = import ./module.nix self; + + # Lets consumers add photogallery to their pkgs without + # pulling in the full flake module. + overlays.default = _final: _prev: { + photogallery = self.packages.${_final.system}.default; + }; + }; +} diff --git a/generator.go b/generator.go new file mode 100644 index 0000000..fe528b2 --- /dev/null +++ b/generator.go @@ -0,0 +1,318 @@ +package main + +import ( + "encoding/xml" + "html/template" + imagedraw "image/draw" + "image" + "image/jpeg" + _ "image/png" + "io" + "log" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/BurntSushi/toml" + xdraw "golang.org/x/image/draw" +) + +const thumbSize = 600 + +// PhotoMeta is the structure of a .toml sidecar file. +type PhotoMeta struct { + Caption string `toml:"caption"` + Date string `toml:"date"` // YYYY-MM-DD +} + +// Photo is the in-memory representation of one image. +type Photo struct { + Slug string + File string // relative path under public/, e.g. "images/foo.jpg" + Thumb string // relative path under public/, e.g. "thumbs/foo.jpg" + Caption string + Date time.Time +} + +type Generator struct { + contentDir string + outputDir string + baseURL string + mu sync.Mutex + tmpl *template.Template +} + +func NewGenerator(contentDir, outputDir, baseURL string) *Generator { + tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) + return &Generator{ + contentDir: contentDir, + outputDir: outputDir, + baseURL: baseURL, + tmpl: tmpl, + } +} + +// Build performs a full rebuild of the site. It is safe to call concurrently; +// concurrent calls are serialised by a mutex. +func (g *Generator) Build() error { + g.mu.Lock() + defer g.mu.Unlock() + + photos, err := g.loadPhotos() + if err != nil { + return err + } + + for _, d := range []string{"images", "thumbs", "photo", "static"} { + if err := os.MkdirAll(filepath.Join(g.outputDir, d), 0755); err != nil { + return err + } + } + + for i := range photos { + if err := g.processImage(&photos[i]); err != nil { + log.Printf("processImage %s: %v", photos[i].Slug, err) + } + } + + if err := g.renderIndex(photos); err != nil { + return err + } + for _, p := range photos { + if err := g.renderPhotoPage(p); err != nil { + return err + } + } + if err := g.renderFeed(photos); err != nil { + return err + } + if err := g.copyStatic(); err != nil { + return err + } + + log.Printf("built: %d photos", len(photos)) + return nil +} + +// loadPhotos scans the content directory and returns photos sorted newest first. +func (g *Generator) loadPhotos() ([]Photo, error) { + entries, err := os.ReadDir(g.contentDir) + if err != nil { + return nil, err + } + + var photos []Photo + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + ext := strings.ToLower(filepath.Ext(name)) + if ext != ".jpg" && ext != ".jpeg" && ext != ".png" { + continue + } + + slug := strings.TrimSuffix(name, filepath.Ext(name)) + meta := PhotoMeta{ + Caption: slug, + Date: time.Now().Format("2006-01-02"), + } + + tomlPath := filepath.Join(g.contentDir, slug+".toml") + if _, err := os.Stat(tomlPath); err == nil { + if _, err := toml.DecodeFile(tomlPath, &meta); err != nil { + log.Printf("parsing %s: %v", tomlPath, err) + } + } + + date, err := time.Parse("2006-01-02", meta.Date) + if err != nil { + date = time.Now() + } + + photos = append(photos, Photo{ + Slug: slug, + File: "images/" + name, + Thumb: "thumbs/" + slug + ".jpg", + Caption: meta.Caption, + Date: date, + }) + } + + sort.Slice(photos, func(i, j int) bool { + return photos[i].Date.After(photos[j].Date) + }) + return photos, nil +} + +// processImage copies the original to public/images/ and writes a square +// thumbnail to public/thumbs/. +func (g *Generator) processImage(p *Photo) error { + src := filepath.Join(g.contentDir, filepath.Base(p.File)) + if err := copyFile(src, filepath.Join(g.outputDir, p.File)); err != nil { + return err + } + return generateThumb(src, filepath.Join(g.outputDir, p.Thumb), thumbSize) +} + +// generateThumb reads src, centre-crops it to a square, scales it to size×size, +// and writes a JPEG to dst. +func generateThumb(src, dst string, size int) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + + img, _, err := image.Decode(f) + if err != nil { + return err + } + + // Centre crop to square. + b := img.Bounds() + w, h := b.Dx(), b.Dy() + crop := w + if h < w { + crop = h + } + x0 := (w - crop) / 2 + y0 := (h - crop) / 2 + cropped := image.NewRGBA(image.Rect(0, 0, crop, crop)) + imagedraw.Draw(cropped, cropped.Bounds(), img, image.Point{x0, y0}, imagedraw.Src) + + // Scale to thumbSize×thumbSize using Catmull-Rom for quality. + thumb := image.NewRGBA(image.Rect(0, 0, size, size)) + xdraw.CatmullRom.Scale(thumb, thumb.Bounds(), cropped, cropped.Bounds(), xdraw.Over, nil) + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + return jpeg.Encode(out, thumb, &jpeg.Options{Quality: 85}) +} + +// renderIndex writes public/index.html. +func (g *Generator) renderIndex(photos []Photo) error { + return g.render("index.html", filepath.Join(g.outputDir, "index.html"), map[string]any{ + "Photos": photos, + "BaseURL": g.baseURL, + }) +} + +// renderPhotoPage writes public/photo//index.html. +func (g *Generator) renderPhotoPage(p Photo) error { + dir := filepath.Join(g.outputDir, "photo", p.Slug) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return g.render("photo.html", filepath.Join(dir, "index.html"), map[string]any{ + "Photo": p, + "BaseURL": g.baseURL, + }) +} + +func (g *Generator) render(tmpl, dst string, data any) error { + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + return g.tmpl.ExecuteTemplate(f, tmpl, data) +} + +// Atom feed --------------------------------------------------------------- + +type atomFeed struct { + XMLName xml.Name `xml:"feed"` + Xmlns string `xml:"xmlns,attr"` + Title string `xml:"title"` + Link atomLink `xml:"link"` + Updated string `xml:"updated"` + ID string `xml:"id"` + Entries []atomEntry `xml:"entry"` +} + +type atomLink struct { + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr,omitempty"` +} + +type atomEntry struct { + Title string `xml:"title"` + Link atomLink `xml:"link"` + ID string `xml:"id"` + Updated string `xml:"updated"` + Summary string `xml:"summary"` +} + +func (g *Generator) renderFeed(photos []Photo) error { + feed := atomFeed{ + Xmlns: "http://www.w3.org/2005/Atom", + Title: "Photo Gallery", + Link: atomLink{Href: g.baseURL + "/feed.xml", Rel: "self"}, + ID: g.baseURL + "/", + } + if len(photos) > 0 { + feed.Updated = photos[0].Date.Format(time.RFC3339) + } + for _, p := range photos { + url := g.baseURL + "/photo/" + p.Slug + "/" + feed.Entries = append(feed.Entries, atomEntry{ + Title: p.Caption, + Link: atomLink{Href: url}, + ID: url, + Updated: p.Date.Format(time.RFC3339), + Summary: p.Caption, + }) + } + + f, err := os.Create(filepath.Join(g.outputDir, "feed.xml")) + if err != nil { + return err + } + defer f.Close() + + f.WriteString(xml.Header) + enc := xml.NewEncoder(f) + enc.Indent("", " ") + return enc.Encode(feed) +} + +// copyStatic copies embedded static/ assets to public/static/. +func (g *Generator) copyStatic() error { + entries, err := assets.ReadDir("static") + if err != nil { + return err + } + for _, e := range entries { + data, err := assets.ReadFile("static/" + e.Name()) + if err != nil { + return err + } + dst := filepath.Join(g.outputDir, "static", e.Name()) + if err := os.WriteFile(dst, data, 0644); err != nil { + return err + } + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c3eb3f9 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module photogallery + +go 1.22 + +require ( + github.com/BurntSushi/toml v1.4.0 + github.com/fsnotify/fsnotify v1.7.0 + golang.org/x/image v0.21.0 +) + +require golang.org/x/sys v0.4.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b520222 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= +golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= +golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c967c5a --- /dev/null +++ b/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "flag" + "log" + "os" +) + +func main() { + contentDir := flag.String("content", "./content", "directory containing images and .toml sidecars") + outputDir := flag.String("output", "./public", "directory for generated site output") + baseURL := flag.String("base-url", "http://localhost:8080", "public base URL, no trailing slash") + port := flag.String("port", "8080", "port to listen on") + serve := flag.Bool("serve", false, "serve the generated site on the same port (for local development)") + flag.Parse() + + for _, d := range []string{*contentDir, *outputDir} { + if err := os.MkdirAll(d, 0755); err != nil { + log.Fatalf("mkdir %s: %v", d, err) + } + } + + g := NewGenerator(*contentDir, *outputDir, *baseURL) + + if err := g.Build(); err != nil { + log.Printf("initial build: %v", err) + } + + w, err := NewWatcher(*contentDir, g) + if err != nil { + log.Fatalf("watcher: %v", err) + } + defer w.Close() + go w.Watch() + + s := NewServer(*port, *contentDir, *outputDir, *serve, g) + log.Fatal(s.Listen()) +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..44a819e --- /dev/null +++ b/module.nix @@ -0,0 +1,135 @@ +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."; + }; + + 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 endpoint."; + }; + + 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) + ]; + + 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; + }; + }; + }; + }) + + ]); +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..6e6138c --- /dev/null +++ b/server.go @@ -0,0 +1,152 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/BurntSushi/toml" +) + +type Server struct { + port string + contentDir string + outputDir string + serve bool + generator *Generator +} + +func NewServer(port, contentDir, outputDir string, serve bool, g *Generator) *Server { + return &Server{ + port: port, + contentDir: contentDir, + outputDir: outputDir, + serve: serve, + generator: g, + } +} + +func (s *Server) Listen() error { + mux := http.NewServeMux() + mux.HandleFunc("GET /upload", s.handleUploadPage) + mux.HandleFunc("POST /upload", s.handleUpload) + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, "ok") + }) + + if s.serve { + // Serve the generated site on the same port — for local development only. + // In production nginx handles this and the server binds to localhost. + mux.Handle("/", http.FileServer(http.Dir(s.outputDir))) + log.Printf("gallery: http://localhost:%s", s.port) + log.Printf("upload: http://localhost:%s/upload", s.port) + return http.ListenAndServe(":"+s.port, mux) + } + + log.Printf("upload API on 127.0.0.1:%s", s.port) + return http.ListenAndServe("127.0.0.1:"+s.port, mux) +} + +func (s *Server) handleUploadPage(w http.ResponseWriter, r *http.Request) { + data, err := assets.ReadFile("templates/upload.html") + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) +} + +// handleUpload accepts multipart/form-data with fields: +// +// image — the image file (jpg or png) +// caption — human-readable caption (optional, defaults to filename) +// date — YYYY-MM-DD (optional, defaults to today) +func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("image") + if err != nil { + http.Error(w, "missing 'image' field", http.StatusBadRequest) + return + } + defer file.Close() + + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext != ".jpg" && ext != ".jpeg" && ext != ".png" { + http.Error(w, "only jpg and png are accepted", http.StatusBadRequest) + return + } + + caption := r.FormValue("caption") + dateStr := r.FormValue("date") + if dateStr == "" { + dateStr = time.Now().Format("2006-01-02") + } + if caption == "" { + caption = strings.TrimSuffix(header.Filename, ext) + } + + slug := sanitiseSlug(strings.TrimSuffix(header.Filename, ext)) + if slug == "" { + slug = fmt.Sprintf("photo-%d", time.Now().UnixMilli()) + } + imgPath := filepath.Join(s.contentDir, slug+ext) + tomlPath := filepath.Join(s.contentDir, slug+".toml") + + out, err := os.Create(imgPath) + if err != nil { + log.Printf("create %s: %v", imgPath, err) + http.Error(w, "could not save image", http.StatusInternalServerError) + return + } + defer out.Close() + if _, err := io.Copy(out, file); err != nil { + http.Error(w, "could not write image", http.StatusInternalServerError) + return + } + + tf, err := os.Create(tomlPath) + if err != nil { + log.Printf("create %s: %v", tomlPath, err) + http.Error(w, "could not save metadata", http.StatusInternalServerError) + return + } + defer tf.Close() + if err := toml.NewEncoder(tf).Encode(PhotoMeta{Caption: caption, Date: dateStr}); err != nil { + http.Error(w, "could not write metadata", http.StatusInternalServerError) + return + } + + // Build() is mutex-guarded so calling it here and from the watcher is safe. + go func() { + if err := s.generator.Build(); err != nil { + log.Printf("rebuild after upload: %v", err) + } + }() + + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, "uploaded %s\n", slug+ext) +} + +// sanitiseSlug returns a filesystem- and URL-safe version of s. +func sanitiseSlug(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + case r == ' ', r == '_': + b.WriteRune('-') + } + } + return b.String() +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..e79032d --- /dev/null +++ b/static/style.css @@ -0,0 +1,270 @@ +:root { + --bg: #1d2021; + --bg0: #282828; + --bg1: #3c3836; + --bg2: #504945; + --fg: #ebdbb2; + --fg2: #d5c4a1; + --fg3: #bdae93; + --fg4: #a89984; + --aqua: #8ec07c; + --line: #3c3836; + --vk: 'Vollkorn', Georgia, serif; + --mj: 'Manjari', sans-serif; +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: var(--bg); + color: var(--fg); + font-family: var(--vk); + min-height: 100vh; +} + +/* ── Profile header ────────────────────────────────────────── */ + +.profile-hdr { + border-bottom: 1px solid var(--line); + padding: 28px 20px 22px; +} + +.profile-inner { + max-width: 935px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 36px; +} + +.avatar-ring { + width: 84px; + height: 84px; + border-radius: 50%; + border: 2px solid var(--bg2); + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg1); +} + +.avatar-ring img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.avatar-initials { + font-size: 32px; + font-weight: 700; + color: var(--fg3); + font-family: var(--vk); +} + +.pinfo h1 { + font-family: var(--vk); + font-size: 26px; + font-weight: 700; + color: var(--fg); + margin-bottom: 3px; +} + +.handle { + font-size: 12px; + color: var(--fg4); + font-family: monospace; + margin-bottom: 10px; + letter-spacing: 0.02em; +} + +.pcount { + font-size: 15px; + color: var(--fg3); + margin-bottom: 8px; +} + +.pcount strong { + color: var(--fg); + font-weight: 600; +} + +.pbio { + font-size: 15px; + color: var(--fg2); + margin-bottom: 10px; + line-height: 1.7; + max-width: 280px; + font-style: italic; +} + +.ml { + font-family: var(--mj); + font-style: normal; + font-weight: 400; +} + +.rss-a { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--aqua); + text-decoration: none; + text-transform: uppercase; + letter-spacing: 0.05em; + font-family: monospace; +} + +.rss-a:hover { + color: var(--fg2); +} + +/* ── Photo grid ────────────────────────────────────────────── */ + +.grid-wrap { + max-width: 935px; + margin: 0 auto; + padding: 3px 0; +} + +.pgrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3px; +} + +.ptile { + aspect-ratio: 1; + overflow: hidden; + display: block; + background: var(--bg1); +} + +.ptile img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform 0.3s ease; +} + +.ptile:hover img { + transform: scale(1.05); +} + +/* ── Single photo page ─────────────────────────────────────── */ + +.photo-page { + max-width: 935px; + margin: 0 auto; + padding: 16px 20px; +} + +.back-btn { + display: inline-block; + font-size: 15px; + color: var(--fg3); + text-decoration: none; + padding: 8px 0; + margin-bottom: 16px; + font-family: var(--vk); +} + +.back-btn:hover { + color: var(--fg); +} + +.dlayout { + display: flex; + border: 1px solid var(--line); +} + +.dimg-wrap { + flex: 1; + aspect-ratio: 1; + overflow: hidden; + background: var(--bg1); +} + +.dimg-wrap img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.dsidebar { + width: 220px; + flex-shrink: 0; + padding: 18px; + border-left: 1px solid var(--line); + background: var(--bg0); +} + +.dauthor { + display: flex; + align-items: center; + gap: 10px; + padding-bottom: 14px; + border-bottom: 1px solid var(--line); + margin-bottom: 14px; +} + +.mini-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--bg2); + background: var(--bg1); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 600; + color: var(--fg3); + font-family: var(--vk); + flex-shrink: 0; +} + +.dauthor-name { + font-size: 14px; + font-weight: 600; + color: var(--fg); + font-family: var(--vk); +} + +.dcaption { + font-family: var(--vk); + font-size: 17px; + font-style: italic; + color: var(--fg); + margin-bottom: 10px; + line-height: 1.6; +} + +.ddate { + font-size: 11px; + color: var(--fg4); + font-family: monospace; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* ── Responsive ────────────────────────────────────────────── */ + +@media (max-width: 600px) { + .profile-inner { gap: 18px; } + .avatar-ring { width: 60px; height: 60px; } + .avatar-initials { font-size: 22px; } + .pinfo h1 { font-size: 20px; } + .pgrid { grid-template-columns: repeat(2, 1fr); } + .dlayout { flex-direction: column; } + .dsidebar { width: 100%; border-left: none; border-top: 1px solid var(--line); } +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f6af54c --- /dev/null +++ b/templates/index.html @@ -0,0 +1,46 @@ + + + + + + Photo Gallery + + + + + +
+
+
+ A +
+
+

Ashik

+

@ashik@yourdomain.tld

+

{{len .Photos}} posts

+

Personal photo journal. Berlin-based. വെളിച്ചം കൊണ്ട് വരയ്ക്കുന്നു.

+ + + RSS feed + +
+
+
+ +
+
+
+ {{range .Photos}} + + {{.Caption}} + + {{end}} +
+
+
+ + diff --git a/templates/photo.html b/templates/photo.html new file mode 100644 index 0000000..493cfc0 --- /dev/null +++ b/templates/photo.html @@ -0,0 +1,29 @@ + + + + + + {{.Photo.Caption}} + + + + + +
+ ← Back +
+
+ {{.Photo.Caption}} +
+
+
+
A
+ Ashik +
+

{{.Photo.Caption}}

+

{{.Photo.Date.Format "Jan 02, 2006"}}

+
+
+
+ + diff --git a/templates/upload.html b/templates/upload.html new file mode 100644 index 0000000..7667d13 --- /dev/null +++ b/templates/upload.html @@ -0,0 +1,213 @@ + + + + + + Upload — Photo Gallery + + + + + +
+ ← Gallery +

Upload photo

+ +
+ +
+ +

Click to choose a photo

+ jpg or png +
+ Preview +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + + + diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..4ec8efb --- /dev/null +++ b/watcher.go @@ -0,0 +1,75 @@ +package main + +import ( + "log" + "path/filepath" + "strings" + "time" + + "github.com/fsnotify/fsnotify" +) + +type Watcher struct { + w *fsnotify.Watcher + g *Generator + done chan struct{} +} + +func NewWatcher(dir string, g *Generator) (*Watcher, error) { + w, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + if err := w.Add(dir); err != nil { + w.Close() + return nil, err + } + return &Watcher{w: w, g: g, done: make(chan struct{})}, nil +} + +// Watch blocks, watching for changes and triggering rebuilds. +// Rapid events (e.g. scp writing an image then its sidecar) are debounced +// with a 500 ms window so only one rebuild fires per logical upload. +func (w *Watcher) Watch() { + var timer *time.Timer + rebuild := func() { + if err := w.g.Build(); err != nil { + log.Printf("rebuild: %v", err) + } + } + + for { + select { + case event, ok := <-w.w.Events: + if !ok { + return + } + if !isRelevant(event.Name) { + continue + } + if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) || event.Has(fsnotify.Remove) { + if timer != nil { + timer.Stop() + } + timer = time.AfterFunc(500*time.Millisecond, rebuild) + } + case err, ok := <-w.w.Errors: + if !ok { + return + } + log.Printf("watcher: %v", err) + case <-w.done: + return + } + } +} + +func (w *Watcher) Close() { + close(w.done) + w.w.Close() +} + +func isRelevant(name string) bool { + ext := strings.ToLower(filepath.Ext(name)) + return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".toml" +}