import { env } from "cloudflare:workers"; import { batch, first, run, stmt } from "./db"; import { hashPassword, randomId } from "./crypto"; import { badRequest, conflict, forbidden, notFoundError } from "./errors"; import { canAdministerAccount } from "./repos"; import type { Viewer } from "~/lib/types"; import { isReservedName } from "~/lib/reserved-names"; /** Names that still redirect stay retired so old URLs and git remotes can't be taken over; only their owner may reclaim one. */ export async function accountNameTaken(name: string, reclaimingAccountId: string | null = null): Promise { if (isReservedName(name)) return true; if ((await first<{ id: string }>("SELECT id FROM accounts WHERE name = ?", name)) !== null) return true; if ((await first("SELECT 1 AS ok FROM retired_account_names WHERE name = ? COLLATE NOCASE", name)) !== null) { return true; } const redirect = await first<{ account_id: string }>( "SELECT account_id FROM account_redirects WHERE old_name = ? COLLATE NOCASE", name, ); return redirect !== null && redirect.account_id !== reclaimingAccountId; } export function signupsEnabled(): boolean { return env.SIGNUPS_ENABLED !== "false"; } export function githubOAuthEnabled(): boolean { return Boolean(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET); } export async function createUser(input: { name: string; email: string; password: string | null; githubId: string | null; displayName: string | null; }): Promise { if (await accountNameTaken(input.name)) throw conflict("That username is already taken"); if (await first<{ account_id: string }>("SELECT account_id FROM users WHERE email = ?", input.email)) { throw conflict("An account with that email already exists"); } const isFirstUser = (await first<{ count: number }>("SELECT COUNT(*) AS count FROM users"))?.count === 0; if (!isFirstUser && !signupsEnabled()) throw badRequest("Sign ups are disabled on this instance"); const id = randomId(); const passwordHash = input.password ? await hashPassword(input.password) : null; await batch([ stmt( "INSERT INTO accounts (id, name, kind, display_name, created_at) VALUES (?, ?, 'user', ?, ?)", id, input.name, input.displayName, Date.now(), ), stmt( "INSERT INTO users (account_id, email, password_hash, github_id, is_admin) VALUES (?, ?, ?, ?, ?)", id, input.email, passwordHash, input.githubId, isFirstUser ? 1 : 0, ), ]); return id; } export async function createOrg(name: string, displayName: string | null, ownerId: string): Promise { if (await accountNameTaken(name)) throw conflict("That name is already taken"); const id = randomId(); const now = Date.now(); await batch([ stmt( "INSERT INTO accounts (id, name, kind, display_name, created_at) VALUES (?, ?, 'org', ?, ?)", id, name, displayName, now, ), stmt("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)", id, ownerId, now), ]); return id; } export type OrgRole = "owner" | "member" | "none"; /** * Adds, re-roles or removes an org member. Org admins may do anything; any member may remove themselves * (leave). Removing or demoting the org's last owner is refused. */ export async function setOrgMember(input: { org: string; username: string; role: OrgRole; viewer: Viewer | null; }): Promise { const org = await first<{ id: string }>("SELECT id FROM accounts WHERE name = ? AND kind = 'org'", input.org); if (!org) throw notFoundError("Organization not found"); const user = await first<{ id: string }>( "SELECT a.id FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.name = ?", input.username, ); const leaving = input.role === "none" && input.viewer !== null && user !== null && user.id === input.viewer.id; if (!leaving && !(await canAdministerAccount(org.id, input.viewer))) throw forbidden(); if (!user) throw notFoundError("User not found"); const membership = await first<{ role: string }>( "SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", org.id, user.id, ); if (leaving && !membership) throw notFoundError("You are not a member of this organization"); if (membership?.role === "owner" && input.role !== "owner") { const owners = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM org_members WHERE org_id = ? AND role = 'owner' AND user_id != ?", org.id, user.id, ); if (!owners?.count) throw conflict("An organization needs at least one owner; add another owner first"); } if (input.role === "none") { await run("DELETE FROM org_members WHERE org_id = ? AND user_id = ?", org.id, user.id); } else { await run( `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT (org_id, user_id) DO UPDATE SET role = excluded.role`, org.id, user.id, input.role, Date.now(), ); } }