diff --git a/.gitea/workflows/infrastructure-deploy.yml b/.gitea/workflows/infrastructure-deploy.yml new file mode 100644 index 0000000..9959405 --- /dev/null +++ b/.gitea/workflows/infrastructure-deploy.yml @@ -0,0 +1,52 @@ +name: Deploy Infrastructure + +on: + push: + branches: [main] + paths: + - "infrastructure/**" + - ".gitea/workflows/infrastructure-deploy.yml" + +# Pulumi state isn't safe to update concurrently; serialize runs and let a +# newer push supersede one still queued (not one already applying). +concurrency: + group: pulumi-thamanyah-main + cancel-in-progress: false + +env: + AWS_REGION: us-east-1 + +jobs: + pulumi-up: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Broad infra-provisioning credentials, distinct from the narrowly + # scoped gitea-ci-user used by the app deploy workflows (ECR push + + # ecs:UpdateService only). Pulumi needs to create/update IAM, RDS, + # ECS, CloudFront, etc., so this identity is intentionally wider. + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.PULUMI_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.PULUMI_AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: infrastructure/go.mod + + # Stack name assumes the runner is logged into the same Pulumi Cloud + # org as `pulumi.yaml`'s default; if PULUMI_ACCESS_TOKEN's org differs, + # qualify this as "/main" instead. + - name: Pulumi up + uses: pulumi/actions@v6 + with: + command: up + stack-name: main + work-dir: infrastructure + env: + PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }} diff --git a/cms/internal/handlers/videos.go b/cms/internal/handlers/videos.go index ac0e74d..65ecf74 100644 --- a/cms/internal/handlers/videos.go +++ b/cms/internal/handlers/videos.go @@ -9,6 +9,7 @@ import ( "log" "net/http" "path/filepath" + "slices" "strconv" "strings" "thamanyah/cms/v2/internal/api" @@ -47,11 +48,6 @@ func ListCategories(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, api.CategoriesResponse{Categories: body}) } -// 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 @@ -147,18 +143,13 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { return } - categoryIDs, unknown, err := resolveCategoryIDs(r.Context(), req.CategoryIDs) + err := resolveCategoryIDs(r.Context(), req.CategoryIDs) if err != nil { log.Printf("Something Went Wrong Loading Categories: %s", err) writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading Categories", "The submitted category ids could not be checked against the database, so no video record was created. This is a server-side fault; the uploaded file is still in storage, so retry this request with the same 'key'.") return } - if len(unknown) > 0 { - writeProblem(w, http.StatusUnprocessableEntity, "Unknown category.", - fmt.Sprintf("The 'categoryIds' field referenced %s, which do not exist. Send ids taken from the 'id' field of GET /api/categories.", formatCategoryIDs(unknown))) - return - } jobID, err := services.MediaConvertClient.QueueEncodingJob(r.Context(), key) if err != nil { @@ -171,7 +162,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { video, err := services.DB.CreateVideo(r.Context(), services.Video{ Title: title, Description: strings.TrimSpace(req.Description), - CategoryIDs: categoryIDs, + CategoryIDs: req.CategoryIDs, Tags: strings.TrimSpace(req.Tags), FileName: strings.TrimSpace(req.FileName), StorageKey: key, @@ -202,40 +193,19 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { }) } -// resolveCategoryIDs deduplicates the submitted category ids, preserving the -// order they arrived in, and splits them into ids that exist and ids that do -// not. Checking them here rather than leaning on the video_categories foreign -// key keeps an unknown id from surfacing only after a transcoding job has -// already been queued — and the duplicate pass keeps a repeated id from -// tripping the join table's composite primary key. Callers reject an empty -// list before calling, so a non-empty submitted list always yields either at -// least one known id or at least one unknown one. -func resolveCategoryIDs(ctx context.Context, submitted []int16) (known, unknown []int16, err error) { - known = make([]int16, 0, len(submitted)) - - categories, err := services.DB.ListCategories(ctx) +func resolveCategoryIDs(ctx context.Context, submitted []int16) (err error) { + categoryIDS, err := services.DB.ListCategoriesIDs(ctx) if err != nil { - return nil, nil, err - } - exists := make(map[int16]struct{}, len(categories)) - for _, category := range categories { - exists[category.ID] = struct{}{} + return err } - seen := make(map[int16]struct{}, len(submitted)) - for _, id := range submitted { - if _, duplicate := seen[id]; duplicate { - continue - } - seen[id] = struct{}{} - - if _, ok := exists[id]; ok { - known = append(known, id) - } else { - unknown = append(unknown, id) + for _, submittedID := range submitted { + if !slices.Contains(categoryIDS, submittedID) { + return fmt.Errorf("Incorrect Category IDs") } } - return known, unknown, nil + + return nil } func formatCategoryIDs(ids []int16) string { diff --git a/cms/internal/services/db.go b/cms/internal/services/db.go index 8d8bbc2..fac6cdb 100644 --- a/cms/internal/services/db.go +++ b/cms/internal/services/db.go @@ -35,6 +35,7 @@ type Video struct { type DBClient interface { CreateVideo(ctx context.Context, v Video) (Video, error) ListCategories(ctx context.Context) ([]Category, error) + ListCategoriesIDs(ctx context.Context) ([]int16, error) } type DBConcrete struct { @@ -102,6 +103,24 @@ func (svc DBConcrete) ListCategories(ctx context.Context) ([]Category, error) { return categories, rows.Err() } +func (svc DBConcrete) ListCategoriesIDs(ctx context.Context) ([]int16, error) { + rows, err := svc.SQLDB.QueryContext(ctx, `SELECT id FROM categories`) + if err != nil { + return nil, err + } + defer rows.Close() + + var categories []int16 + for rows.Next() { + var category int16 + if err := rows.Scan(&category); err != nil { + return nil, err + } + categories = append(categories, category) + } + return categories, rows.Err() +} + func (svc DBConcrete) Migrate() { if err := db.Migrate(svc.SQLDB); err != nil { panic(err) diff --git a/infrastructure/thamanyah b/infrastructure/thamanyah new file mode 100755 index 0000000..b4914f1 Binary files /dev/null and b/infrastructure/thamanyah differ