package main import ( "fmt" "io" "log" "net/http" "net/url" "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 // rebuildHook overrides how a mutation triggers a rebuild. nil means the // default asynchronous Build; tests set it to run synchronously (or not at // all) so no background goroutine outlives the test. rebuildHook func() } 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) } // sameOrigin reports whether a form/API post came from the gallery's own pages. // Basic-auth credentials are replayed by the browser on every request to the // host, so without this check any other website could drive a logged-in user's // browser into submitting an edit, delete or retag on their behalf (CSRF). // // Sec-Fetch-Site is preferred; where it is absent the Origin header is compared // against -base-url and the request Host (behind nginx the Host reaching this // server is the proxy upstream, not the public name). A request with neither // header is a non-browser client such as curl, which cannot be driven // cross-site, so it is allowed. func (s *Server) sameOrigin(r *http.Request) bool { if site := r.Header.Get("Sec-Fetch-Site"); site != "" { return site == "same-origin" || site == "none" } origin := r.Header.Get("Origin") if origin == "" { return true } if origin == s.generator.baseURL { return true } u, err := url.Parse(origin) return err == nil && u.Host == r.Host } // rejectCrossSite writes a 403 and returns true when r is a cross-site request. // State-changing handlers call it first: `if s.rejectCrossSite(w, r) { return }`. func (s *Server) rejectCrossSite(w http.ResponseWriter, r *http.Request) bool { if s.sameOrigin(r) { return false } http.Error(w, "cross-site request refused", http.StatusForbidden) return true } func (s *Server) Listen() error { mux := http.NewServeMux() mux.HandleFunc("GET /upload", s.guard(s.handleUploadPage)) mux.HandleFunc("POST /upload", s.guard(s.handleUpload)) // Edit/delete UI and its API. All auth-gated like /upload; destructive // endpoints (delete) move posts to content/.trash/ rather than erasing. mux.HandleFunc("GET /manage", s.guard(s.handleManagePage)) mux.HandleFunc("GET /api/photos", s.guard(s.handleListPhotos)) mux.HandleFunc("POST /api/edit", s.guard(s.handleEdit)) mux.HandleFunc("POST /api/delete", s.guard(s.handleDelete)) mux.HandleFunc("POST /api/tags", s.guard(s.handleBulkTags)) mux.HandleFunc("GET /api/thumb/{slug}", s.guard(s.handleThumb)) mux.HandleFunc("GET /api/trash", s.guard(s.handleListTrash)) mux.HandleFunc("POST /api/restore", s.guard(s.handleRestore)) mux.HandleFunc("POST /api/trash/empty", s.guard(s.handleEmptyTrash)) // Login-state probe — deliberately NOT guarded, so public pages can ask // without triggering a Basic-auth dialog. It does its own silent check. mux.HandleFunc("GET /api/session", s.handleSession) 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() }