An embeddable web file manager for .NET 10, in the spirit of elFinder and Responsive File Manager. It is written in Blazor and also runs in ASP.NET Core MVC, React, or any HTML page through a custom element.
| Package | Link | Downloads |
|---|---|---|
| WebFilez.AspNetCore | ||
| WebFilez.Components | ||
| WebFilez.Core | ||
| WebFilez.Services |
| Package | Link | Downloads |
|---|---|---|
| WebFilez.Storage.AmazonS3 | ||
| WebFilez.Storage.AzureBlob | ||
| WebFilez.Storage.AzureFiles | ||
| WebFilez.Storage.GoogleCloud |
| Package | Link | Downloads |
|---|---|---|
| WebFilez.Plugins.FileRobot | ||
| WebFilez.Plugins.FortuneSheet | ||
| WebFilez.Plugins.SuperDoc |
- Browse several roots, each with its own path, permissions and optional public URL
- Folder tree, breadcrumbs, tiles, list and compact views, sorting, filtering by name and kind, paging
- Upload by picker or drag and drop, including whole folders, with progress, cancel and overwrite prompts
- Optional resizing of large uploaded images
- Create, rename, delete, copy, cut, paste, duplicate, and drag-and-drop moves (also across roots)
- Zip and tar compression, extraction with zip-slip and zip-bomb protection
- Single-file download, or a zip of any selection streamed from a one-time link
- Image, audio, video, PDF and text preview; a text editor that detects concurrent edits; other editors plugged in per file type
- Thumbnails (WebP, cached on disk) and an info panel with dimensions and folder sizes
- A picker mode (single or multiple, with an
acceptfilter) for choosing files from a form - Keyboard shortcuts, context menus, light, dark, auto and Bootstrap-friendly themes, and a layout that adapts to its container's width
| Project | Purpose |
|---|---|
WebFilez.Core |
DTOs, enums, IFileManagerService, VirtualPath, ApiRoutes. No dependencies beyond Ardalis.Result. |
WebFilez.Services |
The file manager, the file system provider, options, access extension points, archives, thumbnails and transfers. |
WebFilez.Storage.AzureBlob |
The Azure Blob Storage provider. Register it with AddWebFilezAzureBlob(). |
WebFilez.Storage.AzureFiles |
The Azure Files provider. Register it with AddWebFilezAzureFiles(). |
WebFilez.Storage.AmazonS3 |
The Amazon S3 provider. Register it with AddWebFilezAmazonS3(). |
WebFilez.Storage.GoogleCloud |
The Google Cloud Storage provider. Register it with AddWebFilezGoogleCloud(). |
WebFilez.AspNetCore |
AddWebFilez() and MapWebFilezApi(): the HTTP API, returning ProblemDetails on failure. |
WebFilez.Components |
The FileManager Razor component, its JavaScript and CSS, and an HTTP client for WebAssembly. Editors are registered by the host page; this project does not reference one. |
WebFilez.Plugins.FileRobot |
Optional FileRobot image editor. Load its script and name it in Editors. The component does not reference this package. |
WebFilez.Plugins.SuperDoc |
Optional SuperDoc editor for .docx. Load its script and name it in Editors. SuperDoc itself is not included; a commercial license key is supplied by the host. |
WebFilez.Plugins.FortuneSheet |
Optional Fortune Sheet editor for .xlsx and .csv. Load its script and name it in Editors. Fortune Sheet itself is not included. |
WebFilez.CustomElements |
A Blazor WebAssembly app that registers <wf-file-manager> for React and plain HTML. |
WebFilez.Tests |
xUnit tests for the path safety rules, the services, and an HTTP round trip. |
WebFilez.Demo.* |
Blazor, MVC and React demos. |
The component talks to IFileManagerService. On Blazor Server (and therefore MVC) that is the real service, in-process. In WebAssembly it is an HTTP client that calls the API. Either way, bytes always travel over HTTP: uploads, downloads, thumbnails and zips go to the endpoints from MapWebFilezApi(), so every host needs the API mapped somewhere.
There is no database in this version. Roots come from configuration. Each root names a storage provider; the file system is the default, and optional packages add Azure Blob Storage, Azure Files, Amazon S3 and Google Cloud Storage. The extension points below are where a host such as ArcaFS plugs in its own roots, permissions, audit history, or a storage provider of its own.
{
"WebFilez": {
"Roots": [
{ "Id": "files", "Name": "Files", "Path": "App_Data/files" },
{ "Id": "media", "Name": "Media", "Path": "App_Data/media", "PublicUrl": "/media" },
{ "Id": "handbook", "Name": "Handbook", "Path": "App_Data/handbook", "IsReadOnly": true }
],
"MaxUploadBytes": 104857600,
"ImageResize": { "Enabled": true, "MaxWidth": 2560, "MaxHeight": 2560 }
}
}Relative paths on a file system root resolve against the content root. A path on any other provider is a prefix inside that store and is left as written. Other root settings:
Permissions: aFilePermissionset such asReadOnly,ContributororFull(the default).IsReadOnlyclamps whatever is set to non-changing permissions.PublicUrl: where the root is served, such as/mediafor a folder underwwwroot. The path inside the root is appended, and the picker returns that URL. WebFilez doesn't serve it; map it yourself withUseStaticFiles.PublicBaseUrlis the CDN form of the same idea. See Storage setups and file URLs.ShowHiddenFiles: allow dot files and hidden-attribute files to be shown and reached. Off by default.FollowSymlinks: allow paths through links, which may lead outside the root. Off by default.ExcludePatterns: glob patterns, such as*.bakornode_modules, that are hidden and unreachable.
Global settings on WebFilezOptions include AllowedExtensions (if set, only these may be uploaded or created), BlockedExtensions, FileNames (replace spaces, lower-case, remove diacritics), Thumbnails, MaxEditableFileBytes, MaxExtractedBytes and MaxExtractedEntries. PublicBaseUrl applies to every root that does not set its own. AllowExpiringUrls and ProbePublicUrls stay off unless you turn them on; both are described under Storage setups and file URLs.
BlockedExtensions starts with a list of server-executable and script-capable types, such as .php, .aspx, .html and .svg. Every dotted suffix is checked, so shell.php.jpg is refused too. Configuration binding adds to a list rather than replacing it, so entries in appsettings.json extend the defaults. To remove a default, change the list in code:
builder.Services.AddWebFilez(builder.Configuration, options => options.BlockedExtensions.Remove(".svg"));builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddWebFilez(builder.Configuration);
// ...
app.MapWebFilezApi().RequireAuthorization();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();Add the stylesheet to App.razor, and @using WebFilez.Components to _Imports.razor:
<link rel="stylesheet" href="@Assets["_content/WebFilez.Components/webfilez.css"]" /><FileManager Height="600px" Theme="auto" OnNavigated="location => current = location" />
<FileManager Root="media"
Path="42/7"
SelectionMode="single"
Kind="images"
Accept="image/*"
SelectLabel="Use this image"
OnSelected="files => imageUrl = files[0].PublicUrl ?? files[0].Url" />The component must render interactively. In Blazor WebAssembly, call AddWebFilezHttpClient(apiBaseUrl) instead of AddWebFilez, and host the API in a server project.
Parameters: Root, Path, Theme (light, dark, auto, bootstrap), View (tiles, list, compact), Height, SelectionMode (none, single, multiple), Accept, Kind, SelectLabel, ShowTree, ReadOnly, Class, OnSelected and OnNavigated. Other attributes are passed to the outer element.
Kind fixes the listing to images, documents, media, archives, folders, or all, and hides the type buttons. images and media still show folders, so the user can open them. folders shows directories only. Accept still decides which of the listed files the Select button accepts. Double-clicking an accepted file selects it.
The component's root element carries data-wf-root, data-wf-path, and data-wf-public-url for the folder that is open. data-wf-public-url is present when the root has a PublicUrl, ends with a slash, and can be read synchronously. wf-navigated carries the same address as publicUrl. A folder field can store that value without a Select button. File and folder items include PublicUrl as well; folder addresses end with a slash.
builder.Services.AddControllersWithViews();
builder.Services.AddServerSideBlazor();
builder.Services.AddWebFilez(builder.Configuration);
// ...
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
app.MapBlazorHub();
app.MapWebFilezApi().RequireAuthorization();In the layout, add the stylesheet and <script src="~/_framework/blazor.server.js"></script>. Then render the component in any view:
<component type="typeof(WebFilez.Components.FileManager)" render-mode="Server" param-Height="@("600px")" />Parameters are passed as param-Name. Event callbacks aren't available this way, so the component also raises DOM events that bubble from its element:
document.addEventListener('wf-navigated', e => console.log(e.detail.rootId, e.detail.path, e.detail.publicUrl));
document.addEventListener('wf-selected', e => console.log(e.detail.files));
const folder = document.querySelector('[data-wf-public-url]')?.getAttribute('data-wf-public-url');WebFilez.CustomElements is a Blazor WebAssembly app that registers <wf-file-manager>. It is not a NuGet package and not an npm package. Publish it and serve the static files from the site that hosts the page:
dotnet publish WebFilez.CustomElements -c ReleaseCopy wwwroot/_framework and wwwroot/_content from that output so the site serves them at /_framework and /_content. In a Vite app, put both folders in public/. Asset names stay stable between publishes.
The app reads its API address from /appsettings.json on the page's origin. In Vite that file is public/appsettings.json:
{
"WebFilez": {
"ApiBaseUrl": "https://files.example.com/api/webfilez",
"IncludeCredentials": true
}
}A plain page loads the stylesheet and the runtime, then uses the element. The script is a classic script tag, not type="module":
<link rel="stylesheet" href="/_content/WebFilez.Components/webfilez.css" />
<wf-file-manager height="600px" theme="auto" root="media" path="42/7" base-path="42/7" scope-token="..." selection-mode="single" kind="images" accept="image/*"></wf-file-manager>
<script src="/_framework/blazor.webassembly.js"></script>Attributes are kebab-case versions of the component parameters. wf-navigated and wf-selected are DOM events.
base-path makes that folder the top of this instance. The up button, breadcrumbs, folder tree and search stop there, and the server refuses every path above it, including uploads, downloads and thumbnails. Leave it empty to browse the whole root. A server render mints the token that enforces this. A WebAssembly page cannot, so pass scope-token from IFileScopeProtector.Protect(rootId, basePath) on your server, using the same base path.
Add the same stylesheet and script to index.html. The script follows the React module:
<link rel="stylesheet" href="/_content/WebFilez.Components/webfilez.css" />
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script src="/_framework/blazor.webassembly.js"></script>React does not pass custom-element events through as props. Listen for them on the element. Boolean parameters are HTML attributes, so pass "true" and "false":
import { useEffect, useRef } from 'react'
export function FileManager({
root, path, basePath, scopeToken, theme = 'auto', height = '600px',
selectionMode = 'none', accept, kind, selectLabel, showTree = true,
readOnly = false, editors, onNavigated, onSelected,
}) {
const ref = useRef(null)
const handlers = useRef({ onNavigated, onSelected })
useEffect(() => {
handlers.current = { onNavigated, onSelected }
})
useEffect(() => {
const element = ref.current
const navigated = event => handlers.current.onNavigated?.(event.detail)
const selected = event => handlers.current.onSelected?.(event.detail.files)
element.addEventListener('wf-navigated', navigated)
element.addEventListener('wf-selected', selected)
return () => {
element.removeEventListener('wf-navigated', navigated)
element.removeEventListener('wf-selected', selected)
}
}, [])
return (
<wf-file-manager
ref={ref}
root={root}
path={path}
base-path={basePath}
scope-token={scopeToken}
theme={theme}
height={height}
selection-mode={selectionMode}
accept={accept}
kind={kind}
select-label={selectLabel}
show-tree={String(showTree)}
read-only={String(readOnly)}
editors={editors}
/>
)
}onNavigated receives { rootId, path, publicUrl }. onSelected receives the picked files, each with { rootId, path, name, sizeBytes, mimeType, url, publicUrl }, when selectionMode is "single" or "multiple". The React demo's src/WebFilez.jsx listens for those events the same way. Its Vite plugin publishes the custom-element app and copies _framework and _content into the build, so npm run dev is enough inside that demo. Another React app copies those folders itself. Start the API before the page.
When the API is on another origin, it needs a CORS policy. Uploads send an X-WebFilez header, so that header must be allowed:
builder.Services.AddCors(options => options.AddPolicy("WebFilez", policy => policy
.WithOrigins("https://app.example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()
.WithExposedHeaders("Content-Disposition")));
app.UseCors();
app.MapWebFilezApi().RequireCors("WebFilez");IncludeCredentials makes uploads, downloads and thumbnails send cookies to that origin. In the custom elements app, IncludeCredentialsHandler does the same for JSON calls. For bearer tokens, add your own handler through the configureClient argument of AddWebFilezHttpClient. Downloads and thumbnails are plain URLs, so they need cookie authentication when the API is protected.
The built-in editor is for text and code. Anything else is a JavaScript editor the host page registers, with the file types it should open. Several can be registered, and one file manager can use a different mapping from another.
import { registerEditor } from '/_content/WebFilez.Components/webfilez.js';
registerEditor({
id: 'pintura',
name: 'Pintura',
accept: '.jpg,.jpeg,.png,.webp', // same syntax as the Accept parameter
open(file, host) {
// file.sourceUrl is a blob URL of the bytes, so a canvas is not tainted.
// file also has name, path, rootId, url, mimeType, theme and language.
// host.element is an empty full-screen container.
// return a function that disposes the editor.
},
});host.save(blob, fileName) writes the bytes back through the upload endpoint and resolves when that finishes, so the editor can keep its own spinner up until then. The same file name replaces the original. A different name is saved beside it, and a conflict or a rejected type comes back as a rejected promise. host.close() dismisses the editor.
Editors on the component (the editors attribute on <wf-file-manager>) assigns those ids to types for one instance:
<FileManager Editors="images:.jpg,.jpeg,.png,.webp|annotate:.gif,.bmp" />Entries are separated by |. An id with no accept list keeps the types from registerEditor. Leave Editors empty and every registered editor is offered for the types it named. Double-click opens the first match when the file can be modified. The context menu lists every match as "Edit with ...", and Preview stays there too. Types that no editor claims still open in the text editor or the preview, as before.
An editor can also be queued before the module loads, which suits a plain script tag:
<script>
window.WebFilez = { editors: [{ id: 'pintura', name: 'Pintura', accept: '.jpg,.png', open(file, host) { /* ... */ } }] };
</script>WebFilez.Plugins.FileRobot is one such editor, shipped separately so the file manager does not know about it. The demos reference that package and load its script, which registers id filerobot for JPEG, PNG, WebP, GIF, BMP and AVIF:
<script type="module" src="_content/WebFilez.Plugins.FileRobot/filerobot.js"></script>
<FileManager Editors="filerobot:.jpg,.jpeg,.png,.webp,.gif,.bmp,.avif" />GIF animation is flattened to one frame. SVG is never rendered inline, and browsers do not decode TIFF, so those stay on preview or download. The script loads the Roboto font FileRobot asks for; the demo pages load it too. FileRobot's save dialog spells a JPEG as .jpeg; saving the original name that way still replaces a .jpg. FileRobot Image Editor is MIT, including commercial use, and it has no license key. Another editor is the same shape: its own package, its own script, and an entry in Editors.
WebFilez.Plugins.SuperDoc registers id superdoc for .docx. The package does not contain SuperDoc. When a document opens, the script loads SuperDoc 2.24.0 and its DOCX engine from jsDelivr. SuperDoc's editor is AGPL-3.0 for open-source use, and the engine it downloads is proprietary software used as SuperDoc's dependency. A proprietary or commercial deployment needs a commercial license and passes that license key itself. No key is included.
<script>
window.WebFilez = window.WebFilez || {};
window.WebFilez.superdoc = { licenseKey: 'your-commercial-key' };
</script>
<script type="module" src="_content/WebFilez.Plugins.SuperDoc/superdoc.js"></script>
<FileManager Editors="superdoc:.docx" />registerSuperDoc({ licenseKey }) does the same thing from a module. The key is sent to SuperDoc as its client-visible license identity. Leave it unset and SuperDoc uses its community identity. The key is not a substitute for signing in to the file manager.
WebFilez.Plugins.FortuneSheet registers id fortunesheet for .xlsx and .csv. The package does not contain Fortune Sheet. When a spreadsheet opens, the script loads Fortune Sheet 1.0.4 and the fortune-excel converter 2.3.4. Both are MIT, including commercial use, and neither takes a license key. Fortune Sheet draws the grid, and fortune-excel reads the file into that grid and writes it back. Saving keeps the original format: a .csv is written as CSV, and a .xlsx is written as xlsx. The older .xls format is not opened here.
<script type="module" src="_content/WebFilez.Plugins.FortuneSheet/fortunesheet.js"></script>
<FileManager Editors="fortunesheet:.xlsx,.csv" />The component fills its container's width and the Height you give it, and adapts its layout to the container rather than the window. Menus and dialogs are fixed-position overlays, so don't place the component inside an element with a CSS transform, filter or perspective. Those create a new containing block and misplace the overlays. To centre a modal around the picker, use flexbox or <dialog>, not translate(-50%, -50%).
- Protect the API with
RequireAuthorization()or your own policy. WebFilez does no authentication of its own. - Permissions and per-folder policies only control the manager UI and API. They do not secure a public bucket or a folder under
wwwroot. Anyone with the file's URL can read it. - Every path is normalised and clamped to its root. Links, hidden files and excluded names are refused unless the root allows them, and archive entries that would land outside the destination are skipped.
- HTML, SVG and other script-capable types are always downloaded as attachments, never rendered inline, and every file response sends
X-Content-Type-Options: nosniff. - Uploads require the
X-WebFilezheader, which a cross-site form can't send, and are size-limited as they stream in. - Zip downloads use short-lived, single-use tickets, and the items are permission-checked again when the zip is written.
Provider selects where a root is stored. Leave it out, or set filesystem, and the root is a directory on disk. azure-blob stores it in an Azure Blob container. azure-file stores it in an Azure file share, which has real directories. amazon-s3 stores it in an S3 bucket. google-cloud stores it in a Cloud Storage bucket. Register the providers beside AddWebFilez:
builder.Services.AddWebFilez(builder.Configuration);
builder.Services.AddWebFilezAzureBlob();
builder.Services.AddWebFilezAzureFiles();
builder.Services.AddWebFilezAmazonS3();
builder.Services.AddWebFilezGoogleCloud();{
"Id": "uploads",
"Name": "Uploads",
"Provider": "azure-blob",
"Path": "campaigns",
"Settings": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net",
"Container": "files"
}
}Path is the folder inside the container, bucket or share. Settings.Prefix overrides it when you want the prefix separate from Path. The container, bucket or share is not created for you, and an Azure Files root also needs that directory to exist already.
Blob folders are prefixes. An empty folder is a hidden zero-length marker blob inside that prefix, because the blob client drops a trailing slash from a blob name. Blob names are case-sensitive. For local blob testing, run the Azurite image (docker run -d --name azurite --restart unless-stopped -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite) and set ConnectionString to UseDevelopmentStorage=true. It listens on 127.0.0.1:10000. Allow anonymous blob read on the container, or inserted images stay private. Azurite is HTTP-only, so an HTTPS page blocks those URLs. The demos proxy the emulator at /azurite and the sample root sets PublicBaseUrl to /azurite/files. Azurite emulates Blob, Queue, and Table only, so it cannot stand in for an Azure file share. An Azure file share has real directories, so creating a folder makes an empty one, and names are case-insensitive. Dot names stay hidden unless ShowHiddenFiles is on. Neither store has symbolic links.
{
"Id": "shared",
"Name": "Shared",
"Provider": "azure-file",
"Path": "campaigns",
"Settings": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net",
"Share": "files"
}
}{
"Id": "photos",
"Name": "Photos",
"Provider": "amazon-s3",
"Path": "campaigns",
"Settings": {
"AccessKey": "...",
"SecretKey": "...",
"Region": "us-east-1",
"Bucket": "files"
}
}{
"Id": "archive",
"Name": "Archive",
"Provider": "google-cloud",
"Path": "campaigns",
"Settings": {
"Bucket": "files",
"CredentialFile": "C:\\keys\\storage.json"
}
}S3 and Cloud Storage folders are prefixes, with the same hidden marker as Azure Blob, and object names are case-sensitive. The bucket is not created for you. For local S3 testing, run the last community image, localstack/localstack:4.14.0 (docker run -d --name localstack -p 4566:4566 -e SERVICES=s3 localstack/localstack:4.14.0). localstack/localstack:latest exits unless LOCALSTACK_AUTH_TOKEN is set. Set ServiceUrl to http://127.0.0.1:4566 and any access key. For local Cloud Storage testing, start fake-gcs-server with -scheme http (the image defaults to HTTPS) and set ServiceUrl to http://127.0.0.1:4443. Leave the credentials out. It does not serve /{bucket}/{object}, and an HTTPS page would block that HTTP address anyway. The demos proxy its download API at /gcs, and the sample root sets PublicBaseUrl to /gcs/files. CredentialJson is the other way to pass a Google service-account key.
A host can add anything else by implementing IStorageProvider and registering it. The Key is the value used in Provider:
builder.Services.AddSingleton<IStorageProvider, MyStorageProvider>();
builder.Services.AddWebFilez(builder.Configuration);Copy, move and zip across two providers stream the bytes. Two roots on the same provider use that provider's own copy.
The URL stored in page content is the file's PublicUrl. Listings, details, search hits, uploads and the picker's PublicUrl all come from IFileUrlResolver. Downloads, previews and thumbnails inside the manager stay on the WebFilez API, which checks permissions. Those addresses are for the manager, not for content.
Bucket and container access is the host's responsibility. WebFilez does not change who can read an object. To make a bucket or container publicly readable behind a CDN, see Azure Blob anonymous read access, Amazon S3 Block Public Access and Cloud Storage public data.
- Public bucket: works out of the box.
- Private bucket with the manager UI only: browsing and uploads work, but inserted URLs won't load for visitors.
- Private bucket with embedded content: the host must supply their own URL resolver.
Set PublicBaseUrl on a root, as Settings.PublicBaseUrl, or as WebFilez:PublicBaseUrl. The insertable URL is then {PublicBaseUrl}/{key} instead of the account host (bucket.s3.amazonaws.com, account.blob.core.windows.net, storage.googleapis.com). The bucket, container or share name is not added again. Each path segment is encoded, including spaces, and slashes are not doubled.
PublicUrl still means "this address is the root". The path inside the root is appended, which is the existing behaviour for a file-system root and for any root that already set PublicUrl. A root's own PublicUrl wins over the global base. PublicBaseUrl wins over PublicUrl.
Without either, the storage package's IStoragePublicUrl supplies the plain object URL. A file-system root has no public URL, as before. Azure Files uses the same PublicBaseUrl; its plain URL is https://{account}.file.core.windows.net/{share}/{key}, which is not anonymously readable.
Amazon S3:
{
"Id": "photos",
"Name": "Photos",
"Provider": "amazon-s3",
"Path": "campaigns",
"PublicBaseUrl": "https://cdn.example.com",
"Settings": {
"AccessKey": "...",
"SecretKey": "...",
"Region": "us-east-1",
"Bucket": "files"
}
}https://cdn.example.com/campaigns/photo.png, not https://files.s3.us-east-1.amazonaws.com/files/campaigns/photo.png.
Azure Blob. Settings.PublicBaseUrl is the same setting as the root property:
{
"Id": "uploads",
"Name": "Uploads",
"Provider": "azure-blob",
"Path": "campaigns",
"Settings": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net",
"Container": "files",
"PublicBaseUrl": "https://cdn.example.com"
}
}Google Cloud Storage:
{
"Id": "archive",
"Name": "Archive",
"Provider": "google-cloud",
"Path": "campaigns",
"PublicBaseUrl": "https://cdn.example.com",
"Settings": {
"Bucket": "files"
}
}Local disk. PublicUrl stays a site path. PublicBaseUrl is the same kind of override when a CDN serves that folder:
{
"Id": "media",
"Name": "Media",
"Path": "App_Data/media",
"PublicUrl": "/media",
"PublicBaseUrl": "https://cdn.example.com/media"
}PublicBaseUrl wins, so a file holiday/a.jpg is https://cdn.example.com/media/holiday/a.jpg. The disk path is not part of the URL.
Register it before AddWebFilez. The default is added with TryAdd, so yours is kept:
builder.Services.AddSingleton<IFileUrlResolver, AppFileUrlResolver>();
builder.Services.AddWebFilez(builder.Configuration);For a private bucket whose contents are embedded in pages, return an address the application owns. The application checks the caller and redirects to a signed URL minted for that request, so nothing stored in the page expires:
public sealed class AppFileUrlResolver : IFileUrlResolver
{
public ValueTask<string?> ResolveAsync(FileUrlContext context, CancellationToken cancellationToken = default)
{
string path = context.VirtualPath.TrimStart('/');
return ValueTask.FromResult<string?>($"/files/{context.Root.Id}/{path}");
}
}// GET /files/{rootId}/{*path}
// Check the caller, then redirect to a fresh SAS or presigned URL for that one object.FileUrlContext carries the root, the provider key, the virtual path, and AllowExpiringUrls. Return null when the file has no public address.
WebFilez does not insert expiring signed URLs by default. A presigned or SAS URL works today and fails later, and pages that stored it keep the dead address. The built-in resolver always returns a stable URL.
To opt in, set AllowExpiringUrls to true and register a resolver that returns a signed URL only when FileUrlContext.AllowExpiringUrls is true. With the flag set and no custom resolver, the built-in one still returns the stable URL and logs a warning.
Per-folder permissions and IFileAccessPolicy only control what the manager UI and API allow. They do not secure files in a public bucket or web root. A file the policy hides can still be fetched by its public URL.
Set ProbePublicUrls to true. WebFilez then resolves one file's insertable URL and requests it anonymously, once at startup and from GET /api/webfilez/url-check. That route is on the same API group, so it follows the same authorization as the rest. A 401, 403 or 404 is logged, and the endpoint returns: "This URL is not publicly readable. Inserted files may not display for site visitors. See README: Storage setups." The check never blocks startup and never fails an upload. Leave the flag off and nothing is requested. GET /api/webfilez/url-check?root=photos&path=/holiday/a.jpg checks that file instead of the first one found.
These are registered with TryAdd, so register your own implementation before calling AddWebFilez:
builder.Services.AddScoped<IFileRootProvider, TenantRootProvider>();
builder.Services.AddScoped<IFileAccessPolicy, AclAccessPolicy>();
builder.Services.AddScoped<IFileActivitySink, AuditLogSink>();
builder.Services.AddSingleton<IFileUrlResolver, AppFileUrlResolver>();
builder.Services.AddWebFilez(builder.Configuration);IFileRootProvidersupplies the roots for the current request. The default readsWebFilezOptions.Roots. Use it for per-user or per-tenant roots, or roots stored in a database.IFileAccessPolicyreturns theFilePermissionset for a root and path. The default uses the root's permissions. Use it to look up per-user or per-folder access. This does not secure a public bucket or web root; see Storage setups and file URLs.IFileUrlResolverreturns the URL stored in content. The default is a stable public address. Replace it for a private bucket. See Storage setups and file URLs.IFileActivitySinkis told about every successful change and download, with the kind, root, path and target. Use it for an audit log, a search index or cache invalidation. Exceptions thrown from it are logged and swallowed.IStorageProviderstores the bytes for roots whoseProvidermatches itsKey. The file system provider is registered byAddWebFilez, and its key isFileSystemStorageProvider.ProviderKey. Every other package publishes its own key, such asAzureBlobStorageProvider.ProviderKey, and is registered withAddSingleton<IStorageProvider, ...>()or that package'sAddmethod. Registering before or afterAddWebFilezboth work.WebFilez.Servicesis the contract those packages reference. A package that has a plain object URL also registersIStoragePublicUrl. The default resolver asks the first contributor whose key matches, and only when the root sets no public base. Adding a provider does not require a change inWebFilez.Services.
dotnet run --project WebFilez.Demo.Blazor # https://localhost:7311, also hosts the API for the others
dotnet run --project WebFilez.Demo.Mvc # https://localhost:7312
dotnet run --project WebFilez.CustomElements # https://localhost:7313, plain HTML
cd webfilez.demo.react; npm install; npm run dev # http://localhost:5173The demos seed sample files into App_Data on first run. The custom element and React demos call the Blazor demo's API, so start that first.
dotnet run --project WebFilez.TestsMany thanks to Flavio Smirne for his contribution of the Azure Files storage provider.
If you find this project useful, consider supporting its development.
Prefer using your phone? Scan the QR code:


