Skip to content

Commit 0fd566f

Browse files
committed
Merge feature/s3-uploads: S3 image uploads + consistent photo picker
2 parents 34360a2 + c05700f commit 0fd566f

15 files changed

Lines changed: 321 additions & 83 deletions

File tree

cmd/shoplit-api/main.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,19 @@ func run() error {
120120
r.Post("/feedback", fb.Handler())
121121
})
122122

123-
// Public, read-only serving of uploaded images. Files are written by the
124-
// authenticated upload endpoint below; here anyone can fetch them so they
125-
// render on public cart pages.
123+
// Image upload store: S3 in prod (when a bucket is configured), local disk
124+
// otherwise (dev). The disk file-server is always mounted so dev URLs work;
125+
// in prod the returned URLs point at S3 and this route simply goes unused.
126+
var imageStore uploads.Store = uploads.NewDiskStore(cfg.UploadDir)
127+
if cfg.S3Bucket != "" {
128+
s3store, err := uploads.NewS3Store(ctx, cfg.S3Bucket, cfg.AWSRegion)
129+
if err != nil {
130+
slog.Error("S3 store init failed; falling back to disk", "err", err)
131+
} else {
132+
imageStore = s3store
133+
slog.Info("image uploads → S3", "bucket", cfg.S3Bucket, "region", cfg.AWSRegion)
134+
}
135+
}
126136
if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil {
127137
slog.Warn("could not create upload dir", "dir", cfg.UploadDir, "err", err)
128138
}
@@ -133,7 +143,7 @@ func run() error {
133143
r.Use(sm.RequireUser())
134144
r.Post("/extension/token", exttoken.MintHandler(q))
135145
r.Get("/feedback", fb.ListHandler())
136-
r.Post("/uploads", uploads.Handler(cfg.UploadDir))
146+
r.Post("/uploads", uploads.Handler(imageStore))
137147
carts.RegisterRoutes(r, svc, fetcher)
138148
})
139149

deploy/compose.prod.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,12 @@ services:
7979
SHOPLIT_CORS_ORIGIN: ${PUBLIC_URL}
8080
SHOPLIT_FRONTEND_URL: ${PUBLIC_URL}
8181
SHOPLIT_UPLOAD_DIR: /data/uploads
82+
# When set, uploads go to S3 instead of the local volume. Credentials come
83+
# from the EC2 instance role (no keys). Empty → local-disk fallback.
84+
SHOPLIT_S3_BUCKET: ${SHOPLIT_S3_BUCKET:-}
85+
AWS_REGION: ${AWS_REGION:-ap-southeast-2}
8286
volumes:
83-
# Persist uploaded images across container rebuilds/redeploys.
87+
# Persist uploaded images across container rebuilds/redeploys (disk mode).
8488
- uploads_data:/data/uploads
8589

8690
shoplit-redirect:
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# S3 image uploads — setup runbook
2+
3+
Region: **ap-southeast-2 (Sydney)**. Auth: **EC2 instance role** (no access keys).
4+
Goal: creator photo uploads go to S3 and render via the public object URL.
5+
6+
## 1. Create the bucket
7+
S3 console (top-right region = **ap-southeast-2**) → **Create bucket**.
8+
- **Name:** `shoplit-uploads-prod` (must be globally unique — add a suffix if taken).
9+
- **Region:** Asia Pacific (Sydney) ap-southeast-2.
10+
- Leave the rest default for now → **Create bucket**.
11+
12+
## 2. Make uploaded objects publicly readable
13+
Product images are public, so allow read-only GET.
14+
1. Bucket → **Permissions****Block public access (bucket settings)****Edit**
15+
uncheck **Block all public access** → Save → type `confirm`.
16+
2. Same tab → **Bucket policy****Edit** → paste (replace the name if you changed it):
17+
```json
18+
{
19+
"Version": "2012-10-17",
20+
"Statement": [{
21+
"Sid": "PublicReadGetObject",
22+
"Effect": "Allow",
23+
"Principal": "*",
24+
"Action": "s3:GetObject",
25+
"Resource": "arn:aws:s3:::shoplit-uploads-prod/*"
26+
}]
27+
}
28+
```
29+
→ Save. (This grants read on objects only; nobody can write or list.)
30+
31+
## 3. Let the EC2 instance upload (instance role)
32+
1. EC2 console → **Instances** → your instance → **Security** tab → look at **IAM Role**.
33+
- If a role is attached, note its name.
34+
- If **none**: IAM → **Roles****Create role** → trusted entity **AWS service**
35+
use case **EC2** → Next → Next → name `shoplit-ec2-role` → Create. Then back in
36+
EC2 → select instance → **Actions → Security → Modify IAM role** → pick
37+
`shoplit-ec2-role` → Update.
38+
2. IAM → **Roles** → open that role → **Add permissions → Create inline policy**
39+
**JSON** → paste:
40+
```json
41+
{
42+
"Version": "2012-10-17",
43+
"Statement": [{
44+
"Effect": "Allow",
45+
"Action": ["s3:PutObject"],
46+
"Resource": "arn:aws:s3:::shoplit-uploads-prod/*"
47+
}]
48+
}
49+
```
50+
→ Next → name `shoplit-s3-put` → Create policy.
51+
52+
No CORS configuration is needed (the server does the upload; `<img>` rendering doesn't require CORS).
53+
54+
## 4. Hand back to me
55+
Send the **bucket name**. I set on the server (`deploy/.env`) and redeploy the API:
56+
```
57+
SHOPLIT_S3_BUCKET=shoplit-uploads-prod
58+
AWS_REGION=ap-southeast-2
59+
```
60+
When `SHOPLIT_S3_BUCKET` is set, the upload handler uses S3; otherwise it falls back
61+
to local disk (dev). URLs returned look like
62+
`https://shoplit-uploads-prod.s3.ap-southeast-2.amazonaws.com/<random>.jpg`.
63+
64+
## 5. (Optional) verify the role works, from the EC2 box
65+
```
66+
aws sts get-caller-identity # shows the assumed role
67+
echo hi > /tmp/t.txt && aws s3 cp /tmp/t.txt s3://shoplit-uploads-prod/t.txt
68+
curl -I https://shoplit-uploads-prod.s3.ap-southeast-2.amazonaws.com/t.txt # expect 200
69+
aws s3 rm s3://shoplit-uploads-prod/t.txt
70+
```
71+
(If the AWS CLI isn't installed, skip — the app's SDK uses the same role.)

go.mod

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ go 1.26.2
55
require (
66
github.com/PuerkitoBio/goquery v1.12.0
77
github.com/alicebob/miniredis/v2 v2.38.0
8+
github.com/aws/aws-sdk-go-v2 v1.41.7
9+
github.com/aws/aws-sdk-go-v2/config v1.32.18
10+
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
811
github.com/caarlos0/env/v11 v11.4.1
912
github.com/go-chi/chi/v5 v5.3.0
1013
github.com/golang-migrate/migrate/v4 v4.19.1
@@ -22,6 +25,21 @@ require (
2225
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
2326
github.com/Microsoft/go-winio v0.6.2 // indirect
2427
github.com/andybalholm/cascadia v1.3.3 // indirect
28+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
29+
github.com/aws/aws-sdk-go-v2/credentials v1.19.17 // indirect
30+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
31+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
32+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
33+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
34+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
35+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
36+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
37+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
38+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
39+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
40+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 // indirect
41+
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
42+
github.com/aws/smithy-go v1.25.1 // indirect
2543
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
2644
github.com/cespare/xxhash/v2 v2.3.0 // indirect
2745
github.com/containerd/errdefs v1.0.0 // indirect

go.sum

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
2-
cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
31
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
42
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
53
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
@@ -14,6 +12,42 @@ github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e
1412
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
1513
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
1614
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
15+
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
16+
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
17+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
18+
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
19+
github.com/aws/aws-sdk-go-v2/config v1.32.18 h1:Hcia46bxhGgF3BaSnG8nSNCWmqTK6bj9xN9/FJ3WK6Q=
20+
github.com/aws/aws-sdk-go-v2/config v1.32.18/go.mod h1:zEjCAYmxqDadH1WX8CdBvmLKhUEUVFgKRQG38zjDmrY=
21+
github.com/aws/aws-sdk-go-v2/credentials v1.19.17 h1:gP2nkGsS+KMvF/jfFz2Vv2qiiOqWKyPACSzPsqHgoW8=
22+
github.com/aws/aws-sdk-go-v2/credentials v1.19.17/go.mod h1:Bsew3S/moG5iT77giPj1q8wb/s0RE5/QfH+ASjYtuQc=
23+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
24+
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
25+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
26+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
27+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
28+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
29+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
30+
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
31+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
32+
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
33+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
34+
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
35+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
36+
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
37+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
38+
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
39+
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
40+
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
41+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
42+
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
43+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
44+
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
45+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0 h1:nDARhv/oF55bcxF7rCI/4PDxOKnVXVWwDuDwCs2I2SQ=
46+
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.0/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
47+
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
48+
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
49+
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
50+
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
1751
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
1852
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
1953
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=

internal/config/config.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,14 @@ type Config struct {
6969
FrontendURL string `env:"SHOPLIT_FRONTEND_URL" envDefault:"http://localhost:3000"`
7070

7171
// Directory where uploaded product/cover images are written and served
72-
// from (under /uploads). In prod this is a persistent Docker volume.
72+
// from (under /uploads) when S3 is not configured (local dev fallback).
7373
UploadDir string `env:"SHOPLIT_UPLOAD_DIR" envDefault:"./uploads"`
74+
75+
// When set, uploaded images go to this S3 bucket (served via its public
76+
// object URL) instead of local disk. AWSRegion is the bucket's region;
77+
// credentials come from the default AWS chain (EC2 instance role in prod).
78+
S3Bucket string `env:"SHOPLIT_S3_BUCKET"`
79+
AWSRegion string `env:"AWS_REGION" envDefault:"ap-southeast-2"`
7480
}
7581

7682
// GoogleOAuthConfigured returns true only when both client id and secret are set.

internal/uploads/store.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package uploads
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
10+
"github.com/aws/aws-sdk-go-v2/aws"
11+
awsconfig "github.com/aws/aws-sdk-go-v2/config"
12+
"github.com/aws/aws-sdk-go-v2/service/s3"
13+
)
14+
15+
// Store persists an uploaded image and returns a URL that renders it.
16+
type Store interface {
17+
Put(ctx context.Context, name, contentType string, data []byte) (string, error)
18+
}
19+
20+
// DiskStore writes files under a directory; they're served back at /uploads/<name>.
21+
// Used in local dev (no S3). Returns a same-origin relative URL.
22+
type DiskStore struct{ dir string }
23+
24+
func NewDiskStore(dir string) *DiskStore { return &DiskStore{dir: dir} }
25+
26+
func (d *DiskStore) Put(_ context.Context, name, _ string, data []byte) (string, error) {
27+
if err := os.MkdirAll(d.dir, 0o755); err != nil {
28+
return "", err
29+
}
30+
if err := os.WriteFile(filepath.Join(d.dir, name), data, 0o644); err != nil {
31+
return "", err
32+
}
33+
return "/uploads/" + name, nil
34+
}
35+
36+
// S3Store uploads to an S3 bucket and returns the public object URL. Credentials
37+
// come from the default AWS chain (EC2 instance role in prod). The bucket is
38+
// expected to allow public GetObject so the image renders directly.
39+
type S3Store struct {
40+
client *s3.Client
41+
bucket string
42+
region string
43+
}
44+
45+
// NewS3Store builds an S3-backed store. Region defaults via the AWS chain if empty.
46+
func NewS3Store(ctx context.Context, bucket, region string) (*S3Store, error) {
47+
opts := []func(*awsconfig.LoadOptions) error{}
48+
if region != "" {
49+
opts = append(opts, awsconfig.WithRegion(region))
50+
}
51+
cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
52+
if err != nil {
53+
return nil, fmt.Errorf("load aws config: %w", err)
54+
}
55+
return &S3Store{client: s3.NewFromConfig(cfg), bucket: bucket, region: cfg.Region}, nil
56+
}
57+
58+
func (s *S3Store) Put(ctx context.Context, name, contentType string, data []byte) (string, error) {
59+
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
60+
Bucket: aws.String(s.bucket),
61+
Key: aws.String(name),
62+
Body: bytes.NewReader(data),
63+
ContentType: aws.String(contentType),
64+
ContentLength: aws.Int64(int64(len(data))),
65+
// No ACL: buckets default to "ACLs disabled" (bucket-owner-enforced);
66+
// public read is granted by the bucket policy, not per-object ACLs.
67+
})
68+
if err != nil {
69+
return "", fmt.Errorf("s3 put: %w", err)
70+
}
71+
return fmt.Sprintf("https://%s.s3.%s.amazonaws.com/%s", s.bucket, s.region, name), nil
72+
}

internal/uploads/uploads.go

Lines changed: 25 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@ import (
1010
"io"
1111
"net/http"
1212
"os"
13-
"path/filepath"
1413
)
1514

1615
const (
17-
maxUploadBytes = 8 << 20 // 8 MiB — generous for a phone photo.
18-
sniffLen = 512 // bytes http.DetectContentType needs.
16+
maxUploadBytes = 15 << 20 // 15 MiB — generous for a full-res phone photo.
17+
sniffLen = 512 // bytes http.DetectContentType needs.
1918
)
2019

2120
// extByType maps the sniffed content type to a safe, server-chosen extension.
@@ -29,14 +28,14 @@ var extByType = map[string]string{
2928
}
3029

3130
// Handler returns an http.HandlerFunc that accepts a multipart "file" field,
32-
// validates it is an allowed image, stores it under dir with a random name,
33-
// and responds {"url":"/uploads/<name>"}. Mount it behind auth.
34-
func Handler(dir string) http.HandlerFunc {
31+
// validates it is an allowed image, hands it to the Store with a random name,
32+
// and responds {"url":"<public url>"}. Mount it behind auth.
33+
func Handler(store Store) http.HandlerFunc {
3534
return func(w http.ResponseWriter, r *http.Request) {
3635
// Cap the whole request body before reading anything.
3736
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1024)
3837
if err := r.ParseMultipartForm(maxUploadBytes + 1024); err != nil {
39-
http.Error(w, "file too large or malformed (max 8MB)", http.StatusRequestEntityTooLarge)
38+
http.Error(w, "file too large or malformed (max 15MB)", http.StatusRequestEntityTooLarge)
4039
return
4140
}
4241
file, _, err := r.FormFile("file")
@@ -46,11 +45,23 @@ func Handler(dir string) http.HandlerFunc {
4645
}
4746
defer file.Close()
4847

48+
data, err := io.ReadAll(io.LimitReader(file, maxUploadBytes+1))
49+
if err != nil {
50+
http.Error(w, "read failed", http.StatusInternalServerError)
51+
return
52+
}
53+
if len(data) > maxUploadBytes {
54+
http.Error(w, "image too large (max 15MB)", http.StatusRequestEntityTooLarge)
55+
return
56+
}
57+
4958
// Sniff the real content type from the bytes, not the client's claim.
50-
head := make([]byte, sniffLen)
51-
n, _ := io.ReadFull(file, head)
52-
head = head[:n]
53-
ext, ok := extByType[http.DetectContentType(head)]
59+
sniff := data
60+
if len(sniff) > sniffLen {
61+
sniff = sniff[:sniffLen]
62+
}
63+
contentType := http.DetectContentType(sniff)
64+
ext, ok := extByType[contentType]
5465
if !ok {
5566
http.Error(w, "only JPEG, PNG, WebP or GIF images are allowed", http.StatusUnsupportedMediaType)
5667
return
@@ -63,29 +74,14 @@ func Handler(dir string) http.HandlerFunc {
6374
}
6475
name += ext
6576

66-
if err := os.MkdirAll(dir, 0o755); err != nil {
67-
http.Error(w, "storage unavailable", http.StatusInternalServerError)
68-
return
69-
}
70-
dst, err := os.Create(filepath.Join(dir, name))
77+
url, err := store.Put(r.Context(), name, contentType, data)
7178
if err != nil {
72-
http.Error(w, "could not store file", http.StatusInternalServerError)
73-
return
74-
}
75-
defer dst.Close()
76-
77-
// Write the sniffed head, then the rest, bounded again as defence in depth.
78-
if _, err := dst.Write(head); err != nil {
79-
http.Error(w, "write failed", http.StatusInternalServerError)
80-
return
81-
}
82-
if _, err := io.Copy(dst, io.LimitReader(file, maxUploadBytes)); err != nil {
83-
http.Error(w, "write failed", http.StatusInternalServerError)
79+
http.Error(w, "could not store image", http.StatusInternalServerError)
8480
return
8581
}
8682

8783
w.Header().Set("Content-Type", "application/json")
88-
_ = json.NewEncoder(w).Encode(map[string]string{"url": "/uploads/" + name})
84+
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
8985
}
9086
}
9187

0 commit comments

Comments
 (0)