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