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>
126 lines
3.0 KiB
Go
126 lines
3.0 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"image"
|
|
"image/jpeg"
|
|
_ "image/jpeg"
|
|
"image/png"
|
|
_ "image/png"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/disintegration/imaging"
|
|
"golang.org/x/image/webp"
|
|
)
|
|
|
|
const maxImageDimension = 1920
|
|
const jpegQuality = 80
|
|
|
|
// saveUpload writes an uploaded file to uploadDir, resizing images where applicable.
|
|
// peeked contains the first bytes already read for MIME detection.
|
|
// Returns the saved filename (including extension).
|
|
func saveUpload(uploadDir, baseName, mime string, peeked []byte, rest io.Reader) (string, error) {
|
|
full := io.MultiReader(bytes.NewReader(peeked), rest)
|
|
switch mime {
|
|
case "image/jpeg", "image/png":
|
|
return saveResizedImage(uploadDir, baseName, mime, full)
|
|
case "image/webp":
|
|
return saveResizedWebP(uploadDir, baseName, full)
|
|
default:
|
|
ext := allowedMIME[mime]
|
|
dest := filepath.Join(uploadDir, baseName+ext)
|
|
return baseName + ext, saveRaw(dest, full)
|
|
}
|
|
}
|
|
|
|
func saveResizedImage(uploadDir, baseName, mime string, r io.Reader) (string, error) {
|
|
img, _, err := image.Decode(r)
|
|
if err != nil {
|
|
// Fallback: save raw if decode fails
|
|
slog.Warn("image decode failed, saving raw", "mime", mime, "err", err)
|
|
return "", err
|
|
}
|
|
|
|
b := img.Bounds()
|
|
if b.Dx() > maxImageDimension || b.Dy() > maxImageDimension {
|
|
img = imaging.Fit(img, maxImageDimension, maxImageDimension, imaging.Lanczos)
|
|
}
|
|
|
|
ext := allowedMIME[mime]
|
|
filename := baseName + ext
|
|
dest := filepath.Join(uploadDir, filename)
|
|
out, err := os.Create(dest)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer out.Close()
|
|
|
|
switch mime {
|
|
case "image/jpeg":
|
|
err = jpeg.Encode(out, img, &jpeg.Options{Quality: jpegQuality})
|
|
case "image/png":
|
|
err = png.Encode(out, img)
|
|
}
|
|
if err != nil {
|
|
os.Remove(dest)
|
|
return "", err
|
|
}
|
|
return filename, nil
|
|
}
|
|
|
|
func saveResizedWebP(uploadDir, baseName string, r io.Reader) (string, error) {
|
|
img, err := webp.Decode(r)
|
|
if err != nil {
|
|
slog.Warn("webp decode failed, saving raw", "err", err)
|
|
return "", err
|
|
}
|
|
|
|
b := img.Bounds()
|
|
if b.Dx() > maxImageDimension || b.Dy() > maxImageDimension {
|
|
img = imaging.Fit(img, maxImageDimension, maxImageDimension, imaging.Lanczos)
|
|
}
|
|
|
|
// Re-encode as JPEG (no pure-Go WebP encoder available)
|
|
filename := baseName + ".jpg"
|
|
dest := filepath.Join(uploadDir, filename)
|
|
out, err := os.Create(dest)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer out.Close()
|
|
|
|
if err := jpeg.Encode(out, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
|
os.Remove(dest)
|
|
return "", err
|
|
}
|
|
return filename, nil
|
|
}
|
|
|
|
func saveRaw(dest string, r io.Reader) error {
|
|
out, err := os.Create(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
_, err = io.Copy(out, r)
|
|
return err
|
|
}
|
|
|
|
// mimeCategory returns "image", "video", "audio" or "other".
|
|
func mimeCategory(mime string) string {
|
|
switch {
|
|
case strings.HasPrefix(mime, "image/"):
|
|
return "image"
|
|
case strings.HasPrefix(mime, "video/"):
|
|
return "video"
|
|
case strings.HasPrefix(mime, "audio/"):
|
|
return "audio"
|
|
default:
|
|
return "other"
|
|
}
|
|
}
|