"use server"; import { redirect } from "next/navigation"; import { person } from "@/lib/actor"; import { git, GitError, objectType, revParse } from "@/lib/git"; import { cleanRepoPath, commitFiles, isValidBranchName, type FileChange } from "@/lib/git-write"; import { encodePath } from "@/lib/format"; import { assertCanCommit, ProtectionError } from "@/lib/protection"; import { afterRefsUpdatedInBackground } from "@/lib/push"; import { markPushed, type RepoRecord } from "@/lib/repos"; import type { User } from "@/lib/users"; import { ActionError, repoForAction } from "./guard"; import type { FormState } from "./types"; const str = (formData: FormData, key: string) => String(formData.get(key) ?? "").trim(); /** Blob sha of a path at a commit, or null if it doesn't exist there. */ async function blobAt(dir: string, commit: string, path: string) { return (await git(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${commit}:${path}`]).catch(() => "")).trim() || null; } /** * Commits `changes` from the web UI, either directly to `branch` or to a new * branch, and returns the branch that received the commit. `touched` lists the * paths whose content the user saw; if any changed on the branch since the page * was loaded (`baseSha`), the commit is refused instead of overwriting them. */ async function commitFromForm( repo: RepoRecord, viewer: User, formData: FormData, changes: FileChange[], touched: string[], defaultMessage: string, ) { const branch = str(formData, "branch"); const baseSha = str(formData, "baseSha"); if (baseSha && !/^[0-9a-f]{40,64}$/.test(baseSha)) throw new ActionError("Invalid base commit."); const head = await revParse(repo.dir, `refs/heads/${branch}`); if (baseSha) { if (!head) throw new ActionError(`Branch ${branch} no longer exists.`); if (head !== baseSha) { for (const path of touched) { if ((await blobAt(repo.dir, baseSha, path)) !== (await blobAt(repo.dir, head, path))) { throw new ActionError(`${path} was changed on ${branch} since you started editing. Reload to get the latest version.`); } } } } else if (head) { throw new ActionError(`Branch ${branch} was created by someone else in the meantime. Reload and try again.`); } const summary = str(formData, "message") || defaultMessage; const description = str(formData, "description"); const message = `${summary}${description ? `\n\n${description}` : ""}\n`; const author = { name: viewer.name || viewer.username, email: viewer.email }; let target = branch; let commit: string; if (formData.get("target") === "branch") { target = str(formData, "newBranch"); if (!(await isValidBranchName(repo.dir, target))) throw new ActionError(`${target || "That"} is not a valid branch name.`); await assertCanCommit(repo, person(viewer.id), target, null); commit = await commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author }); } else { await assertCanCommit(repo, person(viewer.id), branch, head); commit = await commitFiles(repo.dir, { branch, parent: head, changes, message, author }); } await markPushed(repo.id); // A commit from the browser starts workflows just like a push of it would. const ref = `refs/heads/${target}`; const before = new Map(); if (head && target === branch) before.set(ref, head); afterRefsUpdatedInBackground(repo, before, new Map([[ref, commit]]), person(viewer.id)); return target; } /** * Where to go after committing from a form: the file (or folder) on its branch, or for a commit to a * new branch the comparison with the branch it came from, to open a pull request ("propose changes"). */ function after(repo: RepoRecord, formData: FormData, branch: string, page: string) { if (formData.get("target") !== "branch") return page; return `/${repo.owner}/${repo.name}/compare?${new URLSearchParams({ base: str(formData, "branch"), head: branch })}`; } async function handle(fn: () => Promise): Promise { let destination: string; try { destination = await fn(); } catch (e) { if (e instanceof ActionError || e instanceof GitError || e instanceof ProtectionError) return { error: e.message }; throw e; } redirect(destination); } export async function saveFileAction(owner: string, name: string, _prev: FormState, formData: FormData) { return handle(async () => { const { repo, viewer } = await repoForAction(owner, name, "write"); const path = cleanRepoPath(str(formData, "path")); if (!path) throw new ActionError("That file name is invalid."); const originalPath = str(formData, "originalPath"); const content = Buffer.from(String(formData.get("content") ?? "")); const head = await revParse(repo.dir, `refs/heads/${str(formData, "branch")}`); // Refuse to silently overwrite a different existing file. if (path !== originalPath && head && (await objectType(repo.dir, head, path))) { throw new ActionError(`A file named ${path} already exists.`); } const changes: FileChange[] = [{ path, content }]; if (originalPath && originalPath !== path) changes.unshift({ path: originalPath, delete: true }); const filename = path.split("/").pop()!; const verb = !originalPath ? "Create" : originalPath !== path ? "Rename" : "Update"; const branch = await commitFromForm(repo, viewer, formData, changes, originalPath ? [originalPath] : [], `${verb} ${filename}`); return after(repo, formData, branch, `/${repo.owner}/${repo.name}/blob/${encodePath(branch)}/${encodePath(path)}`); }); } export async function deleteFileAction(owner: string, name: string, _prev: FormState, formData: FormData) { return handle(async () => { const { repo, viewer } = await repoForAction(owner, name, "write"); const path = cleanRepoPath(str(formData, "path")); if (!path) throw new ActionError("That file name is invalid."); const branch = await commitFromForm(repo, viewer, formData, [{ path, delete: true }], [path], `Delete ${path.split("/").pop()}`); // Go to the closest directory that still exists. const parts = path.split("/").slice(0, -1); while (parts.length && (await objectType(repo.dir, `refs/heads/${branch}`, parts.join("/"))) !== "tree") parts.pop(); const suffix = parts.length ? `/${encodePath(parts.join("/"))}` : ""; const hasFiles = await revParse(repo.dir, `refs/heads/${branch}`); return after(repo, formData, branch, hasFiles ? `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${suffix}` : `/${repo.owner}/${repo.name}`); }); } const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; export async function uploadFilesAction(owner: string, name: string, _prev: FormState, formData: FormData) { return handle(async () => { const { repo, viewer } = await repoForAction(owner, name, "write"); const dir = str(formData, "dir"); const files = formData.getAll("files").filter((f): f is File => f instanceof File && f.size > 0); if (files.length === 0) throw new ActionError("Choose at least one file to upload."); const changes: FileChange[] = []; for (const file of files) { if (file.size > MAX_UPLOAD_BYTES) throw new ActionError(`${file.name} is larger than 10 MB.`); const path = cleanRepoPath(dir ? `${dir}/${file.name}` : file.name); if (!path) throw new ActionError(`${file.name} is not a valid file name.`); changes.push({ path, content: Buffer.from(await file.arrayBuffer()) }); } const branch = await commitFromForm( repo, viewer, formData, changes, changes.map((c) => c.path), files.length === 1 ? `Add ${files[0].name}` : `Add ${files.length} files`, ); return after(repo, formData, branch, `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}`); }); }