Tribune Desk — widget dla CMS-a ← Wróć do demo

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:

  1. Token — serwer CMS-a wymienia dane dostępowe portalu na token (jedno wywołanie HTTP raz na dobę) i podaje go stronie.
  2. Osadzenie — strona CMS-a wstawia jeden <script> i wywołuje TribuneDesk.mount(...).
  3. 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.

Nie pobieraj tokenu przy każdym renderze strony. Token trzymaj w cache po stronie serwera i pobieraj nowy dopiero na kilka minut przed wygaśnięciem (np. 5 min zapasu). To wymóg, nie zalecenie: plan Kinde ma miesięczny limit wydanych tokenów. Przy cache to ok. 30 tokenów miesięcznie na portal; bez cache — tyle, ile wyświetleń panelu.

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)

OpcjaTypZnaczenie
tokenstring 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ę”.
sendDraftUrlstring Adres endpointu draftu w CMS-ie (krok 3). Dokładnie jedno z sendDraftUrl i onSendDraft — inaczej mount rzuca wyjątek.
sendDraftHeadersobiekt {nagłówek: wartość} Opcjonalnie. Dodatkowe nagłówki POST-a draftu, np. token CSRF CMS-a.
onSendDraftasync (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.
apiBaseUrlstring 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ć

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-aCo 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ć

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.

PoleTypZnaczenie
article_idstring (UUID) Id artykułu w Tribune Desk. Stały przy ponownej wysyłce — dobry klucz do rozpoznania duplikatu.
org_codestring Kod portalu (projektu) w Tribune Desk.
sent_atstring, ISO 8601 (UTC) Chwila zbudowania payloadu.
topicstring Temat z monitoringu, z którego powstał artykuł.
titlestring Tytuł artykułu. Nie ma go w html (pierwszy <h1> jest usunięty), więc CMS wstawia go jako tytuł wpisu.
alternative_titlesstring[] Inne propozycje tytułu.
htmlstring (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_factsstring 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_titlestring albo null Tytuł SEO (do <title>).
meta_descriptionstring albo null Opis meta dla wyszukiwarek.
focus_keyphrasestring albo null Fraza kluczowa SEO.
facebook_titlestring albo null Wybrany tytuł posta na Facebooka (pierwsza z facebook_titles). Tylko na Facebooka — nie jako tytuł wpisu.
facebook_titlesstring[] Wszystkie propozycje tytułu na Facebooka.
facebook_teaserstring albo null Wybrany teaser (zajawka) na Facebooka (pierwszy z facebook_teasers).
facebook_teasersstring[] Wszystkie propozycje teasera.
twitter_postsstring[] Propozycje wpisów na X/Twitter.
reel_captionsstring[] 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_bynull 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ą:

Bezpieczeństwo

Tribune Desk · dokumentacja integracji widgetu, etap 1. Wróć do demo