Dokumentacja integracji
Jak osadzić widget Tribune Desk w panelu redakcyjnym CMS-a sieci portali. Redaktor nie wychodzi z CMS-a i nie loguje się do nas: widzi tematy z monitoringu swojego portalu, klika „Napisz”, ogląda gotowy artykuł i klika „Wyślij do CMS” — w CMS-ie powstaje draft.
W skrócie
Po stronie CMS-a są trzy kroki, każdy na istniejącym logowaniu redaktora:
- Token — serwer CMS-a wymienia dane dostępowe portalu na token (jedno wywołanie HTTP raz na dobę) i podaje go stronie.
- Osadzenie — strona CMS-a wstawia jeden
<script>i wywołujeTribuneDesk.mount(...). - Endpoint draftu — jeden adres w CMS-ie przyjmuje gotowy artykuł (JSON) i zakłada z niego draft; autorem jest zalogowany redaktor.
CMS nie wystawia publicznego adresu, nie weryfikuje podpisów i nie mapuje autorów. Sekret portalu nigdy nie trafia do przeglądarki.
Spis treści
1. Token
Każdy portal dostaje od nas osobne konto maszynowe (aplikację M2M w Kinde, naszym
dostawcy logowania): client_id i client_secret. Przekazujemy je
bezpiecznym kanałem. Trzymasz je tylko na serwerze — w konfiguracji poza
repozytorium albo w zmiennych środowiska; nigdy w HTML-u, JS-ie ani w repo.
Wymiana na token
POST https://<subdomena>.kinde.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=<client_id portalu>
&client_secret=<client_secret portalu>
&audience=<audience od nas>
Odpowiedź (JSON): access_token — token dla widgetu, expires_in
— czas życia w sekundach (dziś 86400, czyli 24 h). Adres domeny Kinde
i wartość audience podamy razem z danymi portalu.
PHP: funkcja z cache w pliku
Zwykły PHP 8 z curl, bez frameworka i Composera. Cache w pliku działa wszędzie;
jeśli serwer ma APCu, zamiast pliku wystarczy apcu_fetch() /
apcu_store($klucz, $wpis, $ttl) z tym samym zapasem (przykład niżej).
<?php
// tribune-token-lib.php — token widgetu Tribune Desk dla jednego portalu.
// Wołaj tylko na serwerze. Sekret nie może trafić do przeglądarki.
const TD_KINDE_TOKEN_URL = 'https://<subdomena>.kinde.com/oauth2/token'; // podamy
const TD_AUDIENCE = '<audience od nas>'; // podamy
const TD_MARGIN_S = 300; // nowy token 5 min przed wygaśnięciem starego
/** Zwraca ['token' => string, 'exp' => int (unix)] — z cache albo świeży z Kinde. */
function tribuneToken(string $clientId, string $clientSecret): array
{
// Katalog cache: poza katalogiem publicznym, zapisywalny tylko dla PHP.
$cacheFile = '/var/cache/cms/tribune-token-' . sha1($clientId) . '.json';
$cached = is_file($cacheFile) ? json_decode((string) file_get_contents($cacheFile), true) : null;
if (is_array($cached) && ($cached['exp'] ?? 0) - TD_MARGIN_S > time()) {
return $cached; // jeszcze ważny — bez wywołania Kinde
}
$ch = curl_init(TD_KINDE_TOKEN_URL);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'client_credentials',
'client_id' => $clientId,
'client_secret' => $clientSecret,
'audience' => TD_AUDIENCE,
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$data = is_string($body) ? json_decode($body, true) : null;
if ($status !== 200 || !is_string($data['access_token'] ?? null)) {
// Nie loguj treści odpowiedzi ani sekretu — sam kod HTTP wystarczy.
throw new RuntimeException("Kinde odmówił tokenu (HTTP $status)");
}
$entry = ['token' => $data['access_token'], 'exp' => time() + (int) $data['expires_in']];
// Zapis atomowy: plik tymczasowy + rename, żeby równoległe żądania nie czytały połówki.
$tmp = $cacheFile . '.' . bin2hex(random_bytes(4));
file_put_contents($tmp, json_encode($entry));
chmod($tmp, 0600);
rename($tmp, $cacheFile);
return $entry;
}
// Wariant z APCu zamiast pliku (cache w pamięci PHP-FPM):
// $key = 'tribune-token-' . $clientId;
// if (($hit = apcu_fetch($key)) !== false) return $hit;
// ... pobranie z Kinde jak wyżej ...
// apcu_store($key, $entry, $entry['exp'] - TD_MARGIN_S - time());
PHP: endpoint tribune-token.php dla strony
Strona panelu pobiera token z własnego CMS-a, a CMS oddaje token z cache — dla portalu zalogowanego redaktora. Funkcje oznaczone Twoja funkcja w CMS-ie to miejsca na istniejący kod CMS-a.
<?php
// tribune-token.php — GET; zwraca {token, exp} dla portalu zalogowanego redaktora.
require __DIR__ . '/tribune-token-lib.php';
$editor = requireLoggedInEditor(); // Twoja funkcja w CMS-ie: brak sesji → 401 i exit
[$clientId, $clientSecret] = tribuneCredentials($editor->portalId); // Twoja funkcja w CMS-ie:
// dane portalu z konfiguracji poza repo
header('Content-Type: application/json');
header('Cache-Control: no-store'); // token nie może trafić do cache pośredników
try {
echo json_encode(tribuneToken($clientId, $clientSecret)); // {"token": "...", "exp": 1761...}
} catch (Throwable $e) {
error_log('Tribune Desk: ' . $e->getMessage());
http_response_code(502);
echo json_encode(['error' => 'Brak tokenu Tribune Desk']);
}
Token jest ważny do exp i uprawnia tylko do widoków widgetu jednego portalu
(patrz Bezpieczeństwo).
2. Osadzenie widgetu
W szablonie strony panelu, w miejscu, gdzie ma być widget:
<div id="tribunedesk"></div>
<script src="https://tribunedesk.com/embed/v1.js"></script>
<script>
// Token z własnego CMS-a (krok 1). Widget sam trzyma token w pamięci: woła tę funkcję
// przy starcie i ponownie dopiero po odpowiedzi 401 (token wygasł).
async function tribuneToken() {
const resp = await fetch('/tribune-token.php', { credentials: 'same-origin' });
if (!resp.ok) throw new Error('Brak tokenu Tribune Desk');
return (await resp.json()).token; // {token, exp}
}
const handle = TribuneDesk.mount(document.getElementById('tribunedesk'), {
token: tribuneToken,
sendDraftUrl: '/tribune-draft.php', // krok 3
sendDraftHeaders: { 'X-CSRF-Token': <?= json_encode($csrfToken) ?> }, // token CSRF CMS-a
});
// handle.unmount() usuwa widget (np. przy zamknięciu zakładki w panelu SPA).
</script>
Opcje TribuneDesk.mount(element, opcje)
| Opcja | Typ | Znaczenie |
|---|---|---|
token | string albo async () => string |
Wymagana. Token z kroku 1. Zalecana funkcja: widget woła ją raz przy
starcie, zapamiętuje token i woła ją ponownie tylko po odpowiedzi 401
(ponawia zapytanie z nowym tokenem). Gdy podasz sam string, po wygaśnięciu widget pokaże „Sesja
wygasła — odśwież stronę”. |
sendDraftUrl | string | Adres endpointu draftu w CMS-ie (krok 3). Dokładnie jedno
z sendDraftUrl i onSendDraft — inaczej
mount rzuca wyjątek. |
sendDraftHeaders | obiekt {nagłówek: wartość} |
Opcjonalnie. Dodatkowe nagłówki POST-a draftu, np. token CSRF CMS-a. |
onSendDraft | async (payload) => {draft_id?, edit_url?} |
Opcjonalnie, zamiast sendDraftUrl: CMS sam wysyła payload, jak chce.
Wyjątek z tej funkcji widget pokazuje redaktorowi jako błąd wysyłki. |
apiBaseUrl | string | Opcjonalnie. Adres naszego API; domyślnie https://api.tribunedesk.com.
Nie zmieniaj, chyba że podamy inny. |
locale | 'pl' | 'en' |
Zarezerwowane — w etapie 1 widget jest tylko po polsku; wartość jest ignorowana. |
onEvent | (zdarzenie) => void |
Opcjonalnie, do diagnostyki. Zdarzenia api_request,
api_response, draft_post, draft_response
z polem detail (metoda, adres, kod HTTP). Nagłówków
sendDraftHeaders widget tu nie przekazuje. |
mount zwraca obiekt z metodą unmount().
Dobrze wiedzieć
- Widget rysuje się w Shadow DOM: style CMS-a nie psują widgetu, a widget nie zmienia wyglądu reszty strony.
- Zapytania do naszego API idą bez ciasteczek, z tokenem w nagłówku
Authorization. Jedyne zapytanie z ciasteczkami to POST draftu do CMS-a. - Gdy artykuł się pisze, widget odpytuje API co 4 s, ale token trzyma sam i nie woła
funkcji
tokenprzy każdym zapytaniu. Endpoint CMS-a i tak musi oddawać token z cache po stronie serwera (krok 1) — nigdy nie pobieraj nowego przy każdym wywołaniu. - Jeśli CMS ma nagłówek Content-Security-Policy: dopuść
https://tribunedesk.comwscript-srcihttps://api.tribunedesk.comwconnect-src.
3. Endpoint draftu
Po kliknięciu „Wyślij do CMS” widget pobiera od nas payload artykułu i wysyła go z przeglądarki redaktora:
fetch(sendDraftUrl, {
method: 'POST',
credentials: 'include', // przeglądarka dokleja ciasteczko sesji CMS-a
headers: { 'Content-Type': 'application/json', ...sendDraftHeaders },
body: JSON.stringify(payload),
})
Endpoint leży w domenie CMS-a (tej samej co panel), więc uwierzytelnia go zwykła sesja
zalogowanego redaktora — bez publicznego adresu, sekretu i podpisów. CMS wie, kto
kliknął: autorem draftu jest zalogowany redaktor. Pole
author w payloadzie to konto portalu (np. „Portal A – CMS”), a
sent_by ma zawsze wartość null — nie znamy redaktorów CMS-a.
PHP: tribune-draft.php
<?php
// tribune-draft.php — POST z widgetu; zakłada draft i zwraca {draft_id, edit_url}.
$editor = requireLoggedInEditor(); // Twoja funkcja w CMS-ie: brak sesji → 401 i exit
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
// CSRF: ten sam token, który strona przekazała w sendDraftHeaders.
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals(csrfTokenForSession(), $csrf)) { // Twoja funkcja w CMS-ie
http_response_code(403);
exit;
}
$payload = json_decode(file_get_contents('php://input'), true);
if (!is_array($payload) || !is_string($payload['title'] ?? null) || !is_string($payload['html'] ?? null)) {
http_response_code(400);
exit;
}
// Mapowanie pól na draft — dowolne, po stronie CMS-a. Opis pól: sekcja „Pola payloadu”.
$draftId = Drafts::create([ // Twoja funkcja w CMS-ie
'portal_id' => $editor->portalId,
'author_id' => $editor->id, // autor = redaktor, który kliknął
'title' => $payload['title'],
'body_html' => $payload['html'],
'seo_title' => $payload['seo_title'],
'meta_description' => $payload['meta_description'],
'external_ref' => $payload['article_id'], // id artykułu w Tribune Desk
]);
header('Content-Type: application/json');
echo json_encode([
'draft_id' => (string) $draftId,
'edit_url' => "/admin/articles/$draftId/edit", // adres edycji draftu w CMS-ie
]);
Kontrakt odpowiedzi
| Odpowiedź CMS-a | Co widzi redaktor |
|---|---|
2xx, opcjonalnie JSON {draft_id, edit_url} |
„Wysłano”; gdy jest edit_url — przycisk „Otwórz draft w CMS”. Na
liście artykułów: „Wysłano do CMS”, przycisk zmienia się na „Wyślij ponownie”.
draft_id może być stringiem albo liczbą; oba pola są opcjonalne. |
401 albo 403 |
„Sesja CMS-a wygasła — zaloguj się ponownie”. Dotyczy też odrzuconego tokenu CSRF (403). |
| każdy inny kod | „CMS odrzucił draft (HTTP <kod>)”. Artykuł zostaje do ponownej wysyłki. |
| brak połączenia | „Nie udało się połączyć z CMS-em”. |
Dobrze wiedzieć
- CSRF: podaj token w
sendDraftHeaders(dowolna nazwa nagłówka) i sprawdź go w endpoincie jak przy każdym formularzu CMS-a. - „Wyślij ponownie” wysyła ten sam artykuł jeszcze raz. Jeśli CMS ma nie zakładać
drugiego draftu, rozpoznaj go po
article_idi zwróć istniejącydraft_id. - Pole
htmlbywa długie — upewnij się, że limit rozmiaru żądania (post_max_size, limit serwera WWW) mieści kilkaset KB. - Zamiast endpointu możesz podać
onSendDrafti wysłać payload własnym kodem JS; odpowiedź{draft_id?, edit_url?}działa tak samo.
Pola payloadu
Ten sam kształt, który dostaje nasza wtyczka WordPressa — jeden kontrakt dla wszystkich
CMS-ów. Wszystkie klucze są zawsze obecne; pola opisane jako „albo null”
bywają puste.
| Pole | Typ | Znaczenie |
|---|---|---|
article_id | string (UUID) | Id artykułu w Tribune Desk. Stały przy ponownej wysyłce — dobry klucz do rozpoznania duplikatu. |
org_code | string | Kod portalu (projektu) w Tribune Desk. |
sent_at | string, ISO 8601 (UTC) | Chwila zbudowania payloadu. |
topic | string | Temat z monitoringu, z którego powstał artykuł. |
title | string | Tytuł artykułu. Nie ma go w html (pierwszy <h1>
jest usunięty), więc CMS wstawia go jako tytuł wpisu. |
alternative_titles | string[] | Inne propozycje tytułu. |
html | string (HTML) | Treść artykułu bez tytułu. Może zawierać blok JSON-LD z oznakowaniem treści AI
(patrz ai_provenance) — zostaw go w treści. |
raw_facts | string | Fakty, na których oparto artykuł, po jednym w linii (może być pusty). |
related_topics | {title, reason}[] |
Propozycje kolejnych tematów (reason dziś pusty). |
internal_links |
{article_id, anchor_text, href, url, kind, headline}[] |
Linki z treści do innych artykułów portalu: anchor_text — tekst
linku, href — adres w html, kind —
"cms" (artykuł ma już adres w CMS-ie) albo
"placeholder" (adres tymczasowy do podmiany),
headline — tytuł celu albo null. |
generated_images |
{label, url, template_id, created_at, filename?, caption?, description?, alt?}[] |
Grafiki przypięte do artykułu (url — adres pliku). Dla artykułów
z widgetu zwykle pusta. |
seo_title | string albo null |
Tytuł SEO (do <title>). |
meta_description | string albo null |
Opis meta dla wyszukiwarek. |
focus_keyphrase | string albo null |
Fraza kluczowa SEO. |
facebook_title | string albo null |
Wybrany tytuł posta na Facebooka (pierwsza z facebook_titles).
Tylko na Facebooka — nie jako tytuł wpisu. |
facebook_titles | string[] | Wszystkie propozycje tytułu na Facebooka. |
facebook_teaser | string albo null |
Wybrany teaser (zajawka) na Facebooka (pierwszy z facebook_teasers). |
facebook_teasers | string[] | Wszystkie propozycje teasera. |
twitter_posts | string[] | Propozycje wpisów na X/Twitter. |
reel_captions | string[] | Podpisy do rolek (w etapie 1 widget ich nie tworzy). |
custom_content | {label, items: string[]}[] |
Dodatkowe bloki treści skonfigurowane dla portalu: etykieta i lista pozycji. |
ai_provenance | {claims, jws} albo null |
Podpisane oznakowanie treści wygenerowanej z pomocą AI (podpis odpowiada
treści html). null, gdy znakowanie jest wyłączone. |
sent_by | null |
Z widgetu zawsze null. Kto kliknął „Wyślij”, wie CMS z sesji. |
author | {id, name, email} |
Konto portalu, które zleciło artykuł: id =
"m2m:<client_id>", name = nazwa portalu u nas,
email = null. Nie używaj go jako autora
draftu. |
metadata | {created_at, domain} |
Data powstania artykułu (ISO 8601) i domena portalu z naszej konfiguracji. |
Przykład
Zrzut z naszego testu kontraktu (artykuł testowy, stąd krótkie i puste pola). W prawdziwym
artykule html ma kilka tysięcy znaków, a listy mają zawartość.
{
"article_id": "454218a5-a40a-4c51-a6b5-d48c0f37cfb6",
"org_code": "proj_a",
"sent_at": "2026-10-22T12:10:36.330505+00:00",
"topic": "T",
"title": "Naglowek",
"alternative_titles": [
"Alt"
],
"twitter_posts": [],
"reel_captions": [],
"custom_content": [],
"facebook_title": null,
"facebook_titles": [],
"facebook_teaser": null,
"facebook_teasers": [],
"html": "<p>Tresc</p>",
"raw_facts": "",
"related_topics": [],
"internal_links": [],
"generated_images": [],
"seo_title": null,
"meta_description": null,
"focus_keyphrase": null,
"ai_provenance": null,
"sent_by": null,
"author": {
"id": "m2m:cid1",
"name": "Portal A – CMS",
"email": null
},
"metadata": {
"created_at": "2026-10-22T12:10:35.830661+00:00",
"domain": "styl.fm"
}
}
Pełny payload prawdziwego artykułu zobaczysz w demo: panel „Co się dzieje” pokazuje JSON wysłany do CMS-a.
Czego nie ma w etapie 1
Etap 1 to ścieżka: temat → artykuł → draft w CMS-ie. Poza nią:
- fact-checki, mapy, czat z artykułem;
- edycja tekstu w widgecie — poprawki robi się w drafcie w CMS-ie;
- ustawienia artykułu (długość, źródła, fakty, daty) — artykuł powstaje z ustawień domyślnych portalu;
- dodawanie źródeł z poziomu CMS-a;
- rolki, kolaże, biblioteka mediów;
- osobne konta redaktorów u nas — wszyscy redaktorzy portalu korzystają z jednego konta portalu, a autora draftu wyznacza CMS;
- pola specyficzne dla CMS-a sieci: lead, slug, tagi, kategoria, zdjęcie główne. Dziś ich nie generujemy — dodamy brakujące po otrzymaniu listy pól draftu w CMS-ie.
Bezpieczeństwo
- Sekret tylko na serwerze.
client_secretportalu żyje w konfiguracji serwera CMS-a (poza repozytorium). Do przeglądarki trafia wyłącznie token. - Token jest krótkotrwały i wąski. Jest ważny do
exp(dziś 24 h) i działa tylko na trasach widgetu jednego portalu: tematy, zlecanie i podgląd artykułów tego portalu, payload draftu. Nie sięga do innych portali sieci ani do ustawień, zespołu, rozliczeń czy panelu administracyjnego — tam nasze API odpowiada odmową. - Sesja CMS-a zostaje w CMS-ie. Widget nie wysyła ciasteczek do naszego API; do CMS-a idzie tylko POST draftu, uwierzytelniony zwykłą sesją redaktora i tokenem CSRF.
- Gdy sekret wycieknie (trafi do repo, logu, maila): daj nam znać od
razu. Unieważniamy konto maszynowe tego portalu — od tej chwili jego tokeny przestają
działać — i wydajemy nowe
client_id/client_secret. Po stronie CMS-a wystarczy podmienić dane w konfiguracji i usunąć plik (wpis) cache tokenu.
Tribune Desk · dokumentacja integracji widgetu, etap 1. Wróć do demo