Vi sender hver publiserte artikkel som JSON til adressen din.
Én POST per artikkel, signert, normalt rundt kl. 07 norsk tid. Du svarer 2xx — helst med URL-en artikkelen havnet på.
Har du allerede bygget mot tittel, ingress, html og meta? De ligger fortsatt på toppnivå, og de fjernes aldri.
{
"versjon": 1,
"hendelse": "artikkel.publisert",
"hendelse_id": "hnd_9f2c1a7b4e5d6081",
"sendt": "2026-08-10T05:00:12Z",
"tittel": "Slik velger du bookingsystem til klinikken",
"ingress": "Feil system koster deg timer hver uke. Slik finner du riktig.",
"html": "<h2>Hvorfor bookingsystem</h2><p>…</p>",
"meta": "Guide til bookingsystem for klinikker: hva du bør se etter, og hva som koster deg tid.",
"oppdatering": false,
"url": null,
"nettsted": {
"id": 12,
"url": "https://www.proclinic.no",
"merkenavn": "ProClinic",
"sprak": "nb-NO"
},
"artikkel": {
"id": 4711,
"tittel": "Slik velger du bookingsystem til klinikken",
"slug": "slik-velger-du-bookingsystem-til-klinikken",
"ingress": "Feil system koster deg timer hver uke. Slik finner du riktig.",
"html": "<h2>Hvorfor bookingsystem</h2><p>…</p>",
"tekst": "Hvorfor bookingsystem\n\nDe fleste klinikker …",
"meta_beskrivelse": "Guide til bookingsystem for klinikker: …",
"bilde_url": null,
"sokeord": "bookingsystem klinikk",
"ord": 1187,
"lesetid_min": 6,
"publisert_dato": "2026-08-10",
"publisert_url": null,
"ekstern_id": null
}
}
Slik virker det#
Tre ting. Du gjør det første én gang, vi gjør det andre hver dag, og du gjør det tredje i én funksjon — ferdig skrevet for sju språk lenger nede.
Du oppgir URL-en din
I dashbordet, under Tilkoblinger → kortet «Webhook / annet». Kun https://. Kopier hemmeligheten samtidig — den trenger du i steg 3.
Vi POSTer signert JSON
Én artikkel per hendelse, normalt rundt kl. 07 norsk tid. Content-Type: application/json; charset=utf-8, signaturen ligger i Oron-Signatur. Vi venter i 30 sekunder på svaret ditt.
Du svarer 2xx
Verifiser signaturen, lagre artikkelen, svar 200. Legger du ved {"url": "…"}, vet vi hvor artikkelen faktisk ligger — og rapportene dine peker riktig.
Hendelsene#
Tre typer. Typen står både i headeren Oron-Hendelse og i feltet hendelse i kroppen. Kommer det en type du ikke kjenner igjen: svar 200 og ignorer den.
| Type | Når |
|---|---|
artikkel.publisert | Ny artikkel er publisert. Normalt kl. 07 norsk tid. |
artikkel.oppdatert | En artikkel du allerede har fått, er skrevet om. Oppdater den eksisterende posten — ikke lag en ny. Se eget avsnitt. |
test.hendelse | Du trykket «Send testhendelse» i dashbordet. Ekte signatur, oppdiktet artikkel. Skal ikke publiseres. |
Nyttelasten#
Kroppen er JSON, UTF-8, uten escaping av æ, ø og å. Feltrekkefølgen er ikke garantert — parse JSON, ikke tekst. Ukjente felt kan dukke opp senere; ignorer dem, så tåler mottakeren din neste versjon.
{
"versjon": 1,
"hendelse": "artikkel.publisert",
"hendelse_id": "hnd_9f2c1a7b4e5d6081",
"sendt": "2026-08-10T05:00:12Z",
"tittel": "Slik velger du bookingsystem til klinikken",
"ingress": "Feil system koster deg timer hver uke. Slik finner du riktig.",
"html": "<h2>Hvorfor bookingsystem</h2><p>…</p>",
"meta": "Guide til bookingsystem for klinikker: hva du bør se etter, og hva som koster deg tid.",
"oppdatering": false,
"url": null,
"nettsted": {
"id": 12,
"url": "https://www.proclinic.no",
"merkenavn": "ProClinic",
"sprak": "nb-NO"
},
"artikkel": {
"id": 4711,
"tittel": "Slik velger du bookingsystem til klinikken",
"slug": "slik-velger-du-bookingsystem-til-klinikken",
"ingress": "Feil system koster deg timer hver uke. Slik finner du riktig.",
"html": "<h2>Hvorfor bookingsystem</h2><p>…</p>",
"tekst": "Hvorfor bookingsystem\n\nDe fleste klinikker …",
"meta_beskrivelse": "Guide til bookingsystem for klinikker: …",
"bilde_url": null,
"sokeord": "bookingsystem klinikk",
"ord": 1187,
"lesetid_min": 6,
"publisert_dato": "2026-08-10",
"publisert_url": null,
"ekstern_id": null
}
}
tittel, ingress, html og meta ligger på toppnivå og fjernes aldri. Bygde du mot dem før alt det andre kom til, fortsetter integrasjonen din å virke. Nye felt legges alltid ved siden av — aldri i stedet for.
Feltene på toppnivå#
| Felt | Type | Kan være null | Hva det er |
|---|---|---|---|
versjon | tall | nei | Nyttelastversjon. Alltid 1 i dag. |
hendelse | tekst | nei | En av de tre typene over. |
hendelse_id | tekst | nei | Unik ID for hendelsen, prefikset hnd_. Lik på alle forsøk. Bruk den til idempotens. |
sendt | tekst | nei | Da vi bygde nyttelasten. ISO 8601 i UTC: 2026-08-10T05:00:12Z. |
tittelalltid med | tekst | nei | Artikkelens tittel. Samme verdi som artikkel.tittel. |
ingressalltid med | tekst | nei | Kort ingress. Kan være tom tekst, men feltet er alltid der. |
htmlalltid med | tekst | nei | Brødteksten som ferdig HTML. I dag bruker vi bare <h2>, <p> og <br> — ingen lister, tabeller eller bilder. Ingen <html> eller <body> rundt, og ingen <h1>: tittelen er din å sette. |
metaalltid med | tekst | ja | Meta-beskrivelsen. Legg den i <meta name="description">. Feltet er alltid med, men verdien er null på artikler som ikke har fått en meta-beskrivelse — fall tilbake på ingress. |
oppdatering | boolsk | nei | true når dette er en omskriving av en artikkel du har fra før. |
url | tekst | ja | null ved ny artikkel. Ved oppdatering: URL-en artikkelen ligger på hos deg — nøkkelen du skal matche på. |
nettsted | objekt | nei | Nettstedet artikkelen hører til. Se under. |
artikkel | objekt | nei | Hele artikkelen med alle detaljer. Se under. |
nettsted#
| Felt | Type | Kan være null | Hva det er |
|---|---|---|---|
nettsted.id | tall | nei | Vår ID for nettstedet. Stabil. |
nettsted.url | tekst | ja | Nettstedet slik det er registrert hos oss. Er feltet tomt hos oss, sender vi null — vi dikter ikke opp en adresse. |
nettsted.merkenavn | tekst | ja | Bedrifts-/merkenavnet. null hvis det ikke er satt. |
nettsted.sprak | tekst | nei | Språkkode, f.eks. nb-NO. Er ingenting satt på nettstedet, sender vi nb-NO. |
artikkel#
| Felt | Type | Kan være null | Hva det er |
|---|---|---|---|
artikkel.id | tall | nei | Vår ID for artikkelen. Stabil over tid — trygg å lagre som referanse. |
artikkel.tittel | tekst | nei | Samme som tittel. |
artikkel.slug | tekst | nei | URL-vennlig tittel. Vi bruker den også når vi må gjette URL-en din — se Svaret ditt. |
artikkel.ingress | tekst | nei | Samme som ingress. |
artikkel.html | tekst | nei | Samme som html. |
artikkel.tekst | tekst | nei | Samme innhold som råtekst, avsnitt skilt med blank linje. Praktisk hvis du renderer selv eller vil indeksere. |
artikkel.meta_beskrivelse | tekst | ja | Samme som meta, med samme forbehold: kan være null. |
artikkel.bilde_url | tekst | ja | Toppbilde. Ofte null — planlegg for det. |
artikkel.sokeord | tekst | ja | Søkeordet artikkelen er skrevet for. null hvis artikkelen ikke er knyttet til ett. |
artikkel.ord | tall | nei | Antall ord i brødteksten. |
artikkel.lesetid_min | tall | nei | Anslått lesetid i minutter: ord delt på 200, avrundet, minimum 1. |
artikkel.publisert_dato | tekst | nei | Publiseringsdato hos oss, ÅÅÅÅ-MM-DD. Ved første publisering er det dagen vi sender. |
artikkel.publisert_url | tekst | ja | null ved første publisering. Ved oppdatering: URL-en vi har lagret for artikkelen. |
artikkel.ekstern_id | tekst | ja | ID-en du returnerte forrige gang i {"id": "…"}. null hvis du aldri har returnert noen. |
Headere#
Alle forespørsler er POST, med Content-Type satt til application/json; charset=utf-8. I tillegg sender vi:
| Header | Eksempel | Betydning |
|---|---|---|
Oron-Hendelse | artikkel.publisert | Hendelsestype. |
Oron-Hendelse-Id | hnd_9f2c1a7b4e5d6081 | Stabil per hendelse — lik på alle forsøk. Grunnlaget for idempotens. |
Oron-Levering | 1 | Forsøksnummer, 1-basert. Går til 4. |
Oron-Versjon | 1 | Nyttelastversjon. Samme som versjon i kroppen. |
Oron-Signatur | t=1786276812,v1=5257a8… | Se Signatur. |
User-Agent | Mozilla/5.0 (compatible; OronBot/1.0; +https://oron.no) | Slipp den gjennom eventuell bot-filtrering. |
Headernavn er ikke versalfølsomme. Rammeverket ditt gir deg dem kanskje som oron-signatur eller HTTP_ORON_SIGNATUR — det er samme header.
Signatur#
Endepunktet ditt er åpent på internett. Signaturen er det som skiller oss fra hvem som helst som gjetter adressen din. Sjekk den: én HMAC-utregning og én sammenligning i konstant tid. Ferdig kode for sju språk står under Kodeeksempler.
Hver tilkobling har en hemmelighet på formen whsec_ + 32 heksadesimale tegn. Du finner den i dashbordet under Tilkoblinger, i kortet «Webhook / annet», feltet Signeringshemmelighet — med kopiknapp. Legg den i miljøvariabler, aldri i kildekoden.
Oron-Signatur: t=1786276812,v1=5257a8bd9c1f4e0a7b3d2c8e6f1a4b7c9d0e3f2a1b8c5d4e7f0a9b2c3d6e1f4a
t= unix-tid i sekunder da vi signerte.v1= HMAC-SHA256 som små bokstaver hex.- Flere
v-verdier kan komme senere, kommaseparert. Let etterv1=og ignorer nøkler du ikke kjenner.
Strengen som signeres#
Nøyaktig dette, uten mellomrom eller linjeskift lagt til:
signert_streng = t + "." + råkropp
t = "1786276812" tallet fra headeren, som tekst
"." = ett punktum
råkropp = kroppen byte for byte, akkurat slik den kom inn
(ikke parset, ikke reserialisert, ikke trimmet)
v1 = hex( HMAC-SHA256( nøkkel = hemmeligheten, melding = signert_streng ) )
Nøkkelen er hele hemmeligheten som tekst, inkludert whsec_-prefikset, i UTF-8-bytes. Ikke hex-dekod den, ikke fjern prefikset.
Slik verifiserer du#
- Les kroppen som rå bytes, før noen JSON-parser rører den.
- Les
Oron-Signatur. Splitt på komma, så på=. Henttogv1. - Sjekk at
tikke er eldre enn 5 minutter (anbefalt vindu). Avvis hvis den er det — det stopper replay-angrep. - Regn ut
HMAC-SHA256overt + "." + råkroppmed hemmeligheten som nøkkel. Kod som små bokstaver hex. - Sammenlign med
v1i konstant tid —hmac.compare_digest,crypto.timingSafeEqual,hash_equals. Aldri==. - Stemmer det ikke: svar
401og logg det — det er også det kodeeksemplene under gjør. Hvilken 4xx-kode du velger betyr ingenting for oss; vi prøver uansett ikke på nytt på 4xx (unntatt 429). Svar aldri5xxpå en ugyldig signatur, for da sender vi den samme forfalskningen tre ganger til.
Å signere en reserialisert JSON. JSON.stringify(req.body) gir deg en annen bytestrøm enn den vi sendte — andre mellomrom, annen feltrekkefølge, escapet æøå. Signaturen vil aldri stemme. Du må ta vare på råkroppen før body-parseren kjører.
Sjekker du ikke signaturen i det hele tatt, virker integrasjonen fortsatt. Vi sender headeren uansett. Men da kan hvem som helst som kjenner URL-en din publisere på nettstedet ditt.
Svaret ditt#
Alt i 2xx er suksess. Kroppen kan være tom. Alt annet regnes som feil.
Returnerer du JSON med feltet url, lagrer vi den som artikkelens publiserte adresse. Gjør du ikke det, må vi gjette: vi setter sammen nettstedets URL og artikkel.slug. Ligger artikkelen din på /blogg/… eller /artikler/…, blir gjetningen feil — og rapportene og rangeringsmålingen din peker på en side som ikke finnes.
{"url": "https://www.proclinic.no/blogg/slik-velger-du-bookingsystem", "id": "post_8812"}
| Felt | Påkrevd | Effekt |
|---|---|---|
url | valgfritt | Lagres som artikkelens publisert_url i stedet for den gjettede. Må starte med http:// eller https://, maks 500 tegn. |
id | valgfritt | Lagres som ekstern_id og sendes tilbake til deg ved oppdateringer. Maks 100 tegn. Bruk innleggs-ID-en din. |
Er kroppen tom eller ikke JSON, skjer det ingenting galt — vi beholder da den gjettede URL-en. Ukjente felt i svaret ignoreres.
Svar raskt#
Vi venter i 30 sekunder. Skal du generere bilder, varme cache eller kalle tredjeparter: verifiser signaturen, legg jobben i kø, svar 200 med én gang. Bruker du lengre tid enn 30 sekunder, ser vi det som timeout og prøver på nytt — og da kan du få den samme artikkelen to ganger.
Nye forsøk#
Fire forsøk. Ikke flere.
Oron-Levering | Pause før forsøket | Samlet pause |
|---|---|---|
1 | ingen — sendes med én gang | 0 s |
2 | 2 sekunder | 2 s |
3 | 10 sekunder | 12 s |
4 | 30 sekunder | 42 s |
Slår alle fire feil, legger bakgrunnsjobben artikkelen i kø og prøver igjen senere samme dag — med samme hendelse_id. Derfor er det verdt å lagre den id-en: gjør du det, får du aldri det samme innholdet to ganger, uansett hvor mange ganger vi prøver. Hver levering står i loggen i dashbordet med statuskoden din.
Når vi prøver på nytt — og når vi ikke gjør det#
| Vi får | Nytt forsøk? | Hvorfor |
|---|---|---|
| Nettverksfeil (DNS, TLS, tilkobling brutt) | ja | Kan være forbigående. |
| Timeout etter 30 sekunder | ja | Kan være forbigående. |
| HTTP 5xx | ja | Serverfeil hos deg. Kan gå over. |
| HTTP 429 | ja | Du ratebegrenset oss. |
| HTTP 4xx (unntatt 429) | nei | 400, 401, 403, 404, 405, 422 … En gjentakelse gir samme svar. Fiks mottakeren, så virker neste artikkel. |
| HTTP 3xx | nei | Vi følger bare videresendinger til offentlige https-adresser, og da sender vi den samme signerte kroppen på nytt som POST. Peker videresendingen et annet sted, stopper vi. Enkleste løsning: oppgi den endelige adressen med én gang. |
| HTTP 2xx | — | Ferdig. |
Pausene mellom forsøkene summerer seg til 42 sekunder. Bruker hvert forsøk hele tidsavbruddet på 30 sekunder, tar hele sekvensen inntil rundt tre minutter. Alt skjer i bakgrunnsarbeideren vår, så ingen står og venter på svaret ditt.
Idempotens#
Oron-Hendelse-Id (og hendelse_id i kroppen) er identisk på alle fire forsøkene. Det kan bety at du allerede har lagret artikkelen, men at svaret ditt ikke rakk fram til oss.
Så: lagre hendelse_id med en unik indeks. Har du sett den før, svar 200 og gjør ingenting mer. Da får du aldri dobbelt innhold, uansett hva nettverket finner på.
artikkel.oppdatert#
Når vi skriver om en artikkel du allerede har, sender vi samme konvolutt med tre forskjeller:
"hendelse": "artikkel.oppdatert"og"oppdatering": true"url"på toppnivå er satt til adressen vi har lagret for artikkelen — nøkkelen du skal matche påartikkel.ekstern_ider ID-en du returnerte forrige gang, hvis du ga oss en
{
"versjon": 1,
"hendelse": "artikkel.oppdatert",
"hendelse_id": "hnd_2b71e0c4d9a35f18",
"sendt": "2026-09-02T05:00:07Z",
"tittel": "Slik velger du bookingsystem til klinikken",
"ingress": "Feil system koster deg timer hver uke. Slik finner du riktig.",
"html": "<h2>Hvorfor bookingsystem</h2><p>…</p>",
"meta": "Guide til bookingsystem for klinikker: …",
"oppdatering": true,
"url": "https://www.proclinic.no/blogg/slik-velger-du-bookingsystem",
"artikkel": {
"id": 4711,
"publisert_url": "https://www.proclinic.no/blogg/slik-velger-du-bookingsystem",
"ekstern_id": "post_8812"
}
}
Resten av feltene er de samme som ved publisering — hele nettsted-objektet og hele artikkel-objektet følger med, med nytt innhold.
Slik finner du riktig post#
- Har du lagret
artikkel.id? Bruk den. Den er stabil og enklest. - Ellers: slå opp på
ekstern_id— det er din egen ID, som du ga oss sist. - Ellers: slå opp på
url.
Oppdater innholdet på den eksisterende posten. Ikke lag en ny. To sider med samme innhold konkurrerer med hverandre i Google, og da jobber vi mot hverandre.
Svarer du med {"url": "…"} også her, oppdaterer vi adressen vår — nyttig hvis du har flyttet artikkelen.
Kom i gang#
- Logg inn på dashbordet og gå til Tilkoblinger → kortet «Webhook / annet».
- Lim inn
https://-adressen til endepunktet ditt. Bare https, og adressen må være nåbar fra internett. - Kopier hemmeligheten og legg den i miljøvariablene dine.
- Trykk «Send testhendelse». Vi sender en ekte, signert
test.hendelsetil adressen din med én gang — og viser deg statuskoden, svartiden og de første tegnene av svaret ditt.
Du ser også de ti siste leveringene til nettstedet ditt i dashbordet, med statuskode og feilmelding. Trenger du å bytte hemmelighet, roterer du den samme sted — den gamle slutter å virke med én gang, så bytt i mottakeren din samtidig.
Hva testhendelsen inneholder#
Nøyaktig samme struktur som en ekte publisering, med oppdiktet innhold. Signaturen er ekte, så den er perfekt til å teste verifiseringen din. Teksten er med vilje gjenkjennelig falsk: publiserer noen den blindt, ser du det med én gang. nettsted er ditt ekte nettsted, og artikkel.id er 0 — det er ingen ekte artikkel bak.
{
"versjon": 1,
"hendelse": "test.hendelse",
"hendelse_id": "hnd_c0ffee1234567890",
"sendt": "2026-08-09T12:00:00Z",
"tittel": "Testhendelse fra Oron — slik ser en artikkel ut",
"ingress": "Dette er en testhendelse. Ingen ekte artikkel ligger bak den, og den skal ikke publiseres.",
"html": "<h2>Hva er dette</h2><p>Oron sender denne hendelsen når du …</p>",
"meta": "Testhendelse fra Oron. Ikke en ekte artikkel — skal ikke publiseres.",
"oppdatering": false,
"url": null,
"nettsted": { "id": 12, "url": "https://www.proclinic.no", "merkenavn": "ProClinic", "sprak": "nb-NO" },
"artikkel": {
"id": 0,
"tittel": "Testhendelse fra Oron — slik ser en artikkel ut",
"slug": "testhendelse-fra-oron--slik-ser-en-artikkel-ut",
"sokeord": "testhendelse",
"ord": 91,
"lesetid_min": 1,
"bilde_url": null,
"publisert_url": null,
"ekstern_id": null
}
}
Testhendelsen sendes med ett forsøk — du står og venter på svaret, så vi prøver ikke på nytt her. Knappen tåler seks trykk i minuttet; utover det svarer vi 429 til deg i dashbordet.
Teste lokalt#
Vi kan ikke nå localhost — vi POSTer bare til offentlige https-adresser. To måter å jobbe lokalt på:
1. Legg en tunnel foran maskinen din og lim tunnel-URL-en inn i dashbordet mens du utvikler. Da får du ekte signaturer fra oss.
2. Lag forespørselen selv. Denne gir deg en gyldig signatur uten å involvere oss i det hele tatt:
HEM='whsec_din_hemmelighet_her'
KROPP='{"versjon":1,"hendelse":"test.hendelse","hendelse_id":"hnd_lokal_1","tittel":"Test","ingress":"Test","html":"<p>Hei</p>","meta":"Test","oppdatering":false,"url":null}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$KROPP" | openssl dgst -sha256 -hmac "$HEM" -r | cut -d' ' -f1)
curl -i -X POST http://localhost:3000/oron-webhook \
-H 'Content-Type: application/json; charset=utf-8' \
-H 'Oron-Hendelse: test.hendelse' \
-H 'Oron-Hendelse-Id: hnd_lokal_1' \
-H 'Oron-Levering: 1' \
-H 'Oron-Versjon: 1' \
-H "Oron-Signatur: t=$T,v1=$SIG" \
--data-binary "$KROPP"
--data-binary er viktig: -d fjerner linjeskift og kan endre bytene, og da stemmer ikke signaturen. printf '%s' brukes for å unngå at det legges på et linjeskift på slutten.
Kodeeksempler#
Komplette mottakere som verifiserer signaturen, håndterer alle tre hendelsestypene og svarer med den ekte URL-en. Kopier, bytt ut hemmeligheten og lagringen, og du er ferdig.
node-express.js
/**
* Oron webhook-mottaker — Node.js + Express
* =========================================
* Kontrakt: Oron Webhook v1.
*
* Kjør:
* npm install express
* ORON_WEBHOOK_SECRET=whsec_... node node-express.js
*
* Hemmeligheten finner du i Oron-dashbordet (kopiknapp). Legg den ALDRI i koden.
*/
const express = require('express');
const crypto = require('crypto');
const app = express();
const PORT = process.env.PORT || 8801;
// Hemmeligheten leses fra miljøet — aldri en streng i kildekoden, aldri i git.
const SECRET = process.env.ORON_WEBHOOK_SECRET;
if (!SECRET) {
console.error('Mangler ORON_WEBHOOK_SECRET i miljøet.');
process.exit(1);
}
// Tidsvindu for replay-beskyttelse: 5 minutter (anbefalt i kontrakten §3).
const TOLERANSE_SEKUNDER = 300;
/* ------------------------------------------------------------------ *
* RÅ KROPP — dette er felle nr. 1 med Express
* ------------------------------------------------------------------ *
* express.json() leser strømmen, parser JSON og KASTER de opprinnelige
* bytene. Signaturen i §3 er regnet ut over kroppen nøyaktig slik den ble
* sendt over nettet. Hvis du re-serialiserer med JSON.stringify(req.body)
* får du en annen bytestrøm — nøkkelrekkefølge, mellomrom og ikke minst
* æ/ø/å og emoji (Oron sender ensure_ascii=False, altså rå UTF-8, mens
* JSON.stringify i noen oppsett gir \u-escaping) — og HMAC-en vil aldri
* stemme. Derfor: express.raw() på DENNE ruten, og vi parser JSON selv
* FØRST etter at signaturen er godkjent.
*
* type: '*/*' fordi vi vil ha bytene uansett hva Content-Type sier.
* Oron sender «application/json; charset=utf-8».
*
* NB: Har du express.json() globalt (app.use(express.json())) må webhook-
* ruten registreres FØR den — ellers har den globale parseren allerede
* spist strømmen. Alternativet er å ta vare på bytene i verify-callbacken:
* app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
*/
const raaKropp = express.raw({ type: '*/*', limit: '5mb' });
/**
* Konstant-tids sammenligning av to hex-signaturer.
*
* Hvorfor ikke bare `a === b`? Fordi `===` (og `==`, og memcmp) stopper ved
* første byte som er ulik. Tiden svaret bruker lekker dermed HVOR MANGE
* tegn av signaturen som var riktige. En angriper som kan sende mange
* forsøk kan gjette signaturen ett tegn om gangen og måle responstiden —
* 64 hex-tegn faller da på ~64*16 forsøk i stedet for 16^64. Det er en ekte
* sårbarhet, ikke teori. crypto.timingSafeEqual() bruker like lang tid
* uansett hvor de to bufferne skiller seg.
*
* timingSafeEqual() kaster hvis bufferne har ulik lengde, så lengden må
* sjekkes først. Det er trygt: lengden på en hex-signatur er offentlig
* kjent (alltid 64 tegn), den er ingen hemmelighet.
*/
function likeSignaturer(a, b) {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
/**
* Verifiserer Oron-Signatur-headeren mot den rå kroppen.
* Returnerer { ok: true } eller { ok: false, grunn: '...' }.
*/
function verifiserSignatur(headerVerdi, raaBody) {
if (!headerVerdi) return { ok: false, grunn: 'mangler Oron-Signatur' };
// Formatet er «t=1786276812,v1=5257a8…». Flere v-nøkler kan komme senere;
// vi leter etter v1= og ignorerer alt vi ikke kjenner igjen (kontrakten §3).
let t = null;
let v1 = null;
for (const del of String(headerVerdi).split(',')) {
const i = del.indexOf('=');
if (i < 0) continue;
const nokkel = del.slice(0, i).trim();
const verdi = del.slice(i + 1).trim();
if (nokkel === 't') t = verdi;
else if (nokkel === 'v1') v1 = verdi;
}
if (!t || !v1) return { ok: false, grunn: 'ugyldig signaturformat' };
if (!/^\d+$/.test(t)) return { ok: false, grunn: 'ugyldig tidsstempel' };
// Regn ut vår egen HMAC over «t + "." + rå kropp». Merk: raaBody er en
// Buffer, og vi mater den inn som bytes — ingen omveg om en streng.
const hmac = crypto.createHmac('sha256', SECRET);
hmac.update(t, 'utf8');
hmac.update('.', 'utf8');
hmac.update(raaBody);
const forventet = hmac.digest('hex'); // lowercase hex, som i kontrakten
if (!likeSignaturer(forventet, v1.toLowerCase())) {
return { ok: false, grunn: 'signatur stemmer ikke' };
}
// Replay-beskyttelse. Vi sjekker tiden ETTER at HMAC-en er godkjent, slik at
// vi ikke lar en uautentisert header styre logikken vår. Et gammelt, korrekt
// signert kall er nettopp det et replay-angrep ser ut som: noen har fanget
// opp et ekte kall og sender det på nytt. 5 minutters vindu (§3).
const alder = Math.floor(Date.now() / 1000) - Number(t);
if (alder > TOLERANSE_SEKUNDER) return { ok: false, grunn: 'tidsstempel for gammelt' };
// Negativ alder = tidsstempel i framtiden. Litt klokkeavvik er normalt,
// mye er mistenkelig.
if (alder < -TOLERANSE_SEKUNDER) return { ok: false, grunn: 'tidsstempel i framtiden' };
return { ok: true };
}
/* ------------------------------------------------------------------ *
* DEMO-LAGER — bytt ut med din database
* ------------------------------------------------------------------ */
const innlegg = new Map(); // url -> { id, tittel, ingress, html, meta }
const behandlede = new Map(); // hendelse_id -> ferdig svarkropp
app.post('/oron-webhook', raaKropp, (req, res) => {
// req.body er her en Buffer med de rå bytene, ikke et objekt.
const raaBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
// 1) SIGNATUR FØRST. Vi rører ikke innholdet før avsenderen er bekreftet.
// 401 = ikke autentisert. 4xx betyr «ikke prøv igjen» (§5) — og det er
// riktig: et nytt forsøk med samme feil hemmelighet gir samme svar.
const sjekk = verifiserSignatur(req.get('Oron-Signatur'), raaBody);
if (!sjekk.ok) {
console.warn('Avvist webhook:', sjekk.grunn);
return res.status(401).json({ feil: 'ugyldig signatur' });
}
// 2) Nå — og først nå — kan vi tolke kroppen.
let data;
try {
data = JSON.parse(raaBody.toString('utf8'));
} catch (e) {
// 400: kroppen er ødelagt. Et nytt forsøk gir samme ødelagte kropp,
// så vi ber Oron la være (§5: ingen nye forsøk ved 4xx utenom 429).
return res.status(400).json({ feil: 'ugyldig JSON' });
}
const hendelse = data.hendelse;
// hendelse_id er lik på alle fire leveringsforsøk (§5).
const hendelseId = data.hendelse_id || req.get('Oron-Hendelse-Id');
// 3) IDEMPOTENS.
// Oron prøver opptil 4 ganger (straks, +2s, +10s, +30s) ved 5xx, 429,
// timeout og nettverksfeil. Får vi artikkelen publisert men rekker ikke
// å svare før timeouten, kommer NØYAKTIG samme hendelse_id igjen.
// Uten dedupe får kunden fire like innlegg.
//
// >>> HER skal du gjøre dette i databasen din, ikke i minnet: <<<
// Lag en tabell `oron_hendelser(hendelse_id TEXT PRIMARY KEY,
// svar TEXT, ts TIMESTAMP)` og gjør et INSERT med UNIQUE-constraint
// på hendelse_id i SAMME transaksjon som du lagrer innlegget.
// Slår constraint-en inn (duplikat) → hent det lagrede svaret og
// returner 200 med det. Da er dedupe atomisk også når to forsøk
// kommer samtidig — et Map i minnet overlever verken en omstart
// eller to prosesser bak en lastbalanserer.
if (hendelseId && behandlede.has(hendelseId)) {
// Vi returnerer det SAMME svaret som første gang, slik at Oron får den
// ekte URL-en også når det var svaret som gikk tapt.
return res.status(200).json(behandlede.get(hendelseId));
}
// 4) test.hendelse: kunden trykket «Send testhendelse» i dashbordet.
// Ekte signatur, oppdiktet artikkel. Skal ALDRI publiseres (§1).
// Vi svarer 200 slik at testen i dashbordet blir grønn.
if (hendelse === 'test.hendelse') {
return res.status(200).json({ ok: true, melding: 'testhendelse mottatt, ikke publisert' });
}
try {
let svar;
if (hendelse === 'artikkel.publisert') {
// NY artikkel.
const slug = (data.artikkel && data.artikkel.slug) || lagSlug(data.tittel);
const url = `${nettstedBasis(data)}/blogg/${slug}`;
const postId = `post_${innlegg.size + 1}`;
innlegg.set(url, {
id: postId,
tittel: data.tittel,
ingress: data.ingress,
html: data.html, // ferdig HTML fra Oron
meta: data.meta, // meta description
});
// §4: returner den EKTE URL-en. Uten dette gjetter Oron
// «nettsted/slug», og rapporter + rangeringsmåling peker på en URL
// som kanskje ikke finnes. `id` lagres som ekstern_id og kommer
// tilbake ved senere oppdateringer.
svar = { url, id: postId };
} else if (hendelse === 'artikkel.oppdatert') {
// OPPDATERING: skriv om det som allerede finnes — ikke lag et nytt.
// Nøkkelen er toppnivå-feltet `url` (§2).
const url = data.url;
if (!url) {
return res.status(400).json({ feil: 'mangler url ved oppdatering' });
}
const fins = innlegg.get(url);
if (fins) {
fins.tittel = data.tittel;
fins.ingress = data.ingress;
fins.html = data.html;
fins.meta = data.meta;
svar = { url, id: fins.id };
} else {
// Kjenner vi ikke URL-en, oppretter vi heller enn å miste artikkelen.
const postId = `post_${innlegg.size + 1}`;
innlegg.set(url, {
id: postId, tittel: data.tittel, ingress: data.ingress,
html: data.html, meta: data.meta,
});
svar = { url, id: postId };
}
} else {
// Ukjent hendelsestype. 200 — ikke 4xx/5xx: Oron skal ikke prøve igjen
// for noe vi bevisst ignorerer, og nye typer kan komme senere.
return res.status(200).json({ ok: true, melding: 'ignorert hendelsestype' });
}
if (hendelseId) behandlede.set(hendelseId, svar);
// 5) Svar raskt med 2xx. Tung etterbehandling (bilder, cache-tømming,
// indeksering) legger du i en kø ETTER dette svaret — Oron har 30
// sekunders timeout, og et timeout koster deg tre ekstra leveringer.
return res.status(200).json(svar);
} catch (e) {
// 5xx = «prøv igjen». Dette er riktig ved midlertidige feil (database
// nede, disk full). Oron prøver på nytt etter 2s, 10s og 30s (§5).
console.error('Feil ved lagring:', e);
return res.status(500).json({ feil: 'intern feil, prøv igjen' });
}
});
function nettstedBasis(data) {
const u = (data.nettsted && data.nettsted.url) || 'https://example.no';
return u.replace(/\/+$/, '');
}
function lagSlug(tittel) {
return String(tittel || '')
.toLowerCase()
.replace(/æ/g, 'ae').replace(/ø/g, 'oe').replace(/å/g, 'aa')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'artikkel';
}
app.listen(PORT, () => console.log(`Oron webhook-mottaker lytter på :${PORT}`));
Kjørt som ekte HTTP-server mot sju signerte leveringer.
app/api/oron/route.ts
/**
* Oron webhook-mottaker — Next.js App Router
* ==========================================
* Kontrakt: Oron Webhook v1.
*
* Legg denne filen i: app/api/oron-webhook/route.ts
* Endepunktet blir da: https://ditt-domene.no/api/oron-webhook
*
* Miljøvariabel (.env.local, og i Vercel/hosting-panelet):
* ORON_WEBHOOK_SECRET=whsec_...
*/
import crypto from 'node:crypto';
// Webhooken må kjøre på Node-runtime: vi bruker node:crypto og trenger
// timingSafeEqual. Edge-runtime har ikke den (der måtte du brukt WebCrypto).
export const runtime = 'nodejs';
// Aldri cache et webhook-endepunkt. Uten dette kan Next.js i noen oppsett
// prøve å statisk analysere/optimalisere ruten.
export const dynamic = 'force-dynamic';
// Tidsvindu for replay-beskyttelse: 5 minutter (§3).
const TOLERANSE_SEKUNDER = 300;
type Verifisering = { ok: true } | { ok: false; grunn: string };
/**
* Konstant-tids sammenligning.
*
* `a === b` på en signatur er en ekte sårbarhet. Strengsammenligning
* avslutter ved første ulike tegn, så svartiden røper hvor langt inn i
* signaturen angriperen kom. Med nok forsøk kan signaturen gjettes tegn for
* tegn (~1000 forsøk i stedet for 16^64). timingSafeEqual bruker like lang
* tid uansett hvor bytene skiller seg.
*
* Lengden må sjekkes først fordi timingSafeEqual kaster ved ulik lengde.
* Det lekker ingenting — lengden på en hex-SHA256 er alltid 64 og offentlig.
*/
function likeSignaturer(a: string, b: string): boolean {
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
/** Verifiserer «Oron-Signatur» mot den rå kroppen (§3). */
function verifiserSignatur(
headerVerdi: string | null,
raaBody: string,
secret: string,
): Verifisering {
if (!headerVerdi) return { ok: false, grunn: 'mangler Oron-Signatur' };
// «t=1786276812,v1=5257a8…». Ukjente nøkler skal ignoreres — det kommer
// kanskje v2 senere, og da skal gammel kode fortsatt virke.
let t: string | null = null;
let v1: string | null = null;
for (const del of headerVerdi.split(',')) {
const i = del.indexOf('=');
if (i < 0) continue;
const nokkel = del.slice(0, i).trim();
const verdi = del.slice(i + 1).trim();
if (nokkel === 't') t = verdi;
else if (nokkel === 'v1') v1 = verdi;
}
if (!t || !v1) return { ok: false, grunn: 'ugyldig signaturformat' };
if (!/^\d+$/.test(t)) return { ok: false, grunn: 'ugyldig tidsstempel' };
// HMAC-SHA256 over «t + "." + rå kropp», lowercase hex.
const forventet = crypto
.createHmac('sha256', secret)
.update(`${t}.${raaBody}`, 'utf8')
.digest('hex');
if (!likeSignaturer(forventet, v1.toLowerCase())) {
return { ok: false, grunn: 'signatur stemmer ikke' };
}
// Replay-vindu sjekkes etter at HMAC-en er godkjent.
const alder = Math.floor(Date.now() / 1000) - Number(t);
if (alder > TOLERANSE_SEKUNDER) return { ok: false, grunn: 'tidsstempel for gammelt' };
if (alder < -TOLERANSE_SEKUNDER) return { ok: false, grunn: 'tidsstempel i framtiden' };
return { ok: true };
}
/* ------------------------------------------------------------------ *
* DEMO-LAGER — bytt ut med Prisma/Drizzle/Supabase/hva du nå bruker.
* Et modulnivå-Map overlever ikke en serverless-instans; det er kun
* for at eksempelet skal kunne kjøres som det er.
* ------------------------------------------------------------------ */
type Innlegg = { id: string; tittel: string; ingress: string; html: string; meta: string };
const innlegg = new Map<string, Innlegg>();
const behandlede = new Map<string, unknown>();
export async function POST(req: Request): Promise<Response> {
const secret = process.env.ORON_WEBHOOK_SECRET;
if (!secret) {
// Feilkonfigurasjon hos oss → 500 slik at Oron prøver igjen etter at
// du har satt variabelen (§5).
return Response.json({ feil: 'ORON_WEBHOOK_SECRET mangler' }, { status: 500 });
}
// RÅ KROPP. await req.text() gir kroppen nøyaktig slik den kom inn.
// Bruker du req.json() her, mister du bytene, og enhver reserialisering
// (nøkkelrekkefølge, mellomrom, \u-escaping av æøå og emoji) gir en annen
// bytestrøm enn den Oron signerte. HMAC-en vil da aldri stemme.
// Merk også: kroppen kan bare leses ÉN gang. Les den som tekst her, og
// parse med JSON.parse etterpå — ikke kall req.json() i tillegg.
const raaBody = await req.text();
// 1) SIGNATUR FØRST — før vi tolker en eneste byte av innholdet.
const sjekk = verifiserSignatur(req.headers.get('oron-signatur'), raaBody, secret);
if (!sjekk.ok) {
console.warn('Avvist Oron-webhook:', sjekk.grunn);
// 401 og ingen nye forsøk (§5: 4xx utenom 429 gjentas ikke).
return Response.json({ feil: 'ugyldig signatur' }, { status: 401 });
}
// 2) Nå kan vi trygt parse.
let data: any;
try {
data = JSON.parse(raaBody);
} catch {
return Response.json({ feil: 'ugyldig JSON' }, { status: 400 });
}
const hendelse: string = data.hendelse;
const hendelseId: string | undefined =
data.hendelse_id ?? req.headers.get('oron-hendelse-id') ?? undefined;
// 3) IDEMPOTENS på hendelse_id.
// Oron leverer inntil 4 ganger med SAMME hendelse_id (§5). Rekker du
// ikke å svare innen 30 s, kommer nøyaktig samme hendelse igjen selv om
// du allerede har lagret artikkelen.
//
// >>> ERSTATT dette Map-et med databasen din: <<<
// Prisma-eksempel — modell `OronHendelse { hendelseId String @id,
// svar Json, opprettet DateTime @default(now()) }`.
// Kjør create() på hendelseId inne i samme $transaction som
// opprettelsen av innlegget. Får du P2002 (unique constraint),
// er hendelsen allerede behandlet: hent raden og returner det
// lagrede svaret med 200. Da er dedupe atomisk selv når to
// serverless-instanser får hvert sitt forsøk samtidig.
if (hendelseId && behandlede.has(hendelseId)) {
// Samme svar som første gang — Oron skal få den ekte URL-en også når
// det bare var svaret vårt som gikk tapt.
return Response.json(behandlede.get(hendelseId), { status: 200 });
}
// 4) test.hendelse skal ALDRI publiseres (§1) — men skal kvitteres med 2xx
// slik at «Send testhendelse» i dashbordet blir grønn.
if (hendelse === 'test.hendelse') {
return Response.json(
{ ok: true, melding: 'testhendelse mottatt, ikke publisert' },
{ status: 200 },
);
}
try {
let svar: { url: string; id: string };
if (hendelse === 'artikkel.publisert') {
// NY artikkel.
const slug: string = data.artikkel?.slug || lagSlug(data.tittel);
const url = `${nettstedBasis(data)}/blogg/${slug}`;
const postId = `post_${innlegg.size + 1}`;
innlegg.set(url, {
id: postId,
tittel: data.tittel,
ingress: data.ingress,
html: data.html,
meta: data.meta,
});
// §4: den EKTE URL-en tilbake. Uten den lagrer Oron en gjettet
// «nettsted/slug» som kanskje ikke finnes, og rangeringsmålingen
// følger feil side. `id` kommer tilbake som ekstern_id ved oppdatering.
svar = { url, id: postId };
} else if (hendelse === 'artikkel.oppdatert') {
// OPPDATER på stedet. Nøkkelen er toppnivå-`url` (§2), ikke slug.
const url: string | null = data.url;
if (!url) return Response.json({ feil: 'mangler url ved oppdatering' }, { status: 400 });
const fins = innlegg.get(url);
if (fins) {
fins.tittel = data.tittel;
fins.ingress = data.ingress;
fins.html = data.html;
fins.meta = data.meta;
svar = { url, id: fins.id };
} else {
// Ukjent URL: opprett heller enn å miste artikkelen.
const postId = `post_${innlegg.size + 1}`;
innlegg.set(url, {
id: postId, tittel: data.tittel, ingress: data.ingress,
html: data.html, meta: data.meta,
});
svar = { url, id: postId };
}
} else {
// Ukjent type: 200, ikke feil. Nye hendelsestyper skal ikke føre til
// fire mislykkede leveringsforsøk hos Oron.
return Response.json({ ok: true, melding: 'ignorert hendelsestype' }, { status: 200 });
}
if (hendelseId) behandlede.set(hendelseId, svar);
// 5) Svar raskt. Alt tungt (bildeimport, revalidatePath, indeksering)
// legger du i en bakgrunnsjobb — Oron gir deg 30 sekunder.
return Response.json(svar, { status: 200 });
} catch (e) {
// 5xx → Oron prøver på nytt etter 2 s, 10 s og 30 s (§5).
console.error('Feil ved lagring:', e);
return Response.json({ feil: 'intern feil, prøv igjen' }, { status: 500 });
}
}
function nettstedBasis(data: any): string {
const u: string = data?.nettsted?.url || 'https://example.no';
return u.replace(/\/+$/, '');
}
function lagSlug(tittel: string): string {
return String(tittel || '')
.toLowerCase()
.replace(/æ/g, 'ae').replace(/ø/g, 'oe').replace(/å/g, 'aa')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'artikkel';
}
POST-funksjonen kjørt uendret mot et ekte Request-objekt. tsc --noEmit --strict: 0 feil.
oron-webhook.php
<?php
/**
* Oron webhook-mottaker — ren PHP, uten rammeverk
* ===============================================
* Kontrakt: Oron Webhook v1.
*
* Legg filen på f.eks. https://dittdomene.no/oron-webhook.php og lim inn
* den URL-en i Oron-dashbordet.
*
* Hemmeligheten settes som miljøvariabel — aldri i koden, aldri i git:
* Apache: SetEnv ORON_WEBHOOK_SECRET whsec_...
* nginx: fastcgi_param ORON_WEBHOOK_SECRET whsec_...;
* php-fpm: env[ORON_WEBHOOK_SECRET] = whsec_...
*
* Test lokalt: ORON_WEBHOOK_SECRET=whsec_... php -S 127.0.0.1:8803 php-plain.php
*/
declare(strict_types=1);
// Tidsvindu for replay-beskyttelse: 5 minutter (§3).
const TOLERANSE_SEKUNDER = 300;
/** Sender JSON-svar og avslutter. */
function svar(int $kode, array $data): never
{
http_response_code($kode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Henter en HTTP-header uavhengig av oppsett.
*
* PHP legger innkommende headere i $_SERVER med prefikset HTTP_, store
* bokstaver og bindestrek byttet ut med understrek: «Oron-Signatur» blir
* $_SERVER['HTTP_ORON_SIGNATUR']. getallheaders() finnes ikke overalt
* (den mangler på enkelte php-fpm-oppsett), derfor $_SERVER som hovedvei.
*/
function header_verdi(string $navn): ?string
{
$nokkel = 'HTTP_' . strtoupper(str_replace('-', '_', $navn));
if (isset($_SERVER[$nokkel])) {
return (string) $_SERVER[$nokkel];
}
if (function_exists('getallheaders')) {
foreach (getallheaders() as $k => $v) {
if (strcasecmp($k, $navn) === 0) {
return (string) $v;
}
}
}
return null;
}
/**
* Verifiserer «Oron-Signatur» mot den rå kroppen (§3).
* Returnerer null hvis alt er i orden, ellers en kort grunn.
*/
function verifiser_signatur(?string $header, string $raa_body, string $secret): ?string
{
if ($header === null || $header === '') {
return 'mangler Oron-Signatur';
}
// Formatet er «t=1786276812,v1=5257a8…». Ukjente nøkler skal ignoreres,
// for det kan komme flere v-verdier senere.
$t = null;
$v1 = null;
foreach (explode(',', $header) as $del) {
$pos = strpos($del, '=');
if ($pos === false) {
continue;
}
$nokkel = trim(substr($del, 0, $pos));
$verdi = trim(substr($del, $pos + 1));
if ($nokkel === 't') {
$t = $verdi;
} elseif ($nokkel === 'v1') {
$v1 = $verdi;
}
}
if ($t === null || $v1 === null) {
return 'ugyldig signaturformat';
}
if (preg_match('/^\d+$/', $t) !== 1) {
return 'ugyldig tidsstempel';
}
// HMAC-SHA256 over «t + "." + rå kropp». hash_hmac() gir lowercase hex.
$forventet = hash_hmac('sha256', $t . '.' . $raa_body, $secret);
/*
* KONSTANT TID — hash_equals(), ikke ===.
*
* === på strenger i PHP sammenligner byte for byte og stopper ved første
* forskjell. Svartiden røper dermed hvor mange tegn av signaturen som var
* riktige, og en angriper kan gjette signaturen tegn for tegn ved å måle
* responstiden. Det gjør 16^64 mulige signaturer om til ~1000 forsøk.
* Dette er en ekte sårbarhet, ikke en teoretisk. hash_equals() bruker
* like lang tid uansett hvor strengene skiller seg.
*
* (hash_equals() returnerer også false ved ulik lengde — helt trygt,
* lengden på en hex-SHA256 er alltid 64 og ingen hemmelighet.)
*/
if (!hash_equals($forventet, strtolower($v1))) {
return 'signatur stemmer ikke';
}
// Replay-beskyttelse — sjekkes etter at HMAC-en er godkjent. Et gammelt,
// korrekt signert kall er nettopp det et replay-angrep ser ut som.
$alder = time() - (int) $t;
if ($alder > TOLERANSE_SEKUNDER) {
return 'tidsstempel for gammelt';
}
if ($alder < -TOLERANSE_SEKUNDER) {
return 'tidsstempel i framtiden';
}
return null;
}
/* ------------------------------------------------------------------ *
* DEMO-LAGER — en JSON-fil. BYTT UT med databasen din (PDO/MySQL).
* Filen brukes bare for at eksempelet skal kunne kjøres som det er.
* ------------------------------------------------------------------ */
function lager_fil(): string
{
return sys_get_temp_dir() . '/oron_php_lager.json';
}
function les_lager(): array
{
$fil = lager_fil();
if (!is_file($fil)) {
return ['innlegg' => [], 'behandlede' => []];
}
$data = json_decode((string) file_get_contents($fil), true);
return is_array($data) ? $data : ['innlegg' => [], 'behandlede' => []];
}
function skriv_lager(array $lager): void
{
file_put_contents(lager_fil(), json_encode($lager, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
function nettsted_basis(array $data): string
{
$u = $data['nettsted']['url'] ?? 'https://example.no';
return rtrim((string) $u, '/');
}
function lag_slug(string $tittel): string
{
$s = mb_strtolower($tittel, 'UTF-8');
$s = strtr($s, ['æ' => 'ae', 'ø' => 'oe', 'å' => 'aa']);
$s = (string) preg_replace('/[^a-z0-9]+/u', '-', $s);
$s = trim($s, '-');
return $s === '' ? 'artikkel' : mb_substr($s, 0, 80);
}
/* ================================================================== *
* HÅNDTERING
* ================================================================== */
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
// 405 er 4xx → Oron prøver ikke igjen. Riktig: en GET blir aldri en POST.
svar(405, ['feil' => 'kun POST']);
}
$secret = getenv('ORON_WEBHOOK_SECRET') ?: ($_SERVER['ORON_WEBHOOK_SECRET'] ?? '');
if ($secret === '') {
// Feilkonfigurasjon hos oss → 5xx slik at Oron prøver igjen etterpå (§5).
svar(500, ['feil' => 'ORON_WEBHOOK_SECRET mangler']);
}
/*
* RÅ KROPP.
* php://input gir bytene nøyaktig slik de kom inn. Bruk ALDRI $_POST her:
* $_POST er kun utfylt for form-kodede kropper, og en reserialisering av
* JSON (nøkkelrekkefølge, mellomrom, escaping av æøå og emoji) gir en annen
* bytestrøm enn den Oron signerte — da vil HMAC-en aldri stemme.
* Merk: php://input kan bare leses én gang på enkelte oppsett, så vi lagrer
* den i en variabel med det samme.
*/
$raa_body = file_get_contents('php://input');
if ($raa_body === false) {
$raa_body = '';
}
// 1) SIGNATUR FØRST — før vi tolker en eneste byte av innholdet.
$grunn = verifiser_signatur(header_verdi('Oron-Signatur'), $raa_body, (string) $secret);
if ($grunn !== null) {
error_log('Avvist Oron-webhook: ' . $grunn);
// 401 = ikke autentisert, og 4xx betyr «ikke prøv igjen» (§5).
svar(401, ['feil' => 'ugyldig signatur']);
}
// 2) Nå kan vi trygt parse.
$data = json_decode($raa_body, true);
if (!is_array($data)) {
// 400: ødelagt kropp. Et nytt forsøk gir samme ødelagte kropp.
svar(400, ['feil' => 'ugyldig JSON']);
}
$hendelse = (string) ($data['hendelse'] ?? '');
$hendelse_id = (string) ($data['hendelse_id'] ?? header_verdi('Oron-Hendelse-Id') ?? '');
$lager = les_lager();
/*
* 3) IDEMPOTENS på hendelse_id.
* Oron leverer inntil 4 ganger (straks, +2 s, +10 s, +30 s) ved 5xx, 429,
* timeout og nettverksfeil — med SAMME hendelse_id (§5). Rekker du ikke å
* svare innen 30 sekunder, kommer nøyaktig samme hendelse igjen selv om
* artikkelen allerede ligger lagret. Uten dedupe får kunden fire like
* innlegg.
*
* >>> HER skal du bruke databasen, ikke JSON-filen under: <<<
* CREATE TABLE oron_hendelser (
* hendelse_id VARCHAR(64) PRIMARY KEY,
* svar TEXT,
* opprettet DATETIME DEFAULT CURRENT_TIMESTAMP
* );
* Kjør «INSERT INTO oron_hendelser (hendelse_id, svar) VALUES (?, ?)»
* i SAMME transaksjon som du lagrer innlegget. Får du duplikatfeil
* (SQLSTATE 23000), er hendelsen allerede behandlet: hent raden og
* returner det lagrede svaret med 200. Da er dedupe atomisk også når
* to forsøk treffer to php-fpm-prosesser samtidig.
*/
if ($hendelse_id !== '' && isset($lager['behandlede'][$hendelse_id])) {
// Samme svar som første gang — Oron skal få den ekte URL-en også når det
// bare var svaret vårt som gikk tapt.
svar(200, $lager['behandlede'][$hendelse_id]);
}
// 4) test.hendelse: kunden trykket «Send testhendelse». Ekte signatur,
// oppdiktet artikkel. Skal ALDRI publiseres (§1) — men kvitteres med 2xx.
if ($hendelse === 'test.hendelse') {
svar(200, ['ok' => true, 'melding' => 'testhendelse mottatt, ikke publisert']);
}
try {
if ($hendelse === 'artikkel.publisert') {
// NY artikkel.
$slug = (string) ($data['artikkel']['slug'] ?? lag_slug((string) ($data['tittel'] ?? '')));
$url = nettsted_basis($data) . '/blogg/' . $slug;
$post_id = 'post_' . (count($lager['innlegg']) + 1);
$lager['innlegg'][$url] = [
'id' => $post_id,
'tittel' => $data['tittel'] ?? '',
'ingress' => $data['ingress'] ?? '',
'html' => $data['html'] ?? '', // ferdig HTML fra Oron
'meta' => $data['meta'] ?? '',
];
// §4: returner den EKTE URL-en. Uten dette lagrer Oron en gjettet
// «nettsted/slug» som kanskje ikke finnes, og rapportene peker feil.
$ut = ['url' => $url, 'id' => $post_id];
} elseif ($hendelse === 'artikkel.oppdatert') {
// OPPDATER på stedet — ikke lag et nytt innlegg.
// Nøkkelen er toppnivå-feltet «url» (§2).
$url = $data['url'] ?? null;
if (!is_string($url) || $url === '') {
svar(400, ['feil' => 'mangler url ved oppdatering']);
}
if (isset($lager['innlegg'][$url])) {
$post_id = $lager['innlegg'][$url]['id'];
$lager['innlegg'][$url] = [
'id' => $post_id,
'tittel' => $data['tittel'] ?? '',
'ingress' => $data['ingress'] ?? '',
'html' => $data['html'] ?? '',
'meta' => $data['meta'] ?? '',
];
} else {
// Ukjent URL: opprett heller enn å miste artikkelen.
$post_id = 'post_' . (count($lager['innlegg']) + 1);
$lager['innlegg'][$url] = [
'id' => $post_id,
'tittel' => $data['tittel'] ?? '',
'ingress' => $data['ingress'] ?? '',
'html' => $data['html'] ?? '',
'meta' => $data['meta'] ?? '',
];
}
$ut = ['url' => $url, 'id' => $post_id];
} else {
// Ukjent hendelsestype → 200, ikke feil. Nye typer skal ikke føre til
// fire mislykkede leveringsforsøk hos Oron.
svar(200, ['ok' => true, 'melding' => 'ignorert hendelsestype']);
}
if ($hendelse_id !== '') {
$lager['behandlede'][$hendelse_id] = $ut;
}
skriv_lager($lager);
// 5) Svar raskt med 2xx. Tung etterbehandling (bildeimport, cache-tømming,
// sitemap) legger du i en kø ETTER svaret — Oron har 30 s timeout, og
// et timeout koster deg tre ekstra leveringer.
svar(200, $ut);
} catch (Throwable $e) {
// 5xx → Oron prøver igjen etter 2 s, 10 s og 30 s (§5). Riktig ved
// midlertidige feil som database nede eller full disk.
error_log('Feil ved lagring: ' . $e->getMessage());
svar(500, ['feil' => 'intern feil, prøv igjen']);
}
Kjørt som ekte HTTP-server mot sju signerte leveringer.
app/Http/Controllers/OronWebhookController.php
<?php
/**
* Oron webhook-mottaker — Laravel
* ===============================
* Kontrakt: Oron Webhook v1.
*
* Filplassering: app/Http/Controllers/OronWebhookController.php
*
* ---------------------------------------------------------------------------
* RUTE — legg denne i routes/web.php (eller routes/api.php):
*
* use App\Http\Controllers\OronWebhookController;
* Route::post('/oron-webhook', OronWebhookController::class)
* ->name('oron.webhook');
*
* VIKTIG — CSRF:
* Oron sender ingen CSRF-token. Ligger ruten i routes/web.php MÅ den unntas,
* ellers svarer Laravel 419 og Oron prøver ikke engang igjen (419 er 4xx).
*
* Laravel 11/12 — bootstrap/app.php:
* ->withMiddleware(function (Middleware $middleware) {
* $middleware->validateCsrfTokens(except: ['oron-webhook']);
* })
*
* Laravel 10 og eldre — app/Http/Middleware/VerifyCsrfToken.php:
* protected $except = ['oron-webhook'];
*
* Legger du ruten i routes/api.php er CSRF allerede av — men husk at
* prefikset blir /api/oron-webhook.
* ---------------------------------------------------------------------------
*
* MILJØ — .env (aldri i koden, aldri i git):
* ORON_WEBHOOK_SECRET=whsec_...
*
* Legg gjerne til i config/services.php:
* 'oron' => ['webhook_secret' => env('ORON_WEBHOOK_SECRET')],
* slik at `php artisan config:cache` fungerer — env() returnerer null når
* konfigurasjonen er cachet, config() gjør ikke det. Vi leser via config()
* med env() som reserve under.
*/
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Throwable;
class OronWebhookController extends Controller
{
/** Tidsvindu for replay-beskyttelse: 5 minutter (§3). */
private const TOLERANSE_SEKUNDER = 300;
public function __invoke(Request $request): JsonResponse
{
$secret = (string) (config('services.oron.webhook_secret') ?? env('ORON_WEBHOOK_SECRET', ''));
if ($secret === '') {
// Feilkonfigurasjon hos oss → 5xx, slik at Oron prøver igjen
// etter at variabelen er satt (§5).
return response()->json(['feil' => 'ORON_WEBHOOK_SECRET mangler'], 500);
}
/*
* RÅ KROPP.
* $request->getContent() gir bytene nøyaktig slik de kom inn.
* Bruk ALDRI $request->all() / ->input() / ->json() til signaturen:
* de har allerede parset JSON-en, og en reserialisering gir en annen
* bytestrøm (nøkkelrekkefølge, mellomrom, escaping av æøå og emoji)
* enn den Oron signerte — HMAC-en vil da aldri stemme.
*/
$raaBody = $request->getContent();
// 1) SIGNATUR FØRST — før vi tolker en eneste byte av innholdet.
$grunn = $this->verifiserSignatur($request->header('Oron-Signatur'), $raaBody, $secret);
if ($grunn !== null) {
Log::warning('Avvist Oron-webhook', ['grunn' => $grunn]);
// 401 = ikke autentisert. 4xx betyr «ikke prøv igjen» (§5), og det
// er riktig: nytt forsøk med feil hemmelighet gir samme svar.
return response()->json(['feil' => 'ugyldig signatur'], 401);
}
// 2) Nå — og først nå — kan vi tolke kroppen.
$data = json_decode($raaBody, true);
if (!is_array($data)) {
// 400: ødelagt kropp, et nytt forsøk gir samme ødelagte kropp.
return response()->json(['feil' => 'ugyldig JSON'], 400);
}
$hendelse = (string) ($data['hendelse'] ?? '');
$hendelseId = (string) ($data['hendelse_id'] ?? $request->header('Oron-Hendelse-Id') ?? '');
$lager = $this->lesLager();
/*
* 3) IDEMPOTENS på hendelse_id.
* Oron leverer inntil 4 ganger (straks, +2 s, +10 s, +30 s) ved 5xx,
* 429, timeout og nettverksfeil — med SAMME hendelse_id (§5).
* Rekker du ikke å svare innen 30 sekunder, kommer nøyaktig samme
* hendelse igjen selv om artikkelen allerede er lagret.
*
* >>> HER legger DU inn dedupe i databasen: <<<
* Migrering:
* Schema::create('oron_hendelser', function (Blueprint $t) {
* $t->string('hendelse_id')->primary();
* $t->json('svar');
* $t->timestamps();
* });
* Og i koden, i stedet for demo-lageret under:
* return DB::transaction(function () use ($data, $hendelseId) {
* try {
* OronHendelse::create(['hendelse_id' => $hendelseId, 'svar' => []]);
* } catch (QueryException $e) {
* // 23000 = unique violation → allerede behandlet
* $rad = OronHendelse::find($hendelseId);
* return response()->json($rad->svar, 200);
* }
* ... lagre innlegget, oppdater raden med svaret ...
* });
* Poenget er at INSERT-en på hendelse_id skjer i SAMME
* transaksjon som innlegget lagres. Da er dedupe atomisk også
* når to køarbeidere får hvert sitt forsøk samtidig.
*/
if ($hendelseId !== '' && isset($lager['behandlede'][$hendelseId])) {
// Samme svar som første gang — Oron skal få den ekte URL-en også
// når det bare var svaret vårt som gikk tapt.
return response()->json($lager['behandlede'][$hendelseId], 200);
}
// 4) test.hendelse: kunden trykket «Send testhendelse» i dashbordet.
// Ekte signatur, oppdiktet artikkel. Skal ALDRI publiseres (§1),
// men skal kvitteres med 2xx så testen blir grønn.
if ($hendelse === 'test.hendelse') {
return response()->json(
['ok' => true, 'melding' => 'testhendelse mottatt, ikke publisert'],
200
);
}
try {
if ($hendelse === 'artikkel.publisert') {
// NY artikkel.
// Ekte Laravel: $post = Post::create([...]);
// $url = route('blogg.vis', $post->slug);
$slug = (string) ($data['artikkel']['slug'] ?? $this->lagSlug((string) ($data['tittel'] ?? '')));
$url = $this->nettstedBasis($data) . '/blogg/' . $slug;
$postId = 'post_' . (count($lager['innlegg']) + 1);
$lager['innlegg'][$url] = [
'id' => $postId,
'tittel' => $data['tittel'] ?? '',
'ingress' => $data['ingress'] ?? '',
'html' => $data['html'] ?? '',
'meta' => $data['meta'] ?? '',
];
// §4: den EKTE URL-en tilbake. Uten den lagrer Oron en gjettet
// «nettsted/slug» som kanskje ikke finnes, og rangerings-
// målingen følger feil side. `id` kommer tilbake som
// ekstern_id ved senere oppdateringer.
$ut = ['url' => $url, 'id' => $postId];
} elseif ($hendelse === 'artikkel.oppdatert') {
// OPPDATER på stedet — ikke lag et nytt innlegg.
// Nøkkelen er toppnivå-feltet «url» (§2).
// Ekte Laravel: Post::where('publisert_url', $url)->first()
// ?? Post::find($data['artikkel']['ekstern_id'])
$url = $data['url'] ?? null;
if (!is_string($url) || $url === '') {
return response()->json(['feil' => 'mangler url ved oppdatering'], 400);
}
if (isset($lager['innlegg'][$url])) {
$postId = (string) $lager['innlegg'][$url]['id'];
} else {
// Ukjent URL: opprett heller enn å miste artikkelen.
$postId = 'post_' . (count($lager['innlegg']) + 1);
}
$lager['innlegg'][$url] = [
'id' => $postId,
'tittel' => $data['tittel'] ?? '',
'ingress' => $data['ingress'] ?? '',
'html' => $data['html'] ?? '',
'meta' => $data['meta'] ?? '',
];
$ut = ['url' => $url, 'id' => $postId];
} else {
// Ukjent hendelsestype → 200, ikke feil. Nye typer skal ikke
// føre til fire mislykkede leveringsforsøk hos Oron.
return response()->json(['ok' => true, 'melding' => 'ignorert hendelsestype'], 200);
}
if ($hendelseId !== '') {
$lager['behandlede'][$hendelseId] = $ut;
}
$this->skrivLager($lager);
// 5) Svar raskt med 2xx. Tung etterbehandling (bildeimport,
// cache-tømming, sitemap) legger du i en kø:
// EtterbehandleArtikkel::dispatch($postId)->afterResponse();
// Oron har 30 sekunders timeout, og et timeout koster deg tre
// ekstra leveringer.
return response()->json($ut, 200);
} catch (Throwable $e) {
// 5xx → Oron prøver igjen etter 2 s, 10 s og 30 s (§5). Riktig ved
// midlertidige feil (database nede, disk full).
Log::error('Feil ved lagring av Oron-artikkel', ['feil' => $e->getMessage()]);
return response()->json(['feil' => 'intern feil, prøv igjen'], 500);
}
}
/**
* Verifiserer «Oron-Signatur» mot den rå kroppen (§3).
* Returnerer null når alt er i orden, ellers en kort grunn.
*/
private function verifiserSignatur(?string $header, string $raaBody, string $secret): ?string
{
if ($header === null || $header === '') {
return 'mangler Oron-Signatur';
}
// «t=1786276812,v1=5257a8…». Ukjente nøkler ignoreres — det kan komme
// flere v-verdier senere, og gammel kode skal fortsatt virke.
$t = null;
$v1 = null;
foreach (explode(',', $header) as $del) {
$pos = strpos($del, '=');
if ($pos === false) {
continue;
}
$nokkel = trim(substr($del, 0, $pos));
$verdi = trim(substr($del, $pos + 1));
if ($nokkel === 't') {
$t = $verdi;
} elseif ($nokkel === 'v1') {
$v1 = $verdi;
}
}
if ($t === null || $v1 === null) {
return 'ugyldig signaturformat';
}
if (preg_match('/^\d+$/', $t) !== 1) {
return 'ugyldig tidsstempel';
}
// HMAC-SHA256 over «t + "." + rå kropp». hash_hmac() gir lowercase hex.
$forventet = hash_hmac('sha256', $t . '.' . $raaBody, $secret);
/*
* KONSTANT TID — hash_equals(), aldri === eller ==.
*
* En vanlig strengsammenligning stopper ved første byte som er ulik.
* Svartiden røper dermed hvor mange tegn av signaturen angriperen
* traff, og signaturen kan gjettes tegn for tegn ved å måle tiden —
* ~1000 forsøk i stedet for 16^64. Det er en ekte sårbarhet.
* hash_equals() bruker like lang tid uansett.
*/
if (!hash_equals($forventet, strtolower($v1))) {
return 'signatur stemmer ikke';
}
// Replay-beskyttelse, sjekket etter at HMAC-en er godkjent. Et gammelt,
// korrekt signert kall er nettopp det et replay-angrep ser ut som.
$alder = time() - (int) $t;
if ($alder > self::TOLERANSE_SEKUNDER) {
return 'tidsstempel for gammelt';
}
if ($alder < -self::TOLERANSE_SEKUNDER) {
return 'tidsstempel i framtiden';
}
return null;
}
/* -------------------------------------------------------------- *
* DEMO-LAGER — en JSON-fil i storage. BYTT UT med Eloquent.
* Ligger her bare for at eksempelet skal kunne kjøres som det er.
* -------------------------------------------------------------- */
private function lagerFil(): string
{
return sys_get_temp_dir() . '/oron_laravel_lager.json';
}
private function lesLager(): array
{
$fil = $this->lagerFil();
if (!is_file($fil)) {
return ['innlegg' => [], 'behandlede' => []];
}
$data = json_decode((string) file_get_contents($fil), true);
return is_array($data) ? $data : ['innlegg' => [], 'behandlede' => []];
}
private function skrivLager(array $lager): void
{
file_put_contents($this->lagerFil(), json_encode($lager, JSON_UNESCAPED_UNICODE), LOCK_EX);
}
private function nettstedBasis(array $data): string
{
$u = $data['nettsted']['url'] ?? 'https://example.no';
return rtrim((string) $u, '/');
}
private function lagSlug(string $tittel): string
{
// Ekte Laravel: Str::slug($tittel, '-', 'nb') — men den krever
// intl/transliterator for æøå, så vi gjør det eksplisitt her.
$s = mb_strtolower($tittel, 'UTF-8');
$s = strtr($s, ['æ' => 'ae', 'ø' => 'oe', 'å' => 'aa']);
$s = (string) preg_replace('/[^a-z0-9]+/u', '-', $s);
$s = trim($s, '-');
return $s === '' ? 'artikkel' : mb_substr($s, 0, 80);
}
}
Kjørt som fil, mot et minimalt Illuminate-stillas. Laravels egen HTTP-stakk og CSRF-laget er ikke testet — husk å unnta ruten fra CSRF.
oron_webhook.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Oron webhook-mottaker — Python + Flask
======================================
Kontrakt: Oron Webhook v1.
Kjør:
pip install flask
ORON_WEBHOOK_SECRET=whsec_... python3 python-flask.py
Bak nginx/gunicorn i produksjon:
ORON_WEBHOOK_SECRET=whsec_... gunicorn -w 2 -b 127.0.0.1:8804 python-flask:app
Hemmeligheten finner du i Oron-dashbordet (kopiknapp). Aldri i koden, aldri i git.
"""
import hashlib
import hmac
import json
import os
import re
import sys
import time
import unicodedata
from flask import Flask, request, jsonify
app = Flask(__name__)
# Hemmeligheten leses fra miljøet.
SECRET = os.environ.get("ORON_WEBHOOK_SECRET", "")
if not SECRET:
print("Mangler ORON_WEBHOOK_SECRET i miljøet.", file=sys.stderr)
sys.exit(1)
# Tidsvindu for replay-beskyttelse: 5 minutter (§3).
TOLERANSE_SEKUNDER = 300
# ------------------------------------------------------------------ #
# DEMO-LAGER — bytt ut med databasen din (SQLAlchemy/Django/psycopg).
# Dictene lever bare i minnet og forsvinner ved omstart; de er her for
# at eksempelet skal kunne kjøres som det er.
# ------------------------------------------------------------------ #
INNLEGG = {} # url -> dict
BEHANDLEDE = {} # hendelse_id -> ferdig svarkropp
def verifiser_signatur(header_verdi, raa_body):
"""
Verifiserer «Oron-Signatur» mot den rå kroppen (§3).
Returnerer None når alt er i orden, ellers en kort grunn.
"""
if not header_verdi:
return "mangler Oron-Signatur"
# Formatet er «t=1786276812,v1=5257a8…». Ukjente nøkler skal ignoreres —
# det kan komme flere v-verdier senere, og gammel kode skal fortsatt virke.
t = None
v1 = None
for del_ in header_verdi.split(","):
if "=" not in del_:
continue
nokkel, _, verdi = del_.partition("=")
nokkel = nokkel.strip()
verdi = verdi.strip()
if nokkel == "t":
t = verdi
elif nokkel == "v1":
v1 = verdi
if not t or not v1:
return "ugyldig signaturformat"
if not re.fullmatch(r"\d+", t):
return "ugyldig tidsstempel"
# HMAC-SHA256 over «t + "." + rå kropp». Vi bygger den signerte strengen
# som BYTES: tidsstempelet, et punktum, deretter kroppen nøyaktig slik den
# kom inn. hexdigest() gir lowercase hex, som kontrakten krever.
signert = t.encode("utf-8") + b"." + raa_body
forventet = hmac.new(SECRET.encode("utf-8"), signert, hashlib.sha256).hexdigest()
# KONSTANT TID — hmac.compare_digest(), aldri ==.
#
# `forventet == v1` sammenligner tegn for tegn og avslutter ved første
# forskjell. Tiden svaret bruker røper dermed hvor mange tegn av
# signaturen som var riktige, og en angriper som kan sende mange forsøk
# kan gjette signaturen ett tegn om gangen ved å måle responstiden —
# ~1000 forsøk i stedet for 16**64. Det er en ekte sårbarhet, ikke teori.
# compare_digest() bruker like lang tid uansett hvor strengene skiller
# seg, og returnerer også False ved ulik lengde (helt trygt: lengden på
# en hex-SHA256 er alltid 64 og er ingen hemmelighet).
if not hmac.compare_digest(forventet, v1.lower()):
return "signatur stemmer ikke"
# Replay-beskyttelse. Sjekkes ETTER at HMAC-en er godkjent, slik at vi
# ikke lar en uautentisert header styre logikken. Et gammelt, korrekt
# signert kall er nettopp det et replay-angrep ser ut som: noen har fanget
# opp et ekte kall og sender det på nytt.
alder = int(time.time()) - int(t)
if alder > TOLERANSE_SEKUNDER:
return "tidsstempel for gammelt"
# Negativ alder = tidsstempel i framtiden. Litt klokkeavvik er normalt,
# mye er mistenkelig.
if alder < -TOLERANSE_SEKUNDER:
return "tidsstempel i framtiden"
return None
def nettsted_basis(data):
u = (data.get("nettsted") or {}).get("url") or "https://example.no"
return u.rstrip("/")
def lag_slug(tittel):
s = (tittel or "").lower()
for fra, til in (("æ", "ae"), ("ø", "oe"), ("å", "aa")):
s = s.replace(fra, til)
s = unicodedata.normalize("NFD", s)
s = "".join(c for c in s if not unicodedata.combining(c))
s = re.sub(r"[^a-z0-9]+", "-", s).strip("-")
return (s or "artikkel")[:80]
@app.post("/oron-webhook")
def oron_webhook():
# RÅ KROPP.
# request.get_data() gir bytene nøyaktig slik de kom inn. Bruk ALDRI
# request.json eller request.get_json() til signaturen: de har allerede
# parset JSON-en, og en reserialisering med json.dumps() gir en annen
# bytestrøm (nøkkelrekkefølge, mellomrom, og ikke minst \u-escaping av
# æøå og emoji — Oron sender ensure_ascii=False, altså rå UTF-8) enn den
# Oron signerte. HMAC-en vil da aldri stemme.
# cache=True (standard) gjør at strømmen kan leses igjen senere.
raa_body = request.get_data(cache=True, as_text=False)
# 1) SIGNATUR FØRST — før vi tolker en eneste byte av innholdet.
grunn = verifiser_signatur(request.headers.get("Oron-Signatur"), raa_body)
if grunn:
app.logger.warning("Avvist Oron-webhook: %s", grunn)
# 401 = ikke autentisert. 4xx betyr «ikke prøv igjen» (§5), og det er
# riktig: et nytt forsøk med feil hemmelighet gir samme svar.
return jsonify({"feil": "ugyldig signatur"}), 401
# 2) Nå — og først nå — kan vi tolke kroppen.
try:
data = json.loads(raa_body.decode("utf-8"))
if not isinstance(data, dict):
raise ValueError("ikke et objekt")
except Exception:
# 400: ødelagt kropp. Et nytt forsøk gir samme ødelagte kropp, så vi
# ber Oron la være (§5: ingen nye forsøk ved 4xx utenom 429).
return jsonify({"feil": "ugyldig JSON"}), 400
hendelse = data.get("hendelse")
# hendelse_id er lik på alle fire leveringsforsøk (§5).
hendelse_id = data.get("hendelse_id") or request.headers.get("Oron-Hendelse-Id")
# 3) IDEMPOTENS på hendelse_id.
# Oron leverer inntil 4 ganger (straks, +2 s, +10 s, +30 s) ved 5xx,
# 429, timeout og nettverksfeil — med SAMME hendelse_id. Rekker du ikke
# å svare innen 30 sekunder, kommer nøyaktig samme hendelse igjen selv
# om artikkelen allerede er lagret. Uten dedupe får kunden fire like
# innlegg.
#
# >>> HER skal du bruke databasen, ikke dictene over: <<<
# CREATE TABLE oron_hendelser (
# hendelse_id TEXT PRIMARY KEY,
# svar TEXT,
# opprettet TIMESTAMP DEFAULT now()
# );
# Kjør INSERT på hendelse_id i SAMME transaksjon som du lagrer
# innlegget. Får du IntegrityError (unique violation), er hendelsen
# allerede behandlet: hent raden og returner det lagrede svaret med
# 200. Da er dedupe atomisk også når to gunicorn-arbeidere får
# hvert sitt forsøk samtidig — et dict i minnet overlever verken en
# omstart eller flere prosesser.
if hendelse_id and hendelse_id in BEHANDLEDE:
# Samme svar som første gang — Oron skal få den ekte URL-en også når
# det bare var svaret vårt som gikk tapt.
return jsonify(BEHANDLEDE[hendelse_id]), 200
# 4) test.hendelse: kunden trykket «Send testhendelse» i dashbordet.
# Ekte signatur, oppdiktet artikkel. Skal ALDRI publiseres (§1) — men
# skal kvitteres med 2xx slik at testen i dashbordet blir grønn.
if hendelse == "test.hendelse":
return jsonify({"ok": True, "melding": "testhendelse mottatt, ikke publisert"}), 200
try:
if hendelse == "artikkel.publisert":
# NY artikkel.
slug = (data.get("artikkel") or {}).get("slug") or lag_slug(data.get("tittel"))
url = "%s/blogg/%s" % (nettsted_basis(data), slug)
post_id = "post_%d" % (len(INNLEGG) + 1)
INNLEGG[url] = {
"id": post_id,
"tittel": data.get("tittel"),
"ingress": data.get("ingress"),
"html": data.get("html"), # ferdig HTML fra Oron
"meta": data.get("meta"),
}
# §4: returner den EKTE URL-en. Uten dette lagrer Oron en gjettet
# «nettsted/slug» som kanskje ikke finnes, og rapporter og
# rangeringsmåling peker på feil side. `id` lagres som ekstern_id
# og kommer tilbake ved senere oppdateringer.
ut = {"url": url, "id": post_id}
elif hendelse == "artikkel.oppdatert":
# OPPDATER på stedet — ikke lag et nytt innlegg.
# Nøkkelen er toppnivå-feltet «url» (§2).
url = data.get("url")
if not url:
return jsonify({"feil": "mangler url ved oppdatering"}), 400
fins = INNLEGG.get(url)
if fins:
post_id = fins["id"]
else:
# Ukjent URL: opprett heller enn å miste artikkelen.
post_id = "post_%d" % (len(INNLEGG) + 1)
INNLEGG[url] = {
"id": post_id,
"tittel": data.get("tittel"),
"ingress": data.get("ingress"),
"html": data.get("html"),
"meta": data.get("meta"),
}
ut = {"url": url, "id": post_id}
else:
# Ukjent hendelsestype → 200, ikke feil. Nye typer skal ikke føre
# til fire mislykkede leveringsforsøk hos Oron.
return jsonify({"ok": True, "melding": "ignorert hendelsestype"}), 200
if hendelse_id:
BEHANDLEDE[hendelse_id] = ut
# 5) Svar raskt med 2xx. Tung etterbehandling (bildeimport,
# cache-tømming, sitemap, søkeindeks) legger du i en kø (Celery/RQ)
# ETTER dette svaret — Oron har 30 sekunders timeout, og et timeout
# koster deg tre ekstra leveringer.
return jsonify(ut), 200
except Exception as e:
# 5xx → Oron prøver igjen etter 2 s, 10 s og 30 s (§5). Riktig ved
# midlertidige feil som database nede eller full disk.
app.logger.exception("Feil ved lagring: %s", e)
return jsonify({"feil": "intern feil, prøv igjen"}), 500
if __name__ == "__main__":
app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8804")))
Kjørt som ekte HTTP-server mot sju signerte leveringer.
Program.cs
// Oron webhook-mottaker — .NET 8 minimal API
// ==========================================
// Kontrakt: Oron Webhook v1.
//
// Kjør:
// dotnet new web -o OronWebhook (og bytt ut Program.cs med denne filen)
// export ORON_WEBHOOK_SECRET=whsec_...
// dotnet run
//
// Denne filen ER hele programmet (top-level statements). Hemmeligheten leses
// fra miljøet — aldri en streng i kildekoden, aldri i git eller appsettings.json
// som sjekkes inn.
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Hemmeligheten fra miljøet.
var secret = Environment.GetEnvironmentVariable("ORON_WEBHOOK_SECRET");
if (string.IsNullOrEmpty(secret))
{
Console.Error.WriteLine("Mangler ORON_WEBHOOK_SECRET i miljøet.");
return 1;
}
var secretBytes = Encoding.UTF8.GetBytes(secret);
// Tidsvindu for replay-beskyttelse: 5 minutter (§3).
const int ToleranseSekunder = 300;
// ------------------------------------------------------------------ //
// DEMO-LAGER — bytt ut med EF Core / Dapper / hva du nå bruker.
// Ligger i minnet bare for at eksempelet skal kunne kjøres som det er.
// ------------------------------------------------------------------ //
var innlegg = new ConcurrentDictionary<string, Innlegg>();
var behandlede = new ConcurrentDictionary<string, object>();
app.MapPost("/oron-webhook", async (HttpRequest req) =>
{
// RÅ KROPP — som BYTES.
// Vi leser strømmen rett inn i en byte[] og lar den være i fred.
// Bruk ALDRI [FromBody]-modellbinding eller ReadFromJsonAsync til
// signaturen: da er JSON-en allerede parset, og en reserialisering gir en
// annen bytestrøm (nøkkelrekkefølge, mellomrom, escaping av æøå og emoji)
// enn den Oron signerte — HMAC-en vil aldri stemme.
// Vi går heller ikke veien om en string: bytes inn, bytes ut, ingen
// koding fram og tilbake som kan endre noe.
byte[] raaBody;
using (var ms = new MemoryStream())
{
await req.Body.CopyToAsync(ms);
raaBody = ms.ToArray();
}
// 1) SIGNATUR FØRST — før vi tolker en eneste byte av innholdet.
var grunn = VerifiserSignatur(
req.Headers["Oron-Signatur"].ToString(), raaBody, secretBytes, ToleranseSekunder);
if (grunn is not null)
{
Console.Error.WriteLine($"Avvist Oron-webhook: {grunn}");
// 401 = ikke autentisert. 4xx betyr «ikke prøv igjen» (§5), og det er
// riktig: et nytt forsøk med feil hemmelighet gir samme svar.
return Results.Json(new { feil = "ugyldig signatur" }, statusCode: 401);
}
// 2) Nå — og først nå — kan vi tolke kroppen.
JsonObject? data;
try
{
data = JsonNode.Parse(raaBody)?.AsObject();
if (data is null) throw new JsonException("ikke et objekt");
}
catch (Exception)
{
// 400: ødelagt kropp. Et nytt forsøk gir samme ødelagte kropp, så vi
// ber Oron la være (§5: ingen nye forsøk ved 4xx utenom 429).
return Results.Json(new { feil = "ugyldig JSON" }, statusCode: 400);
}
var hendelse = data["hendelse"]?.GetValue<string>();
// hendelse_id er lik på alle fire leveringsforsøk (§5).
var hendelseId = data["hendelse_id"]?.GetValue<string>()
?? req.Headers["Oron-Hendelse-Id"].ToString();
if (string.IsNullOrEmpty(hendelseId)) hendelseId = null;
// 3) IDEMPOTENS på hendelse_id.
// Oron leverer inntil 4 ganger (straks, +2 s, +10 s, +30 s) ved 5xx,
// 429, timeout og nettverksfeil — med SAMME hendelse_id. Rekker du
// ikke å svare innen 30 sekunder, kommer nøyaktig samme hendelse igjen
// selv om artikkelen allerede er lagret. Uten dedupe får kunden fire
// like innlegg.
//
// >>> HER skal du bruke databasen, ikke ConcurrentDictionary-et: <<<
// EF Core: en entitet OronHendelse med HendelseId som primærnøkkel
// (eller en UNIQUE-indeks). Kall SaveChangesAsync() på den i SAMME
// transaksjon som du lagrer innlegget:
// using var tx = db.Database.BeginTransaction();
// db.OronHendelser.Add(new OronHendelse { HendelseId = hendelseId });
// try { await db.SaveChangesAsync(); }
// catch (DbUpdateException) { /* duplikat → hent lagret svar, returner 200 */ }
// Da er dedupe atomisk også når to instanser bak en lastbalanserer
// får hvert sitt forsøk samtidig — et dictionary i minnet overlever
// verken en omstart eller flere prosesser.
if (hendelseId is not null && behandlede.TryGetValue(hendelseId, out var tidligere))
{
// Samme svar som første gang — Oron skal få den ekte URL-en også når
// det bare var svaret vårt som gikk tapt.
return Results.Json(tidligere, statusCode: 200);
}
// 4) test.hendelse: kunden trykket «Send testhendelse» i dashbordet.
// Ekte signatur, oppdiktet artikkel. Skal ALDRI publiseres (§1) — men
// skal kvitteres med 2xx slik at testen i dashbordet blir grønn.
if (hendelse == "test.hendelse")
{
return Results.Json(
new { ok = true, melding = "testhendelse mottatt, ikke publisert" }, statusCode: 200);
}
try
{
object ut;
if (hendelse == "artikkel.publisert")
{
// NY artikkel.
var slug = data["artikkel"]?["slug"]?.GetValue<string>()
?? LagSlug(data["tittel"]?.GetValue<string>());
var url = $"{NettstedBasis(data)}/blogg/{slug}";
var postId = $"post_{innlegg.Count + 1}";
innlegg[url] = new Innlegg(
postId,
data["tittel"]?.GetValue<string>(),
data["ingress"]?.GetValue<string>(),
data["html"]?.GetValue<string>(), // ferdig HTML fra Oron
data["meta"]?.GetValue<string>());
// §4: returner den EKTE URL-en. Uten dette lagrer Oron en gjettet
// «nettsted/slug» som kanskje ikke finnes, og rapporter og
// rangeringsmåling peker på feil side. `id` lagres som ekstern_id
// og kommer tilbake ved senere oppdateringer.
ut = new { url, id = postId };
}
else if (hendelse == "artikkel.oppdatert")
{
// OPPDATER på stedet — ikke lag et nytt innlegg.
// Nøkkelen er toppnivå-feltet «url» (§2).
var url = data["url"]?.GetValue<string>();
if (string.IsNullOrEmpty(url))
{
return Results.Json(new { feil = "mangler url ved oppdatering" }, statusCode: 400);
}
// Ukjent URL: opprett heller enn å miste artikkelen.
var postId = innlegg.TryGetValue(url, out var fins)
? fins.Id
: $"post_{innlegg.Count + 1}";
innlegg[url] = new Innlegg(
postId,
data["tittel"]?.GetValue<string>(),
data["ingress"]?.GetValue<string>(),
data["html"]?.GetValue<string>(),
data["meta"]?.GetValue<string>());
ut = new { url, id = postId };
}
else
{
// Ukjent hendelsestype → 200, ikke feil. Nye typer skal ikke føre
// til fire mislykkede leveringsforsøk hos Oron.
return Results.Json(new { ok = true, melding = "ignorert hendelsestype" }, statusCode: 200);
}
if (hendelseId is not null) behandlede[hendelseId] = ut;
// 5) Svar raskt med 2xx. Tung etterbehandling (bildeimport,
// cache-tømming, sitemap) legger du i en bakgrunnstjeneste
// (IHostedService / Channel<T>) ETTER dette svaret — Oron har 30
// sekunders timeout, og et timeout koster deg tre ekstra leveringer.
return Results.Json(ut, statusCode: 200);
}
catch (Exception e)
{
// 5xx → Oron prøver igjen etter 2 s, 10 s og 30 s (§5). Riktig ved
// midlertidige feil som database nede eller full disk.
Console.Error.WriteLine($"Feil ved lagring: {e.Message}");
return Results.Json(new { feil = "intern feil, prøv igjen" }, statusCode: 500);
}
});
var port = Environment.GetEnvironmentVariable("PORT") ?? "8805";
app.Run($"http://127.0.0.1:{port}");
return 0;
/// <summary>
/// Verifiserer «Oron-Signatur» mot den rå kroppen (§3).
/// Returnerer null når alt er i orden, ellers en kort grunn.
/// </summary>
static string? VerifiserSignatur(string? header, byte[] raaBody, byte[] secretBytes, int toleranse)
{
if (string.IsNullOrEmpty(header)) return "mangler Oron-Signatur";
// Formatet er «t=1786276812,v1=5257a8…». Ukjente nøkler skal ignoreres —
// det kan komme flere v-verdier senere, og gammel kode skal fortsatt virke.
string? t = null, v1 = null;
foreach (var del in header.Split(','))
{
var i = del.IndexOf('=');
if (i < 0) continue;
var nokkel = del[..i].Trim();
var verdi = del[(i + 1)..].Trim();
if (nokkel == "t") t = verdi;
else if (nokkel == "v1") v1 = verdi;
}
if (t is null || v1 is null) return "ugyldig signaturformat";
if (!long.TryParse(t, NumberStyles.None, CultureInfo.InvariantCulture, out var tSek))
return "ugyldig tidsstempel";
// HMAC-SHA256 over «t + "." + rå kropp». Vi setter sammen bytene selv:
// tidsstempelet, et punktum, og deretter kroppen nøyaktig slik den kom inn.
var prefiks = Encoding.UTF8.GetBytes(t + ".");
var signert = new byte[prefiks.Length + raaBody.Length];
Buffer.BlockCopy(prefiks, 0, signert, 0, prefiks.Length);
Buffer.BlockCopy(raaBody, 0, signert, prefiks.Length, raaBody.Length);
byte[] forventet;
using (var hmac = new HMACSHA256(secretBytes))
{
forventet = hmac.ComputeHash(signert);
}
// Gjør den innsendte hex-strengen om til bytes, slik at vi kan sammenligne
// rå bytes med FixedTimeEquals. Er hex-en ugyldig eller feil lengde, er
// signaturen uansett feil.
byte[] oppgitt;
try
{
oppgitt = Convert.FromHexString(v1);
}
catch (FormatException)
{
return "ugyldig signaturformat";
}
// KONSTANT TID — CryptographicOperations.FixedTimeEquals(), aldri == eller
// SequenceEqual().
//
// En vanlig sammenligning stopper ved første byte som er ulik. Tiden svaret
// bruker røper dermed hvor mange bytes av signaturen som var riktige, og en
// angriper som kan sende mange forsøk kan gjette signaturen byte for byte
// ved å måle responstiden — ~8000 forsøk i stedet for 2^256. Det er en ekte
// sårbarhet, ikke teori. FixedTimeEquals bruker like lang tid uansett hvor
// bytene skiller seg. (Den returnerer false ved ulik lengde — helt trygt,
// lengden på en SHA256 er alltid 32 bytes og er ingen hemmelighet.)
if (!CryptographicOperations.FixedTimeEquals(forventet, oppgitt))
return "signatur stemmer ikke";
// Replay-beskyttelse. Sjekkes ETTER at HMAC-en er godkjent, slik at vi ikke
// lar en uautentisert header styre logikken. Et gammelt, korrekt signert
// kall er nettopp det et replay-angrep ser ut som.
var alder = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - tSek;
if (alder > toleranse) return "tidsstempel for gammelt";
// Negativ alder = tidsstempel i framtiden. Litt klokkeavvik er normalt.
if (alder < -toleranse) return "tidsstempel i framtiden";
return null;
}
static string NettstedBasis(JsonObject data)
{
var u = data["nettsted"]?["url"]?.GetValue<string>() ?? "https://example.no";
return u.TrimEnd('/');
}
static string LagSlug(string? tittel)
{
var s = (tittel ?? "").ToLowerInvariant()
.Replace("æ", "ae").Replace("ø", "oe").Replace("å", "aa");
var sb = new StringBuilder();
foreach (var c in s.Normalize(NormalizationForm.FormD))
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark) continue;
sb.Append(c is >= 'a' and <= 'z' or >= '0' and <= '9' ? c : '-');
}
var ut = string.Join('-', sb.ToString().Split('-', StringSplitOptions.RemoveEmptyEntries));
if (ut.Length > 80) ut = ut[..80];
return ut.Length == 0 ? "artikkel" : ut;
}
record Innlegg(string Id, string? Tittel, string? Ingress, string? Html, string? Meta);
Kjørt som ekte HTTP-server mot sju signerte leveringer.
wp-content/plugins/oron-webhook/oron-webhook.php
<?php
/**
* Plugin Name: Oron Webhook-mottaker
* Plugin URI: https://oron.no/utviklere
* Description: Tar imot artikler fra Oron via webhook og publiserer dem som innlegg. Verifiserer Oron-Signatur (HMAC-SHA256) før noe som helst lagres.
* Version: 1.0.0
* Requires at least: 5.6
* Requires PHP: 7.4
* Author: Oron
* License: GPL-2.0-or-later
*
* ---------------------------------------------------------------------------
* INSTALLASJON
* 1. Legg filen i wp-content/plugins/oron-webhook/oron-webhook.php
* 2. Aktiver «Oron Webhook-mottaker» under Plugins.
* 3. Sett hemmeligheten som miljøvariabel (best), f.eks. i wp-config.php:
* putenv('ORON_WEBHOOK_SECRET=whsec_...');
* eller i serveroppsettet:
* SetEnv ORON_WEBHOOK_SECRET whsec_... (Apache)
* env[ORON_WEBHOOK_SECRET] = whsec_... (php-fpm)
* Alternativt en konstant i wp-config.php:
* define('ORON_WEBHOOK_SECRET', 'whsec_...');
* Aldri hardkodet i denne filen, og aldri i git.
* 4. Lim inn denne URL-en i Oron-dashbordet:
* https://ditt-nettsted.no/wp-json/oron/v1/webhook
* ---------------------------------------------------------------------------
*
* Kontrakt: Oron Webhook v1.
*/
// Ingen direkte tilgang til filen.
if (!defined('ABSPATH')) {
exit;
}
/** Tidsvindu for replay-beskyttelse: 5 minutter (§3). */
if (!defined('ORON_TOLERANSE_SEKUNDER')) {
define('ORON_TOLERANSE_SEKUNDER', 300);
}
/**
* Henter hemmeligheten fra miljøet (eller en konstant i wp-config.php).
*/
function oron_hent_hemmelighet() {
$fra_env = getenv('ORON_WEBHOOK_SECRET');
if (is_string($fra_env) && $fra_env !== '') {
return $fra_env;
}
if (defined('ORON_WEBHOOK_SECRET') && ORON_WEBHOOK_SECRET !== '') {
return ORON_WEBHOOK_SECRET;
}
return '';
}
/**
* Registrerer REST-endepunktet.
*/
function oron_register_routes() {
register_rest_route(
'oron/v1',
'/webhook',
array(
'methods' => 'POST',
'callback' => 'oron_handter_webhook',
/*
* permission_callback er '__return_true' MED VILJE: Oron har ingen
* WordPress-bruker og sender ingen nonce. Autentiseringen skjer i
* stedet med HMAC-signaturen, som er det FØRSTE callbacken gjør.
* Uten en permission_callback i det hele tatt gir WordPress 5.5+
* en _doing_it_wrong-advarsel — derfor står den eksplisitt her.
*/
'permission_callback' => '__return_true',
)
);
}
add_action('rest_api_init', 'oron_register_routes');
/**
* Verifiserer «Oron-Signatur» mot den rå kroppen (§3).
*
* @param string|null $header Verdien av Oron-Signatur.
* @param string $raa_body Kroppen nøyaktig slik den kom inn.
* @param string $secret Hemmeligheten.
* @return string|null null når alt er i orden, ellers en kort grunn.
*/
function oron_verifiser_signatur($header, $raa_body, $secret) {
if (!is_string($header) || $header === '') {
return 'mangler Oron-Signatur';
}
// Formatet er «t=1786276812,v1=5257a8…». Ukjente nøkler skal ignoreres —
// det kan komme flere v-verdier senere, og gammel kode skal fortsatt virke.
$t = null;
$v1 = null;
foreach (explode(',', $header) as $del) {
$pos = strpos($del, '=');
if ($pos === false) {
continue;
}
$nokkel = trim(substr($del, 0, $pos));
$verdi = trim(substr($del, $pos + 1));
if ($nokkel === 't') {
$t = $verdi;
} elseif ($nokkel === 'v1') {
$v1 = $verdi;
}
}
if ($t === null || $v1 === null) {
return 'ugyldig signaturformat';
}
if (!preg_match('/^\d+$/', $t)) {
return 'ugyldig tidsstempel';
}
// HMAC-SHA256 over «t + "." + rå kropp». hash_hmac() gir lowercase hex.
$forventet = hash_hmac('sha256', $t . '.' . $raa_body, $secret);
/*
* KONSTANT TID — hash_equals(), aldri === eller ==.
*
* En vanlig strengsammenligning i PHP stopper ved første byte som er ulik.
* Tiden svaret bruker røper dermed hvor mange tegn av signaturen som var
* riktige, og en angriper som kan sende mange forsøk kan gjette signaturen
* tegn for tegn ved å måle responstiden — ~1000 forsøk i stedet for 16^64.
* Det er en ekte sårbarhet, ikke teori: her er konsekvensen at hvem som
* helst kan publisere innlegg på kundens nettsted. hash_equals() bruker
* like lang tid uansett hvor strengene skiller seg.
*/
if (!hash_equals($forventet, strtolower($v1))) {
return 'signatur stemmer ikke';
}
// Replay-beskyttelse. Sjekkes ETTER at HMAC-en er godkjent. Et gammelt,
// korrekt signert kall er nettopp det et replay-angrep ser ut som: noen
// har fanget opp et ekte kall og sender det på nytt.
$alder = time() - (int) $t;
if ($alder > ORON_TOLERANSE_SEKUNDER) {
return 'tidsstempel for gammelt';
}
// Negativ alder = tidsstempel i framtiden. Litt klokkeavvik er normalt.
if ($alder < -ORON_TOLERANSE_SEKUNDER) {
return 'tidsstempel i framtiden';
}
return null;
}
/**
* Finner et eksisterende innlegg ut fra URL-en Oron sendte (§2).
*
* Vi lagrer URL-en vi returnerte i post-metafeltet _oron_publisert_url, og
* slår opp på den. Det er nøkkelen «artikkel.oppdatert» matcher på.
*
* @return int Post-ID, eller 0 hvis ingen treff.
*/
function oron_finn_innlegg_pa_url($url) {
$treff = get_posts(
array(
'post_type' => 'post',
'post_status' => array('publish', 'draft', 'pending', 'private'),
'numberposts' => 1,
'fields' => 'ids',
'meta_key' => '_oron_publisert_url',
'meta_value' => $url,
'suppress_filters' => false,
)
);
return !empty($treff) ? (int) $treff[0] : 0;
}
/**
* Hovedhåndtereren.
*
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
function oron_handter_webhook($request) {
$secret = oron_hent_hemmelighet();
if ($secret === '') {
// Feilkonfigurasjon hos oss → 5xx, slik at Oron prøver igjen etter at
// hemmeligheten er satt (§5).
return new WP_REST_Response(array('feil' => 'ORON_WEBHOOK_SECRET mangler'), 500);
}
/*
* RÅ KROPP.
* $request->get_body() gir bytene nøyaktig slik de kom inn.
* Bruk ALDRI $request->get_json_params() eller get_params() til
* signaturen: JSON-en er da allerede parset, og en reserialisering gir en
* annen bytestrøm (nøkkelrekkefølge, mellomrom, escaping av æøå og emoji)
* enn den Oron signerte — HMAC-en vil aldri stemme.
*/
$raa_body = $request->get_body();
if (!is_string($raa_body)) {
$raa_body = '';
}
// 1) SIGNATUR FØRST — før vi tolker en eneste byte av innholdet.
$grunn = oron_verifiser_signatur($request->get_header('Oron-Signatur'), $raa_body, $secret);
if ($grunn !== null) {
error_log('Oron webhook avvist: ' . $grunn);
// 401 = ikke autentisert. 4xx betyr «ikke prøv igjen» (§5), og det er
// riktig: et nytt forsøk med feil hemmelighet gir samme svar.
return new WP_REST_Response(array('feil' => 'ugyldig signatur'), 401);
}
// 2) Nå — og først nå — kan vi tolke kroppen.
$data = json_decode($raa_body, true);
if (!is_array($data)) {
// 400: ødelagt kropp. Et nytt forsøk gir samme ødelagte kropp.
return new WP_REST_Response(array('feil' => 'ugyldig JSON'), 400);
}
$hendelse = isset($data['hendelse']) ? (string) $data['hendelse'] : '';
$hendelse_id = isset($data['hendelse_id'])
? (string) $data['hendelse_id']
: (string) $request->get_header('Oron-Hendelse-Id');
/*
* 3) IDEMPOTENS på hendelse_id.
* Oron leverer inntil 4 ganger (straks, +2 s, +10 s, +30 s) ved 5xx,
* 429, timeout og nettverksfeil — med SAMME hendelse_id (§5). Rekker
* du ikke å svare innen 30 sekunder, kommer nøyaktig samme hendelse
* igjen selv om innlegget allerede er opprettet. Uten dedupe får
* kunden fire like innlegg.
*
* Under bruker vi en transient (24 timer) fordi det virker i enhver
* WordPress-installasjon uten migreringer.
*
* >>> VIL DU HA DET VANNTETT, gjør DU dette i stedet: <<<
* Lag en egen tabell ved aktivering, med UNIQUE på hendelse_id:
* global $wpdb;
* $wpdb->query("CREATE TABLE IF NOT EXISTS {$wpdb->prefix}oron_hendelser (
* hendelse_id VARCHAR(64) NOT NULL PRIMARY KEY,
* svar TEXT,
* opprettet DATETIME NOT NULL
* )");
* og INSERT rett før du oppretter innlegget. Feiler INSERT-en på
* duplikatnøkkel, er hendelsen allerede behandlet: hent raden og
* returner det lagrede svaret med 200. En transient er ikke
* atomisk — to samtidige forsøk kan begge se «ikke behandlet»
* før noen av dem rekker å skrive.
*/
if ($hendelse_id !== '') {
$lagret = get_transient('oron_hendelse_' . md5($hendelse_id));
if (is_array($lagret)) {
// Samme svar som første gang — Oron skal få den ekte URL-en også
// når det bare var svaret vårt som gikk tapt.
return new WP_REST_Response($lagret, 200);
}
}
// 4) test.hendelse: kunden trykket «Send testhendelse» i dashbordet.
// Ekte signatur, oppdiktet artikkel. Skal ALDRI publiseres (§1) — men
// skal kvitteres med 2xx slik at testen i dashbordet blir grønn.
if ($hendelse === 'test.hendelse') {
return new WP_REST_Response(
array('ok' => true, 'melding' => 'testhendelse mottatt, ikke publisert'),
200
);
}
$tittel = isset($data['tittel']) ? (string) $data['tittel'] : '';
$ingress = isset($data['ingress']) ? (string) $data['ingress'] : '';
$html = isset($data['html']) ? (string) $data['html'] : '';
$meta = isset($data['meta']) ? (string) $data['meta'] : '';
if ($hendelse === 'artikkel.publisert') {
// NY artikkel.
// wp_slash() fordi wp_insert_post() forventer «slashed» data og
// kjører wp_unslash() internt. Uten den forsvinner backslashes.
$post_id = wp_insert_post(
array(
'post_type' => 'post',
'post_status' => 'publish', // vil du godkjenne manuelt: 'draft'
'post_title' => wp_slash(sanitize_text_field($tittel)),
// HTML-en kommer ferdig fra Oron og er signaturverifisert.
// wp_kses_post() beholder alt WordPress selv tillater i innlegg
// og fjerner script/iframe o.l. — billig ekstra sikring.
'post_content' => wp_slash(wp_kses_post($html)),
'post_excerpt' => wp_slash(sanitize_text_field($ingress)),
),
true // returner WP_Error i stedet for 0 ved feil
);
if (is_wp_error($post_id) || !$post_id) {
// 5xx → Oron prøver igjen etter 2 s, 10 s og 30 s (§5).
error_log('Oron: klarte ikke opprette innlegg');
return new WP_REST_Response(array('feil' => 'kunne ikke opprette innlegg'), 500);
}
$url = get_permalink($post_id);
// Vi lagrer URL-en på innlegget, for det er den Oron sender tilbake
// som toppnivå-`url` ved «artikkel.oppdatert».
update_post_meta($post_id, '_oron_publisert_url', $url);
update_post_meta($post_id, '_oron_artikkel_id', isset($data['artikkel']['id']) ? (int) $data['artikkel']['id'] : 0);
// Meta description. Bruker du Yoast eller Rank Math, skriv i stedet til
// '_yoast_wpseo_metadesc' eller 'rank_math_description'.
update_post_meta($post_id, '_oron_meta_beskrivelse', $meta);
// §4: returner den EKTE permalinken. Uten dette lagrer Oron en gjettet
// «nettsted/slug», som sjelden stemmer med WordPress' permalink-
// struktur (/2026/08/tittel/, /blogg/tittel/ …) — og rapporter og
// rangeringsmåling ville pekt på en URL som ikke finnes.
// `id` lagres som ekstern_id og kommer tilbake ved oppdateringer.
$ut = array('url' => $url, 'id' => (string) $post_id);
} elseif ($hendelse === 'artikkel.oppdatert') {
// OPPDATER PÅ STEDET — ikke lag et nytt innlegg (§2).
$url = isset($data['url']) ? (string) $data['url'] : '';
if ($url === '') {
return new WP_REST_Response(array('feil' => 'mangler url ved oppdatering'), 400);
}
// Først: prøv ekstern_id (post-ID-en vi returnerte sist). Deretter URL.
$post_id = 0;
if (!empty($data['artikkel']['ekstern_id'])) {
$kandidat = (int) $data['artikkel']['ekstern_id'];
if ($kandidat > 0 && get_post_status($kandidat) !== false) {
$post_id = $kandidat;
}
}
if (!$post_id) {
$post_id = oron_finn_innlegg_pa_url($url);
}
if ($post_id) {
$resultat = wp_update_post(
array(
'ID' => $post_id,
'post_title' => wp_slash(sanitize_text_field($tittel)),
'post_content' => wp_slash(wp_kses_post($html)),
'post_excerpt' => wp_slash(sanitize_text_field($ingress)),
),
true
);
if (is_wp_error($resultat)) {
error_log('Oron: klarte ikke oppdatere innlegg ' . $post_id);
return new WP_REST_Response(array('feil' => 'kunne ikke oppdatere innlegg'), 500);
}
} else {
// Kjenner vi ikke igjen URL-en (innlegget er slettet, eller
// koblingen ble laget før denne pluginen), oppretter vi heller
// enn å miste artikkelen.
$post_id = wp_insert_post(
array(
'post_type' => 'post',
'post_status' => 'publish',
'post_title' => wp_slash(sanitize_text_field($tittel)),
'post_content' => wp_slash(wp_kses_post($html)),
'post_excerpt' => wp_slash(sanitize_text_field($ingress)),
),
true
);
if (is_wp_error($post_id) || !$post_id) {
error_log('Oron: klarte ikke opprette innlegg ved oppdatering');
return new WP_REST_Response(array('feil' => 'kunne ikke opprette innlegg'), 500);
}
}
update_post_meta($post_id, '_oron_publisert_url', $url);
update_post_meta($post_id, '_oron_meta_beskrivelse', $meta);
// Vi returnerer URL-en Oron allerede kjenner, slik at koblingen holder.
$ut = array('url' => $url, 'id' => (string) $post_id);
} else {
// Ukjent hendelsestype → 200, ikke feil. Nye typer skal ikke føre til
// fire mislykkede leveringsforsøk hos Oron.
return new WP_REST_Response(array('ok' => true, 'melding' => 'ignorert hendelsestype'), 200);
}
if ($hendelse_id !== '') {
// 24 timer holder rikelig: alle fire forsøk skjer innen ~42 sekunder (§5).
set_transient('oron_hendelse_' . md5($hendelse_id), $ut, DAY_IN_SECONDS);
}
// 5) Svar raskt med 2xx. Tung etterbehandling (bildeimport med
// media_sideload_image, cache-tømming, sitemap) legger du i
// wp_schedule_single_event(time(), ...) ETTER dette svaret — Oron har
// 30 sekunders timeout, og et timeout koster deg tre ekstra leveringer.
return new WP_REST_Response($ut, 200);
}
Kjørt som fil, med rest_api_init og permission_callback. Ekte wp_insert_post mot MySQL er ikke testet.
Kodeeksemplene legges inn her. Trenger du et språk som ikke står på listen, send en e-post til per@markusakerlund.com — vi skriver det for deg.
Feilsøking#
Statuskodene i loggen i dashbordet er dine svar til oss, ikke våre til deg. Ser du 401 der, er det endepunktet ditt som svarte 401.
| Symptom | Sannsynlig årsak | Fiks |
|---|---|---|
| Signaturen stemmer aldri | Du signerer en reserialisert JSON, ikke råkroppen. | Ta vare på bytene før body-parseren. Express: express.json({verify}) eller express.raw(). Next.js App Router: await req.text(). PHP: file_get_contents('php://input'). Django: request.body. |
| Stemmer lokalt, ikke i produksjon | En proxy, WAF eller gzip-mellomvare endrer kroppen underveis. | Logg lengden på råkroppen begge steder og sammenlign. Skru av body-omskriving for akkurat denne ruten. |
æ ø å blir æ ø Ã¥ |
Kroppen leses som latin-1, eller dekodes to ganger. | Les bytes og dekod som UTF-8 én gang. Sjekk at databasekolonnen er utf8mb4 og at svaret ditt også er UTF-8. |
| Vi logger 401 eller 403 | Ruten krever innlogging, API-nøkkel eller CSRF-token. | Vi sender ingen cookie og ingen bearer-token. Identiteten ligger i signaturen. Unnta ruten fra auth og CSRF. Laravel: legg URI-en i $except i CSRF-mellomvaren. |
| Vi logger 405 | Ruten godtar bare GET. | Vi bruker alltid POST. |
| Vi logger 404 | Skrivefeil i URL-en, eller en omdirigering foran endepunktet. | Lim inn den endelige adressen — med eller uten www slik den faktisk svarer. En omdirigering kan gjøre at kroppen ikke kommer fram. |
| Vi logger timeout | Du gjør tungt arbeid før du svarer. | Verifiser, legg i kø, svar 200. Grensen er 30 sekunder. |
| Samme artikkel to ganger | Svaret ditt kom ikke fram, og vi prøvde igjen. | Gjør mottakeren idempotent på hendelse_id. Se Idempotens. |
| Tidsstempelet er «for gammelt» | Klokka på serveren din driver. | Slå på NTP. Hold på 5-minutters vinduet — ikke skru det opp for å slippe unna. |
| Ingenting kommer fram i det hele tatt | Adressen er http://, peker på en intern IP, eller er ikke nåbar utenfra. |
Kun offentlige https://-adresser. Test med «Send testhendelse» — svaret der viser nøyaktig hva vi fikk. |
| Oron viser feil URL på artikkelen | Du returnerte ikke url, så vi gjettet. |
Svar med {"url": "…"}. Se Svaret ditt. |
| HTML-en ser rar ut | Den blir escapet, eller kjørt gjennom en streng sanitizer. | html er ferdig HTML og skal settes inn som HTML. I dag sender vi bare h2, p og br — slipper sanitizeren din gjennom minst h2, h3, p, br, ul, ol, li, a, strong og em, tåler den også senere versjoner. |
Spørsmål og svar#
Hvor ofte kommer det noe?
Normalt én artikkel per døgn, rundt kl. 07 norsk tid. Oppdateringer av eksisterende artikler kommer i tillegg, når vi skriver om noe.
Kan jeg bruke http?
Nei. Kun https://. Adresser som peker på interne eller private nettverk blir avvist.
Hva skjer hvis jeg ikke sjekker signaturen?
Alt virker som før — vi sender headeren uansett, og du kan ignorere den. Men da kan hvem som helst som kjenner eller gjetter URL-en din publisere innhold på nettstedet ditt. Sjekk den.
Er feltrekkefølgen i JSON garantert?
Nei. Bruk en JSON-parser, ikke tekstmatching. Og ignorer felt du ikke kjenner — det kan komme nye.
Kan jeg få artikkelen til flere adresser?
Nei. Ett nettsted har én webhook-adresse. Trenger du å distribuere videre, gjør du det på din side etter at du har mottatt hendelsen.
Har dere faste IP-adresser jeg kan slippe gjennom brannmuren?
Nei, vi har ingen faste IP-er å oppgi. Bruk signaturen til å verifisere at kallet kommer fra oss — den er sikrere enn en IP-liste uansett.
Kommer det bilder?
artikkel.bilde_url er ofte null. Bygg mottakeren så den takler artikler uten bilde.
Hva skjer hvis serveren min er nede når artikkelen sendes?
Vi prøver fire ganger, med 2, 10 og 30 sekunders pause mellom. Slår alle fire feil, prøver bakgrunnsjobben igjen senere samme dag med samme hendelse_id. Artikkelen ligger uansett trygt hos oss — får du den aldri, send oss en e-post, så hjelper vi deg med å få den ut.
Kan jeg bytte hemmelighet?
Ja, du roterer den i dashbordet. Den gamle slutter å virke med én gang, så bytt i mottakeren din samtidig.
Jeg har allerede bygget mot den gamle nyttelasten. Må jeg gjøre noe?
Nei. tittel, ingress, html og meta ligger fortsatt på toppnivå, og fjernes aldri. Alt annet er lagt ved siden av. Det eneste vi anbefaler at du legger til, er signaturverifisering og {"url": "…"} i svaret.
Mangler noe?#
Er noe uklart, feil, eller mangler språket ditt i eksemplene: send en e-post til per@markusakerlund.com. Vi svarer, og vi oppdaterer denne siden.