Vectrum
Sign In
151 lines · 6.9 KB · TypeScript
"use server";

import { redirect } from "next/navigation";
import { onRefsUpdated, startWorkflowsInBackground } from "@/lib/ci";
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;
  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.`);
    commit = await commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author });
  } else {
    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<string, string>();
  if (head && target === branch) before.set(ref, head);
  startWorkflowsInBackground(onRefsUpdated(repo, before, new Map([[ref, commit]]), viewer.id));
  return target;
}

async function handle(fn: () => Promise<string>): Promise<FormState> {
  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)}` : ""}`;
  });
}