REFACTOR: Optimized Catagory IDs INput Validation
Build, Push and Deploy CMS / build-push-deploy (push) Successful in 2m47s
Deploy Infrastructure / pulumi-up (push) Failing after 26s

This commit is contained in:
FahdShalhoub
2026-08-17 01:17:08 +03:00
parent 56a2992206
commit b77afbfed6
4 changed files with 82 additions and 41 deletions
@@ -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 "<org>/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 }}
+11 -41
View File
@@ -9,6 +9,7 @@ import (
"log" "log"
"net/http" "net/http"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
"thamanyah/cms/v2/internal/api" "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}) 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{}{ var allowedUploadContentTypes = map[string]struct{}{
"video/mp4": {}, // .mp4 "video/mp4": {}, // .mp4
"video/quicktime": {}, // .mov "video/quicktime": {}, // .mov
@@ -147,18 +143,13 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
return return
} }
categoryIDs, unknown, err := resolveCategoryIDs(r.Context(), req.CategoryIDs) err := resolveCategoryIDs(r.Context(), req.CategoryIDs)
if err != nil { if err != nil {
log.Printf("Something Went Wrong Loading Categories: %s", err) log.Printf("Something Went Wrong Loading Categories: %s", err)
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading Categories", 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'.") "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 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) jobID, err := services.MediaConvertClient.QueueEncodingJob(r.Context(), key)
if err != nil { if err != nil {
@@ -171,7 +162,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
video, err := services.DB.CreateVideo(r.Context(), services.Video{ video, err := services.DB.CreateVideo(r.Context(), services.Video{
Title: title, Title: title,
Description: strings.TrimSpace(req.Description), Description: strings.TrimSpace(req.Description),
CategoryIDs: categoryIDs, CategoryIDs: req.CategoryIDs,
Tags: strings.TrimSpace(req.Tags), Tags: strings.TrimSpace(req.Tags),
FileName: strings.TrimSpace(req.FileName), FileName: strings.TrimSpace(req.FileName),
StorageKey: key, StorageKey: key,
@@ -202,40 +193,19 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
}) })
} }
// resolveCategoryIDs deduplicates the submitted category ids, preserving the func resolveCategoryIDs(ctx context.Context, submitted []int16) (err error) {
// order they arrived in, and splits them into ids that exist and ids that do categoryIDS, err := services.DB.ListCategoriesIDs(ctx)
// 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)
if err != nil { if err != nil {
return nil, nil, err return err
}
exists := make(map[int16]struct{}, len(categories))
for _, category := range categories {
exists[category.ID] = struct{}{}
} }
seen := make(map[int16]struct{}, len(submitted)) for _, submittedID := range submitted {
for _, id := range submitted { if !slices.Contains(categoryIDS, submittedID) {
if _, duplicate := seen[id]; duplicate { return fmt.Errorf("Incorrect Category IDs")
continue
}
seen[id] = struct{}{}
if _, ok := exists[id]; ok {
known = append(known, id)
} else {
unknown = append(unknown, id)
} }
} }
return known, unknown, nil
return nil
} }
func formatCategoryIDs(ids []int16) string { func formatCategoryIDs(ids []int16) string {
+19
View File
@@ -35,6 +35,7 @@ type Video struct {
type DBClient interface { type DBClient interface {
CreateVideo(ctx context.Context, v Video) (Video, error) CreateVideo(ctx context.Context, v Video) (Video, error)
ListCategories(ctx context.Context) ([]Category, error) ListCategories(ctx context.Context) ([]Category, error)
ListCategoriesIDs(ctx context.Context) ([]int16, error)
} }
type DBConcrete struct { type DBConcrete struct {
@@ -102,6 +103,24 @@ func (svc DBConcrete) ListCategories(ctx context.Context) ([]Category, error) {
return categories, rows.Err() 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() { func (svc DBConcrete) Migrate() {
if err := db.Migrate(svc.SQLDB); err != nil { if err := db.Migrate(svc.SQLDB); err != nil {
panic(err) panic(err)
BIN
View File
Binary file not shown.