Files
Jakub Zych edbee885ac 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.
2026-02-05 15:41:50 +01:00

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
09-03
build.mill
summercms/src/content/media/ImageProcessor.scala
summercms/src/api/admin/MediaRoutes.scala
summercms/src/api/Routes.scala
true
truths artifacts key_links
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
path provides contains
summercms/src/content/media/ImageProcessor.scala Image resize/crop operations trait ImageProcessor
path provides contains
summercms/src/api/admin/MediaRoutes.scala Admin media API routes object MediaRoutes
from to via pattern
MediaRoutes MediaLibrary.upload HTTP multipart upload MediaLibrary.*upload
from to via pattern
MediaRoutes.resize ImageProcessor.resize image processing call 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.

<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(_))
  }
`./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:
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:

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

<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>
After completion, create `.planning/phases/09-content-management/09-03b-SUMMARY.md`