From 8de9574b745b20d3c0ff6b7de0a170b4ab9a1003 Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy <4736168+luke-@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:47:39 +0200 Subject: [PATCH 1/7] Add session authentication for API requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept the regular HumHub browser session as an API auth method (SessionAuth, last in the CompositeAuth chain — token methods take precedence). State-changing session-authenticated requests require the CSRF token (X-CSRF-Token) and fail with a 403 JSON response otherwise; token-authenticated requests stay CSRF-exempt. Session auth deliberately bypasses the API user allowlist: a session grants nothing beyond what the same user can already do in the web UI, and the browser frontend must work for every logged-in user. Controlled by the new enableSessionAuth setting, enabled by default on this branch for the Vue islands experiment. --- components/BaseController.php | 15 +++ components/auth/SessionAuth.php | 127 ++++++++++++++++++++++ docs/CHANGELOG.md | 4 + models/ConfigureForm.php | 17 ++- module.json | 2 +- tests/codeception/api/SessionAuthCest.php | 99 +++++++++++++++++ views/admin/index.php | 1 + 7 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 components/auth/SessionAuth.php create mode 100644 tests/codeception/api/SessionAuthCest.php diff --git a/components/BaseController.php b/components/BaseController.php index 215a24cd..3c7245e0 100644 --- a/components/BaseController.php +++ b/components/BaseController.php @@ -12,6 +12,7 @@ use humhub\components\Controller; use humhub\modules\content\models\Content; use humhub\modules\rest\components\auth\ImpersonateAuth; +use humhub\modules\rest\components\auth\SessionAuth; use humhub\modules\rest\components\behaviors\LanguagePickerBehavior; use humhub\modules\rest\components\User as UserComponent; use humhub\modules\rest\components\auth\JwtAuth; @@ -81,6 +82,11 @@ public function behaviors() [[ 'class' => ImpersonateAuth::class, ]], + // Session auth must stay LAST in the chain: every token method takes + // precedence over the browser session, see the SessionAuth docblock. + ConfigureForm::getInstance()->enableSessionAuth ? [[ + 'class' => SessionAuth::class, + ]] : [], ), ], 'languagePicker' => [ @@ -94,10 +100,19 @@ public function behaviors() */ public function beforeAction($action) { + $appUser = Yii::$app->getUser(); + Yii::$app->set('user', [ 'class' => UserComponent::class, 'identityClass' => User::class, + // Always session-less: token logins (`yii\web\User::login()`) must never write + // into the browser session. SessionAuth restores the session identity through + // its own temporary window instead — see SessionAuth::getSessionIdentity(). 'enableSession' => false, + // Session-authenticated requests honor the same idle/absolute session expiry + // rules as the regular web UI (irrelevant for the session-less token methods). + 'authTimeout' => $appUser->authTimeout, + 'absoluteAuthTimeout' => $appUser->absoluteAuthTimeout, ]); Yii::$app->response->format = 'json'; diff --git a/components/auth/SessionAuth.php b/components/auth/SessionAuth.php new file mode 100644 index 00000000..b066276f --- /dev/null +++ b/components/auth/SessionAuth.php @@ -0,0 +1,127 @@ +session; + if (!$session->getHasSessionId() && !$session->getIsActive()) { + return null; + } + + $identity = $this->getSessionIdentity($user); + if ($identity === null) { + return null; + } + + if (!$this->validateCsrfToken($request)) { + throw new ForbiddenHttpException('Unable to verify your data submission. Session-authenticated modifying requests require a valid CSRF token (X-CSRF-Token header).'); + } + + return $identity; + } + + /** + * Restores the identity from the HumHub browser session. + * + * `BaseController::beforeAction()` configures the API user component session-less + * (`enableSession = false`) so that token logins can never write into the browser session + * (`yii\web\User::login()` would otherwise regenerate the session id and rebind the session + * to the token user). Instead of enabling sessions for the whole request, the session + * identity is restored through a temporary window here: `yii\web\User::getIdentity()` + * caches the restored identity on the component, so everything after this call — including + * `Yii::$app->user` access in actions — behaves as usual while the component stays + * session-less for writes. + * + * This runs the full `yii\web\User::renewAuthStatus()` machinery: session auth key + * validation plus the same `authTimeout` / `absoluteAuthTimeout` expiry rules as the web UI + * (the timeouts are copied from the application's user component in + * `BaseController::beforeAction()`). + */ + private function getSessionIdentity(User $user) + { + $enableSession = $user->enableSession; + $user->enableSession = true; + try { + return $user->getIdentity(); + } finally { + $user->enableSession = $enableSession; + } + } + + /** + * Validates the CSRF token for state-changing requests; safe methods (GET/HEAD/OPTIONS) + * always pass — see `yii\web\Request::validateCsrfToken()`. + * + * `BaseController::beforeAction()` disables the CSRF cookie so API responses never emit + * one, but the browser's true CSRF token lives in the `_csrf` cookie (HumHub core default), + * so cookie lookup must be re-enabled while validating. + */ + private function validateCsrfToken(Request $request): bool + { + $enableCsrfCookie = $request->enableCsrfCookie; + $request->enableCsrfCookie = true; + try { + return $request->validateCsrfToken(); + } finally { + $request->enableCsrfCookie = $enableCsrfCookie; + } + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7da7b215..759e6839 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,10 @@ Changelog ========= +0.13.0 (Unreleased) +------------------- +- Enh: Added session authentication (`enableSessionAuth` setting): API requests may be authenticated by the regular HumHub browser session; state-changing requests require the CSRF token, token auth methods take precedence + 0.12.2 (July 16, 2026) ---------------------- - Chg: Removed the obsolete `twofa.beforeCheck` listener (`Module::ignoreTwofaCheck()`) — the event no longer exists since the twofa module moved to the core user gate system; token-authenticated API requests are not intercepted by the gate, so the REST API keeps working for users with 2FA enabled without any opt-out diff --git a/models/ConfigureForm.php b/models/ConfigureForm.php index 541e320d..313e23bc 100644 --- a/models/ConfigureForm.php +++ b/models/ConfigureForm.php @@ -22,6 +22,17 @@ class ConfigureForm extends Model public $enableQueryParamAuth; + /** + * @var bool whether API requests may be authenticated by the regular HumHub browser + * session, see {@see \humhub\modules\rest\components\auth\SessionAuth} for the full + * security contract (CSRF requirement, allowlist bypass, token precedence). + * + * Default ENABLED on this branch — owner decision for the Vue islands UI experiment, + * which drives the browser UI through the REST API. The upstream default may change + * when this is merged. + */ + public $enableSessionAuth; + public $enabledForAllUsers; public $enabledUsers; @@ -34,7 +45,7 @@ class ConfigureForm extends Model public function rules() { return [ - [['enableJwtAuth', 'enableBasicAuth', 'enableBearerAuth', 'enableQueryParamAuth', 'enabledForAllUsers'], 'boolean'], + [['enableJwtAuth', 'enableBasicAuth', 'enableBearerAuth', 'enableQueryParamAuth', 'enableSessionAuth', 'enabledForAllUsers'], 'boolean'], [['enabledUsers', 'apiModules'], 'safe'], ]; } @@ -49,6 +60,7 @@ public function attributeLabels() 'enableBasicAuth' => Yii::t('RestModule.base', 'Allow HTTP Basic Authentication'), 'enableBearerAuth' => Yii::t('RestModule.base', 'Allow Bearer Authentication'), 'enableQueryParamAuth' => Yii::t('RestModule.base', 'Allow Query Param Bearer Authentication'), + 'enableSessionAuth' => Yii::t('RestModule.base', 'Allow Session Authentication'), 'enabledForAllUsers' => Yii::t('RestModule.base', 'Enabled for all registered users'), ]; } @@ -56,6 +68,7 @@ public function attributeLabels() public function attributeHints() { return [ + 'enableSessionAuth' => 'Allows requests carrying a valid, logged-in HumHub browser session to use the API without a token. Modifying requests (POST/PUT/PATCH/DELETE) additionally require the CSRF token. Not restricted by the user list below — a session grants nothing beyond what the same user can already do in the web interface.', 'enabledForAllUsers' => 'Please note, it is not recommended to enable the API for all users yet.
This option affects JWT and HTTP Basic Authentication methods only.', 'enabledUsers' => 'This option affects JWT and HTTP Basic Authentication methods only.', ]; @@ -72,6 +85,7 @@ public function loadSettings() $this->enableBasicAuth = (bool)$settings->get('enableBasicAuth'); $this->enableBearerAuth = (bool)$settings->get('enableBearerAuth'); $this->enableQueryParamAuth = (bool)$settings->get('enableQueryParamAuth'); + $this->enableSessionAuth = (bool)$settings->get('enableSessionAuth', true); $this->enabledForAllUsers = (bool)$settings->get('enabledForAllUsers'); $this->enabledUsers = (array)$settings->getSerialized('enabledUsers'); @@ -97,6 +111,7 @@ public function saveSettings() $module->settings->set('enableBasicAuth', (bool)$this->enableBasicAuth); $module->settings->set('enableBearerAuth', (bool)$this->enableBearerAuth); $module->settings->set('enableQueryParamAuth', (bool)$this->enableQueryParamAuth); + $module->settings->set('enableSessionAuth', (bool)$this->enableSessionAuth); $module->settings->set('enabledForAllUsers', $this->enabledForAllUsers); $module->settings->setSerialized('enabledUsers', (array)$this->enabledUsers); diff --git a/module.json b/module.json index 51dd4fc0..47a8da78 100644 --- a/module.json +++ b/module.json @@ -6,7 +6,7 @@ "api", "rest" ], - "version": "0.12.2", + "version": "0.13.0", "homepage": "https://github.com/humhub/rest", "humhub": { "minVersion": "1.19" diff --git a/tests/codeception/api/SessionAuthCest.php b/tests/codeception/api/SessionAuthCest.php new file mode 100644 index 00000000..f6a98c74 --- /dev/null +++ b/tests/codeception/api/SessionAuthCest.php @@ -0,0 +1,99 @@ +wantTo('authenticate a GET request by browser session'); + + $I->amLoggedInAs(self::USER1_ID); + + $I->sendGet('auth/current'); + $I->seeSuccessResponseContainsJson($I->getUserDefinition('User1')); + } + + public function testGuestIsRejected(ApiTester $I) + { + $I->wantTo('be rejected as guest without token or session'); + + $I->sendGet('auth/current'); + $I->seeCodeResponseContainsJson(HttpCode::UNAUTHORIZED, ['message' => 'Your request was made with invalid credentials.']); + } + + public function testSessionModifyingRequestWithoutCsrfIsRejected(ApiTester $I) + { + $I->wantTo('see a session-authenticated modifying request rejected without CSRF token'); + + $I->amLoggedInAs(self::USER1_ID); + + $I->sendPatch('notification/mark-as-seen'); + $I->seeCodeResponseContainsJson(HttpCode::FORBIDDEN, [ + 'message' => 'Unable to verify your data submission. Session-authenticated modifying requests require a valid CSRF token (X-CSRF-Token header).', + ]); + } + + public function testSessionModifyingRequestWithCsrfSucceeds(ApiTester $I) + { + $I->wantTo('perform a session-authenticated modifying request with a valid CSRF token'); + + $I->amLoggedInAs(self::USER1_ID); + + // The raw CSRF token lives in the `_csrf` cookie; the client sends the masked + // form in the X-CSRF-Token header — same mechanism as humhub.client in the browser. + $rawToken = Yii::$app->security->generateRandomString(); + $I->setCookie('_csrf', $rawToken); + $I->haveHttpHeader('X-CSRF-Token', Yii::$app->security->maskToken($rawToken)); + + $I->sendPatch('notification/mark-as-seen'); + $I->seeSuccessMessage('All notifications successfully marked as seen'); + } + + public function testSessionAuthDisabledSetting(ApiTester $I) + { + $I->wantTo('see session auth rejected when disabled while token auth still works'); + + Yii::$app->getModule('rest')->settings->set('enableSessionAuth', false); + + $I->amLoggedInAs(self::USER1_ID); + $I->sendGet('auth/current'); + $I->seeCodeResponseContainsJson(HttpCode::UNAUTHORIZED, ['message' => 'Your request was made with invalid credentials.']); + + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $I->sendGet('auth/current'); + $I->seeSuccessResponseContainsJson($I->getUserDefinition('User1')); + } + + public function testTokenWinsOverSession(ApiTester $I) + { + $I->wantTo('see token auth take precedence over an existing browser session'); + + // Session of User1, bearer token of... also User1 — use basic auth of Admin instead + // to observe precedence via the returned identity. + $I->amLoggedInAs(self::USER1_ID); + $I->amHttpAuthenticated('Admin', 'admin&humhub@PASS%worD!'); + + $I->sendGet('auth/current'); + $I->seeSuccessResponseContainsJson($I->getUserDefinition('Admin')); + } +} diff --git a/views/admin/index.php b/views/admin/index.php index b7aff2f1..468c599e 100644 --- a/views/admin/index.php +++ b/views/admin/index.php @@ -31,6 +31,7 @@ field($model, 'enableBearerAuth')->checkbox(); ?> field($model, 'enableQueryParamAuth')->checkbox(); ?> +field($model, 'enableSessionAuth')->checkbox(); ?>
From f2ec0fcdc5d5511d179e19da75c4d9d93895039d Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy <4736168+luke-@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:47:52 +0200 Subject: [PATCH 2/7] Add session auth contract and Vue islands endpoint gap analysis --- docs/vue-session-api.md | 179 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/vue-session-api.md diff --git a/docs/vue-session-api.md b/docs/vue-session-api.md new file mode 100644 index 00000000..a8b0b973 --- /dev/null +++ b/docs/vue-session-api.md @@ -0,0 +1,179 @@ +# Session Authentication & Vue Islands Endpoint Gap Analysis + +Status: working document for the `vue` branch (core `enh/vuejs-integration`). +Goal: let the Vue islands UI in core consume `/api/v1` instead of core-internal +JSON controllers. + +## 1. Session authentication (implemented on this branch) + +`/api/v1` now accepts the regular HumHub browser session as an authentication +method, in addition to the existing token methods. + +### Auth method order (CompositeAuth, `components/BaseController.php`) + +1. `JwtAuth` (if `enableJwtAuth`) +2. `HttpBearerAuth` (if `enableBearerAuth`) +3. `QueryParamAuth` (if `enableBearerAuth` + `enableQueryParamAuth`) +4. `HttpBasicAuth` (if `enableBasicAuth`) +5. `ImpersonateAuth` (always) +6. **`SessionAuth` (if `enableSessionAuth`) — always last** + +**Token wins:** a request carrying a valid token is authenticated as the token +user even when a session cookie is present. An *invalid* token falls through to +session auth (standard CompositeAuth fall-through). Guests without token and +session get the usual 401 JSON. + +### CSRF contract + +- Session-authenticated **state-changing** requests (POST/PUT/PATCH/DELETE) + require a valid Yii CSRF token: `X-CSRF-Token` header (what `humhub.client` + sends, fed from the `csrf-token` meta tag) or `_csrf` body param. +- Missing/invalid token → **403 JSON** (`ForbiddenHttpException`), never an + HTML error page (response format is forced to JSON for `/api/` early in + `Events::onBeforeRequest`). +- GET/HEAD/OPTIONS are exempt. Token-authenticated requests remain CSRF-exempt + exactly as before. +- Implementation detail: `BaseController` keeps `enableCsrfCookie = false` for + API responses; `SessionAuth` re-enables cookie lookup only while validating, + because the browser's true token lives in the `_csrf` cookie (core default). + +### Setting + +- `enableSessionAuth` (module setting, checkbox on the admin config form, + `ConfigureForm`). **Default: enabled** on this branch — owner decision for + the Vue experiment; the upstream default may change on merge. + +### Allowlist decision + +Session auth deliberately **bypasses** the "Enabled for all registered users" / +user allowlist gate (`BaseController::isUserEnabled()`): + +- The gate limits who may reach the API *from outside the browser* with + self-obtained credentials/tokens; its own admin hints say it "affects JWT and + HTTP Basic Authentication methods only". +- A session-authenticated call grants nothing the same user's browser session + does not already have via the normal web controllers. +- The browser (Vue) UI must work for **every** logged-in user; gating session + auth would break it for non-allowlisted users with zero security gain. + +### Further semantics + +- Session identity restoration runs the full `yii\web\User::renewAuthStatus()` + (session auth key check); the web UI's `authTimeout` / `absoluteAuthTimeout` + are copied onto the API user component, so idle/absolute expiry matches the + web UI and API activity keeps the session alive like normal page activity. +- The auto-login ("remember me") cookie alone does **not** authenticate API + requests (`enableAutoLogin` stays off); core re-establishes the session on + any page load before an island issues API calls. +- Token logins can never write into the browser session: the API user + component stays session-less (`enableSession = false`); `SessionAuth` reads + the session through a temporary window only. + +## 2. Endpoint gap analysis: core Vue islands vs. current REST module + +What the islands consume today (core `enh/vuejs-integration`) vs. what +`/api/v1` offers. + +### 2.1 Comment window / listing + +| | Core island (`comment/comment/list` → `CommentJsonService::serializeWindow`) | REST (`GET comment/content/`, `GET comment/parent/`) | +|---|---|---| +| Pagination | Cursor/window: `commentId` + `direction=previous\|next` + `pageSize`, or anchored permalink window; returns `prevCount`, `nextCount`, `total` (incl. replies), `rootTotal` (root-only) | Offset: `page`/`limit`; returns `total`, `page`, `pages`, `links`, `results` | +| Reply previews | `children: {total, items, hasMore}` per root, one level deep | none (`childCount` number only) | +| Batch events | fires `EVENT_SERIALIZE_COMMENTS` once per window → `extensions` per comment (module extension point) | none | +| Guest gate | enforces `guestHideComments` (403) | API requires auth anyway | + +### 2.2 Comment shape + +Core (`CommentJsonService::serialize()`), per comment: +`id, contentId, parentCommentId, recordId, createdAt (ATOM), isEdited, +updatedAt, author (UserJsonService shape or null), blocked, message (raw +markdown), messageRenderOptions, attachmentsHtml, likes {count, liked}, +canEdit, canDelete, canAdminDelete, permalink, children, extensions`. + +REST (`CommentDefinitions::getComment()`): +`id, message (raw markdown, no render options), contentId, parentCommentId, +createdBy (id, guid, display_name, url), createdAt (DB format), likes {total}, +files, childCount`. + +Missing in REST: viewer-context permissions (`canEdit`/`canDelete`/ +`canAdminDelete`), viewer like state (`liked`), blocked-author masking, +`messageRenderOptions` (client-side RichText envelope), `attachmentsHtml`, +`recordId`, `permalink`, `isEdited`/`updatedAt`, ATOM timestamps, `extensions`. + +### 2.3 Comment mutations + +| | Core island | REST | +|---|---|---| +| Create | `comment/create` — JSON `message`/`fileList`/`parentCommentId`, enforces one nesting level, 422 + `errors` map, returns full island comment shape | `POST comment?contentId=&parentCommentId=` — returns REST shape, 400 + `comment` errors key, no nesting-depth guard | +| Update | `comment/update` — GET returns raw markdown for editor, POST saves | `PUT comment/` (no "fetch raw for editor" mode — REST shape already carries raw markdown) | +| Delete | `comment/delete` — supports admin delete with notification (`AdminDeleteCommentForm`: notify + reason), returns `{success}` | `DELETE comment/` — plain delete, no notify/reason flow | +| Single | `comment/info` — `showBlocked=1` reveal, island shape | `GET comment/` — REST shape, no blocked masking at all | + +### 2.4 Likes + +| | Core island (`like/*`) | REST | +|---|---|---| +| State | `info` → `{currentUserLiked, likeCounter}` (guest-allowed) | none (only `likes.total` embedded in content shapes) | +| Like / Unlike | `POST like/like`, `POST like/unlike` → same state shape | **no like/unlike endpoint at all** (`GET/DELETE like/`, `GET like/find-by-object` only) | +| User list | `like/user-list` → `{total, users: [UserJsonService], hasMore, nextPage}`, limit clamped to `userListPaginationSize` | `GET like/find-by-object` → offset-paged `{id, createdBy(short), createdAt}` | + +### 2.5 User shape + +Core `UserJsonService::serialize()` (shared island shape, `` props): +`guid, displayName, url, imageUrl, contentContainerId, imageAlt, online`. + +REST `UserDefinitions::getUserShort()`: `id, guid, display_name, url` — +snake_case naming, no `imageUrl`/`online`/`contentContainerId`/`imageAlt`. + +## 3. Convergence proposal (per endpoint) + +Guiding principle: the core `*JsonService` classes are controller-agnostic — +**reuse them from new REST controllers instead of re-modelling their output in +`Definitions`**. Existing REST endpoints/definitions stay untouched (they are a +public, versioned contract also used by the legal data export and third-party +integrations; injecting viewer-dependent fields there would change their +semantics). The owner explicitly allows new, "internal" REST endpoints. + +| Island need | Proposal | +|---|---| +| Comment window | New `GET /comment/window` (`rest/comment/window/list`) with `contentId`/`parentCommentId`, `commentId`, `direction`, `pageSize` → return `CommentJsonService::serializeWindow()` verbatim | +| Single comment (island shape) | New `GET /comment//full` (`?showBlocked=1`) → `serializeComment()` | +| Create/update for islands | New `POST /comment/full?contentId=…` and `PUT /comment//full` returning `serializeComment()` with core's 422 `errors` contract (or: extend existing actions with a `?format=full` switch — less clean, mixes error contracts) | +| Admin delete w/ notification | New `DELETE /comment//full` accepting `notify`/`message` (mirrors `AdminDeleteCommentForm`) | +| Like state / like / unlike | New `GET /like/info`, `POST /like`, `DELETE /like` keyed by `model`+`pk` (RecordMap), returning `{currentUserLiked, likeCounter}` | +| Like user list | New `GET /like/user-list` returning `{total, users, hasMore, nextPage}` via `UserJsonService` | +| User shape | Use `UserJsonService` in all new endpoints; leave `UserDefinitions` untouched | +| `extensions` batch event | Comes for free by reusing `CommentJsonService` (fires `EVENT_SERIALIZE_COMMENTS`) | + +Net effect: the islands can switch from `/comment/...` core routes to +`/api/v1/...` by swapping the base URL + auth stays the browser session + +CSRF header they already send today. + +Naming/versioning: mark these routes as **internal** (serving the HumHub +frontend, shape may change with core) — either under a `/api/v1/internal/…` +prefix or via documentation flag in swagger — so they don't freeze into the +public API contract. + +## 4. Open questions for the owner + +1. **Shape fidelity:** serve the island payloads 1:1 under `/api/v1` + (recommended above) or migrate the Vue clients to REST-envelope conventions + (offset pagination, `code`/`message` errors)? 1:1 keeps the islands + backend-agnostic and diff-free; REST-envelope would make them "real" public + API consumers but requires island rework (window pagination is UX-relevant). +2. **Internal namespace:** `/api/v1/internal/...` prefix, or plain routes with + an "internal/unstable" documentation flag? +3. **HTML fragments:** `attachmentsHtml` (and the admin-delete modal, which + stays a core HTML route) — acceptable in API responses, or should + attachments become structured JSON + a client-side renderer first? +4. **Client wiring:** will the islands' fetch wrapper reuse `humhub.client`'s + CSRF header mechanism as assumed? (The session-auth CSRF contract relies on + `X-CSRF-Token`.) +5. **ImpersonateAuth breakage (pre-existing):** `ImpersonateAuth` sets + `Yii::$app->user->isImpersonated`, a property removed by core 1.19's + impersonation refactor (core PR #8372) — `AuthCest::testImpersonateByAdmin` + fails against current core on a clean checkout. Fix on this branch or + separately? +6. **Rate limiting:** browser-session traffic will multiply API request volume + — is throttling needed before the islands switch over? From 71cf200ebf21d71ab5b0100cd88ec800843de712 Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy <4736168+luke-@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:02:58 +0200 Subject: [PATCH 3/7] Fix impersonate token auth on HumHub 1.19 Core 1.19 replaced the Impersonator user-component behavior with the session-bound Impersonation component (core #8372); the public isImpersonated flag ImpersonateAuth used to set no longer exists and setting it threw an UnknownPropertyException on every impersonate-token request. The API user component is session-less, so there is no session-bound impersonation state to mark instead; applying 1.19's private-content restriction to impersonate-token requests is a separate follow-up. --- components/auth/ImpersonateAuth.php | 9 +++++++-- docs/CHANGELOG.md | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/components/auth/ImpersonateAuth.php b/components/auth/ImpersonateAuth.php index cad6c184..62115711 100644 --- a/components/auth/ImpersonateAuth.php +++ b/components/auth/ImpersonateAuth.php @@ -9,7 +9,6 @@ namespace humhub\modules\rest\components\auth; use humhub\modules\rest\models\ImpersonateAuthToken; -use Yii; use yii\db\Expression; use yii\filters\auth\HttpBearerAuth; use yii\helpers\StringHelper; @@ -41,8 +40,14 @@ public function authenticate($user, $request, $response) ->one(); if ($accessToken && ($identity = $accessToken->user)) { + // Note: up to HumHub 1.18 this additionally set `Yii::$app->user->isImpersonated`, + // a flag of the `Impersonator` user-component behavior. HumHub 1.19 replaced that + // behavior with the session-bound `Impersonation` component + // (`Yii::$app->user->impersonation`, core #8372), so the flag no longer exists — + // and the API user component is deliberately session-less, so there is no session + // state to mark either. Applying 1.19's impersonation restrictions (hidden private + // content) to impersonate-token requests is a separate follow-up. $user->login($identity); - Yii::$app->user->isImpersonated = true; } else { $identity = null; } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 759e6839..ac6ab0ad 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,7 @@ Changelog 0.13.0 (Unreleased) ------------------- - Enh: Added session authentication (`enableSessionAuth` setting): API requests may be authenticated by the regular HumHub browser session; state-changing requests require the CSRF token, token auth methods take precedence +- Fix: Impersonate token authentication crashed on HumHub 1.19 (`isImpersonated` was removed by the core impersonation refactor, core #8372) 0.12.2 (July 16, 2026) ---------------------- From de27e4071716c3ee23b61d57b170b9e32f9132e1 Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy <4736168+luke-@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:45:18 +0200 Subject: [PATCH 4/7] Add island-shape comment and like endpoints for the core Vue islands Serve the payloads the core comment/like Vue islands consume 1:1 under /api/v1 by delegating to the core JSON services (CommentJsonService, UserJsonService, LikeService) instead of re-modelling them in Definitions: - GET comment/window -> serializeWindow (cursor/anchor window) - GET comment//full -> serializeComment (showBlocked reveal) - POST comment/full -> create, 422 {errors} contract - PUT comment//full -> update, 422 {errors} contract - GET comment//full/edit -> raw markdown for the editor - DEL comment//full -> delete incl. admin notify flow - GET like/info, POST/DELETE like, GET like/user-list (recordId-keyed, user-list limit clamped to userListPaginationSize) All new routes are flagged unstable/UI-coupled; the existing REST endpoints and their envelope stay untouched. Viewer-context fields, blocked-author masking, the guestHideComments gate and the extensions namespace come from the delegated services. Guest access mirrors the core controllers via the new BaseController::$guestAllowedActions list (authenticator 'optional' wiring), honored only while guest access is enabled globally. See docs/vue-session-api.md for the full route table and contract notes. --- Events.php | 16 ++ components/BaseController.php | 17 ++ controllers/comment/WindowController.php | 260 +++++++++++++++++ controllers/like/LikeController.php | 120 ++++++++ docs/CHANGELOG.md | 2 + docs/vue-session-api.md | 115 +++++--- tests/codeception/api/CommentWindowCest.php | 299 ++++++++++++++++++++ tests/codeception/api/LikeStateCest.php | 174 ++++++++++++ 8 files changed, 965 insertions(+), 38 deletions(-) create mode 100644 controllers/comment/WindowController.php create mode 100644 tests/codeception/api/CommentWindowCest.php create mode 100644 tests/codeception/api/LikeStateCest.php diff --git a/Events.php b/Events.php index 4dac1cb9..bd68ee3f 100644 --- a/Events.php +++ b/Events.php @@ -127,11 +127,27 @@ public static function onBeforeRequest($event) ['pattern' => 'comment/content/', 'route' => 'rest/comment/comment/find-by-content', 'verb' => 'GET'], ['pattern' => 'comment/parent/', 'route' => 'rest/comment/comment/find-by-parent', 'verb' => 'GET'], + // Comment: island shape (UNSTABLE / UI-coupled, mirrors the core comment Vue endpoints — + // see controllers/comment/WindowController.php and docs/vue-session-api.md) + ['pattern' => 'comment/window', 'route' => 'rest/comment/window/index', 'verb' => ['GET', 'HEAD']], + ['pattern' => 'comment/full', 'route' => 'rest/comment/window/create', 'verb' => 'POST'], + ['pattern' => 'comment//full', 'route' => 'rest/comment/window/view', 'verb' => ['GET', 'HEAD']], + ['pattern' => 'comment//full', 'route' => 'rest/comment/window/update', 'verb' => ['PUT', 'PATCH']], + ['pattern' => 'comment//full', 'route' => 'rest/comment/window/delete', 'verb' => 'DELETE'], + ['pattern' => 'comment//full/edit', 'route' => 'rest/comment/window/edit', 'verb' => ['GET', 'HEAD']], + // Like ['pattern' => 'like/', 'route' => 'rest/like/like/view', 'verb' => ['GET', 'HEAD']], ['pattern' => 'like/', 'route' => 'rest/like/like/delete', 'verb' => 'DELETE'], ['pattern' => 'like/find-by-object', 'route' => 'rest/like/like/find-by-object', 'verb' => 'GET'], + // Like: island shape (UNSTABLE / UI-coupled, mirrors the core like Vue endpoints — + // see the corresponding actions in controllers/like/LikeController.php and docs/vue-session-api.md) + ['pattern' => 'like/info', 'route' => 'rest/like/like/info', 'verb' => ['GET', 'HEAD']], + ['pattern' => 'like/user-list', 'route' => 'rest/like/like/user-list', 'verb' => ['GET', 'HEAD']], + ['pattern' => 'like', 'route' => 'rest/like/like/like', 'verb' => 'POST'], + ['pattern' => 'like', 'route' => 'rest/like/like/unlike', 'verb' => 'DELETE'], + // Post ['pattern' => 'post/', 'route' => 'rest/post/post/find', 'verb' => ['GET', 'HEAD']], ['pattern' => 'post/', 'route' => 'rest/post/post/view', 'verb' => ['GET', 'HEAD']], diff --git a/components/BaseController.php b/components/BaseController.php index 3c7245e0..6a7e9aed 100644 --- a/components/BaseController.php +++ b/components/BaseController.php @@ -18,6 +18,7 @@ use humhub\modules\rest\components\auth\JwtAuth; use humhub\modules\rest\controllers\auth\AuthController; use humhub\modules\rest\models\ConfigureForm; +use humhub\modules\user\helpers\AuthHelper; use humhub\modules\user\models\User; use Yii; use yii\data\Pagination; @@ -54,11 +55,27 @@ abstract class BaseController extends Controller */ protected $doNotInterceptActionIds = ['*']; + /** + * @var string[] ids of actions guests may call without any authentication, mirroring core's + * `AccessControl::$guestAllowedActions`. Only honored while guest access is enabled globally + * ({@see AuthHelper::isGuestAccessEnabled()}) — with guest access disabled, guests keep + * getting the usual 401, exactly like the corresponding core web controllers. Implemented + * via the authenticator's standard `optional` list: requests carrying valid credentials + * (token or session) are still authenticated normally and run with that identity, while + * requests without (or with invalid) credentials run as guest — standard Yii `optional` + * semantics. Actions listed here remain responsible for their own guest-safe authorization + * (e.g. `Content::canView()`). + * + * @since 0.13 + */ + protected array $guestAllowedActions = []; + public function behaviors() { return ArrayHelper::merge([ 'authenticator' => [ 'class' => CompositeAuth::class, + 'optional' => AuthHelper::isGuestAccessEnabled() ? $this->guestAllowedActions : [], 'authMethods' => ArrayHelper::merge( ConfigureForm::getInstance()->enableJwtAuth ? [[ 'class' => JwtAuth::class, diff --git a/controllers/comment/WindowController.php b/controllers/comment/WindowController.php new file mode 100644 index 00000000..66d56d46 --- /dev/null +++ b/controllers/comment/WindowController.php @@ -0,0 +1,260 @@ +request->get('id', Yii::$app->request->post('id')); + $parentCommentId = (int)Yii::$app->request->get( + 'parentCommentId', + Yii::$app->request->post('parentCommentId'), + ); + $contentId = (int)Yii::$app->request->get('contentId', Yii::$app->request->post('contentId')); + + if ($commentId) { + $this->comment = Comment::findOne(['id' => $commentId]); + $this->content = $this->comment?->content; + $this->parentComment = $this->comment?->parentComment; + } elseif ($parentCommentId) { + $this->parentComment = Comment::findOne(['id' => $parentCommentId]); + $this->content = $this->parentComment?->content; + } elseif ($contentId) { + $this->content = Content::findOne(['id' => $contentId]); + } + + if (!$this->content) { + throw new NotFoundHttpException(); + } + + if (!$this->content->canView()) { + throw new ForbiddenHttpException(); + } + + return true; + } + + /** + * Returns a window of comments (cursor pagination, or an anchored permalink window + * when no `direction` is given). Mirrors the core `comment/comment/list` action. + * + * @see CommentJsonService::serializeWindow() + */ + public function actionIndex() + { + $direction = Yii::$app->request->get('direction'); + $commentId = Yii::$app->request->get('commentId'); + $pageSize = Yii::$app->request->get('pageSize'); + + $service = CommentJsonService::create($this->parentComment ?? $this->content); + + return $service->serializeWindow( + $commentId !== null ? (int)$commentId : null, + $direction, + $pageSize !== null ? (int)$pageSize : null, + ); + } + + /** + * Returns a single comment in the island shape. `showBlocked=1` lifts the + * blocked-author mask only. Mirrors the core `comment/comment/info` action. + */ + public function actionView() + { + if ($this->comment === null) { + throw new NotFoundHttpException(); + } + + if (!$this->comment->canView()) { + throw new ForbiddenHttpException(); + } + + $showBlocked = (bool)Yii::$app->request->get('showBlocked'); + + return CommentJsonService::create($this->comment)->serializeComment($this->comment, $showBlocked); + } + + /** + * Creates a comment from a JSON payload (`message`, `fileList`, `parentCommentId`), + * enforcing at most one nesting level. Mirrors the core `comment/comment/create` + * action, including the `422 {"errors": ...}` validation contract. + */ + public function actionCreate() + { + if (!$this->getCommentModule()->canComment($this->content)) { + throw new ForbiddenHttpException(); + } + + if ($this->parentComment !== null && $this->parentComment->parent_comment_id !== null) { + Yii::$app->response->statusCode = 422; + + return [ + 'errors' => [ + 'parentCommentId' => [Yii::t('CommentModule.base', 'Comments can only be nested one level deep.')], + ], + ]; + } + + $model = new Comment(); + $model->content_id = $this->content->id; + $model->parent_comment_id = $this->parentComment?->id; + + if ($model->load(Yii::$app->request->post(), '') && $model->save()) { + return CommentJsonService::create($model)->serializeComment($model); + } + + Yii::$app->response->statusCode = 422; + + return ['errors' => $model->errors]; + } + + /** + * Returns the raw markdown message of an editable comment for the editor — + * the core `comment/comment/update` GET mode. + */ + public function actionEdit() + { + if ($this->comment === null) { + throw new NotFoundHttpException(); + } + + if (!$this->comment->canEdit()) { + throw new ForbiddenHttpException(); + } + + return ['message' => $this->comment->message]; + } + + /** + * Saves a comment from a JSON payload (`message`, `fileList`) and returns the + * updated comment in the island shape — the core `comment/comment/update` POST + * mode, including the `422 {"errors": ...}` validation contract. + */ + public function actionUpdate() + { + if ($this->comment === null) { + throw new NotFoundHttpException(); + } + + if (!$this->comment->canEdit()) { + throw new ForbiddenHttpException(); + } + + if ($this->comment->load(Yii::$app->request->post(), '') && $this->comment->save()) { + return CommentJsonService::create($this->comment)->serializeComment($this->comment); + } + + Yii::$app->response->statusCode = 422; + + return ['errors' => $this->comment->errors]; + } + + /** + * Deletes a comment, optionally notifying the author with a reason + * (`AdminDeleteCommentForm[notify]` / `AdminDeleteCommentForm[message]` body + * params — the fields of the core admin-delete modal). Mirrors the core + * `comment/comment/delete` action, returning `{"success": bool}`. + * + * The notification block is copied from the core action verbatim — core has not + * extracted it into a service yet; when the core controller is removed in favor + * of these endpoints, it should move into one (tracked in `docs/vue-session-api.md`). + */ + public function actionDelete() + { + if ($this->comment === null) { + throw new NotFoundHttpException(); + } + + if (!$this->comment->canDelete()) { + throw new ForbiddenHttpException(); + } + + $form = new AdminDeleteCommentForm(); + + if ($form->load(Yii::$app->request->post()) && $form->validate() && $form->notify) { + $commentDeleted = CommentDeleted::instance() + ->from(Yii::$app->user->getIdentity()) + ->about($this->comment->content->getPolymorphicRelation()) + ->payload( + [ + 'commentText' => (new CommentDeleted())->getContentPreview($this->comment, 30), + 'reason' => $form->message, + ], + ); + $commentDeleted->saveRecord($this->comment->createdBy); + + $commentDeleted->record->updateAttributes([ + 'send_web_notifications' => 1, + ]); + } + + return ['success' => $this->comment->delete()]; + } + + private function getCommentModule(): Module + { + return Yii::$app->getModule('comment'); + } +} diff --git a/controllers/like/LikeController.php b/controllers/like/LikeController.php index 4ddb871e..dc8e5709 100644 --- a/controllers/like/LikeController.php +++ b/controllers/like/LikeController.php @@ -9,10 +9,21 @@ use humhub\modules\rest\components\BaseController; use humhub\modules\rest\definitions\LikeDefinitions; use humhub\modules\like\models\Like; +use humhub\modules\user\models\User; +use humhub\modules\user\services\UserJsonService; use Yii; +use yii\web\ForbiddenHttpException; +use yii\web\NotFoundHttpException; class LikeController extends BaseController { + /** + * @inheritdoc + * + * Mirrors the core `LikeController`'s `guestAllowedActions = ['info']`: guests may read + * the like state of content they can view, everything else stays logged-in only. + */ + protected array $guestAllowedActions = ['info']; public function actionFindByObject() { $object = RecordMap::getByModelAndPk( @@ -74,5 +85,114 @@ public function actionDelete($id) return $this->returnError(500, 'Internal error while delete like!'); } + /** + * Returns the current like state of the record — a 1:1 mirror of the core + * `like/like/info` action consumed by the like Vue island (`LikeButton.vue`). + * + * UNSTABLE / UI-COUPLED: this and the actions below serve the HumHub frontend and + * follow the shape the core islands consume — they are not part of the stable public + * REST contract and may change together with core (see `docs/vue-session-api.md`). + * They deliberately throw HTTP exceptions (same status codes as core) instead of the + * module's usual `{"code", "message"}` envelope. + * + * @since 0.13 + */ + public function actionInfo() + { + $likeService = $this->getLikeServiceByRecordId(); + + return [ + 'currentUserLiked' => $likeService->hasLiked(), + 'likeCounter' => $likeService->getCount(), + ]; + } + + /** + * Likes the record given by `recordId` — mirror of the core `like/like/like` action. + * + * @since 0.13 + */ + public function actionLike() + { + $likeService = $this->getLikeServiceByRecordId(); + + if (!$likeService->canLike()) { + throw new ForbiddenHttpException(); + } + + $likeService->like(); + + return [ + 'currentUserLiked' => $likeService->hasLiked(), + 'likeCounter' => $likeService->getCount(), + ]; + } + + /** + * Unlikes the record given by `recordId` — mirror of the core `like/like/unlike` action. + * + * @since 0.13 + */ + public function actionUnlike() + { + $likeService = $this->getLikeServiceByRecordId(); + + $likeService->unlike(); + + return [ + 'currentUserLiked' => $likeService->hasLiked(), + 'likeCounter' => $likeService->getCount(), + ]; + } + + /** + * Returns a page of the users who liked the record, in the `{total, users, hasMore, + * nextPage}` shape of the core `like/like/user-list` action (rows serialized by + * {@see UserJsonService}), including its `limit` clamp to `[1, userListPaginationSize]`. + * + * @since 0.13 + */ + public function actionUserList() + { + $likeService = $this->getLikeServiceByRecordId(); + + $defaultLimit = Yii::$app->getModule('user')->userListPaginationSize; + $limit = max(1, min((int)Yii::$app->request->get('limit', $defaultLimit), $defaultLimit)); + $page = max(1, (int)Yii::$app->request->get('page', 1)); + + $query = $likeService->getUserQuery(); + $total = (clone $query)->count(); + $users = $query->offset(($page - 1) * $limit)->limit($limit)->all(); + $hasMore = ($page * $limit) < $total; + + $userJsonService = new UserJsonService(); + + return [ + 'total' => $total, + 'users' => array_map(fn(User $user) => $userJsonService->serialize($user), $users), + 'hasMore' => $hasMore, + 'nextPage' => $hasMore ? $page + 1 : null, + ]; + } + /** + * Resolves the like target from the `recordId` request parameter, exactly like the + * core `LikeController::beforeAction()` (404 for an unknown record, 403 when the + * viewer cannot see the record's content). + */ + private function getLikeServiceByRecordId(): LikeService + { + $recordId = (int)Yii::$app->request->get('recordId'); + $target = RecordMap::getById($recordId, ContentProvider::class); + + if (!$target) { + throw new NotFoundHttpException(); + } + + if (!$target->content->canView()) { + throw new ForbiddenHttpException(); + } + + return new LikeService($target); + } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ac6ab0ad..95fa1d1a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,8 @@ Changelog ------------------- - Enh: Added session authentication (`enableSessionAuth` setting): API requests may be authenticated by the regular HumHub browser session; state-changing requests require the CSRF token, token auth methods take precedence - Fix: Impersonate token authentication crashed on HumHub 1.19 (`isImpersonated` was removed by the core impersonation refactor, core #8372) +- Enh: Added unstable, UI-coupled endpoints serving the core Vue islands 1:1 via the core JSON services (`comment/window`, `comment//full[...]`, `like/info`, `POST/DELETE like`, `like/user-list`), see `docs/vue-session-api.md` +- Enh: Added guest access to guest-visible island endpoints (`BaseController::$guestAllowedActions`, honored only while guest access is enabled globally) 0.12.2 (July 16, 2026) ---------------------- diff --git a/docs/vue-session-api.md b/docs/vue-session-api.md index a8b0b973..58c69344 100644 --- a/docs/vue-session-api.md +++ b/docs/vue-session-api.md @@ -126,54 +126,93 @@ Core `UserJsonService::serialize()` (shared island shape, `` props): REST `UserDefinitions::getUserShort()`: `id, guid, display_name, url` — snake_case naming, no `imageUrl`/`online`/`contentContainerId`/`imageAlt`. -## 3. Convergence proposal (per endpoint) +## 3. Island endpoints (implemented on this branch) Guiding principle: the core `*JsonService` classes are controller-agnostic — -**reuse them from new REST controllers instead of re-modelling their output in -`Definitions`**. Existing REST endpoints/definitions stay untouched (they are a -public, versioned contract also used by the legal data export and third-party -integrations; injecting viewer-dependent fields there would change their -semantics). The owner explicitly allows new, "internal" REST endpoints. - -| Island need | Proposal | -|---|---| -| Comment window | New `GET /comment/window` (`rest/comment/window/list`) with `contentId`/`parentCommentId`, `commentId`, `direction`, `pageSize` → return `CommentJsonService::serializeWindow()` verbatim | -| Single comment (island shape) | New `GET /comment//full` (`?showBlocked=1`) → `serializeComment()` | -| Create/update for islands | New `POST /comment/full?contentId=…` and `PUT /comment//full` returning `serializeComment()` with core's 422 `errors` contract (or: extend existing actions with a `?format=full` switch — less clean, mixes error contracts) | -| Admin delete w/ notification | New `DELETE /comment//full` accepting `notify`/`message` (mirrors `AdminDeleteCommentForm`) | -| Like state / like / unlike | New `GET /like/info`, `POST /like`, `DELETE /like` keyed by `model`+`pk` (RecordMap), returning `{currentUserLiked, likeCounter}` | -| Like user list | New `GET /like/user-list` returning `{total, users, hasMore, nextPage}` via `UserJsonService` | -| User shape | Use `UserJsonService` in all new endpoints; leave `UserDefinitions` untouched | -| `extensions` batch event | Comes for free by reusing `CommentJsonService` (fires `EVENT_SERIALIZE_COMMENTS`) | - -Net effect: the islands can switch from `/comment/...` core routes to -`/api/v1/...` by swapping the base URL + auth stays the browser session + -CSRF header they already send today. - -Naming/versioning: mark these routes as **internal** (serving the HumHub -frontend, shape may change with core) — either under a `/api/v1/internal/…` -prefix or via documentation flag in swagger — so they don't freeze into the -public API contract. +**they are reused verbatim from new REST controllers instead of re-modelling +their output in `Definitions`**. Existing REST endpoints/definitions stay +untouched (they are a public, versioned contract also used by the legal data +export and third-party integrations; injecting viewer-dependent fields there +would change their semantics). + +All routes below are **UNSTABLE / UI-COUPLED**: they serve the HumHub frontend +(the core Vue islands) 1:1 and may change together with core — they are not +part of the stable public REST contract. This is flagged in every action +docblock; there is no artificial `/internal/` path prefix (owner decision — +naming may still be revisited before merge). + +### Final routes + +| Method + route | Controller action | Core call / contract | +|---|---|---| +| `GET /api/v1/comment/window` | `rest/comment/window/index` | `CommentJsonService::serializeWindow()` — params `contentId`/`parentCommentId`, `commentId`, `direction`, `pageSize`; returns `{comments, prevCount, nextCount, total, rootTotal}` incl. per-root `children` previews and the `extensions` namespace (mirror of core `comment/comment/list`) | +| `GET /api/v1/comment//full` | `rest/comment/window/view` | `CommentJsonService::serializeComment()` — `?showBlocked=1` lifts the blocked-author mask (mirror of core `comment/comment/info`) | +| `POST /api/v1/comment/full` | `rest/comment/window/create` | Comment create from `message`/`fileList`/`parentCommentId` (+`contentId`), one-nesting-level guard, `canComment()` check; returns `serializeComment()` or `422 {"errors": {attr: [...]}}` (mirror of core `comment/comment/create`) | +| `PUT /api/v1/comment//full` | `rest/comment/window/update` | Comment save; returns `serializeComment()` or the 422 `errors` contract (mirror of core `comment/comment/update` POST mode) | +| `GET /api/v1/comment//full/edit` | `rest/comment/window/edit` | `{"message": }` for the editor (mirror of core `comment/comment/update` GET mode) | +| `DELETE /api/v1/comment//full` | `rest/comment/window/delete` | Delete incl. optional `AdminDeleteCommentForm[notify]`/`[message]` author notification; returns `{"success": bool}` (mirror of core `comment/comment/delete`) | +| `GET /api/v1/like/info` | `rest/like/like/info` | `{currentUserLiked, likeCounter}` via `LikeService`, keyed by `recordId` (RecordMap id — exactly what `LikeButton.vue` sends); guest-allowed (mirror of core `like/like/info`) | +| `POST /api/v1/like` | `rest/like/like/like` | `LikeService::like()` after `canLike()`; returns the state shape (mirror of core `like/like/like`) | +| `DELETE /api/v1/like` | `rest/like/like/unlike` | `LikeService::unlike()`; returns the state shape (mirror of core `like/like/unlike`) | +| `GET /api/v1/like/user-list` | `rest/like/like/user-list` | `{total, users, hasMore, nextPage}` with `UserJsonService` rows and the `limit` clamp to `[1, userListPaginationSize]` (mirror of core `like/like/user-list`) | + +Contract notes (all deliberate, for 1:1 island fidelity): + +- Payloads are byte-compatible with the core JSON controllers — no REST + envelope rewrap. Viewer-context fields (`canEdit`/`canDelete`/`likes.liked`), + blocked-author masking, the `guestHideComments` gate, `extensions`, and + `message`+`messageRenderOptions` all come from the delegated core services. +- Errors are HTTP exceptions (404/403 with Yii's JSON error body, same as the + core controllers) and validation failures are `422 {"errors": ...}` — NOT + the module's usual `400 {"code", "message"}` envelope. The pre-existing + comment/like CRUD endpoints keep their envelope unchanged. +- The core `actionDelete` notification block (`CommentDeleted`) is currently + duplicated in `WindowController::actionDelete()` because core has not + extracted it into a service; when the core controllers are removed in favor + of these endpoints, it should move into a core service. +- The admin-delete modal HTML (`comment/comment/get-admin-delete-modal`) + remains a core route — it returns rendered widget HTML, not island JSON. + +### Guest access + +Core allows guests on some of these actions (comment window/single view, like +info) subject to `Content::canView()` and `guestHideComments`. The REST module +previously had no guest mechanism at all (401 for everything). Implemented +minimal mechanism: `BaseController::$guestAllowedActions` — a per-controller +list of action ids wired to the `CompositeAuth` authenticator's standard +`optional` list, honored **only while guest access is enabled globally** +(`AuthHelper::isGuestAccessEnabled()`), mirroring core's +`AccessControl::$guestAllowedActions` semantics. Requests with valid +credentials still authenticate normally; without credentials the action runs +as guest and is responsible for its own guest-safe authorization (all +delegated core services/`canView()` checks already are). + +Declared lists: `WindowController` → `['index', 'view']`; +`LikeController` → `['info']` (its stable CRUD actions stay logged-in only). + +Net effect: the islands can switch from the core `/comment/...`, `/like/...` +routes to `/api/v1/...` by swapping the base URL — auth stays the browser +session + CSRF header they already send today, guest behavior included. ## 4. Open questions for the owner -1. **Shape fidelity:** serve the island payloads 1:1 under `/api/v1` - (recommended above) or migrate the Vue clients to REST-envelope conventions - (offset pagination, `code`/`message` errors)? 1:1 keeps the islands - backend-agnostic and diff-free; REST-envelope would make them "real" public - API consumers but requires island rework (window pagination is UX-relevant). -2. **Internal namespace:** `/api/v1/internal/...` prefix, or plain routes with - an "internal/unstable" documentation flag? +1. **Shape fidelity:** ~~serve the island payloads 1:1 under `/api/v1`?~~ + **Decided & implemented:** 1:1 fidelity, no REST envelope rewrap (see §3). +2. **Internal namespace:** ~~`/api/v1/internal/...` prefix?~~ **Decided:** + plain routes flagged unstable/UI-coupled in docblocks and this document. + Naming may be revisited before merge. 3. **HTML fragments:** `attachmentsHtml` (and the admin-delete modal, which stays a core HTML route) — acceptable in API responses, or should attachments become structured JSON + a client-side renderer first? 4. **Client wiring:** will the islands' fetch wrapper reuse `humhub.client`'s CSRF header mechanism as assumed? (The session-auth CSRF contract relies on `X-CSRF-Token`.) -5. **ImpersonateAuth breakage (pre-existing):** `ImpersonateAuth` sets - `Yii::$app->user->isImpersonated`, a property removed by core 1.19's - impersonation refactor (core PR #8372) — `AuthCest::testImpersonateByAdmin` - fails against current core on a clean checkout. Fix on this branch or - separately? +5. **ImpersonateAuth breakage (pre-existing):** ~~fix on this branch or + separately?~~ **Fixed on this branch** (own commit, intended to be + cherry-picked to `develop` as an independent core-compat PR): the write to + the removed `isImpersonated` property is gone. Applying core 1.19's + impersonation private-content restriction to impersonate-token requests + remains a separate follow-up (the API user component is session-less, so + `Impersonation::isActive()` can never apply as-is). 6. **Rate limiting:** browser-session traffic will multiply API request volume — is throttling needed before the islands switch over? diff --git a/tests/codeception/api/CommentWindowCest.php b/tests/codeception/api/CommentWindowCest.php new file mode 100644 index 00000000..ecf15dd0 --- /dev/null +++ b/tests/codeception/api/CommentWindowCest.php @@ -0,0 +1,299 @@ +/full`), + * see `controllers/comment/WindowController.php`. Fixture baseline: comment 1 by Admin + * on content 1 (Admin's private profile post); content 10 is a public post in Space 2 + * (guest-visible space, User1 is a member). + */ +class CommentWindowCest extends HumHubApiTestCest +{ + /** + * @var string bearer access token of User1 (BearerAccessTokenFixture) + */ + private const USER1_BEARER_TOKEN = '_sB714dci3pUh6FZw5BFA0wB2ri5TfQ-dxs32iaK920BI1eHn7SX0UphARYr4J-duJbF-ZuULdjOuqc1DSH3DB'; + + public function testWindowPagination(ApiTester $I) + { + $I->wantTo('page through a comment window with roots and replies'); + $I->amAdmin(); + + // Fixture comment 1 is the oldest root; add three roots and two replies on root 2 + $root2 = $this->createComment($I, ['message' => 'Root 2', 'contentId' => 1]); + $root3 = $this->createComment($I, ['message' => 'Root 3', 'contentId' => 1]); + $root4 = $this->createComment($I, ['message' => 'Root 4', 'contentId' => 1]); + $this->createComment($I, ['message' => 'Reply 1', 'contentId' => 1, 'parentCommentId' => $root2]); + $this->createComment($I, ['message' => 'Reply 2', 'contentId' => 1, 'parentCommentId' => $root2]); + + // Initial window: the newest `commentsPreviewMax` (2) roots, ascending order + $I->sendGet('comment/window', ['contentId' => 1]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson([ + 'prevCount' => 2, + 'nextCount' => 0, + 'total' => 6, // all comments including replies + 'rootTotal' => 4, // root comments only + ]); + $ids = $I->grabDataFromResponseByJsonPath('$.comments[*].id'); + Assert::assertEquals([$root3, $root4], $ids); + + // "Show previous" from root 3: both remaining older roots fit the single-overflow + // rule (limit 1 + exactly one more), so the window returns them both + $I->sendGet('comment/window', [ + 'contentId' => 1, + 'commentId' => $root3, + 'direction' => 'previous', + 'pageSize' => 1, + ]); + $I->seeResponseCodeIs(200); + $ids = $I->grabDataFromResponseByJsonPath('$.comments[*].id'); + Assert::assertEquals([1, $root2], $ids); + $I->seeResponseContainsJson(['prevCount' => 0, 'nextCount' => 2]); + + // Page size clamp: 0 is clamped to 1, not passed through (which would drop the LIMIT) + $I->sendGet('comment/window', [ + 'contentId' => 1, + 'commentId' => $root4, + 'direction' => 'previous', + 'pageSize' => 0, + ]); + $I->seeResponseCodeIs(200); + $ids = $I->grabDataFromResponseByJsonPath('$.comments[*].id'); + Assert::assertEquals([$root3], $ids); + + // Reply window of root 2 + $I->sendGet('comment/window', ['contentId' => 1, 'parentCommentId' => $root2]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['total' => 2, 'rootTotal' => 4]); + $messages = $I->grabDataFromResponseByJsonPath('$.comments[*].message'); + Assert::assertEquals(['Reply 1', 'Reply 2'], $messages); + + // Child preview embedded in the root's own island shape + $I->sendGet("comment/$root2/full"); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['children' => ['total' => 2, 'hasMore' => false]]); + Assert::assertCount(2, $I->grabDataFromResponseByJsonPath('$.children.items[*].id')); + } + + public function testViewerContextFields(ApiTester $I) + { + $I->wantTo('see viewer-context fields in the island comment shape'); + $I->amAdmin(); + + $I->sendGet('comment/1/full'); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson([ + 'id' => 1, + 'contentId' => 1, + 'parentCommentId' => null, + 'message' => 'Comment 1 of the Post 1', + 'isEdited' => false, + 'blocked' => false, + 'canEdit' => true, + 'canDelete' => true, + 'canAdminDelete' => false, // own comment + 'likes' => ['count' => 0, 'liked' => false], + 'author' => [ + 'guid' => '01e50e0d-82cd-41fc-8b0c-552392f5839c', + 'displayName' => 'Admin Tester', + 'contentContainerId' => 1, + 'online' => null, // viewer looks at themself + ], + ]); + Assert::assertNotEmpty($I->grabDataFromResponseByJsonPath('$.permalink')[0]); + Assert::assertNotEmpty($I->grabDataFromResponseByJsonPath('$.author.imageUrl')[0]); + Assert::assertNotNull($I->grabDataFromResponseByJsonPath('$.messageRenderOptions')[0]); + + $I->sendGet('comment/999/full'); + $I->seeResponseCodeIs(404); + + // Content 1 is Admin's private profile post — not visible to User1. + // (Bearer token instead of a second basic-auth identity: switching the basic-auth + // user mid-test breaks on the authclient collection's per-process login cache.) + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $I->sendGet('comment/1/full'); + $I->seeResponseCodeIs(403); + $I->sendGet('comment/window', ['contentId' => 1]); + $I->seeResponseCodeIs(403); + } + + public function testCreateValidation(ApiTester $I) + { + $I->wantTo('see the 422 validation contract on comment creation'); + $I->amAdmin(); + + $I->sendPost('comment/full', ['contentId' => 1]); + $I->seeResponseCodeIs(422); + $I->seeResponseContainsJson(['errors' => ['message' => ['The comment must not be empty!']]]); + + // One nesting level only + $root = $this->createComment($I, ['message' => 'Root', 'contentId' => 1]); + $reply = $this->createComment($I, ['message' => 'Reply', 'contentId' => 1, 'parentCommentId' => $root]); + $I->sendPost('comment/full', ['message' => 'Nested', 'contentId' => 1, 'parentCommentId' => $reply]); + $I->seeResponseCodeIs(422); + $I->seeResponseContainsJson(['errors' => ['parentCommentId' => ['Comments can only be nested one level deep.']]]); + + $I->sendPost('comment/full', ['message' => 'No such content', 'contentId' => 9999]); + $I->seeResponseCodeIs(404); + } + + public function testUpdateAndEditorFetch(ApiTester $I) + { + $I->wantTo('update a comment and fetch its raw message for the editor'); + $I->amAdmin(); + + $id = $this->createComment($I, ['message' => 'Original', 'contentId' => 1]); + + $I->sendPut("comment/$id/full", ['message' => 'Edited message']); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['id' => $id, 'message' => 'Edited message']); + + $I->sendGet("comment/$id/full/edit"); + $I->seeResponseCodeIs(200); + $I->seeResponseEquals(json_encode(['message' => 'Edited message'])); + + $I->sendPut("comment/$id/full", ['message' => '']); + $I->seeResponseCodeIs(422); + $I->seeResponseContainsJson(['errors' => ['message' => ['The comment must not be empty!']]]); + + // Not the author (and no permission on the content at all) + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $I->sendPut('comment/1/full', ['message' => 'Hijack']); + $I->seeResponseCodeIs(403); + } + + public function testDelete(ApiTester $I) + { + $I->wantTo('delete comments including the admin notify flow'); + + // User1 (bearer token) comments on a public space post, Admin removes it with a notification + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $userCommentId = $this->createComment($I, ['message' => 'To be moderated', 'contentId' => 10]); + + $I->deleteHeader('Authorization'); + $I->amAdmin(); + $I->sendDelete("comment/$userCommentId/full", [ + 'AdminDeleteCommentForm' => ['notify' => 1, 'message' => 'Against the rules'], + ]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['success' => 1]); + $I->seeRecord(Notification::class, ['class' => CommentDeleted::class, 'user_id' => 2]); + $I->sendGet("comment/$userCommentId/full"); + $I->seeResponseCodeIs(404); + + // No delete permission for User1 on Admin's comment (content not even visible); + // the bearer token takes precedence over the still-configured basic auth + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $I->sendDelete('comment/1/full'); + $I->seeResponseCodeIs(403); + + // Plain delete of the own fixture comment + $I->deleteHeader('Authorization'); + $I->sendDelete('comment/1/full'); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['success' => 1]); + $I->sendGet('comment/1/full'); + $I->seeResponseCodeIs(404); + } + + public function testDeleteWithoutPermission(ApiTester $I) + { + $I->wantTo('be rejected when deleting a comment without permission'); + $I->amUser1(); + + $I->sendDelete('comment/1/full'); + $I->seeResponseCodeIs(403); + } + + public function testSessionAndTokenAuth(ApiTester $I) + { + $I->wantTo('use the window endpoints with both session and token auth'); + + // Session auth (User1 = id 2). amLoggedInAs() must run before the first API request + // of the test: the first request replaces the app's user component with the + // session-less API one, after which the Yii2 module can no longer seed a session. + $I->amLoggedInAs(2); + $I->sendGet('comment/window', ['contentId' => 10]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['total' => 0, 'rootTotal' => 0]); + + // Session-authenticated mutation requires the CSRF token + $I->sendPost('comment/full', ['message' => 'No CSRF', 'contentId' => 10]); + $I->seeResponseCodeIs(403); + + $rawToken = Yii::$app->security->generateRandomString(); + $I->setCookie('_csrf', $rawToken); + $I->haveHttpHeader('X-CSRF-Token', Yii::$app->security->maskToken($rawToken)); + $I->sendPost('comment/full', ['message' => 'With CSRF', 'contentId' => 10]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['message' => 'With CSRF', 'canEdit' => true]); + + // Token (bearer) auth — takes precedence over the still-present session (same user + // here) and sees the comment created above + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $I->sendGet('comment/window', ['contentId' => 10]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['total' => 1, 'rootTotal' => 1]); + } + + public function testGuestAccess(ApiTester $I) + { + $I->wantTo('see guest access to comment windows mirror the core controller'); + + // Guest-visible baseline data: a comment on the public post in the guest-visible Space 2. + // Created via bearer token — basic-auth credentials could not be fully cleared again + // for the guest requests below (they are server params, not a header). + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $this->createComment($I, ['message' => 'Public comment', 'contentId' => 10]); + $I->deleteHeader('Authorization'); + + // Guest access disabled (default): 401 like every other API request + $I->sendGet('comment/window', ['contentId' => 10]); + $I->seeResponseCodeIs(401); + + Yii::$app->getModule('user')->settings->set('auth.allowGuestAccess', 1); + + $I->sendGet('comment/window', ['contentId' => 10]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['total' => 1, 'rootTotal' => 1]); + $I->seeResponseContainsJson(['comments' => [['message' => 'Public comment']]]); + + // guestHideComments rejects guests with 403 (enforced by CommentJsonService) + $commentModule = Yii::$app->getModule('comment'); + $commentModule->guestHideComments = true; + try { + $I->sendGet('comment/window', ['contentId' => 10]); + $I->seeResponseCodeIs(403); + } finally { + $commentModule->guestHideComments = false; + } + + // Content that is not guest-visible stays 403 even with guest access enabled + $I->sendGet('comment/window', ['contentId' => 1]); + $I->seeResponseCodeIs(403); + + // Mutations are never guest-accessible + $I->sendPost('comment/full', ['message' => 'Guest comment', 'contentId' => 10]); + $I->seeResponseCodeIs(401); + } + + /** + * Creates a comment through the island-shape endpoint and returns its id. + */ + private function createComment(ApiTester $I, array $params): int + { + $I->sendPost('comment/full', $params); + $I->seeResponseCodeIs(200); + + return (int)$I->grabDataFromResponseByJsonPath('$.id')[0]; + } +} diff --git a/tests/codeception/api/LikeStateCest.php b/tests/codeception/api/LikeStateCest.php new file mode 100644 index 00000000..dfdb49dc --- /dev/null +++ b/tests/codeception/api/LikeStateCest.php @@ -0,0 +1,174 @@ +wantTo('read the like state of a record'); + $I->amAdmin(); + + $recordId = $this->getPostRecordId(1); + + $I->sendGet('like/info', ['recordId' => $recordId]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 2]); + + $I->sendGet('like/info', ['recordId' => 9999]); + $I->seeResponseCodeIs(404); + + // Content 1 is Admin's private profile post — not visible to User1. + // (Bearer token instead of a second basic-auth identity: switching the basic-auth + // user mid-test breaks on the authclient collection's per-process login cache.) + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $I->sendGet('like/info', ['recordId' => $recordId]); + $I->seeResponseCodeIs(403); + } + + public function testLikeToggle(ApiTester $I) + { + $I->wantTo('like and unlike a record'); + $I->amAdmin(); + + $recordId = $this->getPostRecordId(1); + + $I->sendPost("like?recordId=$recordId"); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => true, 'likeCounter' => 3]); + + $I->sendDelete("like?recordId=$recordId"); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 2]); + + // Unlike is idempotent, like the core action + $I->sendDelete("like?recordId=$recordId"); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 2]); + } + + public function testUserList(ApiTester $I) + { + $I->wantTo('page through the users who liked a record'); + $I->amAdmin(); + + $recordId = $this->getPostRecordId(1); + + // Newest like first: user 4 (Andreas), then user 3 (Sara) + $I->sendGet('like/user-list', ['recordId' => $recordId]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['total' => 2, 'hasMore' => false, 'nextPage' => null]); + Assert::assertEquals( + ['Andreas Tester', 'Sara Tester'], + $I->grabDataFromResponseByJsonPath('$.users[*].displayName'), + ); + // UserJsonService user shape + $I->seeResponseContainsJson(['users' => [['guid' => '01e50e0d-82cd-41fc-8b0c-552392f5839f', 'contentContainerId' => 8]]]); + Assert::assertNotEmpty($I->grabDataFromResponseByJsonPath('$.users[0].imageUrl')[0]); + Assert::assertNotEmpty($I->grabDataFromResponseByJsonPath('$.users[0].url')[0]); + + // `limit` is clamped to [1, userListPaginationSize]: 0 becomes 1 instead of "no LIMIT" + $I->sendGet('like/user-list', ['recordId' => $recordId, 'limit' => 0]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['total' => 2, 'hasMore' => true, 'nextPage' => 2]); + Assert::assertEquals(['Andreas Tester'], $I->grabDataFromResponseByJsonPath('$.users[*].displayName')); + + $I->sendGet('like/user-list', ['recordId' => $recordId, 'limit' => 0, 'page' => 2]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['hasMore' => false, 'nextPage' => null]); + Assert::assertEquals(['Sara Tester'], $I->grabDataFromResponseByJsonPath('$.users[*].displayName')); + + // Oversized limits are clamped to the module default, not passed through + $I->sendGet('like/user-list', ['recordId' => $recordId, 'limit' => 999]); + $I->seeResponseCodeIs(200); + Assert::assertCount(2, $I->grabDataFromResponseByJsonPath('$.users[*].displayName')); + } + + public function testSessionAndTokenAuth(ApiTester $I) + { + $I->wantTo('use the like endpoints with both session and token auth'); + + $recordId = $this->getPostRecordId(10); + + // Session auth (User1 = id 2). amLoggedInAs() must run before the first API request + // of the test: the first request replaces the app's user component with the + // session-less API one, after which the Yii2 module can no longer seed a session. + $I->amLoggedInAs(2); + $I->sendGet('like/info', ['recordId' => $recordId]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 0]); + + // Session-authenticated mutation requires the CSRF token + $I->sendPost("like?recordId=$recordId"); + $I->seeResponseCodeIs(403); + + $rawToken = Yii::$app->security->generateRandomString(); + $I->setCookie('_csrf', $rawToken); + $I->haveHttpHeader('X-CSRF-Token', Yii::$app->security->maskToken($rawToken)); + $I->sendPost("like?recordId=$recordId"); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => true, 'likeCounter' => 1]); + + // Token (bearer) auth — takes precedence over the still-present session (same user here) + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $I->sendGet('like/info', ['recordId' => $recordId]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => true, 'likeCounter' => 1]); + } + + public function testGuestAccess(ApiTester $I) + { + $I->wantTo('see guest access to like info mirror the core controller'); + + $recordId = $this->getPostRecordId(10); + + // Guest access disabled (default): 401 like every other API request + $I->sendGet('like/info', ['recordId' => $recordId]); + $I->seeResponseCodeIs(401); + + Yii::$app->getModule('user')->settings->set('auth.allowGuestAccess', 1); + + // `info` is guest-allowed on guest-visible content, like in the core controller + $I->sendGet('like/info', ['recordId' => $recordId]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 0]); + + // Content that is not guest-visible stays denied + $I->sendGet('like/info', ['recordId' => $this->getPostRecordId(1)]); + $I->seeResponseCodeIs(403); + + // Everything else stays logged-in only, mirroring core's guestAllowedActions = ['info'] + $I->sendGet('like/user-list', ['recordId' => $recordId]); + $I->seeResponseCodeIs(401); + $I->sendPost("like?recordId=$recordId"); + $I->seeResponseCodeIs(401); + $I->sendDelete("like?recordId=$recordId"); + $I->seeResponseCodeIs(401); + } + + /** + * Returns the RecordMap id of the given post — what the like island passes as `recordId`. + */ + private function getPostRecordId(int $postId): int + { + return RecordMap::getId(Post::findOne(['id' => $postId])); + } +} From e5f10f4eba2539b6f5e3f7150c35ce93daa763e9 Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy <4736168+luke-@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:42:34 +0200 Subject: [PATCH 5/7] Harden session auth: close off-rule CSRF gap, enforce gates, block impersonation - Register the bare /rest/ catch-all for every request and hard-fail non-API paths in BaseController so no action runs off-rule as an unconstrained, CSRF-exempt request; add per-action verb guards on the mutating endpoints - Enforce the browser gates (2FA and other non-API gates) for session- authenticated requests, which core would otherwise skip by misclassifying the session-less API request as an API request - Reject session-bound admin impersonation on the API (fail closed) since the 1.19 private-content restriction cannot apply to the session-less user - Validate the CSRF token without ever minting a _csrf Set-Cookie - Default enableSessionAuth to disabled like every other auth method --- Events.php | 25 ++-- components/BaseController.php | 12 ++ components/auth/SessionAuth.php | 113 +++++++++++++-- controllers/comment/WindowController.php | 23 +++ controllers/like/LikeController.php | 22 +++ docs/CHANGELOG.md | 2 +- docs/vue-session-api.md | 58 ++++++-- models/ConfigureForm.php | 13 +- tests/codeception/api/CommentWindowCest.php | 53 ++++--- tests/codeception/api/LikeStateCest.php | 57 ++++---- tests/codeception/api/SessionAuthCest.php | 151 ++++++++++++++++++-- 11 files changed, 438 insertions(+), 91 deletions(-) diff --git a/Events.php b/Events.php index bd68ee3f..25db6402 100644 --- a/Events.php +++ b/Events.php @@ -41,6 +41,21 @@ class Events { public static function onBeforeRequest($event) { + // The bare `/rest/...` URL space is reserved for the admin config page and an + // explicit catch-all. These rules must be registered for EVERY request (not only + // `api/` requests): otherwise a bare `/rest//` URL falls through + // to Yii's default routing and resolves straight to a REST controller action — an + // unconstrained, CSRF-exempt plain GET (see docs/vue-session-api.md §5 and the + // defence-in-depth guard in BaseController::beforeAction()). + Yii::$app->urlManager->addRules([ + + // API Config + ['pattern' => 'rest/admin/index', 'route' => 'rest/admin', 'verb' => ['POST', 'GET']], + + // Catch all to ensure verbs + ['pattern' => 'rest/', 'route' => 'rest/error/notfound'], + + ], true); // Only prepare if API request if (!str_starts_with(Yii::$app->request->pathInfo, 'api/')) { @@ -182,16 +197,6 @@ public static function onBeforeRequest($event) ]); - Yii::$app->urlManager->addRules([ - - // API Config - ['pattern' => 'rest/admin/index', 'route' => 'rest/admin', 'verb' => ['POST', 'GET']], - - // Catch all to ensure verbs - ['pattern' => 'rest/', 'route' => 'rest/error/notfound'], - - ], true); - Event::trigger(Module::class, Module::EVENT_REST_API_ADD_RULES); } diff --git a/components/BaseController.php b/components/BaseController.php index 6a7e9aed..ca9531d5 100644 --- a/components/BaseController.php +++ b/components/BaseController.php @@ -17,6 +17,7 @@ use humhub\modules\rest\components\User as UserComponent; use humhub\modules\rest\components\auth\JwtAuth; use humhub\modules\rest\controllers\auth\AuthController; +use humhub\modules\rest\Module; use humhub\modules\rest\models\ConfigureForm; use humhub\modules\user\helpers\AuthHelper; use humhub\modules\user\models\User; @@ -29,6 +30,7 @@ use yii\filters\auth\QueryParamAuth; use yii\helpers\ArrayHelper; use yii\web\JsonParser; +use yii\web\NotFoundHttpException; /** * Class BaseController @@ -117,6 +119,16 @@ public function behaviors() */ public function beforeAction($action) { + // Defence in depth: hard-fail any request that reached a REST controller off the API + // URL rules — i.e. whose path is not under the API prefix (a bare `/rest// + // ` URL). Together with the `rest/` catch-all in + // Events::onBeforeRequest() this guarantees a mutating action can never be executed + // off-rule as an unconstrained, CSRF-exempt plain request. Must run before auth. + if (!str_starts_with(Yii::$app->request->pathInfo, Module::API_URL_PREFIX)) { + Yii::$app->response->format = 'json'; + throw new NotFoundHttpException(); + } + $appUser = Yii::$app->getUser(); Yii::$app->set('user', [ diff --git a/components/auth/SessionAuth.php b/components/auth/SessionAuth.php index b066276f..8c77b876 100644 --- a/components/auth/SessionAuth.php +++ b/components/auth/SessionAuth.php @@ -8,6 +8,8 @@ namespace humhub\modules\rest\components\auth; +use humhub\components\gates\RequestClass; +use humhub\modules\user\components\Impersonation; use Yii; use yii\filters\auth\AuthMethod; use yii\web\ForbiddenHttpException; @@ -46,6 +48,17 @@ * API user component) — core re-establishes the session on any regular page load before a * browser UI issues API calls. * + * - A session-authenticated request is made gate-visible: the same user gates a browser + * session is subject to (2FA and other non-API gates) are enforced here, because the + * session-less API user component would otherwise make core's `GateFilter` misclassify the + * request as an API request and skip them (a 2FA-pending user must not reach the API). + * See {@see enforceOpenGates()} and `docs/vue-session-api.md` §4.4. + * + * - An active admin impersonation is bound to the browser session and cannot use the API on + * this branch: it is detected from the session marker and rejected (fail closed), because + * the session-less API user component prevents core 1.19's impersonation private-content + * restriction from applying. See {@see isImpersonationSession()} and §4.5 of that document. + * * @since 0.13 */ class SessionAuth extends AuthMethod @@ -75,9 +88,69 @@ public function authenticate($user, $request, $response) throw new ForbiddenHttpException('Unable to verify your data submission. Session-authenticated modifying requests require a valid CSRF token (X-CSRF-Token header).'); } + // I3: an admin impersonation is bound to the browser session. Because the API user + // component is session-less, core's `Impersonation::isActive()` cannot detect it and + // its 1.19 private-content restriction would silently not apply — so a session-bound + // impersonation is rejected (fail closed) until the core-side explicit-session signal + // lands (see docs/vue-session-api.md §4.5). + if ($this->isImpersonationSession()) { + throw new ForbiddenHttpException('Impersonation is not supported over the API. Stop the impersonation to continue.'); + } + + // C2: enforce the user gates a browser session must pass (2FA and other non-API gates). + $this->enforceOpenGates(); + return $identity; } + /** + * Whether the current browser session is an active admin impersonation. + * + * The session identity has just been restored, so the session is open and the marker can + * be read directly. Core's {@see Impersonation::isActive()} cannot be used here: it + * short-circuits `false` while `enableSession` is off, which the API user component pins. + */ + private function isImpersonationSession(): bool + { + return Yii::$app->has('session') && Yii::$app->session->has(Impersonation::SESSION_KEY); + } + + /** + * Enforces the user gates a browser session is subject to on a session-authenticated + * request. + * + * Core's `GateFilter::getRequestClass()` infers {@see RequestClass::Api} purely from + * `Yii::$app->user->enableSession === false`, which `BaseController` pins for every REST + * request. A cookie-authenticated request is therefore misclassified as an API request + * and skips every gate that does not apply to API requests (2FA, legal, onboarding, …) — + * so a user who passed only the first factor could call every endpoint. + * + * This re-classifies the request the way `GateFilter` would for a real browser session + * (never Api) and rejects it when a gate is open. Gates that also apply to + * {@see RequestClass::Api} (e.g. must-change-password, maintenance mode) are already + * enforced by the core `GateFilter` on this same request, so only the misclassification + * gap is closed here — they are not applied twice. + * + * @throws ForbiddenHttpException when an open gate intercepts the request + */ + private function enforceOpenGates(): void + { + if (!Yii::$app->has('gateManager')) { + return; + } + + $request = Yii::$app->request; + $requestClass = ($request->getIsAjax() || $request->getIsPjax()) + ? RequestClass::Ajax + : RequestClass::FullPage; + + $gate = Yii::$app->gateManager->findOpenGate($requestClass, (string)Yii::$app->requestedRoute); + + if ($gate !== null && !$gate->appliesTo(RequestClass::Api)) { + throw new ForbiddenHttpException('This action requires completing the "' . $gate->getId() . '" step first.'); + } + } + /** * Restores the identity from the HumHub browser session. * @@ -107,21 +180,39 @@ private function getSessionIdentity(User $user) } /** - * Validates the CSRF token for state-changing requests; safe methods (GET/HEAD/OPTIONS) - * always pass — see `yii\web\Request::validateCsrfToken()`. + * Validates the CSRF token for state-changing requests without ever minting one; safe + * methods (GET/HEAD/OPTIONS) always pass. * - * `BaseController::beforeAction()` disables the CSRF cookie so API responses never emit - * one, but the browser's true CSRF token lives in the `_csrf` cookie (HumHub core default), - * so cookie lookup must be re-enabled while validating. + * `yii\web\Request::validateCsrfToken()` is deliberately NOT used: it calls + * `getCsrfToken()`, which — with the CSRF cookie enabled — generates a fresh token and + * emits a `_csrf` Set-Cookie whenever the request carries none, clobbering the page's real + * token (M5). Instead the browser's true token is read straight from the `_csrf` cookie + * (HumHub core default) and compared timing-safely against the client-supplied token; no + * cookie means no valid token. No API response ever sets a cookie this way. */ private function validateCsrfToken(Request $request): bool { - $enableCsrfCookie = $request->enableCsrfCookie; - $request->enableCsrfCookie = true; - try { - return $request->validateCsrfToken(); - } finally { - $request->enableCsrfCookie = $enableCsrfCookie; + if (in_array($request->getMethod(), $request->csrfTokenSafeMethods, true)) { + return true; + } + + // The `_csrf` cookie holds the raw token; missing cookie ⇒ no valid token. + $trueToken = $request->getCookies()->getValue($request->csrfParam); + if (!is_string($trueToken) || $trueToken === '') { + return false; } + + // The client sends the masked token via the X-CSRF-Token header (or the `_csrf` body + // param), exactly like `humhub.client` does in the browser. + $clientToken = $request->getCsrfTokenFromHeader() ?? $request->getBodyParam($request->csrfParam); + if (!is_string($clientToken) || $clientToken === '') { + return false; + } + + $security = Yii::$app->security; + + // `unmaskToken()` recovers the raw token from the masked client value for a timing-safe + // comparison — no token generation, no Set-Cookie. + return $security->compareString($security->unmaskToken($clientToken), $trueToken); } } diff --git a/controllers/comment/WindowController.php b/controllers/comment/WindowController.php index 66d56d46..d11b668f 100644 --- a/controllers/comment/WindowController.php +++ b/controllers/comment/WindowController.php @@ -16,6 +16,8 @@ use humhub\modules\content\models\Content; use humhub\modules\rest\components\BaseController; use Yii; +use yii\filters\VerbFilter; +use yii\helpers\ArrayHelper; use yii\web\ForbiddenHttpException; use yii\web\NotFoundHttpException; @@ -54,6 +56,27 @@ class WindowController extends BaseController */ protected array $guestAllowedActions = ['index', 'view']; + /** + * Per-action HTTP method guards so a mutating action can never run on a safe method, + * even if it were ever reached off the URL rules — mirrors core `CommentController`'s + * `RULE_POST => ['create']` verb constraint. Wrong verb → 405. + * + * @inheritdoc + */ + public function behaviors() + { + return ArrayHelper::merge(parent::behaviors(), [ + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'create' => ['POST'], + 'update' => ['PUT', 'PATCH'], + 'delete' => ['DELETE'], + ], + ], + ]); + } + /** * Resolves the target comment / parent comment / content exactly like the core * controller's `beforeAction()`: by `id`, `parentCommentId` or `contentId` diff --git a/controllers/like/LikeController.php b/controllers/like/LikeController.php index dc8e5709..c8b386c4 100644 --- a/controllers/like/LikeController.php +++ b/controllers/like/LikeController.php @@ -12,6 +12,8 @@ use humhub\modules\user\models\User; use humhub\modules\user\services\UserJsonService; use Yii; +use yii\filters\VerbFilter; +use yii\helpers\ArrayHelper; use yii\web\ForbiddenHttpException; use yii\web\NotFoundHttpException; @@ -24,6 +26,26 @@ class LikeController extends BaseController * the like state of content they can view, everything else stays logged-in only. */ protected array $guestAllowedActions = ['info']; + + /** + * Per-action HTTP method guards so a mutating action can never run on a safe method, + * even if it were ever reached off the URL rules — mirrors core `LikeController`'s + * `forcePostRequest()`. Wrong verb → 405. + * + * @inheritdoc + */ + public function behaviors() + { + return ArrayHelper::merge(parent::behaviors(), [ + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'like' => ['POST'], + 'unlike' => ['DELETE'], + ], + ], + ]); + } public function actionFindByObject() { $object = RecordMap::getByModelAndPk( diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 95fa1d1a..88085eea 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,7 +3,7 @@ Changelog 0.13.0 (Unreleased) ------------------- -- Enh: Added session authentication (`enableSessionAuth` setting): API requests may be authenticated by the regular HumHub browser session; state-changing requests require the CSRF token, token auth methods take precedence +- Enh: Added session authentication (`enableSessionAuth` setting, disabled by default): API requests may be authenticated by the regular HumHub browser session; state-changing requests require the CSRF token, token auth methods take precedence, 2FA/other browser gates are enforced, and active impersonation is rejected - Fix: Impersonate token authentication crashed on HumHub 1.19 (`isImpersonated` was removed by the core impersonation refactor, core #8372) - Enh: Added unstable, UI-coupled endpoints serving the core Vue islands 1:1 via the core JSON services (`comment/window`, `comment//full[...]`, `like/info`, `POST/DELETE like`, `like/user-list`), see `docs/vue-session-api.md` - Enh: Added guest access to guest-visible island endpoints (`BaseController::$guestAllowedActions`, honored only while guest access is enabled globally) diff --git a/docs/vue-session-api.md b/docs/vue-session-api.md index 58c69344..6b30e12f 100644 --- a/docs/vue-session-api.md +++ b/docs/vue-session-api.md @@ -19,9 +19,13 @@ method, in addition to the existing token methods. 6. **`SessionAuth` (if `enableSessionAuth`) — always last** **Token wins:** a request carrying a valid token is authenticated as the token -user even when a session cookie is present. An *invalid* token falls through to -session auth (standard CompositeAuth fall-through). Guests without token and -session get the usual 401 JSON. +user even when a session cookie is present. Note the fall-through is not uniform: +only `JwtAuth` returns `null` on failure (falling through to session auth); the +Bearer/QueryParam/Basic/Impersonate methods throw on an invalid credential, so a +malformed token on a login-required action yields 401 rather than silently +downgrading to the session. On the guest-allowed actions (`optional` list) an +invalid token downgrades to guest. Guests without token and session get the usual +401 JSON. ### CSRF contract @@ -33,15 +37,19 @@ session get the usual 401 JSON. `Events::onBeforeRequest`). - GET/HEAD/OPTIONS are exempt. Token-authenticated requests remain CSRF-exempt exactly as before. -- Implementation detail: `BaseController` keeps `enableCsrfCookie = false` for - API responses; `SessionAuth` re-enables cookie lookup only while validating, - because the browser's true token lives in the `_csrf` cookie (core default). +- Implementation detail: `SessionAuth` does **not** call + `Request::validateCsrfToken()` (that method generates and Set-Cookies a fresh + `_csrf` token when the request carries none, which would clobber the browser + page's real token). It reads the raw token straight from the `_csrf` cookie + (core default), unmasks the client-supplied token and compares timing-safely. + No API response ever emits a `_csrf` Set-Cookie. ### Setting - `enableSessionAuth` (module setting, checkbox on the admin config form, - `ConfigureForm`). **Default: enabled** on this branch — owner decision for - the Vue experiment; the upstream default may change on merge. + `ConfigureForm`). **Default: disabled**, like every other auth method — a + module update must never silently open a new authentication surface. The + dev/Vue-islands instance enables it explicitly in the admin config form. ### Allowlist decision @@ -69,6 +77,40 @@ user allowlist gate (`BaseController::isUserEnabled()`): component stays session-less (`enableSession = false`); `SessionAuth` reads the session through a temporary window only. +### 4.4 Gate enforcement (2FA and other non-API gates) + +Core's `GateFilter::getRequestClass()` infers `RequestClass::Api` purely from +`Yii::$app->user->enableSession === false`, which `BaseController` pins for every +REST request. A cookie-authenticated request would therefore be misclassified as +an API request and skip every gate that does not apply to API requests (2FA, +legal, onboarding, …) — so a user who passed only the first factor could reach +every endpoint. `SessionAuth` closes this: after restoring the identity it +re-classifies the request the way `GateFilter` would for a real browser session +(Ajax/FullPage, never Api) via `gateManager->findOpenGate()` and throws a 403 +JSON when an open gate intercepts it. Gates that also apply to `Api` (e.g. +must-change-password, maintenance mode) are already enforced by the core +`GateFilter` on the same request and are not applied twice. + +**Core-side follow-up:** the correct long-term fix is an explicit +"authenticated-by-session" signal on the request that `GateFilter` consumes, +instead of inferring the class from `enableSession`. The module-side guard above +is the interim; it should be revisited when this lands in core. + +### 4.5 Impersonation (fail-closed on this branch) + +`Impersonation::isActive()` short-circuits `false` while `enableSession` is off, +so core 1.19's private-content restriction (core #8372) would silently not apply +to a session-authenticated impersonation — an impersonating admin would see +through the API the private content the web UI hides. Both cases are handled: + +- **Impersonate token** (`ImpersonateAuth`): the removed `isImpersonated` write + is gone (commit "Fix impersonate token auth on HumHub 1.19"); the restriction + is session-bound and does not apply to token requests. Re-applying an + equivalent restriction to impersonate-token API access is a tracked follow-up. +- **Session impersonation** (`SessionAuth`): rejected outright (403) — detected + from the `Impersonation::SESSION_KEY` session marker — until the core-side + explicit-session signal (§4.4) lets the restriction evaluate correctly. + ## 2. Endpoint gap analysis: core Vue islands vs. current REST module What the islands consume today (core `enh/vuejs-integration`) vs. what diff --git a/models/ConfigureForm.php b/models/ConfigureForm.php index 313e23bc..2a73ca09 100644 --- a/models/ConfigureForm.php +++ b/models/ConfigureForm.php @@ -25,11 +25,12 @@ class ConfigureForm extends Model /** * @var bool whether API requests may be authenticated by the regular HumHub browser * session, see {@see \humhub\modules\rest\components\auth\SessionAuth} for the full - * security contract (CSRF requirement, allowlist bypass, token precedence). + * security contract (CSRF requirement, gate enforcement, allowlist bypass, token + * precedence). * - * Default ENABLED on this branch — owner decision for the Vue islands UI experiment, - * which drives the browser UI through the REST API. The upstream default may change - * when this is merged. + * Default DISABLED, like every other auth method: a module update must never silently + * open a new authentication surface. The dev/Vue-islands instance enables it explicitly + * in the admin config form. */ public $enableSessionAuth; @@ -68,7 +69,7 @@ public function attributeLabels() public function attributeHints() { return [ - 'enableSessionAuth' => 'Allows requests carrying a valid, logged-in HumHub browser session to use the API without a token. Modifying requests (POST/PUT/PATCH/DELETE) additionally require the CSRF token. Not restricted by the user list below — a session grants nothing beyond what the same user can already do in the web interface.', + 'enableSessionAuth' => 'Disabled by default. Allows requests carrying a valid, logged-in HumHub browser session to use the API without a token. Modifying requests (POST/PUT/PATCH/DELETE) additionally require the CSRF token. Not restricted by the user list below — a session grants nothing beyond what the same user can already do in the web interface.', 'enabledForAllUsers' => 'Please note, it is not recommended to enable the API for all users yet.
This option affects JWT and HTTP Basic Authentication methods only.', 'enabledUsers' => 'This option affects JWT and HTTP Basic Authentication methods only.', ]; @@ -85,7 +86,7 @@ public function loadSettings() $this->enableBasicAuth = (bool)$settings->get('enableBasicAuth'); $this->enableBearerAuth = (bool)$settings->get('enableBearerAuth'); $this->enableQueryParamAuth = (bool)$settings->get('enableQueryParamAuth'); - $this->enableSessionAuth = (bool)$settings->get('enableSessionAuth', true); + $this->enableSessionAuth = (bool)$settings->get('enableSessionAuth'); $this->enabledForAllUsers = (bool)$settings->get('enabledForAllUsers'); $this->enabledUsers = (array)$settings->getSerialized('enabledUsers'); diff --git a/tests/codeception/api/CommentWindowCest.php b/tests/codeception/api/CommentWindowCest.php index ecf15dd0..77e02a06 100644 --- a/tests/codeception/api/CommentWindowCest.php +++ b/tests/codeception/api/CommentWindowCest.php @@ -218,31 +218,40 @@ public function testSessionAndTokenAuth(ApiTester $I) { $I->wantTo('use the window endpoints with both session and token auth'); - // Session auth (User1 = id 2). amLoggedInAs() must run before the first API request - // of the test: the first request replaces the app's user component with the - // session-less API one, after which the Yii2 module can no longer seed a session. - $I->amLoggedInAs(2); - $I->sendGet('comment/window', ['contentId' => 10]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['total' => 0, 'rootTotal' => 0]); + // Session auth defaults OFF (see ConfigureForm); enable it for the session portion and + // restore the default so it does not leak to other cests on the shared DB. + $settings = Yii::$app->getModule('rest')->settings; + $settings->set('enableSessionAuth', true); - // Session-authenticated mutation requires the CSRF token - $I->sendPost('comment/full', ['message' => 'No CSRF', 'contentId' => 10]); - $I->seeResponseCodeIs(403); + try { + // Session auth (User1 = id 2). amLoggedInAs() must run before the first API request + // of the test: the first request replaces the app's user component with the + // session-less API one, after which the Yii2 module can no longer seed a session. + $I->amLoggedInAs(2); + $I->sendGet('comment/window', ['contentId' => 10]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['total' => 0, 'rootTotal' => 0]); - $rawToken = Yii::$app->security->generateRandomString(); - $I->setCookie('_csrf', $rawToken); - $I->haveHttpHeader('X-CSRF-Token', Yii::$app->security->maskToken($rawToken)); - $I->sendPost('comment/full', ['message' => 'With CSRF', 'contentId' => 10]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['message' => 'With CSRF', 'canEdit' => true]); + // Session-authenticated mutation requires the CSRF token + $I->sendPost('comment/full', ['message' => 'No CSRF', 'contentId' => 10]); + $I->seeResponseCodeIs(403); - // Token (bearer) auth — takes precedence over the still-present session (same user - // here) and sees the comment created above - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $I->sendGet('comment/window', ['contentId' => 10]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['total' => 1, 'rootTotal' => 1]); + $rawToken = Yii::$app->security->generateRandomString(); + $I->setCookie('_csrf', $rawToken); + $I->haveHttpHeader('X-CSRF-Token', Yii::$app->security->maskToken($rawToken)); + $I->sendPost('comment/full', ['message' => 'With CSRF', 'contentId' => 10]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['message' => 'With CSRF', 'canEdit' => true]); + + // Token (bearer) auth — takes precedence over the still-present session (same user + // here) and sees the comment created above + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $I->sendGet('comment/window', ['contentId' => 10]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['total' => 1, 'rootTotal' => 1]); + } finally { + $settings->set('enableSessionAuth', false); + } } public function testGuestAccess(ApiTester $I) diff --git a/tests/codeception/api/LikeStateCest.php b/tests/codeception/api/LikeStateCest.php index dfdb49dc..1ffd8aa4 100644 --- a/tests/codeception/api/LikeStateCest.php +++ b/tests/codeception/api/LikeStateCest.php @@ -108,30 +108,39 @@ public function testSessionAndTokenAuth(ApiTester $I) $recordId = $this->getPostRecordId(10); - // Session auth (User1 = id 2). amLoggedInAs() must run before the first API request - // of the test: the first request replaces the app's user component with the - // session-less API one, after which the Yii2 module can no longer seed a session. - $I->amLoggedInAs(2); - $I->sendGet('like/info', ['recordId' => $recordId]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 0]); - - // Session-authenticated mutation requires the CSRF token - $I->sendPost("like?recordId=$recordId"); - $I->seeResponseCodeIs(403); - - $rawToken = Yii::$app->security->generateRandomString(); - $I->setCookie('_csrf', $rawToken); - $I->haveHttpHeader('X-CSRF-Token', Yii::$app->security->maskToken($rawToken)); - $I->sendPost("like?recordId=$recordId"); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => true, 'likeCounter' => 1]); - - // Token (bearer) auth — takes precedence over the still-present session (same user here) - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $I->sendGet('like/info', ['recordId' => $recordId]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => true, 'likeCounter' => 1]); + // Session auth defaults OFF (see ConfigureForm); enable it for the session portion and + // restore the default so it does not leak to other cests on the shared DB. + $settings = Yii::$app->getModule('rest')->settings; + $settings->set('enableSessionAuth', true); + + try { + // Session auth (User1 = id 2). amLoggedInAs() must run before the first API request + // of the test: the first request replaces the app's user component with the + // session-less API one, after which the Yii2 module can no longer seed a session. + $I->amLoggedInAs(2); + $I->sendGet('like/info', ['recordId' => $recordId]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 0]); + + // Session-authenticated mutation requires the CSRF token + $I->sendPost("like?recordId=$recordId"); + $I->seeResponseCodeIs(403); + + $rawToken = Yii::$app->security->generateRandomString(); + $I->setCookie('_csrf', $rawToken); + $I->haveHttpHeader('X-CSRF-Token', Yii::$app->security->maskToken($rawToken)); + $I->sendPost("like?recordId=$recordId"); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => true, 'likeCounter' => 1]); + + // Token (bearer) auth — takes precedence over the still-present session (same user here) + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $I->sendGet('like/info', ['recordId' => $recordId]); + $I->seeResponseCodeIs(200); + $I->seeResponseContainsJson(['currentUserLiked' => true, 'likeCounter' => 1]); + } finally { + $settings->set('enableSessionAuth', false); + } } public function testGuestAccess(ApiTester $I) diff --git a/tests/codeception/api/SessionAuthCest.php b/tests/codeception/api/SessionAuthCest.php index f6a98c74..1ad28a3a 100644 --- a/tests/codeception/api/SessionAuthCest.php +++ b/tests/codeception/api/SessionAuthCest.php @@ -3,9 +3,16 @@ namespace rest\api; use Codeception\Util\HttpCode; +use humhub\components\gates\GateInitEvent; +use humhub\components\gates\GateManager; +use humhub\components\gates\UserGate; +use humhub\modules\comment\models\Comment; +use humhub\modules\user\components\Impersonation; +use PHPUnit\Framework\Assert; use rest\ApiTester; use tests\codeception\_support\HumHubApiTestCest; use Yii; +use yii\base\Event; /** * Tests for browser session authentication (SessionAuth), see @@ -13,6 +20,11 @@ */ class SessionAuthCest extends HumHubApiTestCest { + /** + * @var int fixture id of Admin (core user fixture) + */ + private const ADMIN_ID = 1; + /** * @var int fixture id of User1 (core user fixture) */ @@ -23,6 +35,53 @@ class SessionAuthCest extends HumHubApiTestCest */ private const USER1_BEARER_TOKEN = '_sB714dci3pUh6FZw5BFA0wB2ri5TfQ-dxs32iaK920BI1eHn7SX0UphARYr4J-duJbF-ZuULdjOuqc1DSH3DB'; + /** + * Registers an always-open gate that applies to browser-session (non-API) requests only — + * the shape of the 2FA gate — for {@see testSessionAuthEnforcesOpenGate()}. + */ + public static function _registerOpenSessionGate(GateInitEvent $event): void + { + $event->manager->register(new class extends UserGate { + public function getId(): string + { + return 'rest-test-gate'; + } + + public function getSortOrder(): int + { + return self::SORT_SECOND_FACTOR; + } + + public function isOpen(): bool + { + return true; + } + + public function getRoute(): array + { + return ['/user/auth/logout']; + } + + public function isCacheable(): bool + { + return false; + } + }); + } + + public function _before() + { + parent::_before(); + // Session auth defaults OFF (see ConfigureForm); enable it for these tests. + Yii::$app->getModule('rest')->settings->set('enableSessionAuth', true); + } + + public function _after() + { + // Restore the default so the setting does not leak into other cests on the shared DB. + Yii::$app->getModule('rest')->settings->set('enableSessionAuth', false); + } + public function testSessionAuthenticatedGet(ApiTester $I) { $I->wantTo('authenticate a GET request by browser session'); @@ -73,15 +132,21 @@ public function testSessionAuthDisabledSetting(ApiTester $I) { $I->wantTo('see session auth rejected when disabled while token auth still works'); - Yii::$app->getModule('rest')->settings->set('enableSessionAuth', false); - - $I->amLoggedInAs(self::USER1_ID); - $I->sendGet('auth/current'); - $I->seeCodeResponseContainsJson(HttpCode::UNAUTHORIZED, ['message' => 'Your request was made with invalid credentials.']); - - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $I->sendGet('auth/current'); - $I->seeSuccessResponseContainsJson($I->getUserDefinition('User1')); + $settings = Yii::$app->getModule('rest')->settings; + $settings->set('enableSessionAuth', false); + + try { + $I->amLoggedInAs(self::USER1_ID); + $I->sendGet('auth/current'); + $I->seeCodeResponseContainsJson(HttpCode::UNAUTHORIZED, ['message' => 'Your request was made with invalid credentials.']); + + $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); + $I->sendGet('auth/current'); + $I->seeSuccessResponseContainsJson($I->getUserDefinition('User1')); + } finally { + // _before() enabled session auth for this cest; restore that state for later tests. + $settings->set('enableSessionAuth', true); + } } public function testTokenWinsOverSession(ApiTester $I) @@ -96,4 +161,72 @@ public function testTokenWinsOverSession(ApiTester $I) $I->sendGet('auth/current'); $I->seeSuccessResponseContainsJson($I->getUserDefinition('Admin')); } + + public function testOffRuleMutationIsBlocked(ApiTester $I) + { + $I->wantTo('see a bare /rest/... URL blocked instead of executing a mutating action (C1)'); + + // Session of an admin who is allowed to delete fixture comment 1. + $I->amLoggedInAs(self::ADMIN_ID); + + // A bare, unprefixed /rest// URL must never resolve to a controller + // action. Before the fix this executed WindowController::actionDelete as a plain GET — + // no verb constraint, no CSRF check (a SameSite=Lax cross-site top-level GET CSRF hole). + $I->sendGet('http://localhost:8080/rest/comment/window/delete?id=1'); + $I->seeResponseCodeIs(404); + + // The targeted comment must still exist — the delete never ran. + Assert::assertNotNull(Comment::findOne(['id' => 1]), 'Off-rule GET must not have deleted the comment'); + } + + public function testCsrfValidationNeverMintsCookie(ApiTester $I) + { + $I->wantTo('see CSRF validation reject without ever Set-Cookie-ing a fresh _csrf token (M5)'); + + $I->amLoggedInAs(self::USER1_ID); + + // Modifying request without a _csrf cookie or token: rejected 403, and the response must + // NOT mint a _csrf Set-Cookie (which would clobber the browser page's real token). + $I->sendPatch('notification/mark-as-seen'); + $I->seeResponseCodeIs(403); + + foreach ((array)$I->grabHttpHeader('Set-Cookie', false) as $setCookie) { + Assert::assertStringNotContainsString('_csrf', (string)$setCookie, 'API response must not Set-Cookie a _csrf token'); + } + } + + public function testSessionAuthEnforcesOpenGate(ApiTester $I) + { + $I->wantTo('see an open non-API gate (e.g. a pending 2FA check) reject a session request (C2)'); + + Event::on(GateManager::class, GateManager::EVENT_INIT_GATES, [self::class, '_registerOpenSessionGate']); + try { + $I->amLoggedInAs(self::USER1_ID); + + // Without the gate-visibility fix the session-less API user component makes core's + // GateFilter classify this as an API request and skip the gate → 200. It must be 403. + $I->sendGet('auth/current'); + $I->seeResponseCodeIs(403); + } finally { + Event::off(GateManager::class, GateManager::EVENT_INIT_GATES, [self::class, '_registerOpenSessionGate']); + } + } + + public function testSessionImpersonationIsRejected(ApiTester $I) + { + $I->wantTo('see a session-bound admin impersonation rejected on the API (I3, fail closed)'); + + $I->amLoggedInAs(self::USER1_ID); + + // Simulate an active impersonation: core stores the impersonator id under this session + // key (Impersonation::SESSION_KEY). The session-less API user component would otherwise + // hide the impersonation and lift core 1.19's private-content restriction. + Yii::$app->session->set(Impersonation::SESSION_KEY, ['id' => self::ADMIN_ID, 'duration' => 0]); + try { + $I->sendGet('auth/current'); + $I->seeResponseCodeIs(403); + } finally { + Yii::$app->session->remove(Impersonation::SESSION_KEY); + } + } } From 6ec65d0a73a28f4622d3719fa9e6a814f862feb0 Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy Date: Sun, 23 Aug 2026 17:26:35 +0200 Subject: [PATCH 6/7] Build on core's API framework instead of shipping one HumHub 1.19 ships the HTTP API framework in core (`humhub\components\api`), with its own endpoints under `/api/v2`. This module keeps what is genuinely integration territory - the token authentication methods, the user allowlist, the admin UI and `/api/v1` - and stops duplicating the rest: - `components/auth/AuthMethods` bundles the token methods (JWT, Bearer, query param, Basic, Impersonate) in one place, used by this module's own controllers and contributed to every core API controller through `BaseController::EVENT_COLLECT_AUTH_METHODS`. An installation with this module can therefore call the core endpoints with a token; a core-only installation has browser-session authentication and nothing else. - The module's own session authentication is gone (`SessionAuth`, the `enableSessionAuth` setting and its admin field). Browser-session access is a core opt-in per endpoint, so `/api/v1` is token-only again - less attack surface than before, since session auth deliberately bypasses the user allowlist and the endpoints here are written for token clients. - The `/rest/...` URL space is sealed off for every request (admin page rule plus catch-all), and a REST controller reached off the `api/v1/` prefix fails with a 404 before authentication runs. Documentation: `docs/api-stack.md` describes this module's role in the platform API stack, and `docs/swagger/v2/` documents core's `/api/v2` endpoints until core ships its own sources - in its own directory so that move is a rename, not a rewrite. `build-all.sh` renders it to `docs/html/v2/` and no longer builds a page for the shared-components file. Requires HumHub 1.19. --- Events.php | 35 +- components/BaseController.php | 81 +-- components/auth/AuthMethods.php | 91 +++ components/auth/SessionAuth.php | 218 ------- config.php | 4 + controllers/comment/WindowController.php | 283 --------- controllers/like/LikeController.php | 142 ----- docs/CHANGELOG.md | 7 +- docs/MANUAL.md | 15 +- docs/api-stack.md | 110 ++++ docs/html/common.html | 145 ----- docs/html/v2/account.html | 391 ++++++++++++ docs/html/v2/comment.html | 623 ++++++++++++++++++++ docs/html/v2/like.html | 525 +++++++++++++++++ docs/swagger/build-all.sh | 27 +- docs/swagger/v2/account.yaml | 72 +++ docs/swagger/v2/comment.yaml | 405 +++++++++++++ docs/swagger/v2/common.yaml | 183 ++++++ docs/swagger/v2/like.yaml | 170 ++++++ docs/vue-session-api.md | 260 -------- models/ConfigureForm.php | 18 +- tests/codeception/api/CommentWindowCest.php | 308 ---------- tests/codeception/api/LikeStateCest.php | 183 ------ tests/codeception/api/SessionAuthCest.php | 232 -------- views/admin/index.php | 1 - 25 files changed, 2639 insertions(+), 1890 deletions(-) create mode 100644 components/auth/AuthMethods.php delete mode 100644 components/auth/SessionAuth.php delete mode 100644 controllers/comment/WindowController.php create mode 100644 docs/api-stack.md delete mode 100644 docs/html/common.html create mode 100644 docs/html/v2/account.html create mode 100644 docs/html/v2/comment.html create mode 100644 docs/html/v2/like.html create mode 100644 docs/swagger/v2/account.yaml create mode 100644 docs/swagger/v2/comment.yaml create mode 100644 docs/swagger/v2/common.yaml create mode 100644 docs/swagger/v2/like.yaml delete mode 100644 docs/vue-session-api.md delete mode 100644 tests/codeception/api/CommentWindowCest.php delete mode 100644 tests/codeception/api/LikeStateCest.php delete mode 100644 tests/codeception/api/SessionAuthCest.php diff --git a/Events.php b/Events.php index 25db6402..94325c57 100644 --- a/Events.php +++ b/Events.php @@ -8,6 +8,7 @@ namespace humhub\modules\rest; +use humhub\components\api\AuthMethodsEvent; use humhub\components\Event; use humhub\modules\activity\models\Activity; use humhub\modules\comment\models\Comment; @@ -17,6 +18,7 @@ use humhub\modules\like\models\Like; use humhub\modules\notification\models\Notification; use humhub\modules\post\models\Post; +use humhub\modules\rest\components\auth\AuthMethods; use humhub\modules\rest\definitions\ActivityDefinitions; use humhub\modules\rest\definitions\CommentDefinitions; use humhub\modules\rest\definitions\FileDefinitions; @@ -45,8 +47,8 @@ public static function onBeforeRequest($event) // explicit catch-all. These rules must be registered for EVERY request (not only // `api/` requests): otherwise a bare `/rest//` URL falls through // to Yii's default routing and resolves straight to a REST controller action — an - // unconstrained, CSRF-exempt plain GET (see docs/vue-session-api.md §5 and the - // defence-in-depth guard in BaseController::beforeAction()). + // unconstrained, CSRF-exempt plain GET (the same reasoning behind core's own + // off-prefix guard, see the defence-in-depth check in BaseController::beforeAction()). Yii::$app->urlManager->addRules([ // API Config @@ -142,27 +144,11 @@ public static function onBeforeRequest($event) ['pattern' => 'comment/content/', 'route' => 'rest/comment/comment/find-by-content', 'verb' => 'GET'], ['pattern' => 'comment/parent/', 'route' => 'rest/comment/comment/find-by-parent', 'verb' => 'GET'], - // Comment: island shape (UNSTABLE / UI-coupled, mirrors the core comment Vue endpoints — - // see controllers/comment/WindowController.php and docs/vue-session-api.md) - ['pattern' => 'comment/window', 'route' => 'rest/comment/window/index', 'verb' => ['GET', 'HEAD']], - ['pattern' => 'comment/full', 'route' => 'rest/comment/window/create', 'verb' => 'POST'], - ['pattern' => 'comment//full', 'route' => 'rest/comment/window/view', 'verb' => ['GET', 'HEAD']], - ['pattern' => 'comment//full', 'route' => 'rest/comment/window/update', 'verb' => ['PUT', 'PATCH']], - ['pattern' => 'comment//full', 'route' => 'rest/comment/window/delete', 'verb' => 'DELETE'], - ['pattern' => 'comment//full/edit', 'route' => 'rest/comment/window/edit', 'verb' => ['GET', 'HEAD']], - // Like ['pattern' => 'like/', 'route' => 'rest/like/like/view', 'verb' => ['GET', 'HEAD']], ['pattern' => 'like/', 'route' => 'rest/like/like/delete', 'verb' => 'DELETE'], ['pattern' => 'like/find-by-object', 'route' => 'rest/like/like/find-by-object', 'verb' => 'GET'], - // Like: island shape (UNSTABLE / UI-coupled, mirrors the core like Vue endpoints — - // see the corresponding actions in controllers/like/LikeController.php and docs/vue-session-api.md) - ['pattern' => 'like/info', 'route' => 'rest/like/like/info', 'verb' => ['GET', 'HEAD']], - ['pattern' => 'like/user-list', 'route' => 'rest/like/like/user-list', 'verb' => ['GET', 'HEAD']], - ['pattern' => 'like', 'route' => 'rest/like/like/like', 'verb' => 'POST'], - ['pattern' => 'like', 'route' => 'rest/like/like/unlike', 'verb' => 'DELETE'], - // Post ['pattern' => 'post/', 'route' => 'rest/post/post/find', 'verb' => ['GET', 'HEAD']], ['pattern' => 'post/', 'route' => 'rest/post/post/view', 'verb' => ['GET', 'HEAD']], @@ -200,6 +186,19 @@ public static function onBeforeRequest($event) Event::trigger(Module::class, Module::EVENT_REST_API_ADD_RULES); } + /** + * Contributes this module's authentication methods to every API controller of the + * platform, so the core endpoints (`/api/v2`) can be called with a token too - see + * {@see AuthMethods} for the list and `docs/api-stack.md` for the model. Core appends its own + * session authentication after them, keeping the "token wins" ordering intact. + * + * @since 0.13 + */ + public static function onCollectApiAuthMethods(AuthMethodsEvent $event): void + { + $event->authMethods = array_merge($event->authMethods, AuthMethods::collect()); + } + private static function addModuleNotFoundRoutes($moduleId) { /* @var Module $module */ diff --git a/components/BaseController.php b/components/BaseController.php index ca9531d5..dac367a2 100644 --- a/components/BaseController.php +++ b/components/BaseController.php @@ -11,23 +11,15 @@ use humhub\components\access\ControllerAccess; use humhub\components\Controller; use humhub\modules\content\models\Content; -use humhub\modules\rest\components\auth\ImpersonateAuth; -use humhub\modules\rest\components\auth\SessionAuth; +use humhub\modules\rest\components\auth\AuthMethods; use humhub\modules\rest\components\behaviors\LanguagePickerBehavior; use humhub\modules\rest\components\User as UserComponent; -use humhub\modules\rest\components\auth\JwtAuth; -use humhub\modules\rest\controllers\auth\AuthController; use humhub\modules\rest\Module; -use humhub\modules\rest\models\ConfigureForm; -use humhub\modules\user\helpers\AuthHelper; use humhub\modules\user\models\User; use Yii; use yii\data\Pagination; use yii\db\ActiveQuery; use yii\filters\auth\CompositeAuth; -use yii\filters\auth\HttpBasicAuth; -use yii\filters\auth\HttpBearerAuth; -use yii\filters\auth\QueryParamAuth; use yii\helpers\ArrayHelper; use yii\web\JsonParser; use yii\web\NotFoundHttpException; @@ -57,56 +49,15 @@ abstract class BaseController extends Controller */ protected $doNotInterceptActionIds = ['*']; - /** - * @var string[] ids of actions guests may call without any authentication, mirroring core's - * `AccessControl::$guestAllowedActions`. Only honored while guest access is enabled globally - * ({@see AuthHelper::isGuestAccessEnabled()}) — with guest access disabled, guests keep - * getting the usual 401, exactly like the corresponding core web controllers. Implemented - * via the authenticator's standard `optional` list: requests carrying valid credentials - * (token or session) are still authenticated normally and run with that identity, while - * requests without (or with invalid) credentials run as guest — standard Yii `optional` - * semantics. Actions listed here remain responsible for their own guest-safe authorization - * (e.g. `Content::canView()`). - * - * @since 0.13 - */ - protected array $guestAllowedActions = []; - public function behaviors() { return ArrayHelper::merge([ 'authenticator' => [ 'class' => CompositeAuth::class, - 'optional' => AuthHelper::isGuestAccessEnabled() ? $this->guestAllowedActions : [], - 'authMethods' => ArrayHelper::merge( - ConfigureForm::getInstance()->enableJwtAuth ? [[ - 'class' => JwtAuth::class, - ]] : [], - ConfigureForm::getInstance()->enableBearerAuth ? [[ - 'class' => HttpBearerAuth::class, - ]] : [], - ConfigureForm::getInstance()->enableBearerAuth && ConfigureForm::getInstance()->enableQueryParamAuth ? [[ - 'class' => QueryParamAuth::class, - ]] : [], - ConfigureForm::getInstance()->enableBasicAuth ? [[ - 'class' => HttpBasicAuth::class, - 'auth' => function ($username, $password) { - if (($identity = AuthController::authByUserAndPassword($username, $password)) && $this->isUserEnabled($identity)) { - return $identity; - } - - return null; - }, - ]] : [], - [[ - 'class' => ImpersonateAuth::class, - ]], - // Session auth must stay LAST in the chain: every token method takes - // precedence over the browser session, see the SessionAuth docblock. - ConfigureForm::getInstance()->enableSessionAuth ? [[ - 'class' => SessionAuth::class, - ]] : [], - ), + // The same methods this module contributes to core's API controllers, see + // {@see AuthMethods}. `/api/v1` is token-only: browser-session + // authentication is a core opt-in, per controller. + 'authMethods' => AuthMethods::collect(), ], 'languagePicker' => [ 'class' => LanguagePickerBehavior::class, @@ -129,19 +80,12 @@ public function beforeAction($action) throw new NotFoundHttpException(); } - $appUser = Yii::$app->getUser(); - Yii::$app->set('user', [ 'class' => UserComponent::class, 'identityClass' => User::class, // Always session-less: token logins (`yii\web\User::login()`) must never write - // into the browser session. SessionAuth restores the session identity through - // its own temporary window instead — see SessionAuth::getSessionIdentity(). + // into the browser session. 'enableSession' => false, - // Session-authenticated requests honor the same idle/absolute session expiry - // rules as the regular web UI (irrelevant for the session-less token methods). - 'authTimeout' => $appUser->authTimeout, - 'absoluteAuthTimeout' => $appUser->absoluteAuthTimeout, ]); Yii::$app->response->format = 'json'; @@ -173,18 +117,7 @@ public function actionNotSupported() */ public function isUserEnabled(User $user) { - $config = new ConfigureForm(); - $config->loadSettings(); - - if (!empty($config->enabledForAllUsers)) { - return true; - } - - if (in_array($user->guid, (array)$config->enabledUsers)) { - return true; - } - - return false; + return AuthMethods::isUserEnabled($user); } diff --git a/components/auth/AuthMethods.php b/components/auth/AuthMethods.php new file mode 100644 index 00000000..97600744 --- /dev/null +++ b/components/auth/AuthMethods.php @@ -0,0 +1,91 @@ +enableJwtAuth ? [[ + 'class' => JwtAuth::class, + ]] : [], + $config->enableBearerAuth ? [[ + 'class' => HttpBearerAuth::class, + ]] : [], + $config->enableBearerAuth && $config->enableQueryParamAuth ? [[ + 'class' => QueryParamAuth::class, + ]] : [], + $config->enableBasicAuth ? [[ + 'class' => HttpBasicAuth::class, + 'auth' => function ($username, $password) { + if (($identity = AuthController::authByUserAndPassword($username, $password)) && static::isUserEnabled($identity)) { + return $identity; + } + + return null; + }, + ]] : [], + [[ + 'class' => ImpersonateAuth::class, + ]], + ); + } + + /** + * Whether the given user may use the API at all - the "Enabled for all registered users" + * setting and the user allowlist below it, which per its own admin hint applies to the + * JWT and HTTP Basic methods only. + */ + public static function isUserEnabled(User $user): bool + { + $config = ConfigureForm::getInstance(); + + if (!empty($config->enabledForAllUsers)) { + return true; + } + + return in_array($user->guid, (array)$config->enabledUsers); + } +} diff --git a/components/auth/SessionAuth.php b/components/auth/SessionAuth.php deleted file mode 100644 index 8c77b876..00000000 --- a/components/auth/SessionAuth.php +++ /dev/null @@ -1,218 +0,0 @@ -session; - if (!$session->getHasSessionId() && !$session->getIsActive()) { - return null; - } - - $identity = $this->getSessionIdentity($user); - if ($identity === null) { - return null; - } - - if (!$this->validateCsrfToken($request)) { - throw new ForbiddenHttpException('Unable to verify your data submission. Session-authenticated modifying requests require a valid CSRF token (X-CSRF-Token header).'); - } - - // I3: an admin impersonation is bound to the browser session. Because the API user - // component is session-less, core's `Impersonation::isActive()` cannot detect it and - // its 1.19 private-content restriction would silently not apply — so a session-bound - // impersonation is rejected (fail closed) until the core-side explicit-session signal - // lands (see docs/vue-session-api.md §4.5). - if ($this->isImpersonationSession()) { - throw new ForbiddenHttpException('Impersonation is not supported over the API. Stop the impersonation to continue.'); - } - - // C2: enforce the user gates a browser session must pass (2FA and other non-API gates). - $this->enforceOpenGates(); - - return $identity; - } - - /** - * Whether the current browser session is an active admin impersonation. - * - * The session identity has just been restored, so the session is open and the marker can - * be read directly. Core's {@see Impersonation::isActive()} cannot be used here: it - * short-circuits `false` while `enableSession` is off, which the API user component pins. - */ - private function isImpersonationSession(): bool - { - return Yii::$app->has('session') && Yii::$app->session->has(Impersonation::SESSION_KEY); - } - - /** - * Enforces the user gates a browser session is subject to on a session-authenticated - * request. - * - * Core's `GateFilter::getRequestClass()` infers {@see RequestClass::Api} purely from - * `Yii::$app->user->enableSession === false`, which `BaseController` pins for every REST - * request. A cookie-authenticated request is therefore misclassified as an API request - * and skips every gate that does not apply to API requests (2FA, legal, onboarding, …) — - * so a user who passed only the first factor could call every endpoint. - * - * This re-classifies the request the way `GateFilter` would for a real browser session - * (never Api) and rejects it when a gate is open. Gates that also apply to - * {@see RequestClass::Api} (e.g. must-change-password, maintenance mode) are already - * enforced by the core `GateFilter` on this same request, so only the misclassification - * gap is closed here — they are not applied twice. - * - * @throws ForbiddenHttpException when an open gate intercepts the request - */ - private function enforceOpenGates(): void - { - if (!Yii::$app->has('gateManager')) { - return; - } - - $request = Yii::$app->request; - $requestClass = ($request->getIsAjax() || $request->getIsPjax()) - ? RequestClass::Ajax - : RequestClass::FullPage; - - $gate = Yii::$app->gateManager->findOpenGate($requestClass, (string)Yii::$app->requestedRoute); - - if ($gate !== null && !$gate->appliesTo(RequestClass::Api)) { - throw new ForbiddenHttpException('This action requires completing the "' . $gate->getId() . '" step first.'); - } - } - - /** - * Restores the identity from the HumHub browser session. - * - * `BaseController::beforeAction()` configures the API user component session-less - * (`enableSession = false`) so that token logins can never write into the browser session - * (`yii\web\User::login()` would otherwise regenerate the session id and rebind the session - * to the token user). Instead of enabling sessions for the whole request, the session - * identity is restored through a temporary window here: `yii\web\User::getIdentity()` - * caches the restored identity on the component, so everything after this call — including - * `Yii::$app->user` access in actions — behaves as usual while the component stays - * session-less for writes. - * - * This runs the full `yii\web\User::renewAuthStatus()` machinery: session auth key - * validation plus the same `authTimeout` / `absoluteAuthTimeout` expiry rules as the web UI - * (the timeouts are copied from the application's user component in - * `BaseController::beforeAction()`). - */ - private function getSessionIdentity(User $user) - { - $enableSession = $user->enableSession; - $user->enableSession = true; - try { - return $user->getIdentity(); - } finally { - $user->enableSession = $enableSession; - } - } - - /** - * Validates the CSRF token for state-changing requests without ever minting one; safe - * methods (GET/HEAD/OPTIONS) always pass. - * - * `yii\web\Request::validateCsrfToken()` is deliberately NOT used: it calls - * `getCsrfToken()`, which — with the CSRF cookie enabled — generates a fresh token and - * emits a `_csrf` Set-Cookie whenever the request carries none, clobbering the page's real - * token (M5). Instead the browser's true token is read straight from the `_csrf` cookie - * (HumHub core default) and compared timing-safely against the client-supplied token; no - * cookie means no valid token. No API response ever sets a cookie this way. - */ - private function validateCsrfToken(Request $request): bool - { - if (in_array($request->getMethod(), $request->csrfTokenSafeMethods, true)) { - return true; - } - - // The `_csrf` cookie holds the raw token; missing cookie ⇒ no valid token. - $trueToken = $request->getCookies()->getValue($request->csrfParam); - if (!is_string($trueToken) || $trueToken === '') { - return false; - } - - // The client sends the masked token via the X-CSRF-Token header (or the `_csrf` body - // param), exactly like `humhub.client` does in the browser. - $clientToken = $request->getCsrfTokenFromHeader() ?? $request->getBodyParam($request->csrfParam); - if (!is_string($clientToken) || $clientToken === '') { - return false; - } - - $security = Yii::$app->security; - - // `unmaskToken()` recovers the raw token from the masked client value for a timing-safe - // comparison — no token generation, no Set-Cookie. - return $security->compareString($security->unmaskToken($clientToken), $trueToken); - } -} diff --git a/config.php b/config.php index f8a42122..dfa30b93 100644 --- a/config.php +++ b/config.php @@ -6,6 +6,7 @@ * @license https://www.humhub.com/licences */ +use humhub\components\api\BaseController; use humhub\components\Application; return [ @@ -14,6 +15,9 @@ 'namespace' => 'humhub\modules\rest', 'events' => [ [Application::class, Application::EVENT_BEFORE_REQUEST, ['\humhub\modules\rest\Events', 'onBeforeRequest']], + // Token authentication for the platform's own API controllers (`/api/v2`), see + // Events::onCollectApiAuthMethods() + [BaseController::class, BaseController::EVENT_COLLECT_AUTH_METHODS, ['\humhub\modules\rest\Events', 'onCollectApiAuthMethods']], ['humhub\modules\legal\services\ExportService', 'collectUserData', ['humhub\modules\rest\Events', 'onLegalModuleUserDataExport']], ], ]; diff --git a/controllers/comment/WindowController.php b/controllers/comment/WindowController.php deleted file mode 100644 index d11b668f..00000000 --- a/controllers/comment/WindowController.php +++ /dev/null @@ -1,283 +0,0 @@ - ['create']` verb constraint. Wrong verb → 405. - * - * @inheritdoc - */ - public function behaviors() - { - return ArrayHelper::merge(parent::behaviors(), [ - 'verbs' => [ - 'class' => VerbFilter::class, - 'actions' => [ - 'create' => ['POST'], - 'update' => ['PUT', 'PATCH'], - 'delete' => ['DELETE'], - ], - ], - ]); - } - - /** - * Resolves the target comment / parent comment / content exactly like the core - * controller's `beforeAction()`: by `id`, `parentCommentId` or `contentId` - * (query or body param). - * - * @inheritdoc - */ - public function beforeAction($action) - { - if (!parent::beforeAction($action)) { - return false; - } - - $commentId = (int)Yii::$app->request->get('id', Yii::$app->request->post('id')); - $parentCommentId = (int)Yii::$app->request->get( - 'parentCommentId', - Yii::$app->request->post('parentCommentId'), - ); - $contentId = (int)Yii::$app->request->get('contentId', Yii::$app->request->post('contentId')); - - if ($commentId) { - $this->comment = Comment::findOne(['id' => $commentId]); - $this->content = $this->comment?->content; - $this->parentComment = $this->comment?->parentComment; - } elseif ($parentCommentId) { - $this->parentComment = Comment::findOne(['id' => $parentCommentId]); - $this->content = $this->parentComment?->content; - } elseif ($contentId) { - $this->content = Content::findOne(['id' => $contentId]); - } - - if (!$this->content) { - throw new NotFoundHttpException(); - } - - if (!$this->content->canView()) { - throw new ForbiddenHttpException(); - } - - return true; - } - - /** - * Returns a window of comments (cursor pagination, or an anchored permalink window - * when no `direction` is given). Mirrors the core `comment/comment/list` action. - * - * @see CommentJsonService::serializeWindow() - */ - public function actionIndex() - { - $direction = Yii::$app->request->get('direction'); - $commentId = Yii::$app->request->get('commentId'); - $pageSize = Yii::$app->request->get('pageSize'); - - $service = CommentJsonService::create($this->parentComment ?? $this->content); - - return $service->serializeWindow( - $commentId !== null ? (int)$commentId : null, - $direction, - $pageSize !== null ? (int)$pageSize : null, - ); - } - - /** - * Returns a single comment in the island shape. `showBlocked=1` lifts the - * blocked-author mask only. Mirrors the core `comment/comment/info` action. - */ - public function actionView() - { - if ($this->comment === null) { - throw new NotFoundHttpException(); - } - - if (!$this->comment->canView()) { - throw new ForbiddenHttpException(); - } - - $showBlocked = (bool)Yii::$app->request->get('showBlocked'); - - return CommentJsonService::create($this->comment)->serializeComment($this->comment, $showBlocked); - } - - /** - * Creates a comment from a JSON payload (`message`, `fileList`, `parentCommentId`), - * enforcing at most one nesting level. Mirrors the core `comment/comment/create` - * action, including the `422 {"errors": ...}` validation contract. - */ - public function actionCreate() - { - if (!$this->getCommentModule()->canComment($this->content)) { - throw new ForbiddenHttpException(); - } - - if ($this->parentComment !== null && $this->parentComment->parent_comment_id !== null) { - Yii::$app->response->statusCode = 422; - - return [ - 'errors' => [ - 'parentCommentId' => [Yii::t('CommentModule.base', 'Comments can only be nested one level deep.')], - ], - ]; - } - - $model = new Comment(); - $model->content_id = $this->content->id; - $model->parent_comment_id = $this->parentComment?->id; - - if ($model->load(Yii::$app->request->post(), '') && $model->save()) { - return CommentJsonService::create($model)->serializeComment($model); - } - - Yii::$app->response->statusCode = 422; - - return ['errors' => $model->errors]; - } - - /** - * Returns the raw markdown message of an editable comment for the editor — - * the core `comment/comment/update` GET mode. - */ - public function actionEdit() - { - if ($this->comment === null) { - throw new NotFoundHttpException(); - } - - if (!$this->comment->canEdit()) { - throw new ForbiddenHttpException(); - } - - return ['message' => $this->comment->message]; - } - - /** - * Saves a comment from a JSON payload (`message`, `fileList`) and returns the - * updated comment in the island shape — the core `comment/comment/update` POST - * mode, including the `422 {"errors": ...}` validation contract. - */ - public function actionUpdate() - { - if ($this->comment === null) { - throw new NotFoundHttpException(); - } - - if (!$this->comment->canEdit()) { - throw new ForbiddenHttpException(); - } - - if ($this->comment->load(Yii::$app->request->post(), '') && $this->comment->save()) { - return CommentJsonService::create($this->comment)->serializeComment($this->comment); - } - - Yii::$app->response->statusCode = 422; - - return ['errors' => $this->comment->errors]; - } - - /** - * Deletes a comment, optionally notifying the author with a reason - * (`AdminDeleteCommentForm[notify]` / `AdminDeleteCommentForm[message]` body - * params — the fields of the core admin-delete modal). Mirrors the core - * `comment/comment/delete` action, returning `{"success": bool}`. - * - * The notification block is copied from the core action verbatim — core has not - * extracted it into a service yet; when the core controller is removed in favor - * of these endpoints, it should move into one (tracked in `docs/vue-session-api.md`). - */ - public function actionDelete() - { - if ($this->comment === null) { - throw new NotFoundHttpException(); - } - - if (!$this->comment->canDelete()) { - throw new ForbiddenHttpException(); - } - - $form = new AdminDeleteCommentForm(); - - if ($form->load(Yii::$app->request->post()) && $form->validate() && $form->notify) { - $commentDeleted = CommentDeleted::instance() - ->from(Yii::$app->user->getIdentity()) - ->about($this->comment->content->getPolymorphicRelation()) - ->payload( - [ - 'commentText' => (new CommentDeleted())->getContentPreview($this->comment, 30), - 'reason' => $form->message, - ], - ); - $commentDeleted->saveRecord($this->comment->createdBy); - - $commentDeleted->record->updateAttributes([ - 'send_web_notifications' => 1, - ]); - } - - return ['success' => $this->comment->delete()]; - } - - private function getCommentModule(): Module - { - return Yii::$app->getModule('comment'); - } -} diff --git a/controllers/like/LikeController.php b/controllers/like/LikeController.php index c8b386c4..4ddb871e 100644 --- a/controllers/like/LikeController.php +++ b/controllers/like/LikeController.php @@ -9,43 +9,10 @@ use humhub\modules\rest\components\BaseController; use humhub\modules\rest\definitions\LikeDefinitions; use humhub\modules\like\models\Like; -use humhub\modules\user\models\User; -use humhub\modules\user\services\UserJsonService; use Yii; -use yii\filters\VerbFilter; -use yii\helpers\ArrayHelper; -use yii\web\ForbiddenHttpException; -use yii\web\NotFoundHttpException; class LikeController extends BaseController { - /** - * @inheritdoc - * - * Mirrors the core `LikeController`'s `guestAllowedActions = ['info']`: guests may read - * the like state of content they can view, everything else stays logged-in only. - */ - protected array $guestAllowedActions = ['info']; - - /** - * Per-action HTTP method guards so a mutating action can never run on a safe method, - * even if it were ever reached off the URL rules — mirrors core `LikeController`'s - * `forcePostRequest()`. Wrong verb → 405. - * - * @inheritdoc - */ - public function behaviors() - { - return ArrayHelper::merge(parent::behaviors(), [ - 'verbs' => [ - 'class' => VerbFilter::class, - 'actions' => [ - 'like' => ['POST'], - 'unlike' => ['DELETE'], - ], - ], - ]); - } public function actionFindByObject() { $object = RecordMap::getByModelAndPk( @@ -107,114 +74,5 @@ public function actionDelete($id) return $this->returnError(500, 'Internal error while delete like!'); } - /** - * Returns the current like state of the record — a 1:1 mirror of the core - * `like/like/info` action consumed by the like Vue island (`LikeButton.vue`). - * - * UNSTABLE / UI-COUPLED: this and the actions below serve the HumHub frontend and - * follow the shape the core islands consume — they are not part of the stable public - * REST contract and may change together with core (see `docs/vue-session-api.md`). - * They deliberately throw HTTP exceptions (same status codes as core) instead of the - * module's usual `{"code", "message"}` envelope. - * - * @since 0.13 - */ - public function actionInfo() - { - $likeService = $this->getLikeServiceByRecordId(); - - return [ - 'currentUserLiked' => $likeService->hasLiked(), - 'likeCounter' => $likeService->getCount(), - ]; - } - - /** - * Likes the record given by `recordId` — mirror of the core `like/like/like` action. - * - * @since 0.13 - */ - public function actionLike() - { - $likeService = $this->getLikeServiceByRecordId(); - - if (!$likeService->canLike()) { - throw new ForbiddenHttpException(); - } - - $likeService->like(); - - return [ - 'currentUserLiked' => $likeService->hasLiked(), - 'likeCounter' => $likeService->getCount(), - ]; - } - - /** - * Unlikes the record given by `recordId` — mirror of the core `like/like/unlike` action. - * - * @since 0.13 - */ - public function actionUnlike() - { - $likeService = $this->getLikeServiceByRecordId(); - - $likeService->unlike(); - - return [ - 'currentUserLiked' => $likeService->hasLiked(), - 'likeCounter' => $likeService->getCount(), - ]; - } - - /** - * Returns a page of the users who liked the record, in the `{total, users, hasMore, - * nextPage}` shape of the core `like/like/user-list` action (rows serialized by - * {@see UserJsonService}), including its `limit` clamp to `[1, userListPaginationSize]`. - * - * @since 0.13 - */ - public function actionUserList() - { - $likeService = $this->getLikeServiceByRecordId(); - - $defaultLimit = Yii::$app->getModule('user')->userListPaginationSize; - $limit = max(1, min((int)Yii::$app->request->get('limit', $defaultLimit), $defaultLimit)); - $page = max(1, (int)Yii::$app->request->get('page', 1)); - - $query = $likeService->getUserQuery(); - $total = (clone $query)->count(); - $users = $query->offset(($page - 1) * $limit)->limit($limit)->all(); - $hasMore = ($page * $limit) < $total; - - $userJsonService = new UserJsonService(); - - return [ - 'total' => $total, - 'users' => array_map(fn(User $user) => $userJsonService->serialize($user), $users), - 'hasMore' => $hasMore, - 'nextPage' => $hasMore ? $page + 1 : null, - ]; - } - - /** - * Resolves the like target from the `recordId` request parameter, exactly like the - * core `LikeController::beforeAction()` (404 for an unknown record, 403 when the - * viewer cannot see the record's content). - */ - private function getLikeServiceByRecordId(): LikeService - { - $recordId = (int)Yii::$app->request->get('recordId'); - $target = RecordMap::getById($recordId, ContentProvider::class); - - if (!$target) { - throw new NotFoundHttpException(); - } - - if (!$target->content->canView()) { - throw new ForbiddenHttpException(); - } - return new LikeService($target); - } } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 88085eea..48e0cdc4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,10 +3,11 @@ Changelog 0.13.0 (Unreleased) ------------------- -- Enh: Added session authentication (`enableSessionAuth` setting, disabled by default): API requests may be authenticated by the regular HumHub browser session; state-changing requests require the CSRF token, token auth methods take precedence, 2FA/other browser gates are enforced, and active impersonation is rejected +- Enh: The module's authentication methods (JWT, Bearer, query param, Basic, Impersonate) now apply to the API endpoints of core 1.19 as well (`/api/v2`), contributed through the core API framework; `/api/v1` stays token-only, browser-session authentication is a core opt-in per endpoint (see `docs/api-stack.md`) +- Enh: Documented core's `/api/v2` endpoints (comment, like, account, incl. the batched `like/states` and per-comment `permissions` calls) under `docs/swagger/v2/`, until core ships its own API documentation; `build-all.sh` renders them to `docs/html/v2/` and no longer builds a page for the shared-components file +- Enh: Hardened the `/rest/...` URL space — the admin-page and catch-all rules are registered for every request, and a REST controller reached off the `api/v1/` prefix (e.g. through Yii's fallback routing) now fails with a 404 before authentication runs - Fix: Impersonate token authentication crashed on HumHub 1.19 (`isImpersonated` was removed by the core impersonation refactor, core #8372) -- Enh: Added unstable, UI-coupled endpoints serving the core Vue islands 1:1 via the core JSON services (`comment/window`, `comment//full[...]`, `like/info`, `POST/DELETE like`, `like/user-list`), see `docs/vue-session-api.md` -- Enh: Added guest access to guest-visible island endpoints (`BaseController::$guestAllowedActions`, honored only while guest access is enabled globally) +- Chg: Replies are validated to nest at most one level (core `Comment` model rule, surfaced through the validation envelope) 0.12.2 (July 16, 2026) ---------------------- diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 6d8977f9..5e094b79 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -6,14 +6,19 @@ Following RESTful API endpoints are available. **Base URL:** -The base url for all APIs is: `https://yourhost/api/v1/` +The base url for the endpoints of this module is: `https://yourhost/api/v1/` + +Since HumHub 1.19 the platform itself ships API endpoints under +`https://yourhost/api/v2/`, in modernized conventions (ISO-8601 timestamps, camelCase, +plain HTTP status codes). This module's authentication methods apply to them as well — see +the **v2 APIs** below and `docs/api-stack.md`. **Language** Logged-in user's language will be used. Can be overwritten by `Accept-Language` header. -**Core APIs:** +**v1 APIs (this module):** - [User](https://marketplace.humhub.com/module/rest/docs/html/user.html) - [Content](https://marketplace.humhub.com/module/rest/docs/html/content.html) @@ -27,6 +32,12 @@ Logged-in user's language will be used. Can be overwritten by `Accept-Language` - [Space](https://marketplace.humhub.com/module/rest/docs/html/space.html) - [Content Topics](https://marketplace.humhub.com/module/rest/docs/html/topic.html) +**v2 APIs (endpoints shipped by HumHub core, 1.19+):** + +- [Comment](https://marketplace.humhub.com/module/rest/docs/html/v2/comment.html) +- [Like](https://marketplace.humhub.com/module/rest/docs/html/v2/like.html) +- [Account](https://marketplace.humhub.com/module/rest/docs/html/v2/account.html) + **Module APIs** - [Calendar](https://marketplace.humhub.com/module/calendar/docs/swagger/calendar.html) diff --git a/docs/api-stack.md b/docs/api-stack.md new file mode 100644 index 00000000..eec15c27 --- /dev/null +++ b/docs/api-stack.md @@ -0,0 +1,110 @@ +# This module and the platform API stack + +HumHub 1.19 ships an HTTP API framework in core (`humhub\components\api\`): base +controller, request/response conventions, URL-rule registration, the serialize +extension point and browser-session authentication. Core's own endpoints live next +to the modules that own them (`humhub\modules\\controllers\api\`) and answer +under `/api/v2`. The full picture is in the core docs, +`docs/develop/concept-api.md`. + +This module builds on that framework and adds what a platform API needs beyond it. + +## What this module provides + +- **Machine authentication** — JWT, HTTP Bearer, query-param bearer, HTTP Basic and + impersonate tokens, plus the user allowlist and the admin configuration UI. + Collected in `components/auth/AuthMethods.php`. +- **`/api/v1`** — this module's own endpoint surface and wire shapes, unchanged + (`docs/swagger/*.yaml` → `docs/html/`). +- **`components/BaseController`** — the base class other modules extend for their + own `/api/v1` endpoints (see `DEVELOPER.md`). + +## Authentication model + +Two questions worth keeping apart: + +**Which methods exist?** Core ships browser-session authentication only. This module +adds the token methods and contributes them to *every* API controller of the platform +through core's collect event: + +```php +// config.php +[BaseController::class, BaseController::EVENT_COLLECT_AUTH_METHODS, [Events::class, 'onCollectApiAuthMethods']], +``` + +So on an installation with this module, the core endpoints can be called with a token +too; on a core-only installation they can only be reached from a logged-in browser +session. + +**Which endpoints accept which method?** Contributed token methods apply everywhere. +Browser-session authentication is an opt-in *per controller* +(`humhub\components\api\BaseController::$enableSessionAuth`, default off) and core +enables it only for the endpoints its own UI needs. `/api/v1` is therefore +token-only: a session cookie authenticates nothing here. + +That asymmetry is deliberate. Session authentication bypasses the user allowlist — +it has to, since the browser UI must work for every logged-in user — and endpoints +written for token clients may have their authorization written for that narrower +threat model. They must not silently become reachable from any logged-in browser +session. + +Ordering is part of the contract: contributed token methods run BEFORE session +authentication, so a request carrying a valid token authenticates as the token user +even when a session cookie is present ("token wins"). The fall-through is not +uniform: `JwtAuth` returns `null` on failure (the next method gets a turn), while the +Bearer/QueryParam/Basic/Impersonate methods throw on an invalid credential, so a +malformed token yields 401 instead of downgrading. On core endpoints that allow guests +(`humhub\components\api\BaseController::$guestAllowedActions`, honored only while guest +access is enabled platform-wide) a missing or invalid credential downgrades to guest; +`/api/v1` has no guest-readable actions. + +## Guarding the URL space + +API controllers are reachable through Yii's fallback routing (`/rest// +`, `//api//`), which would bypass the API URL +rules and their verb constraints. Both layers guard against it: + +- this module prepends a `rest/` catch-all for every request and + `BaseController::beforeAction()` hard-fails any request whose path is not under + `api/v1/`, +- core does the same for its own controllers (`ApiRules::offPrefixGuard()` plus the + `api/v2/` check in its base controller). + +Both require pretty URLs, as the API always has. + +## Documentation layout + +`docs/swagger/` holds the OpenAPI sources, one document per module, rendered to +`docs/html/` by `build-all.sh`: + +- the flat files are this module's `/api/v1` surface, +- `v2/` documents the endpoints **core** ships (`docs/html/v2/`), with `v2/common.yaml` + holding the shared schemas, parameters, error responses and security schemes. + +The v2 documents live here only until core ships the Swagger sources for its own +endpoints — keeping them in their own directory is what makes that a move rather than a +rename, and it leaves every published `/api/v1` documentation URL untouched. + +## Version bounds + +This module version requires core 1.19 (`humhub.minVersion`) — it uses the core +framework's collect event. The previous module line still needs a `humhub.maxVersion`, so +the marketplace does not offer a version without the core stack for 1.19+. Those fields +are marketplace metadata, not runtime enforcement: core does not evaluate them when +loading a module, so an administrator copying an outdated module in by hand bypasses +them. + +## Open points + +1. **Rate limiting** — browser-session traffic (the core Vue islands) multiplies API + request volume. Throttling should be considered before a stable release. +2. **Impersonate-token restriction** — applying core 1.19's impersonation + private-content restriction to impersonate-token API access is a tracked + follow-up. +3. **v1 on the core stack** — `/api/v1` still carries its own base controller, error + envelope and serializers. Reimplementing it over the core framework and + serializers is the next consolidation step; its wire shapes stay as documented in + `docs/swagger/`. +4. **v2 conventions** — ISO-8601 timestamps and camelCase field names are what core's + `/api/v2` uses; the corresponding modernization of the v1 shapes is collected in + the issue "v2: ISO-8601 timestamps and shape modernization". diff --git a/docs/html/common.html b/docs/html/common.html deleted file mode 100644 index 5b71e5fe..00000000 --- a/docs/html/common.html +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - Common API Components - - - - - - - - - -

Common API Components (1.0.0)

Download OpenAPI specification:

- - - - diff --git a/docs/html/v2/account.html b/docs/html/v2/account.html new file mode 100644 index 00000000..c7a8f753 --- /dev/null +++ b/docs/html/v2/account.html @@ -0,0 +1,391 @@ + + + + + + HumHub - Account API (v2) + + + + + + + + + +

HumHub - Account API (v2) (2.0.0)

Download OpenAPI specification:

E-mail: info@humhub.com License: AGPLv2

Welcome to the HumHub account API reference, v2 — the authenticated user's own data.

+

These endpoints are shipped by HumHub core (1.19+), not by the REST API module — the +module only contributes its authentication methods to them and documents them here until +core ships its own API documentation. See the core docs, +docs/develop/concept-api.md.

+

Conventions of this API generation, differing from /api/v1: camelCase field names, +ISO-8601 timestamps with offset, and plain HTTP status codes instead of a +{code, message} envelope.

+

Requests may authenticate with any token method the REST API module offers. Core's own +browser session is accepted too for these endpoints, which is what the platform's Vue UI +uses.

+

The authenticated user

Authorizations:
BearerBasicAuthSessionCookie

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "guid": "string",
  • "displayName": "string",
  • "url": "string",
  • "imageUrl": "string",
  • "contentContainerId": 0,
  • "online": true
}

Ids of the users the caller has blocked

Clients need this to reproduce the platform's blocked-author masking: payloads are +always unmasked, since masking is a display concern rather than an access boundary. +Empty when user blocking is disabled by an administrator.

+
Authorizations:
BearerBasicAuthSessionCookie

Responses

Response samples

Content type
application/json
{
  • "results": [
    ]
}
+ + + + diff --git a/docs/html/v2/comment.html b/docs/html/v2/comment.html new file mode 100644 index 00000000..c676b3ae --- /dev/null +++ b/docs/html/v2/comment.html @@ -0,0 +1,623 @@ + + + + + + HumHub - Comment API (v2) + + + + + + + + + +

HumHub - Comment API (v2) (2.0.0)

Download OpenAPI specification:

E-mail: info@humhub.com License: AGPLv2

Welcome to the HumHub comment API reference, v2.

+

These endpoints are shipped by HumHub core (1.19+), not by the REST API module — the +module only contributes its authentication methods to them and documents them here until +core ships its own API documentation. See the core docs, +docs/develop/concept-api.md.

+

Conventions of this API generation, differing from /api/v1:

+
    +
  • Timestamps are ISO-8601 with offset, in UTC (2026-08-22T08:00:00+00:00).
  • +
  • Field names are camelCase throughout.
  • +
  • Errors are plain HTTP status codes with a JSON body; there is no {code, message} +success/failure envelope. Validation failures answer 422 with +{"errors": {"<field>": ["<message>"]}}, a successful delete answers 204.
  • +
  • Comment lists are cursor windows, not offset pages (see the window endpoints).
  • +
  • The comment shape is caller-neutral: identical for every reader who may see the +content. What depends on the caller has its own endpoints — GET /comment/{id}/permissions +for edit/delete, GET /like/states for like state.
  • +
+

Requests may authenticate with any token method the REST API module offers. Core's own +browser session is accepted too for these endpoints, which is what the platform's Vue UI +uses; state-changing session requests additionally require the CSRF token.

+

Root-comment window of a content

A window of the content's root comments. Without any cursor the newest comments are +returned; commentId + direction pages from a comment ("show previous/next N +comments"), commentId alone focuses the window around a permalinked comment.

+

total counts all comments of the content including replies (what a comment +badge shows), while results, prevCount and nextCount describe the root level +only. rootTotal is the root-only total a root list needs to compute its own +remaining count.

+

Readable by guests for guest-visible content while guest access is enabled +platform-wide, unless the comment module hides comments from guests +(guestHideComments).

+
Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the content

+
query Parameters
commentId
integer

Cursor comment, or the anchor to focus the window around when no direction is given

+
direction
string
Enum: "previous" "next"

Paging direction relative to commentId

+
pageSize
integer

Comments per page while paging with direction. Clamped to the comment module's +configured block load size.

+
limit
integer

Size of an initial (cursor-less) window. Clamped to the comment module's configured +block load size.

+

Responses

Response samples

Content type
application/json
{
  • "results": [
    ],
  • "total": 0,
  • "rootTotal": 0,
  • "prevCount": 0,
  • "nextCount": 0
}

Reply window of a comment thread

The same window semantics as the content window, for the replies of one root comment. +total/rootTotal still describe the whole content, so a client can keep its badge +and its root list consistent while paging replies.

+
Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the root comment

+
query Parameters
commentId
integer

Cursor comment, or the anchor to focus the window around when no direction is given

+
direction
string
Enum: "previous" "next"

Paging direction relative to commentId

+
pageSize
integer

Comments per page while paging with direction. Clamped to the comment module's +configured block load size.

+
limit
integer

Size of an initial (cursor-less) window. Clamped to the comment module's configured +block load size.

+

Responses

Response samples

Content type
application/json
{
  • "results": [
    ],
  • "total": 0,
  • "rootTotal": 0,
  • "prevCount": 0,
  • "nextCount": 0
}

Create a comment

Creates a comment on the given content. Replies pass their parent through +parentCommentId; comments nest at most one level, a deeper reply answers 422.

+
Authorizations:
BearerBasicAuthSessionCookie
query Parameters
contentId
required
integer

The primary key of the content

+
parentCommentId
integer

The primary key of the root comment this is a reply to

+
Request Body schema: application/json
required
message
required
string

Markdown message

+
fileList
Array of strings

Guids of already uploaded files to attach

+

Responses

Request samples

Content type
application/json
{
  • "message": "string",
  • "fileList": [
    ]
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "message": "string",
  • "messageRenderOptions": { },
  • "contentId": 0,
  • "parentCommentId": 0,
  • "recordId": 0,
  • "createdBy": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "url": "string",
  • "files": [
    ],
  • "childCount": 0,
  • "replies": {
    },
  • "extensions": { }
}

What the caller may do with a comment

Deliberately not part of the comment shape: these are the only caller-dependent +values a comment needs, and only when its context menu is opened — keeping them out +is what makes the comment payload identical for every reader (and cacheable). The +same checks the update and delete endpoints enforce.

+

Authenticated callers only: a guest has no permissions to report.

+
Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the comment

+

Responses

Response samples

Content type
application/json
{
  • "canEdit": true,
  • "canDelete": true
}

A single comment

A root comment carries a preview of its newest replies under replies; a reply has +replies: null. Readable by guests under the same conditions as the windows.

+
Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the comment

+

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "message": "string",
  • "messageRenderOptions": { },
  • "contentId": 0,
  • "parentCommentId": 0,
  • "recordId": 0,
  • "createdBy": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "url": "string",
  • "files": [
    ],
  • "childCount": 0,
  • "replies": {
    },
  • "extensions": { }
}

Update a comment

Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the comment

+
Request Body schema: application/json
required
message
required
string

Markdown message

+
fileList
Array of strings

Guids of already uploaded files to attach

+

Responses

Request samples

Content type
application/json
{
  • "message": "string",
  • "fileList": [
    ]
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "message": "string",
  • "messageRenderOptions": { },
  • "contentId": 0,
  • "parentCommentId": 0,
  • "recordId": 0,
  • "createdBy": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "url": "string",
  • "files": [
    ],
  • "childCount": 0,
  • "replies": {
    },
  • "extensions": { }
}

Delete a comment

Deletes the comment. The optional body parameters trigger the moderation flow: with +notify, the author receives a notification carrying a preview of the removed text +and the given message as the reason.

+
Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the comment

+
Request Body schema: application/json
optional
notify
boolean

Notify the author about the removal

+
message
string

Reason shown to the author

+

Responses

Request samples

Content type
application/json
{
  • "notify": true,
  • "message": "string"
}
+ + + + diff --git a/docs/html/v2/like.html b/docs/html/v2/like.html new file mode 100644 index 00000000..a8ae1670 --- /dev/null +++ b/docs/html/v2/like.html @@ -0,0 +1,525 @@ + + + + + + HumHub - Like API (v2) + + + + + + + + + +

HumHub - Like API (v2) (2.0.0)

Download OpenAPI specification:

E-mail: info@humhub.com License: AGPLv2

Welcome to the HumHub like API reference, v2.

+

These endpoints are shipped by HumHub core (1.19+), not by the REST API module — the +module only contributes its authentication methods to them and documents them here until +core ships its own API documentation. See the core docs, +docs/develop/concept-api.md.

+

Conventions of this API generation, differing from /api/v1: camelCase field names, +ISO-8601 timestamps with offset, plain HTTP status codes instead of a +{code, message} envelope, and {results, total, page, pageSize, pages} for paginated +lists.

+

Every likeable record is addressed either by its platform-wide recordId (what shapes +carrying likes expose) or by model + pk.

+

Requests may authenticate with any token method the REST API module offers. Core's own +browser session is accepted too for these endpoints, which is what the platform's Vue UI +uses; state-changing session requests additionally require the CSRF token.

+

Like state of a record

The caller-context like state. Readable by guests for content they can see, with +liked and canLike always false.

+
Authorizations:
BearerBasicAuthSessionCookie
query Parameters
recordId
integer

Platform-wide record id — the addressing every shape that carries likes exposes as +recordId. Alternative to model + pk.

+
model
string

Class name of the record, when addressing it by model and primary key

+
pk
integer

Primary key of the record, when addressing it by model and primary key

+

Responses

Response samples

Content type
application/json
{
  • "total": 0,
  • "liked": true,
  • "canLike": true
}

Like states of many records

The caller's like state for up to 100 records in one request, keyed by record id — +what a client asks for after receiving a page of records whose payloads deliberately +carry no like state (see the LikeState schema).

+

Records the caller may not see, and ids that resolve to nothing, are absent from +the map rather than failing the request. Readable by guests, with liked and +canLike always false.

+
Authorizations:
BearerBasicAuthSessionCookie
query Parameters
recordIds
string

Comma-separated (or repeated recordIds[]=) list of platform-wide record ids, capped +at 100 per request.

+

Responses

Response samples

Content type
application/json
{
  • "results": {
    }
}

Like a record

Authorizations:
BearerBasicAuthSessionCookie
query Parameters
recordId
integer

Platform-wide record id — the addressing every shape that carries likes exposes as +recordId. Alternative to model + pk.

+
model
string

Class name of the record, when addressing it by model and primary key

+
pk
integer

Primary key of the record, when addressing it by model and primary key

+

Responses

Response samples

Content type
application/json
{
  • "total": 0,
  • "liked": true,
  • "canLike": true
}

Remove the caller's like

Idempotent — unliking something that was never liked is a success.

+
Authorizations:
BearerBasicAuthSessionCookie
query Parameters
recordId
integer

Platform-wide record id — the addressing every shape that carries likes exposes as +recordId. Alternative to model + pk.

+
model
string

Class name of the record, when addressing it by model and primary key

+
pk
integer

Primary key of the record, when addressing it by model and primary key

+

Responses

Response samples

Content type
application/json
{
  • "total": 0,
  • "liked": true,
  • "canLike": true
}

Users who liked a record

Newest first. Readable by guests for content they can see.

+
Authorizations:
BearerBasicAuthSessionCookie
query Parameters
recordId
integer

Platform-wide record id — the addressing every shape that carries likes exposes as +recordId. Alternative to model + pk.

+
model
string

Class name of the record, when addressing it by model and primary key

+
pk
integer

Primary key of the record, when addressing it by model and primary key

+
page
integer

Page number, starting at 1

+
pageSize
integer

Records per page (default 25, maximum 100)

+

Responses

Response samples

Content type
application/json
{
  • "results": [
    ],
  • "total": 0,
  • "page": 0,
  • "pageSize": 0,
  • "pages": 0
}
+ + + + diff --git a/docs/swagger/build-all.sh b/docs/swagger/build-all.sh index a1bddcfb..f3577cf4 100755 --- a/docs/swagger/build-all.sh +++ b/docs/swagger/build-all.sh @@ -1,6 +1,25 @@ #!/bin/bash -for filename in *.yaml; do - echo "--------- $filename ---------------------" - npx @redocly/cli build-docs $filename -o ../html/$(basename "$filename" .yaml).html -done \ No newline at end of file +# Renders every endpoint document to ../html/. `common.yaml` holds shared components only +# and is skipped — it is referenced by the endpoint documents, not read on its own. +# +# Layout: the flat files are the /api/v1 surface of this module, `v2/` documents the +# endpoints core itself ships (see ../api-stack.md) and renders to ../html/v2/. + +build() { + local source_dir="$1" + local target_dir="$2" + + mkdir -p "$target_dir" + + for filename in "$source_dir"/*.yaml; do + [ "$(basename "$filename")" = "common.yaml" ] && continue + echo "--------- $filename ---------------------" + npx @redocly/cli build-docs "$filename" -o "$target_dir/$(basename "$filename" .yaml).html" + done +} + +cd "$(dirname "$0")" || exit 1 + +build . ../html +build v2 ../html/v2 diff --git a/docs/swagger/v2/account.yaml b/docs/swagger/v2/account.yaml new file mode 100644 index 00000000..e9bb8a32 --- /dev/null +++ b/docs/swagger/v2/account.yaml @@ -0,0 +1,72 @@ +openapi: 3.0.0 +info: + description: | + Welcome to the HumHub account API reference, v2 — the authenticated user's own data. + + These endpoints are shipped by **HumHub core** (1.19+), not by the REST API module — the + module only contributes its authentication methods to them and documents them here until + core ships its own API documentation. See the core docs, + `docs/develop/concept-api.md`. + + Conventions of this API generation, differing from `/api/v1`: camelCase field names, + ISO-8601 timestamps with offset, and plain HTTP status codes instead of a + `{code, message}` envelope. + + Requests may authenticate with any token method the REST API module offers. Core's own + browser session is accepted too for these endpoints, which is what the platform's Vue UI + uses. + version: 2.0.0 + title: HumHub - Account API (v2) + contact: + email: info@humhub.com + license: + name: AGPLv2 + url: https://www.humhub.org/en/licences +servers: + - url: /api/v2 +security: + - Bearer: [] + - BasicAuth: [] + - SessionCookie: [] +paths: + "/account": + get: + summary: The authenticated user + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "common.yaml#/components/schemas/UserShort" + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "/account/blocked-users": + get: + summary: Ids of the users the caller has blocked + description: | + Clients need this to reproduce the platform's blocked-author masking: payloads are + always unmasked, since masking is a display concern rather than an access boundary. + Empty when user blocking is disabled by an administrator. + responses: + "200": + description: Successful operation + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + type: integer + "401": + $ref: "common.yaml#/components/responses/Unauthorized" +components: + securitySchemes: + Bearer: + $ref: "common.yaml#/components/securitySchemes/Bearer" + BasicAuth: + $ref: "common.yaml#/components/securitySchemes/BasicAuth" + SessionCookie: + $ref: "common.yaml#/components/securitySchemes/SessionCookie" diff --git a/docs/swagger/v2/comment.yaml b/docs/swagger/v2/comment.yaml new file mode 100644 index 00000000..f3e2f59c --- /dev/null +++ b/docs/swagger/v2/comment.yaml @@ -0,0 +1,405 @@ +openapi: 3.0.0 +info: + description: | + Welcome to the HumHub comment API reference, v2. + + These endpoints are shipped by **HumHub core** (1.19+), not by the REST API module — the + module only contributes its authentication methods to them and documents them here until + core ships its own API documentation. See the core docs, + `docs/develop/concept-api.md`. + + Conventions of this API generation, differing from `/api/v1`: + + - Timestamps are ISO-8601 with offset, in UTC (`2026-08-22T08:00:00+00:00`). + - Field names are camelCase throughout. + - Errors are plain HTTP status codes with a JSON body; there is no `{code, message}` + success/failure envelope. Validation failures answer `422` with + `{"errors": {"": [""]}}`, a successful delete answers `204`. + - Comment lists are **cursor windows**, not offset pages (see the window endpoints). + - The comment shape is **caller-neutral**: identical for every reader who may see the + content. What depends on the caller has its own endpoints — `GET /comment/{id}/permissions` + for edit/delete, `GET /like/states` for like state. + + Requests may authenticate with any token method the REST API module offers. Core's own + browser session is accepted too for these endpoints, which is what the platform's Vue UI + uses; state-changing session requests additionally require the CSRF token. + version: 2.0.0 + title: HumHub - Comment API (v2) + contact: + email: info@humhub.com + license: + name: AGPLv2 + url: https://www.humhub.org/en/licences +servers: + - url: /api/v2 +security: + - Bearer: [] + - BasicAuth: [] + - SessionCookie: [] +paths: + "/comment/content/{id}/window": + get: + summary: Root-comment window of a content + description: | + A window of the content's root comments. Without any cursor the newest comments are + returned; `commentId` + `direction` pages from a comment ("show previous/next N + comments"), `commentId` alone focuses the window around a permalinked comment. + + `total` counts all comments of the content **including replies** (what a comment + badge shows), while `results`, `prevCount` and `nextCount` describe the root level + only. `rootTotal` is the root-only total a root list needs to compute its own + remaining count. + + Readable by guests for guest-visible content while guest access is enabled + platform-wide, unless the comment module hides comments from guests + (`guestHideComments`). + parameters: + - name: id + in: path + description: The primary key of the content + required: true + schema: + type: integer + - $ref: "#/components/parameters/commentIdParam" + - $ref: "#/components/parameters/directionParam" + - $ref: "#/components/parameters/windowPageSizeParam" + - $ref: "#/components/parameters/windowLimitParam" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/CommentWindow" + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" + "/comment/parent/{id}/window": + get: + summary: Reply window of a comment thread + description: | + The same window semantics as the content window, for the replies of one root comment. + `total`/`rootTotal` still describe the whole content, so a client can keep its badge + and its root list consistent while paging replies. + parameters: + - name: id + in: path + description: The primary key of the root comment + required: true + schema: + type: integer + - $ref: "#/components/parameters/commentIdParam" + - $ref: "#/components/parameters/directionParam" + - $ref: "#/components/parameters/windowPageSizeParam" + - $ref: "#/components/parameters/windowLimitParam" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/CommentWindow" + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" + "/comment": + post: + summary: Create a comment + description: | + Creates a comment on the given content. Replies pass their parent through + `parentCommentId`; comments nest at most one level, a deeper reply answers `422`. + parameters: + - name: contentId + in: query + description: The primary key of the content + required: true + schema: + type: integer + - name: parentCommentId + in: query + description: The primary key of the root comment this is a reply to + required: false + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommentWrite" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Comment" + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" + "422": + $ref: "common.yaml#/components/responses/ValidationFailed" + "/comment/{id}/permissions": + get: + summary: What the caller may do with a comment + description: | + Deliberately not part of the comment shape: these are the only caller-dependent + values a comment needs, and only when its context menu is opened — keeping them out + is what makes the comment payload identical for every reader (and cacheable). The + same checks the update and delete endpoints enforce. + + Authenticated callers only: a guest has no permissions to report. + parameters: + - $ref: "#/components/parameters/commentPathParam" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + type: object + properties: + canEdit: + type: boolean + canDelete: + type: boolean + description: | + `true` on someone else's comment means moderation. + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" + "/comment/{id}": + get: + summary: A single comment + description: | + A root comment carries a preview of its newest replies under `replies`; a reply has + `replies: null`. Readable by guests under the same conditions as the windows. + parameters: + - $ref: "#/components/parameters/commentPathParam" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Comment" + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" + put: + summary: Update a comment + parameters: + - $ref: "#/components/parameters/commentPathParam" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommentWrite" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Comment" + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" + "422": + $ref: "common.yaml#/components/responses/ValidationFailed" + delete: + summary: Delete a comment + description: | + Deletes the comment. The optional body parameters trigger the moderation flow: with + `notify`, the author receives a notification carrying a preview of the removed text + and the given `message` as the reason. + parameters: + - $ref: "#/components/parameters/commentPathParam" + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + notify: + type: boolean + description: Notify the author about the removal + message: + type: string + description: Reason shown to the author + responses: + "204": + description: Deleted, no content + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" +components: + securitySchemes: + Bearer: + $ref: "common.yaml#/components/securitySchemes/Bearer" + BasicAuth: + $ref: "common.yaml#/components/securitySchemes/BasicAuth" + SessionCookie: + $ref: "common.yaml#/components/securitySchemes/SessionCookie" + parameters: + commentPathParam: + name: id + in: path + description: The primary key of the comment + required: true + schema: + type: integer + commentIdParam: + name: commentId + in: query + description: Cursor comment, or the anchor to focus the window around when no direction is given + required: false + schema: + type: integer + directionParam: + name: direction + in: query + description: Paging direction relative to `commentId` + required: false + schema: + type: string + enum: + - previous + - next + windowPageSizeParam: + name: pageSize + in: query + description: | + Comments per page while paging with `direction`. Clamped to the comment module's + configured block load size. + required: false + schema: + type: integer + windowLimitParam: + name: limit + in: query + description: | + Size of an initial (cursor-less) window. Clamped to the comment module's configured + block load size. + required: false + schema: + type: integer + schemas: + CommentWindow: + type: object + properties: + results: + type: array + description: The window's comments, oldest first + items: + $ref: "#/components/schemas/Comment" + total: + type: integer + description: All comments of the content, including replies + rootTotal: + type: integer + description: Root comments of the content only + prevCount: + type: integer + description: Comments of this level before the window + nextCount: + type: integer + description: Comments of this level after the window + Comment: + type: object + properties: + id: + type: integer + message: + type: string + description: The raw markdown message, not rendered HTML + messageRenderOptions: + type: object + description: | + Options a client needs to reproduce the platform's rich-text rendering of + `message` (mentions, oembed previews, …). `{}` when there is nothing to add. + contentId: + type: integer + parentCommentId: + type: integer + nullable: true + recordId: + type: integer + description: Platform-wide record id, e.g. for the like endpoints + createdBy: + $ref: "common.yaml#/components/schemas/UserShort" + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + nullable: true + description: | + Equal to `createdAt` for an unedited comment — a client derives "edited" from the + two. + url: + type: string + description: Absolute permalink + files: + type: array + items: + $ref: "common.yaml#/components/schemas/File" + childCount: + type: integer + description: Number of replies + replies: + type: object + nullable: true + description: | + Preview of the newest replies of a root comment; `null` on a reply. + properties: + total: + type: integer + hasMore: + type: boolean + items: + type: array + items: + $ref: "#/components/schemas/Comment" + extensions: + type: object + description: | + Namespaced data modules attached to this record (`{}` when nothing did), see the + core serialize event. + CommentWrite: + type: object + properties: + message: + type: string + description: Markdown message + fileList: + type: array + description: Guids of already uploaded files to attach + items: + type: string + required: + - message diff --git a/docs/swagger/v2/common.yaml b/docs/swagger/v2/common.yaml new file mode 100644 index 00000000..bb812b42 --- /dev/null +++ b/docs/swagger/v2/common.yaml @@ -0,0 +1,183 @@ +openapi: 3.0.0 +info: + title: Common v2 API Components + description: | + Shared components of the v2 API — schemas every endpoint document embeds, the + paginated-list envelope, the record addressing, the error contract and the + authentication schemes. Not an endpoint document of its own. + version: 2.0.0 +paths: {} +components: + securitySchemes: + Bearer: + type: http + scheme: bearer + description: Access token or JWT, as issued by the REST API module. + BasicAuth: + type: http + scheme: basic + description: | + User credentials, restricted to the users the administrator enabled the API for. + SessionCookie: + type: apiKey + in: cookie + name: HUMHUB_SESSION + description: | + The regular HumHub browser session, accepted by core's own endpoints (which is how + the platform's Vue UI calls them). State-changing requests additionally require the + CSRF token, sent as the `X-CSRF-Token` header or the `_csrf` body parameter. + parameters: + pageParam: + name: page + in: query + description: Page number, starting at 1 + required: false + schema: + type: integer + pageSizeParam: + name: pageSize + in: query + description: Records per page (default 25, maximum 100) + required: false + schema: + type: integer + recordIdsParam: + name: recordIds + in: query + description: | + Comma-separated (or repeated `recordIds[]=`) list of platform-wide record ids, capped + at 100 per request. + required: false + schema: + type: string + recordIdParam: + name: recordId + in: query + description: | + Platform-wide record id — the addressing every shape that carries likes exposes as + `recordId`. Alternative to `model` + `pk`. + required: false + schema: + type: integer + modelParam: + name: model + in: query + description: Class name of the record, when addressing it by model and primary key + required: false + schema: + type: string + pkParam: + name: pk + in: query + description: Primary key of the record, when addressing it by model and primary key + required: false + schema: + type: integer + responses: + Unauthorized: + description: Authentication required + Forbidden: + description: The caller may not access this record + NotFound: + description: No such record + ValidationFailed: + description: Validation failed + content: + application/json: + schema: + type: object + properties: + errors: + type: object + description: Field name (camelCase) to list of messages + additionalProperties: + type: array + items: + type: string + example: + errors: + message: + - The comment must not be empty! + schemas: + ListEnvelope: + type: object + description: The envelope of every paginated list response + properties: + results: + type: array + items: + type: object + total: + type: integer + description: Total number of records + page: + type: integer + description: Current page, starting at 1 + pageSize: + type: integer + pages: + type: integer + description: Total number of pages + LikeState: + type: object + description: | + The caller-context like state of a record — returned by the like endpoints. NOT + embedded in other shapes: it is the one value that depends both on the record and on + who is asking, so keeping it separate is what lets those payloads be identical for + every reader (and therefore cacheable). Ask for a whole page of records at once with + `GET /like/states`. + properties: + total: + type: integer + description: Number of likes + liked: + type: boolean + description: Whether the authenticated caller has liked the record + canLike: + type: boolean + description: Whether the authenticated caller may like the record + UserShort: + type: object + description: The short user representation every other shape embeds + properties: + id: + type: integer + guid: + type: string + displayName: + type: string + url: + type: string + description: Absolute profile URL + imageUrl: + type: string + description: Absolute profile image URL + contentContainerId: + type: integer + nullable: true + online: + type: boolean + nullable: true + description: | + `null` when the platform does not expose online status to the caller. + File: + type: object + description: An attached file + properties: + id: + type: integer + guid: + type: string + mimeType: + type: string + size: + type: integer + fileName: + type: string + url: + type: string + description: Absolute download URL + previewUrl: + type: string + nullable: true + description: Absolute preview image URL, when the file has one diff --git a/docs/swagger/v2/like.yaml b/docs/swagger/v2/like.yaml new file mode 100644 index 00000000..1d7fda73 --- /dev/null +++ b/docs/swagger/v2/like.yaml @@ -0,0 +1,170 @@ +openapi: 3.0.0 +info: + description: | + Welcome to the HumHub like API reference, v2. + + These endpoints are shipped by **HumHub core** (1.19+), not by the REST API module — the + module only contributes its authentication methods to them and documents them here until + core ships its own API documentation. See the core docs, + `docs/develop/concept-api.md`. + + Conventions of this API generation, differing from `/api/v1`: camelCase field names, + ISO-8601 timestamps with offset, plain HTTP status codes instead of a + `{code, message}` envelope, and `{results, total, page, pageSize, pages}` for paginated + lists. + + Every likeable record is addressed either by its platform-wide `recordId` (what shapes + carrying likes expose) or by `model` + `pk`. + + Requests may authenticate with any token method the REST API module offers. Core's own + browser session is accepted too for these endpoints, which is what the platform's Vue UI + uses; state-changing session requests additionally require the CSRF token. + version: 2.0.0 + title: HumHub - Like API (v2) + contact: + email: info@humhub.com + license: + name: AGPLv2 + url: https://www.humhub.org/en/licences +servers: + - url: /api/v2 +security: + - Bearer: [] + - BasicAuth: [] + - SessionCookie: [] +paths: + "/like/state": + get: + summary: Like state of a record + description: | + The caller-context like state. Readable by guests for content they can see, with + `liked` and `canLike` always `false`. + parameters: + - $ref: "common.yaml#/components/parameters/recordIdParam" + - $ref: "common.yaml#/components/parameters/modelParam" + - $ref: "common.yaml#/components/parameters/pkParam" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "common.yaml#/components/schemas/LikeState" + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" + "/like/states": + get: + summary: Like states of many records + description: | + The caller's like state for up to 100 records in one request, keyed by record id — + what a client asks for after receiving a page of records whose payloads deliberately + carry no like state (see the `LikeState` schema). + + Records the caller may not see, and ids that resolve to nothing, are **absent from + the map** rather than failing the request. Readable by guests, with `liked` and + `canLike` always `false`. + parameters: + - $ref: "common.yaml#/components/parameters/recordIdsParam" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + type: object + properties: + results: + type: object + description: Record id to like state + additionalProperties: + $ref: "common.yaml#/components/schemas/LikeState" + example: + results: + "14": + total: 2 + liked: true + canLike: true + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "/like": + post: + summary: Like a record + parameters: + - $ref: "common.yaml#/components/parameters/recordIdParam" + - $ref: "common.yaml#/components/parameters/modelParam" + - $ref: "common.yaml#/components/parameters/pkParam" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "common.yaml#/components/schemas/LikeState" + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" + delete: + summary: Remove the caller's like + description: Idempotent — unliking something that was never liked is a success. + parameters: + - $ref: "common.yaml#/components/parameters/recordIdParam" + - $ref: "common.yaml#/components/parameters/modelParam" + - $ref: "common.yaml#/components/parameters/pkParam" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + $ref: "common.yaml#/components/schemas/LikeState" + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" + "/like/users": + get: + summary: Users who liked a record + description: Newest first. Readable by guests for content they can see. + parameters: + - $ref: "common.yaml#/components/parameters/recordIdParam" + - $ref: "common.yaml#/components/parameters/modelParam" + - $ref: "common.yaml#/components/parameters/pkParam" + - $ref: "common.yaml#/components/parameters/pageParam" + - $ref: "common.yaml#/components/parameters/pageSizeParam" + responses: + "200": + description: Successful operation + content: + application/json: + schema: + allOf: + - $ref: "common.yaml#/components/schemas/ListEnvelope" + - type: object + properties: + results: + type: array + items: + $ref: "common.yaml#/components/schemas/UserShort" + "401": + $ref: "common.yaml#/components/responses/Unauthorized" + "403": + $ref: "common.yaml#/components/responses/Forbidden" + "404": + $ref: "common.yaml#/components/responses/NotFound" +components: + securitySchemes: + Bearer: + $ref: "common.yaml#/components/securitySchemes/Bearer" + BasicAuth: + $ref: "common.yaml#/components/securitySchemes/BasicAuth" + SessionCookie: + $ref: "common.yaml#/components/securitySchemes/SessionCookie" diff --git a/docs/vue-session-api.md b/docs/vue-session-api.md deleted file mode 100644 index 6b30e12f..00000000 --- a/docs/vue-session-api.md +++ /dev/null @@ -1,260 +0,0 @@ -# Session Authentication & Vue Islands Endpoint Gap Analysis - -Status: working document for the `vue` branch (core `enh/vuejs-integration`). -Goal: let the Vue islands UI in core consume `/api/v1` instead of core-internal -JSON controllers. - -## 1. Session authentication (implemented on this branch) - -`/api/v1` now accepts the regular HumHub browser session as an authentication -method, in addition to the existing token methods. - -### Auth method order (CompositeAuth, `components/BaseController.php`) - -1. `JwtAuth` (if `enableJwtAuth`) -2. `HttpBearerAuth` (if `enableBearerAuth`) -3. `QueryParamAuth` (if `enableBearerAuth` + `enableQueryParamAuth`) -4. `HttpBasicAuth` (if `enableBasicAuth`) -5. `ImpersonateAuth` (always) -6. **`SessionAuth` (if `enableSessionAuth`) — always last** - -**Token wins:** a request carrying a valid token is authenticated as the token -user even when a session cookie is present. Note the fall-through is not uniform: -only `JwtAuth` returns `null` on failure (falling through to session auth); the -Bearer/QueryParam/Basic/Impersonate methods throw on an invalid credential, so a -malformed token on a login-required action yields 401 rather than silently -downgrading to the session. On the guest-allowed actions (`optional` list) an -invalid token downgrades to guest. Guests without token and session get the usual -401 JSON. - -### CSRF contract - -- Session-authenticated **state-changing** requests (POST/PUT/PATCH/DELETE) - require a valid Yii CSRF token: `X-CSRF-Token` header (what `humhub.client` - sends, fed from the `csrf-token` meta tag) or `_csrf` body param. -- Missing/invalid token → **403 JSON** (`ForbiddenHttpException`), never an - HTML error page (response format is forced to JSON for `/api/` early in - `Events::onBeforeRequest`). -- GET/HEAD/OPTIONS are exempt. Token-authenticated requests remain CSRF-exempt - exactly as before. -- Implementation detail: `SessionAuth` does **not** call - `Request::validateCsrfToken()` (that method generates and Set-Cookies a fresh - `_csrf` token when the request carries none, which would clobber the browser - page's real token). It reads the raw token straight from the `_csrf` cookie - (core default), unmasks the client-supplied token and compares timing-safely. - No API response ever emits a `_csrf` Set-Cookie. - -### Setting - -- `enableSessionAuth` (module setting, checkbox on the admin config form, - `ConfigureForm`). **Default: disabled**, like every other auth method — a - module update must never silently open a new authentication surface. The - dev/Vue-islands instance enables it explicitly in the admin config form. - -### Allowlist decision - -Session auth deliberately **bypasses** the "Enabled for all registered users" / -user allowlist gate (`BaseController::isUserEnabled()`): - -- The gate limits who may reach the API *from outside the browser* with - self-obtained credentials/tokens; its own admin hints say it "affects JWT and - HTTP Basic Authentication methods only". -- A session-authenticated call grants nothing the same user's browser session - does not already have via the normal web controllers. -- The browser (Vue) UI must work for **every** logged-in user; gating session - auth would break it for non-allowlisted users with zero security gain. - -### Further semantics - -- Session identity restoration runs the full `yii\web\User::renewAuthStatus()` - (session auth key check); the web UI's `authTimeout` / `absoluteAuthTimeout` - are copied onto the API user component, so idle/absolute expiry matches the - web UI and API activity keeps the session alive like normal page activity. -- The auto-login ("remember me") cookie alone does **not** authenticate API - requests (`enableAutoLogin` stays off); core re-establishes the session on - any page load before an island issues API calls. -- Token logins can never write into the browser session: the API user - component stays session-less (`enableSession = false`); `SessionAuth` reads - the session through a temporary window only. - -### 4.4 Gate enforcement (2FA and other non-API gates) - -Core's `GateFilter::getRequestClass()` infers `RequestClass::Api` purely from -`Yii::$app->user->enableSession === false`, which `BaseController` pins for every -REST request. A cookie-authenticated request would therefore be misclassified as -an API request and skip every gate that does not apply to API requests (2FA, -legal, onboarding, …) — so a user who passed only the first factor could reach -every endpoint. `SessionAuth` closes this: after restoring the identity it -re-classifies the request the way `GateFilter` would for a real browser session -(Ajax/FullPage, never Api) via `gateManager->findOpenGate()` and throws a 403 -JSON when an open gate intercepts it. Gates that also apply to `Api` (e.g. -must-change-password, maintenance mode) are already enforced by the core -`GateFilter` on the same request and are not applied twice. - -**Core-side follow-up:** the correct long-term fix is an explicit -"authenticated-by-session" signal on the request that `GateFilter` consumes, -instead of inferring the class from `enableSession`. The module-side guard above -is the interim; it should be revisited when this lands in core. - -### 4.5 Impersonation (fail-closed on this branch) - -`Impersonation::isActive()` short-circuits `false` while `enableSession` is off, -so core 1.19's private-content restriction (core #8372) would silently not apply -to a session-authenticated impersonation — an impersonating admin would see -through the API the private content the web UI hides. Both cases are handled: - -- **Impersonate token** (`ImpersonateAuth`): the removed `isImpersonated` write - is gone (commit "Fix impersonate token auth on HumHub 1.19"); the restriction - is session-bound and does not apply to token requests. Re-applying an - equivalent restriction to impersonate-token API access is a tracked follow-up. -- **Session impersonation** (`SessionAuth`): rejected outright (403) — detected - from the `Impersonation::SESSION_KEY` session marker — until the core-side - explicit-session signal (§4.4) lets the restriction evaluate correctly. - -## 2. Endpoint gap analysis: core Vue islands vs. current REST module - -What the islands consume today (core `enh/vuejs-integration`) vs. what -`/api/v1` offers. - -### 2.1 Comment window / listing - -| | Core island (`comment/comment/list` → `CommentJsonService::serializeWindow`) | REST (`GET comment/content/`, `GET comment/parent/`) | -|---|---|---| -| Pagination | Cursor/window: `commentId` + `direction=previous\|next` + `pageSize`, or anchored permalink window; returns `prevCount`, `nextCount`, `total` (incl. replies), `rootTotal` (root-only) | Offset: `page`/`limit`; returns `total`, `page`, `pages`, `links`, `results` | -| Reply previews | `children: {total, items, hasMore}` per root, one level deep | none (`childCount` number only) | -| Batch events | fires `EVENT_SERIALIZE_COMMENTS` once per window → `extensions` per comment (module extension point) | none | -| Guest gate | enforces `guestHideComments` (403) | API requires auth anyway | - -### 2.2 Comment shape - -Core (`CommentJsonService::serialize()`), per comment: -`id, contentId, parentCommentId, recordId, createdAt (ATOM), isEdited, -updatedAt, author (UserJsonService shape or null), blocked, message (raw -markdown), messageRenderOptions, attachmentsHtml, likes {count, liked}, -canEdit, canDelete, canAdminDelete, permalink, children, extensions`. - -REST (`CommentDefinitions::getComment()`): -`id, message (raw markdown, no render options), contentId, parentCommentId, -createdBy (id, guid, display_name, url), createdAt (DB format), likes {total}, -files, childCount`. - -Missing in REST: viewer-context permissions (`canEdit`/`canDelete`/ -`canAdminDelete`), viewer like state (`liked`), blocked-author masking, -`messageRenderOptions` (client-side RichText envelope), `attachmentsHtml`, -`recordId`, `permalink`, `isEdited`/`updatedAt`, ATOM timestamps, `extensions`. - -### 2.3 Comment mutations - -| | Core island | REST | -|---|---|---| -| Create | `comment/create` — JSON `message`/`fileList`/`parentCommentId`, enforces one nesting level, 422 + `errors` map, returns full island comment shape | `POST comment?contentId=&parentCommentId=` — returns REST shape, 400 + `comment` errors key, no nesting-depth guard | -| Update | `comment/update` — GET returns raw markdown for editor, POST saves | `PUT comment/` (no "fetch raw for editor" mode — REST shape already carries raw markdown) | -| Delete | `comment/delete` — supports admin delete with notification (`AdminDeleteCommentForm`: notify + reason), returns `{success}` | `DELETE comment/` — plain delete, no notify/reason flow | -| Single | `comment/info` — `showBlocked=1` reveal, island shape | `GET comment/` — REST shape, no blocked masking at all | - -### 2.4 Likes - -| | Core island (`like/*`) | REST | -|---|---|---| -| State | `info` → `{currentUserLiked, likeCounter}` (guest-allowed) | none (only `likes.total` embedded in content shapes) | -| Like / Unlike | `POST like/like`, `POST like/unlike` → same state shape | **no like/unlike endpoint at all** (`GET/DELETE like/`, `GET like/find-by-object` only) | -| User list | `like/user-list` → `{total, users: [UserJsonService], hasMore, nextPage}`, limit clamped to `userListPaginationSize` | `GET like/find-by-object` → offset-paged `{id, createdBy(short), createdAt}` | - -### 2.5 User shape - -Core `UserJsonService::serialize()` (shared island shape, `` props): -`guid, displayName, url, imageUrl, contentContainerId, imageAlt, online`. - -REST `UserDefinitions::getUserShort()`: `id, guid, display_name, url` — -snake_case naming, no `imageUrl`/`online`/`contentContainerId`/`imageAlt`. - -## 3. Island endpoints (implemented on this branch) - -Guiding principle: the core `*JsonService` classes are controller-agnostic — -**they are reused verbatim from new REST controllers instead of re-modelling -their output in `Definitions`**. Existing REST endpoints/definitions stay -untouched (they are a public, versioned contract also used by the legal data -export and third-party integrations; injecting viewer-dependent fields there -would change their semantics). - -All routes below are **UNSTABLE / UI-COUPLED**: they serve the HumHub frontend -(the core Vue islands) 1:1 and may change together with core — they are not -part of the stable public REST contract. This is flagged in every action -docblock; there is no artificial `/internal/` path prefix (owner decision — -naming may still be revisited before merge). - -### Final routes - -| Method + route | Controller action | Core call / contract | -|---|---|---| -| `GET /api/v1/comment/window` | `rest/comment/window/index` | `CommentJsonService::serializeWindow()` — params `contentId`/`parentCommentId`, `commentId`, `direction`, `pageSize`; returns `{comments, prevCount, nextCount, total, rootTotal}` incl. per-root `children` previews and the `extensions` namespace (mirror of core `comment/comment/list`) | -| `GET /api/v1/comment//full` | `rest/comment/window/view` | `CommentJsonService::serializeComment()` — `?showBlocked=1` lifts the blocked-author mask (mirror of core `comment/comment/info`) | -| `POST /api/v1/comment/full` | `rest/comment/window/create` | Comment create from `message`/`fileList`/`parentCommentId` (+`contentId`), one-nesting-level guard, `canComment()` check; returns `serializeComment()` or `422 {"errors": {attr: [...]}}` (mirror of core `comment/comment/create`) | -| `PUT /api/v1/comment//full` | `rest/comment/window/update` | Comment save; returns `serializeComment()` or the 422 `errors` contract (mirror of core `comment/comment/update` POST mode) | -| `GET /api/v1/comment//full/edit` | `rest/comment/window/edit` | `{"message": }` for the editor (mirror of core `comment/comment/update` GET mode) | -| `DELETE /api/v1/comment//full` | `rest/comment/window/delete` | Delete incl. optional `AdminDeleteCommentForm[notify]`/`[message]` author notification; returns `{"success": bool}` (mirror of core `comment/comment/delete`) | -| `GET /api/v1/like/info` | `rest/like/like/info` | `{currentUserLiked, likeCounter}` via `LikeService`, keyed by `recordId` (RecordMap id — exactly what `LikeButton.vue` sends); guest-allowed (mirror of core `like/like/info`) | -| `POST /api/v1/like` | `rest/like/like/like` | `LikeService::like()` after `canLike()`; returns the state shape (mirror of core `like/like/like`) | -| `DELETE /api/v1/like` | `rest/like/like/unlike` | `LikeService::unlike()`; returns the state shape (mirror of core `like/like/unlike`) | -| `GET /api/v1/like/user-list` | `rest/like/like/user-list` | `{total, users, hasMore, nextPage}` with `UserJsonService` rows and the `limit` clamp to `[1, userListPaginationSize]` (mirror of core `like/like/user-list`) | - -Contract notes (all deliberate, for 1:1 island fidelity): - -- Payloads are byte-compatible with the core JSON controllers — no REST - envelope rewrap. Viewer-context fields (`canEdit`/`canDelete`/`likes.liked`), - blocked-author masking, the `guestHideComments` gate, `extensions`, and - `message`+`messageRenderOptions` all come from the delegated core services. -- Errors are HTTP exceptions (404/403 with Yii's JSON error body, same as the - core controllers) and validation failures are `422 {"errors": ...}` — NOT - the module's usual `400 {"code", "message"}` envelope. The pre-existing - comment/like CRUD endpoints keep their envelope unchanged. -- The core `actionDelete` notification block (`CommentDeleted`) is currently - duplicated in `WindowController::actionDelete()` because core has not - extracted it into a service; when the core controllers are removed in favor - of these endpoints, it should move into a core service. -- The admin-delete modal HTML (`comment/comment/get-admin-delete-modal`) - remains a core route — it returns rendered widget HTML, not island JSON. - -### Guest access - -Core allows guests on some of these actions (comment window/single view, like -info) subject to `Content::canView()` and `guestHideComments`. The REST module -previously had no guest mechanism at all (401 for everything). Implemented -minimal mechanism: `BaseController::$guestAllowedActions` — a per-controller -list of action ids wired to the `CompositeAuth` authenticator's standard -`optional` list, honored **only while guest access is enabled globally** -(`AuthHelper::isGuestAccessEnabled()`), mirroring core's -`AccessControl::$guestAllowedActions` semantics. Requests with valid -credentials still authenticate normally; without credentials the action runs -as guest and is responsible for its own guest-safe authorization (all -delegated core services/`canView()` checks already are). - -Declared lists: `WindowController` → `['index', 'view']`; -`LikeController` → `['info']` (its stable CRUD actions stay logged-in only). - -Net effect: the islands can switch from the core `/comment/...`, `/like/...` -routes to `/api/v1/...` by swapping the base URL — auth stays the browser -session + CSRF header they already send today, guest behavior included. - -## 4. Open questions for the owner - -1. **Shape fidelity:** ~~serve the island payloads 1:1 under `/api/v1`?~~ - **Decided & implemented:** 1:1 fidelity, no REST envelope rewrap (see §3). -2. **Internal namespace:** ~~`/api/v1/internal/...` prefix?~~ **Decided:** - plain routes flagged unstable/UI-coupled in docblocks and this document. - Naming may be revisited before merge. -3. **HTML fragments:** `attachmentsHtml` (and the admin-delete modal, which - stays a core HTML route) — acceptable in API responses, or should - attachments become structured JSON + a client-side renderer first? -4. **Client wiring:** will the islands' fetch wrapper reuse `humhub.client`'s - CSRF header mechanism as assumed? (The session-auth CSRF contract relies on - `X-CSRF-Token`.) -5. **ImpersonateAuth breakage (pre-existing):** ~~fix on this branch or - separately?~~ **Fixed on this branch** (own commit, intended to be - cherry-picked to `develop` as an independent core-compat PR): the write to - the removed `isImpersonated` property is gone. Applying core 1.19's - impersonation private-content restriction to impersonate-token requests - remains a separate follow-up (the API user component is session-less, so - `Impersonation::isActive()` can never apply as-is). -6. **Rate limiting:** browser-session traffic will multiply API request volume - — is throttling needed before the islands switch over? diff --git a/models/ConfigureForm.php b/models/ConfigureForm.php index 2a73ca09..541e320d 100644 --- a/models/ConfigureForm.php +++ b/models/ConfigureForm.php @@ -22,18 +22,6 @@ class ConfigureForm extends Model public $enableQueryParamAuth; - /** - * @var bool whether API requests may be authenticated by the regular HumHub browser - * session, see {@see \humhub\modules\rest\components\auth\SessionAuth} for the full - * security contract (CSRF requirement, gate enforcement, allowlist bypass, token - * precedence). - * - * Default DISABLED, like every other auth method: a module update must never silently - * open a new authentication surface. The dev/Vue-islands instance enables it explicitly - * in the admin config form. - */ - public $enableSessionAuth; - public $enabledForAllUsers; public $enabledUsers; @@ -46,7 +34,7 @@ class ConfigureForm extends Model public function rules() { return [ - [['enableJwtAuth', 'enableBasicAuth', 'enableBearerAuth', 'enableQueryParamAuth', 'enableSessionAuth', 'enabledForAllUsers'], 'boolean'], + [['enableJwtAuth', 'enableBasicAuth', 'enableBearerAuth', 'enableQueryParamAuth', 'enabledForAllUsers'], 'boolean'], [['enabledUsers', 'apiModules'], 'safe'], ]; } @@ -61,7 +49,6 @@ public function attributeLabels() 'enableBasicAuth' => Yii::t('RestModule.base', 'Allow HTTP Basic Authentication'), 'enableBearerAuth' => Yii::t('RestModule.base', 'Allow Bearer Authentication'), 'enableQueryParamAuth' => Yii::t('RestModule.base', 'Allow Query Param Bearer Authentication'), - 'enableSessionAuth' => Yii::t('RestModule.base', 'Allow Session Authentication'), 'enabledForAllUsers' => Yii::t('RestModule.base', 'Enabled for all registered users'), ]; } @@ -69,7 +56,6 @@ public function attributeLabels() public function attributeHints() { return [ - 'enableSessionAuth' => 'Disabled by default. Allows requests carrying a valid, logged-in HumHub browser session to use the API without a token. Modifying requests (POST/PUT/PATCH/DELETE) additionally require the CSRF token. Not restricted by the user list below — a session grants nothing beyond what the same user can already do in the web interface.', 'enabledForAllUsers' => 'Please note, it is not recommended to enable the API for all users yet.
This option affects JWT and HTTP Basic Authentication methods only.', 'enabledUsers' => 'This option affects JWT and HTTP Basic Authentication methods only.', ]; @@ -86,7 +72,6 @@ public function loadSettings() $this->enableBasicAuth = (bool)$settings->get('enableBasicAuth'); $this->enableBearerAuth = (bool)$settings->get('enableBearerAuth'); $this->enableQueryParamAuth = (bool)$settings->get('enableQueryParamAuth'); - $this->enableSessionAuth = (bool)$settings->get('enableSessionAuth'); $this->enabledForAllUsers = (bool)$settings->get('enabledForAllUsers'); $this->enabledUsers = (array)$settings->getSerialized('enabledUsers'); @@ -112,7 +97,6 @@ public function saveSettings() $module->settings->set('enableBasicAuth', (bool)$this->enableBasicAuth); $module->settings->set('enableBearerAuth', (bool)$this->enableBearerAuth); $module->settings->set('enableQueryParamAuth', (bool)$this->enableQueryParamAuth); - $module->settings->set('enableSessionAuth', (bool)$this->enableSessionAuth); $module->settings->set('enabledForAllUsers', $this->enabledForAllUsers); $module->settings->setSerialized('enabledUsers', (array)$this->enabledUsers); diff --git a/tests/codeception/api/CommentWindowCest.php b/tests/codeception/api/CommentWindowCest.php deleted file mode 100644 index 77e02a06..00000000 --- a/tests/codeception/api/CommentWindowCest.php +++ /dev/null @@ -1,308 +0,0 @@ -/full`), - * see `controllers/comment/WindowController.php`. Fixture baseline: comment 1 by Admin - * on content 1 (Admin's private profile post); content 10 is a public post in Space 2 - * (guest-visible space, User1 is a member). - */ -class CommentWindowCest extends HumHubApiTestCest -{ - /** - * @var string bearer access token of User1 (BearerAccessTokenFixture) - */ - private const USER1_BEARER_TOKEN = '_sB714dci3pUh6FZw5BFA0wB2ri5TfQ-dxs32iaK920BI1eHn7SX0UphARYr4J-duJbF-ZuULdjOuqc1DSH3DB'; - - public function testWindowPagination(ApiTester $I) - { - $I->wantTo('page through a comment window with roots and replies'); - $I->amAdmin(); - - // Fixture comment 1 is the oldest root; add three roots and two replies on root 2 - $root2 = $this->createComment($I, ['message' => 'Root 2', 'contentId' => 1]); - $root3 = $this->createComment($I, ['message' => 'Root 3', 'contentId' => 1]); - $root4 = $this->createComment($I, ['message' => 'Root 4', 'contentId' => 1]); - $this->createComment($I, ['message' => 'Reply 1', 'contentId' => 1, 'parentCommentId' => $root2]); - $this->createComment($I, ['message' => 'Reply 2', 'contentId' => 1, 'parentCommentId' => $root2]); - - // Initial window: the newest `commentsPreviewMax` (2) roots, ascending order - $I->sendGet('comment/window', ['contentId' => 1]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson([ - 'prevCount' => 2, - 'nextCount' => 0, - 'total' => 6, // all comments including replies - 'rootTotal' => 4, // root comments only - ]); - $ids = $I->grabDataFromResponseByJsonPath('$.comments[*].id'); - Assert::assertEquals([$root3, $root4], $ids); - - // "Show previous" from root 3: both remaining older roots fit the single-overflow - // rule (limit 1 + exactly one more), so the window returns them both - $I->sendGet('comment/window', [ - 'contentId' => 1, - 'commentId' => $root3, - 'direction' => 'previous', - 'pageSize' => 1, - ]); - $I->seeResponseCodeIs(200); - $ids = $I->grabDataFromResponseByJsonPath('$.comments[*].id'); - Assert::assertEquals([1, $root2], $ids); - $I->seeResponseContainsJson(['prevCount' => 0, 'nextCount' => 2]); - - // Page size clamp: 0 is clamped to 1, not passed through (which would drop the LIMIT) - $I->sendGet('comment/window', [ - 'contentId' => 1, - 'commentId' => $root4, - 'direction' => 'previous', - 'pageSize' => 0, - ]); - $I->seeResponseCodeIs(200); - $ids = $I->grabDataFromResponseByJsonPath('$.comments[*].id'); - Assert::assertEquals([$root3], $ids); - - // Reply window of root 2 - $I->sendGet('comment/window', ['contentId' => 1, 'parentCommentId' => $root2]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['total' => 2, 'rootTotal' => 4]); - $messages = $I->grabDataFromResponseByJsonPath('$.comments[*].message'); - Assert::assertEquals(['Reply 1', 'Reply 2'], $messages); - - // Child preview embedded in the root's own island shape - $I->sendGet("comment/$root2/full"); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['children' => ['total' => 2, 'hasMore' => false]]); - Assert::assertCount(2, $I->grabDataFromResponseByJsonPath('$.children.items[*].id')); - } - - public function testViewerContextFields(ApiTester $I) - { - $I->wantTo('see viewer-context fields in the island comment shape'); - $I->amAdmin(); - - $I->sendGet('comment/1/full'); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson([ - 'id' => 1, - 'contentId' => 1, - 'parentCommentId' => null, - 'message' => 'Comment 1 of the Post 1', - 'isEdited' => false, - 'blocked' => false, - 'canEdit' => true, - 'canDelete' => true, - 'canAdminDelete' => false, // own comment - 'likes' => ['count' => 0, 'liked' => false], - 'author' => [ - 'guid' => '01e50e0d-82cd-41fc-8b0c-552392f5839c', - 'displayName' => 'Admin Tester', - 'contentContainerId' => 1, - 'online' => null, // viewer looks at themself - ], - ]); - Assert::assertNotEmpty($I->grabDataFromResponseByJsonPath('$.permalink')[0]); - Assert::assertNotEmpty($I->grabDataFromResponseByJsonPath('$.author.imageUrl')[0]); - Assert::assertNotNull($I->grabDataFromResponseByJsonPath('$.messageRenderOptions')[0]); - - $I->sendGet('comment/999/full'); - $I->seeResponseCodeIs(404); - - // Content 1 is Admin's private profile post — not visible to User1. - // (Bearer token instead of a second basic-auth identity: switching the basic-auth - // user mid-test breaks on the authclient collection's per-process login cache.) - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $I->sendGet('comment/1/full'); - $I->seeResponseCodeIs(403); - $I->sendGet('comment/window', ['contentId' => 1]); - $I->seeResponseCodeIs(403); - } - - public function testCreateValidation(ApiTester $I) - { - $I->wantTo('see the 422 validation contract on comment creation'); - $I->amAdmin(); - - $I->sendPost('comment/full', ['contentId' => 1]); - $I->seeResponseCodeIs(422); - $I->seeResponseContainsJson(['errors' => ['message' => ['The comment must not be empty!']]]); - - // One nesting level only - $root = $this->createComment($I, ['message' => 'Root', 'contentId' => 1]); - $reply = $this->createComment($I, ['message' => 'Reply', 'contentId' => 1, 'parentCommentId' => $root]); - $I->sendPost('comment/full', ['message' => 'Nested', 'contentId' => 1, 'parentCommentId' => $reply]); - $I->seeResponseCodeIs(422); - $I->seeResponseContainsJson(['errors' => ['parentCommentId' => ['Comments can only be nested one level deep.']]]); - - $I->sendPost('comment/full', ['message' => 'No such content', 'contentId' => 9999]); - $I->seeResponseCodeIs(404); - } - - public function testUpdateAndEditorFetch(ApiTester $I) - { - $I->wantTo('update a comment and fetch its raw message for the editor'); - $I->amAdmin(); - - $id = $this->createComment($I, ['message' => 'Original', 'contentId' => 1]); - - $I->sendPut("comment/$id/full", ['message' => 'Edited message']); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['id' => $id, 'message' => 'Edited message']); - - $I->sendGet("comment/$id/full/edit"); - $I->seeResponseCodeIs(200); - $I->seeResponseEquals(json_encode(['message' => 'Edited message'])); - - $I->sendPut("comment/$id/full", ['message' => '']); - $I->seeResponseCodeIs(422); - $I->seeResponseContainsJson(['errors' => ['message' => ['The comment must not be empty!']]]); - - // Not the author (and no permission on the content at all) - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $I->sendPut('comment/1/full', ['message' => 'Hijack']); - $I->seeResponseCodeIs(403); - } - - public function testDelete(ApiTester $I) - { - $I->wantTo('delete comments including the admin notify flow'); - - // User1 (bearer token) comments on a public space post, Admin removes it with a notification - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $userCommentId = $this->createComment($I, ['message' => 'To be moderated', 'contentId' => 10]); - - $I->deleteHeader('Authorization'); - $I->amAdmin(); - $I->sendDelete("comment/$userCommentId/full", [ - 'AdminDeleteCommentForm' => ['notify' => 1, 'message' => 'Against the rules'], - ]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['success' => 1]); - $I->seeRecord(Notification::class, ['class' => CommentDeleted::class, 'user_id' => 2]); - $I->sendGet("comment/$userCommentId/full"); - $I->seeResponseCodeIs(404); - - // No delete permission for User1 on Admin's comment (content not even visible); - // the bearer token takes precedence over the still-configured basic auth - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $I->sendDelete('comment/1/full'); - $I->seeResponseCodeIs(403); - - // Plain delete of the own fixture comment - $I->deleteHeader('Authorization'); - $I->sendDelete('comment/1/full'); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['success' => 1]); - $I->sendGet('comment/1/full'); - $I->seeResponseCodeIs(404); - } - - public function testDeleteWithoutPermission(ApiTester $I) - { - $I->wantTo('be rejected when deleting a comment without permission'); - $I->amUser1(); - - $I->sendDelete('comment/1/full'); - $I->seeResponseCodeIs(403); - } - - public function testSessionAndTokenAuth(ApiTester $I) - { - $I->wantTo('use the window endpoints with both session and token auth'); - - // Session auth defaults OFF (see ConfigureForm); enable it for the session portion and - // restore the default so it does not leak to other cests on the shared DB. - $settings = Yii::$app->getModule('rest')->settings; - $settings->set('enableSessionAuth', true); - - try { - // Session auth (User1 = id 2). amLoggedInAs() must run before the first API request - // of the test: the first request replaces the app's user component with the - // session-less API one, after which the Yii2 module can no longer seed a session. - $I->amLoggedInAs(2); - $I->sendGet('comment/window', ['contentId' => 10]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['total' => 0, 'rootTotal' => 0]); - - // Session-authenticated mutation requires the CSRF token - $I->sendPost('comment/full', ['message' => 'No CSRF', 'contentId' => 10]); - $I->seeResponseCodeIs(403); - - $rawToken = Yii::$app->security->generateRandomString(); - $I->setCookie('_csrf', $rawToken); - $I->haveHttpHeader('X-CSRF-Token', Yii::$app->security->maskToken($rawToken)); - $I->sendPost('comment/full', ['message' => 'With CSRF', 'contentId' => 10]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['message' => 'With CSRF', 'canEdit' => true]); - - // Token (bearer) auth — takes precedence over the still-present session (same user - // here) and sees the comment created above - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $I->sendGet('comment/window', ['contentId' => 10]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['total' => 1, 'rootTotal' => 1]); - } finally { - $settings->set('enableSessionAuth', false); - } - } - - public function testGuestAccess(ApiTester $I) - { - $I->wantTo('see guest access to comment windows mirror the core controller'); - - // Guest-visible baseline data: a comment on the public post in the guest-visible Space 2. - // Created via bearer token — basic-auth credentials could not be fully cleared again - // for the guest requests below (they are server params, not a header). - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $this->createComment($I, ['message' => 'Public comment', 'contentId' => 10]); - $I->deleteHeader('Authorization'); - - // Guest access disabled (default): 401 like every other API request - $I->sendGet('comment/window', ['contentId' => 10]); - $I->seeResponseCodeIs(401); - - Yii::$app->getModule('user')->settings->set('auth.allowGuestAccess', 1); - - $I->sendGet('comment/window', ['contentId' => 10]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['total' => 1, 'rootTotal' => 1]); - $I->seeResponseContainsJson(['comments' => [['message' => 'Public comment']]]); - - // guestHideComments rejects guests with 403 (enforced by CommentJsonService) - $commentModule = Yii::$app->getModule('comment'); - $commentModule->guestHideComments = true; - try { - $I->sendGet('comment/window', ['contentId' => 10]); - $I->seeResponseCodeIs(403); - } finally { - $commentModule->guestHideComments = false; - } - - // Content that is not guest-visible stays 403 even with guest access enabled - $I->sendGet('comment/window', ['contentId' => 1]); - $I->seeResponseCodeIs(403); - - // Mutations are never guest-accessible - $I->sendPost('comment/full', ['message' => 'Guest comment', 'contentId' => 10]); - $I->seeResponseCodeIs(401); - } - - /** - * Creates a comment through the island-shape endpoint and returns its id. - */ - private function createComment(ApiTester $I, array $params): int - { - $I->sendPost('comment/full', $params); - $I->seeResponseCodeIs(200); - - return (int)$I->grabDataFromResponseByJsonPath('$.id')[0]; - } -} diff --git a/tests/codeception/api/LikeStateCest.php b/tests/codeception/api/LikeStateCest.php deleted file mode 100644 index 1ffd8aa4..00000000 --- a/tests/codeception/api/LikeStateCest.php +++ /dev/null @@ -1,183 +0,0 @@ -wantTo('read the like state of a record'); - $I->amAdmin(); - - $recordId = $this->getPostRecordId(1); - - $I->sendGet('like/info', ['recordId' => $recordId]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 2]); - - $I->sendGet('like/info', ['recordId' => 9999]); - $I->seeResponseCodeIs(404); - - // Content 1 is Admin's private profile post — not visible to User1. - // (Bearer token instead of a second basic-auth identity: switching the basic-auth - // user mid-test breaks on the authclient collection's per-process login cache.) - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $I->sendGet('like/info', ['recordId' => $recordId]); - $I->seeResponseCodeIs(403); - } - - public function testLikeToggle(ApiTester $I) - { - $I->wantTo('like and unlike a record'); - $I->amAdmin(); - - $recordId = $this->getPostRecordId(1); - - $I->sendPost("like?recordId=$recordId"); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => true, 'likeCounter' => 3]); - - $I->sendDelete("like?recordId=$recordId"); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 2]); - - // Unlike is idempotent, like the core action - $I->sendDelete("like?recordId=$recordId"); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 2]); - } - - public function testUserList(ApiTester $I) - { - $I->wantTo('page through the users who liked a record'); - $I->amAdmin(); - - $recordId = $this->getPostRecordId(1); - - // Newest like first: user 4 (Andreas), then user 3 (Sara) - $I->sendGet('like/user-list', ['recordId' => $recordId]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['total' => 2, 'hasMore' => false, 'nextPage' => null]); - Assert::assertEquals( - ['Andreas Tester', 'Sara Tester'], - $I->grabDataFromResponseByJsonPath('$.users[*].displayName'), - ); - // UserJsonService user shape - $I->seeResponseContainsJson(['users' => [['guid' => '01e50e0d-82cd-41fc-8b0c-552392f5839f', 'contentContainerId' => 8]]]); - Assert::assertNotEmpty($I->grabDataFromResponseByJsonPath('$.users[0].imageUrl')[0]); - Assert::assertNotEmpty($I->grabDataFromResponseByJsonPath('$.users[0].url')[0]); - - // `limit` is clamped to [1, userListPaginationSize]: 0 becomes 1 instead of "no LIMIT" - $I->sendGet('like/user-list', ['recordId' => $recordId, 'limit' => 0]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['total' => 2, 'hasMore' => true, 'nextPage' => 2]); - Assert::assertEquals(['Andreas Tester'], $I->grabDataFromResponseByJsonPath('$.users[*].displayName')); - - $I->sendGet('like/user-list', ['recordId' => $recordId, 'limit' => 0, 'page' => 2]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['hasMore' => false, 'nextPage' => null]); - Assert::assertEquals(['Sara Tester'], $I->grabDataFromResponseByJsonPath('$.users[*].displayName')); - - // Oversized limits are clamped to the module default, not passed through - $I->sendGet('like/user-list', ['recordId' => $recordId, 'limit' => 999]); - $I->seeResponseCodeIs(200); - Assert::assertCount(2, $I->grabDataFromResponseByJsonPath('$.users[*].displayName')); - } - - public function testSessionAndTokenAuth(ApiTester $I) - { - $I->wantTo('use the like endpoints with both session and token auth'); - - $recordId = $this->getPostRecordId(10); - - // Session auth defaults OFF (see ConfigureForm); enable it for the session portion and - // restore the default so it does not leak to other cests on the shared DB. - $settings = Yii::$app->getModule('rest')->settings; - $settings->set('enableSessionAuth', true); - - try { - // Session auth (User1 = id 2). amLoggedInAs() must run before the first API request - // of the test: the first request replaces the app's user component with the - // session-less API one, after which the Yii2 module can no longer seed a session. - $I->amLoggedInAs(2); - $I->sendGet('like/info', ['recordId' => $recordId]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 0]); - - // Session-authenticated mutation requires the CSRF token - $I->sendPost("like?recordId=$recordId"); - $I->seeResponseCodeIs(403); - - $rawToken = Yii::$app->security->generateRandomString(); - $I->setCookie('_csrf', $rawToken); - $I->haveHttpHeader('X-CSRF-Token', Yii::$app->security->maskToken($rawToken)); - $I->sendPost("like?recordId=$recordId"); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => true, 'likeCounter' => 1]); - - // Token (bearer) auth — takes precedence over the still-present session (same user here) - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $I->sendGet('like/info', ['recordId' => $recordId]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => true, 'likeCounter' => 1]); - } finally { - $settings->set('enableSessionAuth', false); - } - } - - public function testGuestAccess(ApiTester $I) - { - $I->wantTo('see guest access to like info mirror the core controller'); - - $recordId = $this->getPostRecordId(10); - - // Guest access disabled (default): 401 like every other API request - $I->sendGet('like/info', ['recordId' => $recordId]); - $I->seeResponseCodeIs(401); - - Yii::$app->getModule('user')->settings->set('auth.allowGuestAccess', 1); - - // `info` is guest-allowed on guest-visible content, like in the core controller - $I->sendGet('like/info', ['recordId' => $recordId]); - $I->seeResponseCodeIs(200); - $I->seeResponseContainsJson(['currentUserLiked' => false, 'likeCounter' => 0]); - - // Content that is not guest-visible stays denied - $I->sendGet('like/info', ['recordId' => $this->getPostRecordId(1)]); - $I->seeResponseCodeIs(403); - - // Everything else stays logged-in only, mirroring core's guestAllowedActions = ['info'] - $I->sendGet('like/user-list', ['recordId' => $recordId]); - $I->seeResponseCodeIs(401); - $I->sendPost("like?recordId=$recordId"); - $I->seeResponseCodeIs(401); - $I->sendDelete("like?recordId=$recordId"); - $I->seeResponseCodeIs(401); - } - - /** - * Returns the RecordMap id of the given post — what the like island passes as `recordId`. - */ - private function getPostRecordId(int $postId): int - { - return RecordMap::getId(Post::findOne(['id' => $postId])); - } -} diff --git a/tests/codeception/api/SessionAuthCest.php b/tests/codeception/api/SessionAuthCest.php deleted file mode 100644 index 1ad28a3a..00000000 --- a/tests/codeception/api/SessionAuthCest.php +++ /dev/null @@ -1,232 +0,0 @@ -manager->register(new class extends UserGate { - public function getId(): string - { - return 'rest-test-gate'; - } - - public function getSortOrder(): int - { - return self::SORT_SECOND_FACTOR; - } - - public function isOpen(): bool - { - return true; - } - - public function getRoute(): array - { - return ['/user/auth/logout']; - } - - public function isCacheable(): bool - { - return false; - } - }); - } - - public function _before() - { - parent::_before(); - // Session auth defaults OFF (see ConfigureForm); enable it for these tests. - Yii::$app->getModule('rest')->settings->set('enableSessionAuth', true); - } - - public function _after() - { - // Restore the default so the setting does not leak into other cests on the shared DB. - Yii::$app->getModule('rest')->settings->set('enableSessionAuth', false); - } - - public function testSessionAuthenticatedGet(ApiTester $I) - { - $I->wantTo('authenticate a GET request by browser session'); - - $I->amLoggedInAs(self::USER1_ID); - - $I->sendGet('auth/current'); - $I->seeSuccessResponseContainsJson($I->getUserDefinition('User1')); - } - - public function testGuestIsRejected(ApiTester $I) - { - $I->wantTo('be rejected as guest without token or session'); - - $I->sendGet('auth/current'); - $I->seeCodeResponseContainsJson(HttpCode::UNAUTHORIZED, ['message' => 'Your request was made with invalid credentials.']); - } - - public function testSessionModifyingRequestWithoutCsrfIsRejected(ApiTester $I) - { - $I->wantTo('see a session-authenticated modifying request rejected without CSRF token'); - - $I->amLoggedInAs(self::USER1_ID); - - $I->sendPatch('notification/mark-as-seen'); - $I->seeCodeResponseContainsJson(HttpCode::FORBIDDEN, [ - 'message' => 'Unable to verify your data submission. Session-authenticated modifying requests require a valid CSRF token (X-CSRF-Token header).', - ]); - } - - public function testSessionModifyingRequestWithCsrfSucceeds(ApiTester $I) - { - $I->wantTo('perform a session-authenticated modifying request with a valid CSRF token'); - - $I->amLoggedInAs(self::USER1_ID); - - // The raw CSRF token lives in the `_csrf` cookie; the client sends the masked - // form in the X-CSRF-Token header — same mechanism as humhub.client in the browser. - $rawToken = Yii::$app->security->generateRandomString(); - $I->setCookie('_csrf', $rawToken); - $I->haveHttpHeader('X-CSRF-Token', Yii::$app->security->maskToken($rawToken)); - - $I->sendPatch('notification/mark-as-seen'); - $I->seeSuccessMessage('All notifications successfully marked as seen'); - } - - public function testSessionAuthDisabledSetting(ApiTester $I) - { - $I->wantTo('see session auth rejected when disabled while token auth still works'); - - $settings = Yii::$app->getModule('rest')->settings; - $settings->set('enableSessionAuth', false); - - try { - $I->amLoggedInAs(self::USER1_ID); - $I->sendGet('auth/current'); - $I->seeCodeResponseContainsJson(HttpCode::UNAUTHORIZED, ['message' => 'Your request was made with invalid credentials.']); - - $I->amBearerAuthenticated(self::USER1_BEARER_TOKEN); - $I->sendGet('auth/current'); - $I->seeSuccessResponseContainsJson($I->getUserDefinition('User1')); - } finally { - // _before() enabled session auth for this cest; restore that state for later tests. - $settings->set('enableSessionAuth', true); - } - } - - public function testTokenWinsOverSession(ApiTester $I) - { - $I->wantTo('see token auth take precedence over an existing browser session'); - - // Session of User1, bearer token of... also User1 — use basic auth of Admin instead - // to observe precedence via the returned identity. - $I->amLoggedInAs(self::USER1_ID); - $I->amHttpAuthenticated('Admin', 'admin&humhub@PASS%worD!'); - - $I->sendGet('auth/current'); - $I->seeSuccessResponseContainsJson($I->getUserDefinition('Admin')); - } - - public function testOffRuleMutationIsBlocked(ApiTester $I) - { - $I->wantTo('see a bare /rest/... URL blocked instead of executing a mutating action (C1)'); - - // Session of an admin who is allowed to delete fixture comment 1. - $I->amLoggedInAs(self::ADMIN_ID); - - // A bare, unprefixed /rest// URL must never resolve to a controller - // action. Before the fix this executed WindowController::actionDelete as a plain GET — - // no verb constraint, no CSRF check (a SameSite=Lax cross-site top-level GET CSRF hole). - $I->sendGet('http://localhost:8080/rest/comment/window/delete?id=1'); - $I->seeResponseCodeIs(404); - - // The targeted comment must still exist — the delete never ran. - Assert::assertNotNull(Comment::findOne(['id' => 1]), 'Off-rule GET must not have deleted the comment'); - } - - public function testCsrfValidationNeverMintsCookie(ApiTester $I) - { - $I->wantTo('see CSRF validation reject without ever Set-Cookie-ing a fresh _csrf token (M5)'); - - $I->amLoggedInAs(self::USER1_ID); - - // Modifying request without a _csrf cookie or token: rejected 403, and the response must - // NOT mint a _csrf Set-Cookie (which would clobber the browser page's real token). - $I->sendPatch('notification/mark-as-seen'); - $I->seeResponseCodeIs(403); - - foreach ((array)$I->grabHttpHeader('Set-Cookie', false) as $setCookie) { - Assert::assertStringNotContainsString('_csrf', (string)$setCookie, 'API response must not Set-Cookie a _csrf token'); - } - } - - public function testSessionAuthEnforcesOpenGate(ApiTester $I) - { - $I->wantTo('see an open non-API gate (e.g. a pending 2FA check) reject a session request (C2)'); - - Event::on(GateManager::class, GateManager::EVENT_INIT_GATES, [self::class, '_registerOpenSessionGate']); - try { - $I->amLoggedInAs(self::USER1_ID); - - // Without the gate-visibility fix the session-less API user component makes core's - // GateFilter classify this as an API request and skip the gate → 200. It must be 403. - $I->sendGet('auth/current'); - $I->seeResponseCodeIs(403); - } finally { - Event::off(GateManager::class, GateManager::EVENT_INIT_GATES, [self::class, '_registerOpenSessionGate']); - } - } - - public function testSessionImpersonationIsRejected(ApiTester $I) - { - $I->wantTo('see a session-bound admin impersonation rejected on the API (I3, fail closed)'); - - $I->amLoggedInAs(self::USER1_ID); - - // Simulate an active impersonation: core stores the impersonator id under this session - // key (Impersonation::SESSION_KEY). The session-less API user component would otherwise - // hide the impersonation and lift core 1.19's private-content restriction. - Yii::$app->session->set(Impersonation::SESSION_KEY, ['id' => self::ADMIN_ID, 'duration' => 0]); - try { - $I->sendGet('auth/current'); - $I->seeResponseCodeIs(403); - } finally { - Yii::$app->session->remove(Impersonation::SESSION_KEY); - } - } -} diff --git a/views/admin/index.php b/views/admin/index.php index 468c599e..b7aff2f1 100644 --- a/views/admin/index.php +++ b/views/admin/index.php @@ -31,7 +31,6 @@ field($model, 'enableBearerAuth')->checkbox(); ?> field($model, 'enableQueryParamAuth')->checkbox(); ?> -field($model, 'enableSessionAuth')->checkbox(); ?>
From e92b9b74966cf24fd7107deee505ab971a0fbfc9 Mon Sep 17 00:00:00 2001 From: Lucas Bartholemy Date: Mon, 24 Aug 2026 12:11:04 +0200 Subject: [PATCH 7/7] Require core 1.20 and leave the v2 documentation to core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/api/v2` reference is part of HumHub core now (`docs/api/`, served by every installation), so this module's Swagger sources cover its own `/api/v1` surface again and the v2 copies here are removed. The API framework this module builds on ships in core 1.20, which is what `humhub.minVersion` now says — the 0.12.x line stays for 1.19 and gets an upper bound of its own. --- docs/CHANGELOG.md | 5 +- docs/MANUAL.md | 9 +- docs/api-stack.md | 21 +- docs/html/v2/account.html | 391 ---------------------- docs/html/v2/comment.html | 623 ----------------------------------- docs/html/v2/like.html | 525 ----------------------------- docs/swagger/build-all.sh | 26 +- docs/swagger/v2/account.yaml | 72 ---- docs/swagger/v2/comment.yaml | 405 ----------------------- docs/swagger/v2/common.yaml | 183 ---------- docs/swagger/v2/like.yaml | 170 ---------- module.json | 2 +- 12 files changed, 26 insertions(+), 2406 deletions(-) delete mode 100644 docs/html/v2/account.html delete mode 100644 docs/html/v2/comment.html delete mode 100644 docs/html/v2/like.html delete mode 100644 docs/swagger/v2/account.yaml delete mode 100644 docs/swagger/v2/comment.yaml delete mode 100644 docs/swagger/v2/common.yaml delete mode 100644 docs/swagger/v2/like.yaml diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 48e0cdc4..97a4264f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,8 +3,9 @@ Changelog 0.13.0 (Unreleased) ------------------- -- Enh: The module's authentication methods (JWT, Bearer, query param, Basic, Impersonate) now apply to the API endpoints of core 1.19 as well (`/api/v2`), contributed through the core API framework; `/api/v1` stays token-only, browser-session authentication is a core opt-in per endpoint (see `docs/api-stack.md`) -- Enh: Documented core's `/api/v2` endpoints (comment, like, account, incl. the batched `like/states` and per-comment `permissions` calls) under `docs/swagger/v2/`, until core ships its own API documentation; `build-all.sh` renders them to `docs/html/v2/` and no longer builds a page for the shared-components file +- Chg: Raised minimum HumHub version to 1.20 — the module builds on the HTTP API framework of core (`humhub\components\api\`) +- Enh: The module's authentication methods (JWT, Bearer, query param, Basic, Impersonate) now apply to the API endpoints of core 1.20 as well (`/api/v2`), contributed through the core API framework; `/api/v1` stays token-only, browser-session authentication is a core opt-in per endpoint (see `docs/api-stack.md`) +- Chg: The `/api/v2` documentation lives in core (`docs/api/`), not in this module — this module's Swagger sources cover its own `/api/v1` surface again; `build-all.sh` no longer builds a page for the shared-components file - Enh: Hardened the `/rest/...` URL space — the admin-page and catch-all rules are registered for every request, and a REST controller reached off the `api/v1/` prefix (e.g. through Yii's fallback routing) now fails with a 404 before authentication runs - Fix: Impersonate token authentication crashed on HumHub 1.19 (`isImpersonated` was removed by the core impersonation refactor, core #8372) - Chg: Replies are validated to nest at most one level (core `Comment` model rule, surfaced through the validation envelope) diff --git a/docs/MANUAL.md b/docs/MANUAL.md index 5e094b79..ca88dd25 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -8,7 +8,7 @@ Following RESTful API endpoints are available. The base url for the endpoints of this module is: `https://yourhost/api/v1/` -Since HumHub 1.19 the platform itself ships API endpoints under +Since HumHub 1.20 the platform itself ships API endpoints under `https://yourhost/api/v2/`, in modernized conventions (ISO-8601 timestamps, camelCase, plain HTTP status codes). This module's authentication methods apply to them as well — see the **v2 APIs** below and `docs/api-stack.md`. @@ -32,11 +32,10 @@ Logged-in user's language will be used. Can be overwritten by `Accept-Language` - [Space](https://marketplace.humhub.com/module/rest/docs/html/space.html) - [Content Topics](https://marketplace.humhub.com/module/rest/docs/html/topic.html) -**v2 APIs (endpoints shipped by HumHub core, 1.19+):** +**v2 APIs (endpoints shipped by HumHub core, 1.20+):** -- [Comment](https://marketplace.humhub.com/module/rest/docs/html/v2/comment.html) -- [Like](https://marketplace.humhub.com/module/rest/docs/html/v2/like.html) -- [Account](https://marketplace.humhub.com/module/rest/docs/html/v2/account.html) +Documented by core itself and served by every installation at `/docs/api/` — this module only +contributes the token authentication those endpoints accept. **Module APIs** diff --git a/docs/api-stack.md b/docs/api-stack.md index eec15c27..e9311327 100644 --- a/docs/api-stack.md +++ b/docs/api-stack.md @@ -1,6 +1,6 @@ # This module and the platform API stack -HumHub 1.19 ships an HTTP API framework in core (`humhub\components\api\`): base +HumHub 1.20 ships an HTTP API framework in core (`humhub\components\api\`): base controller, request/response conventions, URL-rule registration, the serialize extension point and browser-session authentication. Core's own endpoints live next to the modules that own them (`humhub\modules\\controllers\api\`) and answer @@ -74,22 +74,19 @@ Both require pretty URLs, as the API always has. ## Documentation layout -`docs/swagger/` holds the OpenAPI sources, one document per module, rendered to -`docs/html/` by `build-all.sh`: +`docs/swagger/` holds the OpenAPI sources of **this module's `/api/v1` surface**, one +document per module, rendered to `docs/html/` by `build-all.sh`. -- the flat files are this module's `/api/v1` surface, -- `v2/` documents the endpoints **core** ships (`docs/html/v2/`), with `v2/common.yaml` - holding the shared schemas, parameters, error responses and security schemes. - -The v2 documents live here only until core ships the Swagger sources for its own -endpoints — keeping them in their own directory is what makes that a move rather than a -rename, and it leaves every published `/api/v1` documentation URL untouched. +The endpoints **core** ships (`/api/v2`) are documented by core itself, in `docs/api/` of the +core repository — sources in `docs/api/src/`, the rendered references next to them, and an +index page an installation serves at `/docs/api/`. This module documented them here while core +had no place for them; the move left every published `/api/v1` documentation URL untouched. ## Version bounds -This module version requires core 1.19 (`humhub.minVersion`) — it uses the core +This module version requires core 1.20 (`humhub.minVersion`) — it uses the core framework's collect event. The previous module line still needs a `humhub.maxVersion`, so -the marketplace does not offer a version without the core stack for 1.19+. Those fields +the marketplace does not offer a version without the core stack for 1.20+. Those fields are marketplace metadata, not runtime enforcement: core does not evaluate them when loading a module, so an administrator copying an outdated module in by hand bypasses them. diff --git a/docs/html/v2/account.html b/docs/html/v2/account.html deleted file mode 100644 index c7a8f753..00000000 --- a/docs/html/v2/account.html +++ /dev/null @@ -1,391 +0,0 @@ - - - - - - HumHub - Account API (v2) - - - - - - - - - -

HumHub - Account API (v2) (2.0.0)

Download OpenAPI specification:

E-mail: info@humhub.com License: AGPLv2

Welcome to the HumHub account API reference, v2 — the authenticated user's own data.

-

These endpoints are shipped by HumHub core (1.19+), not by the REST API module — the -module only contributes its authentication methods to them and documents them here until -core ships its own API documentation. See the core docs, -docs/develop/concept-api.md.

-

Conventions of this API generation, differing from /api/v1: camelCase field names, -ISO-8601 timestamps with offset, and plain HTTP status codes instead of a -{code, message} envelope.

-

Requests may authenticate with any token method the REST API module offers. Core's own -browser session is accepted too for these endpoints, which is what the platform's Vue UI -uses.

-

The authenticated user

Authorizations:
BearerBasicAuthSessionCookie

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "guid": "string",
  • "displayName": "string",
  • "url": "string",
  • "imageUrl": "string",
  • "contentContainerId": 0,
  • "online": true
}

Ids of the users the caller has blocked

Clients need this to reproduce the platform's blocked-author masking: payloads are -always unmasked, since masking is a display concern rather than an access boundary. -Empty when user blocking is disabled by an administrator.

-
Authorizations:
BearerBasicAuthSessionCookie

Responses

Response samples

Content type
application/json
{
  • "results": [
    ]
}
- - - - diff --git a/docs/html/v2/comment.html b/docs/html/v2/comment.html deleted file mode 100644 index c676b3ae..00000000 --- a/docs/html/v2/comment.html +++ /dev/null @@ -1,623 +0,0 @@ - - - - - - HumHub - Comment API (v2) - - - - - - - - - -

HumHub - Comment API (v2) (2.0.0)

Download OpenAPI specification:

E-mail: info@humhub.com License: AGPLv2

Welcome to the HumHub comment API reference, v2.

-

These endpoints are shipped by HumHub core (1.19+), not by the REST API module — the -module only contributes its authentication methods to them and documents them here until -core ships its own API documentation. See the core docs, -docs/develop/concept-api.md.

-

Conventions of this API generation, differing from /api/v1:

-
    -
  • Timestamps are ISO-8601 with offset, in UTC (2026-08-22T08:00:00+00:00).
  • -
  • Field names are camelCase throughout.
  • -
  • Errors are plain HTTP status codes with a JSON body; there is no {code, message} -success/failure envelope. Validation failures answer 422 with -{"errors": {"<field>": ["<message>"]}}, a successful delete answers 204.
  • -
  • Comment lists are cursor windows, not offset pages (see the window endpoints).
  • -
  • The comment shape is caller-neutral: identical for every reader who may see the -content. What depends on the caller has its own endpoints — GET /comment/{id}/permissions -for edit/delete, GET /like/states for like state.
  • -
-

Requests may authenticate with any token method the REST API module offers. Core's own -browser session is accepted too for these endpoints, which is what the platform's Vue UI -uses; state-changing session requests additionally require the CSRF token.

-

Root-comment window of a content

A window of the content's root comments. Without any cursor the newest comments are -returned; commentId + direction pages from a comment ("show previous/next N -comments"), commentId alone focuses the window around a permalinked comment.

-

total counts all comments of the content including replies (what a comment -badge shows), while results, prevCount and nextCount describe the root level -only. rootTotal is the root-only total a root list needs to compute its own -remaining count.

-

Readable by guests for guest-visible content while guest access is enabled -platform-wide, unless the comment module hides comments from guests -(guestHideComments).

-
Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the content

-
query Parameters
commentId
integer

Cursor comment, or the anchor to focus the window around when no direction is given

-
direction
string
Enum: "previous" "next"

Paging direction relative to commentId

-
pageSize
integer

Comments per page while paging with direction. Clamped to the comment module's -configured block load size.

-
limit
integer

Size of an initial (cursor-less) window. Clamped to the comment module's configured -block load size.

-

Responses

Response samples

Content type
application/json
{
  • "results": [
    ],
  • "total": 0,
  • "rootTotal": 0,
  • "prevCount": 0,
  • "nextCount": 0
}

Reply window of a comment thread

The same window semantics as the content window, for the replies of one root comment. -total/rootTotal still describe the whole content, so a client can keep its badge -and its root list consistent while paging replies.

-
Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the root comment

-
query Parameters
commentId
integer

Cursor comment, or the anchor to focus the window around when no direction is given

-
direction
string
Enum: "previous" "next"

Paging direction relative to commentId

-
pageSize
integer

Comments per page while paging with direction. Clamped to the comment module's -configured block load size.

-
limit
integer

Size of an initial (cursor-less) window. Clamped to the comment module's configured -block load size.

-

Responses

Response samples

Content type
application/json
{
  • "results": [
    ],
  • "total": 0,
  • "rootTotal": 0,
  • "prevCount": 0,
  • "nextCount": 0
}

Create a comment

Creates a comment on the given content. Replies pass their parent through -parentCommentId; comments nest at most one level, a deeper reply answers 422.

-
Authorizations:
BearerBasicAuthSessionCookie
query Parameters
contentId
required
integer

The primary key of the content

-
parentCommentId
integer

The primary key of the root comment this is a reply to

-
Request Body schema: application/json
required
message
required
string

Markdown message

-
fileList
Array of strings

Guids of already uploaded files to attach

-

Responses

Request samples

Content type
application/json
{
  • "message": "string",
  • "fileList": [
    ]
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "message": "string",
  • "messageRenderOptions": { },
  • "contentId": 0,
  • "parentCommentId": 0,
  • "recordId": 0,
  • "createdBy": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "url": "string",
  • "files": [
    ],
  • "childCount": 0,
  • "replies": {
    },
  • "extensions": { }
}

What the caller may do with a comment

Deliberately not part of the comment shape: these are the only caller-dependent -values a comment needs, and only when its context menu is opened — keeping them out -is what makes the comment payload identical for every reader (and cacheable). The -same checks the update and delete endpoints enforce.

-

Authenticated callers only: a guest has no permissions to report.

-
Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the comment

-

Responses

Response samples

Content type
application/json
{
  • "canEdit": true,
  • "canDelete": true
}

A single comment

A root comment carries a preview of its newest replies under replies; a reply has -replies: null. Readable by guests under the same conditions as the windows.

-
Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the comment

-

Responses

Response samples

Content type
application/json
{
  • "id": 0,
  • "message": "string",
  • "messageRenderOptions": { },
  • "contentId": 0,
  • "parentCommentId": 0,
  • "recordId": 0,
  • "createdBy": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "url": "string",
  • "files": [
    ],
  • "childCount": 0,
  • "replies": {
    },
  • "extensions": { }
}

Update a comment

Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the comment

-
Request Body schema: application/json
required
message
required
string

Markdown message

-
fileList
Array of strings

Guids of already uploaded files to attach

-

Responses

Request samples

Content type
application/json
{
  • "message": "string",
  • "fileList": [
    ]
}

Response samples

Content type
application/json
{
  • "id": 0,
  • "message": "string",
  • "messageRenderOptions": { },
  • "contentId": 0,
  • "parentCommentId": 0,
  • "recordId": 0,
  • "createdBy": {
    },
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "url": "string",
  • "files": [
    ],
  • "childCount": 0,
  • "replies": {
    },
  • "extensions": { }
}

Delete a comment

Deletes the comment. The optional body parameters trigger the moderation flow: with -notify, the author receives a notification carrying a preview of the removed text -and the given message as the reason.

-
Authorizations:
BearerBasicAuthSessionCookie
path Parameters
id
required
integer

The primary key of the comment

-
Request Body schema: application/json
optional
notify
boolean

Notify the author about the removal

-
message
string

Reason shown to the author

-

Responses

Request samples

Content type
application/json
{
  • "notify": true,
  • "message": "string"
}
- - - - diff --git a/docs/html/v2/like.html b/docs/html/v2/like.html deleted file mode 100644 index a8ae1670..00000000 --- a/docs/html/v2/like.html +++ /dev/null @@ -1,525 +0,0 @@ - - - - - - HumHub - Like API (v2) - - - - - - - - - -

HumHub - Like API (v2) (2.0.0)

Download OpenAPI specification:

E-mail: info@humhub.com License: AGPLv2

Welcome to the HumHub like API reference, v2.

-

These endpoints are shipped by HumHub core (1.19+), not by the REST API module — the -module only contributes its authentication methods to them and documents them here until -core ships its own API documentation. See the core docs, -docs/develop/concept-api.md.

-

Conventions of this API generation, differing from /api/v1: camelCase field names, -ISO-8601 timestamps with offset, plain HTTP status codes instead of a -{code, message} envelope, and {results, total, page, pageSize, pages} for paginated -lists.

-

Every likeable record is addressed either by its platform-wide recordId (what shapes -carrying likes expose) or by model + pk.

-

Requests may authenticate with any token method the REST API module offers. Core's own -browser session is accepted too for these endpoints, which is what the platform's Vue UI -uses; state-changing session requests additionally require the CSRF token.

-

Like state of a record

The caller-context like state. Readable by guests for content they can see, with -liked and canLike always false.

-
Authorizations:
BearerBasicAuthSessionCookie
query Parameters
recordId
integer

Platform-wide record id — the addressing every shape that carries likes exposes as -recordId. Alternative to model + pk.

-
model
string

Class name of the record, when addressing it by model and primary key

-
pk
integer

Primary key of the record, when addressing it by model and primary key

-

Responses

Response samples

Content type
application/json
{
  • "total": 0,
  • "liked": true,
  • "canLike": true
}

Like states of many records

The caller's like state for up to 100 records in one request, keyed by record id — -what a client asks for after receiving a page of records whose payloads deliberately -carry no like state (see the LikeState schema).

-

Records the caller may not see, and ids that resolve to nothing, are absent from -the map rather than failing the request. Readable by guests, with liked and -canLike always false.

-
Authorizations:
BearerBasicAuthSessionCookie
query Parameters
recordIds
string

Comma-separated (or repeated recordIds[]=) list of platform-wide record ids, capped -at 100 per request.

-

Responses

Response samples

Content type
application/json
{
  • "results": {
    }
}

Like a record

Authorizations:
BearerBasicAuthSessionCookie
query Parameters
recordId
integer

Platform-wide record id — the addressing every shape that carries likes exposes as -recordId. Alternative to model + pk.

-
model
string

Class name of the record, when addressing it by model and primary key

-
pk
integer

Primary key of the record, when addressing it by model and primary key

-

Responses

Response samples

Content type
application/json
{
  • "total": 0,
  • "liked": true,
  • "canLike": true
}

Remove the caller's like

Idempotent — unliking something that was never liked is a success.

-
Authorizations:
BearerBasicAuthSessionCookie
query Parameters
recordId
integer

Platform-wide record id — the addressing every shape that carries likes exposes as -recordId. Alternative to model + pk.

-
model
string

Class name of the record, when addressing it by model and primary key

-
pk
integer

Primary key of the record, when addressing it by model and primary key

-

Responses

Response samples

Content type
application/json
{
  • "total": 0,
  • "liked": true,
  • "canLike": true
}

Users who liked a record

Newest first. Readable by guests for content they can see.

-
Authorizations:
BearerBasicAuthSessionCookie
query Parameters
recordId
integer

Platform-wide record id — the addressing every shape that carries likes exposes as -recordId. Alternative to model + pk.

-
model
string

Class name of the record, when addressing it by model and primary key

-
pk
integer

Primary key of the record, when addressing it by model and primary key

-
page
integer

Page number, starting at 1

-
pageSize
integer

Records per page (default 25, maximum 100)

-

Responses

Response samples

Content type
application/json
{
  • "results": [
    ],
  • "total": 0,
  • "page": 0,
  • "pageSize": 0,
  • "pages": 0
}
- - - - diff --git a/docs/swagger/build-all.sh b/docs/swagger/build-all.sh index f3577cf4..c54f3250 100755 --- a/docs/swagger/build-all.sh +++ b/docs/swagger/build-all.sh @@ -3,23 +3,15 @@ # Renders every endpoint document to ../html/. `common.yaml` holds shared components only # and is skipped — it is referenced by the endpoint documents, not read on its own. # -# Layout: the flat files are the /api/v1 surface of this module, `v2/` documents the -# endpoints core itself ships (see ../api-stack.md) and renders to ../html/v2/. - -build() { - local source_dir="$1" - local target_dir="$2" - - mkdir -p "$target_dir" - - for filename in "$source_dir"/*.yaml; do - [ "$(basename "$filename")" = "common.yaml" ] && continue - echo "--------- $filename ---------------------" - npx @redocly/cli build-docs "$filename" -o "$target_dir/$(basename "$filename" .yaml).html" - done -} +# This module documents its own `/api/v1` surface here. The endpoints core ships (`/api/v2`) +# are documented by core itself, in `docs/api/` of the core repository (see ../api-stack.md). cd "$(dirname "$0")" || exit 1 -build . ../html -build v2 ../html/v2 +mkdir -p ../html + +for filename in *.yaml; do + [ "$filename" = "common.yaml" ] && continue + echo "--------- $filename ---------------------" + npx @redocly/cli build-docs "$filename" -o "../html/$(basename "$filename" .yaml).html" +done diff --git a/docs/swagger/v2/account.yaml b/docs/swagger/v2/account.yaml deleted file mode 100644 index e9bb8a32..00000000 --- a/docs/swagger/v2/account.yaml +++ /dev/null @@ -1,72 +0,0 @@ -openapi: 3.0.0 -info: - description: | - Welcome to the HumHub account API reference, v2 — the authenticated user's own data. - - These endpoints are shipped by **HumHub core** (1.19+), not by the REST API module — the - module only contributes its authentication methods to them and documents them here until - core ships its own API documentation. See the core docs, - `docs/develop/concept-api.md`. - - Conventions of this API generation, differing from `/api/v1`: camelCase field names, - ISO-8601 timestamps with offset, and plain HTTP status codes instead of a - `{code, message}` envelope. - - Requests may authenticate with any token method the REST API module offers. Core's own - browser session is accepted too for these endpoints, which is what the platform's Vue UI - uses. - version: 2.0.0 - title: HumHub - Account API (v2) - contact: - email: info@humhub.com - license: - name: AGPLv2 - url: https://www.humhub.org/en/licences -servers: - - url: /api/v2 -security: - - Bearer: [] - - BasicAuth: [] - - SessionCookie: [] -paths: - "/account": - get: - summary: The authenticated user - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "common.yaml#/components/schemas/UserShort" - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "/account/blocked-users": - get: - summary: Ids of the users the caller has blocked - description: | - Clients need this to reproduce the platform's blocked-author masking: payloads are - always unmasked, since masking is a display concern rather than an access boundary. - Empty when user blocking is disabled by an administrator. - responses: - "200": - description: Successful operation - content: - application/json: - schema: - type: object - properties: - results: - type: array - items: - type: integer - "401": - $ref: "common.yaml#/components/responses/Unauthorized" -components: - securitySchemes: - Bearer: - $ref: "common.yaml#/components/securitySchemes/Bearer" - BasicAuth: - $ref: "common.yaml#/components/securitySchemes/BasicAuth" - SessionCookie: - $ref: "common.yaml#/components/securitySchemes/SessionCookie" diff --git a/docs/swagger/v2/comment.yaml b/docs/swagger/v2/comment.yaml deleted file mode 100644 index f3e2f59c..00000000 --- a/docs/swagger/v2/comment.yaml +++ /dev/null @@ -1,405 +0,0 @@ -openapi: 3.0.0 -info: - description: | - Welcome to the HumHub comment API reference, v2. - - These endpoints are shipped by **HumHub core** (1.19+), not by the REST API module — the - module only contributes its authentication methods to them and documents them here until - core ships its own API documentation. See the core docs, - `docs/develop/concept-api.md`. - - Conventions of this API generation, differing from `/api/v1`: - - - Timestamps are ISO-8601 with offset, in UTC (`2026-08-22T08:00:00+00:00`). - - Field names are camelCase throughout. - - Errors are plain HTTP status codes with a JSON body; there is no `{code, message}` - success/failure envelope. Validation failures answer `422` with - `{"errors": {"": [""]}}`, a successful delete answers `204`. - - Comment lists are **cursor windows**, not offset pages (see the window endpoints). - - The comment shape is **caller-neutral**: identical for every reader who may see the - content. What depends on the caller has its own endpoints — `GET /comment/{id}/permissions` - for edit/delete, `GET /like/states` for like state. - - Requests may authenticate with any token method the REST API module offers. Core's own - browser session is accepted too for these endpoints, which is what the platform's Vue UI - uses; state-changing session requests additionally require the CSRF token. - version: 2.0.0 - title: HumHub - Comment API (v2) - contact: - email: info@humhub.com - license: - name: AGPLv2 - url: https://www.humhub.org/en/licences -servers: - - url: /api/v2 -security: - - Bearer: [] - - BasicAuth: [] - - SessionCookie: [] -paths: - "/comment/content/{id}/window": - get: - summary: Root-comment window of a content - description: | - A window of the content's root comments. Without any cursor the newest comments are - returned; `commentId` + `direction` pages from a comment ("show previous/next N - comments"), `commentId` alone focuses the window around a permalinked comment. - - `total` counts all comments of the content **including replies** (what a comment - badge shows), while `results`, `prevCount` and `nextCount` describe the root level - only. `rootTotal` is the root-only total a root list needs to compute its own - remaining count. - - Readable by guests for guest-visible content while guest access is enabled - platform-wide, unless the comment module hides comments from guests - (`guestHideComments`). - parameters: - - name: id - in: path - description: The primary key of the content - required: true - schema: - type: integer - - $ref: "#/components/parameters/commentIdParam" - - $ref: "#/components/parameters/directionParam" - - $ref: "#/components/parameters/windowPageSizeParam" - - $ref: "#/components/parameters/windowLimitParam" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/CommentWindow" - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" - "/comment/parent/{id}/window": - get: - summary: Reply window of a comment thread - description: | - The same window semantics as the content window, for the replies of one root comment. - `total`/`rootTotal` still describe the whole content, so a client can keep its badge - and its root list consistent while paging replies. - parameters: - - name: id - in: path - description: The primary key of the root comment - required: true - schema: - type: integer - - $ref: "#/components/parameters/commentIdParam" - - $ref: "#/components/parameters/directionParam" - - $ref: "#/components/parameters/windowPageSizeParam" - - $ref: "#/components/parameters/windowLimitParam" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/CommentWindow" - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" - "/comment": - post: - summary: Create a comment - description: | - Creates a comment on the given content. Replies pass their parent through - `parentCommentId`; comments nest at most one level, a deeper reply answers `422`. - parameters: - - name: contentId - in: query - description: The primary key of the content - required: true - schema: - type: integer - - name: parentCommentId - in: query - description: The primary key of the root comment this is a reply to - required: false - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CommentWrite" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Comment" - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" - "422": - $ref: "common.yaml#/components/responses/ValidationFailed" - "/comment/{id}/permissions": - get: - summary: What the caller may do with a comment - description: | - Deliberately not part of the comment shape: these are the only caller-dependent - values a comment needs, and only when its context menu is opened — keeping them out - is what makes the comment payload identical for every reader (and cacheable). The - same checks the update and delete endpoints enforce. - - Authenticated callers only: a guest has no permissions to report. - parameters: - - $ref: "#/components/parameters/commentPathParam" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - type: object - properties: - canEdit: - type: boolean - canDelete: - type: boolean - description: | - `true` on someone else's comment means moderation. - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" - "/comment/{id}": - get: - summary: A single comment - description: | - A root comment carries a preview of its newest replies under `replies`; a reply has - `replies: null`. Readable by guests under the same conditions as the windows. - parameters: - - $ref: "#/components/parameters/commentPathParam" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Comment" - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" - put: - summary: Update a comment - parameters: - - $ref: "#/components/parameters/commentPathParam" - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CommentWrite" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Comment" - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" - "422": - $ref: "common.yaml#/components/responses/ValidationFailed" - delete: - summary: Delete a comment - description: | - Deletes the comment. The optional body parameters trigger the moderation flow: with - `notify`, the author receives a notification carrying a preview of the removed text - and the given `message` as the reason. - parameters: - - $ref: "#/components/parameters/commentPathParam" - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - notify: - type: boolean - description: Notify the author about the removal - message: - type: string - description: Reason shown to the author - responses: - "204": - description: Deleted, no content - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" -components: - securitySchemes: - Bearer: - $ref: "common.yaml#/components/securitySchemes/Bearer" - BasicAuth: - $ref: "common.yaml#/components/securitySchemes/BasicAuth" - SessionCookie: - $ref: "common.yaml#/components/securitySchemes/SessionCookie" - parameters: - commentPathParam: - name: id - in: path - description: The primary key of the comment - required: true - schema: - type: integer - commentIdParam: - name: commentId - in: query - description: Cursor comment, or the anchor to focus the window around when no direction is given - required: false - schema: - type: integer - directionParam: - name: direction - in: query - description: Paging direction relative to `commentId` - required: false - schema: - type: string - enum: - - previous - - next - windowPageSizeParam: - name: pageSize - in: query - description: | - Comments per page while paging with `direction`. Clamped to the comment module's - configured block load size. - required: false - schema: - type: integer - windowLimitParam: - name: limit - in: query - description: | - Size of an initial (cursor-less) window. Clamped to the comment module's configured - block load size. - required: false - schema: - type: integer - schemas: - CommentWindow: - type: object - properties: - results: - type: array - description: The window's comments, oldest first - items: - $ref: "#/components/schemas/Comment" - total: - type: integer - description: All comments of the content, including replies - rootTotal: - type: integer - description: Root comments of the content only - prevCount: - type: integer - description: Comments of this level before the window - nextCount: - type: integer - description: Comments of this level after the window - Comment: - type: object - properties: - id: - type: integer - message: - type: string - description: The raw markdown message, not rendered HTML - messageRenderOptions: - type: object - description: | - Options a client needs to reproduce the platform's rich-text rendering of - `message` (mentions, oembed previews, …). `{}` when there is nothing to add. - contentId: - type: integer - parentCommentId: - type: integer - nullable: true - recordId: - type: integer - description: Platform-wide record id, e.g. for the like endpoints - createdBy: - $ref: "common.yaml#/components/schemas/UserShort" - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - nullable: true - description: | - Equal to `createdAt` for an unedited comment — a client derives "edited" from the - two. - url: - type: string - description: Absolute permalink - files: - type: array - items: - $ref: "common.yaml#/components/schemas/File" - childCount: - type: integer - description: Number of replies - replies: - type: object - nullable: true - description: | - Preview of the newest replies of a root comment; `null` on a reply. - properties: - total: - type: integer - hasMore: - type: boolean - items: - type: array - items: - $ref: "#/components/schemas/Comment" - extensions: - type: object - description: | - Namespaced data modules attached to this record (`{}` when nothing did), see the - core serialize event. - CommentWrite: - type: object - properties: - message: - type: string - description: Markdown message - fileList: - type: array - description: Guids of already uploaded files to attach - items: - type: string - required: - - message diff --git a/docs/swagger/v2/common.yaml b/docs/swagger/v2/common.yaml deleted file mode 100644 index bb812b42..00000000 --- a/docs/swagger/v2/common.yaml +++ /dev/null @@ -1,183 +0,0 @@ -openapi: 3.0.0 -info: - title: Common v2 API Components - description: | - Shared components of the v2 API — schemas every endpoint document embeds, the - paginated-list envelope, the record addressing, the error contract and the - authentication schemes. Not an endpoint document of its own. - version: 2.0.0 -paths: {} -components: - securitySchemes: - Bearer: - type: http - scheme: bearer - description: Access token or JWT, as issued by the REST API module. - BasicAuth: - type: http - scheme: basic - description: | - User credentials, restricted to the users the administrator enabled the API for. - SessionCookie: - type: apiKey - in: cookie - name: HUMHUB_SESSION - description: | - The regular HumHub browser session, accepted by core's own endpoints (which is how - the platform's Vue UI calls them). State-changing requests additionally require the - CSRF token, sent as the `X-CSRF-Token` header or the `_csrf` body parameter. - parameters: - pageParam: - name: page - in: query - description: Page number, starting at 1 - required: false - schema: - type: integer - pageSizeParam: - name: pageSize - in: query - description: Records per page (default 25, maximum 100) - required: false - schema: - type: integer - recordIdsParam: - name: recordIds - in: query - description: | - Comma-separated (or repeated `recordIds[]=`) list of platform-wide record ids, capped - at 100 per request. - required: false - schema: - type: string - recordIdParam: - name: recordId - in: query - description: | - Platform-wide record id — the addressing every shape that carries likes exposes as - `recordId`. Alternative to `model` + `pk`. - required: false - schema: - type: integer - modelParam: - name: model - in: query - description: Class name of the record, when addressing it by model and primary key - required: false - schema: - type: string - pkParam: - name: pk - in: query - description: Primary key of the record, when addressing it by model and primary key - required: false - schema: - type: integer - responses: - Unauthorized: - description: Authentication required - Forbidden: - description: The caller may not access this record - NotFound: - description: No such record - ValidationFailed: - description: Validation failed - content: - application/json: - schema: - type: object - properties: - errors: - type: object - description: Field name (camelCase) to list of messages - additionalProperties: - type: array - items: - type: string - example: - errors: - message: - - The comment must not be empty! - schemas: - ListEnvelope: - type: object - description: The envelope of every paginated list response - properties: - results: - type: array - items: - type: object - total: - type: integer - description: Total number of records - page: - type: integer - description: Current page, starting at 1 - pageSize: - type: integer - pages: - type: integer - description: Total number of pages - LikeState: - type: object - description: | - The caller-context like state of a record — returned by the like endpoints. NOT - embedded in other shapes: it is the one value that depends both on the record and on - who is asking, so keeping it separate is what lets those payloads be identical for - every reader (and therefore cacheable). Ask for a whole page of records at once with - `GET /like/states`. - properties: - total: - type: integer - description: Number of likes - liked: - type: boolean - description: Whether the authenticated caller has liked the record - canLike: - type: boolean - description: Whether the authenticated caller may like the record - UserShort: - type: object - description: The short user representation every other shape embeds - properties: - id: - type: integer - guid: - type: string - displayName: - type: string - url: - type: string - description: Absolute profile URL - imageUrl: - type: string - description: Absolute profile image URL - contentContainerId: - type: integer - nullable: true - online: - type: boolean - nullable: true - description: | - `null` when the platform does not expose online status to the caller. - File: - type: object - description: An attached file - properties: - id: - type: integer - guid: - type: string - mimeType: - type: string - size: - type: integer - fileName: - type: string - url: - type: string - description: Absolute download URL - previewUrl: - type: string - nullable: true - description: Absolute preview image URL, when the file has one diff --git a/docs/swagger/v2/like.yaml b/docs/swagger/v2/like.yaml deleted file mode 100644 index 1d7fda73..00000000 --- a/docs/swagger/v2/like.yaml +++ /dev/null @@ -1,170 +0,0 @@ -openapi: 3.0.0 -info: - description: | - Welcome to the HumHub like API reference, v2. - - These endpoints are shipped by **HumHub core** (1.19+), not by the REST API module — the - module only contributes its authentication methods to them and documents them here until - core ships its own API documentation. See the core docs, - `docs/develop/concept-api.md`. - - Conventions of this API generation, differing from `/api/v1`: camelCase field names, - ISO-8601 timestamps with offset, plain HTTP status codes instead of a - `{code, message}` envelope, and `{results, total, page, pageSize, pages}` for paginated - lists. - - Every likeable record is addressed either by its platform-wide `recordId` (what shapes - carrying likes expose) or by `model` + `pk`. - - Requests may authenticate with any token method the REST API module offers. Core's own - browser session is accepted too for these endpoints, which is what the platform's Vue UI - uses; state-changing session requests additionally require the CSRF token. - version: 2.0.0 - title: HumHub - Like API (v2) - contact: - email: info@humhub.com - license: - name: AGPLv2 - url: https://www.humhub.org/en/licences -servers: - - url: /api/v2 -security: - - Bearer: [] - - BasicAuth: [] - - SessionCookie: [] -paths: - "/like/state": - get: - summary: Like state of a record - description: | - The caller-context like state. Readable by guests for content they can see, with - `liked` and `canLike` always `false`. - parameters: - - $ref: "common.yaml#/components/parameters/recordIdParam" - - $ref: "common.yaml#/components/parameters/modelParam" - - $ref: "common.yaml#/components/parameters/pkParam" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "common.yaml#/components/schemas/LikeState" - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" - "/like/states": - get: - summary: Like states of many records - description: | - The caller's like state for up to 100 records in one request, keyed by record id — - what a client asks for after receiving a page of records whose payloads deliberately - carry no like state (see the `LikeState` schema). - - Records the caller may not see, and ids that resolve to nothing, are **absent from - the map** rather than failing the request. Readable by guests, with `liked` and - `canLike` always `false`. - parameters: - - $ref: "common.yaml#/components/parameters/recordIdsParam" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - type: object - properties: - results: - type: object - description: Record id to like state - additionalProperties: - $ref: "common.yaml#/components/schemas/LikeState" - example: - results: - "14": - total: 2 - liked: true - canLike: true - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "/like": - post: - summary: Like a record - parameters: - - $ref: "common.yaml#/components/parameters/recordIdParam" - - $ref: "common.yaml#/components/parameters/modelParam" - - $ref: "common.yaml#/components/parameters/pkParam" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "common.yaml#/components/schemas/LikeState" - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" - delete: - summary: Remove the caller's like - description: Idempotent — unliking something that was never liked is a success. - parameters: - - $ref: "common.yaml#/components/parameters/recordIdParam" - - $ref: "common.yaml#/components/parameters/modelParam" - - $ref: "common.yaml#/components/parameters/pkParam" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - $ref: "common.yaml#/components/schemas/LikeState" - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" - "/like/users": - get: - summary: Users who liked a record - description: Newest first. Readable by guests for content they can see. - parameters: - - $ref: "common.yaml#/components/parameters/recordIdParam" - - $ref: "common.yaml#/components/parameters/modelParam" - - $ref: "common.yaml#/components/parameters/pkParam" - - $ref: "common.yaml#/components/parameters/pageParam" - - $ref: "common.yaml#/components/parameters/pageSizeParam" - responses: - "200": - description: Successful operation - content: - application/json: - schema: - allOf: - - $ref: "common.yaml#/components/schemas/ListEnvelope" - - type: object - properties: - results: - type: array - items: - $ref: "common.yaml#/components/schemas/UserShort" - "401": - $ref: "common.yaml#/components/responses/Unauthorized" - "403": - $ref: "common.yaml#/components/responses/Forbidden" - "404": - $ref: "common.yaml#/components/responses/NotFound" -components: - securitySchemes: - Bearer: - $ref: "common.yaml#/components/securitySchemes/Bearer" - BasicAuth: - $ref: "common.yaml#/components/securitySchemes/BasicAuth" - SessionCookie: - $ref: "common.yaml#/components/securitySchemes/SessionCookie" diff --git a/module.json b/module.json index 47a8da78..c5f448d3 100644 --- a/module.json +++ b/module.json @@ -9,7 +9,7 @@ "version": "0.13.0", "homepage": "https://github.com/humhub/rest", "humhub": { - "minVersion": "1.19" + "minVersion": "1.20" }, "screenshots": [] } \ No newline at end of file