feat(hardening): rate limit (signup/reset/bookings) + tâches cron + backup PostgreSQL nocturne
All checks were successful
CI / test (pull_request) Successful in 2m10s
All checks were successful
CI / test (pull_request) Successful in 2m10s
This commit is contained in:
parent
f1fb06b0af
commit
a373bd60ad
8 changed files with 319 additions and 0 deletions
51
scripts/backup-postgres.sh
Executable file
51
scripts/backup-postgres.sh
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# Backup nightly du PostgreSQL Karbé vers MinIO.
|
||||
# Lancé par un systemd timer (karbe-backup.timer).
|
||||
#
|
||||
# Rétention 30 jours côté MinIO (s'appuyer sur une lifecycle policy ou un
|
||||
# nettoyage côté `mc rm` planifié — TODO si on veut être propre).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
STAMP=$(date -u +%Y%m%d-%H%M%S)
|
||||
DUMP_DIR=/tmp/karbe-backup
|
||||
DUMP_FILE="$DUMP_DIR/karbe-${STAMP}.sql.gz"
|
||||
BUCKET_DEST="karbe-backups/postgres/karbe-${STAMP}.sql.gz"
|
||||
|
||||
mkdir -p "$DUMP_DIR"
|
||||
|
||||
# Dump compressé depuis le conteneur postgres
|
||||
docker compose -f /home/ubuntu/karbe/docker-compose.prod.yml \
|
||||
-f /home/ubuntu/karbe/docker-compose.override.yml \
|
||||
exec -T postgres pg_dump -U karbe -d karbe \
|
||||
| gzip > "$DUMP_FILE"
|
||||
|
||||
SIZE=$(stat -c %s "$DUMP_FILE")
|
||||
echo "[$(date -u +%FT%TZ)] dump created size=${SIZE}B path=${DUMP_FILE}"
|
||||
|
||||
# Push vers MinIO via mc Docker
|
||||
docker run --rm --network karbe-net \
|
||||
-v "$DUMP_DIR:/dump" \
|
||||
minio/mc:latest sh -c "
|
||||
mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \
|
||||
mc mb karbe/karbe-backups --ignore-existing >/dev/null 2>&1 && \
|
||||
mc cp /dump/karbe-${STAMP}.sql.gz karbe/${BUCKET_DEST}
|
||||
" \
|
||||
-e MINIO_ROOT_USER \
|
||||
-e MINIO_ROOT_PASSWORD
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] uploaded to karbe/${BUCKET_DEST}"
|
||||
|
||||
# Nettoyage local
|
||||
rm -f "$DUMP_FILE"
|
||||
|
||||
# Rétention : supprime les backups > 30 jours dans MinIO
|
||||
docker run --rm --network karbe-net minio/mc:latest sh -c "
|
||||
mc alias set karbe http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" >/dev/null 2>&1 && \
|
||||
mc rm --recursive --force --older-than 30d karbe/karbe-backups/ 2>/dev/null || true
|
||||
" \
|
||||
-e MINIO_ROOT_USER \
|
||||
-e MINIO_ROOT_PASSWORD
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] retention sweep done (>30d removed)"
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from "@/lib/booking";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendBookingRequestToOwner, sendBookingRequestToTenant } from "@/lib/email";
|
||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
|
|
@ -28,6 +29,14 @@ type CreateBookingBody = {
|
|||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const rl = rateLimitRequest(request, "bookings", 60 * 60 * 1000, 10);
|
||||
if (!rl.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
||||
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
||||
);
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Non authentifié." }, { status: 401 });
|
||||
|
|
|
|||
37
src/app/api/cron/run/[task]/route.ts
Normal file
37
src/app/api/cron/run/[task]/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { SCHEDULED_TASKS, type ScheduledTaskName } from "@/lib/scheduled";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function authorized(req: Request): boolean {
|
||||
const secret = (process.env.CRON_TOKEN ?? "").trim();
|
||||
if (!secret) return false;
|
||||
const header = req.headers.get("authorization") ?? "";
|
||||
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
|
||||
return token === secret;
|
||||
}
|
||||
|
||||
export async function POST(req: Request, ctx: { params: Promise<{ task: string }> }) {
|
||||
if (!authorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const { task } = await ctx.params;
|
||||
const fn = SCHEDULED_TASKS[task as ScheduledTaskName];
|
||||
if (!fn) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unknown task. Available: ${Object.keys(SCHEDULED_TASKS).join(", ")}` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await fn();
|
||||
return NextResponse.json({ ok: true, task, result });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : String(e) },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { createPasswordResetToken } from "@/lib/password-reset";
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import { sendPasswordReset } from "@/lib/email";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
|
|
@ -15,6 +16,13 @@ const schema = z.object({
|
|||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const rl = rateLimitRequest(req, "password-reset", 60 * 60 * 1000, 3);
|
||||
if (!rl.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
||||
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { hashPassword } from "@/lib/password";
|
|||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { sendSignupWelcome } from "@/lib/email";
|
||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
|
|
@ -19,6 +20,14 @@ const schema = z.object({
|
|||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// 5 inscriptions max par IP par heure.
|
||||
const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5);
|
||||
if (!rl.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Trop de tentatives. Réessayez dans ${rl.retryAfter}s.` },
|
||||
{ status: 429, headers: { "Retry-After": String(rl.retryAfter) } },
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
|
|
|
|||
86
src/lib/rate-limit.ts
Normal file
86
src/lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Token-bucket en mémoire — best-effort par instance.
|
||||
*
|
||||
* Pour un déploiement multi-instance, swap pour un store partagé (Redis).
|
||||
* Ici on tourne en mono-instance Next derrière nginx-proxy-manager, donc
|
||||
* une Map locale suffit.
|
||||
*
|
||||
* Usage :
|
||||
* const r = await rateLimit({ key: ip + ":signup", windowMs: 60_000, limit: 5 });
|
||||
* if (!r.ok) return tooManyRequests(r.retryAfter);
|
||||
*/
|
||||
|
||||
type Bucket = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
const SWEEP_INTERVAL_MS = 60_000;
|
||||
let lastSweep = 0;
|
||||
|
||||
function sweep(now: number) {
|
||||
if (now - lastSweep < SWEEP_INTERVAL_MS) return;
|
||||
lastSweep = now;
|
||||
for (const [k, b] of buckets) {
|
||||
if (b.resetAt <= now) buckets.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
export type RateLimitOpts = {
|
||||
key: string;
|
||||
/** Fenêtre glissante en ms. */
|
||||
windowMs: number;
|
||||
/** Nombre max d'appels par fenêtre. */
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type RateLimitResult = {
|
||||
ok: boolean;
|
||||
remaining: number;
|
||||
retryAfter: number; // secondes
|
||||
};
|
||||
|
||||
export function rateLimit(opts: RateLimitOpts): RateLimitResult {
|
||||
const now = Date.now();
|
||||
sweep(now);
|
||||
|
||||
const b = buckets.get(opts.key);
|
||||
if (!b || b.resetAt <= now) {
|
||||
buckets.set(opts.key, { count: 1, resetAt: now + opts.windowMs });
|
||||
return { ok: true, remaining: opts.limit - 1, retryAfter: 0 };
|
||||
}
|
||||
|
||||
if (b.count >= opts.limit) {
|
||||
return {
|
||||
ok: false,
|
||||
remaining: 0,
|
||||
retryAfter: Math.max(1, Math.ceil((b.resetAt - now) / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
b.count++;
|
||||
return { ok: true, remaining: opts.limit - b.count, retryAfter: 0 };
|
||||
}
|
||||
|
||||
/** Extract a client IP from a request, fallback to a safe default. */
|
||||
export function getClientIp(req: Request): string {
|
||||
// nginx-proxy-manager pose x-forwarded-for, x-real-ip
|
||||
const xff = req.headers.get("x-forwarded-for");
|
||||
if (xff) return xff.split(",")[0].trim();
|
||||
const xri = req.headers.get("x-real-ip");
|
||||
if (xri) return xri.trim();
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Helper pratique : extract IP + applique le bucket. */
|
||||
export function rateLimitRequest(
|
||||
req: Request,
|
||||
bucket: string,
|
||||
windowMs: number,
|
||||
limit: number,
|
||||
): RateLimitResult {
|
||||
const ip = getClientIp(req);
|
||||
return rateLimit({ key: `${ip}:${bucket}`, windowMs, limit });
|
||||
}
|
||||
75
src/lib/scheduled.ts
Normal file
75
src/lib/scheduled.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Tâches planifiées exécutables via /api/cron/run/[task] avec le secret
|
||||
* CRON_TOKEN. Idempotents, retournent un compteur d'actions.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
|
||||
import { BookingStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { purgeExpiredResetTokens } from "@/lib/password-reset";
|
||||
|
||||
const PENDING_TTL_DAYS = 7;
|
||||
|
||||
/** Annule les bookings PENDING créés il y a plus de N jours. */
|
||||
export async function autoCancelStalePending(): Promise<{ cancelled: number }> {
|
||||
const cutoff = new Date(Date.now() - PENDING_TTL_DAYS * 86_400_000);
|
||||
const stale = await prisma.booking.findMany({
|
||||
where: { status: BookingStatus.PENDING, createdAt: { lt: cutoff } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (stale.length === 0) return { cancelled: 0 };
|
||||
await prisma.booking.updateMany({
|
||||
where: { id: { in: stale.map((s) => s.id) } },
|
||||
data: { status: BookingStatus.CANCELLED },
|
||||
});
|
||||
await recordAudit({
|
||||
scope: "cron",
|
||||
event: "bookings.auto-cancel-stale",
|
||||
actorEmail: null,
|
||||
details: { count: stale.length, cutoff: cutoff.toISOString() },
|
||||
});
|
||||
return { cancelled: stale.length };
|
||||
}
|
||||
|
||||
/** Purge les password reset tokens expirés. */
|
||||
export async function purgeResetTokens(): Promise<{ purged: number }> {
|
||||
const count = await purgeExpiredResetTokens();
|
||||
if (count > 0) {
|
||||
await recordAudit({
|
||||
scope: "cron",
|
||||
event: "password.purge-expired-tokens",
|
||||
actorEmail: null,
|
||||
details: { count },
|
||||
});
|
||||
}
|
||||
return { purged: count };
|
||||
}
|
||||
|
||||
/** Logique simple : retourne juste la liste des bookings dont l'arrivée est dans 3 jours.
|
||||
* L'envoi email réel est branché plus tard quand RESEND_API_KEY est posée. */
|
||||
export async function listUpcomingArrivalsInThreeDays() {
|
||||
const now = new Date();
|
||||
const in3 = new Date(now.getTime() + 3 * 86_400_000);
|
||||
const in4 = new Date(now.getTime() + 4 * 86_400_000);
|
||||
return prisma.booking.findMany({
|
||||
where: {
|
||||
status: BookingStatus.CONFIRMED,
|
||||
startDate: { gte: in3, lt: in4 },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
tenant: { select: { email: true, firstName: true } },
|
||||
carbet: { select: { title: true, slug: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const SCHEDULED_TASKS = {
|
||||
"auto-cancel-stale-pending": autoCancelStalePending,
|
||||
"purge-reset-tokens": purgeResetTokens,
|
||||
} as const;
|
||||
|
||||
export type ScheduledTaskName = keyof typeof SCHEDULED_TASKS;
|
||||
44
tests/lib/rate-limit.test.ts
Normal file
44
tests/lib/rate-limit.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
describe("rateLimit", () => {
|
||||
it("allows up to limit calls in window", () => {
|
||||
const key = "test:" + Math.random();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = rateLimit({ key, windowMs: 60_000, limit: 5 });
|
||||
expect(r.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks the (limit+1)th call with retryAfter > 0", () => {
|
||||
const key = "test:" + Math.random();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
rateLimit({ key, windowMs: 60_000, limit: 3 });
|
||||
}
|
||||
const r = rateLimit({ key, windowMs: 60_000, limit: 3 });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.retryAfter).toBeGreaterThan(0);
|
||||
expect(r.remaining).toBe(0);
|
||||
});
|
||||
|
||||
it("isolates different keys", () => {
|
||||
const k1 = "test:" + Math.random();
|
||||
const k2 = "test:" + Math.random();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
rateLimit({ key: k1, windowMs: 60_000, limit: 5 });
|
||||
}
|
||||
const r = rateLimit({ key: k2, windowMs: 60_000, limit: 5 });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("resets after window expires", async () => {
|
||||
const key = "test:" + Math.random();
|
||||
rateLimit({ key, windowMs: 10, limit: 1 });
|
||||
const blocked = rateLimit({ key, windowMs: 10, limit: 1 });
|
||||
expect(blocked.ok).toBe(false);
|
||||
await new Promise((r) => setTimeout(r, 15));
|
||||
const after = rateLimit({ key, windowMs: 10, limit: 1 });
|
||||
expect(after.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue