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
422 lines
15 KiB
Markdown
422 lines
15 KiB
Markdown
---
|
|
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>
|