FIX: Changed Content Type From Static mp4 To Input
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m18s

This commit is contained in:
FahdShalhoub
2026-08-16 23:56:21 +03:00
parent f26ea98b52
commit 4b719f712d
5 changed files with 104 additions and 35 deletions
+63 -16
View File
@@ -28,11 +28,23 @@ go vet ./...
``` ```
There are no test files in this repo (`cms`, `discovery`, or `infrastructure`) — don't assume a test suite exists. There are no test files in this repo (`cms`, `discovery`, or `infrastructure`) — don't assume a test suite exists.
Views are written as `.templ` files (github.com/a-h/templ) and compiled to `cms` is a JSON API only — it serves no HTML and has no static assets. The
`*_templ.go`. If you edit a `.templ` file, regenerate its Go code with the OpenAPI spec is generated from swaggo annotations on the handlers into
`templ generate` CLI before building (not installed in this environment by `cms/docs`, a committed, compiled-in Go package. If you add or change a
default — install via `go install github.com/a-h/templ/cmd/templ@v0.3.1020` handler, its annotation comments, or a request/response struct, regenerate it:
to match `go.mod`, or check for an existing binary first).
```bash
go install github.com/swaggo/swag/cmd/swag@v1.16.6 # match go.mod; not installed by default
swag init --generalInfo main.go --dir ./ --parseInternal --output ./docs
```
`--parseInternal` is required — the handlers live under `internal/`, which
swag skips without it. Keep the `swaggo/swag` version in `go.mod` and the CLI
in lockstep: `http-swagger/v2` transitively pulls a much older `swag` whose
`swag.Spec` struct lacks the `LeftDelim`/`RightDelim` fields newer generators
emit, and the build breaks outright if the two drift. (The CLI's `--version`
misreports itself as v1.16.4; `go version -m $(go env GOPATH)/bin/swag` gives
the real one.)
### infrastructure (Go, Pulumi, module `thamanyah`) ### infrastructure (Go, Pulumi, module `thamanyah`)
```bash ```bash
@@ -70,12 +82,29 @@ S3/MediaConvert or start the server. This is run as its own ECS container
before the main container starts (see infrastructure below), so `runServer` before the main container starts (see infrastructure below), so `runServer`
never runs migrations itself, only `AssertSuccessfulConnection`. never runs migrations itself, only `AssertSuccessfulConnection`.
Routes (`cms/main.go`): `GET /`, `GET /health`, `GET /videos/new`, Routes (`cms/main.go`): `GET /health`, `GET /api/categories`,
`POST /videos/presign`, `POST /videos`, static files under `/static/`. `POST /api/videos/presign`, `POST /api/videos`, plus Swagger UI at
`GET /swagger/` (`/swagger/doc.json` serves the spec). The UI assets are
embedded in the binary by `swaggo/files`, so nothing is read from disk and
nothing is fetched from a CDN at runtime.
Views live in `cms/internal/views` (templ components) with a shared Handlers live in `cms/internal/handlers``handlers.go` holds `Health` and
`layouts.Layout` wrapper; `types.go` holds view-model structs like the shared response writers, `videos.go` the categories and upload endpoints.
`VideoMetadata` used by the upload-success page. There is no view layer: the `internal/views` templ package, the `static/`
directory, and the htmx frontend were all removed when the service became a
JSON API, along with the `templ` dependency.
Error responses are RFC 9457 Problem Details objects
(`application/problem+json`), written by
`writeProblem(w, status, title, detail)`. `type` is always `"about:blank"`;
`title` is a short summary held identical across every occurrence of a given
problem, so clients can branch on it; `detail` is the only member that varies
with request data. Success responses go through `writeJSON`
(`application/json`). Both share `writeJSONContent`. Note this deviates
slightly from RFC 9457, which pairs an `about:blank` type with a title that is
just the HTTP status phrase — meaningful titles like these are supposed to
carry a real `type` URI. Adding per-problem type URIs is the conforming fix if
it ever matters.
### Data model (Postgres, `cms/internal/db/migrations/`) ### Data model (Postgres, `cms/internal/db/migrations/`)
- `videos` — one row per uploaded video: `title`, `description`, `tags` - `videos` — one row per uploaded video: `title`, `description`, `tags`
@@ -95,11 +124,12 @@ Views live in `cms/internal/views` (templ components) with a shared
and its `video_categories` links inside a single transaction. and its `video_categories` links inside a single transaction.
### Video upload → transcode pipeline ### Video upload → transcode pipeline
1. Browser calls `POST /videos/presign` → cms returns a presigned S3 `PUT` 1. Client calls `POST /api/videos/presign` → cms returns a presigned S3 `PUT`
URL for `raw-uploads-bucket`, key `videos/<random-hex>.<ext>`. URL for `raw-uploads-bucket`, key `videos/<random-hex>.<ext>`.
2. Browser `PUT`s the file directly to S3 (requires the bucket's CORS rule, 2. Client `PUT`s the file directly to S3 (from a browser this requires the
set up in `infrastructure/main.go`). bucket's CORS rule, set up in `infrastructure/main.go` — see Known gaps,
3. Browser calls `POST /videos` with the metadata + key → cms calls its allowed origin is now stale). The file never passes through cms.
3. Client calls `POST /api/videos` with the metadata + key → cms calls
`MediaConvertClient.QueueEncodingJob(key)`, submitting a MediaConvert job `MediaConvertClient.QueueEncodingJob(key)`, submitting a MediaConvert job
`s3://raw-uploads-bucket/<key>``s3://encoded-bucket/<key>` (H.264/AAC → `s3://raw-uploads-bucket/<key>``s3://encoded-bucket/<key>` (H.264/AAC →
MP4, QVBR rate control — QVBR requires `MaxBitrate` to be set explicitly), MP4, QVBR rate control — QVBR requires `MaxBitrate` to be set explicitly),
@@ -142,8 +172,9 @@ on boot if any required var is empty.
- **S3 raw uploads**: `raw-uploads-bucket` is a separate, private bucket for - **S3 raw uploads**: `raw-uploads-bucket` is a separate, private bucket for
pre-transcode uploads — deliberately kept apart from `encoded-bucket` so pre-transcode uploads — deliberately kept apart from `encoded-bucket` so
raw source video is never reachable through the public CDN. CORS is raw source video is never reachable through the public CDN. CORS is
scoped to `PUT` only, from the `cms` ALB's own origin (browser uploads scoped to `PUT` only, from the `cms` ALB's own origin — correct back when
directly via presigned URL). cms served the upload page itself, but stale now that it serves no UI (see
Known gaps).
- **IAM roles** — four distinct roles/users, each scoped narrowly, don't - **IAM roles** — four distinct roles/users, each scoped narrowly, don't
conflate them: conflate them:
- `ecs-task-execution-role` — shared by both services' ECS *agent* (image - `ecs-task-execution-role` — shared by both services' ECS *agent* (image
@@ -193,3 +224,19 @@ on boot if any required var is empty.
- `discovery` has infra provisioned (ECR repo, ECS service, ALB, Postgres - `discovery` has infra provisioned (ECR repo, ECS service, ALB, Postgres
DB/role) but no application code — it doesn't touch its database at all. DB/role) but no application code — it doesn't touch its database at all.
- No automated tests exist for `cms`, `discovery`, or `infrastructure`. - No automated tests exist for `cms`, `discovery`, or `infrastructure`.
- **No browser client can reach the API yet.** Two separate CORS gaps, both
left over from cms dropping its own UI: cms sends no CORS headers of its
own, and `raw-uploads-bucket`'s CORS rule in `infrastructure/main.go` still
allows only the `cms` ALB origin, so a frontend served from anywhere else
fails preflight on the direct S3 `PUT`. Both need the real frontend origin
before a browser client works end to end.
- `videos.size_bytes` is hardcoded to `60` in `CompleteVideoUpload` — the
request body carries no size field, so every row stores 60 and the API
hands that back in `sizeBytes`. Fixing it means adding `sizeBytes` to the
`POST /api/videos` request contract.
- The generated spec is Swagger 2.0, which has no per-response media type, so
error responses are documented as `application/json` even though they are
actually sent as `application/problem+json`. The schemas themselves are
right. `swag init --v3.1` would emit OpenAPI 3.1 and resolve it.
- `cms/uploads/` holds a stray `.mov` from an older local-disk upload path.
It is untracked, unreferenced by any code, and safe to delete.
+7 -3
View File
@@ -95,7 +95,7 @@ const docTemplate = `{
}, },
"/api/videos/presign": { "/api/videos/presign": {
"post": { "post": {
"description": "Step 1 of the upload flow. Returns a short-lived presigned S3 URL that the client PUTs the video file to directly, plus the storage key identifying it. Once the PUT succeeds, pass that same key to POST /api/videos to register the video and start transcoding. The file itself never passes through this API.", "description": "Step 1 of the upload flow. Returns a short-lived presigned S3 URL that the client PUTs the video file to directly, plus the storage key identifying it. The submitted contentType is signed into that URL, so the PUT must carry an identical Content-Type header or S3 rejects it as a signature mismatch. Once the PUT succeeds, pass the key to POST /api/videos to register the video and start transcoding. The file itself never passes through this API.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -108,7 +108,7 @@ const docTemplate = `{
"summary": "Create a presigned upload URL", "summary": "Create a presigned upload URL",
"parameters": [ "parameters": [
{ {
"description": "Name and media type of the file to be uploaded. contentType must be a video/* type.", "description": "Name and media type of the file to be uploaded. contentType must be exactly 'video/mp4' or 'video/quicktime'.",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -131,7 +131,7 @@ const docTemplate = `{
} }
}, },
"422": { "422": {
"description": "contentType was not a video/* media type", "description": "contentType was not 'video/mp4' or 'video/quicktime'",
"schema": { "schema": {
"$ref": "#/definitions/handlers.problemDetails" "$ref": "#/definitions/handlers.problemDetails"
} }
@@ -231,6 +231,10 @@ const docTemplate = `{
"properties": { "properties": {
"contentType": { "contentType": {
"type": "string", "type": "string",
"enum": [
"video/mp4",
"video/quicktime"
],
"example": "video/quicktime" "example": "video/quicktime"
}, },
"fileName": { "fileName": {
+7 -3
View File
@@ -88,7 +88,7 @@
}, },
"/api/videos/presign": { "/api/videos/presign": {
"post": { "post": {
"description": "Step 1 of the upload flow. Returns a short-lived presigned S3 URL that the client PUTs the video file to directly, plus the storage key identifying it. Once the PUT succeeds, pass that same key to POST /api/videos to register the video and start transcoding. The file itself never passes through this API.", "description": "Step 1 of the upload flow. Returns a short-lived presigned S3 URL that the client PUTs the video file to directly, plus the storage key identifying it. The submitted contentType is signed into that URL, so the PUT must carry an identical Content-Type header or S3 rejects it as a signature mismatch. Once the PUT succeeds, pass the key to POST /api/videos to register the video and start transcoding. The file itself never passes through this API.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -101,7 +101,7 @@
"summary": "Create a presigned upload URL", "summary": "Create a presigned upload URL",
"parameters": [ "parameters": [
{ {
"description": "Name and media type of the file to be uploaded. contentType must be a video/* type.", "description": "Name and media type of the file to be uploaded. contentType must be exactly 'video/mp4' or 'video/quicktime'.",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -124,7 +124,7 @@
} }
}, },
"422": { "422": {
"description": "contentType was not a video/* media type", "description": "contentType was not 'video/mp4' or 'video/quicktime'",
"schema": { "schema": {
"$ref": "#/definitions/handlers.problemDetails" "$ref": "#/definitions/handlers.problemDetails"
} }
@@ -224,6 +224,10 @@
"properties": { "properties": {
"contentType": { "contentType": {
"type": "string", "type": "string",
"enum": [
"video/mp4",
"video/quicktime"
],
"example": "video/quicktime" "example": "video/quicktime"
}, },
"fileName": { "fileName": {
+9 -5
View File
@@ -44,6 +44,9 @@ definitions:
handlers.presignRequest: handlers.presignRequest:
properties: properties:
contentType: contentType:
enum:
- video/mp4
- video/quicktime
example: video/quicktime example: video/quicktime
type: string type: string
fileName: fileName:
@@ -190,12 +193,13 @@ paths:
- application/json - application/json
description: Step 1 of the upload flow. Returns a short-lived presigned S3 URL description: Step 1 of the upload flow. Returns a short-lived presigned S3 URL
that the client PUTs the video file to directly, plus the storage key identifying that the client PUTs the video file to directly, plus the storage key identifying
it. Once the PUT succeeds, pass that same key to POST /api/videos to register it. The submitted contentType is signed into that URL, so the PUT must carry
the video and start transcoding. The file itself never passes through this an identical Content-Type header or S3 rejects it as a signature mismatch.
API. Once the PUT succeeds, pass the key to POST /api/videos to register the video
and start transcoding. The file itself never passes through this API.
parameters: parameters:
- description: Name and media type of the file to be uploaded. contentType must - description: Name and media type of the file to be uploaded. contentType must
be a video/* type. be exactly 'video/mp4' or 'video/quicktime'.
in: body in: body
name: request name: request
required: true required: true
@@ -213,7 +217,7 @@ paths:
schema: schema:
$ref: '#/definitions/handlers.problemDetails' $ref: '#/definitions/handlers.problemDetails'
"422": "422":
description: contentType was not a video/* media type description: contentType was not 'video/mp4' or 'video/quicktime'
schema: schema:
$ref: '#/definitions/handlers.problemDetails' $ref: '#/definitions/handlers.problemDetails'
"500": "500":
+18 -8
View File
@@ -47,9 +47,19 @@ func ListCategories(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, categoriesResponse{Categories: categories}) writeJSON(w, http.StatusOK, categoriesResponse{Categories: categories})
} }
// allowedUploadContentTypes is the set of media types accepted for upload,
// keyed by the exact string a client must send. Matched verbatim rather than
// normalised: the value is signed into the presigned URL, so rewriting it
// here would leave the client PUTting a header that no longer matches the
// signature.
var allowedUploadContentTypes = map[string]struct{}{
"video/mp4": {}, // .mp4
"video/quicktime": {}, // .mov
}
type presignRequest struct { type presignRequest struct {
FileName string `json:"fileName" example:"interview-cut.mov"` FileName string `json:"fileName" example:"interview-cut.mov"`
ContentType string `json:"contentType" example:"video/quicktime"` ContentType string `json:"contentType" enums:"video/mp4,video/quicktime" example:"video/quicktime"`
} }
type presignResponse struct { type presignResponse struct {
@@ -60,14 +70,14 @@ type presignResponse struct {
// PresignVideoUpload issues a presigned S3 PUT URL for a video upload. // PresignVideoUpload issues a presigned S3 PUT URL for a video upload.
// //
// @Summary Create a presigned upload URL // @Summary Create a presigned upload URL
// @Description Step 1 of the upload flow. Returns a short-lived presigned S3 URL that the client PUTs the video file to directly, plus the storage key identifying it. Once the PUT succeeds, pass that same key to POST /api/videos to register the video and start transcoding. The file itself never passes through this API. // @Description Step 1 of the upload flow. Returns a short-lived presigned S3 URL that the client PUTs the video file to directly, plus the storage key identifying it. The submitted contentType is signed into that URL, so the PUT must carry an identical Content-Type header or S3 rejects it as a signature mismatch. Once the PUT succeeds, pass the key to POST /api/videos to register the video and start transcoding. The file itself never passes through this API.
// @Tags videos // @Tags videos
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body presignRequest true "Name and media type of the file to be uploaded. contentType must be a video/* type." // @Param request body presignRequest true "Name and media type of the file to be uploaded. contentType must be exactly 'video/mp4' or 'video/quicktime'."
// @Success 200 {object} presignResponse // @Success 200 {object} presignResponse
// @Failure 400 {object} problemDetails "Request body was not valid JSON" // @Failure 400 {object} problemDetails "Request body was not valid JSON"
// @Failure 422 {object} problemDetails "contentType was not a video/* media type" // @Failure 422 {object} problemDetails "contentType was not 'video/mp4' or 'video/quicktime'"
// @Failure 500 {object} problemDetails "Upload URL could not be issued" // @Failure 500 {object} problemDetails "Upload URL could not be issued"
// @Router /api/videos/presign [post] // @Router /api/videos/presign [post]
func PresignVideoUpload(w http.ResponseWriter, r *http.Request) { func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
@@ -80,9 +90,9 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
if !strings.HasPrefix(req.ContentType, "video/") { if _, ok := allowedUploadContentTypes[req.ContentType]; !ok {
writeProblem(w, http.StatusUnprocessableEntity, "Unsupported file type. Please upload a video.", writeProblem(w, http.StatusUnprocessableEntity, "Unsupported file type. Please upload an MP4 or MOV video.",
fmt.Sprintf("The 'contentType' field was %q, but only video/* media types can be uploaded. Set it to the file's own MIME type, for example 'video/mp4'.", req.ContentType)) fmt.Sprintf("The 'contentType' field was %q, but only 'video/mp4' (.mp4) and 'video/quicktime' (.mov) are accepted. Send one of those two values exactly — it is signed into the upload URL, so the PUT must use the identical header.", req.ContentType))
return return
} }
@@ -94,7 +104,7 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
} }
key := videoUploadPrefix + "/" + filename key := videoUploadPrefix + "/" + filename
presignedURL, err := services.S3Client.GetPresignedURL(r.Context(), key, "video/mp4", time.Hour) presignedURL, err := services.S3Client.GetPresignedURL(r.Context(), key, req.ContentType, time.Hour)
if err != nil { if err != nil {
writeProblem(w, http.StatusInternalServerError, "Could not prepare upload.", writeProblem(w, http.StatusInternalServerError, "Could not prepare upload.",
"A presigned upload URL could not be issued for the storage bucket. This is a server-side fault; no upload slot was reserved, so retry the request.") "A presigned upload URL could not be issued for the storage bucket. This is a server-side fault; no upload slot was reserved, so retry the request.")