Skip to content

Commit 0efccdf

Browse files
committed
optimise media uploads
1 parent 3903284 commit 0efccdf

19 files changed

Lines changed: 1430 additions & 176 deletions

File tree

e2e/media.spec.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { execFileSync } from "node:child_process";
2+
import { createClient } from "@supabase/supabase-js";
3+
import { expect, test } from "@playwright/test";
4+
import { HOST_EMAIL, signIn } from "./helpers";
5+
6+
const BUSINESS_LISTING_SLUG = "demo-inner-west-cafe";
7+
const BUSINESS_LISTING_EDIT_PATH = `/profile/listings/${BUSINESS_LISTING_SLUG}`;
8+
9+
function parseLocalSupabaseEnv() {
10+
const output = execFileSync("supabase", ["status", "-o", "env"], {
11+
encoding: "utf8",
12+
});
13+
14+
return output
15+
.split("\n")
16+
.map((line) => line.trim())
17+
.filter(Boolean)
18+
.reduce<Record<string, string>>((env, line) => {
19+
const separatorIndex = line.indexOf("=");
20+
if (separatorIndex === -1) return env;
21+
22+
const key = line.slice(0, separatorIndex);
23+
const value = line.slice(separatorIndex + 1).replace(/^"(.*)"$/, "$1");
24+
env[key] = value;
25+
return env;
26+
}, {});
27+
}
28+
29+
function createAdminClient() {
30+
const env = parseLocalSupabaseEnv();
31+
32+
return createClient(env.API_URL, env.SERVICE_ROLE_KEY, {
33+
auth: { autoRefreshToken: false, persistSession: false },
34+
});
35+
}
36+
37+
test("public listing media URLs still work without exposing bucket listings", async ({
38+
page,
39+
}) => {
40+
const env = parseLocalSupabaseEnv();
41+
const publicResponse = await page.request.get(
42+
`${env.API_URL}/storage/v1/object/public/listing_photos/demo/garden.jpg`
43+
);
44+
45+
expect(publicResponse.ok()).toBe(true);
46+
47+
const listResponse = await page.request.post(
48+
`${env.API_URL}/storage/v1/object/list/listing_photos`,
49+
{
50+
data: { limit: 10, prefix: "" },
51+
headers: {
52+
apikey: env.ANON_KEY,
53+
Authorization: `Bearer ${env.ANON_KEY}`,
54+
},
55+
}
56+
);
57+
const listedObjects = (await listResponse.json()) as Array<{ name: string }>;
58+
59+
expect(listResponse.ok()).toBe(true);
60+
expect(listedObjects).toEqual([]);
61+
});
62+
63+
test("listing photo uploads are normalised to JPEG and removable", async ({
64+
page,
65+
}) => {
66+
const admin = createAdminClient();
67+
let uploadedFilename: string | null = null;
68+
69+
await signIn(page, {
70+
email: HOST_EMAIL,
71+
redirectTo: BUSINESS_LISTING_EDIT_PATH,
72+
});
73+
74+
try {
75+
const uploadResult = await page.evaluate(async () => {
76+
const canvas = document.createElement("canvas");
77+
canvas.width = 20;
78+
canvas.height = 20;
79+
const context = canvas.getContext("2d");
80+
81+
if (!context) {
82+
throw new Error("Could not create canvas context");
83+
}
84+
85+
context.fillStyle = "#155b4a";
86+
context.fillRect(0, 0, 20, 20);
87+
88+
const blob = await new Promise<Blob>((resolve, reject) => {
89+
canvas.toBlob((result) => {
90+
if (result) resolve(result);
91+
else reject(new Error("Could not create test image"));
92+
}, "image/png");
93+
});
94+
const formData = new FormData();
95+
formData.append("kind", "listing_photo");
96+
formData.append("entityId", "demo-inner-west-cafe");
97+
formData.append(
98+
"file",
99+
new File([blob], "upload-route-test.png", { type: "image/png" })
100+
);
101+
102+
const response = await fetch("/api/media/upload", {
103+
body: formData,
104+
method: "POST",
105+
});
106+
const data = await response.json();
107+
108+
return {
109+
data,
110+
ok: response.ok,
111+
status: response.status,
112+
};
113+
});
114+
115+
expect(uploadResult.ok).toBe(true);
116+
expect(uploadResult.data.contentType).toBe("image/jpeg");
117+
expect(uploadResult.data.filename).toMatch(/\.jpg$/);
118+
uploadedFilename = uploadResult.data.filename;
119+
120+
const { data: listing, error } = await admin
121+
.from("listings")
122+
.select("photos")
123+
.eq("slug", BUSINESS_LISTING_SLUG)
124+
.single();
125+
126+
expect(error).toBeNull();
127+
expect(listing?.photos).toContain(uploadedFilename);
128+
} finally {
129+
if (uploadedFilename) {
130+
const { data: listing } = await admin
131+
.from("listings")
132+
.select("photos")
133+
.eq("slug", BUSINESS_LISTING_SLUG)
134+
.single();
135+
const photos = (listing?.photos ?? []).filter(
136+
(photo: string) => photo !== uploadedFilename
137+
);
138+
139+
await admin
140+
.from("listings")
141+
.update({ photos })
142+
.eq("slug", BUSINESS_LISTING_SLUG);
143+
await admin.storage.from("listing_photos").remove([uploadedFilename]);
144+
}
145+
}
146+
});

next.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ const nextConfig = {
6868
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
6969
// Allowed list of image formats, hosts
7070
images: {
71+
formats: ["image/avif", "image/webp"],
7172
// Increase expiration (Max Age) of cache
7273
// https://vercel.com/docs/image-optimization#remote-images-cache-key
7374
// https://vercel.com/docs/image-optimization/managing-image-optimization-costs

package-lock.json

Lines changed: 8 additions & 33 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"supabase:env": "supabase status -o env",
1212
"supabase:reset": "supabase db reset",
1313
"seed:local-media": "node scripts/seed-local-media.mjs",
14+
"media:audit": "node scripts/audit-user-media.mjs",
1415
"supabase:diff": "supabase db diff",
1516
"i18n:check": "node scripts/check-i18n-messages.mjs",
1617
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
@@ -40,10 +41,10 @@
4041
"@types/mdx": "^2.0.13",
4142
"@vercel/analytics": "^2.0.1",
4243
"@vercel/speed-insights": "^2.0.0",
43-
"compressorjs": "^1.3.0",
4444
"deepmerge": "^4.3.1",
4545
"fakeout": "^1.0.23",
4646
"feed": "^5.2.1",
47+
"heic2any": "^0.0.4",
4748
"lucide-react": "^1.14.0",
4849
"mapbox-gl": "^3.23.1",
4950
"maplibre-gl": "^5.24.0",
@@ -54,6 +55,7 @@
5455
"react-dom": "19.2.6",
5556
"react-dropzone": "^15.0.0",
5657
"react-map-gl": "^8.1.1",
58+
"sharp": "^0.34.5",
5759
"vaul": "^1.1.2"
5860
},
5961
"devDependencies": {

0 commit comments

Comments
 (0)