Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
@@ -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/**"
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
5 changes: 5 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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é",
Expand Down
17 changes: 17 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"test:e2e": "playwright test",
"analyze": "ANALYZE=true next build",
"knip": "knip",
"routes:map": "nextmap",
"prepare": "lefthook install"
},
"dependencies": {
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions public/.well-known/security.txt
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions src/actions/commit.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { unstable_cache } from "next/cache";

import { env } from "@/env";
import { logger } from "@/lib/logger";
import { octokit } from "@/lib/octokit";

Expand All @@ -25,8 +26,8 @@ const fetchCommitData = async (): Promise<CommitData> => {
}
}`,
{
owner: process.env.GITHUB_USERNAME,
repo: process.env.GITHUB_REPO_NAME,
owner: env.GITHUB_USERNAME,
repo: env.GITHUB_REPO_NAME,
}
);

Expand Down
5 changes: 3 additions & 2 deletions src/actions/data.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { unstable_cache } from "next/cache";

import { env } from "@/env";
import { contributionLevelToNumber } from "@/lib/commits";
import { octokit } from "@/lib/octokit";

Expand Down Expand Up @@ -49,8 +50,8 @@ const fetchGitHubData = async (): Promise<GitHubData> => {
}`,
{
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(),
}
);
Expand Down
18 changes: 16 additions & 2 deletions src/actions/safe-action.ts
Original file line number Diff line number Diff line change
@@ -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,
});
31 changes: 29 additions & 2 deletions src/actions/send-cv.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
92 changes: 92 additions & 0 deletions src/actions/views.action.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;

const readViews = async (): Promise<ViewCounts> => {
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 };
});
9 changes: 8 additions & 1 deletion src/app/(fr)/(content)/(writings)/articles/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -68,10 +71,14 @@ export const ArticleView = ({
{locale === "en" && article.locale === "fr" && (
<WritingsLocaleNotice />
)}
<WritingsProgress />
<WritingsTopBar item={article} items={articles} slug={slug} />
<ArticleTitle title={metadata.title} />
<ArticleTitle title={metadata.title}>
<WritingsViews category="articles" slug={slug} />
</ArticleTitle>
<WritingsToC content={content} />
<Mdx code={content} />
<WritingsRelated current={article} items={articles} />
</>
);
};
Expand Down
9 changes: 8 additions & 1 deletion src/app/(fr)/(content)/(writings)/components/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -72,14 +75,18 @@ export const ComponentView = ({
{locale === "en" && component.locale === "fr" && (
<WritingsLocaleNotice />
)}
<WritingsProgress />
<WritingsTopBar
item={component}
items={components}
slug={slug}
/>
<ArticleTitle title={metadata.title} />
<ArticleTitle title={metadata.title}>
<WritingsViews category="components" slug={slug} />
</ArticleTitle>
<WritingsToC content={content} />
<Mdx code={content} />
<WritingsRelated current={component} items={components} />
</>
);
};
Expand Down
Loading
Loading