396 lines
11 KiB
Go
396 lines
11 KiB
Go
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
|
|
}
|