mirror of
https://github.com/arthur-pbty/contact.git
synced 2026-06-03 23:36:30 +02:00
feat: add contact page with form handling and validation
- Implemented a contact page with a form for user inquiries. - Added validation for form fields using Zod schema. - Integrated PostgreSQL database for storing contact messages. - Created necessary API endpoints for form submission. - Added admin authentication and session management. - Developed CGU, cookies policy, privacy policy, and legal mentions pages. - Set up Docker configuration for PostgreSQL and application services. - Enhanced UI with responsive design and accessibility features.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const COOKIE_NAME = "admin_session";
|
||||
const SESSION_TTL_SECONDS = 60 * 60 * 12;
|
||||
|
||||
type SessionPayload = {
|
||||
sub: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
function getSecret() {
|
||||
const secret = process.env.ADMIN_SESSION_SECRET;
|
||||
if (!secret) {
|
||||
throw new Error("ADMIN_SESSION_SECRET is missing.");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string) {
|
||||
return Buffer.from(value).toString("base64url");
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string) {
|
||||
return Buffer.from(value, "base64url").toString();
|
||||
}
|
||||
|
||||
function sign(value: string) {
|
||||
return createHmac("sha256", getSecret()).update(value).digest("base64url");
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string) {
|
||||
const aBuffer = Buffer.from(a);
|
||||
const bBuffer = Buffer.from(b);
|
||||
if (aBuffer.length !== bBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(aBuffer, bBuffer);
|
||||
}
|
||||
|
||||
export function createAdminToken(username: string) {
|
||||
const payload: SessionPayload = {
|
||||
sub: username,
|
||||
exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
|
||||
};
|
||||
|
||||
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
|
||||
const signature = sign(encodedPayload);
|
||||
return `${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
function verifyToken(token: string): SessionPayload | null {
|
||||
const [payloadPart, signaturePart] = token.split(".");
|
||||
if (!payloadPart || !signaturePart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expectedSignature = sign(payloadPart);
|
||||
if (!safeEqual(signaturePart, expectedSignature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(decodeBase64Url(payloadPart)) as SessionPayload;
|
||||
if (!payload?.sub || !payload?.exp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isAdminAuthenticated() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(verifyToken(token));
|
||||
}
|
||||
|
||||
export function getAdminCredentials() {
|
||||
return {
|
||||
username: process.env.ADMIN_USERNAME || "admin",
|
||||
password: process.env.ADMIN_PASSWORD || "change-me",
|
||||
};
|
||||
}
|
||||
|
||||
export function getAdminCookieName() {
|
||||
return COOKIE_NAME;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { z } from "zod";
|
||||
import { normalizeProjectParam, PROJECT_LABELS, PROJECT_VALUES } from "@/lib/sites";
|
||||
|
||||
export const requestTypeValues = ["bug", "question", "suggestion", "other"] as const;
|
||||
|
||||
export type RequestTypeValue = (typeof requestTypeValues)[number];
|
||||
|
||||
export const projectValues = PROJECT_VALUES;
|
||||
|
||||
export const REQUEST_TYPE_LABELS: Record<RequestTypeValue, string> = {
|
||||
bug: "Bug 🐛",
|
||||
question: "Question ❓",
|
||||
suggestion: "Suggestion 💡",
|
||||
other: "Autre",
|
||||
};
|
||||
|
||||
export const contactPayloadSchema = z.object({
|
||||
name: z.string().trim().min(2, "Le nom est requis").max(80, "Nom trop long"),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "L'email est requis")
|
||||
.email("Email invalide")
|
||||
.max(160, "Email trop long"),
|
||||
project: z.enum(projectValues, {
|
||||
error: "Projet invalide",
|
||||
}),
|
||||
requestType: z.enum(requestTypeValues, {
|
||||
error: "Type de demande invalide",
|
||||
}),
|
||||
message: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(10, "Le message doit contenir au moins 10 caracteres")
|
||||
.max(3000, "Le message est trop long"),
|
||||
honeypot: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => value ?? "")
|
||||
.refine((value) => value.trim().length === 0, "Spam detecte"),
|
||||
sourceUrl: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500, "URL trop longue")
|
||||
.optional()
|
||||
.transform((value) => value ?? ""),
|
||||
});
|
||||
|
||||
export type ContactPayload = z.infer<typeof contactPayloadSchema>;
|
||||
|
||||
export { normalizeProjectParam, PROJECT_LABELS };
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
let pool: Pool | null = null;
|
||||
let schemaReady: Promise<void> | null = null;
|
||||
|
||||
function getDatabaseUrl() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error("DATABASE_URL is not configured.");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
pool = new Pool({
|
||||
connectionString: getDatabaseUrl(),
|
||||
});
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function ensureSchema() {
|
||||
if (!schemaReady) {
|
||||
schemaReady = (async () => {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
await client.query(`
|
||||
CREATE TABLE IF NOT EXISTS contact_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
request_type TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
source_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
replied_at TIMESTAMPTZ,
|
||||
admin_reply TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'replied'))
|
||||
);
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
ALTER TABLE contact_messages
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'replied'));
|
||||
`);
|
||||
|
||||
await client.query(`
|
||||
UPDATE contact_messages
|
||||
SET status = CASE WHEN replied_at IS NULL THEN 'pending' ELSE 'replied' END
|
||||
WHERE status IS DISTINCT FROM CASE WHEN replied_at IS NULL THEN 'pending' ELSE 'replied' END;
|
||||
`);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
await schemaReady;
|
||||
}
|
||||
|
||||
export type ContactMessageRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
project: string;
|
||||
request_type: string;
|
||||
message: string;
|
||||
source_url: string | null;
|
||||
created_at: string;
|
||||
replied_at: string | null;
|
||||
admin_reply: string | null;
|
||||
status: "pending" | "replied";
|
||||
};
|
||||
|
||||
export async function createMessage(input: {
|
||||
name: string;
|
||||
email: string;
|
||||
project: string;
|
||||
requestType: string;
|
||||
message: string;
|
||||
sourceUrl?: string | null;
|
||||
}) {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<ContactMessageRow>(
|
||||
`
|
||||
INSERT INTO contact_messages (name, email, project, request_type, message, source_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *
|
||||
`,
|
||||
[input.name, input.email, input.project, input.requestType, input.message, input.sourceUrl ?? null],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
export async function listMessages() {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<ContactMessageRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM contact_messages
|
||||
ORDER BY created_at DESC
|
||||
`,
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
export async function getMessageById(id: number) {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<ContactMessageRow>(
|
||||
`
|
||||
SELECT *
|
||||
FROM contact_messages
|
||||
WHERE id = $1
|
||||
LIMIT 1
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function markMessageReply(id: number, reply: string) {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<ContactMessageRow>(
|
||||
`
|
||||
UPDATE contact_messages
|
||||
SET admin_reply = $2,
|
||||
replied_at = NOW(),
|
||||
status = 'replied'
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
`,
|
||||
[id, reply],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function setMessageStatus(id: number, status: "pending" | "replied") {
|
||||
await ensureSchema();
|
||||
|
||||
const result = await getPool().query<ContactMessageRow>(
|
||||
`
|
||||
UPDATE contact_messages
|
||||
SET status = $2,
|
||||
replied_at = CASE WHEN $2 = 'replied' THEN COALESCE(replied_at, NOW()) ELSE NULL END,
|
||||
admin_reply = CASE WHEN $2 = 'pending' THEN admin_reply ELSE admin_reply END
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
`,
|
||||
[id, status],
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function deleteMessage(id: number) {
|
||||
await ensureSchema();
|
||||
|
||||
const result = await getPool().query<{ id: number }>(
|
||||
`
|
||||
DELETE FROM contact_messages
|
||||
WHERE id = $1
|
||||
RETURNING id
|
||||
`,
|
||||
[id],
|
||||
);
|
||||
|
||||
return Boolean(result.rows[0]);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export const projectSites = [
|
||||
{ value: "arthurp", label: "arthurp.fr", domain: "arthurp.fr", description: "Site principal" },
|
||||
{ value: "links", label: "links.arthurp.fr", domain: "links.arthurp.fr", description: "Liens centralises" },
|
||||
{ value: "qcu", label: "qcu.arthurp.fr", domain: "qcu.arthurp.fr", description: "Quiz et QCU" },
|
||||
{ value: "qrcode", label: "qrcode.arthurp.fr", domain: "qrcode.arthurp.fr", description: "Generateur de QR Code" },
|
||||
{ value: "lazybot", label: "lazybot.arthurp.fr", domain: "lazybot.arthurp.fr", description: "Bot et automatisations" },
|
||||
{ value: "learn", label: "learn.arthurp.fr", domain: "learn.arthurp.fr", description: "Ressources d'apprentissage" },
|
||||
{ value: "sudoku", label: "sudoku.arthurp.fr", domain: "sudoku.arthurp.fr", description: "Jeu Sudoku" },
|
||||
{ value: "reducelink", label: "reducelink.arthurp.fr", domain: "reducelink.arthurp.fr", description: "Reducteur de liens" },
|
||||
{ value: "clock", label: "clock.arthurp.fr", domain: "clock.arthurp.fr", description: "Horloge en ligne" },
|
||||
{ value: "form", label: "form.arthurp.fr", domain: "form.arthurp.fr", description: "Formulaires" },
|
||||
{ value: "pomodoro", label: "pomodoro.arthurp.fr", domain: "pomodoro.arthurp.fr", description: "Timer pomodoro" },
|
||||
{ value: "visio", label: "visio.arthurp.fr", domain: "visio.arthurp.fr", description: "Visioconference" },
|
||||
{ value: "doudou", label: "doudou.arthurp.fr", domain: "doudou.arthurp.fr", description: "Projet Doudou" },
|
||||
{ value: "portfolio", label: "portfolio.arthurp.fr", domain: "portfolio.arthurp.fr", description: "Portfolio" },
|
||||
{ value: "moon", label: "moon.arthurp.fr", domain: "moon.arthurp.fr", description: "Projet Moon" },
|
||||
{ value: "calculatrice", label: "calculatrice.arthurp.fr", domain: "calculatrice.arthurp.fr", description: "Calculatrice" },
|
||||
{ value: "chrono", label: "chrono.arthurp.fr", domain: "chrono.arthurp.fr", description: "Chronometre" },
|
||||
{ value: "blocnote", label: "blocnote.arthurp.fr", domain: "blocnote.arthurp.fr", description: "Bloc-notes" },
|
||||
{ value: "imprimersudoku", label: "imprimersudoku.arthurp.fr", domain: "imprimersudoku.arthurp.fr", description: "Impression Sudoku" },
|
||||
{ value: "other", label: "Autre", domain: "autre", description: "Autre demande" },
|
||||
] as const;
|
||||
|
||||
export type ProjectSite = (typeof projectSites)[number];
|
||||
export type ProjectValue = ProjectSite["value"];
|
||||
|
||||
export const PROJECT_VALUES = projectSites.map((site) => site.value) as [ProjectValue, ...ProjectValue[]];
|
||||
|
||||
export const PROJECT_LABELS: Record<ProjectValue, string> = Object.fromEntries(
|
||||
projectSites.map((site) => [site.value, site.label]),
|
||||
) as Record<ProjectValue, string>;
|
||||
|
||||
export const PROJECT_DESCRIPTIONS: Record<ProjectValue, string> = Object.fromEntries(
|
||||
projectSites.map((site) => [site.value, site.description]),
|
||||
) as Record<ProjectValue, string>;
|
||||
|
||||
export const footerSites = projectSites.filter((site) => site.value !== "other");
|
||||
|
||||
export function normalizeProjectParam(rawProject: string | null): ProjectValue | null {
|
||||
if (!rawProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleaned = rawProject.trim().toLowerCase();
|
||||
const byValue = projectSites.find((site) => site.value === cleaned);
|
||||
if (byValue) {
|
||||
return byValue.value;
|
||||
}
|
||||
|
||||
const byDomain = projectSites.find((site) => site.domain === cleaned);
|
||||
if (byDomain) {
|
||||
return byDomain.value;
|
||||
}
|
||||
|
||||
if (cleaned === "qr-code" || cleaned === "qr" || cleaned === "qr-code-generator") {
|
||||
return "qrcode";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user