Files
pamietnik/backend/internal/api/journal.go
Christoph K. 17186e7b64
All checks were successful
Deploy to NAS / deploy (push) Successful in 2m20s
Add TypeScript migration, image resizing, media upload UX, and multimedia support
- Migrate static JS to TypeScript (static-ts/ → compiled to internal/api/static/)
- Add image resizing on upload: JPEG/PNG/WebP scaled to max 1920px at quality 80
- Extract shared upload logic into upload.go (saveUpload, saveResizedImage, saveResizedWebP)
- Add POST /media endpoint for drag-drop/paste media uploads with markdown ref return
- Add background music player with video/audio coordination (autoplay.ts)
- Add global nav, public feed, hashtags, visibility, Markdown rendering for entries
- Add Dockerfile stage for TypeScript compilation (static-ts-builder)
- Add goldmark, disintegration/imaging, golang.org/x/image dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 23:03:04 +02:00

254 lines
6.7 KiB
Go

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
}