Local filesystem storage provider for StashPup. Implements IFileStorage for local disk storage with folder organization, metadata files, and thumbnail generation. Perfect for development, testing, or self-hosted scenarios.
$ dotnet add package StashPup.Storage.Local
A flexible, provider-agnostic file storage library for .NET with built-in ASP.NET Core integration.
StashPup simplifies file storage in .NET applications by providing a unified interface for local filesystem, AWS S3, and Azure Blob Storage. It includes advanced features like thumbnail generation, signed URLs, metadata management, and powerful search capabilities.
# Core library (required)
dotnet add package StashPup.Core
# Choose your storage provider(s)
dotnet add package StashPup.Storage.Local
dotnet add package StashPup.Storage.S3
dotnet add package StashPup.Storage.Azure
# ASP.NET Core integration (optional)
dotnet add package StashPup.AspNetCore
var builder = WebApplication.CreateBuilder(args);
// Add StashPup with local storage
builder.Services.AddStashPup(stash => stash
.UseLocalStorage(options =>
{
options.BasePath = "./uploads";
options.BaseUrl = "/files";
options.MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
options.AllowedExtensions = [".jpg", ".png", ".pdf"];
}));
var app = builder.Build();
// Add pre-built endpoints
app.MapStashPupEndpoints("/api/files");
app.Run();
app.MapPost("/upload", async (IFormFile file, IFileStorage storage) =>
{
await using var stream = file.OpenReadStream();
var result = await storage.SaveAsync(
stream,
file.FileName,
folder: "documents",
metadata: new Dictionary<string, string>
{
["uploaded-by"] = "john@example.com",
["category"] = "invoice"
});
return result.Success
? Results.Ok(result.Data)
: Results.BadRequest(result.ErrorMessage);
});
app.MapGet("/download/{id:guid}", async (Guid id, IFileStorage storage) =>
{
var result = await storage.GetAsync(id);
var metadata = await storage.GetMetadataAsync(id);
return result.Success
? Results.File(result.Data!, metadata.Data!.ContentType, metadata.Data.Name)
: Results.NotFound();
});
StashPup uses a railway-oriented programming approach. All operations return Result<T>:
public class Result<T>
{
public bool Success { get; }
public T? Data { get; }
public string? ErrorMessage { get; }
public string? ErrorCode { get; }
}
Usage:
var result = await storage.SaveAsync(stream, "photo.jpg");
if (result.Success)
{
Console.WriteLine($"File saved with ID: {result.Data!.Id}");
}
else
{
Console.WriteLine($"Error: {result.ErrorMessage} (Code: {result.ErrorCode})");
}
// Or use implicit bool conversion
if (result)
{
// Success!
}
Every stored file has an associated FileRecord with rich metadata:
public class FileRecord
{
public Guid Id { get; set; } // Unique identifier
public string Name { get; set; } // Current name
public string OriginalName { get; set; } // Original upload name
public string Extension { get; set; } // File extension
public string ContentType { get; set; } // MIME type
public long SizeBytes { get; set; } // File size
public DateTime CreatedAtUtc { get; set; } // Creation timestamp
public DateTime UpdatedAtUtc { get; set; } // Last modified
public string? Hash { get; set; } // SHA-256 hash (optional)
public string? Folder { get; set; } // Folder/prefix
public string StoragePath { get; set; } // Provider-specific path
public Dictionary<string, string>? Metadata { get; set; } // Custom metadata
}
builder.Services.AddStashPup(stash => stash
.UseLocalStorage(options =>
{
options.BasePath = "./uploads";
options.BaseUrl = "/files";
options.OverwriteExisting = false;
options.AutoCreateDirectories = true;
options.EnableSignedUrls = true;
options.SigningKey = "your-secret-key-here";
}));
// Enable file serving middleware
app.UseStashPup();
builder.Services.AddStashPup(stash => stash
.UseS3(options =>
{
options.BucketName = "my-bucket";
options.Region = "us-east-1";
options.AccessKeyId = "AKIA...";
options.SecretAccessKey = "secret";
options.PublicRead = false;
options.EnableEncryption = true;
options.StorageClass = "STANDARD";
}));
builder.Services.AddStashPup(stash => stash
.UseAzureBlob(options =>
{
options.ConnectionString = "DefaultEndpointsProtocol=https;...";
options.ContainerName = "uploads";
options.AccessTier = "Hot";
options.PublicAccess = false;
options.CreateContainerIfNotExists = true;
}));
{
"StashPup": {
"Provider": "S3",
"S3": {
"BucketName": "my-bucket",
"Region": "us-east-1",
"EnableEncryption": true
}
}
}
builder.Services.AddStashPup(builder.Configuration);
var searchParams = new SearchParameters
{
NamePattern = "invoice*.pdf",
ContentType = "application/pdf",
MinSizeBytes = 1024,
CreatedAfter = DateTime.UtcNow.AddDays(-30),
Metadata = new Dictionary<string, string>
{
["category"] = "invoice"
},
SortBy = SearchSortField.CreatedAt,
SortDirection = SearchSortDirection.Descending,
Page = 1,
PageSize = 20
};
var result = await storage.SearchAsync(searchParams);
var thumbnailResult = await storage.GetThumbnailAsync(
fileId,
ThumbnailSize.Medium);
if (thumbnailResult.Success)
{
return Results.File(thumbnailResult.Data!, "image/jpeg");
}
var result = await storage.MoveAsync(
id: fileId,
newFolder: "archive/2024");
var result = await storage.CopyAsync(
id: fileId,
newFolder: "backups");
// Returns a new FileRecord with a new ID
Console.WriteLine($"Copied to new ID: {result.Data!.Id}");
// Bulk upload
var items = new[]
{
new BulkSaveItem(stream1, "file1.jpg", "images"),
new BulkSaveItem(stream2, "file2.jpg", "images"),
new BulkSaveItem(stream3, "file3.jpg", "images")
};
var results = await storage.BulkSaveAsync(items);
// Bulk delete
var idsToDelete = new[] { id1, id2, id3 };
await storage.BulkDeleteAsync(idsToDelete);
// Bulk move to new folder
var idsToMove = new[] { id1, id2, id3 };
var movedFiles = await storage.BulkMoveAsync(idsToMove, "archive/2024");
StashPup uses a fully virtual folder model - folders are just path prefixes on files. This means:
// List all unique folder paths (folders that have files)
var foldersResult = await storage.ListFoldersAsync();
foreach (var folder in foldersResult.Data!)
{
Console.WriteLine($"Folder: {folder}");
}
// List immediate children of a parent folder
var childFolders = await storage.ListFoldersAsync(parentFolder: "projects");
// Delete folder and all contents (recursive)
var deleteResult = await storage.DeleteFolderAsync(
folder: "temp/uploads",
recursive: true);
Console.WriteLine($"Deleted {deleteResult.Data} files");
// Delete only files in exact folder (non-recursive)
var deleteExact = await storage.DeleteFolderAsync(
folder: "temp",
recursive: false);
StashPup doesn't manage empty folders - that's your app's responsibility! Here's how:
// 1. Create database table for empty folders
public class EmptyFolder
{
public string Path { get; set; }
public DateTime CreatedAt { get; set; }
public Guid UserId { get; set; }
}
// 2. When user creates folder
db.EmptyFolders.Add(new EmptyFolder { Path = "Photos/2024", UserId = currentUserId });
await db.SaveChangesAsync();
// 3. Display merged list of folders
var realFolders = await storage.ListFoldersAsync();
var emptyFolders = await db.EmptyFolders.Where(f => f.UserId == currentUserId).ToListAsync();
var allFolders = realFolders.Data.Union(emptyFolders.Select(f => f.Path));
// 4. When file uploaded to empty folder, remove it
if (await db.EmptyFolders.AnyAsync(f => f.Path == uploadFolder))
{
db.EmptyFolders.RemoveRange(db.EmptyFolders.Where(f => f.Path == uploadFolder));
await db.SaveChangesAsync();
}
This keeps clean separation: StashPup handles files, your app handles empty folder UI!
// Search with enhanced folder filtering
var searchParams = new SearchParameters
{
FolderStartsWith = "projects/2024", // Match folders starting with prefix
IncludeSubfolders = true, // Include nested subfolders (default)
Page = 1,
PageSize = 50
};
var result = await storage.SearchAsync(searchParams);
// Search only in immediate folder (no subfolders)
var exactFolderSearch = new SearchParameters
{
Folder = "documents",
IncludeSubfolders = false, // Only files in "documents", not "documents/2024"
};
// Time-limited download URL
var urlResult = await storage.GetSignedUrlAsync(
fileId,
expiry: TimeSpan.FromHours(1));
if (urlResult.Success)
{
// Share this URL with users
var downloadUrl = urlResult.Data;
}
// Configure
options.EnableSignedUrls = true;
options.SigningKey = "your-secret-key";
// Generate
var url = storage.GetSignedUrl(fileId, TimeSpan.FromMinutes(30));
// Returns: /files/{id}?expires=...&signature=...
// Middleware automatically validates signatures
app.UseStashPup();
options.MaxFileSizeBytes = 50 * 1024 * 1024; // 50MB
options.AllowedExtensions = [".jpg", ".png", ".gif", ".webp"];
options.AllowedContentTypes = ["image/*"]; // Supports wildcards
options.ComputeHash = true; // Enable SHA-256 hashing
StashPup automatically detects content types using magic byte analysis:
// Detects based on file signature, not just extension
var result = await storage.SaveAsync(stream, "photo.jpg");
// result.Data.ContentType = "image/jpeg" (verified via magic bytes)
app.MapStashPupEndpoints("/api/files", options =>
{
options.RequireAuthorization = true;
options.EnableUpload = true;
options.EnableDownload = true;
options.EnableDelete = true;
options.EnableMetadata = true;
options.EnableList = false; // Disabled by default for security
options.EnableFolderList = false; // Disabled by default for security
options.EnableFolderDelete = true;
options.EnableBulkMove = true;
});
Available Endpoints:
POST /api/files - Upload fileGET /api/files/{id} - Download fileDELETE /api/files/{id} - Delete fileGET /api/files/{id}/metadata - Get metadataGET /api/files?folder=...&page=1&pageSize=20 - List files (opt-in)GET /api/files/folders?parent=... - List all folder paths (opt-in)DELETE /api/files/folders/{path}?recursive=true - Delete folder and contentsPOST /api/files/bulk-move - Move multiple files to new folderapp.MapPost("/api/upload-avatar", async (
IFormFile file,
IFileStorage storage,
ClaimsPrincipal user) =>
{
var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
await using var stream = file.OpenReadStream();
var result = await storage.SaveAsync(
stream,
file.FileName,
folder: $"avatars/{userId}",
metadata: new Dictionary<string, string>
{
["user-id"] = userId!,
["upload-date"] = DateTime.UtcNow.ToString("O")
});
return result.Success
? Results.Ok(new { fileId = result.Data!.Id, url = $"/files/{result.Data.Id}" })
: Results.BadRequest(new { error = result.ErrorMessage });
});
options.NamingStrategy = (originalFileName) =>
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var extension = Path.GetExtension(originalFileName);
return $"{timestamp}_{Guid.NewGuid()}{extension}";
};
options.SubfolderStrategy = (fileRecord) =>
{
// Organize by year/month
var now = DateTime.UtcNow;
return $"{now.Year}/{now.Month:D2}";
};
public class DocumentService
{
private readonly IFileStorage _storage;
public DocumentService(IFileStorage storage)
{
_storage = storage;
}
public async Task<Result<FileRecord>> SaveInvoice(
Stream pdfStream,
string customerId)
{
return await _storage.SaveAsync(
content: pdfStream,
fileName: $"invoice_{customerId}.pdf",
folder: $"invoices/{DateTime.UtcNow.Year}",
metadata: new Dictionary<string, string>
{
["customer-id"] = customerId,
["document-type"] = "invoice",
["processed"] = "false"
});
}
}
StashPup's interface-based design makes testing easy:
public class MockFileStorage : IFileStorage
{
private readonly Dictionary<Guid, FileRecord> _files = new();
public Task<Result<FileRecord>> SaveAsync(...)
{
var record = new FileRecord { Id = Guid.NewGuid(), ... };
_files[record.Id] = record;
return Task.FromResult(Result<FileRecord>.Ok(record));
}
// Implement other methods...
}
var result = await storage.SaveAsync(stream, fileName);
if (!result.Success)
{
switch (result.ErrorCode)
{
case FileStorageErrors.MaxFileSizeExceeded:
return Results.BadRequest("File too large");
case FileStorageErrors.InvalidFileExtension:
return Results.BadRequest("File type not allowed");
case FileStorageErrors.FileAlreadyExists:
return Results.Conflict("File already exists");
default:
logger.LogError("Upload failed: {ErrorMessage}", result.ErrorMessage);
return Results.StatusCode(500);
}
}
MIT License - see LICENSE file for details
Contributions are welcome! Please feel free to submit issues and pull requests.
For questions, issues, or feature requests, please open an issue on GitHub.