commit fd2828f1e739879d0beaca2c40fb926a3c93b6c7 Author: puttaalu Date: Thu Jul 2 00:53:17 2026 +0200 Initial commit 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" +}