"use server"; import { redirect } from "next/navigation"; import { git, GitError, objectType, revParse } from "@/lib/git"; import { cleanRepoPath, commitFiles, isValidBranchName, type FileChange } from "@/lib/git-write"; import { encodePath } from "@/lib/format"; 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; 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 commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author }); } else { await commitFiles(repo.dir, { branch, parent: head, changes, message, author }); } markPushed(repo.id); return target; } async function handle(fn: () => Promise): Promise { let destination: string; try { destination = await fn(); } catch (e) { if (e instanceof ActionError || e instanceof GitError) 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 `/${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 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 `/${repo.owner}/${repo.name}/tree/${encodePath(branch)}${dir ? `/${encodePath(dir)}` : ""}`; }); }