chore: harden security, clean deps, add vitest baseline
- .env.example: vider les secrets (à rotater côté Plesk)
- src/lib/auth.ts: fail-fast lazy si ADMIN_JWT_SECRET < 32 chars
- src/pages/api/upload.ts: retirer .svg (anti-XSS) + try/catch FS
- src/pages/api/content/[section].ts: masquer err.message (sauf ZodError)
- retirer gsap/three/@types/three/@react-three/{fiber,drei}/lucide-react
- bump astro 6.1.10 (GHSA-xr5h-phrj-8vxv)
- zod 4: z.email()/z.url() au lieu de .email()/.url()
- nettoyer vars inutilisées + Header script is:inline
Vitest (38 tests, 615ms):
- tests/lib/auth.test.ts: verifyPassword + verifySession (round-trip, secrets distincts)
- tests/lib/content.test.ts: load/save + comportement seed
- tests/api/files.test.ts: path traversal (../, /, imbriqué) + 200
- tests/api/upload.test.ts: extension/taille/sanitize folder + bucket
- tests/middleware.test.ts: gating admin pages vs APIs vs routes publiques
- astro check: 0 errors / 0 warnings / 0 hints
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -198,7 +198,7 @@ const current = Astro.url.pathname;
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script data-astro-rerun>
|
||||
<script is:inline data-astro-rerun>
|
||||
// Mobile menu
|
||||
const btn = document.getElementById('mobile-menu-btn');
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
|
||||
@@ -7,7 +7,6 @@ interface Props {
|
||||
const { project } = Astro.props;
|
||||
const isArchived = project.status === 'archived';
|
||||
const isPrivate = project.status === 'private';
|
||||
const isWIP = project.status === 'wip';
|
||||
const statusLabel =
|
||||
project.status === 'live'
|
||||
? 'Live'
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function PatchBay({ technical, soft }: Props) {
|
||||
Techniques
|
||||
</h3>
|
||||
<ul className="space-y-2.5">
|
||||
{technical.map((s, i) => (
|
||||
{technical.map((s) => (
|
||||
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
|
||||
<span className="text-[color:var(--color-text-muted)] flex-1">{s}</span>
|
||||
<span
|
||||
@@ -122,7 +122,7 @@ export default function PatchBay({ technical, soft }: Props) {
|
||||
Transverses
|
||||
</h3>
|
||||
<ul className="space-y-2.5">
|
||||
{soft.map((s, i) => (
|
||||
{soft.map((s) => (
|
||||
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
|
||||
<span
|
||||
data-jack="right"
|
||||
|
||||
@@ -4,7 +4,7 @@ export const FormationSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
school: z.string(),
|
||||
schoolUrl: z.string().url().optional(),
|
||||
schoolUrl: z.url().optional(),
|
||||
period: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -23,11 +23,11 @@ export const ProfileSchema = z.object({
|
||||
.default([]),
|
||||
}),
|
||||
socials: z.object({
|
||||
email: z.string().email().optional(),
|
||||
github: z.string().url().optional(),
|
||||
gitea: z.string().url().optional(),
|
||||
linkedin: z.string().url().optional(),
|
||||
website: z.string().url().optional(),
|
||||
email: z.email().optional(),
|
||||
github: z.url().optional(),
|
||||
gitea: z.url().optional(),
|
||||
linkedin: z.url().optional(),
|
||||
website: z.url().optional(),
|
||||
}),
|
||||
cv: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -8,8 +8,8 @@ export const ProjectSchema = z.object({
|
||||
image: z.string().optional(),
|
||||
category: z.enum(['web', 'mobile', 'bot', 'infra', 'other']).default('web'),
|
||||
status: z.enum(['live', 'archived', 'private', 'wip']).default('live'),
|
||||
url: z.string().url().optional(),
|
||||
repoUrl: z.string().url().optional(),
|
||||
url: z.url().optional(),
|
||||
repoUrl: z.url().optional(),
|
||||
stack: z.array(z.string()).default([]),
|
||||
featured: z.boolean().default(false),
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
export const SiteSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string().url(),
|
||||
url: z.url(),
|
||||
keywords: z.array(z.string()).default([]),
|
||||
ogImage: z.string(),
|
||||
twitter: z.string().optional(),
|
||||
|
||||
@@ -9,9 +9,22 @@ const env = {
|
||||
};
|
||||
|
||||
const ADMIN_PASSWORD_HASH = (env.ADMIN_PASSWORD_HASH as string | undefined) ?? '';
|
||||
const JWT_SECRET =
|
||||
(env.ADMIN_JWT_SECRET as string | undefined) ?? 'dev-secret-change-me-in-production';
|
||||
const SECRET_KEY = new TextEncoder().encode(JWT_SECRET);
|
||||
const JWT_SECRET = (env.ADMIN_JWT_SECRET as string | undefined) ?? '';
|
||||
|
||||
// Lazy : on ne throw pas au chargement du module (Astro build importe ce fichier
|
||||
// en SSR sans forcément avoir le .env). On throw au premier usage runtime.
|
||||
let cachedKey: Uint8Array | null = null;
|
||||
function getSecretKey(): Uint8Array {
|
||||
if (cachedKey) return cachedKey;
|
||||
if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
||||
throw new Error(
|
||||
'[auth] ADMIN_JWT_SECRET manquant ou trop court (>= 32 chars). ' +
|
||||
'Générer via `npm run hash:password -- <motdepasse>` et reporter dans .env.',
|
||||
);
|
||||
}
|
||||
cachedKey = new TextEncoder().encode(JWT_SECRET);
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
export const ADMIN_COOKIE = 'portfolio_admin';
|
||||
|
||||
@@ -28,13 +41,13 @@ export async function createSession(): Promise<string> {
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(SECRET_KEY);
|
||||
.sign(getSecretKey());
|
||||
}
|
||||
|
||||
export async function verifySession(token: string | undefined): Promise<boolean> {
|
||||
if (!token) return false;
|
||||
try {
|
||||
await jwtVerify(token, SECRET_KEY);
|
||||
await jwtVerify(token, getSecretKey());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -19,10 +19,8 @@ export const GET: APIRoute = async ({ params }) => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'load_failed', message: (err as Error).message }),
|
||||
{ status: 500 },
|
||||
);
|
||||
console.error('[content] load_failed', section, err);
|
||||
return new Response(JSON.stringify({ error: 'load_failed' }), { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -43,8 +41,10 @@ export const PUT: APIRoute = async ({ params, request }) => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[content] validation_failed', section, err);
|
||||
const message = err instanceof Error && err.name === 'ZodError' ? err.message : undefined;
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'validation_failed', message: (err as Error).message }),
|
||||
JSON.stringify(message ? { error: 'validation_failed', message } : { error: 'validation_failed' }),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { join, extname, basename } from 'node:path';
|
||||
|
||||
const UPLOAD_ROOT = join(process.cwd(), 'data', 'uploads');
|
||||
|
||||
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg', '.gif']);
|
||||
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
||||
const DOCUMENT_EXT = new Set(['.pdf']);
|
||||
|
||||
const MAX_SIZE: Record<'image' | 'document', number> = {
|
||||
@@ -43,11 +43,16 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
const safeFolder = folder.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const bucket = kind === 'document' ? 'assets' : 'img';
|
||||
const targetDir = safeFolder ? join(UPLOAD_ROOT, bucket, safeFolder) : join(UPLOAD_ROOT, bucket);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
const filename = sanitize(file.name);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(join(targetDir, filename), buffer);
|
||||
|
||||
try {
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(join(targetDir, filename), buffer);
|
||||
} catch (err) {
|
||||
console.error('[upload] write_failed', err);
|
||||
return new Response(JSON.stringify({ error: 'write_failed' }), { status: 500 });
|
||||
}
|
||||
|
||||
const url = `/api/files/${bucket}/${safeFolder ? safeFolder + '/' : ''}${filename}`;
|
||||
return new Response(JSON.stringify({ ok: true, url }), {
|
||||
|
||||
@@ -9,7 +9,7 @@ import BootSequence from '../components/islands/BootSequence';
|
||||
import { Icon } from '@iconify-icon/react';
|
||||
import { loadAll } from '../lib/content';
|
||||
|
||||
const { profile, skills, interests, site } = await loadAll();
|
||||
const { profile, skills, interests } = await loadAll();
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
|
||||
Reference in New Issue
Block a user