fix(09): revise plans based on checker feedback

- Plan 02: Added key_link clarifying CmsPageService.render populates
  PageRenderContext.components before template rendering. Updated
  Task 2/3 actions to emphasize component initialization flow.

- Plan 03: Split into 03 (storage + library core) and 03b (image
  processing + routes) to reduce scope from 9 files to 7+4 files.
  Estimated context reduced from ~65% to ~45% each.

- Plan 03b: New plan for ImageProcessor and MediaRoutes. Added
  specific curl command with -F flags and expected JSON response.

- Plan 05: Added plugin integration test (MenuPluginIntegrationSpec)
  demonstrating custom menu item type registration, resolution, and
  template rendering per CONT-09 requirement.

- Plan 06: Reframed must_haves truths from implementation details
  to user-observable outcomes (e.g., 'Developer edits template file,
  browser refresh shows changes without server restart')

- Roadmap: Updated Phase 9 from 6 to 7 plans.
This commit is contained in:
Jakub Zych
2026-02-05 15:41:50 +01:00
parent dca89e10cd
commit edbee885ac
6 changed files with 506 additions and 296 deletions

View File

@@ -11,16 +11,13 @@ files_modified:
- summercms/src/content/media/S3Storage.scala
- summercms/src/content/media/MediaItem.scala
- summercms/src/content/media/MediaLibrary.scala
- summercms/src/content/media/ImageProcessor.scala
- summercms/resources/db/migration/V010__media_library.sql
- summercms/src/api/admin/MediaRoutes.scala
autonomous: true
must_haves:
truths:
- "Admin can upload files to media library"
- "Files stored via configurable storage backend (local or S3)"
- "Images can be resized and cropped via ImageProcessor"
- "Media items organized in folder hierarchy"
- "File metadata tracked in database for listing and search"
artifacts:
@@ -30,9 +27,6 @@ must_haves:
- path: "summercms/src/content/media/MediaLibrary.scala"
provides: "Media library service"
contains: "trait MediaLibrary"
- path: "summercms/src/content/media/ImageProcessor.scala"
provides: "Image resize/crop operations"
contains: "trait ImageProcessor"
- path: "summercms/resources/db/migration/V010__media_library.sql"
provides: "Media metadata tables"
contains: "CREATE TABLE media_files"
@@ -41,18 +35,14 @@ must_haves:
to: "StorageBackend.put"
via: "stream to storage"
pattern: "storage\\.put"
- from: "MediaRoutes"
to: "MediaLibrary.upload"
via: "HTTP multipart upload"
pattern: "MediaLibrary.*upload"
---
<objective>
Implement media library with storage abstraction and image processing
Implement media library core with storage abstraction
Purpose: Enable admin to upload, organize, and manage media files. Storage backend is pluggable (local filesystem for dev, S3 for production). Images can be resized/cropped per CONTEXT.md decisions.
Purpose: Enable admin to upload and manage media files. Storage backend is pluggable (local filesystem for dev, S3 for production). This plan focuses on the core storage and library service.
Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service, ImageProcessor for resize/crop, database migration for metadata, admin upload routes.
Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service, database migration for metadata.
</objective>
<execution_context>
@@ -65,7 +55,6 @@ Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service
@.planning/ROADMAP.md
@.planning/phases/09-content-management/09-RESEARCH.md
@summercms/src/db/QuillContext.scala
@summercms/src/api/Routes.scala
@build.mill
</context>
@@ -83,10 +72,6 @@ Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service
<action>
**build.mill - Add dependencies:**
```scala
// Image processing
mvn"com.sksamuel.scrimage::scrimage-core:4.1.0",
mvn"com.sksamuel.scrimage::scrimage-filters:4.1.0",
// S3 storage backend
mvn"dev.zio::zio-s3:0.4.2.1"
```
@@ -171,54 +156,12 @@ StorageBackend trait with LocalStorage and S3Storage implementations. MediaItem
</task>
<task type="auto">
<name>Task 2: Implement ImageProcessor and MediaLibrary service</name>
<name>Task 2: Implement MediaLibrary service and database migration</name>
<files>
summercms/src/content/media/ImageProcessor.scala
summercms/src/content/media/MediaLibrary.scala
summercms/resources/db/migration/V010__media_library.sql
</files>
<action>
**ImageProcessor.scala:**
Using Scrimage for image operations (per CONTEXT.md: resize and crop only):
```scala
trait ImageProcessor:
def resize(input: Array[Byte], width: Int, height: Int): IO[ImageError, Array[Byte]]
def crop(input: Array[Byte], x: Int, y: Int, width: Int, height: Int): IO[ImageError, Array[Byte]]
def thumbnail(input: Array[Byte], maxWidth: Int, maxHeight: Int): IO[ImageError, Array[Byte]]
def dimensions(input: Array[Byte]): IO[ImageError, (Int, Int)]
object ImageProcessor:
val live: ULayer[ImageProcessor] = ZLayer.succeed {
new ImageProcessor:
def resize(input: Array[Byte], width: Int, height: Int): IO[ImageError, Array[Byte]] =
ZIO.attemptBlocking {
ImmutableImage.loader()
.detectOrientation(true) // Handle EXIF rotation
.fromBytes(input)
.fit(width, height)
.bytes(JpegWriter.Default)
}.mapError(ImageError.ProcessingFailed(_))
def crop(input: Array[Byte], x: Int, y: Int, width: Int, height: Int): IO[ImageError, Array[Byte]] =
ZIO.attemptBlocking {
ImmutableImage.loader()
.detectOrientation(true)
.fromBytes(input)
.subimage(x, y, width, height)
.bytes(JpegWriter.Default)
}.mapError(ImageError.ProcessingFailed(_))
def thumbnail(input: Array[Byte], maxWidth: Int, maxHeight: Int): IO[ImageError, Array[Byte]] =
ZIO.attemptBlocking {
ImmutableImage.loader()
.detectOrientation(true)
.fromBytes(input)
.bound(maxWidth, maxHeight)
.bytes(JpegWriter.Default)
}.mapError(ImageError.ProcessingFailed(_))
}
```
**V010__media_library.sql:**
```sql
CREATE TABLE media_files (
@@ -266,132 +209,15 @@ case class MediaFolder(id: Long, path: String, name: String, parentPath: Option[
Live implementation:
- Generate unique path: folder/YYYY/MM/uuid-filename
- Upload to storage backend
- If image: extract dimensions, generate thumbnail (store at path_thumb)
- Insert metadata into database
- Return MediaItem with public URL
</action>
<verify>
`./mill summercms.compile` succeeds
Migration file exists
ImageProcessor can resize a test image (manual test with sample JPEG)
Migration file exists and is syntactically valid
</verify>
<done>
ImageProcessor provides resize/crop/thumbnail using Scrimage. MediaLibrary service handles uploads, metadata tracking, folder management.
</done>
</task>
<task type="auto">
<name>Task 3: Create admin media upload routes</name>
<files>
summercms/src/api/admin/MediaRoutes.scala
summercms/src/api/Routes.scala
</files>
<action>
**MediaRoutes.scala:**
Create admin API routes for media operations:
```scala
object MediaRoutes:
val routes: Routes[MediaLibrary & ImageProcessor, Response] =
Routes(
// List files in folder
Method.GET / "admin" / "api" / "media" / "files" -> handler { (req: Request) =>
for
folder <- ZIO.succeed(req.url.queryParams.getOrElse("folder", "/"))
pageNum <- ZIO.succeed(req.url.queryParams.getOrElse("page", "1").toIntOption.getOrElse(1))
library <- ZIO.service[MediaLibrary]
page <- library.list(folder, None, pageNum, 50)
yield Response.json(page.toJson)
},
// Upload file (multipart)
Method.POST / "admin" / "api" / "media" / "upload" -> handler { (req: Request) =>
for
form <- req.body.asMultipartFormStream
folder <- extractField(form, "folder").orElseSucceed("/")
file <- extractFile(form, "file")
library <- ZIO.service[MediaLibrary]
item <- library.upload(folder, file.fileName, file.content, file.contentType)
yield Response.json(item.toJson).status(Status.Created)
},
// Resize image
Method.POST / "admin" / "api" / "media" / "resize" / long("id") -> handler { (id: Long, req: Request) =>
for
body <- req.body.asJson[ResizeRequest]
library <- ZIO.service[MediaLibrary]
processor <- ZIO.service[ImageProcessor]
item <- library.get(id).someOrFail(MediaError.NotFound(id))
// Fetch, resize, re-upload
content <- library.storage.get(item.path).flatMap(_.runCollect)
resized <- processor.resize(content.toArray, body.width, body.height)
newItem <- library.upload(item.folder, item.fileName, ZStream.fromChunk(Chunk.fromArray(resized)), item.mimeType)
yield Response.json(newItem.toJson)
},
// Crop image
Method.POST / "admin" / "api" / "media" / "crop" / long("id") -> handler { (id: Long, req: Request) =>
for
body <- req.body.asJson[CropRequest]
library <- ZIO.service[MediaLibrary]
processor <- ZIO.service[ImageProcessor]
item <- library.get(id).someOrFail(MediaError.NotFound(id))
content <- library.storage.get(item.path).flatMap(_.runCollect)
cropped <- processor.crop(content.toArray, body.x, body.y, body.width, body.height)
newItem <- library.upload(item.folder, item.fileName, ZStream.fromChunk(Chunk.fromArray(cropped)), item.mimeType)
yield Response.json(newItem.toJson)
},
// Delete files
Method.DELETE / "admin" / "api" / "media" / "files" -> handler { (req: Request) =>
for
body <- req.body.asJson[DeleteRequest]
library <- ZIO.service[MediaLibrary]
_ <- library.delete(body.ids)
yield Response.ok
},
// Create folder
Method.POST / "admin" / "api" / "media" / "folders" -> handler { (req: Request) =>
for
body <- req.body.asJson[CreateFolderRequest]
library <- ZIO.service[MediaLibrary]
folder <- library.createFolder(body.path)
yield Response.json(folder.toJson).status(Status.Created)
},
// List folders
Method.GET / "admin" / "api" / "media" / "folders" -> handler { (req: Request) =>
for
parent <- ZIO.succeed(req.url.queryParams.get("parent"))
library <- ZIO.service[MediaLibrary]
folders <- library.listFolders(parent)
yield Response.json(folders.toJson)
}
)
case class ResizeRequest(width: Int, height: Int)
case class CropRequest(x: Int, y: Int, width: Int, height: Int)
case class DeleteRequest(ids: List[Long])
case class CreateFolderRequest(path: String)
```
**Update Routes.scala:**
Add MediaRoutes to admin route composition. Ensure admin routes require authentication (from Phase 6).
Security considerations:
- Validate file size limits (configurable, default 10MB)
- Validate mime types (whitelist images, documents)
- Sanitize file names (remove special chars, limit length)
- Path traversal protection in folder paths
</action>
<verify>
`./mill summercms.compile` succeeds
Routes registered in main Routes composition
Test upload endpoint with curl multipart form
</verify>
<done>
Admin media API routes provide upload, list, resize, crop, delete, folder operations. Routes integrated with main application.
MediaLibrary service handles uploads and metadata tracking. Database migration creates tables for files and folders.
</done>
</task>
@@ -402,18 +228,15 @@ After all tasks complete:
1. `./mill summercms.compile` - all code compiles
2. Migration V010 ready for Flyway
3. Test local storage: upload file, verify on disk, retrieve via URL
4. Test image resize: upload JPEG, call resize endpoint, verify dimensions changed
5. Test folder operations: create folder, list contents
6. curl -X POST -F "file=@test.jpg" -F "folder=/uploads" http://localhost:8080/admin/api/media/upload
4. Test folder operations: create folder, list contents
</verification>
<success_criteria>
- Files upload via multipart POST to configurable storage backend
- LocalStorage writes to local filesystem
- Files upload via MediaLibrary to configurable storage backend
- LocalStorage writes to local filesystem with path traversal protection
- S3Storage integrates with AWS S3 (compile-time only, runtime optional)
- ImageProcessor resizes/crops images using Scrimage with EXIF handling
- Media metadata tracked in database
- Admin API provides full media management capabilities
- Folder hierarchy supported
</success_criteria>
<output>