--- 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" --- 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. @/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 @summercms/src/db/QuillContext.scala @summercms/src/api/Routes.scala @build.mill Task 1: Add dependencies and create storage backend abstraction 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 **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. `./mill summercms.compile` succeeds with new dependencies LocalStorage can write/read files (unit test or manual verification) StorageBackend trait with LocalStorage and S3Storage implementations. MediaItem model for file metadata. Task 2: Implement ImageProcessor and MediaLibrary service summercms/src/content/media/ImageProcessor.scala summercms/src/content/media/MediaLibrary.scala summercms/resources/db/migration/V010__media_library.sql **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 `./mill summercms.compile` succeeds Migration file exists ImageProcessor can resize a test image (manual test with sample JPEG) ImageProcessor provides resize/crop/thumbnail using Scrimage. MediaLibrary service handles uploads, metadata tracking, folder management. Task 3: 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 with curl multipart form Admin media API routes provide upload, list, resize, crop, delete, folder operations. Routes integrated with main application. 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 - 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 After completion, create `.planning/phases/09-content-management/09-03-SUMMARY.md`