--- phase: 09-content-management plan: 03b type: execute wave: 2 depends_on: ["09-03"] files_modified: - build.mill - summercms/src/content/media/ImageProcessor.scala - summercms/src/api/admin/MediaRoutes.scala - summercms/src/api/Routes.scala autonomous: true must_haves: truths: - "Images can be resized and cropped via ImageProcessor" - "Admin can upload files through HTTP multipart endpoint" - "Admin can resize and crop images through API" - "Admin can delete media files through API" artifacts: - path: "summercms/src/content/media/ImageProcessor.scala" provides: "Image resize/crop operations" contains: "trait ImageProcessor" - path: "summercms/src/api/admin/MediaRoutes.scala" provides: "Admin media API routes" contains: "object MediaRoutes" key_links: - from: "MediaRoutes" to: "MediaLibrary.upload" via: "HTTP multipart upload" pattern: "MediaLibrary.*upload" - from: "MediaRoutes.resize" to: "ImageProcessor.resize" via: "image processing call" pattern: "processor\\.resize" --- Implement image processing and admin media upload routes 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. @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md @.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): ```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(_)) } ``` `./mill summercms.compile` succeeds with Scrimage dependencies ImageProcessor can resize a test image (manual test with sample JPEG) ImageProcessor provides resize/crop/thumbnail using Scrimage with EXIF orientation handling. Task 2: Create admin media upload routes summercms/src/api/admin/MediaRoutes.scala summercms/src/api/Routes.scala **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 `./mill summercms.compile` succeeds Routes registered in main Routes composition Test upload endpoint: ```bash 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): ```json { "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: ```bash curl http://localhost:8080/admin/api/media/files?folder=/uploads \ -H "Authorization: Bearer $TOKEN" ``` Expected response (HTTP 200): ```json { "items": [...], "total": 1, "page": 1, "perPage": 50 } ``` Admin media API routes provide upload, list, resize, crop, delete, folder operations. Routes integrated with main application and protected by admin authentication. After all tasks complete: 1. `./mill summercms.compile` - all code compiles 2. Test image resize: upload JPEG, call resize endpoint, verify dimensions changed 3. Test upload with curl: ```bash curl -X POST http://localhost:8080/admin/api/media/upload \ -H "Authorization: Bearer $TOKEN" \ -F "file=@test.jpg" \ -F "folder=/uploads" ``` Expect: HTTP 201 with JSON containing id, path, publicUrl, mimeType, dimensions 4. Test folder creation and listing - 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 After completion, create `.planning/phases/09-content-management/09-03b-SUMMARY.md`