package api import ( "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "strings" "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", } type JournalHandler struct { store *db.JournalStore uploadDir string } func NewJournalHandler(store *db.JournalStore, uploadDir string) *JournalHandler { return &JournalHandler{store: store, uploadDir: uploadDir} } // 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 } // Handle image uploads if r.MultipartForm != nil && r.MultipartForm.File != nil { files := r.MultipartForm.File["images"] for _, fh := range files { if fh.Size > maxSingleImage { continue // skip oversized images silently } f, err := fh.Open() if err != nil { continue } // Detect MIME type from first 512 bytes buf := make([]byte, 512) n, _ := f.Read(buf) mime := http.DetectContentType(buf[:n]) ext, ok := allowedMIME[mime] if !ok { f.Close() continue } filename := saved.EntryID + "_" + fh.Filename filename = sanitizeFilename(filename) + ext destPath := filepath.Join(h.uploadDir, filename) out, err := os.Create(destPath) if err != nil { f.Close() continue } // Write already-read bytes + rest; clean up file on any write error if _, err := out.Write(buf[:n]); err != nil { out.Close() f.Close() os.Remove(destPath) continue } if _, err := io.Copy(out, f); err != nil { out.Close() f.Close() os.Remove(destPath) continue } out.Close() f.Close() img := domain.JournalImage{ EntryID: saved.EntryID, Filename: filename, OriginalName: fh.Filename, MimeType: mime, SizeBytes: fh.Size, } if _, err := h.store.InsertImage(r.Context(), img); err != nil { slog.Error("insert image", "entry_id", saved.EntryID, "filename", filename, "err", err) os.Remove(destPath) } } } http.Redirect(w, r, "/days/"+date, http.StatusSeeOther) } // 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 }