Skip to content

Commit 8aba8d6

Browse files
harden security, extract shared helpers, add error boundaries
- Sanitize fetch errors in UNIT3D adapter to prevent API token leaks - Add password max-length (128) validation on setup and login - Clamp pollIntervalMinutes to 15-1440 range on create and update - Add input length limits for tracker name, URL, color, and token - Switch JWT to encrypted (JWE) with A256GCM via EncryptJWT/jwtDecrypt - Extract shared API route helpers (authenticate, parseTrackerId, parseJsonBody) - Extract shared formatters and types to reduce duplication - Add error boundaries for auth-protected and global contexts
1 parent aa1d67b commit 8aba8d6

21 files changed

Lines changed: 409 additions & 213 deletions

File tree

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/tracker_tracker
2-
SESSION_SECRET=change-this-to-a-random-64-char-string
2+
SESSION_SECRET=change-this-to-a-random-string-minimum-32-chars

src/app/(auth)/error.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"use client"
2+
// src/app/(auth)/error.tsx
3+
4+
import Link from "next/link"
5+
import { useEffect } from "react"
6+
7+
export default function AuthError({
8+
error,
9+
reset,
10+
}: {
11+
error: Error & { digest?: string }
12+
reset: () => void
13+
}) {
14+
useEffect(() => {
15+
console.error(error)
16+
}, [error])
17+
18+
return (
19+
<div className="flex min-h-screen items-center justify-center bg-[#0a0e1a] px-4">
20+
<div className="w-full max-w-md rounded-lg border border-[rgba(239,68,68,0.2)] bg-[#0f1424] p-8">
21+
<p className="mb-1 font-mono text-xs uppercase tracking-widest text-[#ef4444]">
22+
Runtime Error
23+
</p>
24+
<h1 className="mb-4 text-xl font-semibold text-[#e2e8f0]">
25+
Something went wrong
26+
</h1>
27+
<pre className="mb-6 overflow-x-auto rounded border border-[rgba(148,163,184,0.1)] bg-[#080c16] p-3 font-mono text-xs text-[#94a3b8]">
28+
{error.message || "An unexpected error occurred."}
29+
</pre>
30+
<div className="flex gap-3">
31+
<button
32+
type="button"
33+
onClick={reset}
34+
className="rounded border border-[rgba(0,212,255,0.3)] bg-[rgba(0,212,255,0.08)] px-4 py-2 text-sm text-[#00d4ff] transition-colors hover:bg-[rgba(0,212,255,0.15)]"
35+
>
36+
Try Again
37+
</button>
38+
<Link
39+
href="/"
40+
className="rounded border border-[rgba(148,163,184,0.15)] px-4 py-2 text-sm text-[#94a3b8] transition-colors hover:text-[#e2e8f0]"
41+
>
42+
Go Home
43+
</Link>
44+
</div>
45+
</div>
46+
</div>
47+
)
48+
}

src/app/(auth)/trackers/[id]/page.tsx

Lines changed: 8 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
// src/app/(auth)/trackers/[id]/page.tsx
44
//
5-
// Functions: formatGiB, formatRatio, formatHours, TrackerDetailPage
5+
// Functions: formatHours, TrackerDetailPage
66

77
import { useParams } from "next/navigation"
88
import { useCallback, useEffect, useState } from "react"
@@ -11,58 +11,13 @@ import { Badge } from "@/components/ui/Badge"
1111
import { Button } from "@/components/ui/Button"
1212
import { Card } from "@/components/ui/Card"
1313
import { StatCard } from "@/components/ui/StatCard"
14-
15-
interface TrackerLatestStats {
16-
ratio: number | null
17-
uploadedBytes: string | null
18-
downloadedBytes: string | null
19-
seedingCount: number | null
20-
leechingCount: number | null
21-
username: string | null
22-
group: string | null
23-
}
24-
25-
interface Tracker {
26-
id: number
27-
name: string
28-
baseUrl: string
29-
platformType: string
30-
pollIntervalMinutes: number
31-
isActive: boolean
32-
lastPolledAt: string | null
33-
lastError: string | null
34-
color: string
35-
latestStats: TrackerLatestStats | null
36-
}
37-
38-
interface Snapshot {
39-
polledAt: string
40-
uploadedBytes: string
41-
downloadedBytes: string
42-
ratio: number | null
43-
bufferBytes: string
44-
seedbonus: number | null
45-
seedingCount: number | null
46-
leechingCount: number | null
47-
hitAndRuns: number | null
48-
}
14+
import { formatBytesFromString, formatRatio } from "@/lib/formatters"
15+
import type { Snapshot, TrackerSummary } from "@/types/api"
4916

5017
type DayRange = 7 | 30 | 90 | 365
5118

5219
const DAY_RANGES: DayRange[] = [7, 30, 90, 365]
5320

54-
function formatGiB(bytesStr: string | null): string {
55-
if (!bytesStr) return "—"
56-
const gib = Number(BigInt(bytesStr)) / 1024 ** 3
57-
if (gib >= 1024) return `${(gib / 1024).toFixed(2)} TiB`
58-
return `${gib.toFixed(2)} GiB`
59-
}
60-
61-
function formatRatio(ratio: number | null | undefined): string {
62-
if (ratio === null || ratio === undefined) return "—"
63-
return ratio.toFixed(2)
64-
}
65-
6621
function formatHours(minutes: number): string {
6722
if (minutes < 60) return `${minutes}m`
6823
const hours = minutes / 60
@@ -73,7 +28,7 @@ export default function TrackerDetailPage() {
7328
const params = useParams()
7429
const id = params.id as string
7530

76-
const [tracker, setTracker] = useState<Tracker | null>(null)
31+
const [tracker, setTracker] = useState<TrackerSummary | null>(null)
7732
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
7833
const [days, setDays] = useState<DayRange>(30)
7934
const [loading, setLoading] = useState(true)
@@ -88,7 +43,7 @@ export default function TrackerDetailPage() {
8843
])
8944

9045
if (trackersRes.ok) {
91-
const allTrackers: Tracker[] = await trackersRes.json()
46+
const allTrackers: TrackerSummary[] = await trackersRes.json()
9247
const found = allTrackers.find((t) => t.id === Number(id))
9348
setTracker(found ?? null)
9449
}
@@ -211,11 +166,11 @@ export default function TrackerDetailPage() {
211166
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
212167
<StatCard
213168
label="Uploaded"
214-
value={formatGiB(stats?.uploadedBytes ?? null)}
169+
value={formatBytesFromString(stats?.uploadedBytes ?? null)}
215170
/>
216171
<StatCard
217172
label="Downloaded"
218-
value={formatGiB(stats?.downloadedBytes ?? null)}
173+
value={formatBytesFromString(stats?.downloadedBytes ?? null)}
219174
/>
220175
<StatCard
221176
label="Ratio"
@@ -232,7 +187,7 @@ export default function TrackerDetailPage() {
232187
/>
233188
<StatCard
234189
label="Buffer"
235-
value={formatGiB(latestSnapshot?.bufferBytes ?? null)}
190+
value={formatBytesFromString(latestSnapshot?.bufferBytes ?? null)}
236191
/>
237192
</div>
238193

src/app/api/auth/login/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createSession, verifyPassword } from "@/lib/auth"
44
import { deriveKey } from "@/lib/crypto"
55
import { db } from "@/lib/db"
66
import { appSettings } from "@/lib/db/schema"
7+
import { startScheduler } from "@/lib/scheduler"
78

89
export async function POST(request: Request) {
910
const [settings] = await db.select().from(appSettings).limit(1)
@@ -19,8 +20,8 @@ export async function POST(request: Request) {
1920
}
2021

2122
const { password } = body
22-
if (!password || typeof password !== "string") {
23-
return NextResponse.json({ error: "Password required" }, { status: 400 })
23+
if (!password || typeof password !== "string" || password.length > 128) {
24+
return NextResponse.json({ error: "Invalid password" }, { status: 400 })
2425
}
2526

2627
const valid = await verifyPassword(settings.passwordHash, password)
@@ -31,6 +32,7 @@ export async function POST(request: Request) {
3132
// Derive encryption key and store in session
3233
const key = await deriveKey(password, settings.encryptionSalt)
3334
await createSession(key.toString("hex"))
35+
startScheduler(key)
3436

3537
return NextResponse.json({ success: true })
3638
}

src/app/api/auth/logout/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// src/app/api/auth/logout/route.ts
22
import { NextResponse } from "next/server"
33
import { clearSession } from "@/lib/auth"
4+
import { stopScheduler } from "@/lib/scheduler"
45

56
export async function POST() {
7+
stopScheduler()
68
await clearSession()
79
return NextResponse.json({ success: true })
810
}

src/app/api/auth/setup/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ export async function POST(request: Request) {
1919
}
2020

2121
const { password } = body
22-
if (!password || typeof password !== "string" || password.length < 8) {
22+
if (!password || typeof password !== "string" || password.length < 8 || password.length > 128) {
2323
return NextResponse.json(
24-
{ error: "Password must be at least 8 characters" },
24+
{ error: "Password must be between 8 and 128 characters" },
2525
{ status: 400 }
2626
)
2727
}

src/app/api/trackers/[id]/poll/route.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,19 @@
11
// src/app/api/trackers/[id]/poll/route.ts
22
import { NextResponse } from "next/server"
3-
import { requireAuth } from "@/lib/auth"
3+
import { authenticate, parseTrackerId } from "@/lib/api-helpers"
44
import { pollTracker } from "@/lib/scheduler"
55

66
export async function POST(
77
_request: Request,
88
{ params }: { params: Promise<{ id: string }> }
99
) {
10-
let session: { encryptionKey: string }
11-
try {
12-
session = await requireAuth()
13-
} catch {
14-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
15-
}
10+
const auth = await authenticate()
11+
if (auth instanceof NextResponse) return auth
1612

17-
const { id } = await params
18-
const trackerId = parseInt(id, 10)
19-
if (Number.isNaN(trackerId)) {
20-
return NextResponse.json({ error: "Invalid tracker ID" }, { status: 400 })
21-
}
13+
const trackerId = await parseTrackerId(params)
14+
if (trackerId instanceof NextResponse) return trackerId
2215

23-
const key = Buffer.from(session.encryptionKey, "hex")
16+
const key = Buffer.from(auth.encryptionKey, "hex")
2417

2518
try {
2619
await pollTracker(trackerId, key)

src/app/api/trackers/[id]/roles/route.ts

Lines changed: 11 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,19 @@
11
// src/app/api/trackers/[id]/roles/route.ts
22
import { desc, eq } from "drizzle-orm"
33
import { NextResponse } from "next/server"
4-
import { requireAuth } from "@/lib/auth"
4+
import { authenticate, parseJsonBody, parseTrackerId } from "@/lib/api-helpers"
55
import { db } from "@/lib/db"
66
import { trackerRoles } from "@/lib/db/schema"
77

88
export async function GET(
99
_request: Request,
1010
{ params }: { params: Promise<{ id: string }> }
1111
) {
12-
try {
13-
await requireAuth()
14-
} catch {
15-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
16-
}
12+
const auth = await authenticate()
13+
if (auth instanceof NextResponse) return auth
1714

18-
const { id } = await params
19-
const trackerId = parseInt(id, 10)
20-
if (Number.isNaN(trackerId)) {
21-
return NextResponse.json({ error: "Invalid tracker ID" }, { status: 400 })
22-
}
15+
const trackerId = await parseTrackerId(params)
16+
if (trackerId instanceof NextResponse) return trackerId
2317

2418
const roles = await db
2519
.select()
@@ -34,24 +28,14 @@ export async function POST(
3428
request: Request,
3529
{ params }: { params: Promise<{ id: string }> }
3630
) {
37-
try {
38-
await requireAuth()
39-
} catch {
40-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
41-
}
31+
const auth = await authenticate()
32+
if (auth instanceof NextResponse) return auth
4233

43-
const { id } = await params
44-
const trackerId = parseInt(id, 10)
45-
if (Number.isNaN(trackerId)) {
46-
return NextResponse.json({ error: "Invalid tracker ID" }, { status: 400 })
47-
}
34+
const trackerId = await parseTrackerId(params)
35+
if (trackerId instanceof NextResponse) return trackerId
4836

49-
let body: Record<string, unknown>
50-
try {
51-
body = await request.json()
52-
} catch {
53-
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 })
54-
}
37+
const body = await parseJsonBody(request)
38+
if (body instanceof NextResponse) return body
5539

5640
const { roleName, achievedAt, notes } = body as {
5741
roleName?: string

0 commit comments

Comments
 (0)