diff --git a/.env.e2e b/.env.e2e index 85bc277..7008964 100644 --- a/.env.e2e +++ b/.env.e2e @@ -26,10 +26,12 @@ NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321 S3_IMPLEMENTATION=real S3_ENDPOINT=http://localhost:9000 PUBLIC_S3_BASE_URL=http://localhost:9000/paradb-maps-e2e +PUBLIC_ASSETS_BASE_URL=http://localhost:9000/paradb-assets-e2e S3_REGION=us-east-1 S3_ACCESS_KEY_ID=minioadmin S3_ACCESS_KEY_SECRET=minioadmin S3_MAPS_BUCKET=paradb-maps-e2e +S3_ASSETS_BUCKET=paradb-assets-e2e # Everything else stays faked/local - not under test here. SENTRY_DSN=sentryDsn diff --git a/.env.localdev b/.env.localdev index d8cd990..f6f266d 100644 --- a/.env.localdev +++ b/.env.localdev @@ -6,12 +6,14 @@ PGDATABASE=postgres PGPASSWORD=postgres SENTRY_DSN=sentryDsn SENTRY_ENV=localdev -PUBLIC_S3_BASE_URL=http://localhost:3000/api/_dev/s3 -S3_ENDPOINT=http://localhost:3000/api/_dev/s3 +PUBLIC_S3_BASE_URL=http://localhost:3000/api/dev/s3 +S3_ENDPOINT=http://localhost:3000/api/dev/s3 S3_REGION=local S3_ACCESS_KEY_ID=devaccesskeyneedstobeexactly32ch S3_ACCESS_KEY_SECRET=12345678 S3_MAPS_BUCKET=paradb-maps-local +S3_ASSETS_BUCKET=paradb-assets-local +PUBLIC_ASSETS_BASE_URL=http://localhost:3000/api/dev/s3 S3_IMPLEMENTATION=dev SUPABASE_IMPLEMENTATION=fake AXIOM_IMPLEMENTATION=fake diff --git a/.env.test b/.env.test index 7073f4e..620e324 100644 --- a/.env.test +++ b/.env.test @@ -12,13 +12,19 @@ S3_REGION=local S3_ACCESS_KEY_ID=accesskeyneedstobeexactly32chars S3_ACCESS_KEY_SECRET=12345678 S3_MAPS_BUCKET=paradb-maps-test +S3_ASSETS_BUCKET=paradb-assets-test +# Points at the in-process dev S3 route so the fake-stored avatar is actually serveable (e.g. the +# browser upload test loads it). The route serves from the shared in-memory bucket in fake mode. +PUBLIC_ASSETS_BASE_URL=http://localhost:3000/api/dev/s3 S3_IMPLEMENTATION=fake SUPABASE_IMPLEMENTATION=fake AXIOM_IMPLEMENTATION=fake FLAGS_IMPLEMENTATION=local FLAGS_EDGE_CONFIG=abc FLAGS_EDGE_CONFIG_KEY=123 -NEXT_PUBLIC_SUPABASE_URL=example.com +# Must be a valid http(s) URL: the browser Supabase client validates it on construction (the fake +# never makes network calls to it). Without a scheme, client-rendered auth pages throw. +NEXT_PUBLIC_SUPABASE_URL=http://localhost:3000 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=123 SUPABASE_SECRET_KEY=123 AXIOM_API_TOKEN=123 diff --git a/bun.lock b/bun.lock index d702cb4..c4653e6 100644 --- a/bun.lock +++ b/bun.lock @@ -35,10 +35,12 @@ "next": "16.0.10", "node-html-parser": "^7.0.2", "pg": "^8.11.3", + "pica": "^10.0.1", "postcss-modules-values": "^4.0.0", "qs": "^6.11.2", "react": "19.2.0", "react-dom": "19.2.0", + "react-easy-crop": "^6.0.2", "sanitize-html": "^2.11.0", "server-only": "^0.0.1", "snakecase-keys": "^5.4.6", @@ -59,6 +61,7 @@ "@swc/jest": "^0.2.39", "@types/jest": "^29.5.5", "@types/pg": "^8.10.2", + "@types/pica": "^9.0.5", "@types/postcss-modules-values": "^4", "@types/qs": "^6.9.8", "@types/supertest": "^2.0.12", @@ -979,6 +982,8 @@ "@types/phoenix": ["@types/phoenix@1.6.7", "", {}, "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="], + "@types/pica": ["@types/pica@9.0.5", "", {}, "sha512-OSd4905yxFNtRanHuyyQAfC9AkxiYcbhlzP606Gl6rFcYRgq4vdLCZuYKokLQBihgrkNzyPkoeykvJDWcPjaCw=="], + "@types/postcss-modules-values": ["@types/postcss-modules-values@4.0.2", "", { "dependencies": { "postcss": "^8.0.0" } }, "sha512-y4JJK4e/lK3Os6Oc0G+gNT8XjIpMFfJixY/uQzYWfyWn53zWtX+9mIefaI4W6pHqGYTy5WF2Srp8zFTYAYoEzw=="], "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], @@ -1553,6 +1558,8 @@ "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + "glur": ["glur@2.0.0", "", {}, "sha512-ZgJCXwuaQo4vnYG+VqtNIYV9kpNC/V/mafp0UcOMiLN/7eQVjCU+RDBLIwWXlmvwYMLFDf0IJrV/4qnM5VinCA=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -1939,6 +1946,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "multimath": ["multimath@3.0.0", "", { "dependencies": { "glur": "^2.0.0" } }, "sha512-U+s5xgbW55w5sbT9PeaJdEuxH5zL7wLwvp0MOJxXS8WhWDYnsTPIF3faeAltxkPNa+dDXmXs6gIiEpcCeE1YCA=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], @@ -1961,6 +1970,8 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "normalize-wheel": ["normalize-wheel@1.0.1", "", {}, "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA=="], + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], @@ -2031,6 +2042,8 @@ "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "pica": ["pica@10.0.1", "", { "dependencies": { "glur": "^2.0.0", "multimath": "^3.0.0" } }, "sha512-JMM/roFvU59XVD+LQjvTPNKlIKii49flilnUOQltO2OKWKliRZ7r2u+2wMVnxgfneZgeKhOVpP+GhihhyYjGUg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -2145,6 +2158,8 @@ "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + "react-easy-crop": ["react-easy-crop@6.0.2", "", { "dependencies": { "normalize-wheel": "^1.0.1" }, "peerDependencies": { "react": ">=16.4.0", "react-dom": ">=16.4.0" } }, "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], diff --git a/package.json b/package.json index 14c467e..83e4f4c 100644 --- a/package.json +++ b/package.json @@ -50,10 +50,12 @@ "next": "16.0.10", "node-html-parser": "^7.0.2", "pg": "^8.11.3", + "pica": "^10.0.1", "postcss-modules-values": "^4.0.0", "qs": "^6.11.2", "react": "19.2.0", "react-dom": "19.2.0", + "react-easy-crop": "^6.0.2", "sanitize-html": "^2.11.0", "server-only": "^0.0.1", "snakecase-keys": "^5.4.6", @@ -74,6 +76,7 @@ "@swc/jest": "^0.2.39", "@types/jest": "^29.5.5", "@types/pg": "^8.10.2", + "@types/pica": "^9.0.5", "@types/postcss-modules-values": "^4", "@types/qs": "^6.9.8", "@types/supertest": "^2.0.12", diff --git a/src/app/(logged-in)/settings/avatar_crop_dialog.module.css b/src/app/(logged-in)/settings/avatar_crop_dialog.module.css new file mode 100644 index 0000000..22a1bbd --- /dev/null +++ b/src/app/(logged-in)/settings/avatar_crop_dialog.module.css @@ -0,0 +1,59 @@ +.backdrop { + position: fixed; + inset: 0; + background-color: var(--colorDialogOverlay); +} + +.dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + display: flex; + flex-direction: column; + gap: calc(var(--gridBaseline) * 2); + + width: calc(var(--gridBaseline) * 50); + max-width: 90vw; + + background-color: var(--colorBackground); + padding: calc(var(--gridBaseline) * 3); + border: 1px dotted var(--colorForeground); +} + +.cropArea { + position: relative; + width: 100%; + height: calc(var(--gridBaseline) * 40); + background-color: var(--colorDarkGrey); +} + +.zoom { + display: flex; + align-items: center; + gap: var(--gridBaseline); +} + +.zoom input { + flex: 1; +} + +.scaling { + display: flex; + align-items: center; + gap: calc(var(--gridBaseline) * 2); +} + +.scalingOption { + display: flex; + align-items: center; + gap: calc(0.5 * var(--gridBaseline)); + cursor: pointer; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: var(--gridBaseline); +} diff --git a/src/app/(logged-in)/settings/avatar_crop_dialog.tsx b/src/app/(logged-in)/settings/avatar_crop_dialog.tsx new file mode 100644 index 0000000..71ab232 --- /dev/null +++ b/src/app/(logged-in)/settings/avatar_crop_dialog.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { Dialog as BaseDialog } from '@base-ui/react/dialog'; +import { useCallback, useState } from 'react'; +import Cropper, { Area } from 'react-easy-crop'; +import { Button } from 'ui/base/button/button'; +import { FormError } from 'ui/base/form/form_error'; +import { T } from 'ui/base/text/text'; +import { Tooltip } from 'ui/base/tooltip/tooltip'; +import styles from './avatar_crop_dialog.module.css'; +import { ImageScaling } from './render_avatar'; + +type AvatarCropDialogProps = { + /** Object URL of the image to crop. */ + source: string; + submitting: boolean; + error: string | undefined; + onSave: (area: Area, scaling: ImageScaling) => void; + onCancel: () => void; +}; + +export function AvatarCropDialog(props: AvatarCropDialogProps) { + const { source, submitting, error, onSave, onCancel } = props; + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [scaling, setScaling] = useState('smooth'); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + + const onCropComplete = useCallback((_area: Area, areaPixels: Area) => { + setCroppedAreaPixels(areaPixels); + }, []); + + const handleSave = () => { + if (croppedAreaPixels == null) { + return; + } + onSave(croppedAreaPixels, scaling); + }; + + return ( + { + if (!open) { + onCancel(); + } + }} + > + + + + Crop your profile picture +
+ +
+ +
+ Image scaling: + + + + + + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/app/(logged-in)/settings/page.module.css b/src/app/(logged-in)/settings/page.module.css index 269aca0..fece6b8 100644 --- a/src/app/(logged-in)/settings/page.module.css +++ b/src/app/(logged-in)/settings/page.module.css @@ -5,3 +5,21 @@ flex-direction: column; gap: calc(var(--gridBaseline) * 2); } + +.avatarSection { + display: flex; + align-items: center; + gap: calc(var(--gridBaseline) * 2); +} + +.avatar, +.avatarPlaceholder { + width: calc(var(--gridBaseline) * 10); + height: calc(var(--gridBaseline) * 10); + border-radius: 50%; + object-fit: cover; +} + +.avatarPlaceholder { + background-color: var(--colorGreyA15); +} diff --git a/src/app/(logged-in)/settings/page.tsx b/src/app/(logged-in)/settings/page.tsx index f45ad6d..56bb307 100644 --- a/src/app/(logged-in)/settings/page.tsx +++ b/src/app/(logged-in)/settings/page.tsx @@ -2,13 +2,15 @@ import { useApi } from 'app/api/api_provider'; import { observer, useLocalObservable } from 'mobx-react-lite'; -import { redirect } from 'next/navigation'; +import { redirect, useRouter } from 'next/navigation'; +import { useRef } from 'react'; import { Button } from 'ui/base/button/button'; import { FormError } from 'ui/base/form/form_error'; import { T } from 'ui/base/text/text'; import { Textbox } from 'ui/base/textbox/textbox'; import { useSession } from 'ui/session/session_provider'; import { RoutePath, routeFor } from 'utils/routes'; +import { AvatarCropDialog } from './avatar_crop_dialog'; import styles from './page.module.css'; import { SettingsPresenter, SettingsStore } from './settings_presenter'; @@ -17,11 +19,13 @@ const noop = () => void 0; export default observer(() => { const session = useSession(); const api = useApi(); + const router = useRouter(); const store = useLocalObservable(() => new SettingsStore()); + const fileInputRef = useRef(null); if (!session) { return redirect(routeFor([RoutePath.LOGIN])); } - const presenter = new SettingsPresenter(api, store, session.id); + const presenter = new SettingsPresenter(api, store, session.id, () => router.refresh()); const { oldPassword, newPassword, submitting, success, errors } = store; return ( @@ -36,6 +40,40 @@ export default observer(() => { tooltip="Changing your username is not currently implemented." /> + Profile picture +
+ {session.avatarUrl != null ? ( + Profile picture + ) : ( +
+ )} + + { + const file = e.target.files?.[0]; + if (file != null) { + presenter.onSelectAvatarFile(file); + } + // Reset so selecting the same file again still fires onChange. + e.target.value = ''; + }} + /> +
+ + {store.cropSource != null ? ( + + ) : null} + Change password { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('Failed to load image')); + img.src = src; + }); +} + +/** + * Produces the final avatar PNG from a source image and the crop region chosen in the dialog. The + * crop is drawn at its native resolution, then resized to a fixed square with the selected pica + * filter: 'box' (nearest-neighbour, "Precise") or 'lanczos3' ("Smooth"). + */ +export async function renderCroppedAvatar( + source: string, + area: Area, + scaling: ImageScaling +): Promise { + const img = await loadImage(source); + + const cropCanvas = document.createElement('canvas'); + cropCanvas.width = area.width; + cropCanvas.height = area.height; + const ctx = cropCanvas.getContext('2d'); + if (ctx == null) { + throw new Error('Could not get canvas context'); + } + ctx.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, area.width, area.height); + + const outCanvas = document.createElement('canvas'); + outCanvas.width = OUTPUT_SIZE; + outCanvas.height = OUTPUT_SIZE; + await pica.resize(cropCanvas, outCanvas, { + filter: scaling === 'precise' ? 'box' : 'lanczos3', + }); + + return pica.toBlob(outCanvas, 'image/png'); +} diff --git a/src/app/(logged-in)/settings/settings_presenter.ts b/src/app/(logged-in)/settings/settings_presenter.ts index e50a21e..caf75a4 100644 --- a/src/app/(logged-in)/settings/settings_presenter.ts +++ b/src/app/(logged-in)/settings/settings_presenter.ts @@ -1,25 +1,79 @@ import { Api } from 'app/api/api'; import { action, observable } from 'mobx'; +import type { Area } from 'react-easy-crop'; import { FormPresenter, FormStore } from 'ui/base/form/form_presenter'; +import { ImageScaling, renderCroppedAvatar } from './render_avatar'; -export type SettingsFields = 'oldPassword' | 'newPassword' | 'form'; +export type SettingsFields = 'oldPassword' | 'newPassword' | 'avatar' | 'form'; export class SettingsStore extends FormStore { @observable accessor oldPassword = ''; @observable accessor newPassword = ''; @observable accessor submitting = false; @observable accessor success: boolean | undefined = undefined; + + /** Object URL of the image currently being cropped, or undefined when the crop dialog is closed. */ + @observable accessor cropSource: string | undefined = undefined; + @observable accessor avatarSubmitting = false; } export class SettingsPresenter extends FormPresenter { constructor( private readonly api: Api, private readonly store: SettingsStore, - private readonly userId: string + private readonly userId: string, + private readonly onAvatarUpdated: () => void ) { super(store); } + @action.bound onSelectAvatarFile(file: File) { + this.clearErrors(); + if (!file.type.startsWith('image/')) { + this.pushErrors(['avatar'], 'Please choose an image file.'); + return; + } + this.setCropSource(URL.createObjectURL(file)); + } + + @action.bound onCancelCrop() { + this.setCropSource(undefined); + } + + @action.bound async onSaveCrop(area: Area, scaling: ImageScaling) { + const source = this.store.cropSource; + if (source == null) { + return; + } + this.clearErrors(); + this.setAvatarSubmitting(true); + try { + const png = await renderCroppedAvatar(source, area, scaling); + const resp = await this.api.setProfilePicture(png); + if (resp.success) { + this.setCropSource(undefined); + this.onAvatarUpdated(); + } else { + this.pushErrors(['avatar'], resp.errorMessage || 'Failed to upload profile picture.'); + } + } catch { + this.pushErrors(['avatar'], 'Could not process this image. Please try another file.'); + } finally { + this.setAvatarSubmitting(false); + } + } + + @action private setCropSource(url: string | undefined) { + // Revoke the previous object URL so we don't leak it when replacing or closing the dialog. + if (this.store.cropSource != null) { + URL.revokeObjectURL(this.store.cropSource); + } + this.store.cropSource = url; + } + @action private setAvatarSubmitting(value: boolean) { + this.store.avatarSubmitting = value; + } + @action.bound onChangeOldPassword(value: string) { this.store.oldPassword = value; } diff --git a/src/app/(logged-in)/settings/tests/profile_picture_upload.test.ts b/src/app/(logged-in)/settings/tests/profile_picture_upload.test.ts new file mode 100644 index 0000000..3b8ae47 --- /dev/null +++ b/src/app/(logged-in)/settings/tests/profile_picture_upload.test.ts @@ -0,0 +1,124 @@ +import { Browser, chromium } from '@playwright/test'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { deflateSync } from 'zlib'; + +// Drives the profile-picture upload UI end to end in a real browser against the integration server +// (PGlite + fake Supabase + fake S3, served via tools/test.sh). Uses the Playwright library rather +// than its test runner so it lives in the Jest integration suite; assertions are Jest + manual +// Playwright waits (the runner's web-first matchers aren't available here). + +const BASE_URL = 'http://localhost:3000'; +const PASSWORD = 'quartz-meadow-7Vx-galleon'; +const AVATAR_IMG = 'img[src*="/avatars/"]'; + +jest.setTimeout(60_000); + +// Minimal solid-grey RGB PNG, so the test has a real browser-decodable image without committing a +// binary fixture. +function crc32(buf: Buffer): number { + let c = ~0; + for (let i = 0; i < buf.length; i++) { + c ^= buf[i]!; + for (let k = 0; k < 8; k++) { + c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); + } + } + return ~c >>> 0; +} + +function pngChunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const typeAndData = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(typeAndData)); + return Buffer.concat([length, typeAndData, crc]); +} + +function makePng(size: number): Buffer { + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // colour type: RGB + const row = Buffer.concat([Buffer.from([0]), Buffer.alloc(size * 3, 0x4c)]); // filter byte + pixels + const raw = Buffer.concat(Array.from({ length: size }, () => row)); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk('IHDR', ihdr), + pngChunk('IDAT', deflateSync(raw)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + +const avatarLoaded = (): boolean => { + const img = document.querySelector('img[src*="/avatars/"]'); + return img != null && img.complete && img.naturalWidth > 0; +}; + +describe('profile picture upload (browser)', () => { + let browser: Browser; + let fixturePath: string; + + beforeAll(async () => { + browser = await chromium.launch(); + // Date.now is fine here (a normal Jest test, not a workflow script). + fixturePath = path.join(os.tmpdir(), `paradb-avatar-${Date.now()}.png`); + await fs.writeFile(fixturePath, makePng(64)); + }); + + afterAll(async () => { + await browser?.close(); + if (fixturePath != null) { + await fs.rm(fixturePath, { force: true }); + } + }); + + it('uploads, crops, and renders the avatar in the header', async () => { + const context = await browser.newContext({ baseURL: BASE_URL }); + const page = await context.newPage(); + try { + const username = `pic_${Date.now().toString(36)}`; + + // Sign up. The fake Supabase sets the session cookie on signup, so we land authenticated even + // though the UI routes to the email-verification notice. + await page.goto('/signup'); + await page.getByLabel('Username').fill(username); + await page.getByLabel('Email').fill(`${username}@example.com`); + await page.getByLabel('Password').fill(PASSWORD); + await page.getByRole('button', { name: 'Signup' }).click(); + // Signup sets the session cookie and navigates away from /signup (to the verification notice, + // which redirects an already-authenticated user home). + await page.waitForURL((url) => !url.pathname.startsWith('/signup')); + + await page.goto('/settings'); + await page.waitForSelector(`text=Logged in as ${username}`); + // No avatar yet. + expect(await page.locator(AVATAR_IMG).count()).toBe(0); + + // Upload the fixture, then crop with nearest-neighbour ("Precise") scaling. + await page.locator('input[type="file"]').setInputFiles(fixturePath); + await page.waitForSelector('text=Crop your profile picture'); + await page.getByText('Precise').click(); + await page.getByRole('button', { name: 'Save' }).click(); + + // The avatar appears and the image actually loads from the dev S3 route (fake bucket). + await page.waitForFunction(avatarLoaded); + + // Survives a reload (token persisted in the session, bytes served from the fake bucket). + await page.reload(); + await page.waitForFunction(avatarLoaded); + + // Output is the fixed 512px square regardless of the source size. + const naturalWidth = await page + .locator(AVATAR_IMG) + .first() + .evaluate((img: HTMLImageElement) => img.naturalWidth); + expect(naturalWidth).toBe(512); + } finally { + await context.close(); + } + }); +}); diff --git a/src/app/(logged-in)/settings/tests/set_profile_picture.test.ts b/src/app/(logged-in)/settings/tests/set_profile_picture.test.ts new file mode 100644 index 0000000..418b203 --- /dev/null +++ b/src/app/(logged-in)/settings/tests/set_profile_picture.test.ts @@ -0,0 +1,44 @@ +import { + SetProfilePictureError, + setProfilePicture, +} from 'app/api/users/avatar/set_profile_picture'; +import { _unwrap } from 'base/result'; +import { getServerContext } from 'services/server_context'; + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const fakePng = (bytes = 32) => Buffer.concat([PNG_SIGNATURE, Buffer.alloc(bytes)]); + +const assetsBucket = () => getServerContext().assetsBucket; + +describe('setProfilePicture', () => { + it('stores the PNG in the assets bucket and returns a cache-busted avatar URL', async () => { + const url = await _unwrap(setProfilePicture('USER0001', fakePng())); + expect(url).toMatch(/^http:\/\/localhost:3000\/api\/dev\/s3\/avatars\/USER0001\.png\?v=.+/); + + const stored = await _unwrap(assetsBucket().get('avatars/USER0001.png')); + expect(stored.equals(fakePng())).toBe(true); + }); + + it('uses a different cache token each time so the URL changes on replace', async () => { + const first = await _unwrap(setProfilePicture('USER0001', fakePng())); + const second = await _unwrap(setProfilePicture('USER0001', fakePng())); + expect(first).not.toEqual(second); + }); + + it('rejects non-PNG data', async () => { + const result = await setProfilePicture('USER0001', Buffer.from('not a png')); + if (result.success) { + throw new Error('expected failure'); + } + expect(result.errors[0].type).toBe(SetProfilePictureError.INVALID_IMAGE); + expect((await assetsBucket().get('avatars/USER0001.png')).success).toBe(false); + }); + + it('rejects images over the size cap', async () => { + const result = await setProfilePicture('USER0001', fakePng(3 * 1024 * 1024)); + if (result.success) { + throw new Error('expected failure'); + } + expect(result.errors[0].type).toBe(SetProfilePictureError.TOO_LARGE); + }); +}); diff --git a/src/app/api/api.ts b/src/app/api/api.ts index f11945f..6826d78 100644 --- a/src/app/api/api.ts +++ b/src/app/api/api.ts @@ -17,6 +17,7 @@ import { LoginRequest, LoginResponse, SetFavoriteMapsRequest, + SetProfilePictureResponse, SignupRequest, SignupResponse, } from 'schema/users'; @@ -28,6 +29,7 @@ export interface Api { /* User */ changePassword(req: ChangePasswordRequest): Promise; + setProfilePicture(png: Blob): Promise; setFavorites(req: SetFavoriteMapsRequest): Promise; /* Maps */ @@ -60,6 +62,15 @@ export class HttpApi implements Api { return ChangePasswordResponse.parse(resp); } + async setProfilePicture(png: Blob): Promise { + const resp = await fetch(path(this.apiBase, 'users', 'avatar'), { + method: 'POST', + headers: { ['Content-Type']: 'application/octet-stream' }, + body: png, + }); + return SetProfilePictureResponse.parse(await resp.json()); + } + async setFavorites(req: SetFavoriteMapsRequest): Promise { const resp = await post(path(this.apiBase, 'favorites'), SetFavoriteMapsRequest.parse(req)); return ApiResponse.parse(resp); diff --git a/src/app/api/_dev/s3/[...path]/route.ts b/src/app/api/dev/s3/[...path]/route.ts similarity index 50% rename from src/app/api/_dev/s3/[...path]/route.ts rename to src/app/api/dev/s3/[...path]/route.ts index 1da8738..1e16ad8 100644 --- a/src/app/api/_dev/s3/[...path]/route.ts +++ b/src/app/api/dev/s3/[...path]/route.ts @@ -1,17 +1,34 @@ import { NextRequest, NextResponse } from 'next/server'; -import { devS3 } from 'services/maps/s3_handler_fake_disk'; +import { PromisedResult } from 'base/result'; +import { diskRead, diskWrite } from 'services/s3/disk_bucket'; +import { getSharedMemoryBucket } from 'services/s3/memory_bucket'; +import { guessContentType, S3Error } from 'services/s3/s3_bucket'; -// Disk-backed dev-only S3 endpoint. Active when S3_IMPLEMENTATION=dev (the dev handler mints -// upload URLs that point here, and PUBLIC_S3_BASE_URL is configured to route reads through it). -// 404s for any other implementation so the route is a no-op in real environments. +// Local S3 endpoint for the disk (dev) and in-memory (fake) backings. Active when +// S3_IMPLEMENTATION is dev or fake: those buckets mint/serve URLs that route through here, and +// PUBLIC_*_BASE_URL is configured to point at it. 404s for the real implementation so it's a no-op +// in production. -function devOnly(): NextResponse | null { - if (process.env.S3_IMPLEMENTATION !== 'dev') { +function localOnly(): NextResponse | null { + const impl = process.env.S3_IMPLEMENTATION; + if (impl !== 'dev' && impl !== 'fake') { return new NextResponse('Not found', { status: 404 }); } return null; } +function readLocal(key: string): PromisedResult { + return process.env.S3_IMPLEMENTATION === 'fake' + ? getSharedMemoryBucket().get(key) + : diskRead(key); +} + +function writeLocal(key: string, body: Buffer): PromisedResult { + return process.env.S3_IMPLEMENTATION === 'fake' + ? getSharedMemoryBucket().put(key, body, guessContentType(key)) + : diskWrite(key, body); +} + function safeJoinPath(parts: string[]): string | null { // Defensive: reject `..` / absolute segments so a crafted URL can't escape the dev S3 root. for (const p of parts) { @@ -20,40 +37,30 @@ function safeJoinPath(parts: string[]): string | null { return parts.join('/'); } -function contentTypeFor(key: string): string { - if (key.endsWith('.zip')) return 'application/zip'; - if (key.endsWith('.png')) return 'image/png'; - if (key.endsWith('.jpg') || key.endsWith('.jpeg')) return 'image/jpeg'; - if (key.endsWith('.webp')) return 'image/webp'; - if (key.endsWith('.gif')) return 'image/gif'; - if (key.endsWith('.bmp')) return 'image/bmp'; - return 'application/octet-stream'; -} - export async function GET(_req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { - const guard = devOnly(); + const guard = localOnly(); if (guard) return guard; const { path } = await ctx.params; const key = safeJoinPath(path); if (key == null) return new NextResponse('Bad path', { status: 400 }); - const result = await devS3.read(key); + const result = await readLocal(key); if (!result.success) { return new NextResponse('Not found', { status: 404 }); } return new NextResponse(new Uint8Array(result.value), { status: 200, - headers: { 'content-type': contentTypeFor(key) }, + headers: { 'content-type': guessContentType(key) }, }); } export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { - const guard = devOnly(); + const guard = localOnly(); if (guard) return guard; const { path } = await ctx.params; const key = safeJoinPath(path); if (key == null) return new NextResponse('Bad path', { status: 400 }); const body = Buffer.from(await req.arrayBuffer()); - const result = await devS3.write(key, body); + const result = await writeLocal(key, body); if (!result.success) { return new NextResponse('Write failed', { status: 500 }); } diff --git a/src/app/api/fake_api.ts b/src/app/api/fake_api.ts index 8bdf855..1d1cc4e 100644 --- a/src/app/api/fake_api.ts +++ b/src/app/api/fake_api.ts @@ -18,6 +18,7 @@ import { LoginRequest, LoginResponse, SetFavoriteMapsRequest, + SetProfilePictureResponse, SignupRequest, SignupResponse, } from 'schema/users'; @@ -35,6 +36,9 @@ export class FakeApi implements Api { async changePassword(_req: ChangePasswordRequest): Promise { return { success: true }; } + async setProfilePicture(_png: Blob): Promise { + return { success: true, avatarUrl: 'https://fake-assets.local/avatars/fake.png?v=fake' }; + } async login(_req: LoginRequest): Promise { return { success: true, accessToken: '123', refreshToken: '456' }; } diff --git a/src/app/api/users/avatar/route.ts b/src/app/api/users/avatar/route.ts new file mode 100644 index 0000000..29fc407 --- /dev/null +++ b/src/app/api/users/avatar/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { SetProfilePictureResponse } from 'schema/users'; +import { getUserSession } from 'services/session/session'; +import { MAX_AVATAR_BYTES, SetProfilePictureError, setProfilePicture } from './set_profile_picture'; + +const send = (res: SetProfilePictureResponse, status = 200) => + NextResponse.json(SetProfilePictureResponse.parse(res), { status }); + +export async function POST(req: NextRequest): Promise { + const session = await getUserSession(); + if (session == null) { + return send({ success: false, statusCode: 403, errorMessage: 'Not logged in' }, 403); + } + + // Reject oversized uploads from the declared length before buffering the whole body into memory. + const declaredLength = Number(req.headers.get('content-length') ?? 0); + if (declaredLength > MAX_AVATAR_BYTES) { + return send({ success: false, statusCode: 400, errorMessage: 'Invalid image' }, 400); + } + + const png = Buffer.from(await req.arrayBuffer()); + const result = await setProfilePicture(session.id, png); + if (result.success) { + return send({ success: true, avatarUrl: result.value }); + } + + const type = result.errors[0]?.type; + if (type === SetProfilePictureError.INVALID_IMAGE || type === SetProfilePictureError.TOO_LARGE) { + return send({ success: false, statusCode: 400, errorMessage: 'Invalid image' }, 400); + } + return send( + { success: false, statusCode: 500, errorMessage: 'Failed to set profile picture' }, + 500 + ); +} diff --git a/src/app/api/users/avatar/set_profile_picture.ts b/src/app/api/users/avatar/set_profile_picture.ts new file mode 100644 index 0000000..4184cb8 --- /dev/null +++ b/src/app/api/users/avatar/set_profile_picture.ts @@ -0,0 +1,48 @@ +import { PromisedResult, wrapError } from 'base/result'; +import { randomBytes } from 'crypto'; +import { S3Error } from 'services/s3/s3_bucket'; +import { getServerContext } from 'services/server_context'; +import { avatarKey, buildAvatarUrl } from 'services/users/avatar'; + +export const enum SetProfilePictureError { + INVALID_IMAGE = 'invalid_image', + TOO_LARGE = 'too_large', + UPDATE_FAILED = 'update_failed', +} + +// Avatars are small (a 512x512 PNG is tens of KB); cap well above that to reject anything unexpected. +export const MAX_AVATAR_BYTES = 2 * 1024 * 1024; +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +function isPng(buffer: Buffer): boolean { + return ( + buffer.length >= PNG_SIGNATURE.length && + buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) + ); +} + +export async function setProfilePicture( + userId: string, + png: Buffer +): PromisedResult { + if (png.length > MAX_AVATAR_BYTES) { + return { success: false, errors: [{ type: SetProfilePictureError.TOO_LARGE }] }; + } + if (!isPng(png)) { + return { success: false, errors: [{ type: SetProfilePictureError.INVALID_IMAGE }] }; + } + + const { assetsBucket, supabase } = await getServerContext(); + const putResult = await assetsBucket.put(avatarKey(userId), png, 'image/png'); + if (!putResult.success) { + return putResult; + } + + const cacheToken = randomBytes(8).toString('base64url'); + const { error } = await supabase.auth.updateUser({ data: { avatarCacheToken: cacheToken } }); + if (error) { + return { success: false, errors: [wrapError(error, SetProfilePictureError.UPDATE_FAILED)] }; + } + + return { success: true, value: buildAvatarUrl(userId, cacheToken) }; +} diff --git a/src/schema/users.ts b/src/schema/users.ts index 326175f..657a93a 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -7,6 +7,7 @@ export const UserSession = z.object({ id: z.string(), username: z.string(), email: z.string(), + avatarUrl: z.string().optional(), }); export type UserSession = z.infer; @@ -78,6 +79,18 @@ export const ChangePasswordResponse = z.discriminatedUnion('success', [ ]); export type ChangePasswordResponse = z.infer; +/** Profile picture. The image is sent as a raw binary body; the current user is taken from the session. */ +export const SetProfilePictureSuccess = ApiSuccess.extend({ + avatarUrl: z.string(), +}); +export type SetProfilePictureSuccess = z.infer; + +export const SetProfilePictureResponse = z.discriminatedUnion('success', [ + SetProfilePictureSuccess, + ApiError, +]); +export type SetProfilePictureResponse = z.infer; + /** User-specific map data. User ID is implicit and pulled from the session cookie for all of these requests. */ export const GetFavoriteMapsSuccess = ApiSuccess.extend({ maps: z.array(PDMap), diff --git a/src/services/env.ts b/src/services/env.ts index 8755710..e628e5e 100644 --- a/src/services/env.ts +++ b/src/services/env.ts @@ -16,6 +16,10 @@ export type EnvVars = { s3AccessKeyId: string; s3AccessKeySecret: string; s3MapsBucket: string; + /** Bucket for non-map assets (e.g. profile pictures), separate from the maps bucket. */ + s3AssetsBucket: string; + /** Public read base for the assets bucket (e.g. Cloudflare-fronted). */ + publicAssetsBaseUrl: string; /** 'real' talks to S3; 'fake' returns hardcoded data (for tests, no bucket required). */ s3Implementation: string; /** 'real' talks to Supabase; 'fake' uses an in-repo fake (for tests, no Supabase required). */ @@ -76,6 +80,8 @@ function createEnvVars(): EnvVars { s3AccessKeyId: () => requireString('S3_ACCESS_KEY_ID'), s3AccessKeySecret: () => requireString('S3_ACCESS_KEY_SECRET'), s3MapsBucket: () => requireString('S3_MAPS_BUCKET'), + s3AssetsBucket: () => requireString('S3_ASSETS_BUCKET'), + publicAssetsBaseUrl: () => requireString('PUBLIC_ASSETS_BASE_URL'), s3Implementation: () => process.env.S3_IMPLEMENTATION || 'real', supabaseImplementation: () => process.env.SUPABASE_IMPLEMENTATION || 'real', axiomImplementation: () => process.env.AXIOM_IMPLEMENTATION || 'real', diff --git a/src/services/jest_setup.ts b/src/services/jest_setup.ts index 4af6664..f09879b 100644 --- a/src/services/jest_setup.ts +++ b/src/services/jest_setup.ts @@ -1,7 +1,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; -import { MemoryFakeS3Handler } from 'services/maps/s3_handler_fake_memory'; import { getServerContext } from 'services/server_context'; +import { getSharedMemoryBucket } from 'services/s3/memory_bucket'; import { _setCurrentUserForTesting } from 'services/session/supabase_fake'; async function initTestData() { @@ -21,10 +21,8 @@ beforeEach(async () => { throw new Error('Almost dropped DB on prod env'); } _setCurrentUserForTesting(null); - const { s3Handler } = getServerContext(); - if (s3Handler instanceof MemoryFakeS3Handler) { - s3Handler._resetForTesting(); - } + // Tests run with S3_IMPLEMENTATION=fake, where every bucket shares this one in-memory store. + getSharedMemoryBucket()._reset(); await initTestData(); }); afterAll(async () => { diff --git a/src/services/maps/maps_repo.ts b/src/services/maps/maps_repo.ts index 5aab3b3..5452a70 100644 --- a/src/services/maps/maps_repo.ts +++ b/src/services/maps/maps_repo.ts @@ -21,7 +21,8 @@ import { SearchIndex } from 'services/search/types'; import { getServerContext } from 'services/server_context'; import snakeCaseKeys from 'snakecase-keys'; import * as db from 'zapatos/db'; -import { S3Error, S3Handler } from './s3_handler_types'; +import { S3Handler } from 'services/s3/maps_s3_handler'; +import { S3Error } from 'services/s3/s3_bucket'; const exists = (t: T | undefined): t is NonNullable => !!t; diff --git a/src/services/maps/s3_handler.ts b/src/services/maps/s3_handler.ts deleted file mode 100644 index 1956edb..0000000 --- a/src/services/maps/s3_handler.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { - CopyObjectCommand, - DeleteObjectCommand, - DeleteObjectsCommand, - GetObjectCommand, - ListObjectsV2Command, - PutObjectCommand, - S3Client, -} from '@aws-sdk/client-s3'; -import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { checkExists } from 'base/preconditions'; -import { PromisedResult, Result, wrapError } from 'base/result'; -import * as path from 'path'; -import { getEnvVars } from 'services/env'; -import * as unzipper from 'unzipper'; -import { MintUploadUrlResult, S3Error, S3Handler } from './s3_handler_types'; - -let s3: { client: S3Client; bucket: string } | undefined; - -// TODO: this won't be safe for collisions if the same user reuploads against the same map multiple -// times at the same time -const mapKey = (id: string, temp: boolean) => `maps/${id}.zip` + (temp ? '.temp' : ''); -const albumArtPrefix = (id: string, temp: boolean) => `albumArt/${id}` + (temp ? '_temp/' : '/'); - -function getS3Client() { - if (s3 == null) { - const envVars = getEnvVars(); - s3 = { - client: new S3Client({ - endpoint: envVars.s3Endpoint, - region: envVars.s3Region, - credentials: { - accessKeyId: envVars.s3AccessKeyId, - secretAccessKey: envVars.s3AccessKeySecret, - }, - forcePathStyle: true, - requestChecksumCalculation: 'WHEN_REQUIRED', - }), - bucket: envVars.s3MapsBucket, - }; - } - - return s3; -} - -async function s3Get(key: string): PromisedResult { - try { - const s3 = getS3Client(); - const resp = await s3.client.send( - new GetObjectCommand({ - Bucket: s3.bucket, - Key: key, - }) - ); - if (!resp.Body) { - return { - success: false, - errors: [ - { - type: S3Error.S3_GET_ERROR, - internalMessage: 'Missing S3 body', - details: { key }, - }, - ], - }; - } - return { - success: true, - value: Buffer.from(await resp.Body.transformToByteArray()), - }; - } catch (e) { - return { - success: false, - errors: [wrapError(e, S3Error.S3_GET_ERROR, { key })], - }; - } -} - -async function s3Put( - key: string, - buffer: Buffer, - contentType: string -): PromisedResult { - try { - const s3 = getS3Client(); - await s3.client.send( - new PutObjectCommand({ - Bucket: s3.bucket, - Key: key, - Body: buffer, - ContentType: contentType, - }) - ); - return { - success: true, - value: undefined, - }; - } catch (e) { - return { - success: false, - errors: [ - { - type: S3Error.S3_WRITE_ERROR, - internalMessage: (e as Error).message, - stack: (e as Error).stack, - details: { key, contentType, contentLength: buffer.length }, - }, - ], - }; - } -} - -async function s3Delete(keys: string[]): PromisedResult { - if (keys.length === 0) { - return { success: true, value: undefined }; - } - try { - const s3 = getS3Client(); - await s3.client.send( - new DeleteObjectsCommand({ - Bucket: s3.bucket, - Delete: { - Objects: keys.map((k) => ({ Key: k })), - }, - }) - ); - return { success: true, value: undefined }; - } catch (e) { - return { success: false, errors: [wrapError(e, S3Error.S3_DELETE_ERROR, { keys })] }; - } -} - -async function s3Move(oldKey: string, newKey: string): PromisedResult { - try { - const s3 = getS3Client(); - // Copy - await s3.client.send( - new CopyObjectCommand({ - Bucket: s3.bucket, - Key: newKey, - CopySource: `${s3.bucket}/${oldKey}`, - }) - ); - // Delete old - await s3.client.send( - new DeleteObjectCommand({ - Bucket: s3.bucket, - Key: oldKey, - }) - ); - return { success: true, value: undefined }; - } catch (e) { - return { - success: false, - errors: [ - { - type: S3Error.S3_WRITE_ERROR, - internalMessage: (e as Error).message, - stack: (e as Error).stack, - details: { oldKey, newKey }, - }, - ], - }; - } -} - -function guessContentType(filename: string): string { - if (filename.endsWith('png')) { - return 'image/png'; - } else if (filename.endsWith('jpg') || filename.endsWith('jpeg')) { - return 'image/jpeg'; - } else if (filename.endsWith('bmp')) { - return 'image/bmp'; - } else if (filename.endsWith('gif')) { - return 'image/gif'; - } else if (filename.endsWith('webp')) { - return 'image/webp'; - } - return 'application/octet-stream'; -} - -export class RealS3Handler implements S3Handler { - async uploadAlbumArtFiles( - id: string, - albumArtFiles: unzipper.File[], - temp: boolean - ): Promise> { - // Write album art files to S3 - // TODO: display all of the album arts in the FE, e.g. in a carousel, or when selecting a difficulty - await Promise.all( - albumArtFiles.map(async (a) => { - const albumArt = checkExists(a, 'albumArt'); - const buffer = await albumArt.buffer(); - const filename = path.basename(albumArt.path); - return s3Put(`${albumArtPrefix(id, temp)}${filename}`, buffer, guessContentType(filename)); - }) - ); - - return { - success: true, - value: albumArtFiles.length > 0 ? path.basename(albumArtFiles[0]!.path) : undefined, - }; - } - - async getMapFile(id: string, temp: boolean): PromisedResult { - return s3Get(mapKey(id, temp)); - } - - async mintUploadUrl(id: string): Promise { - try { - const s3 = getS3Client(); - const resp = await getSignedUrl( - s3.client, - new PutObjectCommand({ - Bucket: s3.bucket, - Key: mapKey(id, true), - ContentType: 'application/zip', - }), - { expiresIn: 3600 } // 1 hour - ); - return { success: true, value: resp }; - } catch (e) { - return { success: false, error: e }; - } - } - - async deleteFiles(id: string, temp: boolean): Promise> { - const [mapDeleteResult] = await Promise.all([ - // Delete map file - s3Delete([mapKey(id, temp)]), - // Delete album art - (async () => { - try { - const s3 = getS3Client(); - const albumArtFiles = await s3.client.send( - new ListObjectsV2Command({ - Bucket: s3.bucket, - Prefix: albumArtPrefix(id, temp), - }) - ); - - // May throw error if key doesn't exist - that's fine, maybe it was a pre-S3 map - await s3Delete( - albumArtFiles.Contents?.map((c) => c.Key).filter((k): k is string => k != null) || [] - ); - } catch { - // TODO: log error to Sentry? - } - })(), - ]); - - if (!mapDeleteResult.success) { - return mapDeleteResult; - } - return { success: true, value: undefined }; - } - - async promoteTempMapFiles(id: string): PromisedResult { - // Delete originals, if they exist - this may error if this is a new map - await this.deleteFiles(id, false); - - // Move temp to permanent - const moveMapResult = await s3Move(mapKey(id, true), mapKey(id, false)); - if (!moveMapResult.success) { - return moveMapResult; - } - - // Move album art files - try { - const s3 = getS3Client(); - const albumArtFiles = await s3.client.send( - new ListObjectsV2Command({ - Bucket: s3.bucket, - Prefix: albumArtPrefix(id, true), - }) - ); - const albumArtKeys = - albumArtFiles.Contents?.map((c) => c.Key).filter((k): k is string => k != null) || []; - await Promise.all( - albumArtKeys.map((key) => - s3Move(key, key.replace(albumArtPrefix(id, true), albumArtPrefix(id, false))) - ) - ); - } catch (e) { - return { - success: false, - errors: [wrapError(e, S3Error.S3_WRITE_ERROR, { id })], - }; - } - - return { success: true, value: undefined }; - } -} diff --git a/src/services/maps/s3_handler_fake_disk.ts b/src/services/maps/s3_handler_fake_disk.ts deleted file mode 100644 index ba5ae25..0000000 --- a/src/services/maps/s3_handler_fake_disk.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { checkExists } from 'base/preconditions'; -import { PromisedResult, Result, wrapError } from 'base/result'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { getEnvVars } from 'services/env'; -import * as unzipper from 'unzipper'; -import { MintUploadUrlResult, S3Error, S3Handler } from './s3_handler_types'; - -// Disk-backed fake S3 handler for `bun dev` (selected via S3_IMPLEMENTATION=dev). Mirrors the -// key layout of the real S3 handler so the same temp -> permanent promotion flow works without -// an S3 bucket. Browser PUTs and GETs are served by /api/_dev/s3/[...path]; this class handles -// the server-side reads/writes directly against the filesystem. The in-memory counterpart used -// by tests is `MemoryFakeS3Handler` in s3_handler_fake_memory.ts. - -// Resolve to an absolute path per call so handlers don't depend on the ambient cwd. -function devS3Root(): string { - return path.resolve(process.cwd(), process.env.DEV_S3_ROOT || '.fake_dev/s3'); -} - -const mapKey = (id: string, temp: boolean) => `maps/${id}.zip` + (temp ? '.temp' : ''); -const albumArtPrefix = (id: string, temp: boolean) => `albumArt/${id}` + (temp ? '_temp/' : '/'); - -function devS3Path(key: string): string { - return path.join(devS3Root(), key); -} - -async function readFile(key: string): PromisedResult { - try { - return { success: true, value: await fs.readFile(devS3Path(key)) }; - } catch (e) { - return { success: false, errors: [wrapError(e, S3Error.S3_GET_ERROR, { key })] }; - } -} - -async function writeFile(key: string, body: Buffer): Promise> { - try { - const filePath = devS3Path(key); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, body); - return { success: true, value: undefined }; - } catch (e) { - return { success: false, errors: [wrapError(e, S3Error.S3_WRITE_ERROR, { key })] }; - } -} - -async function removeFile(key: string): Promise { - await fs.rm(devS3Path(key), { force: true }); -} - -async function removeDir(prefix: string): Promise { - // Mirror S3 prefix semantics: `albumArt/{id}/` corresponds to a directory we can blow away whole. - await fs.rm(devS3Path(prefix), { recursive: true, force: true }); -} - -async function moveFile(src: string, dst: string): Promise> { - try { - const dstPath = devS3Path(dst); - await fs.mkdir(path.dirname(dstPath), { recursive: true }); - await fs.rename(devS3Path(src), dstPath); - return { success: true, value: undefined }; - } catch (e) { - return { success: false, errors: [wrapError(e, S3Error.S3_WRITE_ERROR, { src, dst })] }; - } -} - -// Browser-facing wrappers: the dev S3 route handler reads/writes through these so its disk layout -// stays in sync with the server-side handler. -export const devS3 = { - read: readFile, - write: writeFile, -}; - -export class FileFakeS3Handler implements S3Handler { - async uploadAlbumArtFiles( - id: string, - albumArtFiles: unzipper.File[], - temp: boolean - ): Promise> { - for (const a of albumArtFiles) { - const albumArt = checkExists(a, 'albumArt'); - const filename = path.basename(albumArt.path); - const writeResult = await writeFile( - `${albumArtPrefix(id, temp)}${filename}`, - await albumArt.buffer() - ); - if (!writeResult.success) return writeResult; - } - return { - success: true, - value: albumArtFiles.length > 0 ? path.basename(albumArtFiles[0]!.path) : undefined, - }; - } - - async getMapFile(id: string, temp: boolean): PromisedResult { - return readFile(mapKey(id, temp)); - } - - async mintUploadUrl(id: string): Promise { - // Browser PUTs land at /api/_dev/s3/[...path] which writes to the same disk root. We use - // publicS3BaseUrl so the same setting also serves the redirect routes for downloads / album - // art (those redirect to `${publicS3BaseUrl}/maps/...` and `${publicS3BaseUrl}/albumArt/...`). - return { success: true, value: `${getEnvVars().publicS3BaseUrl}/${mapKey(id, true)}` }; - } - - async deleteFiles(id: string, temp: boolean): Promise> { - await Promise.all([removeFile(mapKey(id, temp)), removeDir(albumArtPrefix(id, temp))]); - return { success: true, value: undefined }; - } - - async promoteTempMapFiles(id: string): PromisedResult { - await this.deleteFiles(id, false); - const moveMap = await moveFile(mapKey(id, true), mapKey(id, false)); - if (!moveMap.success) return moveMap; - try { - const tempDir = devS3Path(albumArtPrefix(id, true)); - const entries = await fs.readdir(tempDir).catch(() => [] as string[]); - for (const entry of entries) { - const move = await moveFile( - `${albumArtPrefix(id, true)}${entry}`, - `${albumArtPrefix(id, false)}${entry}` - ); - if (!move.success) return move; - } - await fs.rmdir(tempDir).catch(() => undefined); - } catch (e) { - return { success: false, errors: [wrapError(e, S3Error.S3_WRITE_ERROR, { id })] }; - } - return { success: true, value: undefined }; - } -} diff --git a/src/services/maps/s3_handler_fake_memory.ts b/src/services/maps/s3_handler_fake_memory.ts deleted file mode 100644 index 4d0a56d..0000000 --- a/src/services/maps/s3_handler_fake_memory.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { PromisedResult, Result } from 'base/result'; -import * as path from 'path'; -import * as unzipper from 'unzipper'; -import { MintUploadUrlResult, S3Error, S3Handler } from './s3_handler_types'; - -/** - * In-memory S3 handler used in tests (selected via `S3_IMPLEMENTATION=fake`) so they don't require a - * real S3 bucket / minio. Unlike a pure stub it actually round-trips uploaded map archives: a test - * seeds the upload with `_putMapFileForTesting` (standing in for the client's PUT to the presigned - * URL), and getMapFile / promoteTempMapFiles / deleteFiles then behave like the real handler's - * temp-vs-permanent storage, so the upload-completion flow can be exercised end to end. - */ -export class MemoryFakeS3Handler implements S3Handler { - // Map archive bytes keyed by id. Pending uploads live in `temp` and are promoted to `permanent` - // by promoteTempMapFiles, mirroring the real handler's `.temp` suffix scheme. - private tempMapFiles = new Map(); - private permanentMapFiles = new Map(); - - private mapFileStore(temp: boolean) { - return temp ? this.tempMapFiles : this.permanentMapFiles; - } - - /** Test seam: stand in for the client uploading a zip to the presigned URL (writes to temp). */ - _putMapFileForTesting(id: string, buffer: Buffer) { - this.tempMapFiles.set(id, buffer); - } - - /** Test seam: clear all stored files; call between tests. */ - _resetForTesting() { - this.tempMapFiles.clear(); - this.permanentMapFiles.clear(); - } - - async uploadAlbumArtFiles( - _id: string, - albumArtFiles: unzipper.File[], - _temp: boolean - ): Promise> { - return { - success: true, - value: albumArtFiles.length > 0 ? path.basename(albumArtFiles[0]!.path) : undefined, - }; - } - - async getMapFile(id: string, temp: boolean): PromisedResult { - const buffer = this.mapFileStore(temp).get(id); - if (buffer == null) { - return { - success: false, - errors: [ - { - type: S3Error.S3_GET_ERROR, - internalMessage: `No fake map file stored for ${id} (temp=${temp})`, - }, - ], - }; - } - return { success: true, value: buffer }; - } - - async mintUploadUrl(id: string): Promise { - return { success: true, value: `https://fake-s3.local/upload/${id}` }; - } - - async deleteFiles(id: string, temp: boolean): Promise> { - this.mapFileStore(temp).delete(id); - return { success: true, value: undefined }; - } - - async promoteTempMapFiles(id: string): PromisedResult { - const pending = this.tempMapFiles.get(id); - if (pending != null) { - this.permanentMapFiles.set(id, pending); - this.tempMapFiles.delete(id); - } - return { success: true, value: undefined }; - } -} diff --git a/src/services/maps/s3_handler_types.ts b/src/services/maps/s3_handler_types.ts deleted file mode 100644 index 669a7dd..0000000 --- a/src/services/maps/s3_handler_types.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { PromisedResult, Result } from 'base/result'; -import * as unzipper from 'unzipper'; - -export const enum S3Error { - S3_GET_ERROR = 's3_get_error', - S3_WRITE_ERROR = 's3_write_error', - S3_DELETE_ERROR = 's3_delete_error', -} - -export type MintUploadUrlResult = - | { success: true; value: string } - | { success: false; error: unknown }; - -/** - * Handles blob storage for map archives and album art. The real implementation talks to S3; a - * fake implementation (selected via the `S3_IMPLEMENTATION` env var) returns hardcoded data so - * tests can run without a real bucket. - */ -export interface S3Handler { - uploadAlbumArtFiles( - id: string, - albumArtFiles: unzipper.File[], - temp: boolean - ): Promise>; - getMapFile(id: string, temp: boolean): PromisedResult; - mintUploadUrl(id: string): Promise; - deleteFiles(id: string, temp: boolean): Promise>; - promoteTempMapFiles(id: string): PromisedResult; -} diff --git a/src/services/maps/tests/maps.test.ts b/src/services/maps/tests/maps.test.ts index 7941b34..2c63a42 100644 --- a/src/services/maps/tests/maps.test.ts +++ b/src/services/maps/tests/maps.test.ts @@ -4,7 +4,8 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { SubmitMapRequest, SubmitMapResponse } from 'schema/maps'; import { testAuthenticate, testPost, testUser2 } from 'services/jest_helpers'; -import { MemoryFakeS3Handler } from 'services/maps/s3_handler_fake_memory'; +import { mapKey } from 'services/s3/maps_s3_handler'; +import { getSharedMemoryBucket } from 'services/s3/memory_bucket'; import { getServerContext } from 'services/server_context'; import { _setCurrentUserForTesting } from 'services/session/supabase_fake'; import { createUser } from 'services/users/users_repo'; @@ -23,12 +24,14 @@ describe('maps handler', () => { opts: { id?: string; isReupload?: boolean; uploaderId?: string } = {} ) => { const uploaderId = opts.uploaderId ?? UPLOADER.id; - const { mapsRepo, s3Handler } = await getServerContext(); + const { mapsRepo } = await getServerContext(); const buffer = await fs.readFile(path.resolve(__dirname, 'files', zipName)); const id = opts.id ?? (await _unwrap(mapsRepo.createNewMap({ title: 'placeholder', uploader: uploaderId }))).id; - (s3Handler as MemoryFakeS3Handler)._putMapFileForTesting(id, buffer); + // Seed the uploaded zip into the shared fake bucket at the temp map key (stands in for the + // client's PUT to the presigned URL). + await getSharedMemoryBucket().put(mapKey(id, true), buffer, 'application/zip'); _setCurrentUserForTesting({ id: uploaderId, email: UPLOADER.email }); const result = await completeMapUpload(id, opts.isReupload ?? false); return { result, id }; diff --git a/src/services/maps/tests/maps_pagination.test.ts b/src/services/maps/tests/maps_pagination.test.ts index f73eb32..5fbd019 100644 --- a/src/services/maps/tests/maps_pagination.test.ts +++ b/src/services/maps/tests/maps_pagination.test.ts @@ -2,7 +2,8 @@ import { completeMapUpload } from 'app/api/maps/submit/complete/complete_upload' import { _unwrap } from 'base/result'; import { MapVisibility } from 'schema/maps'; import { IdDomain, generateId } from 'services/db/id_gen'; -import { MemoryFakeS3Handler } from 'services/maps/s3_handler_fake_memory'; +import { mapKey } from 'services/s3/maps_s3_handler'; +import { getSharedMemoryBucket } from 'services/s3/memory_bucket'; import { getServerContext } from 'services/server_context'; import { _setCurrentUserForTesting } from 'services/session/supabase_fake'; import { buildMapZip } from './map_generator'; @@ -17,7 +18,7 @@ const PAGE = 20; // validates and publishes it (same path as maps.test.ts). Returns the published map id. async function uploadGeneratedMap(index: number): Promise { const n = String(index).padStart(3, '0'); - const { mapsRepo, s3Handler } = await getServerContext(); + const { mapsRepo } = await getServerContext(); const id = (await _unwrap(mapsRepo.createNewMap({ title: 'placeholder', uploader: UPLOADER.id }))) .id; // Zero-padded title/artist so lexical ordering matches numeric ordering, giving a unique total @@ -27,7 +28,7 @@ async function uploadGeneratedMap(index: number): Promise { title: `Pagination Map ${n}`, artist: `Artist ${n}`, }); - (s3Handler as MemoryFakeS3Handler)._putMapFileForTesting(id, zip); + await getSharedMemoryBucket().put(mapKey(id, true), zip, 'application/zip'); _setCurrentUserForTesting({ id: UPLOADER.id, email: UPLOADER.email }); const result = await completeMapUpload(id, false); if (!result.success) { diff --git a/src/services/s3/create_s3_bucket.ts b/src/services/s3/create_s3_bucket.ts new file mode 100644 index 0000000..3f2744c --- /dev/null +++ b/src/services/s3/create_s3_bucket.ts @@ -0,0 +1,17 @@ +import { getEnvVars } from 'services/env'; +import { DiskBucket } from './disk_bucket'; +import { getSharedMemoryBucket } from './memory_bucket'; +import { RealS3Bucket, S3Bucket } from './s3_bucket'; + +// In fake mode every bucket shares one in-memory store, so the dev S3 route and all domains see the +// same objects. +export function createS3Bucket(bucket: string, publicBaseUrl: string): S3Bucket { + switch (getEnvVars().s3Implementation) { + case 'fake': + return getSharedMemoryBucket(); + case 'dev': + return new DiskBucket(publicBaseUrl); + default: + return new RealS3Bucket(bucket); + } +} diff --git a/src/services/s3/disk_bucket.ts b/src/services/s3/disk_bucket.ts new file mode 100644 index 0000000..6eb440c --- /dev/null +++ b/src/services/s3/disk_bucket.ts @@ -0,0 +1,79 @@ +import { PromisedResult, wrapError } from 'base/result'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { MintUploadUrlResult, S3Bucket, S3Error } from './s3_bucket'; + +// Disk-backed fake bucket for `bun dev`. Browser PUTs/GETs are served by /api/dev/s3/[...path] +// through the exported diskRead/diskWrite. + +// Resolve to an absolute path per call so handlers don't depend on the ambient cwd. +function devS3Root(): string { + return path.resolve(process.cwd(), process.env.DEV_S3_ROOT || '.fake_dev/s3'); +} + +function devS3Path(key: string): string { + return path.join(devS3Root(), key); +} + +export async function diskRead(key: string): PromisedResult { + try { + return { success: true, value: await fs.readFile(devS3Path(key)) }; + } catch (e) { + return { success: false, errors: [wrapError(e, S3Error.S3_GET_ERROR, { key })] }; + } +} + +export async function diskWrite(key: string, body: Buffer): PromisedResult { + try { + const filePath = devS3Path(key); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, body); + return { success: true, value: undefined }; + } catch (e) { + return { success: false, errors: [wrapError(e, S3Error.S3_WRITE_ERROR, { key })] }; + } +} + +export class DiskBucket implements S3Bucket { + constructor(private readonly publicBaseUrl: string) {} + + get(key: string): PromisedResult { + return diskRead(key); + } + + put(key: string, body: Buffer): PromisedResult { + return diskWrite(key, body); + } + + async delete(keys: string[]): PromisedResult { + try { + await Promise.all(keys.map((key) => fs.rm(devS3Path(key), { force: true }))); + return { success: true, value: undefined }; + } catch (e) { + return { success: false, errors: [wrapError(e, S3Error.S3_DELETE_ERROR, { keys })] }; + } + } + + async move(oldKey: string, newKey: string): PromisedResult { + try { + const dstPath = devS3Path(newKey); + await fs.mkdir(path.dirname(dstPath), { recursive: true }); + await fs.rename(devS3Path(oldKey), dstPath); + return { success: true, value: undefined }; + } catch (e) { + return { success: false, errors: [wrapError(e, S3Error.S3_WRITE_ERROR, { oldKey, newKey })] }; + } + } + + async list(prefix: string): PromisedResult { + // Map prefix semantics onto a directory listing: callers list a `foo/bar/`-style prefix that + // corresponds to a directory. A missing directory just means no objects, not an error. + const entries = await fs.readdir(devS3Path(prefix)).catch(() => [] as string[]); + return { success: true, value: entries.map((entry) => `${prefix}${entry}`) }; + } + + async signUploadUrl(key: string): Promise { + // The browser PUTs here and /api/dev/s3/[...path] writes it to the same disk root. + return { success: true, value: `${this.publicBaseUrl}/${key}` }; + } +} diff --git a/src/services/s3/maps_s3_handler.ts b/src/services/s3/maps_s3_handler.ts new file mode 100644 index 0000000..a864b20 --- /dev/null +++ b/src/services/s3/maps_s3_handler.ts @@ -0,0 +1,86 @@ +import { checkExists } from 'base/preconditions'; +import { PromisedResult, Result } from 'base/result'; +import * as path from 'path'; +import * as unzipper from 'unzipper'; +import { MintUploadUrlResult, S3Bucket, S3Error, guessContentType } from './s3_bucket'; + +export const mapKey = (id: string, temp: boolean) => `maps/${id}.zip` + (temp ? '.temp' : ''); +const albumArtPrefix = (id: string, temp: boolean) => `albumArt/${id}` + (temp ? '_temp/' : '/'); + +export interface S3Handler { + uploadAlbumArtFiles( + id: string, + albumArtFiles: unzipper.File[], + temp: boolean + ): Promise>; + getMapFile(id: string, temp: boolean): PromisedResult; + mintUploadUrl(id: string): Promise; + deleteFiles(id: string, temp: boolean): Promise>; + promoteTempMapFiles(id: string): PromisedResult; +} + +export class MapsS3Handler implements S3Handler { + constructor(private readonly bucket: S3Bucket) {} + + async uploadAlbumArtFiles( + id: string, + albumArtFiles: unzipper.File[], + temp: boolean + ): Promise> { + for (const a of albumArtFiles) { + const albumArt = checkExists(a, 'albumArt'); + const filename = path.basename(albumArt.path); + const putResult = await this.bucket.put( + `${albumArtPrefix(id, temp)}${filename}`, + await albumArt.buffer(), + guessContentType(filename) + ); + if (!putResult.success) { + return putResult; + } + } + return { + success: true, + value: albumArtFiles.length > 0 ? path.basename(albumArtFiles[0]!.path) : undefined, + }; + } + + getMapFile(id: string, temp: boolean): PromisedResult { + return this.bucket.get(mapKey(id, temp)); + } + + mintUploadUrl(id: string): Promise { + return this.bucket.signUploadUrl(mapKey(id, true), 'application/zip'); + } + + async deleteFiles(id: string, temp: boolean): Promise> { + const albumArt = await this.bucket.list(albumArtPrefix(id, temp)); + const albumArtKeys = albumArt.success ? albumArt.value : []; + return this.bucket.delete([mapKey(id, temp), ...albumArtKeys]); + } + + async promoteTempMapFiles(id: string): PromisedResult { + // Delete the existing permanent files first, if any (this may be a brand-new map). + await this.deleteFiles(id, false); + + const moveMap = await this.bucket.move(mapKey(id, true), mapKey(id, false)); + if (!moveMap.success) { + return moveMap; + } + + const tempAlbumArt = await this.bucket.list(albumArtPrefix(id, true)); + if (!tempAlbumArt.success) { + return tempAlbumArt; + } + for (const key of tempAlbumArt.value) { + const moved = await this.bucket.move( + key, + key.replace(albumArtPrefix(id, true), albumArtPrefix(id, false)) + ); + if (!moved.success) { + return moved; + } + } + return { success: true, value: undefined }; + } +} diff --git a/src/services/s3/memory_bucket.ts b/src/services/s3/memory_bucket.ts new file mode 100644 index 0000000..585b5aa --- /dev/null +++ b/src/services/s3/memory_bucket.ts @@ -0,0 +1,97 @@ +import { PromisedResult } from 'base/result'; +import { getSingleton } from 'services/singleton'; +import { MintUploadUrlResult, S3Bucket, S3Error } from './s3_bucket'; + +// Cap on the total bytes held by the in-memory fake. It keeps every uploaded buffer so it can be +// served back, so a runaway test could otherwise grow it unbounded. Generous relative to anything a +// test should store; exceeding it throws rather than silently dropping data. +const MEMORY_CAP_BYTES = 64 * 1024 * 1024; + +// One shared instance backs every fake-mode bucket (see getSharedMemoryBucket), so keys must stay +// globally unique across domains (maps/, albumArt/, avatars/). +export class MemoryBucket implements S3Bucket { + private objects = new Map(); + private totalBytes = 0; + + /** Test seam: clear all stored objects; call between tests. */ + _reset() { + this.objects.clear(); + this.totalBytes = 0; + } + + async get(key: string): PromisedResult { + const object = this.objects.get(key); + if (object == null) { + return { + success: false, + errors: [ + { type: S3Error.S3_GET_ERROR, internalMessage: `No fake object stored for ${key}` }, + ], + }; + } + return { success: true, value: object.body }; + } + + async put(key: string, body: Buffer, contentType: string): PromisedResult { + const previous = this.objects.get(key); + const delta = body.length - (previous?.body.length ?? 0); + if (this.totalBytes + delta > MEMORY_CAP_BYTES) { + throw new Error(`MemoryBucket exceeded its ${MEMORY_CAP_BYTES}-byte cap storing ${key}`); + } + this.objects.set(key, { body, contentType }); + this.totalBytes += delta; + return { success: true, value: undefined }; + } + + async delete(keys: string[]): PromisedResult { + for (const key of keys) { + const object = this.objects.get(key); + if (object != null) { + this.totalBytes -= object.body.length; + this.objects.delete(key); + } + } + return { success: true, value: undefined }; + } + + async move(oldKey: string, newKey: string): PromisedResult { + const object = this.objects.get(oldKey); + if (object == null) { + return { + success: false, + errors: [ + { type: S3Error.S3_GET_ERROR, internalMessage: `No fake object to move at ${oldKey}` }, + ], + }; + } + const overwritten = this.objects.get(newKey); + if (overwritten != null) { + this.totalBytes -= overwritten.body.length; + } + this.objects.set(newKey, object); + this.objects.delete(oldKey); + return { success: true, value: undefined }; + } + + async list(prefix: string): PromisedResult { + return { + success: true, + value: [...this.objects.keys()].filter((key) => key.startsWith(prefix)), + }; + } + + async signUploadUrl(key: string): Promise { + // Tests seed uploads by calling put() directly, so this URL is never PUT to; it just needs to + // be a stable non-empty value. + return { success: true, value: `https://fake-s3.local/upload/${key}` }; + } +} + +/** + * The process-wide in-memory bucket shared by every fake-mode bucket and the dev S3 route. Backed by + * getSingleton (globalThis) so it stays one instance even if Next loads this module in more than one + * route context. + */ +export function getSharedMemoryBucket(): MemoryBucket { + return getSingleton('_sharedMemoryBucket', () => new MemoryBucket()); +} diff --git a/src/services/s3/s3_bucket.ts b/src/services/s3/s3_bucket.ts new file mode 100644 index 0000000..04a2ffd --- /dev/null +++ b/src/services/s3/s3_bucket.ts @@ -0,0 +1,168 @@ +import { + CopyObjectCommand, + DeleteObjectCommand, + DeleteObjectsCommand, + GetObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { PromisedResult, wrapError } from 'base/result'; +import { getEnvVars } from 'services/env'; + +export const enum S3Error { + S3_GET_ERROR = 's3_get_error', + S3_WRITE_ERROR = 's3_write_error', + S3_DELETE_ERROR = 's3_delete_error', +} + +export type MintUploadUrlResult = + | { success: true; value: string } + | { success: false; error: unknown }; + +export interface S3Bucket { + get(key: string): PromisedResult; + put(key: string, body: Buffer, contentType: string): PromisedResult; + delete(keys: string[]): PromisedResult; + move(oldKey: string, newKey: string): PromisedResult; + list(prefix: string): PromisedResult; + signUploadUrl(key: string, contentType: string): Promise; +} + +export function guessContentType(filename: string): string { + if (filename.endsWith('png')) { + return 'image/png'; + } else if (filename.endsWith('jpg') || filename.endsWith('jpeg')) { + return 'image/jpeg'; + } else if (filename.endsWith('bmp')) { + return 'image/bmp'; + } else if (filename.endsWith('gif')) { + return 'image/gif'; + } else if (filename.endsWith('webp')) { + return 'image/webp'; + } else if (filename.endsWith('zip')) { + return 'application/zip'; + } + return 'application/octet-stream'; +} + +export class RealS3Bucket implements S3Bucket { + private client: S3Client | undefined; + + constructor(private readonly bucket: string) {} + + private getClient(): S3Client { + if (this.client == null) { + const env = getEnvVars(); + this.client = new S3Client({ + endpoint: env.s3Endpoint, + region: env.s3Region, + credentials: { + accessKeyId: env.s3AccessKeyId, + secretAccessKey: env.s3AccessKeySecret, + }, + forcePathStyle: true, + requestChecksumCalculation: 'WHEN_REQUIRED', + }); + } + return this.client; + } + + async get(key: string): PromisedResult { + try { + const resp = await this.getClient().send( + new GetObjectCommand({ Bucket: this.bucket, Key: key }) + ); + if (!resp.Body) { + return { + success: false, + errors: [ + { type: S3Error.S3_GET_ERROR, internalMessage: 'Missing S3 body', details: { key } }, + ], + }; + } + return { success: true, value: Buffer.from(await resp.Body.transformToByteArray()) }; + } catch (e) { + return { success: false, errors: [wrapError(e, S3Error.S3_GET_ERROR, { key })] }; + } + } + + async put(key: string, body: Buffer, contentType: string): PromisedResult { + try { + await this.getClient().send( + new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + Body: body, + ContentType: contentType, + }) + ); + return { success: true, value: undefined }; + } catch (e) { + return { + success: false, + errors: [wrapError(e, S3Error.S3_WRITE_ERROR, { key, contentType })], + }; + } + } + + async delete(keys: string[]): PromisedResult { + if (keys.length === 0) { + return { success: true, value: undefined }; + } + try { + await this.getClient().send( + new DeleteObjectsCommand({ + Bucket: this.bucket, + Delete: { Objects: keys.map((k) => ({ Key: k })) }, + }) + ); + return { success: true, value: undefined }; + } catch (e) { + return { success: false, errors: [wrapError(e, S3Error.S3_DELETE_ERROR, { keys })] }; + } + } + + async move(oldKey: string, newKey: string): PromisedResult { + try { + const client = this.getClient(); + await client.send( + new CopyObjectCommand({ + Bucket: this.bucket, + Key: newKey, + CopySource: `${this.bucket}/${oldKey}`, + }) + ); + await client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: oldKey })); + return { success: true, value: undefined }; + } catch (e) { + return { success: false, errors: [wrapError(e, S3Error.S3_WRITE_ERROR, { oldKey, newKey })] }; + } + } + + async list(prefix: string): PromisedResult { + try { + const resp = await this.getClient().send( + new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix }) + ); + const keys = resp.Contents?.map((c) => c.Key).filter((k): k is string => k != null) ?? []; + return { success: true, value: keys }; + } catch (e) { + return { success: false, errors: [wrapError(e, S3Error.S3_GET_ERROR, { prefix })] }; + } + } + + async signUploadUrl(key: string, contentType: string): Promise { + try { + const url = await getSignedUrl( + this.getClient(), + new PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }), + { expiresIn: 3600 } + ); + return { success: true, value: url }; + } catch (e) { + return { success: false, error: e }; + } + } +} diff --git a/src/services/server_context.ts b/src/services/server_context.ts index f9fae24..1926a8a 100644 --- a/src/services/server_context.ts +++ b/src/services/server_context.ts @@ -2,47 +2,41 @@ import { Pool } from 'pg'; import { getDbPool } from 'services/db/pool'; import { getEnvVars } from 'services/env'; import { MapsRepo } from 'services/maps/maps_repo'; -import { RealS3Handler } from 'services/maps/s3_handler'; -import { FileFakeS3Handler } from 'services/maps/s3_handler_fake_disk'; -import { MemoryFakeS3Handler } from 'services/maps/s3_handler_fake_memory'; -import { S3Handler } from 'services/maps/s3_handler_types'; +import { createS3Bucket } from 'services/s3/create_s3_bucket'; +import { MapsS3Handler, S3Handler } from 'services/s3/maps_s3_handler'; +import { S3Bucket } from 'services/s3/s3_bucket'; import { getSingleton } from 'services/singleton'; import { FavoritesRepo } from 'services/users/favorites_repo'; import { PostgresIndex } from './search/postgres'; import { SearchIndex } from './search/types'; import { createSupabaseServerClient } from './session/supabase_server'; -// The repos and the S3 handler are cheap, stateless, and shared across requests, so they live on -// the singleton core. Postgres and Supabase are exposed as lazy getters on `getServerContext`. +// The repos, S3 handler, and assets bucket are cheap, stateless, and shared across requests, so they +// live on the singleton core. Postgres and Supabase are exposed as lazy getters on +// `getServerContext`. type ServerContextCore = { s3Handler: S3Handler; + assetsBucket: S3Bucket; mapsRepo: MapsRepo; favoritesRepo: FavoritesRepo; }; -function createS3Handler(): S3Handler { - switch (getEnvVars().s3Implementation) { - case 'fake': - return new MemoryFakeS3Handler(); - case 'dev': - return new FileFakeS3Handler(); - default: - return new RealS3Handler(); - } -} - function createServerContextCore(): ServerContextCore { - const s3Handler: S3Handler = createS3Handler(); + const env = getEnvVars(); + const s3Handler: S3Handler = new MapsS3Handler( + createS3Bucket(env.s3MapsBucket, env.publicS3BaseUrl) + ); + const assetsBucket: S3Bucket = createS3Bucket(env.s3AssetsBucket, env.publicAssetsBaseUrl); const searchIndex: SearchIndex = new PostgresIndex(); const mapsRepo = new MapsRepo(searchIndex, s3Handler); const favoritesRepo = new FavoritesRepo(mapsRepo, searchIndex); - return { s3Handler, mapsRepo, favoritesRepo }; + return { s3Handler, assetsBucket, mapsRepo, favoritesRepo }; } export function getServerContext() { const core = getSingleton('_serverContext', createServerContextCore); // Postgres and Supabase are instantiated lazily on first access, so callers (and tests) that - // don't use them — or that mock them out — never trigger a real DB connection or cookie read. + // don't use them, or that mock them out, never trigger a real DB connection or cookie read. return { get pool(): Pool { return getDbPool(); @@ -52,6 +46,7 @@ export function getServerContext() { return createSupabaseServerClient(); }, s3Handler: core.s3Handler, + assetsBucket: core.assetsBucket, mapsRepo: core.mapsRepo, favoritesRepo: core.favoritesRepo, }; diff --git a/src/services/session/session.ts b/src/services/session/session.ts index 0889bcb..c77cf8e 100644 --- a/src/services/session/session.ts +++ b/src/services/session/session.ts @@ -3,6 +3,7 @@ import 'server-only'; import { redirect } from 'next/navigation'; import { cache } from 'react'; import { UserSession } from 'schema/users'; +import { buildAvatarUrl } from 'services/users/avatar'; import { getServerContext } from 'services/server_context'; import { UserRole, getUser } from 'services/users/users_repo'; import { RoutePath, routeFor } from 'utils/routes'; @@ -18,6 +19,10 @@ export const getUserSession = cache(async (): Promise = id: metadata.id, email: user.data.user.email, username: metadata.username, + avatarUrl: + metadata.avatarCacheToken != null + ? buildAvatarUrl(metadata.id, metadata.avatarCacheToken) + : undefined, }; }); diff --git a/src/services/session/supabase_fake.ts b/src/services/session/supabase_fake.ts index 14c4db0..c906cc9 100644 --- a/src/services/session/supabase_fake.ts +++ b/src/services/session/supabase_fake.ts @@ -14,7 +14,13 @@ import * as db from 'zapatos/db'; const FAKE_SESSION_COOKIE = 'fake-supabase-session'; -type FakeSession = { id: string; username: string; email: string; supabaseId: string }; +type FakeSession = { + id: string; + username: string; + email: string; + supabaseId: string; + avatarCacheToken?: string; +}; // In-process tests (no HTTP cookie scope) opt into an authenticated session by calling // `_setCurrentUserForTesting`. Without it, `getUser()` returns no-user so missing auth surfaces @@ -127,14 +133,53 @@ export function fakeSupabaseClient(): SupabaseClient { user: { id: session.supabaseId, email: session.email, - user_metadata: { id: session.id, username: session.username }, + user_metadata: { + id: session.id, + username: session.username, + avatarCacheToken: session.avatarCacheToken, + }, }, }, error: null, }; }, - async updateUser() { + // Merge the updated metadata back into the session cookie so subsequent getUser() calls (and + // therefore the session) reflect it, mirroring real Supabase's persisted user_metadata. Outside + // a request scope (in-process tests) there's no cookie to update, so this is a no-op there. + async updateUser(attributes: { data?: Record }) { + try { + const cookie = (await cookies()).get(FAKE_SESSION_COOKIE); + if (cookie) { + const existing = decode(cookie.value); + // Only merge known metadata fields. Real Supabase's updateUser({ data }) writes to + // user_metadata and can't touch identity fields (id/email/supabaseId), so whitelisting + // here keeps the fake from diverging. + const updated: FakeSession = { + ...existing, + avatarCacheToken: + (attributes.data?.avatarCacheToken as string | undefined) ?? + existing.avatarCacheToken, + }; + await setSessionCookie(updated); + return { + data: { + user: { + id: updated.supabaseId, + email: updated.email, + user_metadata: { + id: updated.id, + username: updated.username, + avatarCacheToken: updated.avatarCacheToken, + }, + }, + }, + error: null, + }; + } + } catch { + /* no request scope */ + } return { data: { user: null }, error: null }; }, diff --git a/src/services/users/avatar.ts b/src/services/users/avatar.ts new file mode 100644 index 0000000..af43d79 --- /dev/null +++ b/src/services/users/avatar.ts @@ -0,0 +1,9 @@ +import { getEnvVars } from 'services/env'; + +export const avatarKey = (userId: string) => `avatars/${userId}.png`; + +// cacheToken is an opaque cache-buster: a fresh value on each change yields a new URL so the CDN +// serves the new image. +export function buildAvatarUrl(userId: string, cacheToken: string): string { + return `${getEnvVars().publicAssetsBaseUrl}/${avatarKey(userId)}?v=${cacheToken}`; +} diff --git a/src/ui/nav_bar/nav_bar.module.css b/src/ui/nav_bar/nav_bar.module.css index e6a3f2b..5ee9d46 100644 --- a/src/ui/nav_bar/nav_bar.module.css +++ b/src/ui/nav_bar/nav_bar.module.css @@ -23,6 +23,15 @@ margin: 0 var(--gridBaseline); } +.avatar { + width: calc(var(--gridBaseline) * 3); + height: calc(var(--gridBaseline) * 3); + border-radius: 50%; + object-fit: cover; + vertical-align: middle; + margin-right: calc(0.5 * var(--gridBaseline)); +} + .themeToggleContainer { margin-right: var(--gridBaseline); } diff --git a/src/ui/nav_bar/nav_bar.tsx b/src/ui/nav_bar/nav_bar.tsx index 5527b5d..e45028e 100644 --- a/src/ui/nav_bar/nav_bar.tsx +++ b/src/ui/nav_bar/nav_bar.tsx @@ -61,7 +61,11 @@ function UserActions() { ) : ( <> Submit map - {` | Logged in as ${session.username} (${session.email}) | `} + {' | '} + {session.avatarUrl != null ? ( + + ) : null} + {`Logged in as ${session.username} (${session.email}) | `} Settings {' | '} diff --git a/tools/dev.sh b/tools/dev.sh index 8664418..7458c5a 100755 --- a/tools/dev.sh +++ b/tools/dev.sh @@ -43,8 +43,8 @@ if [ -n "$NEXT_PORT" ]; then base_url="http://$url_host:$NEXT_PORT" export NEXT_PUBLIC_BASE_URL="$base_url" export NEXT_PUBLIC_SUPABASE_URL="$base_url" - export S3_ENDPOINT="$base_url/api/_dev/s3" - export PUBLIC_S3_BASE_URL="$base_url/api/_dev/s3" + export S3_ENDPOINT="$base_url/api/dev/s3" + export PUBLIC_S3_BASE_URL="$base_url/api/dev/s3" PG_REQUESTED_PORT=0 fi diff --git a/tools/e2e_setup_s3.ts b/tools/e2e_setup_s3.ts index 8332537..3c1bd8f 100644 --- a/tools/e2e_setup_s3.ts +++ b/tools/e2e_setup_s3.ts @@ -1,7 +1,8 @@ /** - * Prepares the Minio bucket the E2E suite uploads to. Run once after Minio is up and before the - * tests (see tools/e2e.sh): creates the maps bucket if missing and grants anonymous read on its - * objects, because the map download route redirects the browser straight to the public object URL. + * Prepares the Minio buckets the E2E suite uploads to. Run once after Minio is up and before the + * tests (see tools/e2e.sh): creates the maps and assets buckets if missing and grants anonymous read + * on their objects, because the download / avatar URLs point the browser straight at the public + * object URL. * * Reads S3 config straight from the environment (provided by .env.e2e) rather than the app's * getEnvVars() so it stays a standalone script with no app singletons. @@ -15,18 +16,11 @@ import { const endpoint = process.env.S3_ENDPOINT!; const region = process.env.S3_REGION!; -const bucket = process.env.S3_MAPS_BUCKET!; const accessKeyId = process.env.S3_ACCESS_KEY_ID!; const secretAccessKey = process.env.S3_ACCESS_KEY_SECRET!; +const buckets = [process.env.S3_MAPS_BUCKET!, process.env.S3_ASSETS_BUCKET!]; -async function main() { - const client = new S3Client({ - endpoint, - region, - credentials: { accessKeyId, secretAccessKey }, - forcePathStyle: true, - }); - +async function setupBucket(client: S3Client, bucket: string) { try { await client.send(new HeadBucketCommand({ Bucket: bucket })); console.log(`Bucket ${bucket} already exists`); @@ -35,7 +29,7 @@ async function main() { console.log(`Created bucket ${bucket}`); } - // Anonymous read on objects so the download redirect (browser -> Minio public URL) works. + // Anonymous read on objects so the download / avatar redirects (browser -> Minio public URL) work. const policy = { Version: '2012-10-17', Statement: [ @@ -52,6 +46,19 @@ async function main() { console.log(`Set public-read policy on ${bucket}`); } +async function main() { + const client = new S3Client({ + endpoint, + region, + credentials: { accessKeyId, secretAccessKey }, + forcePathStyle: true, + }); + + for (const bucket of buckets) { + await setupBucket(client, bucket); + } +} + main().catch((e) => { console.error('Failed to set up Minio bucket:', e); process.exit(1);