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.
GET|POST /api/searchDriver headerns söklager och startsidans förslagsdropdown. Ersätter
wp_ajax_matochbak_search (spec 02 §3).
| Parameter | Värden | Standard |
|---|---|---|
q (även search, s) | sökfras, minst 2 tecken | — |
type (även post_type) | alla, recept, produkt, utrustning, guide | alla |
limit | 1–20 | 8 |
format | json, html | json |
{ "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/filterSö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/filterReceptarkivets filtrering. Ersätter wp_ajax_matochbak_filter_recipes
(spec 07 §9).
| Parameter | Taxonomi |
|---|---|
category | receptkategori |
time | tillagningstid |
price | prisklass |
allergen | allergen (inkluderande — Gluten ger recept som innehåller gluten, som i originalet) |
dietary | kostalternativ |
taste | smakprofil |
difficulty | svarighetsgrad (ingen rullgardin, används av /svarighetsgrad/) |
equipment | utrustnings-id, fanns i AJAX-kontraktet men aldrig i gränssnittet |
sort | date_desc, date_asc, title_asc, title_desc |
sida (även paged) | sidnummer, 1-baserat |
base | sö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/filterErsä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/filterErsä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/filterNy — 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.
{ "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.astroochNoResults.astro— annars är AJAX-markupen ostylad. Arkivkomponenterna gör det redan.
Alla kräver Origin eller Referer från sajten själv.
POST /api/ratingErsä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/visitErsä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/contactErsä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.
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/produktBody (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:
edit_url är null — det finns ingen wp-admin att länka till.bilden kunde inte hämtas.BASTFORE_DEFAULT_STATUS).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.
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.