mirror of
https://gitbruv.vercel.app/api/git/bruv/gitbruv.git
synced 2025-12-20 23:24:09 +01:00
421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { db } from "@/db";
|
|
import { users, repositories } from "@/db/schema";
|
|
import { eq, and } from "drizzle-orm";
|
|
import git from "isomorphic-git";
|
|
import { createR2Fs, getRepoPrefix, R2Fs } from "@/lib/r2-fs";
|
|
import { revalidateTag } from "next/cache";
|
|
import { rateLimit } from "@/lib/rate-limit";
|
|
import { authenticateRequest } from "@/lib/api-auth";
|
|
import { createHash } from "crypto";
|
|
|
|
function parseGitPath(pathSegments: string[]): { username: string; repoName: string; action: string | null } | null {
|
|
if (pathSegments.length < 2) return null;
|
|
|
|
const username = pathSegments[0];
|
|
let repoName = pathSegments[1];
|
|
|
|
if (repoName.endsWith(".git")) {
|
|
repoName = repoName.slice(0, -4);
|
|
}
|
|
|
|
const remainingPath = pathSegments.slice(2).join("/");
|
|
|
|
let action: string | null = null;
|
|
if (remainingPath === "info/refs") {
|
|
action = "info/refs";
|
|
} else if (remainingPath === "git-upload-pack") {
|
|
action = "git-upload-pack";
|
|
} else if (remainingPath === "git-receive-pack") {
|
|
action = "git-receive-pack";
|
|
}
|
|
|
|
return { username, repoName, action };
|
|
}
|
|
|
|
async function getRefsAdvertisement(fs: R2Fs, gitdir: string, service: string): Promise<Buffer> {
|
|
const capabilities =
|
|
service === "git-upload-pack"
|
|
? ["ofs-delta", "shallow", "no-progress", "include-tag", "symref=HEAD:refs/heads/main"]
|
|
: ["report-status", "delete-refs", "ofs-delta"];
|
|
|
|
const refs: { name: string; oid: string }[] = [];
|
|
|
|
try {
|
|
const branches = await git.listBranches({ fs, gitdir });
|
|
for (const branch of branches) {
|
|
const oid = await git.resolveRef({ fs, gitdir, ref: branch });
|
|
refs.push({ name: `refs/heads/${branch}`, oid });
|
|
}
|
|
} catch {}
|
|
|
|
try {
|
|
const tags = await git.listTags({ fs, gitdir });
|
|
for (const tag of tags) {
|
|
const oid = await git.resolveRef({ fs, gitdir, ref: tag });
|
|
refs.push({ name: `refs/tags/${tag}`, oid });
|
|
}
|
|
} catch {}
|
|
|
|
let head = "";
|
|
try {
|
|
head = await git.resolveRef({ fs, gitdir, ref: "HEAD" });
|
|
} catch {}
|
|
|
|
const lines: string[] = [];
|
|
|
|
if (refs.length === 0) {
|
|
const zeroId = "0".repeat(40);
|
|
const capsLine = `${zeroId} capabilities^{}\0${capabilities.join(" ")}\n`;
|
|
lines.push(capsLine);
|
|
} else {
|
|
const firstRef = head ? { name: "HEAD", oid: head } : refs[0];
|
|
const capsLine = `${firstRef.oid} ${firstRef.name}\0${capabilities.join(" ")}\n`;
|
|
lines.push(capsLine);
|
|
|
|
for (const ref of refs) {
|
|
if (ref.name !== firstRef.name || !head) {
|
|
lines.push(`${ref.oid} ${ref.name}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const packets: Buffer[] = [];
|
|
for (const line of lines) {
|
|
const len = (line.length + 4).toString(16).padStart(4, "0");
|
|
packets.push(Buffer.from(len + line));
|
|
}
|
|
packets.push(Buffer.from("0000"));
|
|
|
|
return Buffer.concat(packets);
|
|
}
|
|
|
|
async function collectReachableObjects(fs: R2Fs, gitdir: string, oids: string[]): Promise<string[]> {
|
|
const visited = new Set<string>();
|
|
const toVisit = [...oids];
|
|
|
|
while (toVisit.length > 0) {
|
|
const oid = toVisit.pop()!;
|
|
if (visited.has(oid)) continue;
|
|
visited.add(oid);
|
|
|
|
try {
|
|
const { object, type } = await git.readObject({ fs, gitdir, oid });
|
|
|
|
if (type === "commit") {
|
|
const commit = object as { tree: string; parent: string[] };
|
|
if (commit.tree && !visited.has(commit.tree)) {
|
|
toVisit.push(commit.tree);
|
|
}
|
|
if (commit.parent) {
|
|
for (const parent of commit.parent) {
|
|
if (!visited.has(parent)) {
|
|
toVisit.push(parent);
|
|
}
|
|
}
|
|
}
|
|
} else if (type === "tree") {
|
|
const tree = object as Array<{ oid: string }>;
|
|
for (const entry of tree) {
|
|
if (!visited.has(entry.oid)) {
|
|
toVisit.push(entry.oid);
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return Array.from(visited);
|
|
}
|
|
|
|
async function handleUploadPack(fs: R2Fs, gitdir: string, body: Buffer): Promise<Buffer> {
|
|
const lines = parsePktLines(body);
|
|
const wants: string[] = [];
|
|
const haves: string[] = [];
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("want ")) {
|
|
wants.push(line.slice(5, 45));
|
|
} else if (line.startsWith("have ")) {
|
|
haves.push(line.slice(5, 45));
|
|
}
|
|
}
|
|
|
|
if (wants.length === 0) {
|
|
return Buffer.from("0000");
|
|
}
|
|
|
|
try {
|
|
const allOids = await collectReachableObjects(fs, gitdir, wants);
|
|
|
|
const haveSet = new Set(haves);
|
|
const neededOids = allOids.filter((oid) => !haveSet.has(oid));
|
|
|
|
if (neededOids.length === 0) {
|
|
return Buffer.from("0008NAK\n0000");
|
|
}
|
|
|
|
const packfile = await git.packObjects({
|
|
fs,
|
|
gitdir,
|
|
oids: neededOids,
|
|
});
|
|
|
|
const nakLine = "0008NAK\n";
|
|
if (!packfile.packfile) {
|
|
return Buffer.from(nakLine + "0000");
|
|
}
|
|
return Buffer.concat([Buffer.from(nakLine), Buffer.from(packfile.packfile)]);
|
|
} catch (err) {
|
|
console.error("Pack error:", err);
|
|
return Buffer.from("0000");
|
|
}
|
|
}
|
|
|
|
async function handleReceivePack(fs: R2Fs, gitdir: string, body: Buffer): Promise<Buffer> {
|
|
const packStart = body.indexOf(Buffer.from("PACK"));
|
|
|
|
if (packStart === -1) {
|
|
return Buffer.from("000eunpack ok\n0000");
|
|
}
|
|
|
|
const commandSection = body.slice(0, packStart);
|
|
const packData = body.slice(packStart);
|
|
|
|
const lines = parsePktLines(commandSection);
|
|
|
|
const updates: { oldOid: string; newOid: string; ref: string }[] = [];
|
|
|
|
for (const line of lines) {
|
|
const match = line.match(/^([0-9a-f]{40}) ([0-9a-f]{40}) (.+)$/);
|
|
if (match) {
|
|
updates.push({ oldOid: match[1], newOid: match[2], ref: match[3].replace("\0", "").split(" ")[0] });
|
|
}
|
|
}
|
|
|
|
try {
|
|
await fs.promises.mkdir("/objects").catch(() => {});
|
|
await fs.promises.mkdir("/objects/pack").catch(() => {});
|
|
|
|
const packHash = createHash("sha1").update(packData).digest("hex");
|
|
const packFileName = `pack-${packHash}`;
|
|
const packPath = `/objects/pack/${packFileName}.pack`;
|
|
|
|
await fs.promises.writeFile(packPath, packData);
|
|
|
|
await git.indexPack({ fs, dir: "/", gitdir: "/", filepath: `objects/pack/${packFileName}.pack` });
|
|
|
|
for (const update of updates) {
|
|
const refPath = update.ref.startsWith("refs/") ? update.ref : `refs/heads/${update.ref}`;
|
|
|
|
if (update.newOid === "0".repeat(40)) {
|
|
await fs.promises.unlink(`/${refPath}`).catch(() => {});
|
|
} else {
|
|
const refDir = "/" + refPath.split("/").slice(0, -1).join("/");
|
|
await fs.promises.mkdir(refDir).catch(() => {});
|
|
await fs.promises.writeFile(`/${refPath}`, update.newOid + "\n");
|
|
}
|
|
}
|
|
|
|
const response = ["unpack ok"];
|
|
for (const update of updates) {
|
|
response.push(`ok ${update.ref}`);
|
|
}
|
|
|
|
let responseStr = "";
|
|
for (const line of response) {
|
|
const pktLine = line + "\n";
|
|
const len = (pktLine.length + 4).toString(16).padStart(4, "0");
|
|
responseStr += len + pktLine;
|
|
}
|
|
responseStr += "0000";
|
|
|
|
return Buffer.from(responseStr);
|
|
} catch (err) {
|
|
console.error("[ReceivePack] Error:", err);
|
|
const errMsg = `ng unpack error ${err}\n`;
|
|
const len = (errMsg.length + 4).toString(16).padStart(4, "0");
|
|
return Buffer.from(len + errMsg + "0000");
|
|
}
|
|
}
|
|
|
|
function parsePktLines(data: Buffer): string[] {
|
|
const lines: string[] = [];
|
|
let offset = 0;
|
|
|
|
while (offset < data.length) {
|
|
const lenHex = data.slice(offset, offset + 4).toString("utf-8");
|
|
const len = parseInt(lenHex, 16);
|
|
|
|
if (len === 0) {
|
|
offset += 4;
|
|
continue;
|
|
}
|
|
|
|
if (len < 4) break;
|
|
|
|
const lineData = data.slice(offset + 4, offset + len);
|
|
lines.push(lineData.toString("utf-8").trim());
|
|
offset += len;
|
|
}
|
|
|
|
return lines;
|
|
}
|
|
|
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
|
const rateLimitResult = rateLimit(request, "git", { limit: 100, windowMs: 60000 });
|
|
if (!rateLimitResult.success) {
|
|
return new NextResponse("Too Many Requests", {
|
|
status: 429,
|
|
headers: { "Retry-After": Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000).toString() },
|
|
});
|
|
}
|
|
|
|
const { path: pathSegments } = await params;
|
|
const parsed = parseGitPath(pathSegments);
|
|
|
|
if (!parsed) {
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
const { username, repoName, action } = parsed;
|
|
|
|
const owner = await db.query.users.findFirst({
|
|
where: eq(users.username, username),
|
|
});
|
|
|
|
if (!owner) {
|
|
return new NextResponse("Repository not found", { status: 404 });
|
|
}
|
|
|
|
const repo = await db.query.repositories.findFirst({
|
|
where: and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)),
|
|
});
|
|
|
|
if (!repo) {
|
|
return new NextResponse("Repository not found", { status: 404 });
|
|
}
|
|
|
|
if (repo.visibility === "private") {
|
|
const user = await authenticateRequest(request);
|
|
if (!user || user.id !== repo.ownerId) {
|
|
return new NextResponse("Unauthorized", {
|
|
status: 401,
|
|
headers: { "WWW-Authenticate": 'Basic realm="gitbruv"' },
|
|
});
|
|
}
|
|
}
|
|
|
|
if (action === "info/refs") {
|
|
const serviceQuery = request.nextUrl.searchParams.get("service");
|
|
|
|
if (serviceQuery === "git-upload-pack" || serviceQuery === "git-receive-pack") {
|
|
if (serviceQuery === "git-receive-pack") {
|
|
const user = await authenticateRequest(request);
|
|
if (!user || user.id !== repo.ownerId) {
|
|
return new NextResponse("Unauthorized", {
|
|
status: 401,
|
|
headers: { "WWW-Authenticate": 'Basic realm="gitbruv"' },
|
|
});
|
|
}
|
|
}
|
|
|
|
const repoPrefix = getRepoPrefix(owner.id, `${repoName}.git`);
|
|
const fs = createR2Fs(repoPrefix);
|
|
|
|
const refs = await getRefsAdvertisement(fs, "/", serviceQuery);
|
|
|
|
const packet = `# service=${serviceQuery}\n`;
|
|
const packetLen = (packet.length + 4).toString(16).padStart(4, "0");
|
|
const response = Buffer.concat([Buffer.from(packetLen + packet + "0000"), refs]);
|
|
|
|
return new NextResponse(new Uint8Array(response), {
|
|
headers: {
|
|
"Content-Type": `application/x-${serviceQuery}-advertisement`,
|
|
"Cache-Control": "no-cache",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
|
const rateLimitResult = rateLimit(request, "git", { limit: 30, windowMs: 60000 });
|
|
if (!rateLimitResult.success) {
|
|
return new NextResponse("Too Many Requests", {
|
|
status: 429,
|
|
headers: { "Retry-After": Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000).toString() },
|
|
});
|
|
}
|
|
|
|
const { path: pathSegments } = await params;
|
|
const parsed = parseGitPath(pathSegments);
|
|
|
|
if (!parsed) {
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
const { username, repoName, action } = parsed;
|
|
|
|
if (action !== "git-upload-pack" && action !== "git-receive-pack") {
|
|
return new NextResponse("Not found", { status: 404 });
|
|
}
|
|
|
|
const owner = await db.query.users.findFirst({
|
|
where: eq(users.username, username),
|
|
});
|
|
|
|
if (!owner) {
|
|
return new NextResponse("Repository not found", { status: 404 });
|
|
}
|
|
|
|
const repo = await db.query.repositories.findFirst({
|
|
where: and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)),
|
|
});
|
|
|
|
if (!repo) {
|
|
return new NextResponse("Repository not found", { status: 404 });
|
|
}
|
|
|
|
const user = await authenticateRequest(request);
|
|
|
|
if (action === "git-receive-pack") {
|
|
if (!user || user.id !== repo.ownerId) {
|
|
return new NextResponse("Unauthorized", {
|
|
status: 401,
|
|
headers: { "WWW-Authenticate": 'Basic realm="gitbruv"' },
|
|
});
|
|
}
|
|
} else if (repo.visibility === "private") {
|
|
if (!user || user.id !== repo.ownerId) {
|
|
return new NextResponse("Unauthorized", {
|
|
status: 401,
|
|
headers: { "WWW-Authenticate": 'Basic realm="gitbruv"' },
|
|
});
|
|
}
|
|
}
|
|
|
|
const body = Buffer.from(await request.arrayBuffer());
|
|
const repoPrefix = getRepoPrefix(owner.id, `${repoName}.git`);
|
|
const fs = createR2Fs(repoPrefix);
|
|
|
|
let response: Buffer;
|
|
|
|
if (action === "git-upload-pack") {
|
|
response = await handleUploadPack(fs, "/", body);
|
|
} else {
|
|
response = await handleReceivePack(fs, "/", body);
|
|
revalidateTag(`repo:${username}/${repoName}`, { expire: 0 });
|
|
}
|
|
|
|
return new NextResponse(new Uint8Array(response), {
|
|
headers: {
|
|
"Content-Type": `application/x-${action}-result`,
|
|
"Cache-Control": "no-cache",
|
|
},
|
|
});
|
|
}
|