a478462aa4
- Add requireAdmin() to all unprotected admin server actions (clients/new, clients/[id], timer-actions — 17 functions total) - Protect /api/internal/* endpoints with X-Internal-Secret header (proxy.ts sends it; routes reject requests without it) - Randomize auto-generated client slugs with 4-char suffix to prevent enumeration via predictable name-based slugs - Add in-memory rate limiting to /api/client/approve (20/min) and /api/client/comment (10/min) per IP - Add security headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, X-DNS-Prefetch-Control - Reduce JWT session from 30 days to 7 days with daily rotation - Remove hardcoded NEXTAUTH_URL from Dockerfile (pass via Coolify env) - Genericize client API error messages to not leak data structure - Update .env.example with all required variables including INTERNAL_SECRET Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
105 lines
3.1 KiB
TypeScript
105 lines
3.1 KiB
TypeScript
"use server";
|
|
|
|
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 {
|
|
const base = name
|
|
.toLowerCase()
|
|
.normalize("NFD")
|
|
.replace(/[̀-ͯ]/g, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 44);
|
|
return `${base}-${randomAlpha(4)}`;
|
|
}
|
|
|
|
async function uniqueSlug(base: string): Promise<string | null> {
|
|
if (base.length < 3) return null;
|
|
const candidates = [base, ...Array.from({ length: 8 }, (_, i) => `${base.slice(0, 47)}-${i + 2}`)];
|
|
for (const candidate of candidates) {
|
|
const rows = await db.select({ id: clients.id }).from(clients).where(eq(clients.slug, candidate)).limit(1);
|
|
if (rows.length === 0) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const createClientSchema = z.object({
|
|
name: z.string().min(1, "Nome richiesto"),
|
|
brand_name: z.string().min(1, "Nome brand richiesto"),
|
|
brief: z.string().min(1, "Brief richiesto"),
|
|
});
|
|
|
|
export async function createClient(formData: FormData) {
|
|
await requireAdmin();
|
|
const raw = {
|
|
name: formData.get("name") as string,
|
|
brand_name: formData.get("brand_name") as string,
|
|
brief: formData.get("brief") as string,
|
|
};
|
|
|
|
const parsed = createClientSchema.safeParse(raw);
|
|
if (!parsed.success) {
|
|
throw new Error(
|
|
parsed.error.issues.map((i) => i.message).join(", ")
|
|
);
|
|
}
|
|
|
|
// Auto-generate slug from name (e.g. "Mario Rossi" → "mario-rossi")
|
|
const slug = await uniqueSlug(toSlug(parsed.data.name));
|
|
|
|
// Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
|
|
const [newClient] = await db
|
|
.insert(clients)
|
|
.values({
|
|
name: parsed.data.name,
|
|
brand_name: parsed.data.brand_name,
|
|
brief: parsed.data.brief,
|
|
slug,
|
|
})
|
|
.returning({ id: clients.id, token: clients.token, brand_name: clients.brand_name });
|
|
|
|
// Create a default project for the client — all work items are project-scoped
|
|
const [newProject] = await db
|
|
.insert(projects)
|
|
.values({
|
|
client_id: newClient.id,
|
|
name: newClient.brand_name,
|
|
})
|
|
.returning({ id: projects.id });
|
|
|
|
// Always create two payment stubs per project — Acconto 50% and Saldo 50%
|
|
await db.insert(payments).values([
|
|
{
|
|
project_id: newProject.id,
|
|
label: "Acconto 50%",
|
|
amount: "0",
|
|
status: "da_saldare",
|
|
},
|
|
{
|
|
project_id: newProject.id,
|
|
label: "Saldo 50%",
|
|
amount: "0",
|
|
status: "da_saldare",
|
|
},
|
|
]);
|
|
|
|
revalidatePath("/admin");
|
|
redirect(`/admin/clients/${newClient.id}`);
|
|
} |