package main import ( "errors" "net/http" "strings" "time" "github.com/go-chi/chi/v5" ) var ( allowedAssetStatuses = map[string]struct{}{ "active": {}, "archived": {}, "quarantined": {}, "disabled": {}, "deleted": {}, } allowedAssetVisibility = map[string]struct{}{ "public": {}, "authenticated": {}, "enrolled": {}, "entitled": {}, "instructor": {}, "admin": {}, "private": {}, } allowedAssetProtectionLevels = map[string]struct{}{ "none": {}, "standard": {}, "encrypted": {}, "watermarked": {}, "restricted": {}, } ) func (a *app) assetsAdminMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if claims == nil { writeError(w, http.StatusUnauthorized, "no authenticated user") return } if !hasRole(claims, "admin") { writeError(w, http.StatusForbidden, "insufficient role for asset management") return } next.ServeHTTP(w, r) }) } func (a *app) listAssets(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } filter := Doc{} if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" { filter["status"] = status } if visibility := strings.TrimSpace(r.URL.Query().Get("visibility")); visibility != "" { filter["visibility"] = visibility } docs, err := a.list("assets", filter, Sort{{Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list assets") return } writeJSON(w, http.StatusOK, docs) } func (a *app) createAsset(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } var payload map[string]any if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid payload") return } doc, err := prepareAssetPayload(Doc(payload), false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } doc, err = a.create("assets", doc) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create resource") return } a.recordAuditEvent(r.Context(), claims, "assets.create", "assets", auditResultSuccess, map[string]any{ "assetId": doc["id"], "status": doc["status"], }) writeJSON(w, http.StatusCreated, doc) } func (a *app) getAsset(w http.ResponseWriter, r *http.Request) { a.getCollection("assets", w, r) } func (a *app) patchAsset(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } id := chi.URLParam(r, "id") var payload map[string]any if err := decodeJSON(r, &payload); err != nil { writeError(w, http.StatusBadRequest, "invalid patch payload") return } patch, err := prepareAssetPayload(Doc(payload), true) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if len(patch) == 0 { writeError(w, http.StatusBadRequest, "no asset changes provided") return } doc, err := a.patch("assets", id, patch) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update resource") return } a.recordAuditEvent(r.Context(), claims, "assets.patch", "assets", auditResultSuccess, map[string]any{ "assetId": id, }) writeJSON(w, http.StatusOK, doc) } func (a *app) deleteAsset(w http.ResponseWriter, r *http.Request) { a.deleteCollection("assets", w, r) } func (a *app) listAssetAccessEvents(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } id := chi.URLParam(r, "id") docs, err := a.list("asset_access_events", Doc{"assetId": id}, Sort{{Key: "createdAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list access events") return } writeJSON(w, http.StatusOK, docs) } func (a *app) listAssetClaims(w http.ResponseWriter, r *http.Request) { if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } id := chi.URLParam(r, "id") docs, err := a.list("copyright_claims", Doc{"assetId": id}, Sort{{Key: "submittedAt", Value: -1}, {Key: "updatedAt", Value: -1}}) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list claims") return } writeJSON(w, http.StatusOK, docs) } func (a *app) quarantineAsset(w http.ResponseWriter, r *http.Request) { a.updateAssetStatusAction(w, r, "quarantined", "assets.quarantine") } func (a *app) disableAsset(w http.ResponseWriter, r *http.Request) { a.updateAssetStatusAction(w, r, "disabled", "assets.disable") } func (a *app) restoreAsset(w http.ResponseWriter, r *http.Request) { a.updateAssetStatusAction(w, r, "active", "assets.restore") } func (a *app) encryptAsset(w http.ResponseWriter, r *http.Request) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if !a.cfg.AssetEncryptionEnabled { writeError(w, http.StatusConflict, "asset encryption is not enabled") return } if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } id := chi.URLParam(r, "id") metadata := assetEncryptionMetadata(time.Now().UnixMilli()) doc, err := a.patch("assets", id, Doc{ "protectionLevel": "encrypted", "encryption": metadata, }) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to encrypt asset") return } a.recordAuditEvent(r.Context(), claims, "assets.encrypt", "assets", auditResultSuccess, map[string]any{ "assetId": id, "protectionLevel": "encrypted", "keyVersion": metadata["keyVersion"], }) writeJSON(w, http.StatusOK, doc) } func (a *app) updateAssetStatusAction(w http.ResponseWriter, r *http.Request, targetStatus, action string) { claims, _ := r.Context().Value(userClaimsKey).(*accessClaims) if a.db == nil { writeError(w, http.StatusServiceUnavailable, "database not configured") return } id := chi.URLParam(r, "id") doc, err := a.patch("assets", id, Doc{"status": targetStatus}) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, http.StatusNotFound, "resource not found") return } writeError(w, http.StatusInternalServerError, "failed to update asset status") return } a.recordAuditEvent(r.Context(), claims, action, "assets", auditResultSuccess, map[string]any{ "assetId": id, "status": targetStatus, }) writeJSON(w, http.StatusOK, doc) } func assetEncryptionMetadata(now int64) Doc { return Doc{ "status": "encrypted", "algorithm": "AES-256-GCM", "keyVersion": "v1", "encryptedAt": now, } } func prepareAssetPayload(payload Doc, partial bool) (Doc, error) { if payload == nil { payload = Doc{} } title, hasTitle, err := getOptionalString(payload, "title") if err != nil { return nil, errors.New("title must be a string") } if !partial && (!hasTitle || title == "") { return nil, errors.New("title is required") } if hasTitle { payload["title"] = title } for _, field := range []string{"description", "ownerUserId", "ownerName", "relatedResourceType", "relatedResourceId", "entitlementKey"} { value, has, err := getOptionalString(payload, field) if err != nil { return nil, errors.New(field + " must be a string") } if has { payload[field] = value } } status, hasStatus, err := getOptionalString(payload, "status") if err != nil { return nil, errors.New("status must be a string") } if !partial && (!hasStatus || status == "") { status = "active" hasStatus = true } if hasStatus { if !isAllowedEnumValue(allowedAssetStatuses, status) { return nil, errors.New("status must be one of active, archived, quarantined, disabled, or deleted") } payload["status"] = status } visibility, hasVisibility, err := getOptionalString(payload, "visibility") if err != nil { return nil, errors.New("visibility must be a string") } if !partial && (!hasVisibility || visibility == "") { visibility = "private" hasVisibility = true } if hasVisibility { if !isAllowedEnumValue(allowedAssetVisibility, visibility) { return nil, errors.New("visibility must be one of public, authenticated, enrolled, entitled, instructor, admin, or private") } payload["visibility"] = visibility } protectionLevel, hasProtectionLevel, err := getOptionalString(payload, "protectionLevel") if err != nil { return nil, errors.New("protectionLevel must be a string") } if !partial && (!hasProtectionLevel || protectionLevel == "") { protectionLevel = "standard" hasProtectionLevel = true } if hasProtectionLevel { if !isAllowedEnumValue(allowedAssetProtectionLevels, protectionLevel) { return nil, errors.New("protectionLevel must be one of none, standard, encrypted, watermarked, or restricted") } payload["protectionLevel"] = protectionLevel } if value, ok := payload["copyright"]; ok { normalized, err := normalizeAssetCopyright(value) if err != nil { return nil, err } payload["copyright"] = normalized } return payload, nil } func normalizeAssetCopyright(raw any) (Doc, error) { mapValue, ok := raw.(map[string]any) if !ok { if bsonValue, docOK := raw.(Doc); docOK { mapValue = map[string]any(bsonValue) } else { return nil, errors.New("copyright must be an object") } } normalized := Doc{} for _, field := range []string{"ownerName", "ownerUserId", "sourceUrl", "attributionText", "claimStatus"} { value, has, err := getOptionalString(mapValue, field) if err != nil { return nil, errors.New("copyright." + field + " must be a string") } if has { normalized[field] = value } } licence, hasLicence, err := getOptionalString(mapValue, "licence") if err != nil { return nil, errors.New("copyright.licence must be a string") } if hasLicence { normalized["licence"] = licence } else { license, hasLicense, err := getOptionalString(mapValue, "license") if err != nil { return nil, errors.New("copyright.license must be a string") } if hasLicense { normalized["licence"] = license } } for _, field := range []string{"requiresAttribution", "allowDownload", "allowReuse", "allowModification", "allowCommercialUse"} { value, has, err := getOptionalBool(mapValue, field) if err != nil { return nil, errors.New("copyright." + field + " must be a boolean") } if has { normalized[field] = value } } return normalized, nil } func isAllowedEnumValue(allowed map[string]struct{}, value string) bool { _, ok := allowed[strings.TrimSpace(strings.ToLower(value))] return ok }