diff --git a/.env.example b/.env.example index 25d5c43..cee7f9e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,12 @@ # Database — Postgres su Coolify DATABASE_URL=postgresql://user:password@host:5432/database + +# NextAuth +NEXTAUTH_URL=https://hub.iamcavalli.net +NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32 +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=use-a-strong-password-min-20-chars + +# Internal API secret — shared between proxy.ts and /api/internal/* routes +# Generate with: openssl rand -base64 32 +INTERNAL_SECRET=generate-with-openssl-rand-base64-32 diff --git a/Dockerfile b/Dockerfile index 5312a6c..507a503 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ RUN npm run build FROM base AS runner WORKDIR /app ENV NODE_ENV=production -ENV NEXTAUTH_URL=https://hub.iamcavalli.net +# NEXTAUTH_URL must be set via Coolify environment variables at runtime RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public diff --git a/next.config.ts b/next.config.ts index 68a6c64..5cf8710 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,18 @@ import type { NextConfig } from "next"; +const securityHeaders = [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + { key: "X-DNS-Prefetch-Control", value: "on" }, +]; + const nextConfig: NextConfig = { output: "standalone", + async headers() { + return [{ source: "/(.*)", headers: securityHeaders }]; + }, }; export default nextConfig; diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index 2acc909..a5f66b5 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -2,7 +2,14 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; import { db } from "@/db"; + +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} import { phases, tasks, @@ -56,6 +63,7 @@ const clientSchema = z.object({ }); export async function updateClient(clientId: string, formData: FormData) { + await requireAdmin(); const parsed = clientSchema.safeParse({ name: formData.get("name"), brand_name: formData.get("brand_name"), @@ -78,12 +86,14 @@ export async function updateClient(clientId: string, formData: FormData) { } export async function deleteClient(clientId: string) { + await requireAdmin(); await db.delete(clients).where(eq(clients.id, clientId)); revalidatePath("/admin"); redirect("/admin"); } export async function setClientArchived(clientId: string, archived: boolean) { + await requireAdmin(); await db.update(clients).set({ archived }).where(eq(clients.id, clientId)); revalidatePath(`/admin/clients/${clientId}`); revalidatePath("/admin"); @@ -92,6 +102,7 @@ export async function setClientArchived(clientId: string, archived: boolean) { // ── PHASES ──────────────────────────────────────────────────────────────────── export async function addPhase(id: string, formData: FormData) { + await requireAdmin(); const title = (formData.get("title") as string)?.trim(); if (!title) throw new Error("Titolo fase richiesto"); @@ -114,6 +125,7 @@ export async function addPhase(id: string, formData: FormData) { } export async function updatePhaseStatus(phaseId: string, id: string, status: string) { + await requireAdmin(); const allowed = ["upcoming", "active", "done"]; if (!allowed.includes(status)) throw new Error("Stato non valido"); await db.update(phases).set({ status }).where(eq(phases.id, phaseId)); @@ -124,6 +136,7 @@ export async function updatePhaseStatus(phaseId: string, id: string, status: str // ── TASKS ───────────────────────────────────────────────────────────────────── export async function addTask(phaseId: string, id: string, formData: FormData) { + await requireAdmin(); const title = (formData.get("title") as string)?.trim(); if (!title) throw new Error("Titolo task richiesto"); @@ -145,6 +158,7 @@ export async function addTask(phaseId: string, id: string, formData: FormData) { } export async function updateTaskStatus(taskId: string, id: string, status: string) { + await requireAdmin(); const allowed = ["todo", "in_progress", "done"]; if (!allowed.includes(status)) throw new Error("Stato non valido"); await db.update(tasks).set({ status }).where(eq(tasks.id, taskId)); @@ -155,6 +169,7 @@ export async function updateTaskStatus(taskId: string, id: string, status: strin // ── DELIVERABLES ────────────────────────────────────────────────────────────── export async function addDeliverable(taskId: string, id: string, formData: FormData) { + await requireAdmin(); const title = (formData.get("title") as string)?.trim(); const url = (formData.get("url") as string)?.trim() || null; if (!title) throw new Error("Titolo deliverable richiesto"); @@ -171,6 +186,7 @@ const docSchema = z.object({ }); export async function addDocument(id: string, formData: FormData) { + await requireAdmin(); const parsed = docSchema.safeParse({ label: formData.get("label"), url: formData.get("url"), @@ -185,6 +201,7 @@ export async function addDocument(id: string, formData: FormData) { } export async function updateDocument(documentId: string, id: string, formData: FormData) { + await requireAdmin(); const parsed = docSchema.safeParse({ label: formData.get("label"), url: formData.get("url"), @@ -196,6 +213,7 @@ export async function updateDocument(documentId: string, id: string, formData: F } export async function deleteDocument(documentId: string, id: string) { + await requireAdmin(); await db.delete(documents).where(eq(documents.id, documentId)); const { path } = await resolveEntity(id); revalidatePath(path); @@ -204,6 +222,7 @@ export async function deleteDocument(documentId: string, id: string) { // ── PAYMENTS ────────────────────────────────────────────────────────────────── export async function updatePaymentStatus(paymentId: string, id: string, status: string) { + await requireAdmin(); const allowed = ["da_saldare", "inviata", "saldato"]; if (!allowed.includes(status)) throw new Error("Stato pagamento non valido"); const paid_at = status === "saldato" ? new Date() : null; @@ -213,6 +232,7 @@ export async function updatePaymentStatus(paymentId: string, id: string, status: } export async function updateAcceptedTotal(id: string, formData: FormData) { + await requireAdmin(); const raw = (formData.get("accepted_total") as string)?.trim(); const val = parseFloat(raw); if (isNaN(val) || val < 0) throw new Error("Importo non valido"); @@ -254,6 +274,7 @@ export async function updateAcceptedTotal(id: string, formData: FormData) { // ── COMMENTS (admin reply) ──────────────────────────────────────────────────── export async function postAdminComment(id: string, formData: FormData) { + await requireAdmin(); const entity = formData.get("entity") as string; const body = (formData.get("body") as string)?.trim(); if (!body || !entity) throw new Error("Dati mancanti"); diff --git a/src/app/admin/clients/new/actions.ts b/src/app/admin/clients/new/actions.ts index ad04bef..c1f9b2b 100644 --- a/src/app/admin/clients/new/actions.ts +++ b/src/app/admin/clients/new/actions.ts @@ -4,17 +4,30 @@ import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { eq } from "drizzle-orm"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; import { db } from "@/db"; import { clients, projects, payments } from "@/db/schema"; +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} + +function randomAlpha(len: number): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + return Array.from({ length: len }, () => chars[Math.floor(Math.random() * chars.length)]).join(""); +} + function toSlug(name: string): string { - return name + const base = name .toLowerCase() .normalize("NFD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") - .slice(0, 50); + .slice(0, 44); + return `${base}-${randomAlpha(4)}`; } async function uniqueSlug(base: string): Promise { @@ -34,6 +47,7 @@ const createClientSchema = z.object({ }); export async function createClient(formData: FormData) { + await requireAdmin(); const raw = { name: formData.get("name") as string, brand_name: formData.get("brand_name") as string, diff --git a/src/app/admin/timer-actions.ts b/src/app/admin/timer-actions.ts index 893ffbb..6b54966 100644 --- a/src/app/admin/timer-actions.ts +++ b/src/app/admin/timer-actions.ts @@ -1,12 +1,20 @@ "use server"; import { revalidatePath } from "next/cache"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; import { db } from "@/db"; + +async function requireAdmin() { + const session = await getServerSession(authOptions); + if (!session) throw new Error("Non autorizzato"); +} import { time_entries, projects } from "@/db/schema"; import { eq, isNull, asc } from "drizzle-orm"; import { nanoid } from "nanoid"; export async function startTimer(projectId: string): Promise<{ entryId: string }> { + await requireAdmin(); // Stop any currently running session before starting a new one const running = await db .select({ id: time_entries.id }) @@ -38,6 +46,7 @@ export async function startTimer(projectId: string): Promise<{ entryId: string } } export async function startTimerForClient(clientId: string): Promise<{ entryId: string }> { + await requireAdmin(); const projectRows = await db .select({ id: projects.id }) .from(projects) @@ -50,6 +59,7 @@ export async function startTimerForClient(clientId: string): Promise<{ entryId: } export async function stopTimer(entryId: string): Promise { + await requireAdmin(); const rows = await db .select({ started_at: time_entries.started_at }) .from(time_entries) diff --git a/src/app/api/client/approve/route.ts b/src/app/api/client/approve/route.ts index 3f7a5eb..aa9b70b 100644 --- a/src/app/api/client/approve/route.ts +++ b/src/app/api/client/approve/route.ts @@ -2,8 +2,14 @@ import { NextRequest, NextResponse } from "next/server"; import { eq, and } from "drizzle-orm"; import { db } from "@/db"; import { clients, deliverables, tasks, phases, projects } from "@/db/schema"; +import { rateLimit } from "@/lib/rate-limit"; export async function POST(request: NextRequest) { + const ip = request.headers.get("x-forwarded-for") ?? "unknown"; + if (!rateLimit(`approve:${ip}`, 20, 60_000)) { + return NextResponse.json({ error: "Troppe richieste" }, { status: 429 }); + } + try { const body = await request.json(); const { token, deliverableId } = body as { token?: string; deliverableId?: string }; @@ -20,7 +26,7 @@ export async function POST(request: NextRequest) { .limit(1); if (clientRows.length === 0) { - return NextResponse.json({ error: "Token non valido" }, { status: 404 }); + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } const clientId = clientRows[0].id; @@ -37,7 +43,7 @@ export async function POST(request: NextRequest) { .limit(1); if (ownershipCheck.length === 0) { - return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 }); + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } // IMMUTABILITY RULE (CLAUDE.md): if approved_at is already set, this is a no-op diff --git a/src/app/api/client/comment/route.ts b/src/app/api/client/comment/route.ts index c376b3f..8fe07c5 100644 --- a/src/app/api/client/comment/route.ts +++ b/src/app/api/client/comment/route.ts @@ -3,6 +3,7 @@ import { eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/db"; import { clients, comments, tasks, phases, deliverables, projects } from "@/db/schema"; +import { rateLimit } from "@/lib/rate-limit"; const commentSchema = z.object({ token: z.string().min(1), @@ -12,6 +13,11 @@ const commentSchema = z.object({ }); export async function POST(request: NextRequest) { + const ip = request.headers.get("x-forwarded-for") ?? "unknown"; + if (!rateLimit(`comment:${ip}`, 10, 60_000)) { + return NextResponse.json({ error: "Troppe richieste" }, { status: 429 }); + } + try { const body = await request.json(); const parsed = commentSchema.safeParse(body); @@ -33,7 +39,7 @@ export async function POST(request: NextRequest) { .limit(1); if (clientRows.length === 0) { - return NextResponse.json({ error: "Token non valido" }, { status: 404 }); + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } const clientId = clientRows[0].id; @@ -41,7 +47,7 @@ export async function POST(request: NextRequest) { if (entity_type === "general") { // General messages: entity_id must be the client's own id if (entity_id !== clientId) { - return NextResponse.json({ error: "Entity non valida" }, { status: 403 }); + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } } else { // Scope phases through projects → client @@ -52,7 +58,7 @@ export async function POST(request: NextRequest) { const projectIds = clientProjects.map((p) => p.id); if (projectIds.length === 0) { - return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 }); + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } const phasesForClient = await db @@ -62,7 +68,7 @@ export async function POST(request: NextRequest) { const phaseIds = phasesForClient.map((p) => p.id); if (phaseIds.length === 0) { - return NextResponse.json({ error: "Nessuna fase trovata" }, { status: 404 }); + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } const taskRows = await db @@ -72,20 +78,20 @@ export async function POST(request: NextRequest) { if (entity_type === "task") { if (!taskRows.find((r) => r.id === entity_id)) { - return NextResponse.json({ error: "Task non trovato" }, { status: 404 }); + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } } else { // deliverable const taskIds = taskRows.map((r) => r.id); if (taskIds.length === 0) { - return NextResponse.json({ error: "Nessun task trovato" }, { status: 404 }); + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } const delivRows = await db .select({ id: deliverables.id }) .from(deliverables) .where(inArray(deliverables.task_id, taskIds)); if (!delivRows.find((r) => r.id === entity_id)) { - return NextResponse.json({ error: "Deliverable non trovato" }, { status: 404 }); + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); } } } diff --git a/src/app/api/internal/validate-slug/route.ts b/src/app/api/internal/validate-slug/route.ts index 5d6a103..0266363 100644 --- a/src/app/api/internal/validate-slug/route.ts +++ b/src/app/api/internal/validate-slug/route.ts @@ -4,6 +4,11 @@ import { db } from "@/db"; import { clients } from "@/db/schema"; export async function GET(request: NextRequest) { + const secret = process.env.INTERNAL_SECRET; + if (!secret || request.headers.get("x-internal-secret") !== secret) { + return NextResponse.json({ valid: false }, { status: 403 }); + } + const slug = request.nextUrl.searchParams.get("slug"); if (!slug) { diff --git a/src/app/api/internal/validate-token/route.ts b/src/app/api/internal/validate-token/route.ts index 11e3f8a..f08dfd0 100644 --- a/src/app/api/internal/validate-token/route.ts +++ b/src/app/api/internal/validate-token/route.ts @@ -4,6 +4,11 @@ import { db } from '@/db'; import { clients } from '@/db/schema'; export async function GET(request: NextRequest) { + const secret = process.env.INTERNAL_SECRET; + if (!secret || request.headers.get("x-internal-secret") !== secret) { + return NextResponse.json({ valid: false }, { status: 403 }); + } + const token = request.nextUrl.searchParams.get('token'); if (!token) { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 71dae67..3fecf5c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -33,7 +33,8 @@ export const authOptions: NextAuthOptions = { ], session: { strategy: "jwt", // stateless JWT — no DB session table (per D-03) - maxAge: 30 * 24 * 60 * 60, // 30 days + maxAge: 7 * 24 * 60 * 60, // 7 days (reduced from 30 for security) + updateAge: 24 * 60 * 60, // rotate token daily on active use }, pages: { signIn: "/admin/login", // custom login page (per D-07) diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..3062145 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,18 @@ +// In-memory rate limiter — suitable for single-container deployments. +// Each bucket tracks hit count within a rolling window. + +const buckets = new Map(); + +export function rateLimit(key: string, limit: number, windowMs: number): boolean { + const now = Date.now(); + const bucket = buckets.get(key); + + if (!bucket || now >= bucket.resetAt) { + buckets.set(key, { hits: 1, resetAt: now + windowMs }); + return true; + } + + if (bucket.hits >= limit) return false; + bucket.hits++; + return true; +} \ No newline at end of file diff --git a/src/proxy.ts b/src/proxy.ts index c48217f..c625108 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -43,15 +43,21 @@ export async function proxy(request: NextRequest) { const port = process.env.PORT ?? "3000"; const base = `http://localhost:${port}`; + const internalHeaders = { + "x-internal-secret": process.env.INTERNAL_SECRET ?? "", + }; + // Try slug first (D-06) — user-friendly slugs before token fallback let res = await fetch( - `${base}/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}` + `${base}/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`, + { headers: internalHeaders } ); // If slug not found, fall back to token validation (existing links continue to work) if (!res.ok) { res = await fetch( - `${base}/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}` + `${base}/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`, + { headers: internalHeaders } ); }