URL publique du site réglable depuis le backoffice

Connecter un réseau social imposait de poser NEXT_PUBLIC_SITE_URL dans
docker-compose et de redémarrer le conteneur. L'écran Réseaux sociaux porte
désormais le champ, pré-rempli avec l'adresse par laquelle l'administrateur
consulte la page.

- Nouveau réglage `site_public_url` ; `siteUrl()` lit la base puis retombe sur
  les variables d'environnement, donc les déploiements actuels ne bougent pas.
- `siteUrl()`, `redirectUri()`, `authorizeUrl()`, `promoLink()` et
  `buildPromoMessage()` deviennent asynchrones.
- Seule l'origine est conservée : le schéma est ajouté s'il manque, un chemin
  est retiré, et une adresse invalide revient sur l'écran avec un message au
  lieu d'une page d'erreur brute.
- `saveSiteSettings` saute cette clé : le formulaire Paramètres ne la contient
  pas et l'aurait écrasée à chaque enregistrement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
Claude committed 2026-07-25 08:28:04 +00:00
1 parent db361051e6
commit e86c67c17e
9 files changed
+146 -37

No files matched your search

+2 -2
View File
@@ -30,8 +30,8 @@ SEED_ADMIN_NAME=Administrateur Plein R
NODE_ENV=production NODE_ENV=production
PORT=3000 PORT=3000
# URL publique du site, utilisée dans le texte des publications réseaux sociaux # URL publique du site. Se règle normalement dans Backend › Réseaux sociaux ;
# (lien vers la fiche de l'adhérent). À défaut, AUTH_URL est utilisé. # cette variable ne sert que de repli (puis AUTH_URL en dernier recours).
NEXT_PUBLIC_SITE_URL= NEXT_PUBLIC_SITE_URL=
# ---- Publication des promotions sur les réseaux sociaux ---- # ---- Publication des promotions sur les réseaux sociaux ----
+5 -1
View File
@@ -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 `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. depuis `/backend/reseaux`. Les variables d'environnement restent lues en repli.
Aucun secret ne doit jamais repartir vers le navigateur. 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` - 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. 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 - Facebook : le jeton de page n'expire pas. LinkedIn : 60 jours, rafraîchissement
+9 -4
View File
@@ -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 navigateur. Un réseau non connecté voit simplement sa case disparaître du
formulaire de promotion. formulaire de promotion.
L'écran affiche l'URL de redirection à déclarer sur le portail développeur — Le même écran porte l'**URL publique du site**, pré-remplie avec l'adresse par
c'est l'erreur de configuration la plus fréquente. 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 **Facebook.** Créez une application « Business » sur
[developers.facebook.com](https://developers.facebook.com/apps), ajoutez le [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 - `AUTH_URL` — URL publique de l'application
- `SEED_ON_START` — `true` pour seeder au démarrage du conteneur - `SEED_ON_START` — `true` pour seeder au démarrage du conteneur
- `SEED_ADMIN_EMAIL` / `SEED_ADMIN_PASSWORD` / `SEED_ADMIN_NAME` — premier admin - `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 - `NEXT_PUBLIC_SITE_URL` — repli pour l'URL publique du site ; elle se règle
de retour OAuth. Obligatoire pour connecter un réseau social. 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`) - `SOCIAL_TOKEN_KEY` — clé de chiffrement des jetons réseaux (défaut : `AUTH_SECRET`)
- `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` / - `FACEBOOK_PAGE_ID` / `FACEBOOK_PAGE_ACCESS_TOKEN`, `LINKEDIN_ORGANIZATION_URN` /
`LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice `LINKEDIN_ACCESS_TOKEN` — repli si aucun compte n'est connecté via le backoffice
@@ -34,10 +34,11 @@ export async function GET(
return back(request, "Réseau inconnu."); return back(request, "Réseau inconnu.");
} }
if (!siteUrl()) { const base = await siteUrl();
if (!base) {
return back( return back(
request, 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 // `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
// de rappel forgée. // de rappel forgée.
const state = randomBytes(24).toString("hex"); 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, { response.cookies.set(`plr_oauth_${network}`, state, {
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
secure: new URL(siteUrl()).protocol === "https:", secure: new URL(base).protocol === "https:",
path: "/", path: "/",
maxAge: 600, maxAge: 600,
}); });
+42
View File
@@ -952,6 +952,9 @@ export async function saveSiteSettings(formData: FormData) {
const updatedAt = new Date(); const updatedAt = new Date();
for (const key of Object.keys(SITE_SETTING_DEFAULTS)) { 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 await db
.insert(siteSettings) .insert(siteSettings)
.values({ key, value: asString(formData, key), updatedAt }) .values({ key, value: asString(formData, key), updatedAt })
@@ -1133,6 +1136,45 @@ export async function setContactStatus(formData: FormData) {
revalidatePath("/backend"); 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 ---- // ---- Réseaux sociaux : identifiants d'application et connexion ----
export async function saveSocialApp(formData: FormData) { export async function saveSocialApp(formData: FormData) {
const { role } = await requireRole(); const { role } = await requireRole();
+50 -10
View File
@@ -1,3 +1,4 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { auth } from "@/auth"; import { auth } from "@/auth";
@@ -13,7 +14,7 @@ import {
SOCIAL_NETWORKS, SOCIAL_NETWORKS,
type SocialTarget, type SocialTarget,
} from "@/lib/social-accounts"; } from "@/lib/social-accounts";
import { disconnectSocial, saveSocialApp, selectSocialTarget } from "../actions"; import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -56,7 +57,20 @@ export default async function ReseauxPage({
const { error, connected, choose } = await searchParams; const { error, connected, choose } = await searchParams;
const accounts = await getSocialAccounts(); const accounts = await getSocialAccounts();
const byNetwork = new Map(accounts.map((a) => [a.network, a])); 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<string, string>();
for (const network of SOCIAL_NETWORKS) {
redirectUris.set(network, await redirectUri(network));
}
// Cibles à proposer quand le compte administre plusieurs pages. // Cibles à proposer quand le compte administre plusieurs pages.
const targets = new Map<string, SocialTarget[]>(); const targets = new Map<string, SocialTarget[]>();
@@ -87,13 +101,39 @@ export default async function ReseauxPage({
</Banner> </Banner>
)} )}
{!base && ( <section style={panel}>
<Banner tone="warn"> <h2 className="font-display" style={{ ...title, margin: "0 0 4px" }}>
L&apos;URL publique du site n&apos;est pas renseignée (variable URL publique du site
<code> NEXT_PUBLIC_SITE_URL</code>). Elle est indispensable pour construire </h2>
l&apos;adresse de retour OAuth : renseignez-la avant de connecter un réseau. <p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#6c6150", lineHeight: 1.6 }}>
</Banner> Adresse à laquelle vos visiteurs accèdent au site. Elle sert à construire
)} l&apos;adresse de retour des connexions Facebook et LinkedIn, et le lien inséré dans
les publications.
</p>
<form action={saveSitePublicUrl} style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "flex-end" }}>
<div style={{ flex: "1 1 320px", minWidth: 0 }}>
<label className="field-label">Adresse</label>
<input
name="sitePublicUrl"
className="field"
defaultValue={base || detected}
placeholder="https://pleinr.example.fr"
/>
</div>
<button type="submit" style={submitButton}>Enregistrer</button>
</form>
{!base && detected && (
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
Adresse détectée depuis votre navigation : <strong>{detected}</strong>. Vérifiez-la puis
enregistrez.
</div>
)}
{!base && !detected && (
<div style={{ marginTop: 10, fontSize: 12.5, color: "#a8503c" }}>
Renseignez cette adresse avant de connecter un réseau social.
</div>
)}
</section>
{SOCIAL_NETWORKS.map((network) => { {SOCIAL_NETWORKS.map((network) => {
const account = byNetwork.get(network); const account = byNetwork.get(network);
@@ -240,7 +280,7 @@ export default async function ReseauxPage({
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10 }}>
<div className="field-label">URL de redirection à déclarer</div> <div className="field-label">URL de redirection à déclarer</div>
<code style={{ display: "block", background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 9, padding: "10px 12px", fontSize: 12.5, color: "#3c3322", overflowWrap: "anywhere" }}> <code style={{ display: "block", background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 9, padding: "10px 12px", fontSize: 12.5, color: "#3c3322", overflowWrap: "anywhere" }}>
{base ? redirectUri(network) : "— renseignez d'abord NEXT_PUBLIC_SITE_URL —"} {base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
</code> </code>
</div> </div>
{help.caution && ( {help.caution && (
+3
View File
@@ -3,6 +3,9 @@ import { db } from "@/db";
import { siteSettings } from "@/db/schema"; import { siteSettings } from "@/db/schema";
export const SITE_SETTING_DEFAULTS = { 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_name: "Plein R",
association_tagline: "Association des commerçants et entreprises du Bassin de Pompey.", association_tagline: "Association des commerçants et entreprises du Bassin de Pompey.",
association_intro: association_intro:
+24 -10
View File
@@ -1,6 +1,6 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/db"; 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"; import { decryptSecret, encryptSecret } from "./crypto";
/** /**
@@ -30,12 +30,21 @@ function env(key: string): string {
return (process.env[key] ?? "").trim(); 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<string> {
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 { export async function redirectUri(network: SocialNetwork): Promise<string> {
return `${siteUrl()}/api/social/${network}/callback`; return `${await siteUrl()}/api/social/${network}/callback`;
} }
// ---- Lecture ---- // ---- Lecture ----
@@ -123,11 +132,16 @@ const SCOPES: Record<SocialNetwork, string> = {
linkedin: "w_organization_social r_organization_social rw_organization_admin", 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<string> {
const redirect = await redirectUri(network);
if (network === "facebook") { if (network === "facebook") {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: appId, client_id: appId,
redirect_uri: redirectUri("facebook"), redirect_uri: redirect,
state, state,
scope: SCOPES.facebook, scope: SCOPES.facebook,
response_type: "code", response_type: "code",
@@ -137,7 +151,7 @@ export function authorizeUrl(network: SocialNetwork, appId: string, state: strin
const params = new URLSearchParams({ const params = new URLSearchParams({
response_type: "code", response_type: "code",
client_id: appId, client_id: appId,
redirect_uri: redirectUri("linkedin"), redirect_uri: redirect,
state, state,
scope: SCOPES.linkedin, scope: SCOPES.linkedin,
}); });
@@ -176,7 +190,7 @@ async function exchangeFacebook(
`${base}/oauth/access_token?${new URLSearchParams({ `${base}/oauth/access_token?${new URLSearchParams({
client_id: appId, client_id: appId,
client_secret: appSecret, client_secret: appSecret,
redirect_uri: redirectUri("facebook"), redirect_uri: await redirectUri("facebook"),
code, code,
})}` })}`
); );
@@ -232,7 +246,7 @@ async function exchangeLinkedIn(
code, code,
client_id: clientId, client_id: clientId,
client_secret: clientSecret, client_secret: clientSecret,
redirect_uri: redirectUri("linkedin"), redirect_uri: await redirectUri("linkedin"),
}), }),
}); });
if (!tokenRes.ok) throw new SocialAuthError(`LinkedIn (code) : ${await readError(tokenRes)}`); if (!tokenRes.ok) throw new SocialAuthError(`LinkedIn (code) : ${await readError(tokenRes)}`);
+6 -6
View File
@@ -39,20 +39,20 @@ export type PromoForSharing = {
memberName: string | null; memberName: string | null;
}; };
export function promoLink(promo: PromoForSharing): string | null { export async function promoLink(promo: PromoForSharing): Promise<string | null> {
const base = siteUrl(); const base = await siteUrl();
if (!base) return null; if (!base) return null;
return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`; return promo.memberId ? `${base}/adherents/${promo.memberId}` : `${base}/#promotions`;
} }
/** Texte du post, commun aux deux réseaux. */ /** Texte du post, commun aux deux réseaux. */
export function buildPromoMessage(promo: PromoForSharing): string { export async function buildPromoMessage(promo: PromoForSharing): Promise<string> {
const lines: string[] = []; const lines: string[] = [];
lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title); lines.push(promo.badge ? `${promo.badge} — ${promo.title}` : promo.title);
if (promo.memberName) lines.push(`Chez ${promo.memberName}`); if (promo.memberName) lines.push(`Chez ${promo.memberName}`);
if (promo.text?.trim()) lines.push("", promo.text.trim()); if (promo.text?.trim()) lines.push("", promo.text.trim());
if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim()); if (promo.validUntil?.trim()) lines.push("", promo.validUntil.trim());
const link = promoLink(promo); const link = await promoLink(promo);
if (link) lines.push("", link); if (link) lines.push("", link);
lines.push("", "#PleinR #BassinDePompey #CommerceLocal"); lines.push("", "#PleinR #BassinDePompey #CommerceLocal");
return lines.join("\n"); return lines.join("\n");
@@ -133,7 +133,7 @@ async function publishToFacebook(promo: PromoForSharing, message: string): Promi
} else { } else {
endpoint = `${base}/feed`; endpoint = `${base}/feed`;
body.set("message", message); body.set("message", message);
const link = promoLink(promo); const link = await promoLink(promo);
if (link) body.set("link", link); if (link) body.set("link", link);
} }
@@ -245,7 +245,7 @@ export async function publishPromoToNetwork(
network: SocialNetwork, network: SocialNetwork,
promo: PromoForSharing promo: PromoForSharing
): Promise<PublishResult> { ): Promise<PublishResult> {
const message = buildPromoMessage(promo); const message = await buildPromoMessage(promo);
return network === "facebook" return network === "facebook"
? publishToFacebook(promo, message) ? publishToFacebook(promo, message)
: publishToLinkedIn(promo, message); : publishToLinkedIn(promo, message);