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() }