diff --git a/AGENTS.md b/AGENTS.md index 79b7a96..580aa90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ This application uses Cloudflare Developer Platform, including Workers and Durab ## Script rules -- Every time you update wrangler.jsonc file, run `npm run cf-typegem` command +- Every time you update wrangler.jsonc file, run `npm run cf-typegen` command - After you update any code, run `npm run format` command ## Worker implementation diff --git a/public/users/accounts.html b/public/users/accounts.html index 70d5eee..2acb4a3 100644 --- a/public/users/accounts.html +++ b/public/users/accounts.html @@ -75,6 +75,33 @@

Account Settings

> Change +

Loading...

@@ -198,6 +225,7 @@

Team Members

img.src = `${API_BASE}/me/accounts/${currentAccountId}/avatar?t=${Date.now()}`; img.style.display = 'block'; document.getElementById('account-avatar-placeholder').style.display = 'none'; + document.getElementById('remove-avatar-btn').style.display = 'flex'; // Refresh power-strip const powerStrip = document.querySelector('power-strip'); @@ -243,6 +271,32 @@

Team Members

} }; + document.getElementById('remove-avatar-btn').onclick = async () => { + if (!confirm('Are you sure you want to remove the account profile picture?')) return; + + try { + const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}/avatar`, { + method: 'DELETE', + }); + + if (res.ok) { + showToast('Account avatar removed'); + loadAccountDetails(); + + // Refresh power-strip + const powerStrip = document.querySelector('power-strip'); + if (powerStrip && typeof powerStrip.refresh === 'function') { + powerStrip.refresh(); + } + } else { + const err = await res.text(); + throw new Error(err || 'Failed to remove account avatar'); + } + } catch (e) { + showToast(e.message); + } + }; + async function loadAccountDetails() { try { const res = await fetch(`${API_BASE}/me/accounts/${currentAccountId}`); @@ -268,6 +322,11 @@

Team Members

img.src = `${API_BASE}/me/accounts/${currentAccountId}/avatar`; img.style.display = 'block'; document.getElementById('account-avatar-placeholder').style.display = 'none'; + document.getElementById('remove-avatar-btn').style.display = 'flex'; + } else { + document.getElementById('account-avatar').style.display = 'none'; + document.getElementById('account-avatar-placeholder').style.display = 'flex'; + document.getElementById('remove-avatar-btn').style.display = 'none'; } } } catch (e) { diff --git a/public/users/profile.html b/public/users/profile.html index 2057275..33a6894 100644 --- a/public/users/profile.html +++ b/public/users/profile.html @@ -47,6 +47,24 @@

User Profile

+
+ + + + +
+

@@ -173,10 +218,14 @@

Link another account

const img = document.getElementById('profile-picture'); img.src = p.picture; img.style.display = 'block'; + document.getElementById('profile-avatar-placeholder').style.display = 'none'; + document.getElementById('remove-avatar-btn').style.display = 'flex'; } else { const img = document.getElementById('profile-picture'); img.src = ''; img.style.display = 'none'; + document.getElementById('profile-avatar-placeholder').style.display = 'flex'; + document.getElementById('remove-avatar-btn').style.display = 'none'; } const emailLabel = document.querySelector('label[for="email"]'); @@ -228,6 +277,11 @@

Link another account

// Refresh image by appending timestamp to bypass cache const img = document.getElementById('profile-picture'); img.src = `/users/me/avatar?t=${Date.now()}`; + img.style.display = 'block'; + document.getElementById('profile-avatar-placeholder').style.display = 'none'; + document.getElementById('remove-avatar-btn').style.display = 'flex'; + + loadProfile(); // Refresh power-strip const powerStrip = document.querySelector('power-strip'); @@ -243,6 +297,32 @@

Link another account

} }; + document.getElementById('remove-avatar-btn').onclick = async () => { + if (!confirm('Are you sure you want to remove your profile picture?')) return; + + try { + const res = await fetch('/users/me/avatar', { + method: 'DELETE', + }); + + if (res.ok) { + showToast('Avatar removed'); + loadProfile(); + + // Refresh power-strip + const powerStrip = document.querySelector('power-strip'); + if (powerStrip && typeof powerStrip.refresh === 'function') { + powerStrip.refresh(); + } + } else { + const err = await res.text(); + throw new Error(err || 'Failed to remove avatar'); + } + } catch (e) { + showToast(e.message); + } + }; + document.getElementById('profile-form').onsubmit = async (e) => { e.preventDefault(); const name = document.getElementById('name').value; diff --git a/src/AccountDO.ts b/src/AccountDO.ts index 6977fd6..e14a389 100644 --- a/src/AccountDO.ts +++ b/src/AccountDO.ts @@ -35,23 +35,30 @@ export class AccountDO extends DurableObject { role INTEGER, joined_at INTEGER ); - - CREATE TABLE IF NOT EXISTS images ( - key TEXT PRIMARY KEY, - value BLOB, - mime_type TEXT - ); `); } async getImage(key: string) { - const result = this.sql.exec('SELECT value, mime_type FROM images WHERE key = ?', key); - const row = result.next().value as any; - return row || null; + const r2Key = `account/${this.ctx.id.toString()}/${key}`; + const object = await this.env.IMAGE_STORAGE.get(r2Key); + if (!object) return null; + return { + value: await object.arrayBuffer(), + mime_type: object.httpMetadata?.contentType || 'image/jpeg', + }; } async storeImage(key: string, value: ArrayBuffer, mime_type: string) { - this.sql.exec('INSERT OR REPLACE INTO images (key, value, mime_type) VALUES (?, ?, ?)', key, value, mime_type); + const r2Key = `account/${this.ctx.id.toString()}/${key}`; + await this.env.IMAGE_STORAGE.put(r2Key, value, { + httpMetadata: { contentType: mime_type }, + }); + return { success: true }; + } + + async deleteImage(key: string) { + const r2Key = `account/${this.ctx.id.toString()}/${key}`; + await this.env.IMAGE_STORAGE.delete(r2Key); return { success: true }; } @@ -94,6 +101,8 @@ export class AccountDO extends DurableObject { let picture = profile.picture || null; if (image) { picture = `/users/api/users/${m.user_id}/avatar`; + } else { + picture = null; } return { @@ -184,6 +193,15 @@ export class AccountDO extends DurableObject { this.sql.exec('DELETE FROM account_info'); this.sql.exec('DELETE FROM members'); + + // Delete all account images from R2 + const prefix = `account/${this.ctx.id.toString()}/`; + const listed = await this.env.IMAGE_STORAGE.list({ prefix }); + const keys = listed.objects.map((o) => o.key); + if (keys.length > 0) { + await this.env.IMAGE_STORAGE.delete(keys); + } + return { success: true }; } diff --git a/src/StartupAPIEnv.ts b/src/StartupAPIEnv.ts index a758398..f5e2a70 100644 --- a/src/StartupAPIEnv.ts +++ b/src/StartupAPIEnv.ts @@ -10,4 +10,5 @@ export type StartupAPIEnv = { SESSION_SECRET: string; ENVIRONMENT?: string; SYSTEM: DurableObjectNamespace; + IMAGE_STORAGE: R2Bucket; } & Env; diff --git a/src/UserDO.ts b/src/UserDO.ts index 77d6f5c..6bca011 100644 --- a/src/UserDO.ts +++ b/src/UserDO.ts @@ -34,12 +34,6 @@ export class UserDO extends DurableObject { meta TEXT ); - CREATE TABLE IF NOT EXISTS images ( - key TEXT PRIMARY KEY, - value BLOB, - mime_type TEXT - ); - CREATE TABLE IF NOT EXISTS memberships ( account_id TEXT PRIMARY KEY, role INTEGER, @@ -280,22 +274,48 @@ export class UserDO extends DurableObject { } async getImage(key: string) { - const result = this.sql.exec('SELECT value, mime_type FROM images WHERE key = ?', key); - const row = result.next().value as any; - return row || null; + const r2Key = `user/${this.ctx.id.toString()}/${key}`; + const object = await this.env.IMAGE_STORAGE.get(r2Key); + if (!object) return null; + return { + value: await object.arrayBuffer(), + mime_type: object.httpMetadata?.contentType || 'image/jpeg', + }; } async storeImage(key: string, value: ArrayBuffer, mime_type: string) { - this.sql.exec('INSERT OR REPLACE INTO images (key, value, mime_type) VALUES (?, ?, ?)', key, value, mime_type); + const r2Key = `user/${this.ctx.id.toString()}/${key}`; + await this.env.IMAGE_STORAGE.put(r2Key, value, { + httpMetadata: { contentType: mime_type }, + }); + return { success: true }; + } + + async deleteImage(key: string) { + const r2Key = `user/${this.ctx.id.toString()}/${key}`; + await this.env.IMAGE_STORAGE.delete(r2Key); + + if (key === 'avatar') { + this.sql.exec("DELETE FROM profile WHERE key = 'picture'"); + } + return { success: true }; } async delete() { this.sql.exec('DELETE FROM profile'); this.sql.exec('DELETE FROM sessions'); - this.sql.exec('DELETE FROM images'); this.sql.exec('DELETE FROM memberships'); this.sql.exec('DELETE FROM user_credentials'); + + // Delete all user images from R2 + const prefix = `user/${this.ctx.id.toString()}/`; + const listed = await this.env.IMAGE_STORAGE.list({ prefix }); + const keys = listed.objects.map((o) => o.key); + if (keys.length > 0) { + await this.env.IMAGE_STORAGE.delete(keys); + } + return { success: true }; } } diff --git a/src/index.ts b/src/index.ts index b013fd5..a0de567 100644 --- a/src/index.ts +++ b/src/index.ts @@ -437,6 +437,8 @@ async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: Coo if (image) { const usersPath = env.USERS_PATH || DEFAULT_USERS_PATH; profile.picture = usersPath + 'me/avatar'; + } else { + profile.picture = null; } data.profile = profile; @@ -588,6 +590,11 @@ async function handleMeImage(request: Request, env: StartupAPIEnv, type: string, return Response.json({ success: true }); } + if (request.method === 'DELETE') { + await stub.deleteImage(type); + return Response.json({ success: true }); + } + return handleUserImage(request, env, doId, type, cookieManager); } catch (e: any) { console.error('[handleMeImage] Error:', e.message, e.stack); @@ -660,6 +667,16 @@ async function handleAccountImage( return Response.json({ success: true }); } + if (request.method === 'DELETE') { + // Only admins can delete + if (membership?.role !== AccountDO.ROLE_ADMIN && !isAdmin(user, env)) { + return new Response('Forbidden', { status: 403 }); + } + + await accountStub.deleteImage(type); + return Response.json({ success: true }); + } + const image = await accountStub.getImage(type); if (!image) return new Response('Not Found', { status: 404 }); return new Response(image.value, { headers: { 'Content-Type': image.mime_type } }); diff --git a/test/images.spec.ts b/test/images.spec.ts new file mode 100644 index 0000000..51e24e0 --- /dev/null +++ b/test/images.spec.ts @@ -0,0 +1,89 @@ +import { env } from 'cloudflare:test'; +import { describe, it, expect } from 'vitest'; + +describe('Image Storage in R2 (Transformation Disabled)', () => { + it('should store and retrieve user avatar in R2', async () => { + const id = env.USER.newUniqueId(); + const stub = env.USER.get(id); + + const imageData = new Uint8Array([1, 2, 3, 4]).buffer; + const mimeType = 'image/png'; + + // Store image + await stub.storeImage('avatar', imageData, mimeType); + + // Retrieve image + const image = await stub.getImage('avatar'); + expect(image).not.toBeNull(); + // Transformation disabled, should match input + expect(image.mime_type).toBe(mimeType); + expect(new Uint8Array(image.value)).toEqual(new Uint8Array(imageData)); + }); + + it('should store and retrieve account avatar in R2', async () => { + const id = env.ACCOUNT.newUniqueId(); + const stub = env.ACCOUNT.get(id); + + const imageData = new Uint8Array([5, 6, 7, 8]).buffer; + const mimeType = 'image/jpeg'; + + // Store image + await stub.storeImage('avatar', imageData, mimeType); + + // Retrieve image + const image = await stub.getImage('avatar'); + expect(image).not.toBeNull(); + expect(image.mime_type).toBe(mimeType); + expect(new Uint8Array(image.value)).toEqual(new Uint8Array(imageData)); + }); + + it('should return null for non-existent image', async () => { + const id = env.USER.newUniqueId(); + const stub = env.USER.get(id); + + const image = await stub.getImage('non-existent'); + expect(image).toBeNull(); + }); + + it('should cleanup R2 on user deletion', async () => { + const id = env.USER.newUniqueId(); + const stub = env.USER.get(id); + const idStr = id.toString(); + + const imageData = new Uint8Array([1, 2, 3, 4]).buffer; + await stub.storeImage('avatar', imageData, 'image/png'); + + // Verify it exists in R2 + const r2Key = `user/${idStr}/avatar`; + const object = await env.IMAGE_STORAGE.get(r2Key); + expect(object).not.toBeNull(); + + // Delete user + await stub.delete(); + + // Verify it is gone from R2 + const deletedObject = await env.IMAGE_STORAGE.get(r2Key); + expect(deletedObject).toBeNull(); + }); + + it('should cleanup R2 on account deletion', async () => { + const id = env.ACCOUNT.newUniqueId(); + const stub = env.ACCOUNT.get(id); + const idStr = id.toString(); + + const imageData = new Uint8Array([5, 6, 7, 8]).buffer; + await stub.storeImage('avatar', imageData, 'image/jpeg'); + + // Verify it exists in R2 + const r2Key = `account/${idStr}/avatar`; + const object = await env.IMAGE_STORAGE.get(r2Key); + expect(object).not.toBeNull(); + + // Delete account + await stub.delete(); + + // Verify it is gone from R2 + const deletedObject = await env.IMAGE_STORAGE.get(r2Key); + expect(deletedObject).toBeNull(); + }); +}); diff --git a/test/integration.spec.ts b/test/integration.spec.ts index 9c45a40..e817db5 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -251,11 +251,13 @@ describe('Integration Tests', () => { const resolveData = await twitchCredStub.get('t123'); expect(resolveData.user_id).toBe(userIdStr); - const isNewUser = !resolveData.user_id ? false : false; // This mimics the logic in handleAuth - expect(isNewUser).toBe(false); + const isNewUserResult = !resolveData.user_id; + expect(isNewUserResult).toBe(false); // 3. Verify that the avatar remains the same const storedImage = await userStub.getImage('avatar'); + expect(storedImage).not.toBeNull(); + expect(storedImage.mime_type).toBe('image/png'); expect(new Uint8Array(storedImage.value)).toEqual(initialAvatar); }); }); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 5528df9..49b9680 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 4e1b2c1880cfcb4dd1ad08d7b8bae460) +// Generated by Wrangler by running `wrangler types` (hash: 1b2f4131cc4427d006995ed2932f1531) // Runtime types generated with workerd@1.20260120.0 2025-09-27 global_fetch_strictly_public declare namespace Cloudflare { interface GlobalProps { @@ -7,6 +7,7 @@ declare namespace Cloudflare { durableNamespaces: "UserDO" | "AccountDO" | "SystemDO" | "CredentialDO"; } interface PreviewEnv { + IMAGE_STORAGE: R2Bucket; ASSETS: Fetcher; SESSION_SECRET: string; ADMIN_IDS: string; @@ -30,6 +31,7 @@ declare namespace Cloudflare { TWITCH_CLIENT_SECRET: string; GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_SECRET: string; + IMAGE_STORAGE: R2Bucket; ASSETS: Fetcher; USER: DurableObjectNamespace; ACCOUNT: DurableObjectNamespace; diff --git a/wrangler.jsonc b/wrangler.jsonc index 7dd43d7..69c1092 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -16,6 +16,12 @@ "observability": { "enabled": true, }, + "r2_buckets": [ + { + "binding": "IMAGE_STORAGE", + "bucket_name": "startup-api-images", + }, + ], "durable_objects": { "bindings": [ { @@ -62,6 +68,12 @@ "run_worker_first": true, "binding": "ASSETS", }, + "r2_buckets": [ + { + "binding": "IMAGE_STORAGE", + "bucket_name": "startup-api-images", + }, + ], "durable_objects": { "bindings": [ { diff --git a/wrangler.test.jsonc b/wrangler.test.jsonc index 4a286f7..2a33ddd 100644 --- a/wrangler.test.jsonc +++ b/wrangler.test.jsonc @@ -8,6 +8,12 @@ "run_worker_first": true, "binding": "ASSETS", }, + "r2_buckets": [ + { + "binding": "IMAGE_STORAGE", + "bucket_name": "startup-api-images-test", + }, + ], "durable_objects": { "bindings": [ { "name": "USER", "class_name": "UserDO" },