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 }