diff --git a/.planning/phases/10-core-plugins/10-RESEARCH.md b/.planning/phases/10-core-plugins/10-RESEARCH.md new file mode 100644 index 0000000..efd0cbc --- /dev/null +++ b/.planning/phases/10-core-plugins/10-RESEARCH.md @@ -0,0 +1,1215 @@ +# 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 = "" + 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 #} +
+ {% if posts is empty %} +
+ {{ noPostsMessage }} +
+ {% else %} +
+ {% for post in posts %} + {% include "posts/item" %} + {% endfor %} +
+ + {% if posts.hasNext %} +
+ +
+ {% endif %} + {% endif %} +
+``` + +## 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)