mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-17 04:07:08 +02:00
Connexion Facebook / LinkedIn en OAuth depuis le backoffice
Les jetons se posaient à la main dans docker-compose. Un écran Backend ›
Réseaux sociaux permet désormais de coller les identifiants de l'application,
cliquer Connecter, choisir la page, et ne plus y revenir.
- Table `social_accounts` : identifiants, jetons et cible par réseau. Tous les
secrets sont chiffrés en AES-256-GCM (`src/lib/crypto.ts`), clé
`SOCIAL_TOKEN_KEY` avec repli sur `AUTH_SECRET`.
- Routes `api/social/[network]/{connect,callback}` : `state` anti-CSRF en cookie
httpOnly, échange du code, récupération des pages administrées. Aucun jeton ne
transite par une URL — les cibles sont relistées côté serveur au moment de la
sélection.
- Facebook : jeton utilisateur longue durée puis jeton de PAGE, qui n'expire
pas. LinkedIn : jeton 60 jours, rafraîchi automatiquement si l'application a
obtenu les jetons de rafraîchissement programmatiques.
- Faute de quoi le backoffice affiche la date d'expiration, un bandeau sur le
tableau de bord à J-7 et un bouton Reconnecter.
- `social.ts` lit les identifiants via `social-accounts.ts` : base d'abord,
variables d'environnement ensuite. Les installations existantes continuent de
fonctionner sans modification.
- Le secret d'application n'est jamais renvoyé au navigateur : champ vide =
valeur conservée.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
19 files changed
+2599
-82
No files matched your search
@@ -0,0 +1,62 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Chiffrement des secrets stockés en base (jetons OAuth, secrets d'application).
|
||||
*
|
||||
* AES-256-GCM : le tag d'authentification garantit qu'une valeur altérée en base
|
||||
* est rejetée au lieu d'être déchiffrée en silence.
|
||||
*
|
||||
* La clé dérive de `SOCIAL_TOKEN_KEY`, avec repli sur `AUTH_SECRET` pour ne rien
|
||||
* imposer aux déploiements existants. Conséquence : changer `AUTH_SECRET` sans
|
||||
* avoir posé `SOCIAL_TOKEN_KEY` rend les secrets illisibles — il suffit alors de
|
||||
* reconnecter les comptes, aucune donnée métier n'est perdue.
|
||||
*/
|
||||
|
||||
const PREFIX = "v1";
|
||||
const SALT = "pleinr.social.v1";
|
||||
|
||||
function secretMaterial(): string {
|
||||
const key = (process.env.SOCIAL_TOKEN_KEY ?? "").trim() || (process.env.AUTH_SECRET ?? "").trim();
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"Chiffrement indisponible : définissez SOCIAL_TOKEN_KEY (ou AUTH_SECRET) sur le serveur."
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function key(): Buffer {
|
||||
return scryptSync(secretMaterial(), SALT, 32);
|
||||
}
|
||||
|
||||
export function encryptSecret(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", key(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [PREFIX, iv.toString("base64"), tag.toString("base64"), encrypted.toString("base64")].join(":");
|
||||
}
|
||||
|
||||
export function decryptSecret(stored: string): string {
|
||||
const parts = stored.split(":");
|
||||
if (parts.length !== 4 || parts[0] !== PREFIX) {
|
||||
throw new Error("Secret chiffré illisible (format inattendu).");
|
||||
}
|
||||
const [, iv, tag, payload] = parts;
|
||||
const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64"));
|
||||
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(payload, "base64")),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
}
|
||||
|
||||
/** Déchiffre sans lever : une clé changée ne doit pas casser l'affichage. */
|
||||
export function tryDecryptSecret(stored: string | null): string | null {
|
||||
if (!stored) return null;
|
||||
try {
|
||||
return decryptSecret(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { socialAccounts, type SocialAccount, type SocialNetwork } from "@/db/schema";
|
||||
import { decryptSecret, encryptSecret } from "./crypto";
|
||||
|
||||
/**
|
||||
* Configuration des comptes Facebook / LinkedIn.
|
||||
*
|
||||
* Les identifiants d'application et les jetons vivent en base (chiffrés), posés
|
||||
* depuis Backend › Réseaux sociaux. Les variables d'environnement restent
|
||||
* acceptées en **repli** pour ne pas casser les déploiements antérieurs.
|
||||
*/
|
||||
|
||||
export type { SocialNetwork };
|
||||
|
||||
export const SOCIAL_NETWORKS: SocialNetwork[] = ["facebook", "linkedin"];
|
||||
|
||||
export const SOCIAL_LABELS: Record<SocialNetwork, string> = {
|
||||
facebook: "Facebook",
|
||||
linkedin: "LinkedIn",
|
||||
};
|
||||
|
||||
const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || "v21.0";
|
||||
const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION?.trim() || "202506";
|
||||
|
||||
/** Un jeton LinkedIn qui expire dans moins de 7 jours est signalé. */
|
||||
export const EXPIRY_WARNING_DAYS = 7;
|
||||
|
||||
function env(key: string): string {
|
||||
return (process.env[key] ?? "").trim();
|
||||
}
|
||||
|
||||
export function siteUrl(): string {
|
||||
return (env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function redirectUri(network: SocialNetwork): string {
|
||||
return `${siteUrl()}/api/social/${network}/callback`;
|
||||
}
|
||||
|
||||
// ---- Lecture ----
|
||||
|
||||
export async function getSocialAccount(network: SocialNetwork): Promise<SocialAccount | null> {
|
||||
const [row] = await db.select().from(socialAccounts).where(eq(socialAccounts.network, network));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function getSocialAccounts(): Promise<SocialAccount[]> {
|
||||
return db.select().from(socialAccounts);
|
||||
}
|
||||
|
||||
export type SocialCredentials = {
|
||||
source: "db" | "env";
|
||||
accessToken: string;
|
||||
targetId: string;
|
||||
expiresAt: Date | null;
|
||||
};
|
||||
|
||||
/** Identifiants utilisables pour publier : la base d'abord, l'environnement ensuite. */
|
||||
export async function resolveCredentials(
|
||||
network: SocialNetwork
|
||||
): Promise<SocialCredentials | null> {
|
||||
const account = await getSocialAccount(network);
|
||||
if (account?.accessToken && account.targetId) {
|
||||
try {
|
||||
return {
|
||||
source: "db",
|
||||
accessToken: decryptSecret(account.accessToken),
|
||||
targetId: account.targetId,
|
||||
expiresAt: account.expiresAt,
|
||||
};
|
||||
} catch {
|
||||
// Clé de chiffrement changée : on retombe sur l'environnement s'il existe.
|
||||
}
|
||||
}
|
||||
|
||||
if (network === "facebook") {
|
||||
const token = env("FACEBOOK_PAGE_ACCESS_TOKEN");
|
||||
const pageId = env("FACEBOOK_PAGE_ID");
|
||||
if (token && pageId) return { source: "env", accessToken: token, targetId: pageId, expiresAt: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = env("LINKEDIN_ACCESS_TOKEN");
|
||||
const urn = env("LINKEDIN_ORGANIZATION_URN") ||
|
||||
(env("LINKEDIN_ORGANIZATION_ID") ? `urn:li:organization:${env("LINKEDIN_ORGANIZATION_ID")}` : "");
|
||||
if (token && urn) return { source: "env", accessToken: token, targetId: urn, expiresAt: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isNetworkConfigured(network: SocialNetwork): Promise<boolean> {
|
||||
return (await resolveCredentials(network)) !== null;
|
||||
}
|
||||
|
||||
export async function configuredNetworks(): Promise<SocialNetwork[]> {
|
||||
const found = await Promise.all(
|
||||
SOCIAL_NETWORKS.map(async (n) => ((await isNetworkConfigured(n)) ? n : null))
|
||||
);
|
||||
return found.filter((n): n is SocialNetwork => n !== null);
|
||||
}
|
||||
|
||||
/** État d'expiration, pour le bandeau d'alerte du backoffice. */
|
||||
export type ExpiryStatus = "never" | "ok" | "soon" | "expired";
|
||||
|
||||
export function expiryStatus(expiresAt: Date | null | undefined): ExpiryStatus {
|
||||
if (!expiresAt) return "never";
|
||||
const remainingMs = new Date(expiresAt).getTime() - Date.now();
|
||||
if (remainingMs <= 0) return "expired";
|
||||
return remainingMs <= EXPIRY_WARNING_DAYS * 86_400_000 ? "soon" : "ok";
|
||||
}
|
||||
|
||||
// ---- OAuth ----
|
||||
|
||||
export class SocialAuthError extends Error {}
|
||||
|
||||
async function readError(res: Response): Promise<string> {
|
||||
const body = await res.text().catch(() => "");
|
||||
return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`;
|
||||
}
|
||||
|
||||
const SCOPES: Record<SocialNetwork, string> = {
|
||||
facebook: "pages_show_list,pages_manage_posts,pages_read_engagement",
|
||||
linkedin: "w_organization_social r_organization_social rw_organization_admin",
|
||||
};
|
||||
|
||||
export function authorizeUrl(network: SocialNetwork, appId: string, state: string): string {
|
||||
if (network === "facebook") {
|
||||
const params = new URLSearchParams({
|
||||
client_id: appId,
|
||||
redirect_uri: redirectUri("facebook"),
|
||||
state,
|
||||
scope: SCOPES.facebook,
|
||||
response_type: "code",
|
||||
});
|
||||
return `https://www.facebook.com/${FACEBOOK_GRAPH_VERSION}/dialog/oauth?${params}`;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: appId,
|
||||
redirect_uri: redirectUri("linkedin"),
|
||||
state,
|
||||
scope: SCOPES.linkedin,
|
||||
});
|
||||
return `https://www.linkedin.com/oauth/v2/authorization?${params}`;
|
||||
}
|
||||
|
||||
/** Cible publiable : page Facebook ou organisation LinkedIn. */
|
||||
export type SocialTarget = { id: string; name: string; token?: string };
|
||||
|
||||
export type ExchangeResult = {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
expiresAt: Date | null;
|
||||
targets: SocialTarget[];
|
||||
};
|
||||
|
||||
export async function exchangeCode(
|
||||
network: SocialNetwork,
|
||||
appId: string,
|
||||
appSecret: string,
|
||||
code: string
|
||||
): Promise<ExchangeResult> {
|
||||
return network === "facebook"
|
||||
? exchangeFacebook(appId, appSecret, code)
|
||||
: exchangeLinkedIn(appId, appSecret, code);
|
||||
}
|
||||
|
||||
async function exchangeFacebook(
|
||||
appId: string,
|
||||
appSecret: string,
|
||||
code: string
|
||||
): Promise<ExchangeResult> {
|
||||
const base = `https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}`;
|
||||
|
||||
const shortRes = await fetch(
|
||||
`${base}/oauth/access_token?${new URLSearchParams({
|
||||
client_id: appId,
|
||||
client_secret: appSecret,
|
||||
redirect_uri: redirectUri("facebook"),
|
||||
code,
|
||||
})}`
|
||||
);
|
||||
if (!shortRes.ok) throw new SocialAuthError(`Facebook (code) : ${await readError(shortRes)}`);
|
||||
const short = (await shortRes.json()) as { access_token?: string };
|
||||
if (!short.access_token) throw new SocialAuthError("Facebook : jeton court absent de la réponse.");
|
||||
|
||||
// Jeton utilisateur longue durée (~60 j) : c'est lui qui rend les jetons de
|
||||
// page permanents.
|
||||
const longRes = await fetch(
|
||||
`${base}/oauth/access_token?${new URLSearchParams({
|
||||
grant_type: "fb_exchange_token",
|
||||
client_id: appId,
|
||||
client_secret: appSecret,
|
||||
fb_exchange_token: short.access_token,
|
||||
})}`
|
||||
);
|
||||
if (!longRes.ok) throw new SocialAuthError(`Facebook (jeton longue durée) : ${await readError(longRes)}`);
|
||||
const long = (await longRes.json()) as { access_token?: string };
|
||||
const userToken = long.access_token ?? short.access_token;
|
||||
|
||||
const pagesRes = await fetch(
|
||||
`${base}/me/accounts?${new URLSearchParams({
|
||||
fields: "id,name,access_token",
|
||||
access_token: userToken,
|
||||
})}`
|
||||
);
|
||||
if (!pagesRes.ok) throw new SocialAuthError(`Facebook (pages) : ${await readError(pagesRes)}`);
|
||||
const pages = (await pagesRes.json()) as {
|
||||
data?: { id: string; name: string; access_token: string }[];
|
||||
};
|
||||
const targets = (pages.data ?? []).map((p) => ({ id: p.id, name: p.name, token: p.access_token }));
|
||||
if (targets.length === 0) {
|
||||
throw new SocialAuthError(
|
||||
"Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page Plein R."
|
||||
);
|
||||
}
|
||||
|
||||
// Le jeton de page ne dépend pas de l'expiration du jeton utilisateur.
|
||||
return { accessToken: userToken, refreshToken: null, expiresAt: null, targets };
|
||||
}
|
||||
|
||||
async function exchangeLinkedIn(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
code: string
|
||||
): Promise<ExchangeResult> {
|
||||
const tokenRes = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri("linkedin"),
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) throw new SocialAuthError(`LinkedIn (code) : ${await readError(tokenRes)}`);
|
||||
const token = (await tokenRes.json()) as {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
};
|
||||
if (!token.access_token) throw new SocialAuthError("LinkedIn : jeton absent de la réponse.");
|
||||
|
||||
const targets = await listLinkedInOrganizations(token.access_token);
|
||||
if (targets.length === 0) {
|
||||
throw new SocialAuthError(
|
||||
"Aucune page LinkedIn administrée par ce compte. Connectez-vous avec un administrateur de la page de l'association."
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token ?? null,
|
||||
expiresAt: token.expires_in ? new Date(Date.now() + token.expires_in * 1000) : null,
|
||||
targets,
|
||||
};
|
||||
}
|
||||
|
||||
function linkedinHeaders(token: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"LinkedIn-Version": LINKEDIN_VERSION,
|
||||
"X-Restli-Protocol-Version": "2.0.0",
|
||||
};
|
||||
}
|
||||
|
||||
async function listLinkedInOrganizations(token: string): Promise<SocialTarget[]> {
|
||||
const res = await fetch(
|
||||
"https://api.linkedin.com/rest/organizationAcls?q=roleAssignee&role=ADMINISTRATOR&state=APPROVED",
|
||||
{ headers: linkedinHeaders(token) }
|
||||
);
|
||||
if (!res.ok) throw new SocialAuthError(`LinkedIn (organisations) : ${await readError(res)}`);
|
||||
const json = (await res.json()) as {
|
||||
elements?: { organization?: string; organizationTarget?: string }[];
|
||||
};
|
||||
|
||||
// Le finder renvoie tantôt `organization`, tantôt `organizationTarget`.
|
||||
const urns = Array.from(
|
||||
new Set((json.elements ?? []).map((e) => e.organization ?? e.organizationTarget).filter(Boolean))
|
||||
) as string[];
|
||||
|
||||
return Promise.all(
|
||||
urns.map(async (urn) => ({ id: urn, name: (await linkedInOrgName(token, urn)) ?? urn }))
|
||||
);
|
||||
}
|
||||
|
||||
async function linkedInOrgName(token: string, urn: string): Promise<string | null> {
|
||||
const id = urn.split(":").pop();
|
||||
if (!id) return null;
|
||||
try {
|
||||
const res = await fetch(`https://api.linkedin.com/rest/organizations/${id}`, {
|
||||
headers: linkedinHeaders(token),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as { localizedName?: string };
|
||||
return json.localizedName ?? null;
|
||||
} catch {
|
||||
return null; // Le nom est un confort : l'URN suffit à publier.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reliste les cibles publiables avec le jeton déjà enregistré. Sert à l'écran de
|
||||
* sélection quand le compte administre plusieurs pages : les jetons de page ne
|
||||
* transitent ainsi jamais par une URL.
|
||||
*/
|
||||
export async function listStoredTargets(network: SocialNetwork): Promise<SocialTarget[]> {
|
||||
const account = await getSocialAccount(network);
|
||||
if (!account?.accessToken) return [];
|
||||
const token = decryptSecret(account.accessToken);
|
||||
|
||||
if (network === "linkedin") return listLinkedInOrganizations(token);
|
||||
|
||||
const res = await fetch(
|
||||
`https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/me/accounts?${new URLSearchParams({
|
||||
fields: "id,name,access_token",
|
||||
access_token: token,
|
||||
})}`
|
||||
);
|
||||
if (!res.ok) throw new SocialAuthError(`Facebook (pages) : ${await readError(res)}`);
|
||||
const json = (await res.json()) as { data?: { id: string; name: string; access_token: string }[] };
|
||||
return (json.data ?? []).map((p) => ({ id: p.id, name: p.name, token: p.access_token }));
|
||||
}
|
||||
|
||||
// ---- Écriture ----
|
||||
|
||||
export async function saveAppCredentials(
|
||||
network: SocialNetwork,
|
||||
appId: string,
|
||||
appSecret: string | null
|
||||
) {
|
||||
const existing = await getSocialAccount(network);
|
||||
if (!existing) {
|
||||
if (!appSecret) throw new Error("Le secret de l'application est requis à la première saisie.");
|
||||
await db.insert(socialAccounts).values({
|
||||
network,
|
||||
appId,
|
||||
appSecret: encryptSecret(appSecret),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await db
|
||||
.update(socialAccounts)
|
||||
.set({
|
||||
appId,
|
||||
// Champ laissé vide = on conserve le secret déjà enregistré.
|
||||
...(appSecret ? { appSecret: encryptSecret(appSecret) } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(socialAccounts.network, network));
|
||||
}
|
||||
|
||||
export async function saveConnection(
|
||||
network: SocialNetwork,
|
||||
result: ExchangeResult,
|
||||
target: SocialTarget | null,
|
||||
userId: number | null
|
||||
) {
|
||||
await db
|
||||
.update(socialAccounts)
|
||||
.set({
|
||||
// Facebook : c'est le jeton de la page qui sert à publier, pas celui de
|
||||
// l'utilisateur — et lui n'expire pas.
|
||||
accessToken: encryptSecret(target?.token ?? result.accessToken),
|
||||
refreshToken: result.refreshToken ? encryptSecret(result.refreshToken) : null,
|
||||
expiresAt: target?.token ? null : result.expiresAt,
|
||||
targetId: target?.id ?? null,
|
||||
targetName: target?.name ?? null,
|
||||
connectedById: userId,
|
||||
connectedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(socialAccounts.network, network));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixe la page / organisation à utiliser, une fois la connexion faite. Côté
|
||||
* Facebook, on bascule ici du jeton utilisateur vers le jeton de page — celui
|
||||
* qui n'expire pas.
|
||||
*/
|
||||
export async function selectTarget(network: SocialNetwork, targetId: string) {
|
||||
const targets = await listStoredTargets(network);
|
||||
const target = targets.find((t) => t.id === targetId);
|
||||
if (!target) throw new Error("Page introuvable : relancez la connexion.");
|
||||
|
||||
await db
|
||||
.update(socialAccounts)
|
||||
.set({
|
||||
targetId: target.id,
|
||||
targetName: target.name,
|
||||
...(target.token
|
||||
? { accessToken: encryptSecret(target.token), expiresAt: null }
|
||||
: {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(socialAccounts.network, network));
|
||||
}
|
||||
|
||||
export async function disconnectAccount(network: SocialNetwork) {
|
||||
await db
|
||||
.update(socialAccounts)
|
||||
.set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
targetId: null,
|
||||
targetName: null,
|
||||
connectedById: null,
|
||||
connectedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(socialAccounts.network, network));
|
||||
}
|
||||
|
||||
export async function getDecryptedAppSecret(network: SocialNetwork): Promise<string | null> {
|
||||
const account = await getSocialAccount(network);
|
||||
if (!account) return null;
|
||||
try {
|
||||
return decryptSecret(account.appSecret);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit un jeton LinkedIn proche de l'expiration. N'est possible que si
|
||||
* LinkedIn a accordé les « programmatic refresh tokens » à l'application ;
|
||||
* sinon on s'appuie sur le bandeau de reconnexion du backoffice.
|
||||
*/
|
||||
export async function refreshLinkedInIfNeeded(): Promise<void> {
|
||||
const account = await getSocialAccount("linkedin");
|
||||
if (!account?.refreshToken || !account.accessToken) return;
|
||||
if (expiryStatus(account.expiresAt) === "ok") return;
|
||||
|
||||
try {
|
||||
const res = await fetch("https://www.linkedin.com/oauth/v2/accessToken", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: decryptSecret(account.refreshToken),
|
||||
client_id: account.appId,
|
||||
client_secret: decryptSecret(account.appSecret),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const json = (await res.json()) as {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
refresh_token?: string;
|
||||
};
|
||||
if (!json.access_token) return;
|
||||
|
||||
await db
|
||||
.update(socialAccounts)
|
||||
.set({
|
||||
accessToken: encryptSecret(json.access_token),
|
||||
refreshToken: json.refresh_token ? encryptSecret(json.refresh_token) : account.refreshToken,
|
||||
expiresAt: json.expires_in ? new Date(Date.now() + json.expires_in * 1000) : null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(socialAccounts.network, "linkedin"));
|
||||
} catch {
|
||||
// Échec silencieux : la publication signalera l'erreur si le jeton est mort.
|
||||
}
|
||||
}
|
||||
+29
-45
@@ -1,51 +1,31 @@
|
||||
/**
|
||||
* Publication des promotions sur les pages Facebook / LinkedIn de l'association.
|
||||
*
|
||||
* Les jetons d'accès sont des SECRETS : ils vivent dans les variables
|
||||
* d'environnement du conteneur, jamais en base ni dans le backoffice. Voir
|
||||
* `.env.example` pour la marche à suivre côté Meta / LinkedIn.
|
||||
* Les identifiants et jetons viennent de `social-accounts.ts` : base de données
|
||||
* en premier (posés depuis Backend › Réseaux sociaux), variables
|
||||
* d'environnement en repli.
|
||||
*
|
||||
* Si un réseau n'est pas configuré, le backoffice masque simplement son bouton :
|
||||
* Si un réseau n'est pas configuré, le backoffice masque simplement sa case :
|
||||
* le reste du site fonctionne normalement.
|
||||
*/
|
||||
|
||||
export type SocialNetwork = "facebook" | "linkedin";
|
||||
import {
|
||||
refreshLinkedInIfNeeded,
|
||||
resolveCredentials,
|
||||
siteUrl,
|
||||
type SocialNetwork,
|
||||
} from "./social-accounts";
|
||||
|
||||
export const SOCIAL_NETWORKS: SocialNetwork[] = ["facebook", "linkedin"];
|
||||
export {
|
||||
configuredNetworks,
|
||||
isNetworkConfigured,
|
||||
SOCIAL_LABELS,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "./social-accounts";
|
||||
|
||||
export const SOCIAL_LABELS: Record<SocialNetwork, string> = {
|
||||
facebook: "Facebook",
|
||||
linkedin: "LinkedIn",
|
||||
};
|
||||
|
||||
const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION ?? "v21.0";
|
||||
const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION ?? "202506";
|
||||
|
||||
function env(key: string): string {
|
||||
return (process.env[key] ?? "").trim();
|
||||
}
|
||||
|
||||
export function isNetworkConfigured(network: SocialNetwork): boolean {
|
||||
if (network === "facebook") {
|
||||
return !!env("FACEBOOK_PAGE_ID") && !!env("FACEBOOK_PAGE_ACCESS_TOKEN");
|
||||
}
|
||||
return !!linkedinOrganizationUrn() && !!env("LINKEDIN_ACCESS_TOKEN");
|
||||
}
|
||||
|
||||
export function configuredNetworks(): SocialNetwork[] {
|
||||
return SOCIAL_NETWORKS.filter(isNetworkConfigured);
|
||||
}
|
||||
|
||||
function linkedinOrganizationUrn(): string {
|
||||
const urn = env("LINKEDIN_ORGANIZATION_URN");
|
||||
if (urn) return urn;
|
||||
const id = env("LINKEDIN_ORGANIZATION_ID");
|
||||
return id ? `urn:li:organization:${id}` : "";
|
||||
}
|
||||
|
||||
function siteUrl(): string {
|
||||
return (env("NEXT_PUBLIC_SITE_URL") || env("AUTH_URL")).replace(/\/+$/, "");
|
||||
}
|
||||
const FACEBOOK_GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || "v21.0";
|
||||
const LINKEDIN_VERSION = process.env.LINKEDIN_API_VERSION?.trim() || "202506";
|
||||
|
||||
// ---- Contenu du post ----
|
||||
|
||||
@@ -133,9 +113,9 @@ async function readError(res: Response): Promise<string> {
|
||||
// ---- Facebook (Graph API, page de l'association) ----
|
||||
|
||||
async function publishToFacebook(promo: PromoForSharing, message: string): Promise<PublishResult> {
|
||||
const pageId = env("FACEBOOK_PAGE_ID");
|
||||
const token = env("FACEBOOK_PAGE_ACCESS_TOKEN");
|
||||
if (!pageId || !token) throw new SocialPublishError("Facebook n'est pas configuré.");
|
||||
const credentials = await resolveCredentials("facebook");
|
||||
if (!credentials) throw new SocialPublishError("Facebook n'est pas connecté.");
|
||||
const { targetId: pageId, accessToken: token } = credentials;
|
||||
|
||||
const image = await loadPromoImage(promo.imageUrl);
|
||||
const base = `https://graph.facebook.com/${FACEBOOK_GRAPH_VERSION}/${pageId}`;
|
||||
@@ -218,9 +198,13 @@ async function uploadLinkedInImage(
|
||||
}
|
||||
|
||||
async function publishToLinkedIn(promo: PromoForSharing, message: string): Promise<PublishResult> {
|
||||
const token = env("LINKEDIN_ACCESS_TOKEN");
|
||||
const owner = linkedinOrganizationUrn();
|
||||
if (!token || !owner) throw new SocialPublishError("LinkedIn n'est pas configuré.");
|
||||
// Rattrape un jeton proche de l'expiration quand LinkedIn a accordé les
|
||||
// jetons de rafraîchissement programmatiques à l'application.
|
||||
await refreshLinkedInIfNeeded();
|
||||
|
||||
const credentials = await resolveCredentials("linkedin");
|
||||
if (!credentials) throw new SocialPublishError("LinkedIn n'est pas connecté.");
|
||||
const { accessToken: token, targetId: owner } = credentials;
|
||||
|
||||
const image = await loadPromoImage(promo.imageUrl);
|
||||
const imageUrn = image ? await uploadLinkedInImage(token, owner, image) : null;
|
||||
|
||||
Reference in new issue
Block a user