# 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 #}