diff --git a/README.md b/README.md index ff0e186..d02e259 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ WinterCMS theme components such as `journalPost` and `journalPosts` are a later - **Posts.** `models.Post` maps `golem15_journal_posts`. Translatable fields are title, content, content_html, excerpt, metadata, and slug (indexed). Fillable does not include `redactor_id`, `content_html`, or `user_id`. Attachments `featured_images` and `content_images` use `system_files` under the same MorphName. YAML admin uses `mltext`, `mltextarea` and the plugin's own `mlblogeditor` field type for content (see [Admin: Blog Editor field types](#admin-blog-editor-field-types)); an admin save stores the sanitized HTML in both `content` and `content_html`. `classes.FormatHTML` regenerates `content_html` on the other write paths (`journal:import-posts`, Journal API writes). It renders markdown and passes raw HTML in `content` through the renderer (Winter parity), then sanitizes the result with a `bluemonday` allow-list: bluemonday's UGC policy plus `footer`, `class`, schema.org microdata, the `color`, `font-size`, `font-weight` and `text-align` styles, goldmark's footnote roles, `dir="auto"`, two pasted-transcript `data-message-*` attributes, and exactly four rules for the Blog Editor's own output (Phase 16.2): `data-embed` matching `^youtube$` and `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` (the embed placeholder), `alt` with any text on `img`, and `start` matching `^[0-9]+$` on `ol`. No iframe, other data attribute, style property or URL scheme is allowed for the editor; `TestEditorOutputPassesPolicy` checks every entry of the editor corpus against the policy. Like Winter's `Html::clean`, it strips what the policy does not allow instead of refusing the save: script, style, iframe, object, embed, form, base and meta elements, comments, event handler attributes, and URLs other than http, https, mailto and relative ones. HTML the policy keeps whole is stored byte for byte, so imported bodies survive a save. Admin saves strip the same way and name what they removed (`classes.SanitizeHTMLReport`). A tag that repeats an attribute is never kept whole: the sanitized output, which keeps the first value, is stored. On the API and import paths `content` itself is stored as submitted. Translated `content_html` goes through the same policy on the API and `journal:import-posts` write paths (`classes.FormatTranslation`): when the translation carries a non-blank `content`, `content_html` is rendered from it and a supplied `content_html` is replaced, as the host column is always derived; a translation with only `content_html` is sanitized with `classes.SanitizeHTML`. Winter stored a supplied translated `content_html` verbatim. The API response shapes and status codes are unchanged. - **Categories and tags.** `models.Category` is translatable (name, description, slug). It also stores `content`, an HTML host column that is not translatable, edited in the admin with the `blogeditor` field type. Fillable is name, slug, code, description, content and parent_id; nest_* columns exist in DDL and are not fillable. `models.Tag` is not translatable. Fillable is name, slug, description. Pivots are `golem15_journal_posts_categories` and `golem15_journal_posts_tags`. - **Settings.** Singleton row ID=1 on `golem15_journal_settings`, not PHP `system_settings`. `search_use_typesense` defaults to false. A fresh save never contacts Typesense. -- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`). +- **Public HTTP.** Anonymous GET `/_journal/api/v1/posts`, `/posts/{slug}`, `/categories`, `/tags`, `/rss`, `/authors/{author}`, and `/authors/{author}/posts`. Writes under the same prefix require a cabana backend JWT (`aud=backend`) or a personal admin API token (`g15_…`) whose scope covers the Journal. - **Author slug.** Plugin migration adds `backend_users.golem15_bloghub_author_slug`. The column stores the public author slug that the author routes resolve. - **Import and export.** `journal:export-posts` and `journal:import-posts` write and read one JSON file per post. The Posts list toolbar exposes the same actions to administrators with `golem15.journal.access_import_export`. - **Phrasebooks.** Embedded `lang/en/lang.yaml` and `lang/pl/lang.yaml` hold plugin, journal, post, category, and tag UI strings. They are independent of model translation JSON. @@ -129,16 +129,39 @@ The response uses the `/posts` pagination envelope (rows trimmed here): An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route. -Editors create a post with a cabana backend JWT (audience `backend`): +Editors create a post with a cabana backend JWT (audience `backend`) or a personal admin API token: ```http POST /_journal/api/v1/posts -Authorization: Bearer +Authorization: Bearer Content-Type: application/json {"title":"Hello","content":"A blog post"} ``` +### Personal API tokens + +The plugin registers two scopes through `journal.Plugin.TokenScopes` (`pact.HasTokenScopes`): + +| Scope | Methods | Cover | +|-------|---------|-------| +| `journal` | GET, HEAD, POST, PUT, PATCH, DELETE | `golem15.journal.*` | +| `journal:read` | GET, HEAD | `golem15.journal.*` | + +Mint a token from My account (or `admin:token:create`) with scope `journal`, then call the Journal API as that administrator: + +```http +POST /_journal/api/v1/posts +Authorization: Bearer g15_<40-hex> +Content-Type: application/json + +{"title":"Agent draft","content":"Written with a journal token"} +``` + +Optional-auth reads (`GET /posts` and `GET /posts/{slug}`) unlock drafts the token owner may see when the Bearer token is a valid `journal` or `journal:read` token. A missing, invalid, expired, revoked or out-of-cover token on those GETs is treated as anonymous (no 401). Write routes stay Bearer-only: missing or bad credentials answer `401 {"error":"Authentication required"}`; a `journal:read` token on a write, or a token whose scopes do not cover `golem15.journal.*`, answers `403 {"error":"Insufficient permissions"}`. + +This reverses Phase 15 D-15 (JWT-only Journal writes): principals resolve through `cabana.ResolveBackend` with cover `golem15.journal.*`, so the admin cookie never authenticates a Journal request. Featured-image upload and delete still authorize only through `Post.CanEdit` (PHP parity — no separate `access_posts` check on those routes). Because the resolver uses the admin guard's compiled registry, the Journal API now honors code-declared role grants (`pact.Permission.Roles`, for example the developer role on `golem15.journal.access_posts`); previously a nil registry meant only row grants applied. The `journal-api` throttle bucket still keys by client IP (it runs before the handler resolves the principal). + A blog post model already declares translatable fields: ```go @@ -239,6 +262,7 @@ The request is a `POST` to the URL with `Authorization: Bearer `, `Conten | `journal.Plugin.Models` | Models registered by this plugin. | | `journal.Plugin.Commands` | Registers `journal:export-posts` and `journal:import-posts`. | | `journal.Plugin.Routes` | Public GETs (posts, categories, tags, RSS, authors) and backend-Bearer writes under `/_journal/api/v1`. | +| `journal.Plugin.TokenScopes` | Personal API token scopes `journal` and `journal:read` (`pact.HasTokenScopes`). | | `journal.Plugin.FormFieldTypes` | Registers the `blogeditor` and `mlblogeditor` admin field types. | | `journal.Plugin.Buckets` | Limiter buckets `journal-public-api` and `journal-api` (Max 120). | | `models.Post` | Post row; `TableName` is `golem15_journal_posts`. | @@ -308,6 +332,8 @@ go test ./... Postgres integration smokes start Docker unless `-short`. Missing Docker is a failure, not a skip, unless `-short`. +Personal API token coverage: `TestResolveErrorMapping` (Docker-free, `controllers/api`) maps resolver errors to the PHP bodies; `TestJournalTokenTracer` and `TestJournalTokenExpansion` (root package, Postgres) mint tokens through the admin account API and exercise write, read-only, unrelated, revoked, featured-image and code-declared-role paths. + The site cache purge has unit tests in `classes` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`) and Postgres tests through the admin API (`TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache`) that point the plugin at a local receiver and check one authorized request after each commit. The Blog Editor has its own suite and drift gate: diff --git a/journal_api_token_smoke_test.go b/journal_api_token_smoke_test.go index f785da5..ca2c632 100644 --- a/journal_api_token_smoke_test.go +++ b/journal_api_token_smoke_test.go @@ -2,15 +2,120 @@ package journal_test import ( "encoding/json" + "fmt" "net/http" + "net/http/httptest" + "strconv" "strings" "testing" + "time" "git.golem15.com/golem15/sm-journal-plugin/controllers" "git.golem15.com/golem15/sm-journal-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" ) +// probeTokenPlugin registers a non-Journal scope so expansion tests can mint a +// token that authenticates but fails the golem15.journal.* cover check. +type probeTokenPlugin struct{} + +func init() { party.Register(&probeTokenPlugin{}) } + +func (p *probeTokenPlugin) ID() string { return "acme.probe" } +func (p *probeTokenPlugin) Requires() []string { return nil } +func (p *probeTokenPlugin) Register(app *backpack.App) error { return nil } +func (p *probeTokenPlugin) Boot(app *backpack.App) error { return nil } +func (p *probeTokenPlugin) Permissions() []pact.Permission { + return []pact.Permission{{ + Code: "acme.probe.access", + Label: "Probe access", + Roles: []string{"developer"}, + }} +} +func (p *probeTokenPlugin) TokenScopes() []pact.TokenScope { + return []pact.TokenScope{{ + Name: "probe", + Label: "Probe", + Description: "Test scope that does not cover Journal", + Permissions: []string{"acme.probe.*"}, + Methods: []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}, + }} +} + +var ( + _ pact.HasTokenScopes = (*probeTokenPlugin)(nil) + _ pact.HasPermissions = (*probeTokenPlugin)(nil) +) + +func (e *adminEnv) mintToken(t *testing.T, jwt string, scopes []string) (plain string, id uint) { + t.Helper() + create := e.call(t, http.MethodPost, adminAPI("/account/tokens"), jwt, map[string]any{ + "name": "smoke-" + strings.Join(scopes, "-"), + "expires": "90d", + "scopes": scopes, + }) + if create.Code != http.StatusCreated { + t.Fatalf("create token scopes=%v: %d %s", scopes, create.Code, create.Body.String()) + } + var created struct { + Data cabana.AdminAPITokenCreated `json:"data"` + } + if err := json.Unmarshal(create.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(created.Data.Token, cabana.APITokenPrefix) { + t.Fatalf("token shape %q", created.Data.Token) + } + return created.Data.Token, created.Data.ID +} + +func (e *adminEnv) developerJWT(t *testing.T) string { + t.Helper() + stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1)) + login := "dev-" + stamp + password := "dev-secret-" + stamp + hash, err := bouncer.HashPassword(10, password) + if err != nil { + t.Fatal(err) + } + var roleID uint + if err := e.gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 { + t.Fatalf("developer role: id=%d err=%v", roleID, err) + } + if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`, + login, login+"@example.test", hash, roleID).Error; err != nil { + t.Fatal(err) + } + rec := e.call(t, http.MethodPost, adminAPI("/auth/login"), "", map[string]string{"login": login, "password": password}) + if rec.Code != http.StatusOK { + t.Fatalf("developer login: %d %s", rec.Code, rec.Body.String()) + } + var body struct { + Data struct { + AccessToken string `json:"access_token"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.AccessToken == "" { + t.Fatalf("developer login body: %s err=%v", rec.Body.String(), err) + } + return body.Data.AccessToken +} + +func assertPHPError(t *testing.T, rec *httptest.ResponseRecorder, status int, want string) { + t.Helper() + if rec.Code != status { + t.Fatalf("status = %d, want %d: %s", rec.Code, status, rec.Body.String()) + } + got, _ := phpErrorString(t, rec.Body.Bytes()) + if got != want { + t.Fatalf("error = %q, want %q; body=%s", got, want, rec.Body.String()) + } +} + // TestJournalTokenTracer mints a journal-scoped personal API token through the // admin account API and creates a Journal post as that token's owner (16.3-03). func TestJournalTokenTracer(t *testing.T) { @@ -66,3 +171,112 @@ func TestJournalTokenTracer(t *testing.T) { t.Fatalf("stored user_id = %v, want %d", row.UserID, adminID) } } + +// TestJournalTokenExpansion pins D-15 read/unrelated/revoked scopes, optional +// draft unlock, featured-image canEdit parity, and code-declared developer roles. +func TestJournalTokenExpansion(t *testing.T) { + env := bootJournal(t, []string{"golem15.translate", "golem15.journal", "acme.probe"}) + editorJWT := env.admin(t, controllers.PermissionAccessPosts, "acme.probe.access") + editorID := env.lastUserID(t) + otherJWT := env.admin(t, controllers.PermissionAccessPosts) + otherID := env.lastUserID(t) + + readTok, _ := env.mintToken(t, editorJWT, []string{"journal:read"}) + journalTok, journalID := env.mintToken(t, editorJWT, []string{"journal"}) + adminReadTok, _ := env.mintToken(t, editorJWT, []string{"admin:read"}) + probeTok, _ := env.mintToken(t, editorJWT, []string{"probe"}) + + assertPHPError(t, + env.call(t, http.MethodPost, "/_journal/api/v1/posts", readTok, map[string]any{ + "title": "Read scope write", "content": "no", + }), + http.StatusForbidden, "Insufficient permissions", + ) + + draft := models.Post{ + Title: "Editor draft", Slug: "editor-token-draft", Content: "secret", + Published: false, UserID: &editorID, + } + if err := env.gdb.Create(&draft).Error; err != nil { + t.Fatal(err) + } + ownerDraft := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", readTok, nil) + if ownerDraft.Code != http.StatusOK { + t.Fatalf("journal:read draft show = %d, want 200: %s", ownerDraft.Code, ownerDraft.Body.String()) + } + anonDraft := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", "", nil) + if anonDraft.Code != http.StatusNotFound { + t.Fatalf("anonymous draft show = %d, want 404: %s", anonDraft.Code, anonDraft.Body.String()) + } + + assertPHPError(t, + env.call(t, http.MethodPost, "/_journal/api/v1/posts", adminReadTok, map[string]any{ + "title": "Unrelated", "content": "no", + }), + http.StatusForbidden, "Insufficient permissions", + ) + assertPHPError(t, + env.call(t, http.MethodPost, "/_journal/api/v1/posts", probeTok, map[string]any{ + "title": "Probe write", "content": "no", + }), + http.StatusForbidden, "Insufficient permissions", + ) + unrelatedGet := env.call(t, http.MethodGet, "/_journal/api/v1/posts/editor-token-draft", probeTok, nil) + if unrelatedGet.Code != http.StatusNotFound { + t.Fatalf("probe draft show = %d, want anonymous 404: %s", unrelatedGet.Code, unrelatedGet.Body.String()) + } + + ownPath := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", draft.ID) + up := env.callMultipart(t, http.MethodPost, ownPath, journalTok, nil, "file", "dot.png", png1x1) + if up.Code != http.StatusCreated { + t.Fatalf("featured upload own post = %d: %s", up.Code, up.Body.String()) + } + var upBody struct { + Data struct { + ID uint `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(up.Body.Bytes(), &upBody); err != nil || upBody.Data.ID == 0 { + t.Fatalf("featured upload body: %s err=%v", up.Body.String(), err) + } + delOwn := env.call(t, http.MethodDelete, + fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images/%d", draft.ID, upBody.Data.ID), + journalTok, nil) + if delOwn.Code != http.StatusOK { + t.Fatalf("featured delete own = %d: %s", delOwn.Code, delOwn.Body.String()) + } + + otherPost := models.Post{ + Title: "Other author", Slug: "other-author-post", Content: "x", + Published: false, UserID: &otherID, + } + if err := env.gdb.Create(&otherPost).Error; err != nil { + t.Fatal(err) + } + otherPath := fmt.Sprintf("/_journal/api/v1/posts/%d/featured-images", otherPost.ID) + assertPHPError(t, + env.callMultipart(t, http.MethodPost, otherPath, journalTok, nil, "file", "dot.png", png1x1), + http.StatusForbidden, "You do not have permission to edit this post", + ) + _ = otherJWT + + revPath := adminAPI("/account/tokens/" + strconv.FormatUint(uint64(journalID), 10)) + rev := env.call(t, http.MethodDelete, revPath, editorJWT, nil) + if rev.Code != http.StatusOK { + t.Fatalf("revoke: %d %s", rev.Code, rev.Body.String()) + } + assertPHPError(t, + env.call(t, http.MethodPost, "/_journal/api/v1/posts", journalTok, map[string]any{ + "title": "After revoke", "content": "no", + }), + http.StatusUnauthorized, "Authentication required", + ) + + devJWT := env.developerJWT(t) + created := env.call(t, http.MethodPost, "/_journal/api/v1/posts", devJWT, map[string]any{ + "title": "Developer role grant", "content": "code-declared Roles", + }) + if created.Code != http.StatusCreated { + t.Fatalf("developer JWT create = %d: %s", created.Code, created.Body.String()) + } +}