mirror of
https://gitbruv.vercel.app/api/git/bruv/gitbruv.git
synced 2025-12-20 23:24:09 +01:00
432 lines
12 KiB
TypeScript
432 lines
12 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { db } from "@/db";
|
|
import { users, repositories, accounts } from "@/db/schema";
|
|
import { eq, and } from "drizzle-orm";
|
|
import git from "isomorphic-git";
|
|
import { createR2Fs, getRepoPrefix } from "@/lib/r2-fs";
|
|
import { scrypt, timingSafeEqual } from "crypto";
|
|
|
|
async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const [, params, salt, key] = hash.split("$");
|
|
if (!params || !salt || !key) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
const paramsObj: Record<string, number> = {};
|
|
params.split(",").forEach((p) => {
|
|
const [k, v] = p.split("=");
|
|
paramsObj[k] = parseInt(v, 10);
|
|
});
|
|
|
|
const keyBuffer = Buffer.from(key, "base64");
|
|
|
|
scrypt(password, salt, keyBuffer.length, { N: paramsObj.n || 16384, r: paramsObj.r || 8, p: paramsObj.p || 1 }, (err, derivedKey) => {
|
|
if (err) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
resolve(timingSafeEqual(keyBuffer, derivedKey));
|
|
});
|
|
} catch {
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function authenticateUser(authHeader: string | null): Promise<{ id: string; username: string } | null> {
|
|
if (!authHeader || !authHeader.startsWith("Basic ")) {
|
|
return null;
|
|
}
|
|
|
|
const base64Credentials = authHeader.split(" ")[1];
|
|
const credentials = Buffer.from(base64Credentials, "base64").toString("utf-8");
|
|
const [email, password] = credentials.split(":");
|
|
|
|
if (!email || !password) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const user = await db.query.users.findFirst({
|
|
where: eq(users.email, email),
|
|
});
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const account = await db.query.accounts.findFirst({
|
|
where: and(eq(accounts.userId, user.id), eq(accounts.providerId, "credential")),
|
|
});
|
|
|
|
if (!account?.password) {
|
|
return null;
|
|
}
|
|
|
|
const valid = await verifyPassword(password, account.password);
|
|
if (!valid) {
|
|
return null;
|
|
}
|
|
|
|
return { id: user.id, username: user.username };
|
|
} catch (err) {
|
|
console.error("Auth error:", err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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: any, gitdir: string, service: string): Promise<Buffer> {
|
|
const capabilities =
|
|
service === "git-upload-pack"
|
|
? [
|
|
"multi_ack",
|
|
"thin-pack",
|
|
"side-band",
|
|
"side-band-64k",
|
|
"ofs-delta",
|
|
"shallow",
|
|
"no-progress",
|
|
"include-tag",
|
|
"multi_ack_detailed",
|
|
"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 handleUploadPack(fs: any, 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 packfile = await git.packObjects({
|
|
fs,
|
|
gitdir,
|
|
oids: wants,
|
|
});
|
|
|
|
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: any, 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 {
|
|
const packPath = `${gitdir}/objects/pack/pack-temp.pack`;
|
|
const packDir = `${gitdir}/objects/pack`;
|
|
|
|
await fs.promises.mkdir(packDir, { recursive: true }).catch(() => {});
|
|
await fs.promises.writeFile(packPath, packData);
|
|
|
|
const { oids } = await git.indexPack({ fs, dir: gitdir, gitdir, filepath: "objects/pack/pack-temp.pack" });
|
|
console.log("Indexed objects:", oids.length);
|
|
|
|
await fs.promises.unlink(packPath).catch(() => {});
|
|
|
|
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(`${gitdir}/${refPath}`).catch(() => {});
|
|
} else {
|
|
const refDir = refPath.split("/").slice(0, -1).join("/");
|
|
await fs.promises.mkdir(`${gitdir}/${refDir}`, { recursive: true }).catch(() => {});
|
|
await fs.promises.writeFile(`${gitdir}/${refPath}`, update.newOid + "\n");
|
|
}
|
|
}
|
|
|
|
const response = ["unpack ok"];
|
|
for (const update of updates) {
|
|
response.push(`ok ${update.ref}`);
|
|
}
|
|
|
|
let result = "";
|
|
for (const line of response) {
|
|
const pktLine = line + "\n";
|
|
const len = (pktLine.length + 4).toString(16).padStart(4, "0");
|
|
result += len + pktLine;
|
|
}
|
|
result += "0000";
|
|
|
|
return Buffer.from(result);
|
|
} catch (err) {
|
|
console.error("Receive pack 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 { 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 authenticateUser(request.headers.get("authorization"));
|
|
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 authenticateUser(request.headers.get("authorization"));
|
|
if (!user || user.id !== repo.ownerId) {
|
|
return new NextResponse("Unauthorized", {
|
|
status: 401,
|
|
headers: { "WWW-Authenticate": 'Basic realm="gitbruv"' },
|
|
});
|
|
}
|
|
}
|
|
|
|
const repoPrefix = getRepoPrefix(owner.id, repoName);
|
|
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 { 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 authenticateUser(request.headers.get("authorization"));
|
|
|
|
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);
|
|
const fs = createR2Fs(repoPrefix);
|
|
|
|
let response: Buffer;
|
|
|
|
if (action === "git-upload-pack") {
|
|
response = await handleUploadPack(fs, "/", body);
|
|
} else {
|
|
response = await handleReceivePack(fs, "/", body);
|
|
}
|
|
|
|
return new NextResponse(new Uint8Array(response), {
|
|
headers: {
|
|
"Content-Type": `application/x-${action}-result`,
|
|
"Cache-Control": "no-cache",
|
|
},
|
|
});
|
|
}
|