399 lines
11 KiB
Go

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
}