Skip to content

Commit da4959b

Browse files
committed
Merge feature/image-upload: product image uploads + WYSIWYG link
2 parents 00a372e + b8e21f3 commit da4959b

9 files changed

Lines changed: 326 additions & 17 deletions

File tree

cmd/shoplit-api/main.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/mayur-tolexo/shoplit/internal/ogfetch"
2828
"github.com/mayur-tolexo/shoplit/internal/publicapi"
2929
"github.com/mayur-tolexo/shoplit/internal/redis"
30+
"github.com/mayur-tolexo/shoplit/internal/uploads"
3031
)
3132

3233
func main() {
@@ -119,11 +120,20 @@ func run() error {
119120
r.Post("/feedback", fb.Handler())
120121
})
121122

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.
126+
if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil {
127+
slog.Warn("could not create upload dir", "dir", cfg.UploadDir, "err", err)
128+
}
129+
r.Handle("/uploads/*", http.StripPrefix("/uploads/", uploads.FileServer(cfg.UploadDir)))
130+
122131
// Authenticated creator endpoints
123132
r.Route("/api/v1", func(r chi.Router) {
124133
r.Use(sm.RequireUser())
125134
r.Post("/extension/token", exttoken.MintHandler(q))
126135
r.Get("/feedback", fb.ListHandler())
136+
r.Post("/uploads", uploads.Handler(cfg.UploadDir))
127137
carts.RegisterRoutes(r, svc, fetcher)
128138
})
129139

deploy/Caddyfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ www.{$SITE_DOMAIN} {
1919
reverse_proxy shoplit-api:8080
2020
}
2121

22+
# Uploaded product/cover images, served by the API from a persistent volume.
23+
handle /uploads/* {
24+
reverse_proxy shoplit-api:8080
25+
}
26+
2227
# Outbound click redirects / single-product short links.
2328
handle /go/* {
2429
reverse_proxy shoplit-redirect:8081

deploy/compose.prod.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ services:
7878
GOOGLE_OAUTH_REDIRECT_URL: ${PUBLIC_URL}/api/v1/auth/google/callback
7979
SHOPLIT_CORS_ORIGIN: ${PUBLIC_URL}
8080
SHOPLIT_FRONTEND_URL: ${PUBLIC_URL}
81+
SHOPLIT_UPLOAD_DIR: /data/uploads
82+
volumes:
83+
# Persist uploaded images across container rebuilds/redeploys.
84+
- uploads_data:/data/uploads
8185

8286
shoplit-redirect:
8387
image: shoplit-go:latest
@@ -138,3 +142,4 @@ volumes:
138142
redis_data:
139143
caddy_data:
140144
caddy_config:
145+
uploads_data:

internal/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ type Config struct {
6767
// After successful sign-in, the OAuth callback redirects the browser to
6868
// this URL on the frontend.
6969
FrontendURL string `env:"SHOPLIT_FRONTEND_URL" envDefault:"http://localhost:3000"`
70+
71+
// Directory where uploaded product/cover images are written and served
72+
// from (under /uploads). In prod this is a persistent Docker volume.
73+
UploadDir string `env:"SHOPLIT_UPLOAD_DIR" envDefault:"./uploads"`
7074
}
7175

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

internal/uploads/uploads.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Package uploads handles creator image uploads (product/cover photos) — most
2+
// importantly for mobile, where there's no easy way to paste an image URL.
3+
// Files are written to a configured directory and served back under /uploads.
4+
package uploads
5+
6+
import (
7+
"crypto/rand"
8+
"encoding/hex"
9+
"encoding/json"
10+
"io"
11+
"net/http"
12+
"os"
13+
"path/filepath"
14+
)
15+
16+
const (
17+
maxUploadBytes = 8 << 20 // 8 MiB — generous for a phone photo.
18+
sniffLen = 512 // bytes http.DetectContentType needs.
19+
)
20+
21+
// extByType maps the sniffed content type to a safe, server-chosen extension.
22+
// Only these image types are accepted; anything else is rejected. The user
23+
// never controls the stored filename or extension.
24+
var extByType = map[string]string{
25+
"image/jpeg": ".jpg",
26+
"image/png": ".png",
27+
"image/webp": ".webp",
28+
"image/gif": ".gif",
29+
}
30+
31+
// 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 {
35+
return func(w http.ResponseWriter, r *http.Request) {
36+
// Cap the whole request body before reading anything.
37+
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1024)
38+
if err := r.ParseMultipartForm(maxUploadBytes + 1024); err != nil {
39+
http.Error(w, "file too large or malformed (max 8MB)", http.StatusRequestEntityTooLarge)
40+
return
41+
}
42+
file, _, err := r.FormFile("file")
43+
if err != nil {
44+
http.Error(w, "missing file field", http.StatusBadRequest)
45+
return
46+
}
47+
defer file.Close()
48+
49+
// 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)]
54+
if !ok {
55+
http.Error(w, "only JPEG, PNG, WebP or GIF images are allowed", http.StatusUnsupportedMediaType)
56+
return
57+
}
58+
59+
name, err := randomName()
60+
if err != nil {
61+
http.Error(w, "could not generate name", http.StatusInternalServerError)
62+
return
63+
}
64+
name += ext
65+
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))
71+
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)
84+
return
85+
}
86+
87+
w.Header().Set("Content-Type", "application/json")
88+
_ = json.NewEncoder(w).Encode(map[string]string{"url": "/uploads/" + name})
89+
}
90+
}
91+
92+
func randomName() (string, error) {
93+
b := make([]byte, 16)
94+
if _, err := rand.Read(b); err != nil {
95+
return "", err
96+
}
97+
return hex.EncodeToString(b), nil
98+
}
99+
100+
// FileServer serves uploaded images from dir for public GETs, but never lists
101+
// directory contents (so the upload directory can't be enumerated) and sends
102+
// X-Content-Type-Options: nosniff so a file is never interpreted as anything
103+
// other than its declared type.
104+
func FileServer(dir string) http.Handler {
105+
fs := http.FileServer(noListFS{http.Dir(dir)})
106+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107+
w.Header().Set("X-Content-Type-Options", "nosniff")
108+
fs.ServeHTTP(w, r)
109+
})
110+
}
111+
112+
// noListFS wraps an http.FileSystem and reports directories as "not found",
113+
// which makes http.FileServer 404 instead of rendering a directory listing.
114+
type noListFS struct{ fs http.FileSystem }
115+
116+
func (n noListFS) Open(name string) (http.File, error) {
117+
f, err := n.fs.Open(name)
118+
if err != nil {
119+
return nil, err
120+
}
121+
info, err := f.Stat()
122+
if err != nil {
123+
f.Close()
124+
return nil, err
125+
}
126+
if info.IsDir() {
127+
f.Close()
128+
return nil, os.ErrNotExist
129+
}
130+
return f, nil
131+
}

internal/uploads/uploads_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package uploads_test
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"image"
7+
"image/png"
8+
"mime/multipart"
9+
"net/http"
10+
"net/http/httptest"
11+
"os"
12+
"strings"
13+
"testing"
14+
15+
"github.com/mayur-tolexo/shoplit/internal/uploads"
16+
)
17+
18+
func multipartBody(t *testing.T, field, filename string, data []byte) (*bytes.Buffer, string) {
19+
t.Helper()
20+
var buf bytes.Buffer
21+
w := multipart.NewWriter(&buf)
22+
fw, err := w.CreateFormFile(field, filename)
23+
if err != nil {
24+
t.Fatal(err)
25+
}
26+
if _, err := fw.Write(data); err != nil {
27+
t.Fatal(err)
28+
}
29+
w.Close()
30+
return &buf, w.FormDataContentType()
31+
}
32+
33+
func pngBytes(t *testing.T) []byte {
34+
t.Helper()
35+
var buf bytes.Buffer
36+
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil {
37+
t.Fatal(err)
38+
}
39+
return buf.Bytes()
40+
}
41+
42+
func TestUpload_StoresImageAndReturnsURL(t *testing.T) {
43+
dir := t.TempDir()
44+
body, ct := multipartBody(t, "file", "photo.png", pngBytes(t))
45+
req := httptest.NewRequest(http.MethodPost, "/api/v1/uploads", body)
46+
req.Header.Set("Content-Type", ct)
47+
rr := httptest.NewRecorder()
48+
49+
uploads.Handler(dir).ServeHTTP(rr, req)
50+
51+
if rr.Code != http.StatusOK {
52+
t.Fatalf("want 200, got %d (%s)", rr.Code, rr.Body.String())
53+
}
54+
var resp struct{ URL string }
55+
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
56+
t.Fatal(err)
57+
}
58+
if !strings.HasPrefix(resp.URL, "/uploads/") || !strings.HasSuffix(resp.URL, ".png") {
59+
t.Fatalf("unexpected url %q", resp.URL)
60+
}
61+
// The file actually exists on disk.
62+
name := strings.TrimPrefix(resp.URL, "/uploads/")
63+
if _, err := os.Stat(dir + "/" + name); err != nil {
64+
t.Fatalf("file not written: %v", err)
65+
}
66+
}
67+
68+
func TestUpload_RejectsNonImage(t *testing.T) {
69+
dir := t.TempDir()
70+
body, ct := multipartBody(t, "file", "evil.html", []byte("<html><script>alert(1)</script></html>"))
71+
req := httptest.NewRequest(http.MethodPost, "/api/v1/uploads", body)
72+
req.Header.Set("Content-Type", ct)
73+
rr := httptest.NewRecorder()
74+
75+
uploads.Handler(dir).ServeHTTP(rr, req)
76+
77+
if rr.Code != http.StatusUnsupportedMediaType {
78+
t.Fatalf("want 415 for non-image, got %d", rr.Code)
79+
}
80+
}
81+
82+
func TestUpload_MissingFile(t *testing.T) {
83+
dir := t.TempDir()
84+
var buf bytes.Buffer
85+
w := multipart.NewWriter(&buf)
86+
w.Close()
87+
req := httptest.NewRequest(http.MethodPost, "/api/v1/uploads", &buf)
88+
req.Header.Set("Content-Type", w.FormDataContentType())
89+
rr := httptest.NewRecorder()
90+
91+
uploads.Handler(dir).ServeHTTP(rr, req)
92+
93+
if rr.Code != http.StatusBadRequest {
94+
t.Fatalf("want 400 for missing file, got %d", rr.Code)
95+
}
96+
}

web/app/add/add-form.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useEffect, useState } from "react";
44
import Link from "next/link";
55
import { toast } from "sonner";
6-
import { addSharedItem, createCart } from "@/lib/api-client";
6+
import { addSharedItem, createCart, uploadImage } from "@/lib/api-client";
77
import { parseShare, type ParsedShare } from "@/lib/parse-share";
88

99
type CartOpt = { id: string; title: string; slug: string };
@@ -27,6 +27,7 @@ export function AddForm({ carts, initial }: { carts: CartOpt[]; initial: ParsedS
2727
const [note, setNote] = useState("");
2828
const [busy, setBusy] = useState(false);
2929
const [cartBusy, setCartBusy] = useState(false);
30+
const [uploading, setUploading] = useState(false);
3031
const [added, setAdded] = useState<CartOpt | null>(null);
3132
const [newCartTitle, setNewCartTitle] = useState("");
3233

@@ -37,6 +38,20 @@ export function AddForm({ carts, initial }: { carts: CartOpt[]; initial: ParsedS
3738
if (p.priceText) setPriceText(p.priceText);
3839
};
3940

41+
const onPickPhoto = async (e: React.ChangeEvent<HTMLInputElement>) => {
42+
const file = e.target.files?.[0];
43+
e.target.value = ""; // allow re-picking the same file
44+
if (!file) return;
45+
setUploading(true);
46+
try {
47+
setImageUrl(await uploadImage(file));
48+
} catch {
49+
toast.error("Couldn't upload that photo — try a smaller image.");
50+
} finally {
51+
setUploading(false);
52+
}
53+
};
54+
4055
const submit = async () => {
4156
if (!cartId) return toast.error("Pick a cart first.");
4257
if (!title.trim()) return toast.error("Add a title.");
@@ -146,10 +161,22 @@ export function AddForm({ carts, initial }: { carts: CartOpt[]; initial: ParsedS
146161
<input value={originalUrl} onChange={(e) => setOriginalUrl(e.target.value)} className={inputCls} />
147162
</label>
148163

149-
<label className="block">
150-
<span className="block text-sm font-medium mb-1">Image URL <span className="text-muted font-normal">(optional)</span></span>
151-
<input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="Leave blank for a placeholder" className={inputCls} />
152-
</label>
164+
<div>
165+
<span className="block text-sm font-medium mb-1">Photo <span className="text-muted font-normal">(optional)</span></span>
166+
<div className="flex items-center gap-3">
167+
{imageUrl ? (
168+
// eslint-disable-next-line @next/next/no-img-element
169+
<img src={imageUrl} alt="" className="w-16 h-16 rounded-md object-cover border border-rule" />
170+
) : (
171+
<div className="w-16 h-16 rounded-md border border-rule bg-paper grid place-items-center text-[10px] text-muted text-center">no photo</div>
172+
)}
173+
<label className="rounded-full border border-ink px-4 py-2 text-sm font-medium cursor-pointer hover:bg-paper">
174+
{uploading ? "Uploading…" : "📷 Add a photo"}
175+
<input type="file" accept="image/*" className="hidden" disabled={uploading} onChange={onPickPhoto} />
176+
</label>
177+
</div>
178+
<input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="…or paste an image URL" className={`${inputCls} mt-2 text-sm`} />
179+
</div>
153180

154181
<label className="block">
155182
<span className="block text-sm font-medium mb-1">Note <span className="text-muted font-normal">(optional)</span></span>

0 commit comments

Comments
 (0)