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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user