Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,15 @@ Tests sind **Integrationstests**, die echte HTTP-Requests via cURL an eine laufe
- `BearerAuth` — Token-basierte Authentifizierung via `Authorization: Bearer <token>` Header, validiert gegen `rex_api_token`-Tabelle mit Scope-Prüfung
- `BackendUser` — Session-Cookie-Authentifizierung für reine Backend-Endpunkte
- **`Token`** (`lib/Token.php`) — Verwaltet API-Tokens in der `rex_api_token`-Tabelle. Tokens haben Scopes (kommagetrennte Route-Scope-Namen).
- **`OpenAPIConfig`** (`lib/OpenAPIConfig.php`) — Generiert OpenAPI-3.0-Spezifikation aus registrierten Routen für Swagger UI.
- **`OpenAPIConfig`** (`lib/OpenAPIConfig.php`) — Generiert OpenAPI-3.0-Spezifikation aus registrierten Routen für Swagger UI. Wird auch von `/api/me?format=openapi` genutzt, dort mit einer gefilterten Routen-Teilmenge.

**Scope-freie Routen:** `Auth::requiresScope()` (Default `true`) entscheidet, ob der Route-Scope explizit vergeben sein muss. `new BearerAuth(false)` autorisiert jedes gültige Token ohne Scope-Prüfung — genutzt von `/api/me`, damit die Selbstauskunft nicht genau bei den Tokens fehlt, bei denen der Scope vergessen wurde. Solche Routen werden von `Token::getAvailableScopes()` ausgefiltert und erscheinen deshalb nicht als Checkbox auf der Token-Seite.

**OpenAPI-Generator (`OpenAPIConfig`):** Alle Feld-Definitionen — `query` wie `Body` — laufen über `getSchema()`/`getSchemaType()`: die Typnamen der Route-Definitionen (`int`, `bool`, `float`) werden auf OpenAPI-Typen gemappt, Typ und Default gehören ins `schema`, nicht an den Parameter. `required` steht als Liste auf Objektebene, nie als Bool in der Property, und ein leeres `required` wird weggelassen. `'type' => 'file'` ergänzt `format: binary` und schaltet den Content-Type auf `multipart/form-data`. Tags ohne Sprachschlüssel (`api_openapi_tag_<tag>_description`) bekommen eine leere Beschreibung statt eines `[translate:…]`-Platzhalters — Tags können aus fremden AddOns kommen. `tags` muss ein Array bleiben (`array_values`), sonst ist das Dokument für getypte Parser ungültig. **Was weiter fehlt:** Response-Schemas pro Route (Listen liefern `{data, meta}`, Detail-Routen das Objekt flach).

**Swagger-UI-Anzeige:** `pages/openapi.php` kürzt die Beschreibungen in der Endpunktliste per JS auf 50 Zeichen und legt den Originaltext ins `title`-Attribut (Hover). Nur Darstellung — die Spec bleibt vollständig. Der `MutationObserver` dort arbeitet ohne `requestAnimationFrame`, weil das in einem Hintergrund-Tab nicht ausgeführt wird.

**Route-Objekte nicht mutieren:** `RouteCollection::handle()` klont jede Route, bevor es `/api` an den Pfad hängt. Die registrierten `Route`-Objekte behalten ihren unpräfixierten Pfad — Handler, die `RouteCollection::getRoutes()` auslesen (z.B. `Discovery`), würden sonst `/api` doppelt sehen.

### Route Packages (lib/RoutePackage/)

Expand All @@ -67,6 +75,7 @@ Jede Datei definiert Routen und Handler-Methoden für eine Ressourcengruppe:
| `Templates.php` | Templates CRUD | `templates/` |
| `Clangs.php` | Sprachen CRUD | `system/clangs/` |
| `Metainfo.php` | Metainfo-Felddefinitionen + Werte (Artikel/Kategorie/Medium/Sprache) | `metainfo/` |
| `Discovery.php` | Selbstauskunft `/api/me` (erlaubte Endpunkte, OpenAPI gefiltert) | — (scope-frei) |

Die `lib/RoutePackage/Backend/`-Klassen erweitern jeweils ihre Bearer-Variante, klonen alle passenden Routen, hängen `backend/` an Pfad und Scope und ersetzen das Auth-Objekt durch `BackendUser`. Beim Anlegen eines neuen Bearer-Endpunkts entsteht der Backend-Spiegel automatisch — eigene `Backend/*.php`-Implementierungen sind nur nötig, wenn das Standardverhalten überschrieben werden soll (Beispiel: `Backend/Media.php`).

Expand Down Expand Up @@ -111,6 +120,7 @@ RouteCollection::registerRoute(
- **PRE-Extension-Points & API-Kontext**: Manche Extension Points (z.B. `SLICE_UPDATE`, `SLICE_DELETE`) rufen `rex::requireUser()` auf — das schlägt im Bearer-Token-Kontext fehl. Im API-Kontext entweder den EP nur firen, wenn `rex::getUser() !== null`, oder die Service-Methode bewusst umgehen und nur den POST-EP firen (siehe `Structure::handleUpdateArticleSlice` / `handleDeleteArticleSlice`).
- **Service-Exceptions**: `rex_api_exception` trägt eine i18n-übersetzte Message. Status-Code daher nicht über `str_contains($e->getMessage(), 'not found')` ermitteln (locale-abhängig), sondern über einen Helper, der EN- und DE-Marker prüft (siehe `Users::statusFromApiException`).
- Rückgabe: `new Response(json_encode($data), $statusCode)`
- **401 mit `required_scope`**: Ist das Bearer-Token gültig, fehlt aber der Scope, ergänzt `handle()` den benötigten Scope-Namen im Fehler-Body. Bei ungültigem Token bleibt das Feld weg — der Statuscode ist in beiden Fällen 401.

### Verbindlich: Exaktes Spiegeln des REDAXO-Core-Verhaltens

Expand Down
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Spalten: **Status** = Endpoint implementiert · **Test** = Bearer-API-Test vorha
| /api/media/{filename}/metainfo | PUT/PATCH | Medien-Metainfo ändern | ✅ | ✅ | ✅ | ✅ |
| /api/system/clangs/{id}/metainfo | GET | Sprach-Metainfo lesen | ✅ | ✅ | ✅ | ✅ |
| /api/system/clangs/{id}/metainfo | PUT/PATCH | Sprach-Metainfo ändern | ✅ | ✅ | ✅ | ✅ |
| /api/me | GET | Selbstauskunft: erlaubte Endpunkte | ✅ | ✅ | ✅ | ✅ |

**Metainfo & Backend:** Wert-Endpunkte (Article/Category/Media/Clang) sind via Backend-Session erreichbar und prüfen die jeweiligen User-Rechte: `structure`-Perm für Article/Category, `media`-Perm für Media, **admin-only für Clang** (REDAXO-Core's Sprachen-Page `pages/system.clangs.php` ist via `setRequiredPermissions('isAdmin')` ebenfalls admin-only — wir spiegeln das exakt). Field-Management (`/metainfo/types`, `/metainfo/fields`, `/metainfo/fields/{id}`) bleibt bewusst Bearer-only — Schema-Änderungen sind kein typischer Backend-User-Job.

Expand All @@ -105,9 +106,58 @@ RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
Die meisten APIs haben Authentifizierung. Das heisst, es muss ein API-Token im Backend angelegt werden, um die Endpunkte nutzen zu können, wie auch der entsprechende Scope gesetzt werden.
Andere APIs haben eine Backend-Authentifizierung, die dann über den Backend-User läuft, d.h. es kann der Session Cookie verwendet werden, um die Endpunkte zu nutzen.

## Selbstauskunft: /api/me

`GET /api/me` beantwortet für den *aufrufenden* Zugang die Frage, was er darf. Gedacht für Clients und Agenten, die die API ohne externe Doku bedienen sollen:

* Gelistet werden **nur Endpunkte, für die der Scope tatsächlich vorhanden ist** — nicht die komplette Routentabelle.
* Der Endpunkt braucht **keinen eigenen Scope**. Jedes gültige Token bekommt eine Antwort, auch ein neu angelegtes.
* Das Token selbst wird nicht ausgegeben, nur sein Name und seine Scopes.

```bash
curl -H "Authorization: Bearer DEIN_TOKEN" https://example.org/api/me
```

```json
{
"meta": {
"api_base": "/api",
"auth": { "type": "bearer", "token_name": "Sync", "scopes": ["structure/articles/list", "..."] },
"endpoint_count": 26,
"openapi_url": "/api/me?format=openapi"
},
"endpoints": [
{
"scope": "structure/articles/get",
"methods": ["GET"],
"path": "/api/structure/articles/{id}",
"description": "Get article details",
"tags": ["default"],
"path_parameters": { "id": { "required": true, "type": "string", "pattern": "\\d+" } }
}
]
}
```

Pro Endpunkt werden `path_parameters`, `query` und `body` mit Typ, `required`, Default und Beschreibung ausgegeben — leere Blöcke werden weggelassen. `required` folgt der Validierung: ein Feld ohne explizites `required` **ist** erforderlich.

`GET /api/me?format=openapi` liefert dieselbe Menge als vollständige OpenAPI-3.0-Spezifikation — gleicher Generator wie die Swagger-UI im Backend, nur auf die erlaubten Routen gefiltert. Das kompakte Format ist der Default, weil es bei vielen Routen deutlich weniger Kontext kostet. Parameter und Body-Felder tragen dort ihren Typ und Default im `schema`, sind also für Client-Generatoren verwendbar. Was die Spec nicht enthält, sind Response-Schemas pro Route: Listen liefern `{data, meta}` (siehe unten), Detail-Routen das Objekt flach.

Für Backend-Session-Zugriffe gibt es `GET /api/backend/me`. Dort wird nicht vorab gefiltert: Backend-Permissions werden pro Request geprüft, ein gelisteter Endpunkt kann also weiterhin mit 403 antworten. Der Hinweis steht in `meta.note`.

### Fehlender Scope ist unterscheidbar

Bei einem gültigen Token ohne den benötigten Scope nennt die 401-Antwort den Scope, der fehlt. Bei ungültigem oder fehlendem Token fehlt das Feld:

```json
{ "error": "Authorization failed", "required_scope": "users/list" }
```

## API Struktur

Am besten direkt im AddOn unter OpenAPI nachsehen. Dort werden alle verfügbaren Endpunkte aufgelistet.
Am besten direkt im AddOn unter OpenAPI nachsehen. Dort werden alle verfügbaren Endpunkte aufgelistet. Programmatisch übernimmt das `/api/me` (siehe oben).

In der Endpunktliste der Swagger-UI wird die Beschreibung auf 50 Zeichen gekürzt, damit jeder Endpunkt eine Zeile bleibt — der vollständige Text erscheint beim Hover über der Beschreibung. Gekürzt wird nur die Anzeige: die ausgelieferte Spezifikation enthält die Beschreibung unverändert.

### Response-Format für Listen-Endpunkte

Expand Down Expand Up @@ -163,6 +213,12 @@ Jeder Endpunkt hat eine eigene Whitelist erlaubter Sortierfelder (siehe OpenAPI-

## Was funktioniert vielleicht nicht, und müssen AddOn Entwickler beachten

Eigene Endpunkte anderer AddOns erscheinen automatisch in `/api/me` und in der OpenAPI-Spezifikation — es ist nichts zusätzlich zu registrieren. Ausgegeben wird dabei genau das, was die Route deklariert: gepflegte `query`- und `Body`-Definitionen samt `description` machen den Endpunkt für einen aufrufenden Client oder Agenten benutzbar, fehlende Definitionen lassen ihn ohne Parameter erscheinen. Datei-Uploads sollten `'type' => 'file'` verwenden, dann wird in der Spezifikation `multipart/form-data` mit `format: binary` erzeugt.

Wer eigene Tags vergibt, sollte auch den Sprachschlüssel `api_openapi_tag_<tag>_description` mitliefern — sonst bleibt die Tag-Beschreibung in Swagger UI und in der Spezifikation leer.

`new BearerAuth(false)` autorisiert jedes gültige Token ohne Scope-Prüfung. Das ist für Selbstauskunft-artige Endpunkte gedacht; alles, was Daten liest oder schreibt, gehört hinter `new BearerAuth()` mit eigenem Scope.

Das API AddON funktioniert aus dem Frontend-User-Kontext heraus. Das heisst, sollte es registrierte Methoden an bestimmten
ExtensionPoints geben, welche nur im Backend-User-Kontext gesetzt wurden, z.B. (rex::isBackend) -> registerEP, dann werden diese nicht in der dieser API ausgeführt.
D.h. diese AddOns müssen entsprechend angepasst werden.
Expand Down
4 changes: 4 additions & 0 deletions boot.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@

use FriendsOfRedaxo\Api\RouteCollection;
use FriendsOfRedaxo\Api\RoutePackage\Backend\Clangs as BackendClangs;
use FriendsOfRedaxo\Api\RoutePackage\Backend\Discovery as BackendDiscovery;
use FriendsOfRedaxo\Api\RoutePackage\Backend\Media as BackendMedia;
use FriendsOfRedaxo\Api\RoutePackage\Backend\Metainfo as BackendMetainfo;
use FriendsOfRedaxo\Api\RoutePackage\Backend\Modules as BackendModules;
use FriendsOfRedaxo\Api\RoutePackage\Backend\Structure as BackendStructure;
use FriendsOfRedaxo\Api\RoutePackage\Backend\Templates as BackendTemplates;
use FriendsOfRedaxo\Api\RoutePackage\Backend\Users as BackendUsers;
use FriendsOfRedaxo\Api\RoutePackage\Clangs;
use FriendsOfRedaxo\Api\RoutePackage\Discovery;
use FriendsOfRedaxo\Api\RoutePackage\Media;
use FriendsOfRedaxo\Api\RoutePackage\Metainfo;
use FriendsOfRedaxo\Api\RoutePackage\Modules;
Expand All @@ -23,13 +25,15 @@
RouteCollection::registerRoutePackage(new Media());
RouteCollection::registerRoutePackage(new Users());
RouteCollection::registerRoutePackage(new Metainfo());
RouteCollection::registerRoutePackage(new Discovery());
RouteCollection::registerRoutePackage(new BackendClangs());
RouteCollection::registerRoutePackage(new BackendMedia());
RouteCollection::registerRoutePackage(new BackendMetainfo());
RouteCollection::registerRoutePackage(new BackendModules());
RouteCollection::registerRoutePackage(new BackendStructure());
RouteCollection::registerRoutePackage(new BackendTemplates());
RouteCollection::registerRoutePackage(new BackendUsers());
RouteCollection::registerRoutePackage(new BackendDiscovery());

if (!rex::getConsole()) {
rex_extension::register('YREWRITE_PREPARE', static function (rex_extension_point $ep) {
Expand Down
3 changes: 3 additions & 0 deletions lang/de_de.lang
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,8 @@ api_token_added = API-Token wurde hinzugefügt

api_openapi_title = OpenAPI Dokumentation
api_openapi_description = Hier werden alle erfassten Endpunkte ausgegeben und können eingesehen und getestet werden. Um sie nutzen zu können, muss vorher ein Token mit den entsprechenden Zugriffen erstellt werden.
api_openapi_tag_default_description = Endpunkte mit Bearer-Token-Authentifizierung
api_openapi_tag_backend_description = Endpunkte mit Backend-Session-Authentifizierung (Cookie)
api_openapi_tag_meta_description = Selbstauskunft: welche Endpunkte das aktuelle Token bzw. der Backend-User nutzen darf

readme = ReadMe
9 changes: 9 additions & 0 deletions lib/Auth/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ public function __construct()

abstract public function isAuthorized(array $parameters): bool;

/**
* Whether the route scope must be granted explicitly for this auth handler.
* Discovery routes answer for every valid credential and therefore return false.
*/
public function requiresScope(): bool
{
return true;
}

public function getAuthorizationObject(): mixed
{
return null;
Expand Down
24 changes: 24 additions & 0 deletions lib/Auth/BearerAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,36 @@ class BearerAuth extends Auth
{
private ?Token $Token = null;

/**
* Declared with a class default instead of a promoted constructor property:
* a subclass in another addon may override __construct() without calling
* parent::__construct(), and requiresScope() must still work.
*/
private bool $requireScope = true;

/**
* @param bool $requireScope false: every valid token is authorized, no scope needed (discovery routes)
*/
public function __construct(bool $requireScope = true)
{
parent::__construct();
$this->requireScope = $requireScope;
}

public function requiresScope(): bool
{
return $this->requireScope;
}

public function isAuthorized($parameters): bool
{
$this->Token = Token::getFromBearerToken();
if (null === $this->Token) {
return false;
}
if (!$this->requireScope) {
return true;
}
if (in_array($parameters['_route'], $this->Token->getScopes(), true)) {
return true;
}
Expand Down
Loading