Convert backend from submodule to regular directory
Some checks failed
Deploy to NAS / deploy (push) Failing after 4s
Some checks failed
Deploy to NAS / deploy (push) Failing after 4s
Remove submodule tracking; backend is now a plain directory in the repo. Also update deploy workflow: remove --recurse-submodules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
164
backend/internal/api/journal.go
Normal file
164
backend/internal/api/journal.go
Normal file
@@ -0,0 +1,164 @@
|
||||
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"))
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user