Vectrum
Sign In
290 lines · 10.8 KB · TypeScript
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import { createWriteStream } from "node:fs";
import { mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { count, db, filter, findById, findFirst, isUniqueViolation, newId, orNull, withLock, type RecordModel } from "./db";
import { revParse } from "./git";
import { createTag, isValidTagName } from "./git-write";
import { DATA_ROOT } from "./paths";
import type { RepoRecord } from "./repos";

// Releases: a tag with notes and files to download. The records are in PocketBase; the files are on
// disk at releases/<repo id>/<release id>/<asset id>, so they are streamed in and out, never buffered.

const RELEASES_ROOT = path.join(DATA_ROOT, "releases");

export type ReleaseAsset = {
  id: string;
  name: string;
  size: number;
  sha256: string;
  downloads: number;
  createdAt: number;
  updatedAt: number;
};

export type Release = {
  id: string;
  repoId: string;
  tag: string;
  /** The branch or commit a missing tag is created from on publishing. */
  target: string;
  title: string;
  body: string;
  draft: boolean;
  prerelease: boolean;
  author: string | null;
  createdAt: number;
  updatedAt: number;
  publishedAt: number | null;
  assets: ReleaseAsset[];
};

export class ReleaseError extends Error {}

const ASSETS = "release_assets_via_release";
const EXPAND = `author,${ASSETS}`;

function toAsset(r: RecordModel): ReleaseAsset {
  return {
    id: r.id,
    name: r.name,
    size: r.size,
    sha256: r.sha256,
    downloads: r.download_count,
    createdAt: r.created_at,
    updatedAt: r.updated_at,
  };
}

function toRelease(r: RecordModel): Release {
  const assets = ((r.expand?.[ASSETS] ?? []) as RecordModel[]).map(toAsset).sort((a, b) => a.name.localeCompare(b.name));
  return {
    id: r.id,
    repoId: r.repo,
    tag: r.tag,
    target: r.target,
    title: r.title,
    body: r.body,
    draft: r.draft,
    prerelease: r.prerelease,
    author: (r.expand?.author as RecordModel | undefined)?.username ?? null,
    createdAt: r.created_at,
    updatedAt: r.updated_at,
    publishedAt: orNull(r.published_at),
    assets,
  };
}

const releases = async () => (await db()).collection("releases");
const assets = async () => (await db()).collection("release_assets");

/** The release's name, or its tag when it has none. */
export const releaseTitle = (release: Release) => release.title || release.tag;

/** Published releases first, newest first; drafts (shown to writers only) above them. */
export async function listReleases(repoId: string, opts: { drafts: boolean; page: number; perPage: number }) {
  const where = filter(opts.drafts ? "repo = {:repoId}" : "repo = {:repoId} && draft = false", { repoId });
  const { items, totalItems } = await (await releases()).getList(opts.page, opts.perPage, {
    filter: where,
    sort: "-draft,-published_at,-created_at",
    expand: EXPAND,
  });
  return { releases: items.map(toRelease), total: totalItems };
}

/** The tags that have a release, drafts included. */
export async function releaseTags(repoId: string) {
  const rows = await (await releases()).getFullList({ filter: filter("repo = {:repoId}", { repoId }), fields: "tag" });
  return new Set(rows.map((r) => r.tag as string));
}

export async function countReleases(repoId: string) {
  return count("releases", filter("repo = {:repoId} && draft = false", { repoId }));
}

export async function findRelease(repoId: string, tag: string) {
  const row = await findFirst("releases", filter("repo = {:repoId} && tag = {:tag}", { repoId, tag }), { expand: EXPAND });
  return row ? toRelease(row) : null;
}

export async function findReleaseById(repoId: string, id: string) {
  const row = await findById("releases", id, { expand: EXPAND });
  return row && row.repo === repoId ? toRelease(row) : null;
}

/** The newest published release that isn't a pre-release. */
export async function latestRelease(repoId: string) {
  const row = await findFirst("releases", filter("repo = {:repoId} && draft = false && prerelease = false", { repoId }), {
    sort: "-published_at",
    expand: EXPAND,
  });
  return row ? toRelease(row) : null;
}

/** The commit the release's tag points to, or null if the tag doesn't exist (yet). */
export function tagCommit(repo: RepoRecord, tag: string) {
  return revParse(repo.dir, `refs/tags/${tag}`);
}

export type ReleaseInput = { tag: string; target: string; title: string; body: string; prerelease: boolean; draft: boolean };

/** A branch name or a full commit id, as offered by the release form. */
async function resolveTarget(repo: RepoRecord, target: string) {
  if (!target) return null;
  return /^[0-9a-f]{40,64}$/.test(target) ? revParse(repo.dir, target) : revParse(repo.dir, `refs/heads/${target}`);
}

/**
 * Checks a release's tag and, when it's published, makes sure the tag exists: a missing tag is
 * created at the target. Returns the commit of a tag it created, so the caller can start the
 * workflows a push of that tag would.
 */
async function prepareTag(repo: RepoRecord, input: ReleaseInput, exceptId?: string) {
  if (!(await isValidTagName(repo.dir, input.tag))) throw new ReleaseError(`${input.tag || "That"} is not a valid tag name.`);
  const other = await findRelease(repo.id, input.tag);
  if (other && other.id !== exceptId) throw new ReleaseError(`There is already a release for the tag ${input.tag}.`);
  if (await tagCommit(repo, input.tag)) return null;

  const sha = await resolveTarget(repo, input.target);
  if (!sha) {
    throw new ReleaseError(
      input.target ? `${input.target} is not a branch or commit of this repository.` : `Choose the branch to create the tag ${input.tag} from.`,
    );
  }
  if (input.draft) return null;
  await createTag(repo.dir, input.tag, sha);
  return sha;
}

function fields(input: ReleaseInput) {
  return {
    tag: input.tag,
    target: input.target,
    title: input.title.trim().slice(0, 256),
    body: input.body.trim().slice(0, 65536),
    prerelease: input.prerelease,
    updated_at: Date.now(),
  };
}

export async function createRelease(repo: RepoRecord, authorId: string | null, input: ReleaseInput) {
  const createdTag = await prepareTag(repo, input);
  const now = Date.now();
  try {
    const row = await (await releases()).create({
      repo: repo.id,
      author: authorId ?? "",
      ...fields(input),
      draft: input.draft,
      created_at: now,
      published_at: input.draft ? 0 : now,
    });
    return { release: (await findReleaseById(repo.id, row.id))!, createdTag };
  } catch (e) {
    if (isUniqueViolation(e)) throw new ReleaseError(`There is already a release for the tag ${input.tag}.`);
    throw e;
  }
}

/** Saves a release's changes. `draft: false` publishes a draft; a published release stays published. */
export async function updateRelease(repo: RepoRecord, release: Release, input: ReleaseInput) {
  const draft = release.draft && input.draft;
  const createdTag = await prepareTag(repo, { ...input, draft }, release.id);
  try {
    await (await releases()).update(release.id, {
      ...fields(input),
      draft,
      ...(release.draft && !draft ? { published_at: Date.now() } : {}),
    });
  } catch (e) {
    if (isUniqueViolation(e)) throw new ReleaseError(`There is already a release for the tag ${input.tag}.`);
    throw e;
  }
  return { release: (await findReleaseById(repo.id, release.id))!, createdTag };
}

export function publishRelease(repo: RepoRecord, release: Release) {
  return updateRelease(repo, release, { ...release, draft: false });
}

/** Deletes a release and its files. Its tag stays. */
export async function deleteRelease(release: Release) {
  await (await releases()).delete(release.id);
  await rm(path.join(RELEASES_ROOT, release.repoId, release.id), { recursive: true, force: true });
}

/** Removes the files of a deleted repository's releases (PocketBase deletes the records with it). */
export async function deleteRepoReleaseFiles(repoId: string) {
  await rm(path.join(RELEASES_ROOT, repoId), { recursive: true, force: true });
}

// ---------------------------------------------------------------------------
// Assets

/** A file name for an asset: no path, no control characters. Null if there's nothing usable. */
export function cleanAssetName(input: string) {
  const name = input.trim();
  if (!name || name.length > 255 || name === "." || name === ".." || /[\x00-\x1f\x7f/\\]/.test(name)) return null;
  return name;
}

export const assetPath = (release: Release, asset: ReleaseAsset) => path.join(RELEASES_ROOT, release.repoId, release.id, asset.id);

/**
 * Stores an uploaded file as an asset of the release, replacing an asset of the same name. The
 * upload is written to a temporary file first, so a failed or aborted one leaves nothing behind.
 */
export async function storeAsset(release: Release, rawName: string, body: ReadableStream<Uint8Array>, uploaderId: string | null) {
  const name = cleanAssetName(rawName);
  if (!name) throw new ReleaseError(`${rawName || "That"} is not a valid file name.`);
  const dir = path.join(RELEASES_ROOT, release.repoId, release.id);
  await mkdir(dir, { recursive: true });
  const temp = path.join(dir, `.upload-${randomBytes(8).toString("hex")}`);
  const hash = createHash("sha256");
  let size = 0;
  const tap = new Transform({
    transform(chunk: Buffer, _enc, done) {
      hash.update(chunk);
      size += chunk.length;
      done(null, chunk);
    },
  });

  try {
    await pipeline(Readable.fromWeb(body as never), tap, createWriteStream(temp));
    const data = { size, sha256: hash.digest("hex"), uploader: uploaderId ?? "", updated_at: Date.now() };
    // Serialized per release, so two uploads of one name can't both create a record.
    return await withLock(`release-assets:${release.id}`, async () => {
      const existing = await findFirst("release_assets", filter("release = {:release} && name = {:name}", { release: release.id, name }));
      if (existing) {
        // Downloads already running keep reading the old file.
        await rename(temp, path.join(dir, existing.id));
        return toAsset(await (await assets()).update(existing.id, data));
      }
      const id = newId();
      const row = await (await assets()).create({ id, release: release.id, name, download_count: 0, created_at: data.updated_at, ...data });
      try {
        await rename(temp, path.join(dir, id));
      } catch (e) {
        await (await assets()).delete(id);
        throw e;
      }
      return toAsset(row);
    });
  } finally {
    await rm(temp, { force: true });
  }
}

export async function deleteAsset(release: Release, asset: ReleaseAsset) {
  await (await assets()).delete(asset.id);
  await rm(assetPath(release, asset), { force: true });
}

export async function countDownload(asset: ReleaseAsset) {
  await (await assets()).update(asset.id, { "download_count+": 1 });
}