REFACTOR: Optimized Catagory IDs INput Validation
This commit is contained in:
@@ -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 }}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user