Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions public/users/accounts.html
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,33 @@ <h1 class="page-subtitle">Account Settings</h1>
>
Change
</button>
<button
type="button"
id="remove-avatar-btn"
title="Remove image"
style="
position: absolute;
top: -5px;
right: -5px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #ea4335;
color: white;
border: 2px solid white;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
padding: 0;
line-height: 1;
font-weight: bold;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
"
>
</button>
</div>
<div>
<h2 id="display-account-name" style="margin: 0; color: #333">Loading...</h2>
Expand Down Expand Up @@ -198,6 +225,7 @@ <h2>Team Members</h2>
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');
Expand Down Expand Up @@ -243,6 +271,32 @@ <h2>Team Members</h2>
}
};

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}`);
Expand All @@ -268,6 +322,11 @@ <h2>Team Members</h2>
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) {
Expand Down
80 changes: 80 additions & 0 deletions public/users/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ <h1 class="page-subtitle">User Profile</h1>
<div class="avatar-section">
<div style="position: relative">
<img id="profile-picture" class="avatar-large" src="" alt="Profile Picture" style="display: none" />
<div
id="profile-avatar-placeholder"
class="avatar-large"
style="background: #f1f3f4; display: flex; align-items: center; justify-content: center; color: #5f6368"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="width: 48px; height: 48px"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<input type="file" id="avatar-input" accept="image/*" style="display: none" />
<button
type="button"
Expand All @@ -64,6 +82,33 @@ <h1 class="page-subtitle">User Profile</h1>
>
Change
</button>
<button
type="button"
id="remove-avatar-btn"
title="Remove image"
style="
position: absolute;
top: -5px;
right: -5px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #ea4335;
color: white;
border: 2px solid white;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
padding: 0;
line-height: 1;
font-weight: bold;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
"
>
</button>
</div>
<div>
<p id="display-email" style="margin: 0.25rem 0 0 0; color: #666"></p>
Expand Down Expand Up @@ -173,10 +218,14 @@ <h3>Link another account</h3>
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"]');
Expand Down Expand Up @@ -228,6 +277,11 @@ <h3>Link another account</h3>
// 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');
Expand All @@ -243,6 +297,32 @@ <h3>Link another account</h3>
}
};

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;
Expand Down
38 changes: 28 additions & 10 deletions src/AccountDO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
}

Expand Down
1 change: 1 addition & 0 deletions src/StartupAPIEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export type StartupAPIEnv = {
SESSION_SECRET: string;
ENVIRONMENT?: string;
SYSTEM: DurableObjectNamespace;
IMAGE_STORAGE: R2Bucket;
} & Env;
42 changes: 31 additions & 11 deletions src/UserDO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
}
}
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 } });
Expand Down
Loading