import { createServerFn } from "@tanstack/react-start"; import { isTakenDown } from "~/lib/account-disable"; import type { Viewer } from "~/lib/types"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { all, batch, first, run, stmt } from "~/server/db"; import { assertBelowCap, CAPS, enforceRateLimit, limitBrowse, limitWrite, limitAccountWrite } from "~/server/limits"; import { assertOwnerNotDisabled, canAdministerAccount, requireRepoAccess } from "~/server/repos"; import { assertCanTransfer } from "~/server/fork-policy"; import { accountNameTaken } from "~/server/accounts"; import { profileReadme } from "~/server/profiles"; import { recordActivity } from "~/server/events/emit"; import { repoMoveStatements } from "~/server/redirects"; import { disableRepoConfigOnTransfer, repoOwnershipChangeStatements } from "~/server/integrations/ownership"; import { rebillRepoLfs } from "~/server/lfs/objects"; import { syncHostingForRepo, syncSitesForOwner } from "~/server/sites/sync"; import { confirmPassword } from "~/server/account-security"; import { badRequest, conflict, forbidden, notFoundError } from "~/server/errors"; import { accountName, repoRef } from "~/lib/validation"; import { userChanged } from "~/server/user-row"; import { isSupporter } from "~/server/supporter"; const FOLLOW_PAGE = 100; async function accountByName(name: string, viewer: Viewer | null) { const account = await first<{ id: string; name: string; kind: "user" | "org"; avatar_key: string | null; disabled_at: number | null; disabled_reason: string | null; }>("SELECT id, name, kind, avatar_key, disabled_at, disabled_reason FROM accounts WHERE name = ?", name); if (!account || (isTakenDown(account) && !viewer?.isAdmin)) throw notFoundError("Account not found"); return account; } export const getProfileExtras = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(z.object({ name: z.string().min(1).max(64) })) .handler(async ({ data, context }) => { await limitBrowse(context.viewer); const account = await accountByName(data.name, context.viewer); const counts = await first<{ followers: number; following: number }>( `SELECT (SELECT COUNT(*) FROM follows WHERE followee_id = ?1) AS followers, (SELECT COUNT(*) FROM follows WHERE follower_id = ?1) AS following`, account.id, ); const isFollowing = context.viewer ? (await first( "SELECT 1 AS ok FROM follows WHERE follower_id = ? AND followee_id = ?", context.viewer.id, account.id, )) !== null : false; const isSelf = context.viewer?.id === account.id; return { avatarUrl: account.avatar_key ? `/avatars/${account.avatar_key}` : null, followers: counts?.followers ?? 0, following: counts?.following ?? 0, isFollowing, canFollow: context.viewer !== null && !isSelf, readmeHtml: await profileReadme(account.name), pro: account.kind === "user" ? await isSupporter(account.id) : false, }; }); export const setFollow = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ name: z.string().min(1).max(64), follow: z.boolean() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitAccountWrite(viewer); const account = await accountByName(data.name, context.viewer); if (account.id === viewer.id) throw badRequest("You can't follow yourself"); if (!data.follow) { await run("DELETE FROM follows WHERE follower_id = ? AND followee_id = ?", viewer.id, account.id); return { ok: true }; } const count = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM follows WHERE follower_id = ?", viewer.id, ); assertBelowCap(count?.count ?? 0, CAPS.followingPerUser, "followed accounts"); await run( "INSERT OR IGNORE INTO follows (follower_id, followee_id, created_at) VALUES (?, ?, ?)", viewer.id, account.id, Date.now(), ); return { ok: true }; }); export const listFollows = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(z.object({ name: z.string().min(1).max(64), direction: z.enum(["followers", "following"]) })) .handler(async ({ data, context }) => { await limitBrowse(context.viewer); const account = await accountByName(data.name, context.viewer); const [mine, theirs] = data.direction === "followers" ? ["followee_id", "follower_id"] : ["follower_id", "followee_id"]; return all<{ name: string; display_name: string | null; avatar_key: string | null }>( `SELECT a.name, a.display_name, a.avatar_key FROM follows f JOIN accounts a ON a.id = f.${theirs} WHERE f.${mine} = ? AND NOT (a.disabled_at IS NOT NULL AND a.disabled_reason = 'admin') ORDER BY f.created_at DESC LIMIT ${FOLLOW_PAGE}`, account.id, ); }); /** Renames a user or org; the old name keeps redirecting (web, raw and git) and stays reserved for this account. */ export const renameAccount = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ account: z.string().min(1).max(64), newName: accountName, password: z.string().max(200) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await enforceRateLimit("AUTH_LIMITER", `rename:${viewer.id}`); await confirmPassword(viewer.id, data.password); const account = await accountByName(data.account, context.viewer); if (!(await canAdministerAccount(account.id, viewer))) throw forbidden(); if (account.name === data.newName) return { name: account.name }; if ( account.name.toLowerCase() !== data.newName.toLowerCase() && (await accountNameTaken(data.newName, account.id)) ) { throw conflict("That name is already taken"); } const now = Date.now(); await batch([ stmt("DELETE FROM account_redirects WHERE old_name = ?", data.newName), stmt("DELETE FROM repo_redirects WHERE owner_name = ?", data.newName), stmt("UPDATE accounts SET name = ? WHERE id = ?", data.newName, account.id), stmt( `INSERT INTO account_redirects (old_name, account_id, created_at) VALUES (?, ?, ?) ON CONFLICT (old_name) DO UPDATE SET account_id = excluded.account_id, created_at = excluded.created_at`, account.name, account.id, now, ), ]); await userChanged(account.id); if (account.name.toLowerCase() === data.newName.toLowerCase()) { await run("DELETE FROM account_redirects WHERE old_name = ?", account.name); } await syncSitesForOwner(account.id); await recordActivity(viewer.id, null, "rename_account", { from: account.name, to: data.newName }); return { name: data.newName }; }); export const transferRepo = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ newOwner: z.string().min(1).max(64), confirm: z.string() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "admin"); assertOwnerNotDisabled(repo, viewer); if (data.confirm !== `${repo.owner_name}/${repo.name}`) throw badRequest("Type the full repository name to confirm"); const target = await accountByName(data.newOwner, context.viewer); if (target.id === repo.owner_id) throw badRequest("The repository already belongs to that account"); await assertCanTransfer(repo, target, viewer); if (await first("SELECT 1 AS ok FROM repos WHERE owner_id = ? AND name = ?", target.id, repo.name)) { throw conflict(`${target.name} already has a repository named ${repo.name}`); } const owned = await first<{ count: number }>("SELECT COUNT(*) AS count FROM repos WHERE owner_id = ?", target.id); assertBelowCap(owned?.count ?? 0, CAPS.reposPerOwner, "repositories per owner"); const now = Date.now(); await batch([ stmt("UPDATE repos SET owner_id = ?, updated_at = ? WHERE id = ?", target.id, now, repo.id), ...repoMoveStatements( repo.id, { owner: repo.owner_name, name: repo.name }, { owner: target.name, name: repo.name }, ), ...repoOwnershipChangeStatements(repo.id, viewer.id), ]); await disableRepoConfigOnTransfer(repo.id, repo.owner_id); await rebillRepoLfs(repo.id, target.id); await syncHostingForRepo(repo.id, repo.owner_id); await recordActivity(viewer.id, repo.id, "transfer", { from: repo.owner_name, to: target.name }); return { owner: target.name, name: repo.name }; });