Files
summercms-initial-research/.planning/phases/09-content-management/09-03-PLAN.md
Jakub Zych dca89e10cd docs(09): create phase plan
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
2026-02-05 15:33:51 +01:00

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
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
true
truths artifacts key_links
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
path provides contains
summercms/src/content/media/StorageBackend.scala Storage abstraction trait trait StorageBackend
path provides contains
summercms/src/content/media/MediaLibrary.scala Media library service trait MediaLibrary
path provides contains
summercms/src/content/media/ImageProcessor.scala Image resize/crop operations trait ImageProcessor
path provides contains
summercms/resources/db/migration/V010__media_library.sql Media metadata tables CREATE TABLE media_files
from to via pattern
MediaLibrary.upload StorageBackend.put stream to storage storage.put
from to via pattern
MediaRoutes MediaLibrary.upload HTTP multipart upload 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.

<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.

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:

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:
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

<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>
After completion, create `.planning/phases/09-content-management/09-03-SUMMARY.md`