API-endpoints

Ersätter WordPress’ admin-ajax.php, Contact Form 7:s REST-rutt och Bäst Före-connectorns plugin-rutter. Alla routes har export const prerender = false och körs på servern.

Gemensamt för allt: rate limit per IP (de gamla endpointerna var öppna och obegränsade, DECISIONS R6), origin-kontroll på skrivningar, validerad och typad indata, och 400 med ett svenskt felmeddelande vid ogiltig indata — aldrig ett stacktrace. Felsvaren har texten i både error och data, eftersom WordPress-klienterna läste data.

Rate limiten räknas i minnet per serverinstans. Den stoppar en skenande klient, inte ett distribuerat angrepp; ett globalt tak kräver Redis eller en tabell och är inte värt en databasskrivning per filterklick.

Läsande endpoints

GET|POST /api/search

Driver headerns söklager och startsidans förslagsdropdown. Ersätter wp_ajax_matochbak_search (spec 02 §3).

ParameterVärdenStandard
q (även search, s)sökfras, minst 2 tecken
type (även post_type)alla, recept, produkt, utrustning, guidealla
limit1–208
formatjson, htmljson
{ "success": true, "query": "kaka", "type": "alla", "count": 3,
  "hits": [{ "type": "recept", "id": 74, "slug": "…", "title": "…",
             "excerpt": "…", "featured_image": { "url": "…" },
             "url": "/recept/…/", "rank": 100 }] }

type=recept följer startsidans dropdown-kontrakt och lägger till time (tillagningstid) och price (prisklass) per träff. För kort fras ger 200 med success: false och hits: [].

Avvikelse: produkter är sökbara. WordPress uteslöt dem trots att de är den största innehållsmängden — de saknade mall då och har riktiga sidor nu (G1).

GET|POST /api/sok/filter

Sökresultatsidans filtrering och paginering. Ersätter wp_ajax_matochbak_search_filter (spec 10 §8). Samma fråga som /api/search men alltid format=html. Egen route eftersom FilterBar lägger sina parametrar efter endpointens URL och därför inte kan ha en egen querysträng.

Parametrar: q/s, type, sort (relevance, date_desc, date_asc, popular, rating_desc, rating_asc), sida, base.

GET|POST /api/recept/filter

Receptarkivets filtrering. Ersätter wp_ajax_matochbak_filter_recipes (spec 07 §9).

ParameterTaxonomi
categoryreceptkategori
timetillagningstid
priceprisklass
allergenallergen (inkluderande — Gluten ger recept som innehåller gluten, som i originalet)
dietarykostalternativ
tastesmakprofil
difficultysvarighetsgrad (ingen rullgardin, används av /svarighetsgrad/)
equipmentutrustnings-id, fanns i AJAX-kontraktet men aldrig i gränssnittet
sortdate_desc, date_asc, title_asc, title_desc
sida (även paged)sidnummer, 1-baserat
basesökväg sidnummerlänkarna ska peka på, t.ex. /receptkategori/pasta/

Filter AND-as mellan taxonomier och tar med underliggande termer (include_children). En slug som inte finns ger noll träffar, inte hela arkivet.

GET|POST /api/utrustning/filter

Ersätter wp_ajax_matochbak_filter_equipment (spec 08 §6). Parametrar: category (utrustningstyp), price_range (0-500, 500-1000, 1000-1500, 1500-2000, 2000+ — gränserna överlappar, som MySQL:s BETWEEN), sort (date_desc, date_asc, title_asc, title_desc, price_asc, price_desc, rating_desc), sida, base.

Avvikelser: prisfiltren visas inte i gränssnittet förrän det finns priser (D4), och price_asc/price_desc/rating_desc sorterar med nulls last i stället för att filtrera bort rader utan värde. WordPress’ meta_key-join gjorde att “Högst betyg” gav exakt en träff av åtta.

GET|POST /api/guide/filter

Ersätter wp_ajax_matochbak_filter_guides (spec 09 §9). Parametrar: category (guidekategori, hierarkisk), sort (date_desc, date_asc, title_asc, title_desc), sida, base.

GET|POST /api/produkt/filter

Ny — produkter hade ingen mall och inget arkiv i WordPress (G1). Parametrar: category (produktkategori, hierarkisk), brand, allergen, q (fritext), sort (title_asc, title_desc, calories_desc, calories_asc), sida, base.

Svarsformen för alla fem filter-endpoints

{ "success": true,
  "data": { "html": "<article …>…", "found": 42, "max_pages": 4, "page": 1 } }

html är färdig markup, inte rader — samma val som originalet, och det FilterBar.astro väntar på. Korten renderas med Astros container-API från samma komponenter som sidan använder, så markup och scopad CSS är identiska och recepttitlar escapas (WordPress hade lagrad XSS här, R6). found är totalen över alla sidor, inte antalet kort i html (D6). page är ett tillägg; max_pages fanns men lästes aldrig av den gamla klienten.

⚠️ Container-API:t skickar ingen CSS med fragmentet. Sidan som tar emot det måste importera samma komponenter — kortet, Pagination.astro och NoResults.astro — annars är AJAX-markupen ostylad. Arkivkomponenterna gör det redan.

Skrivande endpoints

Alla kräver Origin eller Referer från sajten själv.

POST /api/rating

Ersätter wp_ajax_matochbak_save_rating (spec 02 §9.2). JSON { recipeId, rating } eller form-encoded post_id/rating som den gamla klienten. Rate limit: 5 per minut.

{ "success": true, "data": { "average": "4.3", "count": 7,
                             "message": "Tack för ditt betyg!" } }
{ "success": false, "data": "Du har redan röstat på detta recept" }
{ "success": false, "data": "Ogiltigt betyg" }

Dubbelröstning stoppas av cookien rated_recipe_<id> (365 dagar, path /) och av voter_hash = sha256(salt + IP + user agent) i databasen, så en rensad cookie inte ger en ny röst. Råa IP-adresser lagras aldrig. Receptet måste finnas och vara publicerat — WordPress betygsatte vilket post-id som helst.

“Redan röstat” svarar 200 med success: false, som WordPress, så klienten visar meddelandet i stället för att tolka det som ett transportfel. Ogiltigt betyg ger 400.

POST /api/visit

Ersätter matochbak_track_recipe_visit (spec 02 §1, DECISIONS R9). Anropas som beacon från receptsidan:

navigator.sendBeacon('/api/visit',
  new Blob([JSON.stringify({ recipeId })], { type: 'application/json' }));

Räknar bara när cookien matochbak_consent har statistics: true, när user agent inte ser ut som en bot, och högst en gång per besökare och recept per timme. Svarar alltid 200: { success: true, data: { counted: true } } eller { counted: false, reason: "consent" | "bot" | "duplicate" }.

Siffrorna är därför inte jämförbara med historiken. Medvetet brott — de gamla talen innehöll crawlers och varje egen sidvisning.

POST /api/contact

Ersätter CF7:s /wp-json/contact-form-7/v1/contact-forms/126/feedback (spec 11 §10.3, R4). Fält och maxlängder: your-name (400, obligatoriskt), your-email (400, obligatoriskt, e-postformat), your-subject (400), your-message (2000). Honeypot: honeypot. Rate limit: 3 per 10 minuter.

Med Accept: application/json (formulärets skript) svarar den i CF7:s form:

{ "success": true,  "status": "mail_sent", "message": "Tack för ditt meddelande. Det har skickats.", "invalid_fields": [] }
{ "success": false, "status": "validation_failed", "message": "Ett eller flera fält har ett fel. Kontrollera och försök igen.",
  "invalid_fields": [{ "field": "your-email", "message": "Ange en e-postadress." }] }
{ "success": false, "status": "spam",        "message": "Det var ett fel vid försöket att skicka ditt meddelande. Försök igen senare." }
{ "success": false, "status": "mail_failed", "message": "Det var ett fel vid försöket att skicka ditt meddelande. Försök igen senare." }

Utan Accept: application/json (formulärpost utan JavaScript) svarar den 303 till /kontakt/?status=ok|invalid|error|spam#kontakt-status.

Meddelandena är CF7:s svenska strängar ordagrant, med ett undantag: stavfelet “Detta för har en för lång inmatning.” är rättat till “fält” (R4).

Post går till love.lindberg@matochbak.se (D11) via Resend. Utan RESEND_API_KEY skickas ingenting: felet loggas på servern och svaret blir mail_failed. Ingen annan leverantör antas, och inga nycklar ligger i koden.

Bäst Före-connectorn

Maskin-till-maskin från Bäst Före-appen via dess Supabase Edge Function. Autentisering: Authorization: Bearer <BASTFORE_INBOUND_TOKEN>, jämförd i konstant tid. Ingen origin-kontroll (anropet har ingen Origin), och ?token= i querysträngen stöds inte — querysträngar hamnar i åtkomstloggar.

Den gamla token låg i klartext i options-tabellen och i backupen och får inte återanvändas (G5, R2).

GET /api/bastfore/ping

{ "ok": true, "plugin": "bastfore-connector", "version": "1.1.0",
  "time": "2026-07-25 14:32:01" }

POST /api/bastfore/produkt

Body (JSON): namn (obligatoriskt), marke, ean, kategori, innehallsforteckning, energi_kcal, protein_g, kolhydrater_g, fett_g, mattat_fett_g, socker_g, salt_g, default_unit, product_store, bild_url, post_status.

{ "ok": true, "action": "created", "post_id": 214, "title": "Potatismjöl",
  "slug": "garant-potatismjol", "post_status": "draft", "edit_url": null,
  "view_url": "/produkt/garant-potatismjol/", "warnings": [] }
{ "ok": true, "action": "duplicate", "post_id": 88, "title": "Potatismjöl",
  "slug": "garant-potatismjol", "view_url": "/produkt/garant-potatismjol/",
  "message": "Produkten finns redan på sajten – skapades inte igen." }

Fel: Ogiltig JSON (400), namn krävs (422), Ogiltig token (401), Ingen token konfigurerad (500). Rate limit: 30 per minut.

Oförändrat från pluginet: titeln är produkttypen med märke och storlek avskalat, slugen är märke-typ, dedupe sker på EAN först, sedan slug (utkast räknas), varumärkestermen skapas med normaliserad casing, produktkategorin bara matchas mot befintliga termer, och allergener härleds ur innehållsförteckningen med samma nyckelordslistor. Logiken ligger i src/lib/bastfore.ts.

Skillnader:

Miljövariabler

Se .env.example. Endpointerna använder DATABASE_URL, PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY (bildimport), SITE_URL (origin-kontroll), RESEND_API_KEY, CONTACT_TO, CONTACT_FROM, BASTFORE_INBOUND_TOKEN, BASTFORE_DEFAULT_STORE, BASTFORE_DEFAULT_STATUS och VOTER_HASH_SALT.

Fortfarande kvar att bygga

POST /api/comments (spec 02 §8, 05 §5.2) och Matbottens två tunna endpoints (D14) hör till kommentars- respektive Matbotten-arbetet, inte hit. Månadsstädningen av recipe_monthly_views (spec 02 §2) är ett cron-jobb, inte en endpoint.