Files
Jakub Zych 85dc7c7464 docs(10): research core plugins domain
Phase 10: Core Plugins
- User plugin patterns from Golem15.User reference
- Blog plugin patterns from Golem15.Blog reference
- TinyMCE for WYSIWYG editing
- Frontend authentication with cookie/JWT hybrid
- Nested set model for categories
- Component patterns for posts/account/session
2026-02-05 16:00:33 +01:00

1216 lines
46 KiB
Markdown

# Phase 10: Core Plugins - Research
**Researched:** 2026-02-05
**Domain:** User plugin (frontend authentication, registration, profiles) and Blog plugin (posts, categories, tags, WYSIWYG, listing components)
**Confidence:** HIGH
## Summary
This phase delivers the User and Blog plugins that demonstrate the complete SummerCMS plugin system. The User plugin provides frontend user registration, login/logout, profile management, and password reset. The Blog plugin provides blog post management with WYSIWYG editor, hierarchical categories, tags, and frontend listing components.
The research builds on established patterns from Phase 2 (Plugin System with ZIO lifecycle), Phase 3 (Component System with HTMX handlers), Phase 6 (Backend Authentication with Argon2id), and Phase 9 (Content Management). The WinterCMS reference implementation (Golem15.User and Golem15.Blog plugins) provides detailed patterns for models, components, and settings that must be adapted to ZIO/Scala.
Key technology decisions: **TinyMCE 7.x** for WYSIWYG editing (mature, widely integrated, MIT license for self-hosted), **jwt-scala 11.0.3** for frontend user session tokens (already in stack from Phase 6), **Password4j** for password hashing (already in stack), and **Session component pattern** for access control on frontend pages.
**Primary recommendation:** Implement User plugin first (registration, login, session, account, reset-password components), then Blog plugin (post model with WYSIWYG, categories with nested tree, tags, posts/post components). Use cookie-based sessions for frontend users (separate from admin JWT) with CSRF protection. Follow WinterCMS component property patterns adapted to YAML + ZIO.
## Standard Stack
The established libraries/tools for this domain:
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| jwt-scala | 11.0.3 | Frontend user JWT tokens | Already in stack from Phase 6 |
| Password4j | 1.8.4 | Password hashing (Argon2id) | Already in stack from Phase 6 |
| TinyMCE | 7.x | WYSIWYG blog post editor | Mature, MIT self-hosted, excellent plugin ecosystem |
| Pebble | 4.1.1 | Component templates | Already in stack from Phase 3/4 |
| Quill (DB) | 4.8.6 | Database queries | Already in stack |
| circe-yaml | 1.0.0 | Settings model YAML | Already in stack |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| slugify | 3.0.7 | URL slug generation | Post/category slug auto-generation |
| commonmark-java | 0.22.0 | Markdown parsing | Content rendering option (alongside HTML) |
| zio-cache | 0.2.3 | User session caching | Reduce DB lookups for session validation |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| TinyMCE | Quill.js | Quill lighter but TinyMCE has better plugin ecosystem |
| TinyMCE | CKEditor 5 | CKEditor requires license for self-hosted advanced features |
| Cookie sessions | Pure JWT | Pure JWT cannot be revoked; cookies enable server-side control |
| Slugify library | Hand-roll | Slug generation has edge cases (unicode, special chars) |
**Installation (Mill build.mill):**
```scala
def mvnDeps = Seq(
// Existing deps from Phase 1-9...
// Slug generation
mvn"com.github.slugify:slugify:3.0.7",
// Markdown support (optional content format)
mvn"org.commonmark:commonmark:0.22.0"
)
```
**Frontend (package.json):**
```json
{
"dependencies": {
"tinymce": "^7.0.0"
}
}
```
## Architecture Patterns
### Recommended Project Structure
```
plugins/
├── golem15/
│ ├── user/
│ │ ├── plugin.yaml # Plugin manifest
│ │ ├── Plugin.scala # Lifecycle, component/settings registration
│ │ ├── models/
│ │ │ ├── User.scala # Frontend user model
│ │ │ ├── UserGroup.scala # User groups (optional)
│ │ │ ├── Settings.scala # Plugin settings model
│ │ │ └── Throttle.scala # Login throttle tracking
│ │ ├── components/
│ │ │ ├── Session.scala # Session/auth component
│ │ │ ├── Account.scala # Registration/login/profile
│ │ │ └── ResetPassword.scala # Password reset flow
│ │ ├── services/
│ │ │ ├── UserService.scala # User CRUD operations
│ │ │ ├── AuthService.scala # Authentication logic
│ │ │ └── MailService.scala # Email notifications
│ │ └── resources/
│ │ ├── db/migration/ # User tables
│ │ └── views/ # Component templates
│ └── blog/
│ ├── plugin.yaml
│ ├── Plugin.scala
│ ├── models/
│ │ ├── Post.scala # Blog post model
│ │ ├── Category.scala # Hierarchical categories
│ │ ├── Tag.scala # Tags (many-to-many)
│ │ └── Settings.scala # Blog settings
│ ├── components/
│ │ ├── Posts.scala # Post listing
│ │ ├── Post.scala # Single post display
│ │ ├── Categories.scala # Category listing
│ │ └── RelatedPosts.scala # Related posts widget
│ ├── controllers/
│ │ ├── Posts.scala # Admin post management
│ │ └── Categories.scala # Admin category management
│ └── resources/
│ ├── db/migration/
│ └── views/
```
### Pattern 1: Frontend User Model (from Golem15.User)
**What:** User model for frontend authentication with activation, groups, and settings
**When to use:** All frontend user operations
**Example:**
```scala
// Source: Golem15.User User.php adapted to Scala/ZIO
case class FrontendUser(
id: Long,
email: String,
passwordHash: String,
name: String,
surname: String,
username: Option[String] = None, // Optional per Settings
avatarPath: Option[String] = None,
isActivated: Boolean = false,
activationCode: Option[String] = None,
activatedAt: Option[Instant] = None,
persistCode: Option[String] = None, // "Remember me" token
resetPasswordCode: Option[String] = None,
lastLogin: Option[Instant] = None,
lastSeen: Option[Instant] = None,
createdIpAddress: Option[String] = None,
lastIpAddress: Option[String] = None,
isGuest: Boolean = false,
isSuperuser: Boolean = false,
deletedAt: Option[Instant] = None, // Soft delete
createdAt: Instant,
updatedAt: Instant
)
object FrontendUser:
// Validation rules (from WinterCMS User model)
val validationRules = Map(
"email" -> List(Required, Email, Unique("users", "email"), Between(6, 255)),
"password" -> List(Required, Between(8, 255), Confirmed),
"name" -> List(Required, Between(2, 100)),
"surname" -> List(Required, Between(2, 100))
)
// Minimum password length from settings
def minPasswordLength: ZIO[UserSettings, Nothing, Int] =
ZIO.serviceWith[UserSettings](_.minPasswordLength.getOrElse(8))
// User service with ZIO
trait FrontendUserService:
def findByEmail(email: String): IO[UserError, Option[FrontendUser]]
def findById(id: Long): IO[UserError, Option[FrontendUser]]
def register(data: RegistrationData): IO[UserError, FrontendUser]
def authenticate(credentials: Credentials, remember: Boolean): IO[AuthError, FrontendUser]
def activate(userId: Long, code: String): IO[ActivationError, FrontendUser]
def requestPasswordReset(email: String): IO[UserError, Unit]
def resetPassword(code: String, newPassword: String): IO[ResetError, FrontendUser]
def updateProfile(userId: Long, data: ProfileUpdate): IO[UserError, FrontendUser]
def updateAvatar(userId: Long, file: UploadedFile): IO[UserError, FrontendUser]
def touchLastSeen(userId: Long): UIO[Unit]
def touchIpAddress(userId: Long, ip: String): UIO[Unit]
def isThrottled(ip: String): UIO[Boolean]
```
### Pattern 2: User Settings Model (from Golem15.User Settings)
**What:** Plugin settings stored in database, YAML-defined fields
**When to use:** Configurable plugin behavior
**Example:**
```scala
// Source: Golem15.User Settings.php adapted to Scala
sealed trait ActivateMode
object ActivateMode:
case object Auto extends ActivateMode // Automatic activation
case object User extends ActivateMode // User email verification
case object Admin extends ActivateMode // Admin approval required
sealed trait RememberLogin
object RememberLogin:
case object Always extends RememberLogin
case object Never extends RememberLogin
case object Ask extends RememberLogin
case class UserPluginSettings(
allowRegistration: Boolean = true,
requireActivation: Boolean = true,
activateMode: ActivateMode = ActivateMode.Auto,
useThrottle: Boolean = true,
useRegisterThrottle: Boolean = true,
blockPersistence: Boolean = false, // Prevent concurrent sessions
rememberLogin: RememberLogin = RememberLogin.Always,
minPasswordLength: Int = 8
)
// Settings YAML (models/settings/fields.yaml)
// allow_registration:
// type: checkbox
// label: golem15.user::lang.settings.allow_registration
// default: true
// comment: Allow new users to register
//
// activate_mode:
// type: dropdown
// label: golem15.user::lang.settings.activate_mode
// options:
// auto: Automatic activation
// user: User activates via email
// admin: Administrator approval
// default: auto
// Settings service
trait UserSettingsService:
def get: UIO[UserPluginSettings]
def update(settings: UserPluginSettings): IO[SettingsError, Unit]
object UserSettingsService:
val live: ZLayer[SettingsRepository, Nothing, UserSettingsService] =
ZLayer.fromFunction { (repo: SettingsRepository) =>
new UserSettingsService:
def get: UIO[UserPluginSettings] =
repo.get("user_settings")
.map(_.map(parseSettings).getOrElse(UserPluginSettings()))
.orElseSucceed(UserPluginSettings())
}
```
### Pattern 3: Session Component (from Golem15.User Session)
**What:** Component that controls page access and injects user context
**When to use:** All pages requiring authentication or guest-only access
**Example:**
```scala
// Source: Golem15.User Session.php adapted to ZIO component
sealed trait SecurityMode
object SecurityMode:
case object All extends SecurityMode // Allow everyone
case object User extends SecurityMode // Logged-in users only
case object Guest extends SecurityMode // Non-logged-in only
class SessionComponent(
val alias: String,
val properties: Ref[Map[String, PropertyValue]],
val pageContext: PageContext,
userService: FrontendUserService,
authService: AuthService
) extends SummerComponent:
override val details = ComponentDetails(
name = "Session",
description = "User session management and access control"
)
override val propertySchema = List(
PropertyDef("security", PropertyType.Dropdown,
title = "Access Control",
options = Map("all" -> "All visitors", "user" -> "Logged-in users", "guest" -> "Guests only"),
default = "all"),
PropertyDef("allowedUserGroups", PropertyType.Set,
title = "Allowed User Groups",
description = "Restrict to specific user groups"),
PropertyDef("redirect", PropertyType.Dropdown,
title = "Redirect Page",
description = "Page to redirect unauthorized visitors")
)
// Page variable: user
private var currentUser: Option[FrontendUser] = None
override def onRun: ZIO[ComponentEnv, ComponentError, Unit] =
for
props <- properties.get
security = props.get("security").map(_.asString).getOrElse("all")
allowed <- checkUserSecurity(security, props)
_ <- ZIO.when(!allowed) {
val redirectPage = props.get("redirect").map(_.asString)
redirectPage match
case Some(page) if page.nonEmpty =>
ZIO.fail(ComponentRedirect(page))
case _ =>
ZIO.fail(ComponentError.AccessDenied("User not authorized for this page"))
}
user <- authService.getCurrentUser
_ <- user.fold(ZIO.unit)(u => userService.touchLastSeen(u.id))
yield
currentUser = user
pageContext.set("user", user.orNull)
private def checkUserSecurity(
security: String,
props: Map[String, PropertyValue]
): ZIO[ComponentEnv, ComponentError, Boolean] =
for
isAuthenticated <- authService.isAuthenticated
allowedGroups = props.get("allowedUserGroups").map(_.asStringList).getOrElse(Nil)
result <- security match
case "all" => ZIO.succeed(true)
case "guest" => ZIO.succeed(!isAuthenticated)
case "user" =>
if !isAuthenticated then ZIO.succeed(false)
else if allowedGroups.isEmpty then ZIO.succeed(true)
else
authService.getCurrentUser.map { user =>
user.exists(u => allowedGroups.exists(g => u.groups.contains(g)))
}
yield result
// HTMX handler: Logout
def onLogout: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
for
_ <- authService.logout
props <- properties.get
redirectUrl = props.get("redirect").map(_.asString).getOrElse("/")
yield HtmxResponse(
html = Html.empty,
triggers = Map("userLoggedOut" -> "true"),
headers = Map("HX-Redirect" -> redirectUrl)
)
def user: Option[FrontendUser] = currentUser
```
### Pattern 4: Account Component (from Golem15.User Account)
**What:** Combined registration, login, and profile management component
**When to use:** Account management pages
**Example:**
```scala
// Source: Golem15.User Account.php adapted to ZIO
class AccountComponent(
val alias: String,
val properties: Ref[Map[String, PropertyValue]],
val pageContext: PageContext,
userService: FrontendUserService,
authService: AuthService,
mailService: MailService,
settingsService: UserSettingsService
) extends SummerComponent:
override val propertySchema = List(
PropertyDef("redirect", PropertyType.Dropdown,
title = "Redirect After",
description = "Page to redirect after login/register"),
PropertyDef("paramCode", PropertyType.String,
title = "Activation Code Param",
default = "code"),
PropertyDef("requirePassword", PropertyType.Checkbox,
title = "Require Password for Update",
description = "Require current password when updating profile")
)
// HTMX handler: Sign in
def onSignin: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
for
request <- ZIO.service[Request]
settings <- settingsService.get
login <- getFormValue("login").map(_.trim)
password <- getFormValue("password")
remember <- getFormValue("remember").map(_.contains("true"))
// Validate input
_ <- ZIO.when(login.isEmpty) {
ZIO.fail(ValidationError("login", "Email is required"))
}
_ <- ZIO.when(password.length < settings.minPasswordLength) {
ZIO.fail(ValidationError("password", s"Password must be at least ${settings.minPasswordLength} characters"))
}
// Authenticate
credentials = Credentials(login, password)
user <- authService.authenticate(credentials, remember)
.mapError {
case AuthError.InvalidCredentials =>
// Generic message to prevent enumeration
ValidationError("login", "Invalid email or password")
case AuthError.AccountBanned =>
ValidationError("login", "Account is banned")
case AuthError.AccountNotActivated =>
ValidationError("login", "Account not activated")
}
// Record IP address
ip = request.remoteAddress.map(_.toString).getOrElse("")
_ <- userService.touchIpAddress(user.id, ip)
// Redirect
props <- properties.get
redirectUrl = props.get("redirect").map(_.asString).getOrElse("/")
yield HtmxResponse(
html = Html.empty,
triggers = Map("userLoggedIn" -> user.id.toString),
headers = Map("HX-Redirect" -> redirectUrl)
)
// HTMX handler: Register
def onRegister: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
for
settings <- settingsService.get
_ <- ZIO.when(!settings.allowRegistration) {
ZIO.fail(ComponentError.Forbidden("Registration is disabled"))
}
request <- ZIO.service[Request]
ip = request.remoteAddress.map(_.toString).getOrElse("")
// Check throttle
throttled <- userService.isThrottled(ip)
_ <- ZIO.when(throttled && settings.useRegisterThrottle) {
ZIO.fail(ComponentError.RateLimited("Registration is throttled"))
}
// Parse form data
data <- parseRegistrationForm
// Register user
requireActivation = settings.requireActivation
autoActivate = settings.activateMode == ActivateMode.Auto
user <- userService.register(data.copy(
createdIpAddress = Some(ip),
isActivated = autoActivate || !requireActivation
))
// Send activation email if needed
_ <- ZIO.when(settings.activateMode == ActivateMode.User) {
for
code <- generateActivationCode(user.id)
_ <- mailService.sendActivationEmail(user, code)
yield ()
}
// Auto-login if activated
_ <- ZIO.when(autoActivate || !requireActivation) {
authService.login(user, remember = false)
}
props <- properties.get
redirectUrl = props.get("redirect").map(_.asString).getOrElse("/")
yield HtmxResponse(
html = Html.empty,
triggers = Map("userRegistered" -> user.id.toString),
headers = Map("HX-Redirect" -> redirectUrl)
)
// HTMX handler: Update profile
def onUpdate: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
for
user <- authService.getCurrentUser.someOrFail(
ComponentError.Unauthorized("Not logged in")
)
props <- properties.get
requirePassword = props.get("requirePassword").exists(_.asBoolean)
// Validate current password if required
_ <- ZIO.when(requirePassword) {
for
currentPass <- getFormValue("password_current")
valid <- authService.verifyPassword(user.id, currentPass)
_ <- ZIO.when(!valid) {
ZIO.fail(ValidationError("password_current", "Invalid current password"))
}
yield ()
}
// Parse and validate update data
update <- parseProfileUpdate
// Handle avatar upload
avatarFile <- getUploadedFile("avatar")
_ <- avatarFile.fold(ZIO.unit) { file =>
userService.updateAvatar(user.id, file)
}
// Update user
updated <- userService.updateProfile(user.id, update)
// If password changed, re-authenticate
_ <- ZIO.when(update.newPassword.isDefined) {
authService.login(updated, remember = true)
}
yield HtmxResponse(
html = renderPartial("success", Map("message" -> "Profile updated successfully")),
triggers = Map("profileUpdated" -> "true")
)
```
### Pattern 5: Blog Post Model (from Golem15.Blog Post)
**What:** Blog post with content, categories, tags, featured images, and publishing state
**When to use:** All blog functionality
**Example:**
```scala
// Source: Golem15.Blog Post.php adapted to Scala
case class BlogPost(
id: Long,
authorId: Long, // Backend user who created
title: String,
slug: String,
excerpt: Option[String] = None,
content: String, // Markdown or HTML
contentHtml: String, // Rendered HTML
charactersCount: Int = 0,
published: Boolean = false,
publishedAt: Option[Instant] = None,
isFeatured: Boolean = false,
mainCategoryId: Option[Long] = None,
metadata: Map[String, Json] = Map.empty,
createdAt: Instant,
updatedAt: Instant
)
// Relations
case class BlogPostWithRelations(
post: BlogPost,
categories: List[Category],
tags: List[Tag],
featuredImages: List[MediaItem],
author: Option[BackendUser]
)
object BlogPost:
val validationRules = Map(
"title" -> List(Required),
"slug" -> List(Required, Regex("^[a-z0-9\\-]+$"), Unique("blog_posts", "slug")),
"content" -> List(Required)
)
// Allowed sorting options (from WinterCMS)
val allowedSortingOptions = Map(
"title asc" -> "Title (A-Z)",
"title desc" -> "Title (Z-A)",
"created_at asc" -> "Created (oldest)",
"created_at desc" -> "Created (newest)",
"published_at asc" -> "Published (oldest)",
"published_at desc" -> "Published (newest)",
"random" -> "Random"
)
// Format content to HTML (before save)
def formatHtml(content: String): String =
val parser = Parser.builder().build()
val document = parser.parse(content)
val renderer = HtmlRenderer.builder().build()
val html = renderer.render(document)
// Sanitize to prevent XSS (allow safe tags only)
Jsoup.clean(html, Safelist.relaxed())
// Generate summary from content
def generateSummary(contentHtml: String, maxLength: Int = 600): String =
val moreMarker = "<!-- more -->"
if contentHtml.contains(moreMarker) then
contentHtml.split(moreMarker).headOption.getOrElse("")
else
Jsoup.parse(contentHtml).text().take(maxLength) + "..."
trait BlogPostService:
def create(data: PostCreateData): IO[PostError, BlogPost]
def update(id: Long, data: PostUpdateData): IO[PostError, BlogPost]
def delete(id: Long): IO[PostError, Unit]
def findBySlug(slug: String): IO[PostError, Option[BlogPostWithRelations]]
def listFrontend(options: PostListOptions): IO[PostError, PaginatedResult[BlogPostWithRelations]]
def listByCategory(categoryId: Long, options: PostListOptions): IO[PostError, PaginatedResult[BlogPostWithRelations]]
def listByTag(tagSlug: String, options: PostListOptions): IO[PostError, PaginatedResult[BlogPostWithRelations]]
def getRelatedPosts(postId: Long, limit: Int): IO[PostError, List[BlogPostWithRelations]]
case class PostListOptions(
page: Int = 1,
perPage: Int = 10,
sort: String = "published_at desc",
published: Boolean = true,
exceptPost: Option[List[String]] = None,
exceptCategories: Option[List[String]] = None,
search: Option[String] = None
)
```
### Pattern 6: Hierarchical Category Model (from Golem15.Blog Category)
**What:** Blog categories with nested tree structure (parent/child relationships)
**When to use:** Category organization
**Example:**
```scala
// Source: Golem15.Blog Category.php with NestedTree trait
case class Category(
id: Long,
name: String,
slug: String,
description: Option[String] = None,
parentId: Option[Long] = None,
nestLeft: Int = 0, // Nested set model
nestRight: Int = 0,
nestDepth: Int = 0,
published: Boolean = true,
publishedAt: Option[Instant] = None,
createdAt: Instant,
updatedAt: Instant
)
trait CategoryService:
def create(data: CategoryData): IO[CategoryError, Category]
def update(id: Long, data: CategoryData): IO[CategoryError, Category]
def delete(id: Long): IO[CategoryError, Unit]
def findBySlug(slug: String): IO[CategoryError, Option[Category]]
def listAll: UIO[List[Category]]
def getTree: UIO[List[CategoryTreeNode]]
def getChildren(parentId: Long): UIO[List[Category]]
def getAllChildrenAndSelf(categoryId: Long): UIO[List[Category]]
def getPostCount(categoryId: Long): UIO[Int]
def getNestedPostCount(categoryId: Long): UIO[Int]
case class CategoryTreeNode(
category: Category,
children: List[CategoryTreeNode],
postCount: Int
)
// Nested tree operations
object NestedTree:
// Rebuild left/right values after insert/delete
def rebuild(categories: List[Category]): List[Category] = ???
// Get all descendants
def descendants(category: Category, all: List[Category]): List[Category] =
all.filter(c => c.nestLeft > category.nestLeft && c.nestRight < category.nestRight)
// Get ancestors
def ancestors(category: Category, all: List[Category]): List[Category] =
all.filter(c => c.nestLeft < category.nestLeft && c.nestRight > category.nestRight)
```
### Pattern 7: Tag System (Separate from Categories)
**What:** Tags for free-form labeling, separate from hierarchical categories
**When to use:** Flexible post organization
**Example:**
```scala
// Source: CONTEXT.md - tags separate from categories
case class Tag(
id: Long,
name: String,
slug: String,
createdAt: Instant,
updatedAt: Instant
)
// Many-to-many pivot table
case class PostTag(
postId: Long,
tagId: Long
)
trait TagService:
def findOrCreate(name: String): IO[TagError, Tag]
def findBySlug(slug: String): IO[TagError, Option[Tag]]
def listAll: UIO[List[Tag]]
def listForPost(postId: Long): UIO[List[Tag]]
def getPopularTags(limit: Int): UIO[List[TagWithCount]]
def syncPostTags(postId: Long, tagNames: List[String]): IO[TagError, List[Tag]]
case class TagWithCount(tag: Tag, postCount: Int)
```
### Pattern 8: Posts Component (from Golem15.Blog Posts)
**What:** Frontend component for displaying blog post listings with pagination
**When to use:** Blog index, category pages, archive pages
**Example:**
```scala
// Source: Golem15.Blog Posts.php adapted to ZIO
class PostsComponent(
val alias: String,
val properties: Ref[Map[String, PropertyValue]],
val pageContext: PageContext,
postService: BlogPostService,
categoryService: CategoryService
) extends SummerComponent:
override val propertySchema = List(
PropertyDef("pageNumber", PropertyType.String,
title = "Page Number Parameter",
default = "{{ :page }}"),
PropertyDef("categoryFilter", PropertyType.String,
title = "Category Filter",
description = "Filter by category slug"),
PropertyDef("postsPerPage", PropertyType.String,
title = "Posts Per Page",
default = "10",
validation = ValidationRules.integer(min = 1, max = 100)),
PropertyDef("noPostsMessage", PropertyType.String,
title = "No Posts Message",
default = "No posts found."),
PropertyDef("sortOrder", PropertyType.Dropdown,
title = "Sort Order",
options = BlogPost.allowedSortingOptions,
default = "published_at desc"),
PropertyDef("throwNotFound", PropertyType.Checkbox,
title = "Throw 404 on Empty",
default = "false")
)
// Page variables
private var posts: PaginatedResult[BlogPostWithRelations] = _
private var category: Option[Category] = None
private var pageParam: String = "page"
override def onRun: ZIO[ComponentEnv, ComponentError, Unit] =
for
props <- properties.get
// Load category if filtered
catSlug = props.get("categoryFilter").map(_.asString).filter(_.nonEmpty)
cat <- catSlug.fold(ZIO.succeed(None))(slug =>
categoryService.findBySlug(slug).map(Some(_))
)
_ <- ZIO.when(catSlug.isDefined && cat.isEmpty) {
ZIO.fail(ComponentError.NotFound("Category not found"))
}
// Build list options
perPage = props.get("postsPerPage").flatMap(_.asInt).getOrElse(10)
sort = props.get("sortOrder").map(_.asString).getOrElse("published_at desc")
page = pageContext.param("page").flatMap(_.toIntOption).getOrElse(1)
options = PostListOptions(
page = page,
perPage = perPage,
sort = sort,
published = !pageContext.isBackendEditor
)
// Fetch posts
result <- cat match
case Some(c) => postService.listByCategory(c.id, options)
case None => postService.listFrontend(options)
// Handle empty results
throwNotFound = props.get("throwNotFound").exists(_.asBoolean)
_ <- ZIO.when(result.items.isEmpty && throwNotFound) {
ZIO.fail(ComponentError.NotFound("No posts found"))
}
yield
posts = result
category = cat
pageParam = "page"
pageContext.set("posts", posts.items.asJava)
pageContext.set("category", category.orNull)
pageContext.set("noPostsMessage", props.get("noPostsMessage").map(_.asString).getOrElse("No posts found."))
// HTMX handler: Load more (infinite scroll)
def onLoadMore: ZIO[ComponentEnv, ComponentError, HtmxResponse] =
for
page <- getFormValue("page").map(_.toIntOption.getOrElse(1))
props <- properties.get
perPage = props.get("postsPerPage").flatMap(_.asInt).getOrElse(10)
sort = props.get("sortOrder").map(_.asString).getOrElse("published_at desc")
options = PostListOptions(page = page, perPage = perPage, sort = sort)
result <- postService.listFrontend(options)
html <- renderPartial("items", Map("posts" -> result.items.asJava))
yield HtmxResponse(
html = html,
triggers = if !result.hasMore then Map("postsExhausted" -> "true") else Map.empty
)
def getPosts: PaginatedResult[BlogPostWithRelations] = posts
def getCategory: Option[Category] = category
```
### Pattern 9: WYSIWYG Editor Integration (TinyMCE)
**What:** Rich text editing for blog posts in admin
**When to use:** Post create/edit forms
**Example:**
```javascript
// Source: TinyMCE documentation + CONTEXT.md decisions
// Admin form widget for blog post content
// Initialize TinyMCE for blog content field
function initBlogEditor(selector) {
tinymce.init({
selector: selector,
height: 500,
plugins: [
'anchor', 'autolink', 'charmap', 'codesample', 'emoticons',
'image', 'link', 'lists', 'media', 'searchreplace', 'table',
'visualblocks', 'wordcount', 'fullscreen', 'preview'
],
toolbar: 'undo redo | blocks fontfamily fontsize | ' +
'bold italic underline strikethrough | link image media table | ' +
'align lineheight | numlist bullist indent outdent | ' +
'emoticons charmap | removeformat | fullscreen preview',
// Image upload handler
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
const formData = new FormData();
formData.append('file', blobInfo.blob(), blobInfo.filename());
htmx.ajax('POST', '/admin/media/upload', {
values: formData,
target: 'body',
swap: 'none'
}).then(response => {
resolve(response.url);
}).catch(error => {
reject('Image upload failed');
});
}),
// Content style
content_style: 'body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 16px; }',
// HTMX integration - update hidden field on change
setup: (editor) => {
editor.on('change', () => {
editor.save(); // Sync to textarea
// Trigger HTMX form validation if needed
htmx.trigger(editor.getElement(), 'change');
});
}
});
}
// Usage in admin form
document.addEventListener('DOMContentLoaded', () => {
initBlogEditor('#Form-field-Post-content');
});
```
### Pattern 10: Frontend Authentication Flow
**What:** Cookie-based session for frontend users with CSRF protection
**When to use:** All authenticated frontend requests
**Example:**
```scala
// Source: Phase 6 research + ZIO HTTP cookies
trait FrontendAuthService:
def login(user: FrontendUser, remember: Boolean): IO[AuthError, String]
def logout: UIO[Unit]
def getCurrentUser: UIO[Option[FrontendUser]]
def isAuthenticated: UIO[Boolean]
def verifyPassword(userId: Long, password: String): IO[AuthError, Boolean]
def generateSessionToken(userId: Long, remember: Boolean): IO[AuthError, String]
object FrontendAuthService:
val live: ZLayer[
FrontendUserRepository & PasswordService & Config,
Nothing,
FrontendAuthService
] = ZLayer.fromZIO {
for
userRepo <- ZIO.service[FrontendUserRepository]
password <- ZIO.service[PasswordService]
config <- ZIO.service[Config]
session <- Ref.make[Option[FrontendUser]](None)
yield new FrontendAuthService:
private val sessionCookieName = "summer_session"
private val rememberCookieName = "summer_remember"
def login(user: FrontendUser, remember: Boolean): IO[AuthError, String] =
for
token <- generateSessionToken(user.id, remember)
_ <- session.set(Some(user))
yield token
def generateSessionToken(userId: Long, remember: Boolean): IO[AuthError, String] =
val duration = if remember then config.rememberDuration else config.sessionDuration
val expiry = Instant.now.plus(duration)
val claims = JwtClaim(
subject = Some(userId.toString),
expiration = Some(expiry.getEpochSecond),
issuedAt = Some(Instant.now.getEpochSecond)
)
ZIO.succeed(Jwt.encode(claims, config.jwtSecret, JwtAlgorithm.HS256))
def getCurrentUser: UIO[Option[FrontendUser]] =
session.get
// Cookie configuration
object FrontendCookieConfig:
def sessionCookie(token: String, remember: Boolean, config: Config): Cookie.Response =
Cookie.Response(
name = "summer_session",
content = token,
maxAge = Some(if remember then config.rememberDuration else config.sessionDuration),
path = Some(Path.root),
isSecure = config.isProduction,
isHttpOnly = true,
sameSite = Some(Cookie.SameSite.Lax) // Lax for frontend to allow navigation
)
// Auth middleware for frontend
val frontendAuthMiddleware: HandlerAspect[FrontendAuthService, Unit] =
HandlerAspect.interceptIncomingHandler(Handler.fromFunctionZIO[Request] { request =>
for
authService <- ZIO.service[FrontendAuthService]
token <- ZIO.fromOption(
request.cookie("summer_session").map(_.content)
).orElse(ZIO.succeed(""))
_ <- authService.validateAndSetSession(token).ignore
yield (request, ())
})
```
### Anti-Patterns to Avoid
- **Storing plain passwords:** Always use Argon2id via Password4j
- **Revealing email existence:** Login errors should be generic
- **Direct user input in templates:** Always escape, use Pebble autoescaping
- **Skipping CSRF on HTMX POSTs:** All mutations need CSRF validation
- **Nested tree without rebuild:** Category operations must rebuild left/right
- **Publishing without published_at:** Published posts must have a date
- **Blocking in components:** All operations must be ZIO effects
- **Hard-coded redirects:** Use component properties for redirect URLs
## Don't Hand-Roll
Problems that look simple but have existing solutions:
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Password hashing | Custom hash | Password4j Argon2id | Timing attacks, salt handling, cost tuning |
| WYSIWYG editor | Custom textarea | TinyMCE | XSS sanitization, image upload, formatting |
| URL slugs | Simple replace | Slugify library | Unicode handling, transliteration, edge cases |
| Nested categories | Manual tree | Nested set model | Efficient subtree queries, ordering |
| Email templates | String concat | Pebble templates | Escaping, i18n, layout inheritance |
| Session tokens | UUID only | JWT with claims | Expiry, user context, revocation tracking |
| Markdown rendering | Regex | CommonMark parser | Edge cases, security, spec compliance |
| Activation codes | Random string | Secure random + hash | Timing attacks, entropy |
**Key insight:** User authentication and CMS content have many security and UX edge cases. The WinterCMS reference implementation handles these; adapt patterns, don't reinvent.
## Common Pitfalls
### Pitfall 1: User Enumeration via Login
**What goes wrong:** Attacker determines which emails have accounts
**Why it happens:** Different error messages for "user not found" vs "wrong password"
**How to avoid:** Always return generic "Invalid email or password" message
**Warning signs:** Different response times for existing vs non-existing emails
### Pitfall 2: Activation Code Timing Attack
**What goes wrong:** Attacker brute-forces activation codes faster
**Why it happens:** Early return when code not found vs wrong code
**How to avoid:** Always perform comparison even for non-existent users
**Warning signs:** Response time varies with code validity
### Pitfall 3: Session Fixation on Login
**What goes wrong:** Attacker pre-sets session ID, hijacks after login
**Why it happens:** Reusing session token after authentication
**How to avoid:** Generate new session token on login
**Warning signs:** Same cookie value before and after login
### Pitfall 4: XSS in Blog Content
**What goes wrong:** Malicious script executes in blog post
**Why it happens:** Raw HTML allowed without sanitization
**How to avoid:** Sanitize HTML with allow-list (Jsoup Safelist.relaxed())
**Warning signs:** Script tags or event handlers in content
### Pitfall 5: Nested Tree Corruption
**What goes wrong:** Categories display in wrong order or missing
**Why it happens:** Left/right values not rebuilt after insert/delete
**How to avoid:** Rebuild tree on every structural change
**Warning signs:** Gaps in left/right sequence, overlapping ranges
### Pitfall 6: Published State Bypass
**What goes wrong:** Unpublished posts visible on frontend
**Why it happens:** Missing `published=true AND published_at <= NOW()` filter
**How to avoid:** Always apply publication filter in frontend queries
**Warning signs:** Draft posts appearing in listings
### Pitfall 7: Remember Me Token Reuse
**What goes wrong:** Logged out user can still access with old cookie
**Why it happens:** Token not invalidated on logout
**How to avoid:** Rotate persist_code on logout, track in database
**Warning signs:** "Logout" doesn't actually prevent access
## Code Examples
Verified patterns from official sources:
### Database Schema (Flyway Migration)
```sql
-- Source: Golem15.User + Golem15.Blog adapted for Scala
-- V10.1__user_plugin.sql
CREATE TABLE frontend_users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
surname VARCHAR(100) NOT NULL,
username VARCHAR(255) UNIQUE,
avatar_path VARCHAR(255),
is_activated BOOLEAN DEFAULT false,
activation_code VARCHAR(64),
activated_at TIMESTAMPTZ,
persist_code VARCHAR(64), -- Remember me token
reset_password_code VARCHAR(64),
last_login TIMESTAMPTZ,
last_seen TIMESTAMPTZ,
created_ip_address INET,
last_ip_address INET,
is_guest BOOLEAN DEFAULT false,
is_superuser BOOLEAN DEFAULT false,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_frontend_users_email ON frontend_users(email);
CREATE INDEX idx_frontend_users_username ON frontend_users(username);
CREATE INDEX idx_frontend_users_persist_code ON frontend_users(persist_code);
CREATE TABLE user_groups (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
code VARCHAR(64) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE users_groups (
user_id BIGINT NOT NULL REFERENCES frontend_users(id) ON DELETE CASCADE,
group_id BIGINT NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE user_throttle (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES frontend_users(id) ON DELETE CASCADE,
ip_address INET,
attempts INT DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
is_suspended BOOLEAN DEFAULT false,
suspended_at TIMESTAMPTZ,
is_banned BOOLEAN DEFAULT false,
banned_at TIMESTAMPTZ
);
CREATE INDEX idx_user_throttle_user ON user_throttle(user_id);
CREATE INDEX idx_user_throttle_ip ON user_throttle(ip_address);
-- V10.2__blog_plugin.sql
CREATE TABLE blog_posts (
id BIGSERIAL PRIMARY KEY,
author_id BIGINT NOT NULL REFERENCES backend_users(id),
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
excerpt TEXT,
content TEXT NOT NULL,
content_html TEXT NOT NULL,
characters_count INT DEFAULT 0,
published BOOLEAN DEFAULT false,
published_at TIMESTAMPTZ,
is_featured BOOLEAN DEFAULT false,
main_category_id BIGINT REFERENCES blog_categories(id),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_blog_posts_slug ON blog_posts(slug);
CREATE INDEX idx_blog_posts_published ON blog_posts(published, published_at);
CREATE INDEX idx_blog_posts_author ON blog_posts(author_id);
CREATE TABLE blog_categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
parent_id BIGINT REFERENCES blog_categories(id),
nest_left INT DEFAULT 0,
nest_right INT DEFAULT 0,
nest_depth INT DEFAULT 0,
published BOOLEAN DEFAULT true,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_blog_categories_slug ON blog_categories(slug);
CREATE INDEX idx_blog_categories_nested ON blog_categories(nest_left, nest_right);
CREATE TABLE blog_tags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_blog_tags_slug ON blog_tags(slug);
CREATE TABLE blog_posts_categories (
post_id BIGINT NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
category_id BIGINT NOT NULL REFERENCES blog_categories(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, category_id)
);
CREATE TABLE blog_posts_tags (
post_id BIGINT NOT NULL REFERENCES blog_posts(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES blog_tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
```
### Component Template Example
```html
{# plugins/golem15/blog/components/posts/default.htm #}
{# Source: Golem15.Blog Posts component pattern #}
<div class="blog-posts" id="posts-{{ __SELF__ }}">
{% if posts is empty %}
<div class="no-posts">
{{ noPostsMessage }}
</div>
{% else %}
<div class="posts-list">
{% for post in posts %}
{% include "posts/item" %}
{% endfor %}
</div>
{% if posts.hasNext %}
<div class="load-more">
<button
hx-post="{{ componentHandler('onLoadMore') }}"
hx-vals='{"page": {{ posts.currentPage + 1 }}}'
hx-target="#posts-{{ __SELF__ }} .posts-list"
hx-swap="beforeend"
hx-indicator=".htmx-indicator"
class="btn btn-primary">
{{ 'Load More'|_ }}
<span class="htmx-indicator">Loading...</span>
</button>
</div>
{% endif %}
{% endif %}
</div>
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Server sessions only | JWT + cookies hybrid | 2020+ | Scalability with revocation |
| MD5/SHA1 passwords | Argon2id | OWASP 2022+ | GPU resistance |
| Custom WYSIWYG | TinyMCE/CKEditor | Always | Security, features |
| Flat categories | Nested set model | Established | Efficient tree queries |
| Manual pagination | Infinite scroll option | 2023+ | Better mobile UX |
| Callback handlers | HTMX + ZIO effects | 2024+ | Simpler state management |
**Deprecated/outdated:**
- MD5/SHA1 for passwords: Never use, no resistance to cracking
- jQuery AJAX: Use HTMX data attributes
- Storing passwords encrypted (not hashed): Use one-way hashing
- Session-only auth without remember: Poor UX
## Open Questions
Things that couldn't be fully resolved:
1. **Avatar Storage Location**
- What we know: Phase 9 has media library with storage abstraction
- What's unclear: Should user avatars use media library or separate user files?
- Recommendation: Use media library for consistency, path in user record
2. **Reading Time Calculation**
- What we know: CONTEXT.md says "auto-calculated"
- What's unclear: Words per minute rate, include code blocks?
- Recommendation: 200 WPM average, exclude code blocks, round to nearest minute
3. **Related Posts Algorithm**
- What we know: "Automatic by shared categories/tags"
- What's unclear: Weighting between categories vs tags, minimum threshold
- Recommendation: Score = 2*shared_categories + 1*shared_tags, exclude current post, limit 3-5
4. **Social Share Buttons**
- What we know: OpenGraph meta tags + share buttons component
- What's unclear: Which platforms? Native share API?
- Recommendation: Copy link, Twitter, Facebook, LinkedIn; use Web Share API if available
5. **Archive Pages by Year/Month**
- What we know: CONTEXT.md marks as Claude's discretion
- What's unclear: Whether to implement in Phase 10 or defer
- Recommendation: Include basic archive routes, full archive component can be Phase 11+
## Sources
### Primary (HIGH confidence)
- [Golem15.User Plugin](golem15-wintercms-starter/plugins/golem15/user/) - Reference implementation
- [Golem15.Blog Plugin](golem15-wintercms-starter/plugins/golem15/blog/) - Reference implementation
- [ZIO HTTP Authentication](https://ziohttp.com/examples/authentication) - JWT patterns
- [ZIO HTTP Cookies](https://zio.dev/zio-http/reference/headers/session/cookies/) - Cookie management
- [Password4j](https://github.com/Password4j/password4j) - Already in stack
- [TinyMCE Documentation](https://www.tiny.cloud/docs/tinymce/latest/) - WYSIWYG integration
### Secondary (MEDIUM confidence)
- [HTMX Async Auth Pattern](https://htmx.org/examples/async-auth/) - Frontend auth with HTMX
- [HTMX Web Security](https://htmx.org/essays/web-security-basics-with-htmx/) - CSRF patterns
- [Liveblocks Editor Comparison](https://liveblocks.io/blog/which-rich-text-editor-framework-should-you-choose-in-2025) - Editor selection rationale
- [Nested Set Model](https://en.wikipedia.org/wiki/Nested_set_model) - Category tree structure
- [OWASP Password Storage](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - Security guidance
### Tertiary (LOW confidence)
- Community patterns for Scala CMS implementations
- General blog architecture recommendations
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH - All libraries already in stack or well-documented
- User plugin patterns: HIGH - Direct adaptation of Golem15.User reference
- Blog plugin patterns: HIGH - Direct adaptation of Golem15.Blog reference
- WYSIWYG integration: MEDIUM - TinyMCE documented but specific integration needs validation
- Component patterns: HIGH - Based on Phase 3 research
**Research date:** 2026-02-05
**Valid until:** 2026-03-05 (30 days - stable domain, reference implementation well-tested)