Wenn Ihre Website nicht auf WordPress, Webflow oder einer anderen unterstützten Plattform läuft, bauen Sie einen kleinen Empfänger. AutoSeoPro.ai sendet ihm signierte Anfragen zum Veröffentlichen, Aktualisieren und Löschen von Artikeln, und Ihre Website antwortet mit einer Quittung.
Ein Empfänger ist eine Webadresse auf Ihrer Website, die POST-Anfragen von AutoSeoPro.ai annimmt. Jede Anfrage ist mit einem Geheimnis signiert, das nur Sie und AutoSeoPro.ai kennen, sodass Ihre Website prüfen kann, ob sie echt ist. Für jeden Artikel gibt es vier mögliche Ereignisse: ping (ein Test), publish, update und delete.
AutoSeoPro.ai wertet einen Artikel nur dann als veröffentlicht, wenn Ihre Antwort eine vollständige Quittung ist, die zur Anfrage passt. Ein bloßes 200 ist kein Nachweis.
Das Artikelobjekt kann weitere Felder enthalten als hier beschrieben, etwa Kategorien und Tags. Ignorieren Sie, was Sie nicht brauchen.
Einrichtung in fünf Schritten
Bauen Sie den Empfänger. Starten Sie mit einem Kit unten (Node.js, PHP oder Python) und ersetzen Sie die Handler-Funktionen durch Aufrufe Ihres CMS oder Ihrer Datenbank.
Erzeugen Sie ein Geheimnis. Erzeugen Sie mindestens 32 zufällige Zeichen, zum Beispiel mit openssl rand -hex 32, und legen Sie es in der Umgebung Ihres Empfängers ab, nie in veröffentlichtem Code.
Tragen Sie es in AutoSeoPro.ai ein. Öffnen Sie Verbindungen, wählen Sie Andere Plattform (REST API) und geben Sie die Empfänger-URL, die öffentliche Adresse Ihrer Website (der genaue https-Origin, etwa https://www.example.com) und das Geheimnis ein.
Starten Sie den Verbindungstest. AutoSeoPro.ai sendet einen signierten Ping und akzeptiert nur eine Antwort, die den Idempotenzschlüssel mit accepted: true wiederholt.
Veröffentlichen Sie. Veröffentlichen Sie einen Artikel von Hand, um das Ergebnis auf Ihrer Website zu prüfen, und schalten Sie dann den Autopiloten ein.
Was jede Anfrage enthält
Jede Anfrage ist ein POST mit JSON-Body und diesen Headern.
Header
Bedeutung
Content-Type
Immer application/json.
Authorization
Bearer plus Ihr Geheimnis. Dasselbe Geheimnis signiert die Anfrage.
X-Rankora-Protocol
Immer 2026-08-27. Lehnen Sie jeden anderen Wert ab.
X-Rankora-Event
ping, publish, update oder delete. Muss dem Feld event im Body entsprechen.
X-Rankora-Idempotency-Key
Eindeutig pro Vorgang, höchstens 128 Zeichen. Damit erkennen Sie Wiederholungen.
X-Rankora-Timestamp
Unix-Zeit in Sekunden, zu der die Anfrage signiert wurde.
X-Rankora-Signature
v1= gefolgt vom HMAC-SHA256 der Anfrage in Kleinbuchstaben-Hex.
So wird die Signatur berechnet
Prüfen Sie die Signatur, bevor Sie den Body auswerten. Schlägt eine Prüfung fehl, antworten Sie mit 401 und tun sonst nichts.
Lesen Sie den rohen Request-Body als Bytes. Werten Sie ihn nicht aus und kodieren Sie ihn nicht neu, denn das verändert die Bytes.
Bilden Sie den String timestamp.idempotencyKey.rawBody: den Header X-AutoSeoPro.ai-Timestamp, den Header X-AutoSeoPro.ai-Idempotency-Key und den rohen Body, durch Punkte verbunden.
Berechnen Sie den HMAC-SHA256 dieses Strings mit Ihrem Geheimnis und schreiben Sie ihn als Kleinbuchstaben-Hex.
Vergleichen Sie v1= plus dieses Hex mit dem Header X-AutoSeoPro.ai-Signature in konstanter Zeit, zum Beispiel mit crypto.timingSafeEqual, hash_equals oder hmac.compare_digest.
Lehnen Sie einen Zeitstempel ab, der mehr als fünf Minuten von Ihrer Uhr abweicht (in beide Richtungen), sowie ein fehlendes oder falsches Authorization: Bearer-Token.
Die Signatur in Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is the Buffer you received, untouched.
const signed = `${timestamp}.${idempotencyKey}.`;
const expected = "v1=" + createHmac("sha256", secret)
.update(signed)
.update(rawBody)
.digest("hex");
const ok = expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
Idempotenz
AutoSeoPro.ai wiederholt nach einem Timeout, daher kann dasselbe Ereignis mehrfach eintreffen. Ihr Empfänger darf Ihre Website nur einmal ändern.
Jedes Ereignis trägt einen Idempotenzschlüssel. Speichern Sie den Schlüssel zusammen mit der genauen Antwort, die Sie dafür gesendet haben.
Kommt ein Schlüssel wieder, geben Sie die gespeicherte Antwort zurück und berühren Ihr CMS nicht erneut.
Vergleichen Sie nur den Schlüssel. Eine Wiederholung hat einen neuen Zeitstempel und occurredAt, ihr Body unterscheidet sich also vom ersten Versuch.
Speichern Sie die Antwort erst, wenn die Änderung gelungen ist. Scheitert Ihr Handler, antworten Sie mit einem Fehlerstatus und speichern nichts, damit die Wiederholung noch gelingen kann.
Zwei Anfragen mit demselben Schlüssel können gleichzeitig eintreffen. Nehmen Sie eine Sperre pro Schlüssel, damit nur eine Ihren Handler ausführt.
Ein Update nutzt einen aus dem Artikelinhalt abgeleiteten Schlüssel: Geänderter Inhalt ergibt einen neuen Schlüssel, identischer Inhalt ist eine Wiederholung.
Antwortformen
Antworten Sie mit 200 und einem JSON-Body. Die Beispiele zeigen den Request-Body jedes Ereignisses in Kürze und die genaue Antwort, die AutoSeoPro.ai erwartet.
version ist 2026-08-27 und idempotencyKey ist der Schlüssel der Anfrage.
state ist published, wenn die Sichtbarkeit des Artikels publish ist, und draft, wenn sie draft ist. Veröffentlichen Sie nie einen Entwurf.
postId und receiptId sind stabile IDs mit höchstens 256 Zeichen (Buchstaben, Ziffern und . _ : / -). Ein Update gibt die empfangene postId zurück.
Das Löschen eines bereits entfernten Beitrags gilt weiterhin als Erfolg: Antworten Sie mit deleted: true.
Um ein Ereignis abzulehnen, antworten Sie mit 4xx oder 5xx und einem application/problem+json-Body. Antworten Sie bei einem Fehler nie mit 2xx.
{
"type": "about:blank",
"title": "Bad signature",
"status": 401,
"detail": "The signature does not match the body."
}
Die Antwortadresse muss auf Ihrer Website liegen
Die url in einer publish- oder update-Antwort muss https verwenden und genau auf der öffentlichen Website-Adresse liegen, die Sie in AutoSeoPro.ai eingetragen haben: gleiches Schema, gleicher Host, gleicher Port. Eine Antwort mit einer anderen Adresse wird als unvollständige Quittung abgelehnt, sodass ein Fehler oder ein gekaperter Empfänger AutoSeoPro.ai nicht auf eine andere Website lenken kann.
Empfänger-Kits
Jedes Kit ist ein kleiner, vollständiger Empfänger: Bearer- und Signaturprüfung, das Fünf-Minuten-Fenster, ein Idempotenzspeicher mit Sperre und ein Demo-Handler, der Beiträge als Dateien speichert. Kopieren Sie die Dateien und ersetzen Sie die Handler durch Ihre CMS-Aufrufe.
Node.js (Express)
Benötigt Node 18 oder neuer und npm install express. Die Prüfdatei hat keine Abhängigkeiten.
Datei: receiver-kits/node/autoseopro-receiver.mjs
/**
* AutoSeoPro.ai signed publishing receiver: framework-free core (Node 18+, no dependencies).
*
* Protocol 2026-08-27. Copy this file next to server.mjs (the Express wiring).
* It does five things, in this order, for every request:
*
* 1. checks the Bearer token (the same secret you pasted into AutoSeoPro.ai)
* 2. checks the protocol version and the 5-minute timestamp window
* 3. recomputes the HMAC-SHA256 signature over the RAW body bytes and compares it in constant time
* 4. returns the stored reply when it has already seen this idempotency key (a replay never repeats the CMS change)
* 5. otherwise runs your handler once, validates its answer, stores the reply and returns it
*
* Only step 5 touches your CMS. Everything else is the same for every site.
*/
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
export const PROTOCOL = "2026-08-27";
export const WINDOW_SECONDS = 300;
export const MIN_SECRET_LENGTH = 32;
const IDEMPOTENCY_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const EXTERNAL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/;
const TIMESTAMP = /^[0-9]{10,13}$/;
const SIGNATURE = /^v1=[a-f0-9]{64}$/;
const EVENTS = new Set(["ping", "publish", "update", "delete"]);
/** Compare two strings without leaking where they differ (hash first, so the lengths never matter). */
export function safeEqual(a, b) {
const left = createHash("sha256").update(String(a)).digest();
const right = createHash("sha256").update(String(b)).digest();
return timingSafeEqual(left, right);
}
/** `v1=` + hex HMAC-SHA256 of `timestamp.idempotencyKey.rawBody`. The timestamp is the header text exactly as sent. */
export function sign(secret, timestamp, idempotencyKey, rawBody) {
const mac = createHmac("sha256", secret);
mac.update(`${timestamp}.${idempotencyKey}.`);
mac.update(rawBody); // the exact bytes received, never JSON.stringify(JSON.parse(body))
return `v1=${mac.digest("hex")}`;
}
function problem(status, title, detail) {
return { status, contentType: "application/problem+json", body: { type: "about:blank", title, status, detail } };
}
function header(headers, name) {
const wanted = name.toLowerCase();
for (const [key, value] of Object.entries(headers ?? {})) {
if (key.toLowerCase() === wanted) return Array.isArray(value) ? value[0] : value;
}
return undefined;
}
/**
* Checks everything that proves the request is genuinely from AutoSeoPro.ai and is fresh.
* Returns `{ ok: true, event, idempotencyKey }` or `{ ok: false, response }`.
*/
export function verifyRequest({ headers, rawBody }, { secret, now = () => Date.now() }) {
if (typeof secret !== "string" || secret.length < MIN_SECRET_LENGTH) {
return { ok: false, response: problem(500, "Receiver is not configured", "The shared secret must be at least 32 characters.") };
}
const authorization = header(headers, "authorization") ?? "";
if (!authorization.startsWith("Bearer ") || !safeEqual(authorization.slice(7), secret)) {
return { ok: false, response: problem(401, "Unauthorized", "Missing or wrong bearer token.") };
}
if (header(headers, "x-rankora-protocol") !== PROTOCOL) {
return { ok: false, response: problem(400, "Unsupported protocol version", `Only ${PROTOCOL} is supported.`) };
}
const event = header(headers, "x-rankora-event") ?? "";
const idempotencyKey = header(headers, "x-rankora-idempotency-key") ?? "";
const timestamp = header(headers, "x-rankora-timestamp") ?? "";
const signature = header(headers, "x-rankora-signature") ?? "";
if (!EVENTS.has(event) || !IDEMPOTENCY_KEY.test(idempotencyKey) || !TIMESTAMP.test(timestamp) || !SIGNATURE.test(signature)) {
return { ok: false, response: problem(400, "Malformed headers", "An X-Rankora-* header is missing or invalid.") };
}
// Ten digits are seconds (what AutoSeoPro.ai sends); thirteen are milliseconds.
const seconds = timestamp.length >= 13 ? Math.floor(Number(timestamp) / 1000) : Number(timestamp);
if (Math.abs(Math.floor(now() / 1000) - seconds) > WINDOW_SECONDS) {
return { ok: false, response: problem(401, "Stale timestamp", "The request is outside the 5-minute window.") };
}
if (!safeEqual(signature, sign(secret, timestamp, idempotencyKey, rawBody))) {
return { ok: false, response: problem(401, "Bad signature", "The signature does not match the body.") };
}
return { ok: true, event, idempotencyKey };
}
/** One JSON file per idempotency key, written atomically, plus an in-process lock so two simultaneous
* deliveries of the same key never both reach your handler. Behind several processes use a database
* table with a UNIQUE(idempotency_key) constraint instead. */
export class FileStore {
constructor(directory) {
this.directory = directory;
this.chains = new Map();
mkdirSync(directory, { recursive: true });
}
path(key) {
return join(this.directory, `${createHash("sha256").update(key).digest("hex")}.json`);
}
get(key) {
try {
return JSON.parse(readFileSync(this.path(key), "utf8"));
} catch {
return undefined;
}
}
put(key, reply) {
const target = this.path(key);
const temporary = `${target}.${randomBytes(6).toString("hex")}.tmp`;
writeFileSync(temporary, JSON.stringify(reply));
renameSync(temporary, target);
}
async withLock(key, work) {
const previous = this.chains.get(key) ?? Promise.resolve();
const run = previous.then(work, work);
const tail = run.catch(() => undefined);
this.chains.set(key, tail);
try {
return await run;
} finally {
if (this.chains.get(key) === tail) this.chains.delete(key);
}
}
}
/** True when `url` is https on the origin (scheme + host + port) of your public site, e.g. "https://www.example.com". */
function onPublicOrigin(url, publicSiteOrigin) {
try {
const candidate = new URL(url);
return candidate.protocol === "https:" && candidate.origin === new URL(publicSiteOrigin).origin;
} catch {
return false;
}
}
function parseBody(rawBody, event, idempotencyKey) {
let payload;
try {
payload = JSON.parse(Buffer.from(rawBody).toString("utf8"));
} catch {
return { error: problem(400, "Invalid JSON", "The body is not valid JSON.") };
}
if (!payload || payload.version !== PROTOCOL || payload.event !== event || payload.idempotencyKey !== idempotencyKey) {
return { error: problem(400, "Header and body disagree", "version, event and idempotencyKey must match the headers.") };
}
return { payload };
}
/**
* Handle one delivery.
*
* handlers = {
* publish({ article, idempotencyKey }) -> { postId, receiptId, url }
* update({ postId, article, idempotencyKey }) -> { postId, receiptId, url } (postId must be the one received)
* delete({ postId, idempotencyKey }) -> void (deleting a missing post is still a success)
* }
* A handler throws an Error with `.status` (404, 409, 422...) to refuse; any other throw is a 500.
* `ping` needs no handler. The article may carry fields beyond the ones documented: ignore what you do not use.
*/
export async function handleEvent({ headers, rawBody }, { secret, publicSiteOrigin, store, handlers, now }) {
const verified = verifyRequest({ headers, rawBody }, { secret, now });
if (!verified.ok) return verified.response;
const { event, idempotencyKey } = verified;
return store.withLock(idempotencyKey, async () => {
// Replays are answered from the store and never reach the CMS again. The body of a retry can differ
// (occurredAt changes), so the key alone decides.
const stored = store.get(idempotencyKey);
if (stored) return { status: 200, contentType: "application/json", body: stored };
const parsed = parseBody(rawBody, event, idempotencyKey);
if (parsed.error) return parsed.error;
const { payload } = parsed;
let receipt;
try {
if (event === "ping") {
receipt = { version: PROTOCOL, event, idempotencyKey, accepted: true };
} else if (event === "delete") {
if (!EXTERNAL_ID.test(String(payload.postId ?? ""))) return problem(422, "Invalid postId", "postId is required for delete.");
await handlers.delete({ postId: payload.postId, idempotencyKey });
receipt = { version: PROTOCOL, event, idempotencyKey, postId: payload.postId, deleted: true };
} else {
const article = payload.article;
if (!article || typeof article.title !== "string" || typeof article.html !== "string" || !article.title || !article.html) {
return problem(422, "Invalid article", "article.title and article.html are required.");
}
if (event === "update" && !EXTERNAL_ID.test(String(payload.postId ?? ""))) return problem(422, "Invalid postId", "postId is required for update.");
const result = event === "publish"
? await handlers.publish({ article, idempotencyKey })
: await handlers.update({ postId: payload.postId, article, idempotencyKey });
if (!EXTERNAL_ID.test(String(result?.postId)) || !EXTERNAL_ID.test(String(result?.receiptId)) || (event === "update" && result.postId !== payload.postId)) {
return problem(500, "Handler returned no durable ids", "postId and receiptId are required and must be stable.");
}
// The reply URL must be on the public-site origin you configured in AutoSeoPro.ai, or the reply is not trusted.
if (!onPublicOrigin(result.url, publicSiteOrigin)) {
return problem(500, "URL outside the public site", `url must be https on ${publicSiteOrigin}.`);
}
receipt = {
version: PROTOCOL,
event,
idempotencyKey,
receiptId: result.receiptId,
postId: result.postId,
// The state must be exactly what was asked for: "publish" -> "published", "draft" -> "draft".
state: (article.visibility ?? article.publishMode) === "publish" ? "published" : "draft",
url: result.url,
};
}
} catch (error) {
const status = Number.isInteger(error?.status) && error.status >= 400 && error.status < 600 ? error.status : 500;
return problem(status, status === 500 ? "Receiver error" : "Refused", status === 500 ? "The receiver failed; the event was not recorded." : String(error.message));
}
store.put(idempotencyKey, receipt); // only successes are stored, so a failed event can be retried
return { status: 200, contentType: "application/json", body: receipt };
});
}
Datei: receiver-kits/node/server.mjs
/**
* AutoSeoPro.ai publishing receiver: Express wiring + a demo handler that saves posts as JSON files.
*
* npm install express
* AUTOSEOPRO_RECEIVER_SECRET="$(openssl rand -hex 32)" PUBLIC_SITE_ORIGIN=https://www.example.com node server.mjs
*
* Put it behind HTTPS (your reverse proxy or host) and paste its public URL, e.g.
* https://www.example.com/autoseopro/events, into AutoSeoPro.ai.
*
* Replace the three functions in `handlers` with calls into your CMS or database.
*/
import { createHash, randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import express from "express";
import { FileStore, handleEvent } from "./autoseopro-receiver.mjs";
const secret = process.env.AUTOSEOPRO_RECEIVER_SECRET ?? "";
const publicSiteOrigin = process.env.PUBLIC_SITE_ORIGIN ?? "";
const dataDirectory = process.env.RECEIVER_DATA_DIR ?? "./receiver-data";
if (secret.length < 32 || !publicSiteOrigin) {
console.error("Set AUTOSEOPRO_RECEIVER_SECRET (32+ characters) and PUBLIC_SITE_ORIGIN (https://your-site).");
process.exit(1);
}
const postsDirectory = join(dataDirectory, "posts");
mkdirSync(postsDirectory, { recursive: true });
const store = new FileStore(join(dataDirectory, "idempotency"));
const slugify = (value) => String(value ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
const postFile = (postId) => join(postsDirectory, `${createHash("sha256").update(postId).digest("hex")}.json`);
const urlFor = (post) => `${publicSiteOrigin.replace(/\/$/, "")}/blog/${post.slug}`;
const receiptIdFor = (idempotencyKey) => `rcpt_${createHash("sha256").update(idempotencyKey).digest("hex").slice(0, 16)}`;
/**
* Images: `article.featuredImageUrl` and any <img src> inside `article.html` are absolute https URLs.
* Either hotlink them as they are, or copy them to your own storage first. If you copy: fetch only https URLs,
* refuse private and loopback addresses, set a timeout, cap the size (for example 10 MB) and check the
* Content-Type is image/*. Never let a failed image download fail the publish; keep the original URL instead.
*/
const handlers = {
publish({ article, idempotencyKey }) {
const postId = `post_${randomUUID()}`;
const post = { postId, slug: slugify(article.slug || article.title) || postId, article, state: article.visibility };
writeFileSync(postFile(postId), JSON.stringify(post));
return { postId, receiptId: receiptIdFor(idempotencyKey), url: urlFor(post) };
},
update({ postId, article, idempotencyKey }) {
let post;
try {
post = JSON.parse(readFileSync(postFile(postId), "utf8"));
} catch {
throw Object.assign(new Error("Unknown postId."), { status: 404 });
}
post = { ...post, article, state: article.visibility, slug: slugify(article.slug || article.title) || post.slug };
writeFileSync(postFile(postId), JSON.stringify(post));
return { postId, receiptId: receiptIdFor(idempotencyKey), url: urlFor(post) };
},
delete({ postId }) {
// Deleting something that is already gone is still a success: the goal state is "not there".
writeFileSync(postFile(postId), JSON.stringify({ postId, deleted: true }));
},
};
const app = express();
// The signature covers the exact bytes, so keep the body raw. Do NOT use express.json() on this route.
app.post("/autoseopro/events", express.raw({ type: "*/*", limit: "5mb" }), async (req, res) => {
const reply = await handleEvent(
{ headers: req.headers, rawBody: Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0) },
{ secret, publicSiteOrigin, store, handlers },
);
res.status(reply.status).type(reply.contentType).send(JSON.stringify(reply.body));
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Receiver listening on :${port}/autoseopro/events`));
PHP 8, ohne Framework
Ohne Framework und ohne Composer-Pakete. Läuft auf gewöhnlichem PHP-Hosting.
Datei: receiver-kits/php/autoseopro_receiver.php
<?php
/**
* AutoSeoPro.ai signed publishing receiver: the verification and idempotency core (PHP 8+, no framework,
* no Composer packages). Protocol 2026-08-27.
*
* For every request it: checks the Bearer token, the protocol version and the 5-minute timestamp window,
* recomputes the HMAC-SHA256 signature over the RAW body and compares it in constant time, answers a
* replayed idempotency key from the store without touching your CMS, and otherwise runs your handler once
* and stores the reply. Only the handlers in index.php talk to your CMS.
*/
declare(strict_types=1);
const AUTOSEOPRO_PROTOCOL = '2026-08-27';
const AUTOSEOPRO_WINDOW_SECONDS = 300;
const AUTOSEOPRO_MIN_SECRET_LENGTH = 32;
/** Compare two strings without leaking where they differ (hash first, so the lengths never matter). */
function autoseopro_safe_equal(string $a, string $b): bool
{
return hash_equals(hash('sha256', $a, true), hash('sha256', $b, true));
}
/** `v1=` + hex HMAC-SHA256 of "timestamp.idempotencyKey.rawBody". The timestamp is the header text exactly as sent. */
function autoseopro_sign(string $secret, string $timestamp, string $idempotencyKey, string $rawBody): string
{
return 'v1=' . hash_hmac('sha256', $timestamp . '.' . $idempotencyKey . '.' . $rawBody, $secret);
}
/** A problem+json answer: [status, content type, body]. */
function autoseopro_problem(int $status, string $title, string $detail): array
{
return [$status, 'application/problem+json', ['type' => 'about:blank', 'title' => $title, 'status' => $status, 'detail' => $detail]];
}
/** Case-insensitive header lookup in an array of name => value. */
function autoseopro_header(array $headers, string $name): string
{
foreach ($headers as $key => $value) {
if (strcasecmp((string) $key, $name) === 0) {
return is_array($value) ? (string) ($value[0] ?? '') : (string) $value;
}
}
return '';
}
/** Request headers from $_SERVER. Some Apache/FastCGI setups hide Authorization; see the .htaccess note in index.php. */
function autoseopro_request_headers(): array
{
$headers = [];
foreach ($_SERVER as $key => $value) {
if (strncmp($key, 'HTTP_', 5) === 0) {
$headers[str_replace('_', '-', substr($key, 5))] = $value;
}
}
if (!isset($headers['AUTHORIZATION']) && isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
$headers['AUTHORIZATION'] = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
}
return $headers;
}
/**
* Proves the request is genuinely from AutoSeoPro.ai and is fresh.
* Returns ['ok' => true, 'event' => ..., 'idempotencyKey' => ...] or ['ok' => false, 'response' => [status, type, body]].
*/
function autoseopro_verify(array $headers, string $rawBody, string $secret, ?int $now = null): array
{
if (strlen($secret) < AUTOSEOPRO_MIN_SECRET_LENGTH) {
return ['ok' => false, 'response' => autoseopro_problem(500, 'Receiver is not configured', 'The shared secret must be at least 32 characters.')];
}
$authorization = autoseopro_header($headers, 'authorization');
if (strncmp($authorization, 'Bearer ', 7) !== 0 || !autoseopro_safe_equal(substr($authorization, 7), $secret)) {
return ['ok' => false, 'response' => autoseopro_problem(401, 'Unauthorized', 'Missing or wrong bearer token.')];
}
if (autoseopro_header($headers, 'x-rankora-protocol') !== AUTOSEOPRO_PROTOCOL) {
return ['ok' => false, 'response' => autoseopro_problem(400, 'Unsupported protocol version', 'Only ' . AUTOSEOPRO_PROTOCOL . ' is supported.')];
}
$event = autoseopro_header($headers, 'x-rankora-event');
$key = autoseopro_header($headers, 'x-rankora-idempotency-key');
$timestamp = autoseopro_header($headers, 'x-rankora-timestamp');
$signature = autoseopro_header($headers, 'x-rankora-signature');
if (!in_array($event, ['ping', 'publish', 'update', 'delete'], true)
|| preg_match('/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/', $key) !== 1
|| preg_match('/^[0-9]{10,13}$/', $timestamp) !== 1
|| preg_match('/^v1=[a-f0-9]{64}$/', $signature) !== 1) {
return ['ok' => false, 'response' => autoseopro_problem(400, 'Malformed headers', 'An X-Rankora-* header is missing or invalid.')];
}
// Ten digits are seconds (what AutoSeoPro.ai sends); thirteen are milliseconds.
$seconds = strlen($timestamp) >= 13 ? intdiv((int) $timestamp, 1000) : (int) $timestamp;
if (abs(($now ?? time()) - $seconds) > AUTOSEOPRO_WINDOW_SECONDS) {
return ['ok' => false, 'response' => autoseopro_problem(401, 'Stale timestamp', 'The request is outside the 5-minute window.')];
}
if (!autoseopro_safe_equal($signature, autoseopro_sign($secret, $timestamp, $key, $rawBody))) {
return ['ok' => false, 'response' => autoseopro_problem(401, 'Bad signature', 'The signature does not match the body.')];
}
return ['ok' => true, 'event' => $event, 'idempotencyKey' => $key];
}
/**
* One JSON file per idempotency key plus an exclusive file lock, so two simultaneous deliveries of the same
* key never both reach your handler. With several servers use a database table with a UNIQUE key instead.
*/
final class AutoSeoProFileStore
{
public function __construct(private string $directory)
{
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
throw new RuntimeException('Cannot create ' . $directory);
}
}
private function path(string $key, string $extension): string
{
return $this->directory . '/' . hash('sha256', $key) . '.' . $extension;
}
public function get(string $key): ?array
{
$file = $this->path($key, 'json');
if (!is_file($file)) {
return null;
}
$decoded = json_decode((string) file_get_contents($file), true);
return is_array($decoded) ? $decoded : null;
}
public function put(string $key, array $reply): void
{
$file = $this->path($key, 'json');
$temporary = $file . '.' . bin2hex(random_bytes(6)) . '.tmp';
file_put_contents($temporary, json_encode($reply, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
rename($temporary, $file);
}
public function withLock(string $key, callable $work): mixed
{
$lock = fopen($this->path($key, 'lock'), 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new RuntimeException('Cannot lock the idempotency store.');
}
try {
return $work();
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
}
/** True when $url is https on the origin (scheme + host + port) of your public site. */
function autoseopro_on_public_origin(mixed $url, string $publicSiteOrigin): bool
{
if (!is_string($url)) {
return false;
}
$candidate = parse_url($url);
$configured = parse_url($publicSiteOrigin);
if (!is_array($candidate) || !is_array($configured) || !isset($candidate['host'], $configured['host'])) {
return false;
}
$origin = static fn (array $parts): string => strtolower(($parts['scheme'] ?? '') . '://' . $parts['host'] . ':' . ($parts['port'] ?? (($parts['scheme'] ?? '') === 'https' ? 443 : 80)));
return ($candidate['scheme'] ?? '') === 'https' && !isset($candidate['user']) && $origin($candidate) === $origin($configured);
}
function autoseopro_valid_external_id(mixed $value): bool
{
return is_string($value) && preg_match('/^[A-Za-z0-9][A-Za-z0-9._:\/-]{0,255}$/', $value) === 1;
}
/**
* Handle one delivery. Returns [status, content type, body array].
*
* $config = ['secret' => ..., 'public_site_origin' => 'https://www.example.com', 'now' => optional unix seconds]
* $handlers = [
* 'publish' => fn(array $article, string $idempotencyKey): array => ['postId' => ..., 'receiptId' => ..., 'url' => ...],
* 'update' => fn(string $postId, array $article, string $idempotencyKey): array => same shape, same postId,
* 'delete' => fn(string $postId, string $idempotencyKey): void (deleting a missing post is still a success),
* ]
* A handler refuses by throwing AutoSeoProRefusal(status, message); any other exception is a 500.
* ping needs no handler. The article may carry fields beyond the documented ones: ignore what you do not use.
*/
function autoseopro_handle(array $headers, string $rawBody, array $config, AutoSeoProFileStore $store, array $handlers): array
{
$verified = autoseopro_verify($headers, $rawBody, (string) $config['secret'], $config['now'] ?? null);
if (!$verified['ok']) {
return $verified['response'];
}
$event = $verified['event'];
$key = $verified['idempotencyKey'];
return $store->withLock($key, static function () use ($event, $key, $rawBody, $config, $store, $handlers): array {
// Replays are answered from the store and never reach the CMS again. The body of a retry can differ
// (occurredAt changes), so the key alone decides.
$stored = $store->get($key);
if ($stored !== null) {
return [200, 'application/json', $stored];
}
$payload = json_decode($rawBody, true);
if (!is_array($payload)) {
return autoseopro_problem(400, 'Invalid JSON', 'The body is not valid JSON.');
}
if (($payload['version'] ?? null) !== AUTOSEOPRO_PROTOCOL || ($payload['event'] ?? null) !== $event || ($payload['idempotencyKey'] ?? null) !== $key) {
return autoseopro_problem(400, 'Header and body disagree', 'version, event and idempotencyKey must match the headers.');
}
try {
if ($event === 'ping') {
$receipt = ['version' => AUTOSEOPRO_PROTOCOL, 'event' => 'ping', 'idempotencyKey' => $key, 'accepted' => true];
} elseif ($event === 'delete') {
if (!autoseopro_valid_external_id($payload['postId'] ?? null)) {
return autoseopro_problem(422, 'Invalid postId', 'postId is required for delete.');
}
($handlers['delete'])($payload['postId'], $key);
$receipt = ['version' => AUTOSEOPRO_PROTOCOL, 'event' => 'delete', 'idempotencyKey' => $key, 'postId' => $payload['postId'], 'deleted' => true];
} else {
$article = $payload['article'] ?? null;
if (!is_array($article) || !is_string($article['title'] ?? null) || $article['title'] === '' || !is_string($article['html'] ?? null) || $article['html'] === '') {
return autoseopro_problem(422, 'Invalid article', 'article.title and article.html are required.');
}
if ($event === 'update' && !autoseopro_valid_external_id($payload['postId'] ?? null)) {
return autoseopro_problem(422, 'Invalid postId', 'postId is required for update.');
}
$result = $event === 'publish'
? ($handlers['publish'])($article, $key)
: ($handlers['update'])($payload['postId'], $article, $key);
if (!autoseopro_valid_external_id($result['postId'] ?? null) || !autoseopro_valid_external_id($result['receiptId'] ?? null)
|| ($event === 'update' && $result['postId'] !== $payload['postId'])) {
return autoseopro_problem(500, 'Handler returned no durable ids', 'postId and receiptId are required and must be stable.');
}
// The reply URL must be on the public-site origin you configured in AutoSeoPro.ai, or the reply is not trusted.
if (!autoseopro_on_public_origin($result['url'] ?? null, (string) $config['public_site_origin'])) {
return autoseopro_problem(500, 'URL outside the public site', 'url must be https on ' . $config['public_site_origin'] . '.');
}
$requested = $article['visibility'] ?? ($article['publishMode'] ?? 'draft');
$receipt = [
'version' => AUTOSEOPRO_PROTOCOL,
'event' => $event,
'idempotencyKey' => $key,
'receiptId' => $result['receiptId'],
'postId' => $result['postId'],
// The state must be exactly what was asked for: "publish" -> "published", "draft" -> "draft".
'state' => $requested === 'publish' ? 'published' : 'draft',
'url' => $result['url'],
];
}
} catch (AutoSeoProRefusal $refusal) {
return autoseopro_problem($refusal->status, 'Refused', $refusal->getMessage());
} catch (Throwable $error) {
return autoseopro_problem(500, 'Receiver error', 'The receiver failed; the event was not recorded.');
}
$store->put($key, $receipt); // only successes are stored, so a failed event can be retried
return [200, 'application/json', $receipt];
});
}
/** Throw from a handler to refuse an event with a 4xx status (404 unknown post, 409 conflict, 422 invalid content). */
final class AutoSeoProRefusal extends RuntimeException
{
public function __construct(public int $status, string $message)
{
parent::__construct($message);
}
}
Datei: receiver-kits/php/index.php
<?php
/**
* AutoSeoPro.ai publishing receiver: the entry point + a demo handler that saves posts as JSON files.
*
* Put this folder on any PHP 8 host over HTTPS and paste the URL that reaches this file, e.g.
* https://www.example.com/autoseopro/events, into AutoSeoPro.ai (route /autoseopro/events to index.php with your
* web server or a tiny rewrite). Set two environment variables on the host:
*
* AUTOSEOPRO_RECEIVER_SECRET a random 32+ character secret, the same value you paste into AutoSeoPro.ai
* (generate one with: openssl rand -hex 32)
* PUBLIC_SITE_ORIGIN https://www.example.com
*
* Apache hides the Authorization header from PHP unless you add this to .htaccess:
* RewriteEngine On
* RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
*
* Replace the three functions in $handlers with calls into your CMS or database.
*/
declare(strict_types=1);
require __DIR__ . '/autoseopro_receiver.php';
$secret = (string) getenv('AUTOSEOPRO_RECEIVER_SECRET');
$publicSiteOrigin = (string) getenv('PUBLIC_SITE_ORIGIN');
$dataDirectory = getenv('RECEIVER_DATA_DIR') ?: __DIR__ . '/receiver-data'; // keep this OUTSIDE the web root in production
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Allow: POST');
exit;
}
$store = new AutoSeoProFileStore($dataDirectory . '/idempotency');
if (!is_dir($dataDirectory . '/posts')) {
mkdir($dataDirectory . '/posts', 0700, true);
}
$postFile = static fn (string $postId): string => $dataDirectory . '/posts/' . hash('sha256', $postId) . '.json';
$slugify = static fn (string $value): string => trim(substr(preg_replace('/[^a-z0-9]+/', '-', strtolower($value)) ?? '', 0, 80), '-');
$urlFor = static fn (string $slug): string => rtrim($publicSiteOrigin, '/') . '/blog/' . $slug;
$receiptIdFor = static fn (string $key): string => 'rcpt_' . substr(hash('sha256', $key), 0, 16);
/*
* Images: article.featuredImageUrl and any <img src> inside article.html are absolute https URLs.
* Either hotlink them as they are, or copy them to your own storage first. If you copy: fetch only https URLs,
* refuse private and loopback addresses, set a timeout, cap the size (for example 10 MB) and check the
* Content-Type is image/*. Never let a failed image download fail the publish; keep the original URL instead.
*/
$handlers = [
'publish' => static function (array $article, string $key) use ($postFile, $slugify, $urlFor, $receiptIdFor): array {
$postId = 'post_' . bin2hex(random_bytes(16));
$slug = $slugify((string) ($article['slug'] ?? '') ?: $article['title']) ?: $postId;
file_put_contents($postFile($postId), json_encode(['postId' => $postId, 'slug' => $slug, 'article' => $article]));
return ['postId' => $postId, 'receiptId' => $receiptIdFor($key), 'url' => $urlFor($slug)];
},
'update' => static function (string $postId, array $article, string $key) use ($postFile, $slugify, $urlFor, $receiptIdFor): array {
if (!is_file($postFile($postId))) {
throw new AutoSeoProRefusal(404, 'Unknown postId.');
}
$slug = $slugify((string) ($article['slug'] ?? '') ?: $article['title']) ?: $postId;
file_put_contents($postFile($postId), json_encode(['postId' => $postId, 'slug' => $slug, 'article' => $article]));
return ['postId' => $postId, 'receiptId' => $receiptIdFor($key), 'url' => $urlFor($slug)];
},
'delete' => static function (string $postId, string $key) use ($postFile): void {
// Deleting something that is already gone is still a success: the goal state is "not there".
if (is_file($postFile($postId))) {
unlink($postFile($postId));
}
},
];
[$status, $contentType, $body] = autoseopro_handle(
autoseopro_request_headers(),
(string) file_get_contents('php://input'), // the RAW bytes: never re-encode the JSON before verifying
['secret' => $secret, 'public_site_origin' => $publicSiteOrigin],
$store,
$handlers,
);
http_response_code($status);
header('Content-Type: ' . $contentType);
echo json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
Python (Flask)
Python 3.8 oder neuer mit Flask. Die Prüfdatei verwendet nur die Standardbibliothek.
"""AutoSeoPro.ai signed publishing receiver: framework-free core (Python 3.8+, standard library only).
Protocol 2026-08-27. For every request it checks the Bearer token, the protocol version and the 5-minute
timestamp window, recomputes the HMAC-SHA256 signature over the RAW body and compares it in constant time,
answers a replayed idempotency key from the store without touching your CMS, and otherwise runs your handler
once and stores the reply. Only the handlers in app.py talk to your CMS.
"""
import hashlib
import hmac
import json
import os
import re
import threading
import time
from contextlib import contextmanager
from urllib.parse import urlsplit
try: # POSIX file locks so several worker processes share one store; Windows falls back to the thread lock.
import fcntl
except ImportError: # pragma: no cover
fcntl = None
PROTOCOL = "2026-08-27"
WINDOW_SECONDS = 300
MIN_SECRET_LENGTH = 32
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
_EXTERNAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$")
_TIMESTAMP = re.compile(r"^[0-9]{10,13}$")
_SIGNATURE = re.compile(r"^v1=[a-f0-9]{64}$")
_EVENTS = ("ping", "publish", "update", "delete")
class Refusal(Exception):
"""Raise from a handler to refuse an event with a 4xx status (404 unknown post, 409 conflict, 422 invalid)."""
def __init__(self, status, message):
super().__init__(message)
self.status = status
def safe_equal(a, b):
"""Compare two strings without leaking where they differ (hash first, so the lengths never matter)."""
return hmac.compare_digest(hashlib.sha256(a.encode()).digest(), hashlib.sha256(b.encode()).digest())
def sign(secret, timestamp, idempotency_key, raw_body):
"""`v1=` + hex HMAC-SHA256 of "timestamp.idempotencyKey.rawBody". The timestamp is the header text as sent."""
message = timestamp.encode() + b"." + idempotency_key.encode() + b"." + raw_body
return "v1=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
def problem(status, title, detail):
return status, "application/problem+json", {"type": "about:blank", "title": title, "status": status, "detail": detail}
def _header(headers, name):
wanted = name.lower()
for key, value in headers.items():
if key.lower() == wanted:
return value
return ""
def verify_request(headers, raw_body, secret, now=None):
"""Prove the request is genuinely from AutoSeoPro.ai and is fresh.
`raw_body` must be the exact bytes received (Flask: request.get_data()), never re-serialised JSON.
Returns (event, idempotency_key, None) when valid, or (None, None, (status, content_type, body)) when not.
"""
if len(secret) < MIN_SECRET_LENGTH:
return None, None, problem(500, "Receiver is not configured", "The shared secret must be at least 32 characters.")
authorization = _header(headers, "authorization")
if not authorization.startswith("Bearer ") or not safe_equal(authorization[7:], secret):
return None, None, problem(401, "Unauthorized", "Missing or wrong bearer token.")
if _header(headers, "x-rankora-protocol") != PROTOCOL:
return None, None, problem(400, "Unsupported protocol version", "Only " + PROTOCOL + " is supported.")
event = _header(headers, "x-rankora-event")
key = _header(headers, "x-rankora-idempotency-key")
timestamp = _header(headers, "x-rankora-timestamp")
signature = _header(headers, "x-rankora-signature")
if (event not in _EVENTS or not _IDEMPOTENCY_KEY.match(key) or not _TIMESTAMP.match(timestamp)
or not _SIGNATURE.match(signature)):
return None, None, problem(400, "Malformed headers", "An X-Rankora-* header is missing or invalid.")
# Ten digits are seconds (what AutoSeoPro.ai sends); thirteen are milliseconds.
seconds = int(timestamp) // 1000 if len(timestamp) >= 13 else int(timestamp)
if abs(int(now if now is not None else time.time()) - seconds) > WINDOW_SECONDS:
return None, None, problem(401, "Stale timestamp", "The request is outside the 5-minute window.")
if not safe_equal(signature, sign(secret, timestamp, key, raw_body)):
return None, None, problem(401, "Bad signature", "The signature does not match the body.")
return event, key, None
class FileStore:
"""One JSON file per idempotency key plus an exclusive lock, so two simultaneous deliveries of the same
key never both reach your handler. With several servers use a database table with a UNIQUE key instead."""
def __init__(self, directory):
self.directory = directory
self._thread_lock = threading.Lock()
os.makedirs(directory, mode=0o700, exist_ok=True)
def _path(self, key, extension):
return os.path.join(self.directory, hashlib.sha256(key.encode()).hexdigest() + "." + extension)
def get(self, key):
try:
with open(self._path(key, "json"), encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return None
def put(self, key, reply):
target = self._path(key, "json")
temporary = "%s.%s.tmp" % (target, os.urandom(6).hex())
with open(temporary, "w", encoding="utf-8") as handle:
json.dump(reply, handle)
os.replace(temporary, target)
@contextmanager
def lock(self, key):
# flock is per open file, so it excludes other threads and other worker processes for the same key
# without blocking different keys. Without fcntl (Windows) one process-wide lock is used instead.
handle = open(self._path(key, "lock"), "a")
try:
if fcntl is not None:
fcntl.flock(handle, fcntl.LOCK_EX)
yield
else: # pragma: no cover
with self._thread_lock:
yield
finally:
handle.close() # closing releases the flock
def _on_public_origin(url, public_site_origin):
"""True when `url` is https on the origin (scheme + host + port) of your public site."""
try:
candidate, configured = urlsplit(url), urlsplit(public_site_origin)
return (candidate.scheme == "https" and not candidate.username and candidate.hostname is not None
and configured.hostname is not None
and (candidate.hostname, candidate.port or 443) == (configured.hostname, configured.port or 443))
except (ValueError, TypeError, AttributeError):
return False
def handle_event(headers, raw_body, config, store, handlers):
"""Handle one delivery. Returns (status, content_type, body dict).
config = {"secret": ..., "public_site_origin": "https://www.example.com", "now": optional unix seconds}
handlers = {
"publish": lambda article, idempotency_key: {"postId": ..., "receiptId": ..., "url": ...},
"update": lambda post_id, article, idempotency_key: same shape, same postId,
"delete": lambda post_id, idempotency_key: None (deleting a missing post is still a success),
}
A handler refuses by raising Refusal(status, message); any other exception becomes a 500.
ping needs no handler. The article may carry fields beyond the documented ones: ignore what you do not use.
"""
event, key, failure = verify_request(headers, raw_body, config["secret"], config.get("now"))
if failure:
return failure
with store.lock(key):
# Replays are answered from the store and never reach the CMS again. The body of a retry can differ
# (occurredAt changes), so the key alone decides.
stored = store.get(key)
if stored is not None:
return 200, "application/json", stored
try:
payload = json.loads(raw_body.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return problem(400, "Invalid JSON", "The body is not valid JSON.")
if (not isinstance(payload, dict) or payload.get("version") != PROTOCOL
or payload.get("event") != event or payload.get("idempotencyKey") != key):
return problem(400, "Header and body disagree", "version, event and idempotencyKey must match the headers.")
try:
if event == "ping":
receipt = {"version": PROTOCOL, "event": "ping", "idempotencyKey": key, "accepted": True}
elif event == "delete":
post_id = payload.get("postId")
if not isinstance(post_id, str) or not _EXTERNAL_ID.match(post_id):
return problem(422, "Invalid postId", "postId is required for delete.")
handlers["delete"](post_id, key)
receipt = {"version": PROTOCOL, "event": "delete", "idempotencyKey": key, "postId": post_id, "deleted": True}
else:
article = payload.get("article")
if (not isinstance(article, dict) or not isinstance(article.get("title"), str) or not article["title"]
or not isinstance(article.get("html"), str) or not article["html"]):
return problem(422, "Invalid article", "article.title and article.html are required.")
post_id = payload.get("postId")
if event == "update" and (not isinstance(post_id, str) or not _EXTERNAL_ID.match(post_id)):
return problem(422, "Invalid postId", "postId is required for update.")
result = (handlers["publish"](article, key) if event == "publish"
else handlers["update"](post_id, article, key))
valid_ids = all(isinstance(result.get(name), str) and _EXTERNAL_ID.match(result[name]) for name in ("postId", "receiptId"))
if not valid_ids or (event == "update" and result["postId"] != post_id):
return problem(500, "Handler returned no durable ids", "postId and receiptId are required and must be stable.")
# The reply URL must be on the public-site origin you configured in AutoSeoPro.ai, or the reply is not trusted.
if not _on_public_origin(result.get("url"), config["public_site_origin"]):
return problem(500, "URL outside the public site", "url must be https on " + config["public_site_origin"] + ".")
requested = article.get("visibility") or article.get("publishMode") or "draft"
receipt = {
"version": PROTOCOL,
"event": event,
"idempotencyKey": key,
"receiptId": result["receiptId"],
"postId": result["postId"],
# The state must be exactly what was asked for: "publish" -> "published", "draft" -> "draft".
"state": "published" if requested == "publish" else "draft",
"url": result["url"],
}
except Refusal as refusal:
return problem(refusal.status, "Refused", str(refusal))
except Exception: # noqa: BLE001 - a receiver must answer, never crash the worker
return problem(500, "Receiver error", "The receiver failed; the event was not recorded.")
store.put(key, receipt) # only successes are stored, so a failed event can be retried
return 200, "application/json", receipt
Datei: receiver-kits/python/app.py
"""AutoSeoPro.ai publishing receiver: Flask wiring + a demo handler that saves posts as JSON files.
pip install flask
export AUTOSEOPRO_RECEIVER_SECRET="$(openssl rand -hex 32)"
export PUBLIC_SITE_ORIGIN=https://www.example.com
flask --app app run (use gunicorn or uWSGI behind HTTPS in production)
Paste the public URL, e.g. https://www.example.com/autoseopro/events, into AutoSeoPro.ai.
Replace the three functions in HANDLERS with calls into your CMS or database.
"""
import hashlib
import json
import os
import re
import secrets
from flask import Flask, Response, request
from autoseopro_receiver import FileStore, Refusal, handle_event
SECRET = os.environ.get("AUTOSEOPRO_RECEIVER_SECRET", "")
PUBLIC_SITE_ORIGIN = os.environ.get("PUBLIC_SITE_ORIGIN", "")
DATA_DIR = os.environ.get("RECEIVER_DATA_DIR", "./receiver-data") # keep this OUTSIDE the web root in production
os.makedirs(os.path.join(DATA_DIR, "posts"), mode=0o700, exist_ok=True)
STORE = FileStore(os.path.join(DATA_DIR, "idempotency"))
def _post_file(post_id):
return os.path.join(DATA_DIR, "posts", hashlib.sha256(post_id.encode()).hexdigest() + ".json")
def _slug(article, fallback):
text = re.sub(r"[^a-z0-9]+", "-", (article.get("slug") or article["title"]).lower()).strip("-")[:80]
return text or fallback
def _url(slug):
return PUBLIC_SITE_ORIGIN.rstrip("/") + "/blog/" + slug
def _receipt_id(key):
return "rcpt_" + hashlib.sha256(key.encode()).hexdigest()[:16]
def _save(post_id, article):
slug = _slug(article, post_id)
with open(_post_file(post_id), "w", encoding="utf-8") as handle:
json.dump({"postId": post_id, "slug": slug, "article": article}, handle)
return slug
# Images: article["featuredImageUrl"] and any <img src> inside article["html"] are absolute https URLs.
# Either hotlink them as they are, or copy them to your own storage first. If you copy: fetch only https URLs,
# refuse private and loopback addresses, set a timeout, cap the size (for example 10 MB) and check the
# Content-Type is image/*. Never let a failed image download fail the publish; keep the original URL instead.
def publish(article, key):
post_id = "post_" + secrets.token_hex(16)
return {"postId": post_id, "receiptId": _receipt_id(key), "url": _url(_save(post_id, article))}
def update(post_id, article, key):
if not os.path.isfile(_post_file(post_id)):
raise Refusal(404, "Unknown postId.")
return {"postId": post_id, "receiptId": _receipt_id(key), "url": _url(_save(post_id, article))}
def delete(post_id, key):
# Deleting something that is already gone is still a success: the goal state is "not there".
try:
os.remove(_post_file(post_id))
except FileNotFoundError:
pass
HANDLERS = {"publish": publish, "update": update, "delete": delete}
app = Flask(__name__)
@app.post("/autoseopro/events")
def autoseopro_events():
# get_data() returns the exact bytes received. Never use request.get_json() before verifying the signature.
status, content_type, body = handle_event(
dict(request.headers),
request.get_data(),
{"secret": SECRET, "public_site_origin": PUBLIC_SITE_ORIGIN},
STORE,
HANDLERS,
)
return Response(json.dumps(body), status=status, content_type=content_type)
Bilder
featuredImageUrl und die img-Tags in html sind absolute https-Links. Sie können sie als Links belassen oder in Ihren eigenen Speicher kopieren. Beim Kopieren laden Sie nur https-Adressen, lehnen private Adressen ab, setzen ein Zeitlimit und eine Größengrenze und behalten den ursprünglichen Link, wenn ein Download scheitert.
Vor dem Produktivbetrieb
Der Dateispeicher passt zu einem Server. Bei mehreren Servern speichern Sie Idempotenzschlüssel in einer Datenbanktabelle mit eindeutigem Schlüssel und liefern den Empfänger nur über HTTPS aus.