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
7 changes: 4 additions & 3 deletions public/users/power-strip.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,10 @@ class PowerStrip extends HTMLElement {
}

render() {
const googleLink = `${this.basePath}/auth/google`;
const twitchLink = `${this.basePath}/auth/twitch`;
const logoutLink = `${this.basePath}/logout`;
const returnUrl = encodeURIComponent(window.location.href);
const googleLink = `${this.basePath}/auth/google?return_url=${returnUrl}`;
const twitchLink = `${this.basePath}/auth/twitch?return_url=${returnUrl}`;
const logoutLink = `${this.basePath}/logout?return_url=${returnUrl}`;

const providersStr = this.getAttribute('providers') || '';
const providers = providersStr.split(',');
Expand Down
30 changes: 17 additions & 13 deletions src/AccountDO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,14 @@ export class AccountDO extends DurableObject {
}

async getInfo() {
const result = this.sql.exec('SELECT key, value FROM account_info');
const info: Record<string, any> = {};
for (const row of result) {
// @ts-ignore
info[row.key] = JSON.parse(row.value as string);
}
try {
const result = this.sql.exec('SELECT key, value FROM account_info');
for (const row of result) {
// @ts-ignore
info[row.key] = JSON.parse(row.value as string);
}
} catch (e) {}
return info;
}

Expand Down Expand Up @@ -191,9 +193,6 @@ 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 });
Expand All @@ -202,17 +201,22 @@ export class AccountDO extends DurableObject {
await this.env.IMAGE_STORAGE.delete(keys);
}

// Wipe all Durable Object storage
await this.ctx.storage.deleteAll();

return { success: true };
}

// Billing Implementation

private getBillingState(): any {
const result = this.sql.exec("SELECT value FROM account_info WHERE key = 'billing'");
for (const row of result) {
// @ts-ignore
return JSON.parse(row.value as string);
}
try {
const result = this.sql.exec("SELECT value FROM account_info WHERE key = 'billing'");
for (const row of result) {
// @ts-ignore
return JSON.parse(row.value as string);
}
} catch (e) {}
return {
plan_slug: 'free',
status: 'active',
Expand Down
127 changes: 74 additions & 53 deletions src/UserDO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,55 +55,59 @@ export class UserDO extends DurableObject {
* @returns A Promise resolving to the session status and user profile.
*/
async validateSession(sessionId: string) {
// Check session
const sessionResult = this.sql.exec('SELECT * FROM sessions WHERE id = ?', sessionId);
const session = sessionResult.next().value as any;
try {
// Check session
const sessionResult = this.sql.exec('SELECT * FROM sessions WHERE id = ?', sessionId);
const session = sessionResult.next().value as any;

if (!session) {
return { valid: false };
}
if (!session) {
return { valid: false };
}

if (session.expires_at < Date.now()) {
return { valid: false, error: 'Expired' };
}
if (session.expires_at < Date.now()) {
return { valid: false, error: 'Expired' };
}

let profile: Record<string, any> = {};
let profile: Record<string, any> = {};

// Get profile data from local 'profile' table
const customProfileResult = this.sql.exec('SELECT key, value FROM profile');
for (const row of customProfileResult) {
try {
// @ts-ignore
profile[row.key] = JSON.parse(row.value as string);
} catch (e) {}
}

// Determine login context (provider and subject_id)
const sessionMeta = session.meta ? JSON.parse(session.meta) : {};
const loginProvider = sessionMeta.provider;
let credential: Record<string, any> = {};

if (loginProvider) {
credential.provider = loginProvider;
const credResult = this.sql.exec('SELECT subject_id FROM user_credentials WHERE provider = ?', loginProvider);
const credRow = credResult.next().value as any;
if (credRow) {
credential.subject_id = credRow.subject_id;
// Get profile data from local 'profile' table
const customProfileResult = this.sql.exec('SELECT key, value FROM profile');
for (const row of customProfileResult) {
try {
// @ts-ignore
profile[row.key] = JSON.parse(row.value as string);
} catch (e) {}
}
} else {
// Fallback: get first available credential if no provider in session
const credResult = this.sql.exec('SELECT provider, subject_id FROM user_credentials LIMIT 1');
const credRow = credResult.next().value as any;
if (credRow) {
credential.provider = credRow.provider;
credential.subject_id = credRow.subject_id;

// Determine login context (provider and subject_id)
const sessionMeta = session.meta ? JSON.parse(session.meta) : {};
const loginProvider = sessionMeta.provider;
let credential: Record<string, any> = {};

if (loginProvider) {
credential.provider = loginProvider;
const credResult = this.sql.exec('SELECT subject_id FROM user_credentials WHERE provider = ?', loginProvider);
const credRow = credResult.next().value as any;
if (credRow) {
credential.subject_id = credRow.subject_id;
}
} else {
// Fallback: get first available credential if no provider in session
const credResult = this.sql.exec('SELECT provider, subject_id FROM user_credentials LIMIT 1');
const credRow = credResult.next().value as any;
if (credRow) {
credential.provider = credRow.provider;
credential.subject_id = credRow.subject_id;
}
}
}

// Ensure the ID is set
profile.id = this.ctx.id.toString();
// Ensure the ID is set
profile.id = this.ctx.id.toString();

return { valid: true, profile, credential };
return { valid: true, profile, credential };
} catch (e) {
return { valid: false };
}
}

/**
Expand All @@ -112,12 +116,14 @@ export class UserDO extends DurableObject {
* @returns A Promise resolving to a JSON response containing the profile key-value pairs.
*/
async getProfile() {
const result = this.sql.exec('SELECT key, value FROM profile');
const profile: Record<string, any> = {};
for (const row of result) {
// @ts-ignore
profile[row.key] = JSON.parse(row.value as string);
}
try {
const result = this.sql.exec('SELECT key, value FROM profile');
for (const row of result) {
// @ts-ignore
profile[row.key] = JSON.parse(row.value as string);
}
} catch (e) {}
return profile;
}

Expand Down Expand Up @@ -206,13 +212,19 @@ export class UserDO extends DurableObject {
* @returns A Promise resolving to a JSON response indicating success.
*/
async deleteSession(sessionId: string) {
this.sql.exec('DELETE FROM sessions WHERE id = ?', sessionId);
try {
this.sql.exec('DELETE FROM sessions WHERE id = ?', sessionId);
} catch (e) {}
return { success: true };
}

async getMemberships() {
const result = this.sql.exec('SELECT account_id, role, is_current FROM memberships');
return Array.from(result);
try {
const result = this.sql.exec('SELECT account_id, role, is_current FROM memberships');
return Array.from(result);
} catch (e) {
return [];
}
}

async addMembership(account_id: string, role: number, is_current?: boolean) {
Expand Down Expand Up @@ -303,10 +315,16 @@ export class UserDO extends DurableObject {
}

async delete() {
this.sql.exec('DELETE FROM profile');
this.sql.exec('DELETE FROM sessions');
this.sql.exec('DELETE FROM memberships');
this.sql.exec('DELETE FROM user_credentials');
// Delete all credentials from provider-specific CredentialDOs
const credentialsMapping = this.sql.exec('SELECT provider, subject_id FROM user_credentials');
for (const row of credentialsMapping) {
try {
const stub = this.env.CREDENTIAL.get(this.env.CREDENTIAL.idFromName(row.provider as string));
await stub.delete(row.subject_id as string);
} catch (e) {
console.error(`Failed to delete credential mapping for provider ${row.provider}`, e);
}
}

// Delete all user images from R2
const prefix = `user/${this.ctx.id.toString()}/`;
Expand All @@ -316,6 +334,9 @@ export class UserDO extends DurableObject {
await this.env.IMAGE_STORAGE.delete(keys);
}

// Wipe all Durable Object storage
await this.ctx.storage.deleteAll();

return { success: true };
}
}
55 changes: 52 additions & 3 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ export async function handleAuth(
// Handle Auth Start
for (const provider of activeProviders) {
if (provider.isMatch(path, authPath)) {
const authUrl = provider.getAuthUrl(`state-${provider.name}`);
const returnUrl = url.searchParams.get('return_url');
const stateObj = {
nonce: Math.random().toString(36).substring(2),
return_url: returnUrl,
};
const state = btoa(JSON.stringify(stateObj));
const authUrl = provider.getAuthUrl(state);
return Response.redirect(authUrl, 302);
}
}
Expand All @@ -38,6 +44,17 @@ export async function handleAuth(
const code = url.searchParams.get('code');
if (!code) return new Response('Missing code', { status: 400 });

const stateBase64 = url.searchParams.get('state');
let returnUrl: string | null = null;
if (stateBase64) {
try {
const stateObj = JSON.parse(atob(stateBase64));
returnUrl = stateObj.return_url;
} catch (e) {
console.error('Failed to parse state', e);
}
}

try {
const token = await provider.getToken(code);
const profile = await provider.getUserProfile(token.access_token);
Expand All @@ -49,6 +66,7 @@ export async function handleAuth(
const resolveData = await credentialStub.get(profile.id);

let userIdStr: string | null = null;
let staleSessionId: string | null = null;

if (resolveData) {
userIdStr = resolveData.user_id;
Expand All @@ -68,12 +86,29 @@ export async function handleAuth(
if (sessionCookieEncrypted) {
const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted);
if (sessionCookie && sessionCookie.includes(':')) {
userIdStr = sessionCookie.split(':')[1];
const parts = sessionCookie.split(':');
staleSessionId = parts[0];
userIdStr = parts[1];
}
}
}
}

if (userIdStr) {
// Verify user still exists (has a profile)
const userStub = env.USER.get(env.USER.idFromString(userIdStr));
const profileData = await userStub.getProfile();
if (Object.keys(profileData).length === 0) {
// User was deleted!
if (staleSessionId) {
try {
await userStub.deleteSession(staleSessionId);
} catch (e) {}
}
userIdStr = null;
}
}

const isNewUser = !userIdStr;
const id = userIdStr ? env.USER.idFromString(userIdStr) : env.USER.newUniqueId();
const userStub = env.USER.get(id);
Expand Down Expand Up @@ -157,8 +192,22 @@ export async function handleAuth(
const encryptedSession = await cookieManager.encrypt(`${session.sessionId}:${userIdStr}`);
const headers = new Headers();
headers.set('Set-Cookie', `session_id=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax`);
headers.set('Location', !isNewUser ? usersPath + 'profile.html' : '/');

let redirectUrl = !isNewUser ? usersPath + 'profile.html' : '/';
if (returnUrl) {
try {
const parsedReturn = new URL(returnUrl, origin);
if (parsedReturn.origin === origin) {
redirectUrl = parsedReturn.toString();
}
} catch (e) {
if (returnUrl.startsWith('/')) {
redirectUrl = returnUrl;
}
}
}

headers.set('Location', redirectUrl);
return new Response(null, { status: 302, headers });
} catch (e: any) {
return new Response('Auth failed: ' + e.message, { status: 500 });
Expand Down
Loading