package api import ( "fmt" "log/slog" "mime/multipart" "net/http" "os" "path/filepath" "strings" "github.com/go-chi/chi/v5" "github.com/jacek/pamietnik/backend/internal/db" "github.com/jacek/pamietnik/backend/internal/domain" ) const ( maxUploadSize = 32 << 20 // 32 MB per request maxSingleImage = 10 << 20 // 10 MB per image ) var allowedMIME = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/heic": ".heic", "image/gif": ".gif", "video/mp4": ".mp4", "video/webm": ".webm", "audio/mpeg": ".mp3", "audio/ogg": ".ogg", "audio/wav": ".wav", "audio/aac": ".aac", } type JournalHandler struct { store *db.JournalStore uploadDir string } func NewJournalHandler(store *db.JournalStore, uploadDir string) *JournalHandler { return &JournalHandler{store: store, uploadDir: uploadDir} } // HandleGetEditEntry renders the edit form for an existing entry. func (h *JournalHandler) HandleGetEditEntry(w http.ResponseWriter, r *http.Request) { userID := userIDFromContext(r.Context()) entryID := chi.URLParam(r, "id") entry, err := h.store.GetEntry(r.Context(), entryID, userID) if err != nil { http.Error(w, "Eintrag nicht gefunden", http.StatusNotFound) return } render(w, r, "edit_entry.html", map[string]any{"Entry": entry}) } // HandleUpdateEntry handles POST /entries/{id} (multipart/form-data). func (h *JournalHandler) HandleUpdateEntry(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(maxUploadSize); err != nil { http.Error(w, "Formular zu groß", http.StatusRequestEntityTooLarge) return } userID := userIDFromContext(r.Context()) entryID := chi.URLParam(r, "id") // Verify ownership first existing, err := h.store.GetEntry(r.Context(), entryID, userID) if err != nil { http.Error(w, "Eintrag nicht gefunden", http.StatusNotFound) return } entryTime := strings.TrimSpace(r.FormValue("time")) title := strings.TrimSpace(r.FormValue("title")) description := strings.TrimSpace(r.FormValue("description")) visibility := r.FormValue("visibility") if visibility != "public" && visibility != "private" { visibility = "private" } var hashtags []string if raw := strings.TrimSpace(r.FormValue("hashtags")); raw != "" { for _, tag := range strings.Split(raw, ",") { tag = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(tag), "#")) if tag != "" { hashtags = append(hashtags, tag) } } } entry := domain.JournalEntry{ EntryID: entryID, UserID: userID, EntryDate: existing.EntryDate, EntryTime: entryTime, Title: title, Description: description, Visibility: visibility, Hashtags: hashtags, } if lat := r.FormValue("lat"); lat != "" { var v float64 if _, err := fmt.Sscanf(lat, "%f", &v); err == nil { entry.Lat = &v } } if lon := r.FormValue("lon"); lon != "" { var v float64 if _, err := fmt.Sscanf(lon, "%f", &v); err == nil { entry.Lon = &v } } if err := h.store.UpdateEntry(r.Context(), entry); err != nil { http.Error(w, "Datenbankfehler", http.StatusInternalServerError) return } if r.MultipartForm != nil { h.saveJournalImages(r, entryID, r.MultipartForm.File["images"]) } http.Redirect(w, r, "/days/"+existing.EntryDate, http.StatusSeeOther) } // HandleCreateEntry handles POST /entries (multipart/form-data). func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(maxUploadSize); err != nil { http.Error(w, "Formular zu groß", http.StatusRequestEntityTooLarge) return } userID := userIDFromContext(r.Context()) date := strings.TrimSpace(r.FormValue("date")) entryTime := strings.TrimSpace(r.FormValue("time")) title := strings.TrimSpace(r.FormValue("title")) description := strings.TrimSpace(r.FormValue("description")) visibility := r.FormValue("visibility") if visibility != "public" && visibility != "private" { visibility = "private" } var hashtags []string if raw := strings.TrimSpace(r.FormValue("hashtags")); raw != "" { for _, tag := range strings.Split(raw, ",") { tag = strings.TrimSpace(tag) tag = strings.TrimPrefix(tag, "#") if tag != "" { hashtags = append(hashtags, tag) } } } if date == "" || entryTime == "" { http.Error(w, "Datum und Uhrzeit sind Pflichtfelder", http.StatusBadRequest) return } entry := domain.JournalEntry{ UserID: userID, EntryDate: date, EntryTime: entryTime, Title: title, Description: description, Visibility: visibility, Hashtags: hashtags, } if lat := r.FormValue("lat"); lat != "" { var v float64 if _, err := fmt.Sscanf(lat, "%f", &v); err == nil { entry.Lat = &v } } if lon := r.FormValue("lon"); lon != "" { var v float64 if _, err := fmt.Sscanf(lon, "%f", &v); err == nil { entry.Lon = &v } } saved, err := h.store.InsertEntry(r.Context(), entry) if err != nil { http.Error(w, "Datenbankfehler", http.StatusInternalServerError) return } if r.MultipartForm != nil { h.saveJournalImages(r, saved.EntryID, r.MultipartForm.File["images"]) } http.Redirect(w, r, "/days/"+date, http.StatusSeeOther) } // saveJournalImages saves uploaded files for a journal entry, with image resizing. // Errors per file are logged and silently skipped so the entry is always saved. func (h *JournalHandler) saveJournalImages(r *http.Request, entryID string, files []*multipart.FileHeader) { ctx := r.Context() for _, fh := range files { if fh.Size == 0 || fh.Size > maxSingleImage { continue } f, err := fh.Open() if err != nil { continue } buf := make([]byte, 512) n, _ := f.Read(buf) mime := http.DetectContentType(buf[:n]) if _, ok := allowedMIME[mime]; !ok { f.Close() continue } baseName := sanitizeFilename(entryID + "_" + fh.Filename) filename, err := saveUpload(h.uploadDir, baseName, mime, buf[:n], f) f.Close() if err != nil { slog.Error("save upload", "entry_id", entryID, "err", err) continue } img := domain.JournalImage{ EntryID: entryID, Filename: filename, OriginalName: fh.Filename, MimeType: mime, SizeBytes: fh.Size, } if _, err := h.store.InsertImage(ctx, img); err != nil { slog.Error("insert image", "entry_id", entryID, "err", err) os.Remove(filepath.Join(h.uploadDir, filename)) } } } // sanitizeFilename strips path separators and non-printable characters. func sanitizeFilename(name string) string { name = filepath.Base(name) var b strings.Builder for _, r := range name { if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { b.WriteRune('_') } else { b.WriteRune(r) } } s := b.String() // strip extension — we append the detected one if idx := strings.LastIndex(s, "."); idx > 0 { s = s[:idx] } return s }