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
1216 lines
46 KiB
Markdown
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)
|