Add TypeScript migration, image resizing, media upload UX, and multimedia support
All checks were successful
Deploy to NAS / deploy (push) Successful in 2m20s
All checks were successful
Deploy to NAS / deploy (push) Successful in 2m20s
- 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>
This commit is contained in:
@@ -2,8 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -20,10 +20,17 @@ const (
|
||||
)
|
||||
|
||||
var allowedMIME = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/heic": ".heic",
|
||||
"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 {
|
||||
@@ -108,51 +115,8 @@ func (h *JournalHandler) HandleUpdateEntry(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Handle new image uploads
|
||||
if r.MultipartForm != nil && r.MultipartForm.File != nil {
|
||||
files := r.MultipartForm.File["images"]
|
||||
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])
|
||||
ext, ok := allowedMIME[mime]
|
||||
if !ok {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
filename := sanitizeFilename(entryID+"_"+fh.Filename) + ext
|
||||
destPath := filepath.Join(h.uploadDir, filename)
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
continue
|
||||
}
|
||||
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: 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", entryID, "err", err)
|
||||
os.Remove(destPath)
|
||||
}
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
h.saveJournalImages(r, entryID, r.MultipartForm.File["images"])
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/days/"+existing.EntryDate, http.StatusSeeOther)
|
||||
@@ -219,71 +183,56 @@ func (h *JournalHandler) HandleCreateEntry(w http.ResponseWriter, r *http.Reques
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user