Phase 09: Content Management - 6 plan(s) in 3 wave(s) - Wave 1: 09-01 (CMS pages/layouts), 09-03 (media library) - parallel - Wave 2: 09-02 (component embedding), 09-04 (revisions), 09-05 (menus) - Wave 3: 09-06 (hot reload) - Ready for execution
15 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 | 03 | execute | 1 |
|
true |
|
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.
<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 @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:
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.
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:
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.compilesucceeds 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.
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.compilesucceeds 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.
<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>