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
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 ----
+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
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
+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
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
@@ -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,
});
+42
View File
@@ -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();
+50 -10
View File
@@ -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<string, string>();
for (const network of SOCIAL_NETWORKS) {
redirectUris.set(network, await redirectUri(network));
}
// Cibles à proposer quand le compte administre plusieurs pages.
const targets = new Map<string, SocialTarget[]>();
@@ -87,13 +101,39 @@ export default async function ReseauxPage({
</Banner>
)}
{!base && (
<Banner tone="warn">
L&apos;URL publique du site n&apos;est pas renseignée (variable
<code> NEXT_PUBLIC_SITE_URL</code>). Elle est indispensable pour construire
l&apos;adresse de retour OAuth : renseignez-la avant de connecter un réseau.
</Banner>
)}
<section style={panel}>
<h2 className="font-display" style={{ ...title, margin: "0 0 4px" }}>
URL publique du site
</h2>
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#6c6150", lineHeight: 1.6 }}>
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) => {
const account = byNetwork.get(network);
@@ -240,7 +280,7 @@ export default async function ReseauxPage({
<div style={{ marginTop: 10 }}>
<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" }}>
{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>
</div>
{help.caution && (
+3
View File
@@ -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:
+24 -10
View File
@@ -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<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 {
return `${siteUrl()}/api/social/${network}/callback`;
export async function redirectUri(network: SocialNetwork): Promise<string> {
return `${await siteUrl()}/api/social/${network}/callback`;
}
// ---- Lecture ----
@@ -123,11 +132,16 @@ const SCOPES: Record<SocialNetwork, string> = {
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") {
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)}`);
+6 -6
View File
@@ -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<string | null> {
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<string> {
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<PublishResult> {
const message = buildPromoMessage(promo);
const message = await buildPromoMessage(promo);
return network === "facebook"
? publishToFacebook(promo, message)
: publishToLinkedIn(promo, message);