mirror of
https://gitbruv.vercel.app/api/git/bruv/gitbruv.git
synced 2025-12-20 23:24:09 +01:00
rate limit and auth checks
This commit is contained in:
parent
125c6fdd6a
commit
91208e44a1
9 changed files with 403 additions and 257 deletions
|
|
@ -816,7 +816,9 @@ export async function getPublicUsers(sortBy: "newest" | "oldest" = "newest", lim
|
||||||
avatarUrl: users.avatarUrl,
|
avatarUrl: users.avatarUrl,
|
||||||
bio: users.bio,
|
bio: users.bio,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
repoCount: sql<number>`(SELECT COUNT(*) FROM repositories WHERE repositories.owner_id = users.id AND repositories.visibility = 'public')`.as("repo_count"),
|
repoCount: sql<number>`(SELECT COUNT(*) FROM repositories WHERE repositories.owner_id = users.id AND repositories.visibility = 'public')`.as(
|
||||||
|
"repo_count"
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.orderBy(sortBy === "newest" ? desc(users.createdAt) : users.createdAt)
|
.orderBy(sortBy === "newest" ? desc(users.createdAt) : users.createdAt)
|
||||||
|
|
|
||||||
169
app/(main)/new/form.tsx
Normal file
169
app/(main)/new/form.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { createRepository } from "@/actions/repositories";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2, Lock, Globe } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function NewRepoForm({ username }: { username: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
visibility: "public" as "public" | "private",
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createRepository({
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description || undefined,
|
||||||
|
visibility: formData.visibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Repository created!");
|
||||||
|
router.push(`/${username}/${formData.name.toLowerCase().replace(/\s+/g, "-")}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to create repository");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-2xl! py-8">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">Create a new repository</h1>
|
||||||
|
<p className="text-muted-foreground">A repository contains all project files, including the revision history.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6 space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="name" className="text-sm font-medium">
|
||||||
|
Repository name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-muted-foreground font-medium">{username}</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="my-awesome-project"
|
||||||
|
required
|
||||||
|
pattern="^[a-zA-Z0-9_.-]+$"
|
||||||
|
className="flex-1 bg-input/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Great repository names are short and memorable.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="description" className="text-sm font-medium">
|
||||||
|
Description <span className="text-muted-foreground">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="A short description of your project"
|
||||||
|
rows={3}
|
||||||
|
className="bg-input/50 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6 space-y-4">
|
||||||
|
<Label className="text-sm font-medium">Visibility</Label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label
|
||||||
|
className={`flex items-start gap-4 p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||||
|
formData.visibility === "public" ? "border-primary bg-primary/5" : "border-transparent bg-input/30 hover:bg-input/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||||
|
formData.visibility === "public" ? "border-primary" : "border-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formData.visibility === "public" && <div className="w-2.5 h-2.5 rounded-full bg-primary" />}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="public"
|
||||||
|
checked={formData.visibility === "public"}
|
||||||
|
onChange={() => setFormData({ ...formData, visibility: "public" })}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Public
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">Anyone on the internet can see this repository.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className={`flex items-start gap-4 p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||||
|
formData.visibility === "private" ? "border-primary bg-primary/5" : "border-transparent bg-input/30 hover:bg-input/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||||
|
formData.visibility === "private" ? "border-primary" : "border-muted-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formData.visibility === "private" && <div className="w-2.5 h-2.5 rounded-full bg-primary" />}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="visibility"
|
||||||
|
value="private"
|
||||||
|
checked={formData.visibility === "private"}
|
||||||
|
onChange={() => setFormData({ ...formData, visibility: "private" })}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
Private
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">You choose who can see and commit to this repository.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-4 pt-4">
|
||||||
|
<Button type="button" variant="ghost" asChild>
|
||||||
|
<Link href="/">Cancel</Link>
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading || !formData.name} className="min-w-[160px]">
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Create repository"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,202 +1,15 @@
|
||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
|
import { NewRepoForm } from "./form";
|
||||||
|
|
||||||
import { useState } from "react";
|
export default async function NewRepoPage() {
|
||||||
import { useRouter } from "next/navigation";
|
const session = await getSession();
|
||||||
import { createRepository } from "@/actions/repositories";
|
|
||||||
import { useSession } from "@/lib/auth-client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Loader2, Lock, Globe, BookMarked } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function NewRepoPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
visibility: "public" as "public" | "private",
|
|
||||||
});
|
|
||||||
|
|
||||||
const username = (session?.user as { username?: string } | undefined)?.username || "";
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!session?.user) {
|
|
||||||
toast.error("You must be logged in");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createRepository({
|
|
||||||
name: formData.name,
|
|
||||||
description: formData.description || undefined,
|
|
||||||
visibility: formData.visibility,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success("Repository created!");
|
|
||||||
router.push(`/${username}/${formData.name.toLowerCase().replace(/\s+/g, "-")}`);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "Failed to create repository");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="container max-w-2xl py-16">
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return (
|
redirect("/login");
|
||||||
<div className="container max-w-2xl py-16">
|
|
||||||
<div className="rounded-xl border border-border bg-card p-12 text-center">
|
|
||||||
<BookMarked className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
|
||||||
<h2 className="text-xl font-semibold mb-2">Sign in required</h2>
|
|
||||||
<p className="text-muted-foreground mb-6">Please sign in to create a repository</p>
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/login">Sign in</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const username = (session.user as { username?: string }).username || "";
|
||||||
<div className="container max-w-2xl! py-8">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold mb-2">Create a new repository</h1>
|
|
||||||
<p className="text-muted-foreground">A repository contains all project files, including the revision history.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-8">
|
return <NewRepoForm username={username} />;
|
||||||
<div className="rounded-xl border border-border bg-card p-6 space-y-6">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label htmlFor="name" className="text-sm font-medium">
|
|
||||||
Repository name <span className="text-destructive">*</span>
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-muted-foreground font-medium">{username}</span>
|
|
||||||
<span className="text-muted-foreground">/</span>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="my-awesome-project"
|
|
||||||
required
|
|
||||||
pattern="^[a-zA-Z0-9_.-]+$"
|
|
||||||
className="flex-1 bg-input/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">Great repository names are short and memorable.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label htmlFor="description" className="text-sm font-medium">
|
|
||||||
Description <span className="text-muted-foreground">(optional)</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
placeholder="A short description of your project"
|
|
||||||
rows={3}
|
|
||||||
className="bg-input/50 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-card p-6 space-y-4">
|
|
||||||
<Label className="text-sm font-medium">Visibility</Label>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label
|
|
||||||
className={`flex items-start gap-4 p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
|
||||||
formData.visibility === "public" ? "border-primary bg-primary/5" : "border-transparent bg-input/30 hover:bg-input/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
|
|
||||||
formData.visibility === "public" ? "border-primary" : "border-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formData.visibility === "public" && <div className="w-2.5 h-2.5 rounded-full bg-primary" />}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="visibility"
|
|
||||||
value="public"
|
|
||||||
checked={formData.visibility === "public"}
|
|
||||||
onChange={() => setFormData({ ...formData, visibility: "public" })}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 font-medium">
|
|
||||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Public
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">Anyone on the internet can see this repository.</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label
|
|
||||||
className={`flex items-start gap-4 p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
|
||||||
formData.visibility === "private" ? "border-primary bg-primary/5" : "border-transparent bg-input/30 hover:bg-input/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
|
|
||||||
formData.visibility === "private" ? "border-primary" : "border-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{formData.visibility === "private" && <div className="w-2.5 h-2.5 rounded-full bg-primary" />}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="visibility"
|
|
||||||
value="private"
|
|
||||||
checked={formData.visibility === "private"}
|
|
||||||
onChange={() => setFormData({ ...formData, visibility: "private" })}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 font-medium">
|
|
||||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
|
||||||
Private
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">You choose who can see and commit to this repository.</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-4 pt-4">
|
|
||||||
<Button type="button" variant="ghost" asChild>
|
|
||||||
<Link href="/">Cancel</Link>
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={loading || !formData.name} className="min-w-[160px]">
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Creating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Create repository"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { r2Get } from "@/lib/r2";
|
import { r2Get } from "@/lib/r2";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ filename: string }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ filename: string }> }) {
|
||||||
|
const rateLimitResult = rateLimit(request, "avatar", { limit: 200, 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 { filename } = await params;
|
const { filename } = await params;
|
||||||
|
|
||||||
const key = `avatars/${filename}`;
|
const key = `avatars/${filename}`;
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,22 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { users, repositories } from "@/db/schema";
|
import { users, repositories } from "@/db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { getSession } from "@/lib/session";
|
|
||||||
import git from "isomorphic-git";
|
import git from "isomorphic-git";
|
||||||
import { createR2Fs, getRepoPrefix } from "@/lib/r2-fs";
|
import { createR2Fs, getRepoPrefix } from "@/lib/r2-fs";
|
||||||
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { authenticateRequest } from "@/lib/api-auth";
|
||||||
|
|
||||||
const CHUNK_SIZE = 64 * 1024;
|
const CHUNK_SIZE = 64 * 1024;
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const rateLimitResult = rateLimit(request, "file", { limit: 120, 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 } = await params;
|
const { path } = await params;
|
||||||
|
|
||||||
if (path.length < 4) {
|
if (path.length < 4) {
|
||||||
|
|
@ -35,9 +44,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repo.visibility === "private") {
|
if (repo.visibility === "private") {
|
||||||
const session = await getSession();
|
const authenticatedUser = await authenticateRequest(request);
|
||||||
if (!session?.user || session.user.id !== repo.ownerId) {
|
if (!authenticatedUser || authenticatedUser.id !== repo.ownerId) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
return new NextResponse("Unauthorized", {
|
||||||
|
status: 401,
|
||||||
|
headers: { "WWW-Authenticate": 'Basic realm="gitbruv"' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,4 +144,3 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,11 @@ import { db } from "@/db";
|
||||||
import { users, repositories } from "@/db/schema";
|
import { users, repositories } from "@/db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import git from "isomorphic-git";
|
import git from "isomorphic-git";
|
||||||
import { createR2Fs, getRepoPrefix } from "@/lib/r2-fs";
|
import { createR2Fs, getRepoPrefix, R2Fs } from "@/lib/r2-fs";
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { auth } from "@/lib/auth";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { authenticateRequest } from "@/lib/api-auth";
|
||||||
async function authenticateUser(authHeader: string | null): Promise<{ id: string; username: string } | null> {
|
import { createHash } from "crypto";
|
||||||
if (!authHeader?.startsWith("Basic ")) return null;
|
|
||||||
|
|
||||||
const credentials = Buffer.from(authHeader.split(" ")[1], "base64").toString("utf-8");
|
|
||||||
const [email, password] = credentials.split(":");
|
|
||||||
if (!email || !password) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await auth.api.signInEmail({
|
|
||||||
body: { email, password },
|
|
||||||
asResponse: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result?.user) return null;
|
|
||||||
|
|
||||||
const user = await db.query.users.findFirst({
|
|
||||||
where: eq(users.email, email),
|
|
||||||
});
|
|
||||||
|
|
||||||
return user ? { id: user.id, username: user.username } : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseGitPath(pathSegments: string[]): { username: string; repoName: string; action: string | null } | null {
|
function parseGitPath(pathSegments: string[]): { username: string; repoName: string; action: string | null } | null {
|
||||||
if (pathSegments.length < 2) return null;
|
if (pathSegments.length < 2) return null;
|
||||||
|
|
@ -56,7 +33,7 @@ function parseGitPath(pathSegments: string[]): { username: string; repoName: str
|
||||||
return { username, repoName, action };
|
return { username, repoName, action };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRefsAdvertisement(fs: any, gitdir: string, service: string): Promise<Buffer> {
|
async function getRefsAdvertisement(fs: R2Fs, gitdir: string, service: string): Promise<Buffer> {
|
||||||
const capabilities =
|
const capabilities =
|
||||||
service === "git-upload-pack"
|
service === "git-upload-pack"
|
||||||
? [
|
? [
|
||||||
|
|
@ -124,16 +101,13 @@ async function getRefsAdvertisement(fs: any, gitdir: string, service: string): P
|
||||||
return Buffer.concat(packets);
|
return Buffer.concat(packets);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUploadPack(fs: any, gitdir: string, body: Buffer): Promise<Buffer> {
|
async function handleUploadPack(fs: R2Fs, gitdir: string, body: Buffer): Promise<Buffer> {
|
||||||
const lines = parsePktLines(body);
|
const lines = parsePktLines(body);
|
||||||
const wants: string[] = [];
|
const wants: string[] = [];
|
||||||
const haves: string[] = [];
|
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith("want ")) {
|
if (line.startsWith("want ")) {
|
||||||
wants.push(line.slice(5, 45));
|
wants.push(line.slice(5, 45));
|
||||||
} else if (line.startsWith("have ")) {
|
|
||||||
haves.push(line.slice(5, 45));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,23 +133,17 @@ async function handleUploadPack(fs: any, gitdir: string, body: Buffer): Promise<
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleReceivePack(fs: any, gitdir: string, body: Buffer): Promise<Buffer> {
|
async function handleReceivePack(fs: R2Fs, gitdir: string, body: Buffer): Promise<Buffer> {
|
||||||
console.log("[ReceivePack] Starting, body length:", body.length);
|
|
||||||
|
|
||||||
const packStart = body.indexOf(Buffer.from("PACK"));
|
const packStart = body.indexOf(Buffer.from("PACK"));
|
||||||
console.log("[ReceivePack] PACK header at:", packStart);
|
|
||||||
|
|
||||||
if (packStart === -1) {
|
if (packStart === -1) {
|
||||||
console.log("[ReceivePack] No PACK header found");
|
|
||||||
return Buffer.from("000eunpack ok\n0000");
|
return Buffer.from("000eunpack ok\n0000");
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandSection = body.slice(0, packStart);
|
const commandSection = body.slice(0, packStart);
|
||||||
const packData = body.slice(packStart);
|
const packData = body.slice(packStart);
|
||||||
console.log("[ReceivePack] Command section length:", commandSection.length, "Pack data length:", packData.length);
|
|
||||||
|
|
||||||
const lines = parsePktLines(commandSection);
|
const lines = parsePktLines(commandSection);
|
||||||
console.log("[ReceivePack] Parsed lines:", lines);
|
|
||||||
|
|
||||||
const updates: { oldOid: string; newOid: string; ref: string }[] = [];
|
const updates: { oldOid: string; newOid: string; ref: string }[] = [];
|
||||||
|
|
||||||
|
|
@ -185,35 +153,27 @@ async function handleReceivePack(fs: any, gitdir: string, body: Buffer): Promise
|
||||||
updates.push({ oldOid: match[1], newOid: match[2], ref: match[3].replace("\0", "").split(" ")[0] });
|
updates.push({ oldOid: match[1], newOid: match[2], ref: match[3].replace("\0", "").split(" ")[0] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log("[ReceivePack] Updates:", updates);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("[ReceivePack] Creating directories...");
|
await fs.promises.mkdir("/objects").catch(() => {});
|
||||||
await fs.promises.mkdir("/objects", { recursive: true }).catch(() => {});
|
await fs.promises.mkdir("/objects/pack").catch(() => {});
|
||||||
await fs.promises.mkdir("/objects/pack", { recursive: true }).catch(() => {});
|
|
||||||
|
|
||||||
const packHash = require("crypto").createHash("sha1").update(packData).digest("hex");
|
const packHash = createHash("sha1").update(packData).digest("hex");
|
||||||
const packFileName = `pack-${packHash}`;
|
const packFileName = `pack-${packHash}`;
|
||||||
const packPath = `/objects/pack/${packFileName}.pack`;
|
const packPath = `/objects/pack/${packFileName}.pack`;
|
||||||
const idxPath = `/objects/pack/${packFileName}.idx`;
|
|
||||||
|
|
||||||
console.log("[ReceivePack] Writing pack file:", packPath);
|
|
||||||
await fs.promises.writeFile(packPath, packData);
|
await fs.promises.writeFile(packPath, packData);
|
||||||
|
|
||||||
console.log("[ReceivePack] Calling indexPack...");
|
await git.indexPack({ fs, dir: "/", gitdir: "/", filepath: `objects/pack/${packFileName}.pack` });
|
||||||
const result = await git.indexPack({ fs, dir: "/", gitdir: "/", filepath: `objects/pack/${packFileName}.pack` });
|
|
||||||
console.log("[ReceivePack] indexPack result, oids:", result.oids?.length);
|
|
||||||
|
|
||||||
console.log("[ReceivePack] Writing refs...");
|
|
||||||
for (const update of updates) {
|
for (const update of updates) {
|
||||||
const refPath = update.ref.startsWith("refs/") ? update.ref : `refs/heads/${update.ref}`;
|
const refPath = update.ref.startsWith("refs/") ? update.ref : `refs/heads/${update.ref}`;
|
||||||
console.log("[ReceivePack] Writing ref:", refPath, "->", update.newOid);
|
|
||||||
|
|
||||||
if (update.newOid === "0".repeat(40)) {
|
if (update.newOid === "0".repeat(40)) {
|
||||||
await fs.promises.unlink(`/${refPath}`).catch(() => {});
|
await fs.promises.unlink(`/${refPath}`).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
const refDir = "/" + refPath.split("/").slice(0, -1).join("/");
|
const refDir = "/" + refPath.split("/").slice(0, -1).join("/");
|
||||||
await fs.promises.mkdir(refDir, { recursive: true }).catch(() => {});
|
await fs.promises.mkdir(refDir).catch(() => {});
|
||||||
await fs.promises.writeFile(`/${refPath}`, update.newOid + "\n");
|
await fs.promises.writeFile(`/${refPath}`, update.newOid + "\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +191,6 @@ async function handleReceivePack(fs: any, gitdir: string, body: Buffer): Promise
|
||||||
}
|
}
|
||||||
responseStr += "0000";
|
responseStr += "0000";
|
||||||
|
|
||||||
console.log("[ReceivePack] Success!");
|
|
||||||
return Buffer.from(responseStr);
|
return Buffer.from(responseStr);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ReceivePack] Error:", err);
|
console.error("[ReceivePack] Error:", err);
|
||||||
|
|
@ -265,6 +224,14 @@ function parsePktLines(data: Buffer): string[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
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 { path: pathSegments } = await params;
|
||||||
const parsed = parseGitPath(pathSegments);
|
const parsed = parseGitPath(pathSegments);
|
||||||
|
|
||||||
|
|
@ -291,7 +258,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||||
}
|
}
|
||||||
|
|
||||||
if (repo.visibility === "private") {
|
if (repo.visibility === "private") {
|
||||||
const user = await authenticateUser(request.headers.get("authorization"));
|
const user = await authenticateRequest(request);
|
||||||
if (!user || user.id !== repo.ownerId) {
|
if (!user || user.id !== repo.ownerId) {
|
||||||
return new NextResponse("Unauthorized", {
|
return new NextResponse("Unauthorized", {
|
||||||
status: 401,
|
status: 401,
|
||||||
|
|
@ -305,7 +272,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||||
|
|
||||||
if (serviceQuery === "git-upload-pack" || serviceQuery === "git-receive-pack") {
|
if (serviceQuery === "git-upload-pack" || serviceQuery === "git-receive-pack") {
|
||||||
if (serviceQuery === "git-receive-pack") {
|
if (serviceQuery === "git-receive-pack") {
|
||||||
const user = await authenticateUser(request.headers.get("authorization"));
|
const user = await authenticateRequest(request);
|
||||||
if (!user || user.id !== repo.ownerId) {
|
if (!user || user.id !== repo.ownerId) {
|
||||||
return new NextResponse("Unauthorized", {
|
return new NextResponse("Unauthorized", {
|
||||||
status: 401,
|
status: 401,
|
||||||
|
|
@ -336,6 +303,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
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 { path: pathSegments } = await params;
|
||||||
const parsed = parseGitPath(pathSegments);
|
const parsed = parseGitPath(pathSegments);
|
||||||
|
|
||||||
|
|
@ -365,7 +340,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||||
return new NextResponse("Repository not found", { status: 404 });
|
return new NextResponse("Repository not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await authenticateUser(request.headers.get("authorization"));
|
const user = await authenticateRequest(request);
|
||||||
|
|
||||||
if (action === "git-receive-pack") {
|
if (action === "git-receive-pack") {
|
||||||
if (!user || user.id !== repo.ownerId) {
|
if (!user || user.id !== repo.ownerId) {
|
||||||
|
|
|
||||||
69
lib/api-auth.ts
Normal file
69
lib/api-auth.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { users } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateRequest(request: NextRequest): Promise<AuthenticatedUser | null> {
|
||||||
|
const session = await getSession();
|
||||||
|
if (session?.user) {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, session.user.id),
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
return { id: user.id, username: user.username };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = request.headers.get("authorization");
|
||||||
|
if (authHeader?.startsWith("Basic ")) {
|
||||||
|
const credentials = Buffer.from(authHeader.split(" ")[1], "base64").toString("utf-8");
|
||||||
|
const [email, password] = credentials.split(":");
|
||||||
|
if (email && password) {
|
||||||
|
try {
|
||||||
|
const result = await auth.api.signInEmail({
|
||||||
|
body: { email, password },
|
||||||
|
asResponse: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.user) {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email),
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
return { id: user.id, username: user.username };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearerMatch = request.headers.get("authorization")?.match(/^Bearer (.+)$/);
|
||||||
|
if (bearerMatch) {
|
||||||
|
try {
|
||||||
|
const tokenSession = await auth.api.getSession({
|
||||||
|
headers: request.headers,
|
||||||
|
});
|
||||||
|
if (tokenSession?.user) {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.id, tokenSession.user.id),
|
||||||
|
});
|
||||||
|
if (user) {
|
||||||
|
return { id: user.id, username: user.username };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -178,3 +178,5 @@ export function createR2Fs(repoPrefix: string) {
|
||||||
export function getRepoPrefix(userId: string, repoName: string): string {
|
export function getRepoPrefix(userId: string, repoName: string): string {
|
||||||
return `repos/${userId}/${repoName}`;
|
return `repos/${userId}/${repoName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type R2Fs = ReturnType<typeof createR2Fs>;
|
||||||
|
|
|
||||||
96
lib/rate-limit.ts
Normal file
96
lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
resetAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (entry.resetAt < now) {
|
||||||
|
store.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
export interface RateLimitConfig {
|
||||||
|
limit: number;
|
||||||
|
windowMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: RateLimitConfig = {
|
||||||
|
limit: 60,
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getClientIp(request: NextRequest): string {
|
||||||
|
const xff = request.headers.get("x-forwarded-for");
|
||||||
|
if (xff) {
|
||||||
|
return xff.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
const realIp = request.headers.get("x-real-ip");
|
||||||
|
if (realIp) {
|
||||||
|
return realIp;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rateLimit(
|
||||||
|
request: NextRequest,
|
||||||
|
identifier?: string,
|
||||||
|
config: Partial<RateLimitConfig> = {}
|
||||||
|
): { success: boolean; remaining: number; resetAt: number } {
|
||||||
|
const { limit, windowMs } = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
const key = identifier ? `${ip}:${identifier}` : ip;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
let entry = store.get(key);
|
||||||
|
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
entry = { count: 0, resetAt: now + windowMs };
|
||||||
|
store.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: entry.count <= limit,
|
||||||
|
remaining: Math.max(0, limit - entry.count),
|
||||||
|
resetAt: entry.resetAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withRateLimit(
|
||||||
|
handler: (request: NextRequest, context: any) => Promise<NextResponse>,
|
||||||
|
config: Partial<RateLimitConfig> & { identifier?: string } = {}
|
||||||
|
) {
|
||||||
|
return async (request: NextRequest, context: any): Promise<NextResponse> => {
|
||||||
|
const { identifier, ...rateLimitConfig } = config;
|
||||||
|
const result = rateLimit(request, identifier, rateLimitConfig);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return new NextResponse("Too Many Requests", {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
"Retry-After": Math.ceil((result.resetAt - Date.now()) / 1000).toString(),
|
||||||
|
"X-RateLimit-Limit": (rateLimitConfig.limit || DEFAULT_CONFIG.limit).toString(),
|
||||||
|
"X-RateLimit-Remaining": "0",
|
||||||
|
"X-RateLimit-Reset": result.resetAt.toString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await handler(request, context);
|
||||||
|
|
||||||
|
response.headers.set("X-RateLimit-Limit", (rateLimitConfig.limit || DEFAULT_CONFIG.limit).toString());
|
||||||
|
response.headers.set("X-RateLimit-Remaining", result.remaining.toString());
|
||||||
|
response.headers.set("X-RateLimit-Reset", result.resetAt.toString());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue