import { assignedToViewer } from "~/server/involvement-read"; import { createServerFn } from "@tanstack/react-start"; import { isTakenDown } from "~/lib/account-disable"; import { invalidateUnread } from "~/server/user-inbox"; import { env } from "cloudflare:workers"; import { accountOgImage } from "~/server/og/data"; import { limitWrite, limitAccountWrite } from "~/server/limits"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { all, first, MAX_IN_LIST, placeholders, run } from "~/server/db"; import { replica } from "~/server/db-replica"; import { accountDb, repoDb } from "~/server/scoped-db"; import { filterReadable } from "~/server/notification-store"; import { hashPassword } from "~/server/crypto"; import { revokeAccessTokens, revokeAccessTokensWhere } from "~/server/access-token-store"; import { createSession } from "~/server/auth"; import { confirmPassword, notifySecurityEvent, revokeAllSessions } from "~/server/account-security"; import { canListOrgMembers, createOrg, listMyOrgInvitations, listOrgInvitations as listPendingOrgInvitations, loadOrgSettings, respondToOrgInvitation, setOrgMember as setOrgMembership, updateOrgSettings as saveOrgSettings, } from "~/server/accounts"; import { ORG_DEFAULT_PERMISSIONS } from "~/server/repos"; import { deleteOrganization as removeOrganization } from "~/server/org-delete"; import { revokeUnsubscribeTokens } from "~/server/notify/prefs"; import { canAdministerAccount, toRepoSummaries, visibleReposClause } from "~/server/repos"; import { accountOrGhost, accountsById } from "~/server/people"; import { forbidden, notFoundError } from "~/server/errors"; import { accountNameFormat, displayName, password, website } from "~/lib/validation"; import type { AccountRow, JsonValue, RepoRow } from "~/lib/types"; const ACTIVITY_LIMIT = 40; /** Activity feeds read this many of the most recently pushed repositories. */ const FEED_REPOS = 30; const NOTIFICATION_LIMIT = 100; /** Stored URLs are absolute, but a malformed row must not take the whole inbox down. */ function notificationPath(url: string): string { try { const parsed = new URL(url, env.PUBLIC_URL); return `${parsed.pathname}${parsed.search}${parsed.hash}`; } catch { return "/notifications"; } } type FeedRepo = { id: string; name: string; owner_name: string }; /** Most recent activity across `repos`: activity lives in each repository's object, so this fans out and merges. */ async function activityFeed(repos: FeedRepo[], where: string, ...params: string[]) { const perRepo = await Promise.all( repos.map(async (repo) => ( await repoDb(repo.id).all<{ id: string; actor_id: string | null; kind: string; data: string; created_at: number; }>( `SELECT id, actor_id, kind, data, created_at FROM activity WHERE ${where} AND kind != 'view' ORDER BY created_at DESC LIMIT ${ACTIVITY_LIMIT}`, ...params, ) ).map((row) => ({ ...row, repo })), ), ); const rows = perRepo .flat() .sort((left, right) => right.created_at - left.created_at) .slice(0, ACTIVITY_LIMIT); const people = await accountsById(rows.map((row) => row.actor_id)); return rows.map((row) => ({ id: row.id, actor: accountOrGhost(people, row.actor_id), repo: { owner: row.repo.owner_name, name: row.repo.name }, kind: row.kind, data: JSON.parse(row.data) as { [key: string]: JsonValue }, createdAt: row.created_at, })); } export const getDashboard = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .handler(async ({ context }) => { const viewer = requireViewer(context.viewer); const repos = await all( `SELECT r.*, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE (r.owner_id = ?1 OR r.id IN (SELECT repo_id FROM collaborators WHERE user_id = ?1) OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?1)) AND ${visibleReposClause(viewer, { adminSeesAll: false }).clause} ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT 50`, viewer.id, ); const feedRepos = await all( `SELECT r.id, r.name, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE (r.id IN (SELECT repo_id FROM stars WHERE user_id = ?1) OR r.id IN (SELECT repo_id FROM watches WHERE user_id = ?1) OR r.owner_id = ?1 OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?1) OR r.id IN (SELECT repo_id FROM collaborators WHERE user_id = ?1)) AND ${visibleReposClause(viewer).clause} ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT ${FEED_REPOS}`, viewer.id, ); const feed = await activityFeed(feedRepos, "1 = 1"); const assigned = await assignedToViewer(viewer, DASHBOARD_ASSIGNED); return { repos: await toRepoSummaries(repos), feed, assigned, invitations: await listMyOrgInvitations(viewer) }; }); export const getAccountPage = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator( z.object({ name: z.string(), tab: z.enum(["repositories", "stars", "people"]).default("repositories"), }), ) .handler(async ({ data, context }) => { const account = (await first("SELECT * FROM accounts WHERE name = ?", data.name)) ?? (await first( "SELECT a.* FROM account_redirects ar JOIN accounts a ON a.id = ar.account_id WHERE ar.old_name = ?", data.name, )); if (!account || (isTakenDown(account) && !context.viewer?.isAdmin)) throw notFoundError("Account not found"); const { clause, viewerId } = visibleReposClause(context.viewer); const repos = data.tab === "stars" ? await all( `SELECT r.*, a.name AS owner_name FROM stars s JOIN repos r ON r.id = s.repo_id JOIN accounts a ON a.id = r.owner_id WHERE s.user_id = ?2 AND ${clause} ORDER BY s.created_at DESC LIMIT 100`, viewerId, account.id, ) : await all( `SELECT r.*, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.owner_id = ?2 AND ${clause} ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT 100`, viewerId, account.id, ); const canAdmin = await canAdministerAccount(account.id, context.viewer); const members = account.kind === "org" && (await canListOrgMembers(account.id, context.viewer)) ? await all<{ name: string; role: string }>( "SELECT a.name, m.role FROM org_members m JOIN accounts a ON a.id = m.user_id WHERE m.org_id = ? ORDER BY a.name", account.id, ) : []; const orgs = account.kind === "user" ? await all<{ name: string }>( `SELECT a.name FROM org_members m JOIN accounts a ON a.id = m.org_id WHERE m.user_id = ?1 AND (COALESCE(a.members_visibility, 'public') != 'members' OR ?1 = ?2 OR ?3 = 1 OR EXISTS (SELECT 1 FROM org_members vm WHERE vm.org_id = m.org_id AND vm.user_id = ?2)) ORDER BY a.name`, account.id, context.viewer?.id ?? null, context.viewer?.isAdmin ? 1 : 0, ) : []; const feed = account.kind === "user" ? await activityFeed( await all( `SELECT r.id, r.name, a.name AS owner_name FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.visibility = 'public' AND r.hidden_at IS NULL AND (r.owner_id = ?1 OR r.id IN (SELECT repo_id FROM collaborators WHERE user_id = ?1) OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?1) OR r.id IN (SELECT repo_id FROM stars WHERE user_id = ?1) OR r.id IN (SELECT repo_id FROM watches WHERE user_id = ?1)) ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT ${FEED_REPOS}`, account.id, ), "actor_id = ?", account.id, ) : []; return { account: { id: account.id, name: account.name, kind: account.kind, displayName: account.display_name, bio: account.bio, website: account.website, createdAt: account.created_at, disabled: account.disabled_at !== null && account.disabled_at !== undefined, }, repos: await toRepoSummaries(repos), members, orgs: orgs.map((org) => org.name), feed, canAdmin, canViewBilling: account.kind === "org" && (canAdmin || (context.viewer !== null && (await first( "SELECT 1 AS one FROM org_members WHERE org_id = ? AND user_id = ?", account.id, context.viewer.id, )) !== null)), invitations: canAdmin && account.kind === "org" ? await listPendingOrgInvitations(account.name, context.viewer) : [], orgSettings: canAdmin && account.kind === "org" ? await loadOrgSettings(account.id) : null, ogImage: await accountOgImage(account.name), }; }); export const updateProfile = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( z.object({ account: z.string(), displayName, bio: z.string().max(500).nullable(), website, }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); const account = await first<{ id: string }>("SELECT id FROM accounts WHERE name = ?", data.account); if (!account) throw notFoundError("Account not found"); if (!(await canAdministerAccount(account.id, viewer))) throw forbidden(); await run( "UPDATE accounts SET display_name = ?, bio = ?, website = ? WHERE id = ?", data.displayName, data.bio, data.website, account.id, ); return { ok: true }; }); export const changePassword = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ current: z.string(), next: password })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); await confirmPassword(viewer.id, data.current); await run("UPDATE users SET password_hash = ? WHERE account_id = ?", await hashPassword(data.next), viewer.id); // Sessions first: they are what locks out whoever else holds the account, before the slower token cascade. await revokeAllSessions(viewer.id); await revokeUnsubscribeTokens(viewer.id); const cascade = await revokeAccessTokensWhere(viewer.id, "1 = 1").then( () => null, (error: unknown) => error, ); await createSession(viewer.id); await notifySecurityEvent(viewer.id, "your password was changed and your personal access tokens were revoked"); if (cascade) throw cascade; return { ok: true }; }); export const revokeToken = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ id: z.string() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); await revokeAccessTokens(viewer.id, [data.id]); return { ok: true }; }); export const createOrganization = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( z.object({ name: accountNameFormat, displayName, }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); await createOrg(data.name, data.displayName, viewer.id, viewer); return { name: data.name }; }); export const deleteOrganization = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ org: z.string().min(1), confirm: z.string(), deleteRepos: z.boolean().default(false) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); return removeOrganization({ ...data, viewer }); }); export const setOrgMember = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( z.object({ org: z.string(), username: z.string(), role: z.enum(["owner", "member", "none"]), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); return setOrgMembership({ org: data.org, username: data.username, role: data.role, viewer }); }); export const respondOrgInvitation = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ org: z.string(), accept: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); await respondToOrgInvitation({ org: data.org, accept: data.accept, viewer }); return { ok: true }; }); export const updateOrgSettings = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( z.object({ org: z.string(), defaultPermission: z.enum(ORG_DEFAULT_PERMISSIONS), membersVisibility: z.enum(["public", "members"]), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); await saveOrgSettings({ org: data.org, viewer, settings: { defaultPermission: data.defaultPermission, membersVisibility: data.membersVisibility }, }); return { ok: true }; }); export const listNotifications = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(z.object({ all: z.boolean().default(false) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); const candidates = await accountDb(viewer.id).all<{ id: string; repo_id: string | null; reason: string; title: string; url: string; read_at: number | null; created_at: number; }>( `SELECT id, repo_id, reason, title, url, read_at, created_at FROM notifications WHERE user_id = ? ${data.all ? "" : "AND read_at IS NULL"} ORDER BY created_at DESC LIMIT ?`, viewer.id, NOTIFICATION_LIMIT * 2, ); const rows = (await filterReadable(viewer.id, candidates)).slice(0, NOTIFICATION_LIMIT); return rows.map((row) => ({ id: row.id, reason: row.reason, title: row.title, url: notificationPath(row.url), read: row.read_at !== null, createdAt: row.created_at, })); }); export const markNotifications = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ ids: z.array(z.string()).max(MAX_IN_LIST).nullable() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); if (data.ids === null) { await accountDb(viewer.id).run( "UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL", Date.now(), viewer.id, ); } else if (data.ids.length > 0) { await accountDb(viewer.id).run( `UPDATE notifications SET read_at = ? WHERE user_id = ? AND id IN (${placeholders(data.ids.length)})`, Date.now(), viewer.id, ...data.ids, ); } await invalidateUnread([viewer.id]); return { ok: true }; }); const ACCOUNT_RESULTS = 20; const DASHBOARD_ASSIGNED = 20; /** D1 rejects LIKE patterns over 50 bytes; a name prefix plus `%` stays well under. */ const ACCOUNT_PREFIX_CHARS = 39; /** People and organizations by name prefix (or display name prefix): the only account lookup global search needs. */ export const searchAccounts = createServerFn({ method: "GET" }) .validator(z.object({ q: z.string().trim().min(1).max(200) })) .handler(async ({ data }) => { const prefix = data.q.slice(0, ACCOUNT_PREFIX_CHARS).replace(/[\\%_]/g, (char) => `\\${char}`); return replica.all<{ name: string; kind: "user" | "org"; display_name: string | null }>( `SELECT name, kind, display_name FROM accounts WHERE disabled_at IS NULL AND (name LIKE ?1 ESCAPE '\\' OR display_name LIKE ?1 ESCAPE '\\') ORDER BY name = ?2 COLLATE NOCASE DESC, length(name), name LIMIT ${ACCOUNT_RESULTS}`, `${prefix}%`, data.q, ); });