8.0.0
8.0.0
Highlights
This release is the architectural-review pass the project has been building toward — issues #369, #380, #409, #420, and #425, closed in lockstep — plus a brand-new Redis lock provider and a sharper provider-registration story. The breaking surface is mostly in WopiHost.Abstractions: the generic IWopiStorageProvider/IWopiWritableStorageProvider are split into typed file/container pairs, IWopiFolder is gone (replaced by IWopiContainer), IWopiFile.GetReadStream/GetWriteStream are renamed to OpenReadAsync/OpenWriteAsync, and the WopiHostOptions assembly-name strings have been replaced by typed Add{Provider}… extensions. See Migration guide below.
✨ New
- Redis lock provider (#412) — new
WopiHost.RedisLockProviderpackage. Best-effort, single-Redis (does not implement Redlock — see its README); atomicity via Lua scripts; TTL-driven 30-min WOPI expiry. Aspire AppHost flips on a Redis container by default for the orchestrated dev loop. - Typed provider-registration extensions (#428) —
AddFileSystemStorageProvider(cfg),AddAzureStorageProvider(cfg),AddMemoryLockProvider(),AddAzureLockProvider(cfg),AddRedisLockProvider(cfg). Replaces the previousWopiHostOptions.StorageProviderAssemblyName/LockProviderAssemblyNamestring-dispatch +AssemblyLoadContextreflection fallback. IWopiHostExtensionshost-customization seam (#417) — replacesWopiHostOptions.OnCheckFileInfo/OnCheckContainerInfo/OnCheckFolderInfo/OnCheckEcosystemcallbacks with a single overrideable seam. Plug audit, telemetry, or response mutations by registering a subclass.ICheckFileInfoBuilder/ICheckContainerInfoBuilder/ICheckFolderInfoBuilder— pluggable response builders, scoped DI lifetime.WopiHost.Abstractions.Testing— sharedLockProviderConformanceTestsxUnit class that everyIWopiLockProviderimplementation runs through (#411).- Atomic lock CAS contract (#412) —
IWopiLockProvider.TryUnlockAndRelockAsyncand the newRefreshLockAsync(fileId, expectedExistingLockId, …)signature push the spec-mandated compare-and-swap into the provider, eliminating the check-then-act race the controller layer had been doing. - Playwright sample-frontend smoke tests (#368, #357 part A) —
WopiHost.SmokeTestsruns against bothWopiHost.WebandWopiHost.Validator. WopiHost.Cobaltunit tests (#367) — 42 tests added against the previously-untested Cobalt project.
🐛 Fixes
InMemoryFileIdsthread-safe + O(1) reverse lookup (#418) — the FileSystem provider's id↔path map was racy and linear on path lookup.JwtAccessTokenServiceephemeral dev key wrapped inLazy<T>(#422).WopiOriginValidationActionFilterauth-ordering made explicit (#407) — the filter validates the proof-key signature over the access token, so it must run after authentication. The dependency is now self-checking (runtime guard + log on misuse) instead of implicit in the pipeline shape.- WOPI spec-correctness audit (#362) — all 7 items from #359 fixed.
- Dead
catch (Exception)removed,CheckValidNameenforced in rename/delete (#415). CheckValidFileName/CheckValidContainerNameunified + warning-suppression audit (#429, closes #416).- Controllers restructured to clear Infer# null-deref false positives (#365, follow-up to #363).
SHA256.HashDataAsync, drop shared static instance (#375) — eliminates contention on the previously sharedSHA256.Create()instance.- Sample apps gate
UseDeveloperExceptionPagebehindIsDevelopment(#381). - AppHost launch hardening — fixes for dynamic backend ports, Aspire 13.x
isProxied:falsehangs, CollaboraReferenceExpressioncontainer-startup hangs, VS-injectedASPNETCORE_HOSTINGSTARTUPASSEMBLIES, andASPNETCORE_ENVIRONMENT=Developmentforwarding to child projects.
💥 Breaking changes
WopiHost.Abstractions
| Before (7.x) | After (8.0) | PR | |
|---|---|---|---|
| Container interface | IWopiFolder |
IWopiContainer (now carries Size + ChildCount) |
#374, #426 |
| File size property | IWopiFile.Size |
IWopiFile.Length |
#373 |
| File read/write seams | IWopiFile.GetReadStream / GetWriteStream |
IWopiFile.OpenReadAsync / IWopiWritableFile.OpenWriteAsync |
#379 |
| Write seam location | both on IWopiFile |
split — read on IWopiFile, write on IWopiWritableFile |
#373 (item 1.2 of #420) |
| Storage provider shape | IWopiStorageProvider had generic GetWopiResource<T> / GetAncestors<T> / GetWopiResourceByName<T> and accepted null "root" ids |
typed pairs: GetWopiFile / GetWopiContainer, GetFileAncestors / GetContainerAncestors, GetWopiFileByName / GetWopiContainerByName; root addressing via RootContainer.Identifier only |
#424, #374 |
| Writable storage provider | CreateWopiChildResource<T> / DeleteWopiResource<T> / RenameWopiResource<T> / CheckValidName<T> / GetSuggestedName<T> |
typed pairs: CreateWopiChildFile / CreateWopiChildContainer, DeleteWopiFile / DeleteWopiContainer, RenameWopiFile / RenameWopiContainer, CheckValidFileName / CheckValidContainerName, GetSuggestedFileName / GetSuggestedContainerName, plus new GetWritableFile |
#424 |
| Root container | IWopiStorageProvider.RootContainerPointer (IWopiFolder) |
IWopiStorageProvider.RootContainer (IWopiContainer) |
#374 |
| File enumeration filter | GetWopiFiles(containerId, string? searchPattern, …) |
GetWopiFiles(containerId, IReadOnlyCollection<string>? fileExtensions = null, …) — leading-dot, case-insensitive, matches WOPI X-WOPI-FileExtensionFilterList |
#377 |
| Permission provider | GetContainerPermissionsAsync(…, IWopiFolder, …) |
GetContainerPermissionsAsync(…, IWopiContainer, …) |
#374 |
| Cobalt processor | ICobaltProcessor.ProcessCobalt(IWopiFile, …) |
ICobaltProcessor.ProcessCobalt(IWopiWritableFile, …) |
#373 |
| Lock provider | RefreshLockAsync(fileId, …) (controller-side CAS) |
RefreshLockAsync(fileId, expectedExistingLockId, …) + new TryUnlockAndRelockAsync(fileId, newLockId, expectedExistingLockId, …) — provider-side atomic CAS |
#412 |
| Lock expiry | WopiLockInfo.Expired (ambient clock) |
WopiLockInfo.IsExpiredAt(DateTimeOffset now) — caller supplies clock |
#412 |
| Auth requirement | IWopiAuthorizationRequirement.ResourceId (shared-state race) |
removed; resource id flows through HttpContext instead |
#408 |
| Configuration sections | WopiConfigurationSections static enum |
per-options Options.SectionName const |
#405 |
| Proof validator | IWopiProofValidator in WopiHost.Core.Security.Authentication |
moved to WopiHost.Abstractions |
#378 |
WopiHost.Core
| Before (7.x) | After (8.0) | PR | |
|---|---|---|---|
| Provider registration | WopiHostOptions.StorageProviderAssemblyName / LockProviderAssemblyName + reflection scan |
typed services.Add{Provider}…(cfg) extensions per provider package |
#428 |
CheckXxxInfo customization |
WopiHostOptions.OnCheckFileInfo / OnCheckContainerInfo / OnCheckFolderInfo / OnCheckEcosystem callbacks + WopiCheck*Context types |
IWopiHostExtensions (override one or more virtuals) + per-builder seams (ICheckFileInfoBuilder, etc.) |
#417 |
ChildFile |
mutable setters | init-only |
#427 |
WopiAuthorizeAttribute.ResourceId |
property | removed (resource id is on the route) | #408 |
WopiExtensions.GetWopiCheck*Info |
extension methods | replaced by the corresponding builder DI seam | #417 |
| Controller ctors | – | ICobaltProcessor? removed from FilesController; new lock/storage parameter shapes |
#373, #424 |
Runtime/data breaks (not API)
- Azure-blob resource IDs are now case-sensitive (#406) —
WopiResourceId.FromCanonicalPathno longer case-folds blob paths before SHA-256-ing them, because Azure Blob Storage is itself case-sensitive (the previous behaviour mirrored the filesystem provider, where it's correct). Lock blobs / token-bound ids minted by 7.x against Azure containers will not match what 8.0 produces. Drain in-flight locks and reissue access tokens during the upgrade; the filesystem provider is unaffected.
Migration guide
1. Storage-provider implementations
The single generic <T>-discriminated IWopiStorageProvider / IWopiWritableStorageProvider are now typed file/container pairs.
-public class MyProvider : IWopiStorageProvider, IWopiWritableStorageProvider
-{
- public IWopiFolder RootContainerPointer { get; }
- public Task<T?> GetWopiResource<T>(string id, CancellationToken ct) where T : class, IWopiResource { ... }
- public IAsyncEnumerable<IWopiFile> GetWopiFiles(string? containerId, string? searchPattern, CancellationToken? ct) { ... }
- public IAsyncEnumerable<IWopiFolder> GetWopiContainers(string? containerId, CancellationToken ct) { ... }
- public Task<ReadOnlyCollection<IWopiFolder>> GetAncestors<T>(string id, CancellationToken ct) where T : IWopiResource { ... }
- public Task<T?> GetWopiResourceByName<T>(string parent, string name, CancellationToken ct) where T : class, IWopiResource { ... }
- public Task<T?> CreateWopiChildResource<T>(string? parent, string name, CancellationToken ct) where T : class, IWopiResource { ... }
- public Task<bool> DeleteWopiResource<T>(string id, CancellationToken ct) where T : IWopiResource { ... }
- public Task<bool> RenameWopiResource<T>(string id, string newName, CancellationToken ct) where T : IWopiResource { ... }
- public Task<bool> CheckValidName<T>(string name, CancellationToken ct) where T : IWopiResource { ... }
- public Task<string> GetSuggestedName<T>(string parent, string name, CancellationToken ct) where T : IWopiResource { ... }
-}
+public class MyProvider : IWopiStorageProvider, IWopiWritableStorageProvider
+{
+ public IWopiContainer RootContainer { get; }
+
+ public Task<IWopiFile?> GetWopiFile (string id, CancellationToken ct = default) { ... }
+ public Task<IWopiContainer?> GetWopiContainer(string id, CancellationToken ct = default) { ... }
+
+ public IAsyncEnumerable<IWopiFile> GetWopiFiles (string containerId, IReadOnlyCollection<string>? fileExtensions = null, CancellationToken ct = default) { ... }
+ public IAsyncEnumerable<IWopiContainer> GetWopiContainers(string containerId, CancellationToken ct = default) { ... }
+
+ public Task<ReadOnlyCollection<IWopiContainer>> GetFileAncestors (string fileId, CancellationToken ct = default) { ... }
+ public Task<ReadOnlyCollection<IWopiContainer>> GetContainerAncestors(string containerId, CancellationToken ct = default) { ... }
+
+ public Task<IWopiFile?> GetWopiFileByName (string containerId, string name, CancellationToken ct = default) { ... }
+ public Task<IWopiContainer?> GetWopiContainerByName(string containerId, string name, CancellationToken ct = default) { ... }
+
+ // Writable side
+ public Task<IWopiWritableFile?> CreateWopiChildFile (string containerId, string name, CancellationToken ct = default) { ... }
+ public Task<IWopiContainer?> CreateWopiChildContainer(string containerId, string name, CancellationToken ct = default) { ... }
+ public Task<IWopiWritableFile?> GetWritableFile(string id, CancellationToken ct = default) { ... }
+ public Task<bool> DeleteWopiFile (string id, CancellationToken ct = default) { ... }
+ public Task<bool> DeleteWopiContainer(string id, CancellationToken ct = default) { ... }
+ public Task<bool> RenameWopiFile (string id, string requestedName, CancellationToken ct = default) { ... }
+ public Task<bool> RenameWopiContainer(string id, string requestedName, CancellationToken ct = default) { ... }
+ public Task<bool> CheckValidFileName (string name, CancellationToken ct = default) { ... }
+ public Task<bool> CheckValidContainerName(string name, CancellationToken ct = default) { ... }
+ public Task<string> GetSuggestedFileName (string containerId, string name, CancellationToken ct = default) { ... }
+ public Task<string> GetSuggestedContainerName(string containerId, string name, CancellationToken ct = default) { ... }
+}null is no longer accepted as "the root" — pass RootContainer.Identifier explicitly.
2. File implementations
-public class MyFile : IWopiFile
-{
- public long Size => ...;
- public Task<Stream> GetReadStream(CancellationToken ct) => ...;
- public Task<Stream> GetWriteStream(CancellationToken ct) => ...; // moves to IWopiWritableFile
-}
+public class MyFile : IWopiFile
+{
+ public long Length => ...; // renamed
+ public Task<Stream> OpenReadAsync(CancellationToken ct = default) => ...;
+}
+
+public class MyWritableFile : MyFile, IWopiWritableFile
+{
+ public Task<Stream> OpenWriteAsync(CancellationToken ct = default) => ...;
+}Callers must own the returned stream — await using var s = await file.OpenReadAsync(ct);.
3. Container implementations
-public class MyFolder : IWopiFolder { /* marker */ }
+public class MyContainer : IWopiContainer
+{
+ public long Size => ...; // recursive byte total
+ public int ChildCount => ...; // direct children, no recursion
+}4. Composition root — provider registration
var builder = WebApplication.CreateBuilder(args);
builder.Services
- .Configure<WopiHostOptions>(o =>
- {
- o.StorageProviderAssemblyName = "WopiHost.FileSystemProvider";
- o.LockProviderAssemblyName = "WopiHost.MemoryLockProvider";
- })
- .AddWopi();
+ .AddWopi()
+ .AddFileSystemStorageProvider(builder.Configuration)
+ .AddMemoryLockProvider();Each provider package now ships its own typed Add* extension — AddFileSystemStorageProvider(cfg), AddAzureStorageProvider(cfg), AddMemoryLockProvider(), AddAzureLockProvider(cfg), AddRedisLockProvider(cfg). There is no reflection fallback; reference the package(s) you want and call them directly.
5. CheckXxxInfo customization
WopiHostOptions.OnCheck* callbacks are gone; subclass WopiHostExtensions (or implement IWopiHostExtensions) and register:
-services.Configure<WopiHostOptions>(o =>
-{
- o.OnCheckFileInfo = ctx => ApplyTenantOverrides(ctx);
-});
+public sealed class MyHostExtensions : WopiHostExtensions
+{
+ public override Task<WopiCheckFileInfo> CheckFileInfoAsync(WopiCheckFileInfo cfi, HttpContext ctx, CancellationToken ct)
+ => ApplyTenantOverrides(cfi, ctx, ct);
+}
+services.AddSingleton<IWopiHostExtensions, MyHostExtensions>();6. Configuration sections
-builder.Services.Configure<DiscoveryOptions>(builder.Configuration.GetSection(WopiConfigurationSections.Discovery));
+builder.Services.Configure<DiscoveryOptions>(builder.Configuration.GetSection(DiscoveryOptions.SectionName));Every options type now exposes a public const string SectionName (e.g. "Wopi:Discovery", "Wopi:Security", "Wopi:StorageProvider", "Wopi:LockProvider").
7. Lock-provider implementations
RefreshLockAsync and the new TryUnlockAndRelockAsync require provider-side compare-and-swap. The previous "Get + write" pair was racy — the controller-side observation is now passed through and the swap must be atomic.
-public Task<bool> RefreshLockAsync(string fileId, CancellationToken ct)
-{
- // racy: a concurrent UnlockAndRelock between the Get and the write extends the wrong lock
- var info = _store.Get(fileId);
- if (info is null) return Task.FromResult(false);
- _store.Set(fileId, info with { DateCreated = _clock.UtcNow });
- return Task.FromResult(true);
-}
+public Task<bool> RefreshLockAsync(string fileId, string expectedExistingLockId, CancellationToken ct)
+{
+ // atomic compare-and-swap: ConcurrentDictionary.TryUpdate, ETag-conditional write, Lua, etc.
+ return _store.TryRefresh(fileId, expectedExistingLockId, _clock.UtcNow);
+}
+
+public Task<bool> TryUnlockAndRelockAsync(string fileId, string newLockId, string expectedExistingLockId, CancellationToken ct)
+{
+ return _store.TryRelock(fileId, expected: expectedExistingLockId, replacement: newLockId);
+}WopiLockInfo.Expired (ambient clock) is gone — call lock.IsExpiredAt(timeProvider.GetUtcNow()).
8. Azure-blob upgrade — operational
Drain in-flight WOPI locks against your Azure container before upgrading, and reissue any long-lived access tokens. Existing 7.x lock-blob filenames don't match the 8.0 case-sensitive id scheme.
🧹 Architectural / hardening
- Architectural review fixes — round 1 (#373) — #369 items 1.3, 1.7, 2.10, 3.4, 4.5.
IWopiHostExtensions+ builder seams (#417) — #409 items 5.4, 5.5.- Provider audit pass (#421, #426, #427) — share hash-finalize between
HashingBlobWriteStream.Disposepaths; typedGetWopiSrc;UserInfocache TTL;IWopiContainer.Size/ChildCount;ChildFileinit-only. - Style/naming standardization (#382, #410) — IDE1006 (private-field naming); cleared IDE0290/0300/0301, CA1056/1859/1861.
ConfigureAwait(false)enforced via CA2007 (#376).- Redundant
Project/PackageReferencecleanup (#423).
🧪 Test infrastructure
- xUnit v2 → v3 migration (#372).
- Shared
LockProviderConformanceTestssuite (#411) — everyIWopiLockProviderimpl runs through the same xUnit base class; adding a future provider = one more sealed conformance subclass. - Sample-frontend Playwright smoke tests (#368).
- Cobalt unit tests (#367).
📦 Dependency updates
- All
Microsoft.AspNetCore.*/Microsoft.Extensions.*bumped to 10.0.8 (#404, plus individual dependabot PRs #383, #386, #389, #391, #395). Azure.Storage.Blobs12.26.0 → 12.28.0 (#385).Microsoft.AspNetCore.Http.Abstractions2.3.0 → 2.3.10 (#388).Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.ServiceDiscovery→ 10.6.0 (#398, #402).Microsoft.SourceLink.GitHub10.0.203 → 10.0.300 (#403).Aspire.Hosting.AppHost/Aspire.Hosting.Azure.Storage13.2.4 → 13.3.2 (#370, #371, #383, #384, #413, #414).
📝 Docs / CI
- README trimmed; advanced content moved to the wiki.
- FOSSA scan + policy gate (
fossa-contrib/fossa-action@v3) on PRs and master pushes. qltyexcludessample/**andinfra/**(#361) and skips missing source-generated files (#366) to match Codecov.- Infer# allowlist for 3 known false positives, gated on net-new findings (#364).
Full Changelog: 7.0.0...8.0.0