Shared utilities for MediaKit storage providers — key generation, resilience pipelines, and file name sanitization.
$ dotnet add package MediaKit.Storage.AbstractionsPolymorphic media management library for .NET — file attachments, multi-provider storage, image processing, and background conversions without owning your app architecture.
MediaKit gives you production-grade media handling for any .NET API. Attach files to any entity, store them on local disk or S3-compatible services, generate image conversions, and query media with EF Core or Dapper — all with a fluent builder API. It fills the Spatie Media Library gap for the .NET ecosystem.
| Package | Description | Target |
|---|---|---|
| MediaKit | Core types — media entity, interfaces, DTOs, validation, extensions. Zero ASP.NET dependency. | net8.0, net9.0, net10.0 |
| MediaKit.AspNetCore | ASP.NET Core integration — MediaService, ImageSharp processing, background workers, file validation, DI registration. | net8.0, net10.0 |
| MediaKit.EntityFrameworkCore | EF Core integration — generic entity configuration, query extensions, media eager loading. | net8.0, net10.0 |
| MediaKit.Dapper | Dapper integration — type handlers, repository, query extensions for high-performance media access. | net8.0, net9.0, net10.0 |
| MediaKit.Storage | Meta-package — includes S3 and Local providers for convenience. | net8.0, net9.0, net10.0 |
| MediaKit.Storage.S3 | S3-compatible storage — AWS S3, SeaweedFS, RustFS with Polly resilience. | net8.0, net9.0, net10.0 |
| MediaKit.Storage.Local | Local filesystem storage with JWT signed URLs. | net8.0, net9.0, net10.0 |
| MediaKit.Storage.AzureBlobs | Azure Blob Storage with SAS signed URLs and Polly resilience. | net8.0, net9.0, net10.0 |
| MediaKit.Storage.Abstractions | Shared utilities — key generation, resilience pipelines, file name sanitization. | net8.0, net9.0, net10.0 |
# Core (required)
dotnet add package MediaKit
# ASP.NET Core integration (required for web apps)
dotnet add package MediaKit.AspNetCore
# EF Core entity configuration and query helpers
dotnet add package MediaKit.EntityFrameworkCore
# Dapper integration (alternative to EF Core)
dotnet add package MediaKit.Dapper
# Storage — pick what you need:
dotnet add package MediaKit.Storage # Meta-package (S3 + Local)
dotnet add package MediaKit.Storage.S3 # S3 / SeaweedFS / RustFS only
dotnet add package MediaKit.Storage.Local # Local filesystem only
dotnet add package MediaKit.Storage.AzureBlobs # Azure Blob Storageusing MediaKit.AspNetCore.DependencyInjection;
using MediaKit.Collections;
using MediaKit.Entities;
using MediaKit.Storage.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// Register MediaKit with local storage and ImageSharp
builder.Services.AddMediaKit<AppDbContext, Media>(options =>
{
options.UseBackgroundProcessing = true;
})
.AddLocalStorage(config =>
{
config.RootPath = "wwwroot/storage";
config.BaseUrl = "/storage";
})
.AddImageSharp();
// Register collection metadata
MediaCollectionRegistry.Register<Product, Media>(Product.GetCollectionMetadata());
var app = builder.Build();
app.MapPost("/api/products/{id}/cover", async (
int id, IFormFile file, AppDbContext db,
MediaService<AppDbContext, Media> mediaService) =>
{
var product = await db.Products.FindAsync(id);
var media = await mediaService.UploadAsync(product!, file, "cover");
return Results.Ok(new { media.Id, media.Url, media.Conversions });
});
app.Run();The Media class is the base entity for all file attachments. It uses a polymorphic design (EntityId + EntityType) to attach to any entity without foreign keys:
public class Media
{
public string Id { get; set; } // Auto-generated GUID
public string FileName { get; set; } // Storage filename
public string OriginalName { get; set; } // User-facing filename
public string MimeType { get; set; } // Content type
public string Path { get; set; } // Storage path
public string Url { get; set; } // Public URL
public long Size { get; set; } // File size in bytes
public string Collection { get; set; } // "cover", "gallery", "documents"
public string Disk { get; set; } // "local", "s3", "seaweedfs"
public int SortOrder { get; set; } // Ordering within collection
public string? EntityId { get; set; } // Polymorphic owner ID
public string? EntityType { get; set; } // Polymorphic owner type
// JSON-serialized dictionaries
public Dictionary<string, string> Conversions { get; set; } // "thumbnail" => "/storage/.../thumb.webp"
public Dictionary<string, object> CustomProperties { get; set; } // width, height, etc.
}Extend Media with a derived class for app-specific properties:
public class AppMedia : Media
{
public string? AltText { get; set; }
public string? Caption { get; set; }
}Any entity can have media by implementing IHasMedia<TMedia>:
public class Product : IHasMedia<Media>
{
public int Id { get; set; }
public string Name { get; set; } = "";
// IHasMedia implementation
object IHasMedia<Media>.Id => Id;
public static string MediaEntityType => "product";
public ICollection<Media>? Media { get; set; }
}
public class Author : IHasMedia<Media>
{
public int Id { get; set; }
public string Name { get; set; } = "";
object IHasMedia<Media>.Id => Id;
public static string MediaEntityType => "author";
public ICollection<Media>? Media { get; set; }
}The MediaEntityType string is stored in the entity_type column, enabling queries like "get all media for product 42" without a direct FK.
Collections organize media by purpose. Define collection behavior with IHasMediaCollections:
public class Product : IHasMedia<Media>, IHasMediaCollections
{
// ...
public static IReadOnlyDictionary<string, MediaCollectionMetadata> GetCollectionMetadata() =>
new Dictionary<string, MediaCollectionMetadata>
{
// Single-item: uploading replaces existing media
["cover"] = new(IsSingleItem: true, MaxItems: 1, Description: "Product cover image"),
// Multi-item: uploading appends to collection
["gallery"] = new(IsSingleItem: false, MaxItems: 10, Description: "Product gallery"),
// Documents collection
["documents"] = new(IsSingleItem: false, MaxItems: 5, Description: "Product documents")
};
}Register metadata at startup:
MediaCollectionRegistry.Register<Product, Media>(Product.GetCollectionMetadata());Instead of calling product.GetFirstMedia("cover") (O(n) LINQ scan every time), define typed properties with cached O(1) access using MediaCollectionView<TMedia>:
using MediaKit.Collections;
using MediaKit.Entities;
public class Product : IHasMedia<Media>, IHasMediaCollections
{
public int Id { get; set; }
public string Name { get; set; } = "";
// IHasMedia — unchanged
object IHasMedia<Media>.Id => Id;
public static string MediaEntityType => "product";
public ICollection<Media>? Media { get; set; }
// Cached collection view — single O(n) pass indexes all collections
private MediaCollectionView<Media>? _mediaView;
private MediaCollectionView<Media> MediaView
=> _mediaView ??= new(Media);
// Typed properties — O(1) cached lookup
[MediaCollection("cover", IsSingle = true)]
public Media? Cover => MediaView.GetFirst("cover");
[MediaCollection("gallery")]
public IReadOnlyList<Media> Gallery => MediaView.GetAll("gallery");
// ... collection metadata, conversions, etc.
}How it works:
MediaCollectionView<TMedia> builds a Dictionary<string, List<TMedia>> index in a single O(n) pass on first access to any method[MediaCollection] attribute decorates typed properties so EF Core automatically ignores them (no shadow columns or FKs)Array.Empty<TMedia>() is returned for missing collections (singleton, zero allocation)Collection-specific database loading:
// Only load cover media from DB — not gallery, not everything
var products = await db.Products
.IncludeMedia<Product, Media>("cover")
.ToListAsync();
// product.Cover is populated, product.Gallery is empty (not loaded)
var coverUrl = products[0].Cover?.Url;
// Load specific collections
var products = await db.Products
.IncludeMedia<Product, Media>("cover", "gallery")
.ToListAsync();
// Load via property expression (resolves collection name from [MediaCollection] attribute)
var products = await db.Products
.IncludeMediaFor<Product, Media>(p => p.Cover)
.ToListAsync();Mutation helpers:
// Replace single-item collection
product.SetSingleMedia("cover", newMedia);
// Replace multi-item collection
product.SetCollectionMedia("gallery", newGalleryItems);This feature is fully opt-in — entities without [MediaCollection] properties work exactly as before.
Define conversions per collection with IHasMediaConversions:
public class Product : IHasMedia<Media>, IHasMediaConversions
{
// ...
public static IReadOnlyDictionary<string, List<ImageConversion>> GetMediaConversions() =>
new Dictionary<string, List<ImageConversion>>
{
["cover"] =
[
new ImageConversion("thumbnail", Width: 300, Height: null), // 300px wide, auto height
new ImageConversion("preview", Width: 800, Height: null), // 800px wide, auto height
new ImageConversion("og-image", Width: 1200, Height: 630, // Exact dimensions
Format: ImageOutputFormat.Jpeg, Quality: 90)
],
["gallery"] =
[
new ImageConversion("thumbnail", Width: 200, Height: 200) // Square thumbnail
]
};
}Conversions are stored in the Conversions dictionary:
var thumbnailUrl = product.GetFirstMediaUrl("thumbnail", "cover");
// Returns the thumbnail URL, or falls back to original if conversion doesn't existbuilder.Services.AddMediaKit<AppDbContext, Media>(options =>
{
// Background processing (default: false — conversions run synchronously)
options.UseBackgroundProcessing = true;
// Channel capacity for background queues (backpressure control)
options.BackgroundQueueCapacity = 100;
// OpenTelemetry activity source name
options.ActivitySourceName = "MyApp.Media";
// Fallback base URL when HttpContext is unavailable
options.BaseUrl = "https://api.example.com";
// Default collection name for media without explicit collection
options.DefaultCollection = "default";
});AddMediaKit() returns an IMediaKitBuilder for fluent chaining:
builder.Services.AddMediaKit<AppDbContext, Media>(options =>
{
options.UseBackgroundProcessing = true;
})
.AddS3Storage(config => // Storage provider (required)
{
config.BucketName = "my-media";
config.Region = "us-east-1";
config.CloudFrontDomain = "d123.cloudfront.net";
})
.AddImageSharp() // Image processor (optional)
.AddConversions( // Config-based conversions (optional)
builder.Configuration.GetSection("MediaConversions"));Storage is validated at startup — if no provider is registered, the app fails fast with a clear error message.
All storage providers implement IFileStorageService and support upload, download, delete, and signed URLs.
.AddLocalStorage(config =>
{
config.RootPath = "wwwroot/storage"; // Physical path on disk
config.BaseUrl = "/storage"; // URL prefix
config.JwtSecretKey = "..."; // Optional: enable signed download URLs
config.JwtIssuer = "MediaKit";
config.JwtAudience = "MediaKit";
}).AddS3Storage(config =>
{
config.BucketName = "my-media-bucket";
config.Region = "us-east-1";
config.PublicRead = true;
config.CloudFrontDomain = "d123.cloudfront.net"; // Optional CDN
config.CustomDomain = "media.example.com"; // Optional custom domain
})S3 uploads use Polly resilience pipelines with exponential backoff and jitter for transient error handling.
.AddSeaweedFSStorage(config =>
{
config.BucketName = "media";
config.S3Endpoint = "http://seaweedfs:8333";
config.FilerUrl = "http://seaweedfs:8888";
config.AccessKey = "admin";
config.SecretKey = "secret";
}).AddRustFSStorage(config =>
{
config.BucketName = "media";
config.Endpoint = "http://rustfs:9000";
config.AccessKey = "minioadmin";
config.SecretKey = "minioadmin";
config.Region = "us-east-1";
config.PublicRead = true;
config.CustomDomain = "cdn.example.com";
}).AddAzureBlobStorage(config =>
{
config.ConnectionString = "DefaultEndpointsProtocol=https;AccountName=...";
config.ContainerName = "media";
config.CreateContainerIfNotExists = true; // Default: true
config.CustomDomain = "cdn.example.com"; // Optional custom domain
})Azure Blob Storage uses Polly resilience pipelines and generates SAS signed URLs for secure direct access.
Register your own IFileStorageService implementation without creating a NuGet package:
// Simple class registration
builder.Services.AddMediaKit<AppDbContext, Media>()
.AddCustomStorage<MyMinioStorageService>();
// With typed options
builder.Services.AddMediaKit<AppDbContext, Media>()
.AddCustomStorage<MyMinioStorageService, MyMinioOptions>(o =>
{
o.Endpoint = "http://localhost:9000";
o.BucketName = "media";
});
// Factory delegate for full control
builder.Services.AddMediaKit<AppDbContext, Media>()
.AddCustomStorage(sp => new MyMinioStorageService(
sp.GetRequiredService<IOptions<MyMinioOptions>>(),
sp.GetRequiredService<ILogger<MyMinioStorageService>>()
));Use AddStorage() to select the provider from appsettings.json:
.AddStorage(builder.Configuration){
"FileStorage": {
"Type": "s3",
"S3": {
"BucketName": "my-media",
"Region": "us-east-1",
"CloudFrontDomain": "d123.cloudfront.net"
}
}
}Supported types: s3, seaweedfs, rustfs, local. For Azure Blob Storage, install MediaKit.Storage.AzureBlobs and call .AddAzureBlobStorage() directly.
Register the built-in ImageSharp processor:
.AddImageSharp()This registers ImageSharpProcessor as IImageProcessor. It supports:
The IImageProcessor interface enables swapping to alternatives (SkiaSharp, Magick.NET):
public interface IImageProcessor
{
Task<IReadOnlyList<ImageConversionResult>> ProcessAsync(
Stream sourceStream, string fileName,
IReadOnlyList<ImageConversion> conversions,
CancellationToken ct = default);
}Define conversions in appsettings.json as a fallback when entities don't implement IHasMediaConversions:
{
"MediaConversions": {
"Conversions": {
"product": {
"cover": [
{ "Name": "thumbnail", "Width": 300, "Format": "WebP", "Quality": 80 },
{ "Name": "preview", "Width": 800, "Format": "WebP", "Quality": 85 }
]
}
}
}
}.AddConversions(builder.Configuration.GetSection("MediaConversions"))Type-safe conversions via IHasMediaConversions take priority over appsettings.json:
public class Product : IHasMediaConversions
{
public static IReadOnlyDictionary<string, List<ImageConversion>> GetMediaConversions() =>
new Dictionary<string, List<ImageConversion>>
{
["cover"] =
[
new ImageConversion("thumbnail", Width: 300, Height: null,
Format: ImageOutputFormat.WebP, Quality: 80)
]
};
}When enabled, image conversions and file deletions run in background hosted services using System.Threading.Channels with bounded capacity for backpressure:
options.UseBackgroundProcessing = true;
options.BackgroundQueueCapacity = 200;MediaProcessingHostedService processes them asynchronously.QueueAsync blocks until space is available, preventing memory exhaustion.When UseBackgroundProcessing = false (default), conversions run synchronously within the upload request.
Chain validation methods with a fluent API:
using MediaKit.AspNetCore.Validation;
// Individual validators
file.ValidateNotEmpty()
.ValidateIsImage()
.ValidateSize(maxSizeBytes: 10 * 1024 * 1024)
.ValidateMimeType(["image/jpeg", "image/png", "image/webp"])
.ValidateExtension([".jpg", ".png", ".webp"]);
await file.ValidateSignatureAsync(); // Magic byte verification
await file.ValidateImageDimensionsAsync( // Pixel dimension limits
maxWidth: 4000, maxHeight: 4000);
// Convenience: validate all common image constraints in one call
await file.ValidateImageUploadAsync(maxSizeBytes: 5 * 1024 * 1024);
// Batch validation
files.ValidateFiles(f => f.ValidateNotEmpty().ValidateIsImage().ValidateSize(10_000_000));All validators throw MediaValidationException with descriptive messages.
FileSignatureValidator inspects magic bytes to prevent MIME type spoofing:
using MediaKit.Validation;
var isValid = await FileSignatureValidator.ValidateAsync(stream, "image/jpeg");
var supported = FileSignatureValidator.GetSupportedMimeTypes();
// JPEG, PNG, GIF, WebP, BMP, ICO, SVG, PDF, MP4MediaService<TDbContext, TMedia> orchestrates uploads, conversions, and storage with OpenTelemetry tracing.
Upload a single file to an entity's collection:
var media = await mediaService.UploadAsync(product, file, "cover");
// For single-item collections, existing media is automatically replaced
// Old files are queued for background deletionUpload multiple files concurrently:
var results = await mediaService.UploadManyAsync(product, files, "gallery");
// Each file gets its own DI scope for safe concurrent uploadsDelete all existing media in a collection, then upload a new file:
var media = await mediaService.ReplaceCollectionAsync(product, file, "cover");Delete a single media item and its conversions:
var deleted = await mediaService.DeleteAsync(mediaId);Delete all media in a collection:
var count = await mediaService.DeleteCollectionAsync(product, "gallery");Rich extension methods on IHasMedia<TMedia> for convenient media access:
using MediaKit.Extensions;
// Get media by collection (ordered by SortOrder)
var covers = product.GetMedia("cover");
var galleryImages = product.GetMedia("gallery");
// First media item
var cover = product.GetFirstMedia("cover");
// Check existence
bool hasCover = product.HasMedia("cover");
int galleryCount = product.GetMediaCount("gallery");
// URLs — original or conversion
string? coverUrl = product.GetFirstMediaUrl("cover");
string? thumbUrl = product.GetFirstMediaUrl("thumbnail", "cover");
IEnumerable<string> urls = product.GetMediaUrls("gallery");
// Image dimensions (from CustomProperties)
var dims = product.GetFirstMediaDimensions("cover"); // (int Width, int Height)?
// Filter by MIME type
var images = product.GetImages("gallery");
var videos = product.GetVideos("gallery");
// Get all collection names on an entity
var collections = product.GetMediaCollectionNames();
// Check if a specific conversion exists
bool hasThumb = product.HasMediaConversion("thumbnail", "cover");
// Set/replace media in a single-item collection
product.SetSingleMedia("cover", newCoverMedia);
// Set/replace all media in a collection
product.SetCollectionMedia("gallery", newGalleryItems);MediaEntityConfiguration<TMedia> provides a portable configuration with JSON value conversions for Conversions and CustomProperties:
// In your DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new MediaEntityConfiguration<Media>());
}This configures:
mediaId (max 26 chars)IX_Media_EntityType_EntityId_CollectionIX_Media_EntityType_CollectionIX_Media_EntityId_EntityTypeIX_Media_CreatedAt_Id_DescIX_Media_CreatedAt_Id_Ascusing MediaKit.EntityFrameworkCore;
// Eager load all media
var products = await db.Products
.IncludeMedia<Product, Media>()
.ToListAsync();
// Eager load specific collection
var products = await db.Products
.IncludeMedia<Product, Media>("cover")
.ToListAsync();
// Eager load multiple specific collections
var products = await db.Products
.IncludeMedia<Product, Media>("cover", "gallery")
.ToListAsync();
// Eager load via typed property expression (resolves from [MediaCollection] attribute)
var products = await db.Products
.IncludeMediaFor<Product, Media>(p => p.Cover)
.ToListAsync();
// Eager load multiple typed properties
var products = await db.Products
.IncludeMediaFor<Product, Media>(p => p.Cover, p => p.Gallery)
.ToListAsync();
// Explicit loading
await product.LoadMediaAsync<Product, Media>(db);
await product.LoadMediaAsync<Product, Media>(db, "cover");
await product.LoadMediaAsync<Product, Media>(db, ["cover", "gallery"]);
// Explicit loading via typed property expression
await product.LoadMediaForAsync<Product, Media>(db, p => p.Cover);
// Filter entities by media existence
var withCovers = await db.Products
.WhereHasMedia<Product, Media>("cover")
.ToListAsync();
// Filter by media count
var withGallery = await db.Products
.WhereHasMediaCount<Product, Media>(3, "gallery")
.ToListAsync();
// Filter by MIME type
var withImages = await db.Products
.WhereHasMediaType<Product, Media>("image/", "gallery")
.ToListAsync();
// Batch preload media for a list of entities
await products.PreloadMediaAsync<Product, Media>(db, "cover");Override MediaEntityConfiguration for database-specific column types:
public class AppMediaConfiguration : MediaEntityConfiguration<AppMedia>
{
public override void Configure(EntityTypeBuilder<AppMedia> builder)
{
base.Configure(builder);
// PostgreSQL jsonb columns
builder.Property(m => m.Conversions).HasColumnType("jsonb");
builder.Property(m => m.CustomProperties).HasColumnType("jsonb");
// App-specific columns
builder.Property(m => m.AltText).HasMaxLength(500);
builder.Property(m => m.Caption).HasMaxLength(1000);
// Global query filter (soft delete)
builder.HasQueryFilter(m => !m.IsDeleted);
}
}MediaKit.Dapper provides a lightweight, high-performance alternative to EF Core for media queries. It includes JSON type handlers, a generic repository, and query extensions that work seamlessly with typed collection properties.
Register Dapper with a standalone connection factory:
using MediaKit.Dapper.DependencyInjection;
builder.Services.AddMediaKit<Media>(options => { })
.AddDapper<Media>(sp => new NpgsqlConnection(connectionString))
.AddLocalStorage(config => { config.RootPath = "wwwroot/storage"; });Or register type handlers only (for direct Dapper usage without the repository):
builder.Services.AddMediaKit<Media>(options => { })
.AddDapperTypeHandlers();IMediaRepository<TMedia> provides full CRUD operations:
using MediaKit.Dapper.Repository;
public class ProductService(IMediaRepository<Media> mediaRepo)
{
public async Task<IReadOnlyList<Media>> GetProductMedia(string productId)
{
return await mediaRepo.GetByEntityAsync("product", productId);
}
public async Task<Media?> GetCover(string productId)
{
return await mediaRepo.GetFirstByEntityAsync("product", productId, "cover");
}
public async Task<int> GetGalleryCount(string productId)
{
return await mediaRepo.CountByEntityAsync("product", productId, "gallery");
}
public async Task<bool> DeleteGallery(string productId)
{
var count = await mediaRepo.DeleteByEntityAsync("product", productId, "gallery");
return count > 0;
}
}Extension methods on IDbConnection mirror the EF Core query extensions:
using MediaKit.Dapper.Extensions;
// Load all media for an entity
await connection.LoadMediaAsync<Product, Media>(product);
// Load a specific collection
await connection.LoadMediaAsync<Product, Media>(product, "cover");
// Load multiple collections
await connection.LoadMediaAsync<Product, Media>(product, ["cover", "gallery"]);
// Batch preload media for multiple entities
await connection.PreloadMediaAsync<Product, Media>(products, "cover");
// Check media existence and count
bool hasCover = await connection.HasMediaAsync<Product, Media>(product, "cover");
int count = await connection.GetMediaCountAsync<Product, Media>(product, "gallery");
// Find entities with specific media types
var ids = await connection.GetEntityIdsWithMediaTypeAsync(
"product", "image/", "gallery");Use typed property expressions to load collections — resolves the collection name from [MediaCollection] attributes:
// Load via property expression
await connection.LoadMediaForAsync<Product, Media>(product, p => p.Cover);
// Load multiple typed collections
await connection.LoadMediaForAsync<Product, Media>(
product,
[p => p.Cover, p => p.Gallery]);
// Typed properties work after Dapper loads the data
var coverUrl = product.Cover?.Url;
var galleryCount = product.Gallery.Count;Use Dapper alongside EF Core by extracting the connection from your DbContext:
builder.Services.AddMediaKit<AppDbContext, Media>(options => { })
.AddDapper<AppDbContext, Media>();This resolves AppDbContext from DI and extracts its IDbConnection via reflection — no compile-time EF Core dependency in the Dapper package.
IMediaUrlBuilder generates absolute URLs from relative paths, using HttpContext with a configurable fallback:
// Automatically registered — uses HttpContext.Request for scheme/host
// Falls back to MediaKitOptions.BaseUrl when HttpContext is unavailable (background services, CLI tools)
builder.Services.AddMediaKit<AppDbContext, Media>(options =>
{
options.BaseUrl = "https://api.example.com";
});All MediaService operations emit OpenTelemetry activities with detailed tags:
options.ActivitySourceName = "MyApp.Media"; // Default: "MediaKit"Activities include:
MediaService.Upload — entity type, entity ID, collection, file size, upload duration, conversion countA complete working example is at samples/MediaKit.Sample/. It demonstrates:
Product entityIHasMedia, IHasMediaCollections, and IHasMediaConversions implementationsRun it:
cd samples/MediaKit.Sample
dotnet runThen test:
# Create a product
curl -X POST http://localhost:5000/api/products -F "name=Widget"
# Upload a cover image (single-item — replaces existing)
curl -X POST http://localhost:5000/api/products/1/cover -F "file=@photo.jpg"
# Upload gallery images (multi-item — appends)
curl -X POST http://localhost:5000/api/products/1/gallery \
-F "files=@img1.jpg" -F "files=@img2.jpg"
# List products with media URLs
curl http://localhost:5000/api/products
# Get product detail with full media info
curl http://localhost:5000/api/products/1
# Delete a media item
curl -X DELETE http://localhost:5000/api/media/{mediaId}MIT -- Copyright (c) 2026 MediaKit Contributors