FIX: Changed Content Type From Static mp4 To Input
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m18s
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m18s
This commit is contained in:
@@ -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.
|
||||
|
||||
Views are written as `.templ` files (github.com/a-h/templ) and compiled to
|
||||
`*_templ.go`. If you edit a `.templ` file, regenerate its Go code with the
|
||||
`templ generate` CLI before building (not installed in this environment by
|
||||
default — install via `go install github.com/a-h/templ/cmd/templ@v0.3.1020`
|
||||
to match `go.mod`, or check for an existing binary first).
|
||||
`cms` is a JSON API only — it serves no HTML and has no static assets. The
|
||||
OpenAPI spec is generated from swaggo annotations on the handlers into
|
||||
`cms/docs`, a committed, compiled-in Go package. If you add or change a
|
||||
handler, its annotation comments, or a request/response struct, regenerate it:
|
||||
|
||||
```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`)
|
||||
```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`
|
||||
never runs migrations itself, only `AssertSuccessfulConnection`.
|
||||
|
||||
Routes (`cms/main.go`): `GET /`, `GET /health`, `GET /videos/new`,
|
||||
`POST /videos/presign`, `POST /videos`, static files under `/static/`.
|
||||
Routes (`cms/main.go`): `GET /health`, `GET /api/categories`,
|
||||
`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
|
||||
`layouts.Layout` wrapper; `types.go` holds view-model structs like
|
||||
`VideoMetadata` used by the upload-success page.
|
||||
Handlers live in `cms/internal/handlers` — `handlers.go` holds `Health` and
|
||||
the shared response writers, `videos.go` the categories and upload endpoints.
|
||||
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/`)
|
||||
- `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.
|
||||
|
||||
### 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>`.
|
||||
2. Browser `PUT`s the file directly to S3 (requires the bucket's CORS rule,
|
||||
set up in `infrastructure/main.go`).
|
||||
3. Browser calls `POST /videos` with the metadata + key → cms calls
|
||||
2. Client `PUT`s the file directly to S3 (from a browser this requires the
|
||||
bucket's CORS rule, set up in `infrastructure/main.go` — see Known gaps,
|
||||
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
|
||||
`s3://raw-uploads-bucket/<key>` → `s3://encoded-bucket/<key>` (H.264/AAC →
|
||||
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
|
||||
pre-transcode uploads — deliberately kept apart from `encoded-bucket` so
|
||||
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
|
||||
directly via presigned URL).
|
||||
scoped to `PUT` only, from the `cms` ALB's own origin — correct back when
|
||||
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
|
||||
conflate them:
|
||||
- `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
|
||||
DB/role) but no application code — it doesn't touch its database at all.
|
||||
- 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
@@ -95,7 +95,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/videos/presign": {
|
||||
"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": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -108,7 +108,7 @@ const docTemplate = `{
|
||||
"summary": "Create a presigned upload URL",
|
||||
"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",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -131,7 +131,7 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "contentType was not a video/* media type",
|
||||
"description": "contentType was not 'video/mp4' or 'video/quicktime'",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.problemDetails"
|
||||
}
|
||||
@@ -231,6 +231,10 @@ const docTemplate = `{
|
||||
"properties": {
|
||||
"contentType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"video/mp4",
|
||||
"video/quicktime"
|
||||
],
|
||||
"example": "video/quicktime"
|
||||
},
|
||||
"fileName": {
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
"/api/videos/presign": {
|
||||
"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": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -101,7 +101,7 @@
|
||||
"summary": "Create a presigned upload URL",
|
||||
"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",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
@@ -124,7 +124,7 @@
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "contentType was not a video/* media type",
|
||||
"description": "contentType was not 'video/mp4' or 'video/quicktime'",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.problemDetails"
|
||||
}
|
||||
@@ -224,6 +224,10 @@
|
||||
"properties": {
|
||||
"contentType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"video/mp4",
|
||||
"video/quicktime"
|
||||
],
|
||||
"example": "video/quicktime"
|
||||
},
|
||||
"fileName": {
|
||||
|
||||
@@ -44,6 +44,9 @@ definitions:
|
||||
handlers.presignRequest:
|
||||
properties:
|
||||
contentType:
|
||||
enum:
|
||||
- video/mp4
|
||||
- video/quicktime
|
||||
example: video/quicktime
|
||||
type: string
|
||||
fileName:
|
||||
@@ -190,12 +193,13 @@ paths:
|
||||
- application/json
|
||||
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.
|
||||
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.
|
||||
parameters:
|
||||
- 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
|
||||
name: request
|
||||
required: true
|
||||
@@ -213,7 +217,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.problemDetails'
|
||||
"422":
|
||||
description: contentType was not a video/* media type
|
||||
description: contentType was not 'video/mp4' or 'video/quicktime'
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.problemDetails'
|
||||
"500":
|
||||
|
||||
@@ -47,9 +47,19 @@ func ListCategories(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
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 {
|
||||
@@ -60,14 +70,14 @@ type presignResponse struct {
|
||||
// PresignVideoUpload issues a presigned S3 PUT URL for a video upload.
|
||||
//
|
||||
// @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
|
||||
// @Accept 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
|
||||
// @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"
|
||||
// @Router /api/videos/presign [post]
|
||||
func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -80,9 +90,9 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(req.ContentType, "video/") {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unsupported file type. Please upload a 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))
|
||||
if _, ok := allowedUploadContentTypes[req.ContentType]; !ok {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unsupported file type. Please upload an MP4 or MOV video.",
|
||||
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
|
||||
}
|
||||
|
||||
@@ -94,7 +104,7 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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 {
|
||||
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.")
|
||||
|
||||
Reference in New Issue
Block a user