--- 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/resources/db/migration/V010__media_library.sql autonomous: true must_haves: truths: - "Admin can upload files to media library" - "Files stored via configurable storage backend (local or S3)" - "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/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" --- Implement media library core with storage abstraction Purpose: Enable admin to upload and manage media files. Storage backend is pluggable (local filesystem for dev, S3 for production). This plan focuses on the core storage and library service. Output: StorageBackend trait with Local/S3 implementations, MediaLibrary service, database migration for metadata. @/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 @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 // 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 MediaLibrary service and database migration summercms/src/content/media/MediaLibrary.scala summercms/resources/db/migration/V010__media_library.sql **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 - Insert metadata into database - Return MediaItem with public URL `./mill summercms.compile` succeeds Migration file exists and is syntactically valid MediaLibrary service handles uploads and metadata tracking. Database migration creates tables for files and folders. 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 folder operations: create folder, list contents - Files upload via MediaLibrary to configurable storage backend - LocalStorage writes to local filesystem with path traversal protection - S3Storage integrates with AWS S3 (compile-time only, runtime optional) - Media metadata tracked in database - Folder hierarchy supported After completion, create `.planning/phases/09-content-management/09-03-SUMMARY.md`