diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..51dcf08 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,29 @@ +# Configuration de actions/labeler@v4 (.github/workflows/label.yml) : +# applique des labels aux PR selon les fichiers modifiés. + +documentation: + - "**/*.md" + +content: + - "src/content/**" + - "messages/**" + +components: + - "src/components/**" + - "src/registry/**" + +actions: + - "src/actions/**" + +routes: + - "src/app/**" + +tests: + - "**/*.test.ts" + - "e2e/**" + +dependencies: + - "package.json" + +ci: + - ".github/**" diff --git a/CLAUDE.md b/CLAUDE.md index e9052d4..e51e087 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ pnpm test # Vitest unit tests (src/**/*.test.ts) pnpm test:e2e # Playwright e2e (e2e/, needs `playwright install chromium`) pnpm analyze # Build with @next/bundle-analyzer pnpm knip # Dead code/dependency detection +pnpm routes:map # Interactive visual map of all routes (nextmap) pnpm i18n:compile # Compile Paraglide messages to src/paraglide/ pnpm registry:build # Build component registry for distribution ``` diff --git a/messages/en.json b/messages/en.json index 5d5b79e..35b52f3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "article_french_only": "this article is written in French.", + "back_to_top_aria": "Back to top", + "easter_egg_toast": "secret code found, well done! 🎉", "footer_made_with_love_suffix": "in Paris.", "footer_on_branch": "(on branch {branch})", "home_about_collapse_button": "show less", @@ -290,6 +292,9 @@ "writings_pagination_next_sr": "next", "writings_pagination_previous_aria": "previous", "writings_pagination_previous_sr": "previous", + "writings_related_heading": "read next:", + "writings_views_count": "{views} view{plural}", + "writings_related_item_aria": "Read: {title}", "writings_tags_by_category": "by category:", "writings_toc_trigger_label": "key points on this page", "writings_utils_description": "a suite of free web tools designed to optimize your workflow and boost your productivity", diff --git a/messages/fr.json b/messages/fr.json index c056260..7544f76 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "article_french_only": "cet article est rédigé en français.", + "back_to_top_aria": "Retour en haut de page", + "easter_egg_toast": "code secret découvert, bravo ! 🎉", "footer_made_with_love_suffix": "à Paris.", "footer_on_branch": "(sur la branche {branch})", "home_about_collapse_button": "réduire le texte", @@ -290,6 +292,9 @@ "writings_pagination_next_sr": "Suivant", "writings_pagination_previous_aria": "Précédent", "writings_pagination_previous_sr": "Précédent", + "writings_related_heading": "à lire ensuite :", + "writings_views_count": "{views} vue{plural}", + "writings_related_item_aria": "Lire : {title}", "writings_tags_by_category": "par catégorie :", "writings_toc_trigger_label": "points importants sur cette page", "writings_utils_description": "Une suite d'outils web gratuits conçus pour optimiser votre workflow et booster votre productivité", diff --git a/next.config.ts b/next.config.ts index 7e676d4..3936e7b 100644 --- a/next.config.ts +++ b/next.config.ts @@ -18,6 +18,23 @@ const nextConfig: NextConfig = { return [ { headers: [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Content-Security-Policy", + value: + "base-uri 'self'; frame-ancestors 'none'; object-src 'none'; form-action 'self'", + }, + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + { + key: "X-DNS-Prefetch-Control", + value: "on", + }, { key: "X-Frame-Options", value: "DENY", diff --git a/package.json b/package.json index 9922bf0..030443e 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "test:e2e": "playwright test", "analyze": "ANALYZE=true next build", "knip": "knip", + "routes:map": "nextmap", "prepare": "lefthook install" }, "dependencies": { @@ -89,6 +90,7 @@ "@tsparticles/slim": "^4.1.3", "@uidotdev/usehooks": "^2.4.1", "@vercel/analytics": "^2.0.1", + "@vercel/blob": "^2.4.0", "@vercel/speed-insights": "^2.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -118,6 +120,7 @@ "react-use-measure": "^2.1.7", "resend": "^6.12.4", "rough-notation": "^0.5.1", + "rsc-boundary": "^0.2.0", "schema-dts": "^2.0.0", "sharp": "^0.34.5", "sonner": "^2.0.7", @@ -151,6 +154,7 @@ "jsdom": "^29.1.1", "knip": "^6.16.0", "lefthook": "^2.1.9", + "nextmap": "^1.0.1", "oxfmt": "^0.53.0", "oxlint": "^1.68.0", "pngjs": "^7.0.0", diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000..eac1b95 --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,4 @@ +Contact: mailto:contact@cuzeacflorin.fr +Expires: 2027-06-09T00:00:00.000Z +Preferred-Languages: fr, en +Canonical: https://cuzeacflorin.fr/.well-known/security.txt diff --git a/src/actions/commit.action.ts b/src/actions/commit.action.ts index 5ad15aa..377989f 100644 --- a/src/actions/commit.action.ts +++ b/src/actions/commit.action.ts @@ -2,6 +2,7 @@ import { unstable_cache } from "next/cache"; +import { env } from "@/env"; import { logger } from "@/lib/logger"; import { octokit } from "@/lib/octokit"; @@ -25,8 +26,8 @@ const fetchCommitData = async (): Promise => { } }`, { - owner: process.env.GITHUB_USERNAME, - repo: process.env.GITHUB_REPO_NAME, + owner: env.GITHUB_USERNAME, + repo: env.GITHUB_REPO_NAME, } ); diff --git a/src/actions/data.action.ts b/src/actions/data.action.ts index 654ea98..26db4f0 100644 --- a/src/actions/data.action.ts +++ b/src/actions/data.action.ts @@ -2,6 +2,7 @@ import { unstable_cache } from "next/cache"; +import { env } from "@/env"; import { contributionLevelToNumber } from "@/lib/commits"; import { octokit } from "@/lib/octokit"; @@ -49,8 +50,8 @@ const fetchGitHubData = async (): Promise => { }`, { from: from.toISOString(), - owner: process.env.GITHUB_USERNAME, - repo: process.env.GITHUB_REPO_NAME, + owner: env.GITHUB_USERNAME, + repo: env.GITHUB_REPO_NAME, to: to.toISOString(), } ); diff --git a/src/actions/safe-action.ts b/src/actions/safe-action.ts index 7f62198..4ac6372 100644 --- a/src/actions/safe-action.ts +++ b/src/actions/safe-action.ts @@ -1,3 +1,17 @@ -import { createSafeActionClient } from "next-safe-action"; +import { + createSafeActionClient, + DEFAULT_SERVER_ERROR_MESSAGE, +} from "next-safe-action"; -export const actionClient = createSafeActionClient(); +// Seuls les messages levés via ActionError sont renvoyés au client, +// le reste est masqué pour ne pas fuiter de détails serveur. +export class ActionError extends Error { + override name = "ActionError"; +} + +export const actionClient = createSafeActionClient({ + handleServerError: (error) => + error instanceof ActionError + ? error.message + : DEFAULT_SERVER_ERROR_MESSAGE, +}); diff --git a/src/actions/send-cv.action.ts b/src/actions/send-cv.action.ts index e545bfe..b432713 100644 --- a/src/actions/send-cv.action.ts +++ b/src/actions/send-cv.action.ts @@ -3,21 +3,48 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { headers } from "next/headers"; import { Resend } from "resend"; -import { actionClient } from "@/actions/safe-action"; +import { ActionError, actionClient } from "@/actions/safe-action"; import { CvTemplate } from "@/app/(fr)/(content)/(root)/_components/cv/CvTemplate"; import GLOBAL_DATA from "@/data/global"; import { env } from "@/env"; +import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; import { emailSchema } from "@/schemas/emailSchema"; -const resend = new Resend(env.RESEND_API_KEY); +const RATE_LIMIT_PER_IP = { limit: 3, windowMs: 10 * 60 * 1000 }; +const RATE_LIMIT_GLOBAL = { limit: 20, windowMs: 60 * 60 * 1000 }; export const sendCvAction = actionClient .inputSchema(emailSchema) .action(async ({ parsedInput }) => { const { firstName, recipientEmail } = parsedInput; + const clientIp = getClientIp(await headers()); + const perIp = checkRateLimit( + `send-cv:ip:${clientIp}`, + RATE_LIMIT_PER_IP + ); + const global = checkRateLimit( + "send-cv:global", + RATE_LIMIT_GLOBAL + ); + + if (!(perIp.allowed && global.allowed)) { + throw new ActionError( + "Trop de demandes, réessayez dans quelques minutes !" + ); + } + + if (!env.RESEND_API_KEY) { + throw new ActionError( + "L'envoi d'e-mails n'est pas disponible pour le moment !" + ); + } + + const resend = new Resend(env.RESEND_API_KEY); + const path = join( process.cwd(), "public", diff --git a/src/actions/views.action.ts b/src/actions/views.action.ts new file mode 100644 index 0000000..45d47a7 --- /dev/null +++ b/src/actions/views.action.ts @@ -0,0 +1,92 @@ +"use server"; + +import { head, put } from "@vercel/blob"; +import { headers } from "next/headers"; +import { z } from "zod"; + +import { actionClient } from "@/actions/safe-action"; +import { env } from "@/env"; +import { getContentBySlug } from "@/lib/content"; +import { logger } from "@/lib/logger"; +import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; + +const VIEWS_BLOB_PATH = "stats/views.json"; +const RATE_LIMIT_PER_IP = { limit: 60, windowMs: 10 * 60 * 1000 }; +const SLUG_REGEX = /^[a-z0-9-]+$/u; + +const viewSchema = z.object({ + category: z.enum(["articles", "components", "utils"]), + increment: z.boolean(), + slug: z.string().regex(SLUG_REGEX).max(100), +}); + +type ViewCounts = Record; + +const readViews = async (): Promise => { + try { + const blob = await head(VIEWS_BLOB_PATH, { + token: env.BLOB_READ_WRITE_TOKEN, + }); + const res = await fetch(blob.url, { cache: "no-store" }); + if (!res.ok) { + return {}; + } + + const data = (await res.json()) as ViewCounts; + return typeof data === "object" && data !== null ? data : {}; + } catch { + // blob inexistant tant qu'aucune vue n'a été enregistrée + return {}; + } +}; + +export const trackViewAction = actionClient + .inputSchema(viewSchema) + .action(async ({ parsedInput }) => { + const { category, increment, slug } = parsedInput; + + if (!env.BLOB_READ_WRITE_TOKEN) { + return { views: null }; + } + + if (!getContentBySlug(slug, category)) { + return { views: null }; + } + + const key = `${category}/${slug}`; + const views = await readViews(); + const current = views[key] ?? 0; + + if (!increment) { + return { views: current }; + } + + const clientIp = getClientIp(await headers()); + const rate = checkRateLimit( + `views:ip:${clientIp}`, + RATE_LIMIT_PER_IP + ); + if (!rate.allowed) { + return { views: current }; + } + + // Lecture-modification-écriture non atomique : des incréments + // peuvent se perdre en cas d'accès concurrents, acceptable pour + // un compteur de vues de portfolio. + views[key] = current + 1; + + try { + await put(VIEWS_BLOB_PATH, JSON.stringify(views), { + access: "public", + addRandomSuffix: false, + allowOverwrite: true, + contentType: "application/json", + token: env.BLOB_READ_WRITE_TOKEN, + }); + } catch (error) { + logger.error("Failed to persist view counts:", error); + return { views: current }; + } + + return { views: current + 1 }; + }); diff --git a/src/app/(fr)/(content)/(writings)/articles/[slug]/page.tsx b/src/app/(fr)/(content)/(writings)/articles/[slug]/page.tsx index f14b1eb..8fdbf26 100644 --- a/src/app/(fr)/(content)/(writings)/articles/[slug]/page.tsx +++ b/src/app/(fr)/(content)/(writings)/articles/[slug]/page.tsx @@ -3,8 +3,11 @@ import { notFound } from "next/navigation"; import { ArticleTitle } from "@/components/blog/ArticleTitle"; import { WritingsLocaleNotice } from "@/components/features/WritingsLocaleNotice"; +import { WritingsProgress } from "@/components/features/WritingsProgress"; +import { WritingsRelated } from "@/components/features/WritingsRelated"; import { WritingsToC } from "@/components/features/WritingsToC"; import { WritingsTopBar } from "@/components/features/WritingsTopBar"; +import { WritingsViews } from "@/components/features/WritingsViews"; import { Mdx } from "@/components/markdown/mdx"; import type { ContentLocale } from "@/lib/content"; import { @@ -68,10 +71,14 @@ export const ArticleView = ({ {locale === "en" && article.locale === "fr" && ( )} + - + + + + ); }; diff --git a/src/app/(fr)/(content)/(writings)/components/[slug]/page.tsx b/src/app/(fr)/(content)/(writings)/components/[slug]/page.tsx index 3fe1195..4572531 100644 --- a/src/app/(fr)/(content)/(writings)/components/[slug]/page.tsx +++ b/src/app/(fr)/(content)/(writings)/components/[slug]/page.tsx @@ -3,8 +3,11 @@ import { notFound } from "next/navigation"; import { ArticleTitle } from "@/components/blog/ArticleTitle"; import { WritingsLocaleNotice } from "@/components/features/WritingsLocaleNotice"; +import { WritingsProgress } from "@/components/features/WritingsProgress"; +import { WritingsRelated } from "@/components/features/WritingsRelated"; import { WritingsToC } from "@/components/features/WritingsToC"; import { WritingsTopBar } from "@/components/features/WritingsTopBar"; +import { WritingsViews } from "@/components/features/WritingsViews"; import { Mdx } from "@/components/markdown/mdx"; import type { ContentLocale } from "@/lib/content"; import { @@ -72,14 +75,18 @@ export const ComponentView = ({ {locale === "en" && component.locale === "fr" && ( )} + - + + + + ); }; diff --git a/src/app/(fr)/(content)/(writings)/utils/[slug]/page.tsx b/src/app/(fr)/(content)/(writings)/utils/[slug]/page.tsx index 6c0a6bf..f11bf14 100644 --- a/src/app/(fr)/(content)/(writings)/utils/[slug]/page.tsx +++ b/src/app/(fr)/(content)/(writings)/utils/[slug]/page.tsx @@ -5,8 +5,11 @@ import { Divider } from "@/components/base/Divider"; import { WritingsBreadcrumb } from "@/components/features/WritingsBreadcrumb"; import { WritingsHeading } from "@/components/features/WritingsHeading"; import { WritingsLocaleNotice } from "@/components/features/WritingsLocaleNotice"; +import { WritingsProgress } from "@/components/features/WritingsProgress"; +import { WritingsRelated } from "@/components/features/WritingsRelated"; import { WritingsToC } from "@/components/features/WritingsToC"; import { WritingsTopBar } from "@/components/features/WritingsTopBar"; +import { WritingsViews } from "@/components/features/WritingsViews"; import { Mdx } from "@/components/markdown/mdx"; import type { ContentLocale } from "@/lib/content"; import { @@ -81,7 +84,11 @@ export const UtilView = ({ - + } + /> @@ -89,8 +96,12 @@ export const UtilView = ({ + + + +