Add post image upload backend
CI / test (pull_request) Successful in 7m1s

This commit is contained in:
2026-08-27 23:53:27 -07:00
parent 1840a662d9
commit 677e63329d
6 changed files with 884 additions and 1 deletions
+395
View File
@@ -0,0 +1,395 @@
package web
import (
"bytes"
"context"
"errors"
"image"
"image/jpeg"
"image/png"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"path"
"strings"
"time"
"github.com/google/uuid"
"github.com/rwcarlsen/goexif/exif"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"plumber/internal/blob"
"plumber/internal/store"
)
const (
defaultRequestBodyBytes = 3 << 20
postImageMaxFileBytes = 5 << 20
postImageMaxRequestBytes = 22 << 20
postImageMultipartMemory = 2 << 20
postImageMaxSourceDim = 6000
postImageMaxSourcePixels = 16_000_000
postImageMaxRenderedDim = 1600
postImageCleanupTimeout = 10 * time.Second
)
func requestBodyLimit(r *http.Request) int64 {
if r.Method != http.MethodPost {
return defaultRequestBodyBytes
}
switch {
case r.URL.Path == "/submit", r.URL.Path == "/posts":
return postImageMaxRequestBytes
case strings.HasPrefix(r.URL.Path, "/posts/") && strings.HasSuffix(r.URL.Path, "/edit"):
return postImageMaxRequestBytes
default:
return defaultRequestBodyBytes
}
}
type postImageRequestError struct {
status int
message string
cause error
}
func (e *postImageRequestError) Error() string {
if e.cause == nil {
return e.message
}
return e.message + ": " + e.cause.Error()
}
func parsePostMutationForm(w http.ResponseWriter, r *http.Request) (func(), bool) {
contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil && strings.HasPrefix(strings.ToLower(contentType), "multipart/") {
http.Error(w, "Could not read image upload.", http.StatusBadRequest)
return func() {}, false
}
if mediaType != "multipart/form-data" {
if err := r.ParseForm(); err != nil {
writePostImageRequestError(w, err)
return func() {}, false
}
return func() {}, true
}
if err := r.ParseMultipartForm(postImageMultipartMemory); err != nil {
writePostImageRequestError(w, err)
return func() {}, false
}
cleanup := func() {
if r.MultipartForm != nil {
_ = r.MultipartForm.RemoveAll()
}
}
return cleanup, true
}
func writePostImageRequestError(w http.ResponseWriter, err error) {
var requestErr *postImageRequestError
if errors.As(err, &requestErr) {
http.Error(w, requestErr.message, requestErr.status)
return
}
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
http.Error(w, "Image upload is too large.", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "Could not read image upload.", http.StatusBadRequest)
}
func (s *Server) postImagesFromForm(
ctx context.Context,
r *http.Request,
postID string,
existing []store.PostImage,
) ([]store.PostImage, []string, error) {
if r.MultipartForm == nil {
return append([]store.PostImage(nil), existing...), nil, nil
}
retained, err := retainedPostImages(r.MultipartForm, existing)
if err != nil {
return nil, nil, err
}
files := r.MultipartForm.File["images"]
descriptions := r.MultipartForm.Value["image_description"]
if len(descriptions) > len(files) {
return nil, nil, invalidPostImage("Image descriptions do not match selected images.", nil)
}
if len(retained)+len(files) > store.MaxPostImages {
return nil, nil, invalidPostImage("You can attach up to 4 images.", nil)
}
if len(files) > 0 && !s.cfg.Blob.Enabled() {
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Image uploads are not configured on this server.",
}
}
images := append([]store.PostImage(nil), retained...)
newKeys := make([]string, 0, len(files))
for i, header := range files {
description := ""
if i < len(descriptions) {
description = strings.TrimSpace(descriptions[i])
}
if len([]rune(description)) > store.MaxImageDescriptionRunes {
s.deletePostImageObjects(newKeys)
return nil, nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
prepared, err := preparePostImage(header)
if err != nil {
s.deletePostImageObjects(newKeys)
return nil, nil, err
}
imageID := uuid.NewString()
objectKey := path.Join("post-images", postID, imageID+prepared.extension)
publicURL, err := s.cfg.Blob.Upload(ctx, blob.FileUpload{
Key: objectKey,
Body: bytes.NewReader(prepared.body),
ContentType: prepared.contentType,
Size: int64(len(prepared.body)),
})
if err != nil {
s.deletePostImageObjects(newKeys)
return nil, nil, &postImageRequestError{
status: http.StatusServiceUnavailable,
message: "Could not upload image. Try again later.",
cause: err,
}
}
newKeys = append(newKeys, objectKey)
images = append(images, store.PostImage{
ID: imageID,
PostID: postID,
ObjectKey: objectKey,
PublicURL: publicURL,
Description: description,
Width: prepared.width,
Height: prepared.height,
})
}
return images, newKeys, nil
}
func retainedPostImages(form *multipart.Form, existing []store.PostImage) ([]store.PostImage, error) {
byID := make(map[string]store.PostImage, len(existing))
for _, image := range existing {
byID[image.ID] = image
}
ids := form.Value["existing_image_id"]
descriptions := form.Value["existing_image_description"]
if len(descriptions) > len(ids) {
return nil, invalidPostImage("Existing image descriptions do not match the images.", nil)
}
seen := make(map[string]bool, len(ids))
retained := make([]store.PostImage, 0, len(ids))
for i, id := range ids {
id = strings.TrimSpace(id)
image, ok := byID[id]
if !ok || seen[id] {
return nil, invalidPostImage("An existing image selection is invalid.", nil)
}
seen[id] = true
if i < len(descriptions) {
image.Description = strings.TrimSpace(descriptions[i])
}
if len([]rune(image.Description)) > store.MaxImageDescriptionRunes {
return nil, invalidPostImage("Image descriptions must be 500 characters or fewer.", nil)
}
retained = append(retained, image)
}
return retained, nil
}
func invalidPostImage(message string, cause error) error {
return &postImageRequestError{status: http.StatusBadRequest, message: message, cause: cause}
}
type preparedPostImage struct {
body []byte
extension string
contentType string
width int
height int
}
func preparePostImage(header *multipart.FileHeader) (preparedPostImage, error) {
if header == nil {
return preparedPostImage{}, invalidPostImage("Select a valid image.", nil)
}
if header.Size > postImageMaxFileBytes {
return preparedPostImage{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each image must be 5 MB or smaller.",
}
}
file, err := header.Open()
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not read image.", err)
}
defer file.Close()
raw, err := io.ReadAll(io.LimitReader(file, postImageMaxFileBytes+1))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not read image.", err)
}
if len(raw) == 0 {
return preparedPostImage{}, invalidPostImage("Images cannot be empty.", nil)
}
if int64(len(raw)) > postImageMaxFileBytes {
return preparedPostImage{}, &postImageRequestError{
status: http.StatusRequestEntityTooLarge,
message: "Each image must be 5 MB or smaller.",
}
}
sniffed := http.DetectContentType(raw)
switch sniffed {
case "image/jpeg", "image/png", "image/webp":
default:
return preparedPostImage{}, invalidPostImage("Images must be JPEG, PNG, or WebP.", nil)
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not decode image.", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 ||
cfg.Width > postImageMaxSourceDim || cfg.Height > postImageMaxSourceDim ||
int64(cfg.Width)*int64(cfg.Height) > postImageMaxSourcePixels {
return preparedPostImage{}, invalidPostImage("Image dimensions are too large.", nil)
}
decoded, decodedFormat, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return preparedPostImage{}, invalidPostImage("Could not decode image.", err)
}
if format != "" {
decodedFormat = format
}
if sniffed == "image/jpeg" {
decoded = orientPostImage(decoded, jpegOrientation(raw))
}
decoded = fitPostImage(decoded, postImageMaxRenderedDim)
var out bytes.Buffer
result := preparedPostImage{}
switch decodedFormat {
case "jpeg":
if err := jpeg.Encode(&out, decoded, &jpeg.Options{Quality: 85}); err != nil {
return preparedPostImage{}, invalidPostImage("Could not encode image.", err)
}
result.extension = ".jpg"
result.contentType = "image/jpeg"
case "png", "webp":
if err := png.Encode(&out, decoded); err != nil {
return preparedPostImage{}, invalidPostImage("Could not encode image.", err)
}
result.extension = ".png"
result.contentType = "image/png"
default:
return preparedPostImage{}, invalidPostImage("Images must be JPEG, PNG, or WebP.", nil)
}
result.body = out.Bytes()
result.width = decoded.Bounds().Dx()
result.height = decoded.Bounds().Dy()
return result, nil
}
func jpegOrientation(raw []byte) int {
metadata, err := exif.Decode(bytes.NewReader(raw))
if err != nil {
return 1
}
tag, err := metadata.Get(exif.Orientation)
if err != nil {
return 1
}
orientation, err := tag.Int(0)
if err != nil || orientation < 1 || orientation > 8 {
return 1
}
return orientation
}
func orientPostImage(source image.Image, orientation int) image.Image {
if orientation <= 1 || orientation > 8 {
return source
}
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
targetWidth, targetHeight := width, height
if orientation >= 5 {
targetWidth, targetHeight = height, width
}
target := image.NewNRGBA(image.Rect(0, 0, targetWidth, targetHeight))
for y := 0; y < targetHeight; y++ {
for x := 0; x < targetWidth; x++ {
sourceX, sourceY := x, y
switch orientation {
case 2:
sourceX = width - 1 - x
case 3:
sourceX, sourceY = width-1-x, height-1-y
case 4:
sourceY = height - 1 - y
case 5:
sourceX, sourceY = y, x
case 6:
sourceX, sourceY = y, height-1-x
case 7:
sourceX, sourceY = width-1-y, height-1-x
case 8:
sourceX, sourceY = width-1-y, x
}
target.Set(x, y, source.At(bounds.Min.X+sourceX, bounds.Min.Y+sourceY))
}
}
return target
}
func fitPostImage(source image.Image, maxDimension int) image.Image {
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= maxDimension && height <= maxDimension {
return source
}
scale := float64(maxDimension) / float64(width)
if float64(height)*scale > float64(maxDimension) {
scale = float64(maxDimension) / float64(height)
}
targetWidth := max(1, int(float64(width)*scale))
targetHeight := max(1, int(float64(height)*scale))
target := image.NewNRGBA(image.Rect(0, 0, targetWidth, targetHeight))
draw.CatmullRom.Scale(target, target.Bounds(), source, bounds, draw.Over, nil)
return target
}
func (s *Server) deletePostImageObjects(keys []string) {
if len(keys) == 0 {
return
}
ctx, cancel := context.WithTimeout(context.Background(), postImageCleanupTimeout)
defer cancel()
for _, key := range keys {
if err := s.cfg.Blob.Delete(ctx, key); err != nil {
log.Printf("delete post image %s: %v", key, err)
}
}
}
func removedPostImageKeys(before, after []store.PostImage) []string {
retained := make(map[string]bool, len(after))
for _, image := range after {
retained[image.ObjectKey] = true
}
var removed []string
for _, image := range before {
if !retained[image.ObjectKey] {
removed = append(removed, image.ObjectKey)
}
}
return removed
}