227 lines
10 KiB
Go
227 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"thamanyah/cms/v2/internal/api"
|
|
"thamanyah/cms/v2/internal/db/repositories"
|
|
"thamanyah/cms/v2/internal/models"
|
|
"thamanyah/cms/v2/internal/services"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
videoUploadPrefix = "videos"
|
|
uploadURLExpiry = 60 * time.Minute
|
|
maxJSONBodySize = 1 << 20 // 1 MiB
|
|
)
|
|
|
|
// ListCategories returns the categories a video may be assigned to.
|
|
//
|
|
// @Summary List video categories
|
|
// @Description Returns the fixed lookup set of categories a video can belong to, each with the id used to reference it. The `id` values from this list are the only ones accepted in the `categoryIds` field of POST /api/videos.
|
|
// @Tags categories
|
|
// @Produce json
|
|
// @Success 200 {object} api.CategoriesResponse
|
|
// @Failure 500 {object} api.ProblemDetails "Categories could not be read from the database"
|
|
// @Router /api/categories [get]
|
|
func ListCategories(w http.ResponseWriter, r *http.Request) {
|
|
categories, err := repositories.CatagoriesRepo.ListCategories(r.Context())
|
|
if err != nil {
|
|
log.Printf("Something Went Wrong Loading Categories: %s", err)
|
|
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Loading Categories",
|
|
"The list of video categories could not be read from the database. This is a server-side fault and the request was not processed; retrying in a few moments may succeed.")
|
|
return
|
|
}
|
|
|
|
body := make([]api.Category, 0, len(categories))
|
|
for _, category := range categories {
|
|
body = append(body, api.Category{ID: category.ID, Name: category.Name})
|
|
}
|
|
writeJSON(w, http.StatusOK, api.CategoriesResponse{Categories: body})
|
|
}
|
|
|
|
var allowedUploadContentTypes = map[string]struct{}{
|
|
"video/mp4": {}, // .mp4
|
|
"video/quicktime": {}, // .mov
|
|
}
|
|
|
|
// 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. 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 api.PresignRequest true "Name and media type of the file to be uploaded. contentType must be exactly 'video/mp4' or 'video/quicktime'."
|
|
// @Success 200 {object} api.PresignResponse
|
|
// @Failure 400 {object} api.ProblemDetails "Request body was not valid JSON"
|
|
// @Failure 422 {object} api.ProblemDetails "contentType was not 'video/mp4' or 'video/quicktime'"
|
|
// @Failure 500 {object} api.ProblemDetails "Upload URL could not be issued"
|
|
// @Router /api/videos/presign [post]
|
|
func PresignVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize)
|
|
|
|
var req api.PresignRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Invalid request.",
|
|
fmt.Sprintf("The request body could not be parsed as JSON (%s). Send an object with the string fields 'fileName' and 'contentType', and keep the body under 1 MiB.", err))
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
filename, err := randomFilename(req.FileName)
|
|
if err != nil {
|
|
writeProblem(w, http.StatusInternalServerError, "Could not prepare upload.",
|
|
"A unique storage key could not be generated because the server's random source failed. This is a server-side fault; retry the request.")
|
|
return
|
|
}
|
|
key := videoUploadPrefix + "/" + filename
|
|
|
|
presignedURL, err := services.S3Client.GetPresignedURL(r.Context(), key, req.ContentType, uploadURLExpiry)
|
|
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.")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, api.PresignResponse{UploadURL: presignedURL, Key: key})
|
|
}
|
|
|
|
// CompleteVideoUpload registers an uploaded video and queues it for transcoding.
|
|
//
|
|
// @Summary Register an uploaded video
|
|
// @Description Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status "processing" — one of the states "processing", "ready" or "failed" — though nothing currently moves it on once transcoding finishes.
|
|
// @Tags videos
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body api.CompleteRequest true "Video metadata plus the storage key from the presign step. title, key and categoryIds are required; categoryIds must hold at least one id from GET /api/categories."
|
|
// @Success 201 {object} api.CompleteResponse
|
|
// @Failure 400 {object} api.ProblemDetails "Request body was not valid JSON"
|
|
// @Failure 422 {object} api.ProblemDetails "title was empty, key was missing or not a key issued by this API, or categoryIds was empty or contained an id that does not exist"
|
|
// @Failure 500 {object} api.ProblemDetails "Categories could not be read, the transcoding job could not be queued, or the video record could not be saved"
|
|
// @Router /api/videos [post]
|
|
func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize)
|
|
|
|
var req api.CompleteRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeProblem(w, http.StatusBadRequest, "Invalid request.",
|
|
fmt.Sprintf("The request body could not be parsed as JSON (%s). Send an object containing at least 'title' and 'key', and keep the body under 1 MiB.", err))
|
|
return
|
|
}
|
|
|
|
title := strings.TrimSpace(req.Title)
|
|
if title == "" {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Title is required.",
|
|
"The 'title' field was absent or contained only whitespace. Every video needs a non-empty title.")
|
|
return
|
|
}
|
|
|
|
key := strings.TrimSpace(req.Key)
|
|
if key == "" || !strings.HasPrefix(key, videoUploadPrefix+"/") {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "A video file is required.",
|
|
fmt.Sprintf("The 'key' field was %q, which is not a storage key issued by this API. Use the 'key' returned by POST /api/videos/presign, after the file has been PUT to the accompanying upload URL.", req.Key))
|
|
return
|
|
}
|
|
|
|
if len(req.CategoryIDs) == 0 {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "At least one category is required.",
|
|
"The 'categoryIds' field was absent or empty. Every video belongs to at least one category; send ids taken from the 'id' field of GET /api/categories.")
|
|
return
|
|
}
|
|
|
|
exists := services.S3Client.DoesFileExist(r.Context(), key)
|
|
if exists {
|
|
writeProblem(w, http.StatusUnprocessableEntity, "Failed to find video in storage",
|
|
"The video file that was supposed to be uploaded via the url sent in the PresignVideoUpload endpoint was not uploaded before calling this endpoint")
|
|
return
|
|
}
|
|
|
|
err := validateCatagoryIDS(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
|
|
}
|
|
|
|
jobID, err := services.MediaConvertClient.QueueEncodingJob(r.Context(), key)
|
|
if err != nil {
|
|
log.Printf("Something Went Wrong On Creation Of Transcoding Job: %s", err)
|
|
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong On Creation Of Transcoding Job",
|
|
"The transcoding job could not be queued, so no video record was created. The uploaded file is still in storage; retry this request with the same 'key' rather than uploading the file again.")
|
|
return
|
|
}
|
|
|
|
video, err := repositories.VideoRepo.CreateVideo(r.Context(), models.Video{
|
|
Title: title,
|
|
Description: strings.TrimSpace(req.Description),
|
|
CategoryIDs: req.CategoryIDs,
|
|
Tags: strings.TrimSpace(req.Tags),
|
|
FileName: strings.TrimSpace(req.FileName),
|
|
StorageKey: key,
|
|
MediaConvertJobID: jobID,
|
|
Status: models.VideoStatusProcessing,
|
|
SizeBytes: 60,
|
|
})
|
|
if err != nil {
|
|
log.Printf("Something Went Wrong Saving The Video Record: %s", err)
|
|
writeProblem(w, http.StatusInternalServerError, "Something Went Wrong Saving The Video Record",
|
|
"The transcoding job was queued but its video record could not be written to the database, so the video may finish transcoding without ever appearing in the catalogue. Retry this request or escalate to an operator.")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, api.CompleteResponse{
|
|
ID: video.ID,
|
|
Title: video.Title,
|
|
Description: video.Description,
|
|
CategoryIDs: video.CategoryIDs,
|
|
Tags: video.Tags,
|
|
FileName: video.FileName,
|
|
StorageKey: video.StorageKey,
|
|
MediaConvertJobID: video.MediaConvertJobID,
|
|
Status: video.Status.String(),
|
|
SizeBytes: video.SizeBytes,
|
|
CreatedAt: video.CreatedAt,
|
|
UpdatedAt: video.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
func validateCatagoryIDS(ctx context.Context, submitted []int16) (err error) {
|
|
categoryIDS, err := repositories.CatagoriesRepo.ListCategoriesIDs(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, submittedID := range submitted {
|
|
if !slices.Contains(categoryIDS, submittedID) {
|
|
return fmt.Errorf("Incorrect Category IDs")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func randomFilename(original string) (string, error) {
|
|
ext := filepath.Ext(filepath.Base(original))
|
|
buf := make([]byte, 16)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf) + ext, nil
|
|
}
|