Skip to content

Commit 3d5a6f0

Browse files
committed
optimise media uploads
2 parents 7d18598 + b986a86 commit 3d5a6f0

49 files changed

Lines changed: 2350 additions & 271 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
1111
# 5. Paste NEXT_PUBLIC_SUPABASE_ANON_KEY below
1212
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331
1313
NEXT_PUBLIC_SUPABASE_ANON_KEY=
14+
SUPABASE_SERVICE_ROLE_KEY=
1415

1516
# For contributors: Either follow the below forking instructions or ask the project maintainers for the shared development key
1617
# For forking: Get your own free API key at https://cloud.maptiler.com/

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# next.js
1616
/.next/
1717
/out/
18+
peels-org/dist/
1819
/.swc/
1920

2021
# production

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ For local-first Supabase development:
8282
3. Run `npm run supabase:env`
8383
4. Make sure `.env.local` contains the local URL `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331`
8484
5. Copy the local `ANON_KEY` value into `NEXT_PUBLIC_SUPABASE_ANON_KEY` in `.env.local`
85+
6. Copy the local `SERVICE_ROLE_KEY` value into `SUPABASE_SERVICE_ROLE_KEY` in `.env.local`
8586

8687
The repo defaults `NEXT_PUBLIC_SUPABASE_URL` to `http://127.0.0.1:54331` so local development does not need to point at the hosted Peels project. If you already had a `.env.local` from hosted development, update that value manually because copying `.env.example` later may not overwrite your existing file.
8788
Peels intentionally uses the `54331`-`54334` local port range so it can run alongside other Supabase projects that still use the CLI defaults.
@@ -108,6 +109,7 @@ For how auth/session forwarding, public-page performance, footer locale state, u
108109
- Run `npm run supabase:env`
109110
- Confirm `.env.local` uses `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331`
110111
- Copy the local `ANON_KEY` into `NEXT_PUBLIC_SUPABASE_ANON_KEY`
112+
- Copy the local `SERVICE_ROLE_KEY` into `SUPABASE_SERVICE_ROLE_KEY`
111113

112114
3. Start development:
113115

@@ -272,13 +274,15 @@ Before `npm run test:e2e` or `npm run test:e2e:prod`, make sure you are using th
272274
- Run `npm run supabase:env`
273275
- Make sure `.env.local` uses `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331`
274276
- Copy the local `ANON_KEY` into `NEXT_PUBLIC_SUPABASE_ANON_KEY`
277+
- Copy the local `SERVICE_ROLE_KEY` into `SUPABASE_SERVICE_ROLE_KEY`
275278

276279
If your `.env.local` was previously set up for the hosted Peels project, update it before running Playwright. A working local test setup looks like:
277280

278281
```bash
279282
NEXT_PUBLIC_SITE_URL=http://127.0.0.1:3000
280283
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331
281284
NEXT_PUBLIC_SUPABASE_ANON_KEY=<paste the ANON_KEY from npm run supabase:env>
285+
SUPABASE_SERVICE_ROLE_KEY=<paste the SERVICE_ROLE_KEY from npm run supabase:env>
282286
```
283287

284288
Then run either:

docs/supabase-data-architecture.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,31 @@ User-uploaded media lives in public Storage buckets, but the canonical
180180
references live in `public.profiles.avatar`, `public.listings.avatar`, and
181181
`public.listings.photos`.
182182

183+
### Media write boundary
184+
185+
Browser clients must not write Storage objects or media reference columns
186+
directly. Media writes go through `src/app/api/media/upload/route.ts`, which
187+
authenticates the user, optimises the image, writes the Storage object with the
188+
service-role client, and then updates the matching profile/listing reference
189+
after ownership checks.
190+
191+
For new listings, uploads happen before the listing row exists. Those temporary
192+
uploads are tracked in `public.pending_media_uploads`, keyed by user, bucket,
193+
kind, and path. The table is server-owned: authenticated clients cannot insert,
194+
update, or delete rows directly. When the listing is saved, the server action
195+
validates that every submitted media path belongs to that user's pending upload
196+
set before attaching it to the new listing.
197+
198+
Direct authenticated writes to `public.profiles.avatar`,
199+
`public.listings.avatar`, and `public.listings.photos` are revoked. This is
200+
important because delete routes remove files with the service-role Storage
201+
client; the app must never treat a client-forged media path as proof of
202+
ownership.
203+
204+
Listing photo append/remove operations use RPCs so array changes are atomic.
205+
Those RPCs are service-role only and are called from the media route after the
206+
route has verified the signed-in user owns the listing.
207+
183208
Listing and account deletion should clean up media through the Edge Functions
184209
before the database rows are deleted:
185210

@@ -192,6 +217,10 @@ Do not add a Postgres Cron job that deletes from `storage.objects`. Supabase
192217
Storage object deletion must go through the Storage API; deleting rows from
193218
`storage.objects` only removes metadata and can leave object bytes behind.
194219

220+
Pending uploads are treated as active for one day. After that, abandoned
221+
new-listing uploads can be reported by the orphan cleanup tooling and removed
222+
through the Storage API.
223+
195224
For historical orphans, first inspect the read-only helper:
196225

197226
```sql
@@ -206,7 +235,25 @@ npm run media:cleanup-orphans
206235

207236
For hosted environments, pass the project URL and service-role key explicitly
208237
and include `--allow-remote`. Deletion requires both `--delete-orphans` and
209-
`--confirm-delete-orphans`; this keeps the default path inspect-only.
238+
`--confirm-delete-orphans`, plus an explicit `--before` cutoff; this keeps the
239+
default path inspect-only and protects in-flight uploads whose database
240+
references have not been written yet.
241+
242+
Example production dry run:
243+
244+
```sh
245+
SUPABASE_URL="https://YOUR_PROJECT.supabase.co" \
246+
SUPABASE_SERVICE_ROLE_KEY="YOUR_SERVICE_ROLE_KEY" \
247+
npm run media:cleanup-orphans -- --allow-remote --before=2026-05-18
248+
```
249+
250+
Example production deletion after reviewing the dry-run output:
251+
252+
```sh
253+
SUPABASE_URL="https://YOUR_PROJECT.supabase.co" \
254+
SUPABASE_SERVICE_ROLE_KEY="YOUR_SERVICE_ROLE_KEY" \
255+
npm run media:cleanup-orphans -- --allow-remote --before=2026-05-18 --delete-orphans --confirm-delete-orphans
256+
```
210257

211258
Storage folder placeholders such as `.emptyFolderPlaceholder` are Supabase
212259
folder bookkeeping objects, not user media. The helper and cleanup script ignore

docs/supabase-local-first.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,9 @@ Use this when setting up Peels on a new machine.
178178
7. Run `npm run supabase:env`.
179179
8. Set `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331` in `.env.local`.
180180
9. Copy the printed `ANON_KEY` into `NEXT_PUBLIC_SUPABASE_ANON_KEY` in `.env.local`.
181-
10. Run `npm run dev`.
182-
11. Sign in with one of the demo accounts above.
181+
10. Copy the printed `SERVICE_ROLE_KEY` into `SUPABASE_SERVICE_ROLE_KEY` in `.env.local`.
182+
11. Run `npm run dev`.
183+
12. Sign in with one of the demo accounts above.
183184

184185
If the app still shows old environment values, clear the build cache and restart:
185186

e2e/listings.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ test("listing type chooser routes signed-in hosts to the selected form", async (
6666
await expect(page.locator("#name")).toBeVisible();
6767
});
6868

69+
test("listing type chooser hides residential option at the residential limit", async ({
70+
page,
71+
}) => {
72+
await signIn(page, {
73+
email: DONOR_EMAIL,
74+
redirectTo: "/profile/listings/new?type=host",
75+
});
76+
77+
const chooser = page.getByTestId("listing-type-chooser");
78+
const communityOption = page.getByTestId("listing-type-option-community");
79+
const continueButton = page.getByTestId("listing-type-chooser-submit");
80+
81+
await expect(chooser).toBeVisible();
82+
await expect(page.getByTestId("listing-type-option-residential")).toHaveCount(
83+
0
84+
);
85+
await expect(communityOption).toBeVisible();
86+
await communityOption.click();
87+
await expect(continueButton).toBeEnabled();
88+
await continueButton.click();
89+
90+
await expect(page).toHaveURL(/\/profile\/listings\/new\/community$/);
91+
});
92+
6993
test("new listing form shows validation feedback when location is missing", async ({
7094
page,
7195
}) => {

e2e/map.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,19 @@ async function zoomInUntilMapPinsAreDetailed(page: Page) {
135135
await expectMapPinDetailScale(page, 1);
136136
}
137137

138+
async function focusMapControlByKeyboard(page: Page, testId: string) {
139+
for (let i = 0; i < 20; i += 1) {
140+
await page.keyboard.press("Tab");
141+
const activeTestId = await page.evaluate(() =>
142+
document.activeElement?.getAttribute("data-testid")
143+
);
144+
145+
if (activeTestId === testId) return;
146+
}
147+
148+
throw new Error(`Could not focus ${testId} by keyboard`);
149+
}
150+
138151
test("map mounts when IP location is unavailable and restores the last view", async ({
139152
page,
140153
}) => {
@@ -155,6 +168,25 @@ test("map mounts when IP location is unavailable and restores the last view", as
155168
});
156169
expect(await readStoredMapView(page)).toBeNull();
157170

171+
await focusMapControlByKeyboard(page, "map-control-search");
172+
await expect(page.getByTestId("map-control-search")).toBeFocused();
173+
await expect
174+
.poll(() =>
175+
page
176+
.getByTestId("map-control-search")
177+
.evaluate((element) => getComputedStyle(element).boxShadow)
178+
)
179+
.toContain("inset");
180+
await focusMapControlByKeyboard(page, "map-control-zoom-in");
181+
await expect(page.getByTestId("map-control-zoom-in")).toBeFocused();
182+
await expect
183+
.poll(() =>
184+
page
185+
.getByTestId("map-control-zoom-in")
186+
.evaluate((element) => getComputedStyle(element).boxShadow)
187+
)
188+
.toContain("inset");
189+
158190
await page.getByTestId("map-control-zoom-in").click();
159191

160192
await expect
@@ -264,6 +296,12 @@ test("map search palette flies to a picked geocoding result", async ({
264296
await page.getByTestId("map-control-search").click();
265297
const searchInput = page.getByTestId("geocoding-search-input");
266298
await expect(searchInput).toBeFocused();
299+
await expect(page.getByTestId("map-search-close")).toHaveCount(0);
300+
await page.keyboard.press("Escape");
301+
await expect(page.getByTestId("map-search-dialog")).toBeHidden();
302+
303+
await page.getByTestId("map-control-search").click();
304+
await expect(searchInput).toBeFocused();
267305
await searchInput.fill("Newtown");
268306
await expect
269307
.poll(async () =>

0 commit comments

Comments
 (0)