From f7bfb3313b718e31cc754c87e4e0f9b0dcf95c0a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 31 Oct 2026 13:11:44 +0100 Subject: [PATCH] feat: slug on the post author object, for author links that do not expose the login --- README.md | 4 ++-- controllers/api/posts.go | 16 ++++++++++++++-- journal_api_authors_test.go | 12 ++++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4b5a5fd..131b478 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ The admin Categories form edits content with the `blogeditor` field type: a save 1. `backend_users.golem15_bloghub_author_slug`, the public author slug. A user with a slug resolves even before their first post. 2. `backend_users.login`, but only for a user with at least one published post. That login is already public through the post `author` object, so other backend accounts cannot be enumerated. -Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login and no slug. +Soft-deleted backend users never resolve. Unlike the WinterCMS author component, a login also resolves for a user who has a slug, because the post `author` object carries the login. Since v0.3.1 that object also carries `slug` (the same canonical identifier as below), and consumers link by it. ```json {"data":{"id":7,"slug":"jane-doe","first_name":"Jane","last_name":"Doe","subtitle":"Editor","description":"Writes about releases.","avatar":"https://example.com/storage/app/uploads/public/012/345/678/0123456789abcdef01234567.webp"}} @@ -128,7 +128,7 @@ An unknown author, a login without published posts, a soft-deleted user and a ma The response uses the `/posts` pagination envelope (rows trimmed here): ```json -{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}} +{"data":[{"id":12,"slug":"hello-world","title":"Hello world","author":{"id":7,"first_name":"Jane","last_name":"Doe","login":"jdoe","slug":"jane-doe"}}],"meta":{"current_page":2,"last_page":2,"per_page":9,"total":10}} ``` An author that does not resolve answers the same `404 {"error":"Author not found"}` as the profile route. diff --git a/controllers/api/posts.go b/controllers/api/posts.go index d72b0fa..f489e28 100644 --- a/controllers/api/posts.go +++ b/controllers/api/posts.go @@ -620,7 +620,7 @@ func serializePost(p *models.Post, db *gorm.DB) map[string]any { "is_pinned": p.IsPinned, "metadata": jsonField(p.Metadata), "sources": jsonField(p.Sources), - "author": serializeAuthor(p.User), + "author": serializeAuthor(db, p.User), "categories": cats, "tags": tags, "featured_images": featured, @@ -683,15 +683,27 @@ func firstImageURL(featured []map[string]any) any { return featured[0]["url"] } -func serializeAuthor(user *cabana.BackendUser) any { +// serializeAuthor is a post's author object. slug is the author's canonical +// identifier, as on GET /authors/{author}: the author slug when set, otherwise +// the login, so a consumer links to the author page by slug. +func serializeAuthor(db *gorm.DB, user *cabana.BackendUser) any { if user == nil { return nil } + slug := user.Login + if db != nil { + var authorSlug string + if err := db.Table("backend_users").Select("COALESCE(golem15_bloghub_author_slug, '')"). + Where("id = ?", user.ID).Scan(&authorSlug).Error; err == nil && authorSlug != "" { + slug = authorSlug + } + } return map[string]any{ "id": user.ID, "first_name": user.FirstName, "last_name": user.LastName, "login": user.Login, + "slug": slug, } } diff --git a/journal_api_authors_test.go b/journal_api_authors_test.go index 046ef6e..7caf27d 100644 --- a/journal_api_authors_test.go +++ b/journal_api_authors_test.go @@ -293,8 +293,16 @@ func TestJournalAuthorPosts(t *testing.T) { t.Fatalf("author row keys = %v, want /posts row keys %v", got, want) } author, _ := page.Data[0]["author"].(map[string]any) - if author == nil || author["login"] != "jdoe" { - t.Fatalf("row author = %v, want login jdoe", page.Data[0]["author"]) + if author == nil || author["login"] != "jdoe" || author["slug"] != "jane-doe" { + t.Fatalf("row author = %v, want login jdoe and slug jane-doe", page.Data[0]["author"]) + } + // Without an author slug, the row's slug is the login. + for _, row := range all.Data { + if row["slug"] == "b1" { + if a, _ := row["author"].(map[string]any); a == nil || a["slug"] != "bob" { + t.Fatalf("b1 author = %v, want slug bob", row["author"]) + } + } } page = getAuthorPosts(t, env, base+"?per_page=2&page=2", "")