diff --git a/.env.example b/.env.example index 0ef7414..6597547 100644 --- a/.env.example +++ b/.env.example @@ -30,8 +30,8 @@ SEED_ADMIN_NAME=Administrateur Plein R NODE_ENV=production PORT=3000 -# URL publique du site, utilisée dans le texte des publications réseaux sociaux -# (lien vers la fiche de l'adhérent). À défaut, AUTH_URL est utilisé. +# URL publique du site. Se règle normalement dans Backend › Réseaux sociaux ; +# cette variable ne sert que de repli (puis AUTH_URL en dernier recours). NEXT_PUBLIC_SITE_URL= # ---- Publication des promotions sur les réseaux sociaux ---- diff --git a/CLAUDE.md b/CLAUDE.md index 4cdf92f..b4260d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,11 @@ site sans traitement supplémentaire. `src/lib/crypto.ts` (AES-256-GCM, clé `SOCIAL_TOKEN_KEY` ou `AUTH_SECRET`), posés depuis `/backend/reseaux`. Les variables d'environnement restent lues en repli. Aucun secret ne doit jamais repartir vers le navigateur. -- `isNetworkConfigured()` / `configuredNetworks()` sont **asynchrones** (accès base). +- `isNetworkConfigured()` / `configuredNetworks()` / `siteUrl()` / `redirectUri()` + sont **asynchrones** (accès base). +- L'URL publique est le réglage `site_public_url`, édité sur `/backend/reseaux` + (pré-rempli depuis les en-têtes de la requête). `saveSiteSettings` **saute** + cette clé : le formulaire Paramètres ne la contient pas et l'écraserait. - Routes OAuth : `src/app/api/social/[network]/{connect,callback}`. Le `state` anti-CSRF passe par un cookie httpOnly ; aucun jeton ne transite par une URL. - Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement diff --git a/README.md b/README.md index 068b633..28b8183 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,13 @@ jetons sont récupérés par OAuth et stockés **chiffrés** (AES-256-GCM, clé navigateur. Un réseau non connecté voit simplement sa case disparaître du formulaire de promotion. -L'écran affiche l'URL de redirection à déclarer sur le portail développeur — -c'est l'erreur de configuration la plus fréquente. +Le même écran porte l'**URL publique du site**, pré-remplie avec l'adresse par +laquelle vous consultez le backoffice : elle sert à l'adresse de retour OAuth et +au lien inséré dans les publications. Seule l'origine est conservée (le schéma +est ajouté si vous l'omettez, un éventuel chemin est retiré). + +L'écran affiche ensuite l'URL de redirection à déclarer sur le portail +développeur — c'est l'erreur de configuration la plus fréquente. **Facebook.** Créez une application « Business » sur [developers.facebook.com](https://developers.facebook.com/apps), ajoutez le @@ -171,8 +176,8 @@ Voir [`.env.example`](./.env.example). Les principales : - `AUTH_URL` — URL publique de l'application - `SEED_ON_START` — `true` pour seeder au démarrage du conteneur - `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin -- `NEXT_PUBLIC_SITE_URL` — URL publique : sert au lien des posts **et** à l'adresse - de retour OAuth. Obligatoire pour connecter un réseau social. +- `NEXT_PUBLIC_SITE_URL` — repli pour l'URL publique du site ; elle se règle + normalement dans **Backend › Réseaux sociaux**, aucune variable n'est requise. - `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux (défaut : `AUTH_SECRET`) - `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` / `LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice diff --git a/src/app/api/social/[network]/connect/route.ts b/src/app/api/social/[network]/connect/route.ts index a691ce6..6a112a5 100644 --- a/src/app/api/social/[network]/connect/route.ts +++ b/src/app/api/social/[network]/connect/route.ts @@ -34,10 +34,11 @@ export async function GET( return back(request, "Réseau inconnu."); } - if (!siteUrl()) { + const base = await siteUrl(); + if (!base) { return back( request, - "L'URL publique du site n'est pas configurée (NEXT_PUBLIC_SITE_URL) : impossible de construire l'adresse de retour." + "Renseignez d'abord l'URL publique du site : elle sert à construire l'adresse de retour." ); } @@ -50,11 +51,11 @@ export async function GET( // `state` en cookie httpOnly : vérifié au retour pour écarter toute requête // de rappel forgée. const state = randomBytes(24).toString("hex"); - const response = NextResponse.redirect(authorizeUrl(network, account.appId, state)); + const response = NextResponse.redirect(await authorizeUrl(network, account.appId, state)); response.cookies.set(`plr_oauth_${network}`, state, { httpOnly: true, sameSite: "lax", - secure: new URL(siteUrl()).protocol === "https:", + secure: new URL(base).protocol === "https:", path: "/", maxAge: 600, }); diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index e756bd2..f9b9eb4 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -952,6 +952,9 @@ export async function saveSiteSettings(formData: FormData) { const updatedAt = new Date(); for (const key of Object.keys(SITE_SETTING_DEFAULTS)) { + // Réglée sur l'écran Réseaux sociaux : ce formulaire ne la contient pas et + // l'écraserait avec une chaîne vide. + if (key === "site_public_url") continue; await db .insert(siteSettings) .values({ key, value: asString(formData, key), updatedAt }) @@ -1133,6 +1136,45 @@ export async function setContactStatus(formData: FormData) { revalidatePath("/backend"); } +// ---- URL publique de l'application ---- +// Sert à l'adresse de retour OAuth et au lien inséré dans les publications. +export async function saveSitePublicUrl(formData: FormData) { + const { role } = await requireRole(); + if (!can(role, "manageSettings")) throw new Error("Accès refusé"); + + const raw = asString(formData, "sitePublicUrl"); + let value = ""; + if (raw) { + // Le schéma est facultatif à la saisie ; seul l'origine est conservée, un + // chemin ou des paramètres casseraient l'adresse de retour OAuth. + const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`; + let parsed: URL | null = null; + try { + parsed = new URL(candidate); + } catch { + parsed = null; + } + if (!parsed || !["http:", "https:"].includes(parsed.protocol) || !parsed.hostname) { + // Message rendu par le bandeau de l'écran, plutôt qu'une page d'erreur brute. + redirect( + `/backend/reseaux?error=${encodeURIComponent( + "Adresse invalide : indiquez par exemple https://pleinr.example.fr" + )}` + ); + } + value = parsed.origin; + } + + const updatedAt = new Date(); + await db + .insert(siteSettings) + .values({ key: "site_public_url", value, updatedAt }) + .onConflictDoUpdate({ target: siteSettings.key, set: { value, updatedAt } }); + + revalidatePath("/backend/reseaux"); + revalidatePath("/backend/parametres"); +} + // ---- Réseaux sociaux : identifiants d'application et connexion ---- export async function saveSocialApp(formData: FormData) { const { role } = await requireRole(); diff --git a/src/app/backend/reseaux/page.tsx b/src/app/backend/reseaux/page.tsx index 285fa81..2ee431f 100644 --- a/src/app/backend/reseaux/page.tsx +++ b/src/app/backend/reseaux/page.tsx @@ -1,3 +1,4 @@ +import { headers } from "next/headers"; import { redirect } from "next/navigation"; import type { CSSProperties } from "react"; import { auth } from "@/auth"; @@ -13,7 +14,7 @@ import { SOCIAL_NETWORKS, type SocialTarget, } from "@/lib/social-accounts"; -import { disconnectSocial, saveSocialApp, selectSocialTarget } from "../actions"; +import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions"; export const dynamic = "force-dynamic"; @@ -56,7 +57,20 @@ export default async function ReseauxPage({ const { error, connected, choose } = await searchParams; const accounts = await getSocialAccounts(); const byNetwork = new Map(accounts.map((a) => [a.network, a])); - const base = siteUrl(); + const base = await siteUrl(); + + // Adresse par laquelle l'administrateur consulte cette page : proposée par + // défaut pour lui éviter de la recopier à la main. + const requestHeaders = await headers(); + const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? ""; + const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https"); + const detected = host ? `${protocol}://${host}` : ""; + + // Calculées ici : le rendu des cartes n'est pas asynchrone. + const redirectUris = new Map(); + for (const network of SOCIAL_NETWORKS) { + redirectUris.set(network, await redirectUri(network)); + } // Cibles à proposer quand le compte administre plusieurs pages. const targets = new Map(); @@ -87,13 +101,39 @@ export default async function ReseauxPage({ )} - {!base && ( - - L'URL publique du site n'est pas renseignée (variable - NEXT_PUBLIC_SITE_URL). Elle est indispensable pour construire - l'adresse de retour OAuth : renseignez-la avant de connecter un réseau. - - )} +
+

+ URL publique du site +

+

+ Adresse à laquelle vos visiteurs accèdent au site. Elle sert à construire + l'adresse de retour des connexions Facebook et LinkedIn, et le lien inséré dans + les publications. +

+
+
+ + +
+ +
+ {!base && detected && ( +
+ Adresse détectée depuis votre navigation : {detected}. Vérifiez-la puis + enregistrez. +
+ )} + {!base && !detected && ( +
+ Renseignez cette adresse avant de connecter un réseau social. +
+ )} +
{SOCIAL_NETWORKS.map((network) => { const account = byNetwork.get(network); @@ -240,7 +280,7 @@ export default async function ReseauxPage({
URL de redirection à déclarer
- {base ? redirectUri(network) : "— renseignez d'abord NEXT_PUBLIC_SITE_URL —"} + {base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
{help.caution && ( diff --git a/src/lib/site-settings.ts b/src/lib/site-settings.ts index 27c75b6..e3966bf 100644 --- a/src/lib/site-settings.ts +++ b/src/lib/site-settings.ts @@ -3,6 +3,9 @@ import { db } from "@/db"; import { siteSettings } from "@/db/schema"; export const SITE_SETTING_DEFAULTS = { + // URL publique de l'application : sert à l'adresse de retour OAuth des réseaux + // sociaux et au lien inséré dans les publications. + site_public_url: "", association_name: "Plein R", association_tagline: "Association des commerçants et entreprises du Bassin de Pompey.", association_intro: diff --git a/src/lib/social-accounts.ts b/src/lib/social-accounts.ts index 39e8fc3..2b27221 100644 --- a/src/lib/social-accounts.ts +++ b/src/lib/social-accounts.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import { db } from "@/db"; -import { socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema"; +import { siteSettings, socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema"; import { decryptSecret, encryptSecret } from "./crypto"; /** @@ -30,12 +30,21 @@ function env(key: string): string { return (process.env[key] ?? "").trim(); } -export function siteUrl(): string { - return (env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, ""); +/** + * URL publique de l'application. Réglée dans Backend › Réseaux sociaux ; les + * variables d'environnement restent acceptées en repli. + */ +export async function siteUrl(): Promise { + const [row] = await db + .select({ value: siteSettings.value }) + .from(siteSettings) + .where(eq(siteSettings.key, "site_public_url")); + const stored = (row?.value ?? "").trim(); + return (stored || env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, ""); } -export function redirectUri(network: SocialNetwork): string { - return `${siteUrl()}/api/social/${network}/callback`; +export async function redirectUri(network: SocialNetwork): Promise { + return `${await siteUrl()}/api/social/${network}/callback`; } // ---- Lecture ---- @@ -123,11 +132,16 @@ const SCOPES: Record = { linkedin: "w_organization_social r_organization_social rw_organization_admin", }; -export function authorizeUrl(network: SocialNetwork, appId: string, state: string): string { +export async function authorizeUrl( + network: SocialNetwork, + appId: string, + state: string +): Promise { + const redirect = await redirectUri(network); if (network === "facebook") { const params = new URLSearchParams({ client_id: appId, - redirect_uri: redirectUri("facebook"), + redirect_uri: redirect, state, scope: SCOPES.facebook, response_type: "code", @@ -137,7 +151,7 @@ export function authorizeUrl(network: SocialNetwork, appId: string, state: strin const params = new URLSearchParams({ response_type: "code", client_id: appId, - redirect_uri: redirectUri("linkedin"), + redirect_uri: redirect, state, scope: SCOPES.linkedin, }); @@ -176,7 +190,7 @@ async function exchangeFacebook( `${base}/oauth/access_token?${new URLSearchParams({ client_id: appId, client_secret: appSecret, - redirect_uri: redirectUri("facebook"), + redirect_uri: await redirectUri("facebook"), code, })}` ); @@ -232,7 +246,7 @@ async function exchangeLinkedIn( code, client_id: clientId, client_secret: clientSecret, - redirect_uri: redirectUri("linkedin"), + redirect_uri: await redirectUri("linkedin"), }), }); if (!tokenRes.ok) throw new SocialAuthError(`LinkedIn (code) : ${await readError(tokenRes)}`); diff --git a/src/lib/social.ts b/src/lib/social.ts index 9ec2e78..a300315 100644 --- a/src/lib/social.ts +++ b/src/lib/social.ts @@ -39,20 +39,20 @@ export type PromoForSharing = { memberName: string | null; }; -export function promoLink(promo: PromoForSharing): string | null { - const base = siteUrl(); +export async function promoLink(promo: PromoForSharing): Promise { + const base = await siteUrl(); if (!base) return null; return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`; } /** Texte du post, commun aux deux réseaux. */ -export function buildPromoMessage(promo: PromoForSharing): string { +export async function buildPromoMessage(promo: PromoForSharing): Promise { const lines: string[] = []; lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title); if (promo.memberName) lines.push(`Chez ${promo.memberName}`); if (promo.text?.trim()) lines.push("", promo.text.trim()); if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim()); - const link = promoLink(promo); + const link = await promoLink(promo); if (link) lines.push("", link); lines.push("", "#PleinR #BassinDePompey #CommerceLocal"); return lines.join("\n"); @@ -133,7 +133,7 @@ async function publishToFacebook(promo: PromoForSharing, message: string): Promi } else { endpoint = `${base}/feed`; body.set("message", message); - const link = promoLink(promo); + const link = await promoLink(promo); if (link) body.set("link", link); } @@ -245,7 +245,7 @@ export async function publishPromoToNetwork( network: SocialNetwork, promo: PromoForSharing ): Promise { - const message = buildPromoMessage(promo); + const message = await buildPromoMessage(promo); return network === "facebook" ? publishToFacebook(promo, message) : publishToLinkedIn(promo, message);