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///, 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, 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 }); }