399 lines
11 KiB
Go
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
|
|
}
|