---
phase: 09-content-management
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
  - build.mill
  - summercms/src/content/media/StorageBackend.scala
  - summercms/src/content/media/LocalStorage.scala
  - 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:
    - path: "summercms/src/content/media/StorageBackend.scala"
      provides: "Storage abstraction trait"
      contains: "trait StorageBackend"
    - 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"
  key_links:
    - from: "MediaLibrary.upload"
      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

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.

Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service, ImageProcessor for resize/crop, database migration for metadata, admin upload routes.
</objective>

<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/09-content-management/09-RESEARCH.md
@summercms/src/db/QuillContext.scala
@summercms/src/api/Routes.scala
@build.mill
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add dependencies and create storage backend abstraction</name>
  <files>
    build.mill
    summercms/src/content/media/StorageBackend.scala
    summercms/src/content/media/LocalStorage.scala
    summercms/src/content/media/S3Storage.scala
    summercms/src/content/media/MediaItem.scala
  </files>
  <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"
```

**MediaItem.scala:**
```scala
case class MediaItem(
  id: Long,
  path: String,           // Storage path: "uploads/2024/01/image.jpg"
  publicUrl: String,      // Public access URL
  fileName: String,       // Original file name
  size: Long,
  mimeType: String,
  folder: String,         // Folder path: "uploads/2024/01"
  itemType: MediaItemType,
  width: Option[Int],     // For images
  height: Option[Int],    // For images
  createdAt: Instant,
  updatedAt: Instant
)

enum MediaItemType:
  case File, Image, Video, Audio, Document

object MediaItemType:
  def fromMimeType(mime: String): MediaItemType = mime match
    case m if m.startsWith("image/") => Image
    case m if m.startsWith("video/") => Video
    case m if m.startsWith("audio/") => Audio
    case m if m.startsWith("application/pdf") => Document
    // ... more mappings
    case _ => File
```

**StorageBackend.scala:**
```scala
trait StorageBackend:
  def put(path: String, content: ZStream[Any, Throwable, Byte], contentType: String): IO[StorageError, Unit]
  def get(path: String): IO[StorageError, ZStream[Any, Throwable, Byte]]
  def delete(path: String): IO[StorageError, Unit]
  def exists(path: String): IO[StorageError, Boolean]
  def url(path: String): IO[StorageError, String]
  def list(folder: String): IO[StorageError, List[String]]

enum StorageError:
  case NotFound(path: String)
  case AccessDenied(path: String)
  case WriteError(path: String, cause: Throwable)
  case ReadError(path: String, cause: Throwable)
```

**LocalStorage.scala:**
Implementation using java.nio.file:
- basePath: Path (e.g., /var/www/media)
- publicUrlPrefix: String (e.g., /media)
- put: Create directories, stream to file
- get: Stream from file
- delete: Files.delete with existence check
- url: Combine publicUrlPrefix with path
- Validate paths don't escape basePath (security: prevent path traversal)

**S3Storage.scala:**
Implementation using zio-s3:
- bucket: String
- region: String
- Provide ZLayer for S3 client
- put: Use putObject with streaming
- get: Use getObject
- delete: Use deleteObject
- url: Generate public URL or presigned URL
- List uses listObjectsV2

Note: S3 implementation should be optional - compile without AWS credentials. Use config to select backend.
  </action>
  <verify>
`./mill summercms.compile` succeeds with new dependencies
LocalStorage can write/read files (unit test or manual verification)
  </verify>
  <done>
StorageBackend trait with LocalStorage and S3Storage implementations. MediaItem model for file metadata.
  </done>
</task>

<task type="auto">
  <name>Task 2: Implement ImageProcessor and MediaLibrary service</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 (
  id BIGSERIAL PRIMARY KEY,
  path VARCHAR(500) UNIQUE NOT NULL,
  file_name VARCHAR(255) NOT NULL,
  size BIGINT NOT NULL,
  mime_type VARCHAR(100) NOT NULL,
  folder VARCHAR(500) NOT NULL,
  item_type VARCHAR(20) NOT NULL,
  width INT,
  height INT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE media_folders (
  id BIGSERIAL PRIMARY KEY,
  path VARCHAR(500) UNIQUE NOT NULL,
  name VARCHAR(255) NOT NULL,
  parent_path VARCHAR(500),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_media_files_folder ON media_files(folder);
CREATE INDEX idx_media_files_type ON media_files(item_type);
CREATE INDEX idx_media_folders_parent ON media_folders(parent_path);
```

**MediaLibrary.scala:**
```scala
trait MediaLibrary:
  def upload(folder: String, fileName: String, content: ZStream[Any, Throwable, Byte], contentType: String): IO[MediaError, MediaItem]
  def list(folder: String, filter: Option[MediaItemType] = None, page: Int = 1, perPage: Int = 50): IO[MediaError, MediaPage]
  def get(id: Long): IO[MediaError, Option[MediaItem]]
  def delete(ids: List[Long]): IO[MediaError, Unit]
  def move(id: Long, newFolder: String): IO[MediaError, MediaItem]
  def createFolder(path: String): IO[MediaError, MediaFolder]
  def listFolders(parent: Option[String]): IO[MediaError, List[MediaFolder]]

case class MediaPage(items: List[MediaItem], total: Long, page: Int, perPage: Int)
case class MediaFolder(id: Long, path: String, name: String, parentPath: Option[String])
```

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)
  </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.
  </done>
</task>

</tasks>

<verification>
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
</verification>

<success_criteria>
- Files upload via multipart POST to configurable storage backend
- LocalStorage writes to local filesystem
- 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
</success_criteria>

<output>
After completion, create `.planning/phases/09-content-management/09-03-SUMMARY.md`
</output>
