From f2e5b3d072d21a7865d30cd504330bcc5164e0a1 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 18:54:41 +0200 Subject: [PATCH] feat(15-03): serve anonymous GET /_journal/api/v1/posts as a published list - Public and write groups under /_journal/api/v1 with journal-public-api and journal-api buckets (Max 120) - Index omits drafts and future published_at; per_page default 9 max 30 - Optional backend JWT on GET; missing Bearer on writes returns PHP {error:Authentication required} Co-authored-by: Cursor --- controllers/api/auth.go | 120 +++++++++ controllers/api/posts.go | 394 ++++++++++++++++++++++++++++++ journal_public_list_smoke_test.go | 71 ++++++ plugin.go | 47 +++- plugin_test.go | 21 ++ routes.go | 31 +++ 6 files changed, 679 insertions(+), 5 deletions(-) create mode 100644 controllers/api/auth.go create mode 100644 controllers/api/posts.go create mode 100644 journal_public_list_smoke_test.go create mode 100644 routes.go diff --git a/controllers/api/auth.go b/controllers/api/auth.go new file mode 100644 index 0000000..f6d4a03 --- /dev/null +++ b/controllers/api/auth.go @@ -0,0 +1,120 @@ +package api + +import ( + "database/sql" + "errors" + "net/http" + "strings" + + "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/wire" + "gorm.io/gorm" +) + +const ( + errAuthenticationRequired = "Authentication required" + errInsufficientPerms = "Insufficient permissions" + errPublishPermission = "You do not have permission to publish posts" + errEditPermission = "You do not have permission to edit this post" + errPostNotFound = "Post not found" + errValidationFailed = "Validation failed" + + permAccessPosts = "golem15.journal.access_posts" + permAccessOtherPosts = "golem15.journal.access_other_posts" + permAccessPublish = "golem15.journal.access_publish" + + backendJWTBlacklistTable = "backend_jwt_blacklist" +) + +func writeAPIError(w http.ResponseWriter, status int, message string) { + wire.WriteJSON(w, status, map[string]any{"error": message}) +} + +func writeAuthRequired(w http.ResponseWriter, _ error) { + writeAPIError(w, http.StatusUnauthorized, errAuthenticationRequired) +} + +func hasBearer(r *http.Request) bool { + if r == nil { + return false + } + h := strings.TrimSpace(r.Header.Get("Authorization")) + token, ok := strings.CutPrefix(h, "Bearer ") + return ok && strings.TrimSpace(token) != "" +} + +func newBackendGuard(app *backpack.App, write func(http.ResponseWriter, error)) bouncer.Guard { + secret := "" + if app != nil && app.Config != nil { + secret = strings.TrimSpace(app.Config.String("admin.jwt.secret")) + } + var gdb *gorm.DB + var sqlDB *sql.DB + var reg *cabana.Registry + if app != nil { + gdb, _ = app.Lookup[*gorm.DB]() + sqlDB, _ = app.Lookup[*sql.DB]() + reg, _ = app.Lookup[*cabana.Registry]() + if sqlDB == nil && gdb != nil { + if db, err := gdb.DB(); err == nil { + sqlDB = db + } + } + } + var bl bouncer.BlacklistStore + if sqlDB != nil { + bl = bouncer.NewPostgresBlacklist(sqlDB, backendJWTBlacklistTable) + } + return bouncer.NewBackendJWTGuard(secret, cabana.BackendUsers{DB: gdb, Registry: reg}, bl, write) +} + +// optionalBackendPrincipal verifies a backend JWT only when Authorization +// Bearer is present. Failure is anonymous: public GET must not 401. +func optionalBackendPrincipal(app *backpack.App, r *http.Request) *http.Request { + if r == nil || !hasBearer(r) { + return r + } + pr, err := newBackendGuard(app, nil).Authenticate(r) + if err != nil || pr == nil { + return r + } + return r.WithContext(bouncer.WithUser(r.Context(), pr)) +} + +// requireBackendPrincipal authenticates a backend-audience JWT and writes the +// PHP {error:"Authentication required"} body when the token is missing or +// invalid (D-15). It does not use cabana's admin unauthenticated envelope. +func requireBackendPrincipal(app *backpack.App, w http.ResponseWriter, r *http.Request) (*http.Request, *bouncer.Principal, bool) { + pr, err := newBackendGuard(app, writeAuthRequired).Authenticate(r) + if err != nil || pr == nil { + writeAuthRequired(w, err) + return r, nil, false + } + r = r.WithContext(bouncer.WithUser(r.Context(), pr)) + return r, pr, true +} + +func isEditor(pr *bouncer.Principal) bool { + return cabana.Allows(pr, []string{permAccessPosts}) +} + +func requireAccessPosts(w http.ResponseWriter, pr *bouncer.Principal) bool { + if !cabana.Allows(pr, []string{permAccessPosts}) { + writeAPIError(w, http.StatusForbidden, errInsufficientPerms) + return false + } + return true +} + +func gdb(app *backpack.App) (*gorm.DB, error) { + if app == nil { + return nil, errors.New("journal api: application is not configured") + } + db, ok := app.Lookup[*gorm.DB]() + if !ok || db == nil { + return nil, errors.New("journal api: database is not configured") + } + return db, nil +} diff --git a/controllers/api/posts.go b/controllers/api/posts.go new file mode 100644 index 0000000..439491c --- /dev/null +++ b/controllers/api/posts.go @@ -0,0 +1,394 @@ +package api + +import ( + "encoding/json" + "html" + "net/http" + "strconv" + "strings" + "time" + "unicode" + + "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/lagoon" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +const ( + defaultPerPage = 9 + maxPerPage = 30 +) + +// Index is GET /_journal/api/v1/posts. Anonymous callers see published posts +// only; an editor with access_posts may include drafts unless ?published=true. +func Index(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r = optionalBackendPrincipal(app, r) + db, err := gdb(app) + if err != nil { + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + pr, _ := bouncer.User(r.Context()) + editor := isEditor(pr) + + perPage := clampPerPage(r.URL.Query().Get("per_page")) + page := max(1, atoiDefault(r.URL.Query().Get("page"), 1)) + + q := db.WithContext(r.Context()).Model(&models.Post{}). + Preload("User").Preload("Categories").Preload("Tags") + + publishedOnly := true + if editor { + if _, ok := r.URL.Query()["published"]; ok { + publishedOnly = queryBool(r.URL.Query().Get("published")) + } else { + publishedOnly = false + } + } + if publishedOnly { + q = applyPublished(q) + } + if editor { + if raw := strings.TrimSpace(r.URL.Query().Get("author")); raw != "" { + if id, err := strconv.ParseUint(raw, 10, 64); err == nil && id > 0 { + q = q.Where("user_id = ?", id) + } + } + } + if cat := strings.TrimSpace(r.URL.Query().Get("category")); cat != "" { + if ids := resolveCategoryIDs(db.WithContext(r.Context()), cat); len(ids) > 0 { + q = q.Where("id IN (SELECT post_id FROM golem15_journal_posts_categories WHERE category_id IN (?))", ids) + } else { + q = q.Where("1 = 0") + } + } + if tag := strings.TrimSpace(r.URL.Query().Get("tag")); tag != "" { + q = applyTagFilter(q, db.WithContext(r.Context()), tag) + } + search := strings.TrimSpace(r.URL.Query().Get("search")) + if len([]rune(search)) < 3 { + search = "" + } + if search != "" { + like := "%" + escapeLike(search) + "%" + q = q.Where( + "(title ILIKE ? ESCAPE '\\' OR slug ILIKE ? ESCAPE '\\' OR excerpt ILIKE ? ESCAPE '\\' OR content ILIKE ? ESCAPE '\\')", + like, like, like, like, + ) + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + + sort := strings.TrimSpace(r.URL.Query().Get("sort")) + order := listOrder(sort, queryBool(r.URL.Query().Get("pinned_first"))) + var posts []models.Post + if err := q.Order(order).Offset((page - 1) * perPage).Limit(perPage).Find(&posts).Error; err != nil { + writeAPIError(w, http.StatusInternalServerError, "Internal server error") + return + } + rows := make([]map[string]any, 0, len(posts)) + for i := range posts { + rows = append(rows, serializePost(&posts[i], db.WithContext(r.Context()))) + } + wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(rows, page, perPage, total)) + } +} + +// Show is GET /_journal/api/v1/posts/{slug}. Task 2 fills JOURNAL-005. +func Show(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + r = optionalBackendPrincipal(app, r) + writeAPIError(w, http.StatusNotFound, errPostNotFound) + } +} + +// Categories is GET /_journal/api/v1/categories. Task 2 fills the PHP list. +func Categories(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": []any{}}) + } +} + +// Tags is GET /_journal/api/v1/tags. Task 2 fills the PHP list. +func Tags(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": []any{}}) + } +} + +// RSS is GET /_journal/api/v1/rss. Task 3 fills the PHP RSS contract. +func RSS(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + } +} + +// Store is POST /_journal/api/v1/posts. Task 2 fills field-by-field create. +func Store(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, _, ok := requireBackendPrincipal(app, w, r); !ok { + return + } + writeAPIError(w, http.StatusForbidden, errInsufficientPerms) + } +} + +// Update is PUT /_journal/api/v1/posts/{id}. Task 2 fills field-by-field update. +func Update(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, _, ok := requireBackendPrincipal(app, w, r); !ok { + return + } + writeAPIError(w, http.StatusForbidden, errInsufficientPerms) + } +} + +// Destroy is DELETE /_journal/api/v1/posts/{id}. Task 2 fills canEdit delete. +func Destroy(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, _, ok := requireBackendPrincipal(app, w, r); !ok { + return + } + writeAPIError(w, http.StatusForbidden, errInsufficientPerms) + } +} + +// UploadFeaturedImage is POST /_journal/api/v1/posts/{id}/featured-images. +func UploadFeaturedImage(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, _, ok := requireBackendPrincipal(app, w, r); !ok { + return + } + writeAPIError(w, http.StatusForbidden, errEditPermission) + } +} + +// DeleteFeaturedImage is DELETE /_journal/api/v1/posts/{id}/featured-images/{fileId}. +func DeleteFeaturedImage(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, _, ok := requireBackendPrincipal(app, w, r); !ok { + return + } + writeAPIError(w, http.StatusForbidden, errEditPermission) + } +} + +func clampPerPage(raw string) int { + n := atoiDefault(raw, defaultPerPage) + if n < 1 { + n = 1 + } + if n > maxPerPage { + return maxPerPage + } + return n +} + +func atoiDefault(raw string, fallback int) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil { + return fallback + } + return n +} + +func queryBool(raw string) bool { + s := strings.ToLower(strings.TrimSpace(raw)) + return s == "1" || s == "true" || s == "on" || s == "yes" +} + +func applyPublished(q *gorm.DB) *gorm.DB { + return q.Where("published = ? AND published_at IS NOT NULL AND published_at < ?", true, time.Now()) +} + +func listOrder(sort string, pinnedFirst bool) string { + col, dir := "published_at", "DESC" + parts := strings.Fields(sort) + if len(parts) > 0 { + switch strings.ToLower(parts[0]) { + case "published_at", "created_at", "updated_at", "title", "id": + col = strings.ToLower(parts[0]) + } + } + if len(parts) > 1 && strings.EqualFold(parts[1], "asc") { + dir = "ASC" + } + order := col + " " + dir + ", id DESC" + if pinnedFirst { + return "is_pinned DESC, " + order + } + return order +} + +func escapeLike(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `%`, `\%`) + s = strings.ReplaceAll(s, `_`, `\_`) + return s +} + +func resolveCategoryIDs(db *gorm.DB, input string) []uint { + var cat models.Category + if id, err := strconv.ParseUint(input, 10, 64); err == nil && id > 0 { + if err := db.Where("id = ?", id).Take(&cat).Error; err != nil { + return nil + } + } else if err := db.Where("slug = ?", input).Take(&cat).Error; err != nil { + return nil + } + ids := []uint{cat.ID} + if cat.NestLeft == nil || cat.NestRight == nil { + return ids + } + var kids []models.Category + if err := db.Where("nest_left > ? AND nest_right < ?", *cat.NestLeft, *cat.NestRight).Find(&kids).Error; err != nil { + return ids + } + for _, kid := range kids { + ids = append(ids, kid.ID) + } + return ids +} + +func applyTagFilter(q, db *gorm.DB, input string) *gorm.DB { + var tag models.Tag + if id, err := strconv.ParseUint(input, 10, 64); err == nil && id > 0 { + if err := db.Where("id = ?", id).Take(&tag).Error; err != nil { + return q.Where("1 = 0") + } + } else if err := db.Where("slug = ?", input).Take(&tag).Error; err != nil { + return q.Where("1 = 0") + } + return q.Where("id IN (SELECT post_id FROM golem15_journal_posts_tags WHERE tag_id = ?)", tag.ID) +} + +func serializePost(p *models.Post, db *gorm.DB) map[string]any { + if p == nil { + return map[string]any{} + } + cats := make([]map[string]any, 0, len(p.Categories)) + for _, c := range p.Categories { + cats = append(cats, map[string]any{"id": c.ID, "name": c.Name, "slug": c.Slug}) + } + tags := make([]map[string]any, 0, len(p.Tags)) + for _, t := range p.Tags { + tags = append(tags, map[string]any{"id": t.ID, "name": t.Name, "slug": t.Slug}) + } + featured := featuredImages(db, p) + var featuredURL any + if len(featured) > 0 { + featuredURL = featured[0]["url"] + } + row := map[string]any{ + "id": p.ID, + "title": p.Title, + "slug": p.Slug, + "excerpt": p.Excerpt, + "content": p.Content, + "content_html": p.ContentHTML, + "reading_time": readingTime(p.ContentHTML), + "published": p.Published, + "published_at": carbonTime(p.PublishedAt), + "is_pinned": p.IsPinned, + "metadata": jsonField(p.Metadata), + "sources": jsonField(p.Sources), + "author": serializeAuthor(p.User), + "categories": cats, + "tags": tags, + "featured_images": featured, + "featured_image_url": featuredURL, + "translations": []any{}, + "created_at": carbonTime(p.CreatedAt), + "updated_at": carbonTime(p.UpdatedAt), + } + return row +} + +func serializeAuthor(user *cabana.BackendUser) any { + if user == nil { + return nil + } + return map[string]any{ + "id": user.ID, + "first_name": user.FirstName, + "last_name": user.LastName, + "login": user.Login, + } +} + +func featuredImages(_ *gorm.DB, _ *models.Post) []map[string]any { + return []map[string]any{} +} + +func jsonField(raw *string) any { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil + } + var v any + if err := json.Unmarshal([]byte(*raw), &v); err != nil { + return *raw + } + return v +} + +func carbonTime(t *time.Time) any { + if t == nil { + return nil + } + return wire.Time{Time: *t} +} + +func readingTime(htmlSrc string) int { + plain := stripTags(htmlSrc) + words := 0 + inWord := false + for _, r := range plain { + if unicode.IsSpace(r) { + inWord = false + continue + } + if !inWord { + words++ + inWord = true + } + } + if words == 0 { + return 0 + } + n := (words + 199) / 200 + if n < 1 { + return 1 + } + return n +} + +func stripTags(s string) string { + var b strings.Builder + inTag := false + for _, r := range s { + switch { + case r == '<': + inTag = true + case r == '>': + inTag = false + case !inTag: + b.WriteRune(r) + } + } + return html.UnescapeString(b.String()) +} diff --git a/journal_public_list_smoke_test.go b/journal_public_list_smoke_test.go new file mode 100644 index 0000000..e402ee3 --- /dev/null +++ b/journal_public_list_smoke_test.go @@ -0,0 +1,71 @@ +package journal_test + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "git.golem15.com/golem15/sm-journal-plugin/models" +) + +func TestJournalPublicList(t *testing.T) { + env := newPostsEnv(t) + now := time.Now().UTC() + past := now.Add(-time.Hour) + published := models.Post{ + Title: "Published", + Slug: "published-row", + Content: "Hello published", + Published: true, + PublishedAt: &past, + } + if err := env.gdb.Create(&published).Error; err != nil { + t.Fatal(err) + } + draft := models.Post{ + Title: "Draft", + Slug: "draft-row", + Content: "Hello draft", + Published: false, + } + if err := env.gdb.Create(&draft).Error; err != nil { + t.Fatal(err) + } + + rec := env.call(t, http.MethodGet, "/_journal/api/v1/posts", "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("GET /_journal/api/v1/posts = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var body struct { + Data []struct { + Slug string `json:"slug"` + Title string `json:"title"` + Published bool `json:"published"` + } `json:"data"` + Meta struct { + CurrentPage int `json:"current_page"` + LastPage int `json:"last_page"` + PerPage int `json:"per_page"` + Total int64 `json:"total"` + } `json:"meta"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("list body: %s err=%v", rec.Body.String(), err) + } + if body.Meta.PerPage != 9 { + t.Fatalf("meta.per_page = %d, want 9", body.Meta.PerPage) + } + if body.Meta.Total != 1 { + t.Fatalf("meta.total = %d, want 1 (draft omitted); body=%s", body.Meta.Total, rec.Body.String()) + } + if len(body.Data) != 1 { + t.Fatalf("data len = %d, want 1: %s", len(body.Data), rec.Body.String()) + } + if body.Data[0].Slug != "published-row" { + t.Fatalf("listed slug = %q, want published-row", body.Data[0].Slug) + } + if !body.Data[0].Published { + t.Fatal("listed row is not published") + } +} diff --git a/plugin.go b/plugin.go index 73cf1c9..cfc12be 100644 --- a/plugin.go +++ b/plugin.go @@ -3,23 +3,30 @@ package journal import ( "embed" "io/fs" + "net/http" + "strconv" + "time" "git.golem15.com/golem15/sm-journal-plugin/console" "git.golem15.com/golem15/sm-journal-plugin/models" "git.golem15.com/golem15/sm-journal-plugin/updates" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/surf" "github.com/go-gormigrate/gormigrate/v2" ) var ( - _ party.Plugin = (*Plugin)(nil) - _ pact.HasMigrations = (*Plugin)(nil) - _ pact.HasModels = (*Plugin)(nil) - _ pact.HasLang = (*Plugin)(nil) - _ pact.HasCommands = (*Plugin)(nil) + _ party.Plugin = (*Plugin)(nil) + _ pact.HasMigrations = (*Plugin)(nil) + _ pact.HasModels = (*Plugin)(nil) + _ pact.HasLang = (*Plugin)(nil) + _ pact.HasCommands = (*Plugin)(nil) + _ pact.HasRoutes = (*Plugin)(nil) + _ surf.BucketProvider = (*Plugin)(nil) ) //go:embed lang @@ -57,6 +64,36 @@ func (p *Plugin) Commands() []bonfire.Command { } } +// Buckets registers PHP limiter names journal-public-api and journal-api +// (D-17). 429 bodies stay surf {"message":"Too Many Attempts."}. +func (p *Plugin) Buckets() map[string]surf.Bucket { + trusted := surf.TrustedProxies(nil) + if p.app != nil { + trusted = surf.TrustedProxies(p.app.Config) + } + return map[string]surf.Bucket{ + "journal-public-api": { + Name: "journal-public-api", + Max: 120, + Decay: time.Minute, + Key: func(r *http.Request) string { + return "journal-public|" + surf.ClientIP(r, trusted) + }, + }, + "journal-api": { + Name: "journal-api", + Max: 120, + Decay: time.Minute, + Key: func(r *http.Request) string { + if pr, ok := bouncer.User(r.Context()); ok && pr != nil && pr.Backend { + return "journal-api|u:" + strconv.FormatUint(uint64(pr.ID), 10) + } + return "journal-api|" + surf.ClientIP(r, trusted) + }, + }, + } +} + func init() { party.Register(&Plugin{}) } diff --git a/plugin_test.go b/plugin_test.go index 626be34..ccfa9f7 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -3,6 +3,7 @@ package journal import ( "strings" "testing" + "time" "git.golem15.com/golem15/summercms/modules/pact" ) @@ -52,3 +53,23 @@ func TestJournalCommands(t *testing.T) { t.Fatalf("Posts AdminActions missing import/export with access_import_export (export=%v import=%v)", foundExport, foundImport) } } + +func TestJournalBuckets(t *testing.T) { + p := &Plugin{} + buckets := p.Buckets() + for _, name := range []string{"journal-public-api", "journal-api"} { + b, ok := buckets[name] + if !ok { + t.Fatalf("Buckets() missing %s", name) + } + if b.Max != 120 { + t.Fatalf("%s Max = %d, want 120", name, b.Max) + } + if b.Decay != time.Minute { + t.Fatalf("%s Decay = %s, want 1m", name, b.Decay) + } + if b.Key == nil { + t.Fatalf("%s Key is nil", name) + } + } +} diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..b600fbc --- /dev/null +++ b/routes.go @@ -0,0 +1,31 @@ +package journal + +import ( + apicontrollers "git.golem15.com/golem15/sm-journal-plugin/controllers/api" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/surf" +) + +// Routes mounts public GETs and backend-Bearer writes under /_journal/api/v1 +// (D-14). The public group must not attach cabana middleware name "backend" +// (anonymous callers would 401 — pitfall 4). +func (p *Plugin) Routes(r pact.Router) error { + r.Group("/_journal/api/v1", surf.Use("throttle:journal-public-api"), func(g pact.Router) { + g.Get("/posts", apicontrollers.Index(p.app)) + g.Get("/posts/{slug}", apicontrollers.Show(p.app)) + g.Where("slug", `[a-z0-9][a-z0-9\-/]*`) + g.Get("/categories", apicontrollers.Categories(p.app)) + g.Get("/tags", apicontrollers.Tags(p.app)) + g.Get("/rss", apicontrollers.RSS(p.app)) + }) + r.Group("/_journal/api/v1", surf.Use("throttle:journal-api"), func(g pact.Router) { + g.Post("/posts", apicontrollers.Store(p.app)) + g.Put("/posts/{id}", apicontrollers.Update(p.app)) + g.Delete("/posts/{id}", apicontrollers.Destroy(p.app)) + g.Post("/posts/{id}/featured-images", apicontrollers.UploadFeaturedImage(p.app)) + g.Delete("/posts/{id}/featured-images/{fileId}", apicontrollers.DeleteFeaturedImage(p.app)) + g.Where("id", `[0-9]+`) + g.Where("fileId", `[0-9]+`) + }) + return nil +}