User-Generated Content

UGC upload pipeline, validation, content-addressed storage, approval workflows, and moderation integration in Aether.

User-Generated Content (UGC) is central to the Aether platform. Creators upload assets -- 3D models, textures, audio, scripts -- which flow through a validation pipeline, content-addressed storage, and an approval workflow before being published. The aether-ugc crate provides the complete UGC domain logic.

Key Concepts

  • Upload pipeline -- Multipart uploads with configurable size limits per file type.
  • Content addressing -- Assets are stored by their SHA-256 hash, enabling deduplication and integrity verification.
  • Version tracking -- Every asset maintains a version history with parent-version linking.
  • Approval workflow -- State machine governing content review from upload through publication.
  • Signed manifests -- Content-addressed manifests bundle asset metadata with integrity hashes.
  • Moderation integration -- Automated scanning and manual review hooks at each pipeline stage.

Architecture

The UGC pipeline processes uploads through a series of stages:

UploadRequest
  -> Validate file type & size
    -> Compute SHA-256 hash
      -> Store via AssetStorage trait
        -> Create AssetVersion record
          -> Approval Workflow
            -> Generate SignedManifest

Each stage can reject the upload, and the pipeline tracks which stage a given asset has reached.

Upload Handling

Uploads are validated against configurable size and type constraints:

use aether_ugc::{UploadRequest, UploadConfig, UploadError};

let config = UploadConfig::default();

let request = UploadRequest {
    creator_id: creator_uuid,
    asset_name: "sword_model".to_string(),
    file_type: FileType::Model3D,
    data: model_bytes,
    parent_version: None, // first version
};

// Validate before processing
match config.validate(&request) {
    Ok(()) => { /* proceed with pipeline */ }
    Err(UploadError::SizeExceeded { max, actual }) => {
        // File too large for this type
    }
    Err(UploadError::UnsupportedType) => {
        // File type not in allowlist
    }
    Err(UploadError::EmptyData) => {
        // No data provided
    }
    Err(UploadError::InvalidName) => {
        // Asset name contains invalid characters
    }
}

The UploadConfig allows setting per-type size limits:

let mut config = UploadConfig::default();
// Configure max sizes per file type
// Model3D: 50MB, Texture: 20MB, Audio: 10MB, Script: 1MB

Asset Versioning

Every asset maintains an ordered version history. New versions link to their parent, forming a linear chain:

use aether_ugc::{VersionHistory, AssetVersion};

let mut history = VersionHistory::new(asset_id);

// First upload creates version 1
let v1: AssetVersion = history.add_version(content_hash, file_size, status);
assert_eq!(v1.version, 1);

// Subsequent uploads auto-increment
let v2: AssetVersion = history.add_version(new_hash, new_size, status);
assert_eq!(v2.version, 2);

Each AssetVersion records:

FieldTypeDescription
idUUIDUnique version identifier
asset_idUUIDParent asset identifier
versionu32Sequential version number
content_hashStringSHA-256 hash of the content
sizeu64File size in bytes
statusApprovalStatusCurrent approval state
created_atDateTimeUpload timestamp

Validation Pipeline

The ValidationPipeline orchestrates the full upload flow:

use aether_ugc::{ValidationPipeline, PipelineResult, PipelineStage};

let result: PipelineResult = ValidationPipeline::process(
    &request,
    &storage,
    &approval_policy,
).await;

match result.stage {
    PipelineStage::Approved => {
        // Asset is ready for distribution
        let manifest = result.manifest.unwrap();
    }
    PipelineStage::Rejected { reason } => {
        // Asset failed validation or moderation
    }
    _ => {
        // Asset is still in progress
    }
}

Pipeline stages in order:

  1. Received -- Upload data received from the creator.
  2. Validated -- File type and size checks passed.
  3. Hashed -- SHA-256 content hash computed.
  4. Stored -- Binary data written to asset storage.
  5. Approved / Rejected -- Approval workflow completed.

Content-Addressed Storage

Assets are stored by their SHA-256 hash, enabling automatic deduplication:

use aether_ugc::{AssetStorage, InMemoryStorage, StorageError};

let storage = InMemoryStorage::new();

// Store an asset (keyed by content hash)
storage.store(&content_hash, &data).await?;

// Retrieve by hash
let retrieved = storage.retrieve(&content_hash).await?;

// Check existence without downloading
let exists = storage.exists(&content_hash).await?;

// Delete when no longer referenced
storage.delete(&content_hash).await?;

The AssetStorage trait is async, allowing implementations backed by local disk, S3-compatible object stores, or distributed storage:

#[async_trait]
pub trait AssetStorage {
    async fn store(&self, hash: &str, data: &[u8]) -> Result<(), StorageError>;
    async fn retrieve(&self, hash: &str) -> Result<Vec<u8>, StorageError>;
    async fn delete(&self, hash: &str) -> Result<(), StorageError>;
    async fn exists(&self, hash: &str) -> Result<bool, StorageError>;
}

Signed Manifests

Manifests bundle asset metadata with integrity verification. Each manifest entry records the asset, version, hash, and size. The manifest itself is signed with an overall SHA-256 digest:

use aether_ugc::{ManifestBuilder, SignedManifest, ManifestEntry};

let manifest: SignedManifest = ManifestBuilder::new()
    .add_entry(ManifestEntry {
        asset_id: asset_uuid,
        version: 1,
        content_hash: "sha256:abc123...".to_string(),
        size: 1024000,
        file_type: FileType::Model3D,
    })
    .add_entry(ManifestEntry {
        asset_id: texture_uuid,
        version: 1,
        content_hash: "sha256:def456...".to_string(),
        size: 512000,
        file_type: FileType::Texture,
    })
    .build();

// The manifest digest covers all entry hashes
println!("Manifest digest: {}", manifest.digest);

Approval Workflow

The approval system uses a state machine to govern content review:

use aether_ugc::{ApprovalWorkflow, ApprovalStatus, ApprovalPolicy};

let mut workflow = ApprovalWorkflow::new();

// Transition through states
workflow.transition(ApprovalStatus::Scanning)?;
workflow.transition(ApprovalStatus::Approved)?;

// Invalid transitions are rejected
let result = workflow.transition(ApprovalStatus::Pending);
assert!(result.is_err());

Valid state transitions:

  • Pending -> Scanning (moderation scan begins)
  • Scanning -> Approved (scan passed)
  • Scanning -> Rejected { reason } (scan failed or manual rejection)

The ApprovalPolicy can auto-approve content based on configurable criteria:

use aether_ugc::{ApprovalPolicy, evaluate_approval};

let policy = ApprovalPolicy {
    auto_approve_trusted_creators: true,
    auto_approve_max_size: 1024 * 1024, // 1MB
    // ...
};

let decision = evaluate_approval(&request, &policy);
// Returns AutoApprove or NeedsReview

Artifact Lifecycle

The full artifact lifecycle tracks content from initial upload through publication and eventual archival: Upload -> Scan -> Approve -> Publish -> Archive. The UGC pipeline integrates with Aether's moderation system at the scanning stage, supporting automated content scanning, MIME type verification, and manual review for flagged content.

Chunked Uploads

For large assets, the UGC system supports chunked uploads through upload sessions:

use aether_ugc::{UploadSession, ChunkUpload};

let session = UploadSession::new(creator_id, asset_name, total_size);

let chunk = ChunkUpload {
    session_id: session.id,
    chunk_index: 0,
    data: chunk_bytes,
};

Upload sessions track progress, support resumption after interruption, and validate that all chunks are received before assembling the final asset.