Files
photog/server.go
T
puttaalu 434d2437bf Move upload auth from nginx into the app
Add HTTP Basic auth on /upload, reading an Apache htpasswd file (bcrypt
only, `htpasswd -B`) via a new -htpasswd flag. This lets nginx drop to a
plain TLS-terminating reverse proxy with no auth of its own; the existing
bcrypt htpasswd file is reused unchanged.

auth.go:
- LoadHtpasswd parses user:hash lines, keeps bcrypt entries ($2a/$2b/$2y,
  so Apache's $2y$ works), skips MD5/SHA/crypt with a warning.
- Unknown users still pay for one bcrypt comparison against a dummy hash,
  so response timing can't enumerate valid usernames.
- Failed attempts draw from a token-bucket limiter (x/time/rate, ~1/s,
  burst 10) returning 429 once drained; successful and bulk uploads never
  touch it.

server.go guards both /upload routes (nil authenticator = passthrough for
local --serve). /health stays open. Empty -htpasswd disables auth and logs
a warning.

module.nix: new top-level services.photogallery.htpasswdFile, delivered to
the service via systemd LoadCredential (so a root-owned 0400 secret works).
Removed nginx.htpasswdFile and basic_auth from the vhost.

Both uploader apps already send Basic credentials, so no client change.

Deps: golang.org/x/crypto/bcrypt, golang.org/x/time/rate (vendorHash bump).
2026-10-18 14:45:33 +02:00

177 lines
5.0 KiB
Go

package main
import (
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
"golang.org/x/time/rate"
)
type Server struct {
port string
contentDir string
outputDir string
serve bool
generator *Generator
auth *Authenticator // nil disables auth (dev only)
authLimit *rate.Limiter
}
func NewServer(port, contentDir, outputDir string, serve bool, g *Generator, auth *Authenticator) *Server {
return &Server{
port: port,
contentDir: contentDir,
outputDir: outputDir,
serve: serve,
generator: g,
auth: auth,
// Brute-force throttle: ~1 failed auth/sec steady, burst of 10. Only
// failures draw from it, so legitimate bulk uploads are never slowed.
authLimit: rate.NewLimiter(rate.Every(time.Second), 10),
}
}
// guard applies HTTP Basic auth to h when an Authenticator is configured, and
// passes it through untouched otherwise (local --serve development).
func (s *Server) guard(h http.HandlerFunc) http.HandlerFunc {
if s.auth == nil {
return h
}
return s.auth.basicAuth(s.authLimit, h)
}
func (s *Server) Listen() error {
mux := http.NewServeMux()
mux.HandleFunc("GET /upload", s.guard(s.handleUploadPage))
mux.HandleFunc("POST /upload", s.guard(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)
// tags — comma-separated album tags (optional; lowercased, deduped)
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)
}
// Normalize the tags once here so the sidecar stores clean lowercase names;
// the generator derives album slugs from them at build time.
var tags []string
for _, t := range parseTags(r.FormValue("tags")) {
tags = append(tags, t.Name)
}
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, Tags: tags}); 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()
}