- 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.
10 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 09-content-management | 03b | execute | 2 |
|
|
true |
|
Purpose: Enable image resize/crop operations and expose media library functionality through admin HTTP API. This plan builds on the core MediaLibrary from Plan 03.
Output: ImageProcessor for resize/crop using Scrimage, admin upload routes with multipart handling.
<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/09-content-management/09-RESEARCH.md @.planning/phases/09-content-management/09-03-SUMMARY.md @summercms/src/content/media/MediaLibrary.scala @summercms/src/api/Routes.scala @build.mill Task 1: Implement ImageProcessor using Scrimage build.mill summercms/src/content/media/ImageProcessor.scala **build.mill - Add Scrimage dependencies:** ```scala // Image processing mvn"com.sksamuel.scrimage::scrimage-core:4.1.0", mvn"com.sksamuel.scrimage::scrimage-filters:4.1.0", ```ImageProcessor.scala: Using Scrimage for image operations (per CONTEXT.md: resize and crop only):
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(_))
}
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
./mill summercms.compilesucceeds Routes registered in main Routes composition
Test upload endpoint:
curl -X POST http://localhost:8080/admin/api/media/upload \
-H "Authorization: Bearer $TOKEN" \
-F "file=@test.jpg" \
-F "folder=/uploads"
Expected response (HTTP 201):
{
"id": 1,
"path": "uploads/2024/01/abc123-test.jpg",
"publicUrl": "/media/uploads/2024/01/abc123-test.jpg",
"fileName": "test.jpg",
"size": 12345,
"mimeType": "image/jpeg",
"folder": "/uploads",
"itemType": "Image",
"width": 800,
"height": 600,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
Test list endpoint:
curl http://localhost:8080/admin/api/media/files?folder=/uploads \
-H "Authorization: Bearer $TOKEN"
Expected response (HTTP 200):
{
"items": [...],
"total": 1,
"page": 1,
"perPage": 50
}
<success_criteria>
- ImageProcessor resizes/crops images using Scrimage with EXIF handling
- Upload endpoint accepts multipart form with file and folder fields
- Upload returns JSON with complete MediaItem including id, path, publicUrl
- Resize endpoint creates new resized version of image
- All routes protected by admin authentication </success_criteria>