Files
thomas f8792f8db8
All checks were successful
Deploy API / deploy (push) Successful in 35s
1
2026-05-26 09:01:25 +08:00

176 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"bytes"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"os"
"path/filepath"
"strings"
_ "golang.org/x/image/webp"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/google/uuid"
)
const uploadMaxBytes = 512 << 20 // 512 MiB upper bound per upload
// UploadDeps configures admin multipart upload (local disk and/or S3).
type UploadDeps struct {
LocalDir string
MaxMultipartMem int64
S3 *s3.Client
S3Bucket string
AWSRegion string
S3Prefix string // e.g. "uploads" (no leading/trailing slashes)
S3PublicBase string // optional, e.g. https://cdn.example.com — else virtual-hosted S3 URL
S3ObjectACL string // optional canned ACL, e.g. public-read (bucket must allow ACLs)
}
func UploadFile(d UploadDeps) http.HandlerFunc {
maxMem := d.MaxMultipartMem
if maxMem <= 0 {
maxMem = 32 << 20
}
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(maxMem); err != nil {
http.Error(w, "multipart required", http.StatusBadRequest)
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
http.Error(w, "file field required", http.StatusBadRequest)
return
}
defer file.Close()
ext := filepath.Ext(hdr.Filename)
if ext == "" {
ext = ".bin"
}
name := uuid.NewString() + ext
data, err := io.ReadAll(io.LimitReader(file, uploadMaxBytes+1))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(data) > uploadMaxBytes {
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
return
}
ct := hdr.Header.Get("Content-Type")
if ct == "" {
ct = http.DetectContentType(data)
}
if d.S3 != nil && strings.TrimSpace(d.S3Bucket) != "" {
pfx := strings.Trim(strings.TrimSpace(d.S3Prefix), "/")
if pfx == "" {
pfx = "uploads"
}
key := pfx + "/" + name
ctx := r.Context()
put := &s3.PutObjectInput{
Bucket: aws.String(d.S3Bucket),
Key: aws.String(key),
Body: bytes.NewReader(data),
ContentType: aws.String(ct),
}
if acl, ok := s3PutObjectCannedACL(d.S3ObjectACL); ok {
put.ACL = acl
}
_, err := d.S3.PutObject(ctx, put)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
pub := publicObjectURL(d.S3PublicBase, d.S3Bucket, d.AWSRegion, key)
writeUploadJSON(w, r, pub, hdr.Filename, name, ct, int64(len(data)), data, "s3")
return
}
dst := filepath.Join(d.LocalDir, name)
if err := os.MkdirAll(d.LocalDir, 0o755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
out, err := os.Create(dst)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer out.Close()
if _, err := out.Write(data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeUploadJSON(w, r, "/uploads/"+name, hdr.Filename, name, ct, int64(len(data)), data, "local")
}
}
func writeUploadJSON(w http.ResponseWriter, r *http.Request, url, originalName, storedName, mime string, size int64, data []byte, storage string) {
kind := classifyAttachmentKind(mime, originalName)
if kind == "" {
kind = classifyAttachmentKind(mime, storedName)
}
if k := strings.TrimSpace(r.FormValue("kind")); k == "image" || k == "document" || k == "video" {
kind = k
}
out := map[string]any{
"url": url,
"filename": storedName,
"storage": storage,
"mime": mime,
"sizeBytes": size,
"kind": kind,
}
if strings.HasPrefix(strings.ToLower(mime), "image/") {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err == nil {
out["width"] = cfg.Width
out["height"] = cfg.Height
}
}
writeJSON(w, out)
}
// s3PutObjectCannedACL maps env S3_OBJECT_ACL to SDK enum; unknown values are ignored.
func s3PutObjectCannedACL(raw string) (types.ObjectCannedACL, bool) {
switch strings.TrimSpace(strings.ToLower(raw)) {
case "private":
return types.ObjectCannedACLPrivate, true
case "public-read":
return types.ObjectCannedACLPublicRead, true
case "public-read-write":
return types.ObjectCannedACLPublicReadWrite, true
case "authenticated-read":
return types.ObjectCannedACLAuthenticatedRead, true
case "bucket-owner-full-control":
return types.ObjectCannedACLBucketOwnerFullControl, true
case "bucket-owner-read":
return types.ObjectCannedACLBucketOwnerRead, true
case "aws-exec-read":
return types.ObjectCannedACLAwsExecRead, true
default:
return "", false
}
}
func publicObjectURL(base, bucket, region, key string) string {
base = strings.TrimSpace(base)
if base != "" {
return strings.TrimSuffix(base, "/") + "/" + key
}
// Virtual-hostedstyle URL (works for most buckets; use S3_PUBLIC_BASE_URL if not).
return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", bucket, region, key)
}