@@ -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" },