Skip to content

8.0.0

Choose a tag to compare

@petrsvihlik petrsvihlik released this 14 May 18:43
· 185 commits to master since this release
0154071

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.RedisLockProvider package. 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 previous WopiHostOptions.StorageProviderAssemblyName / LockProviderAssemblyName string-dispatch + AssemblyLoadContext reflection fallback.
  • IWopiHostExtensions host-customization seam (#417) — replaces WopiHostOptions.OnCheckFileInfo / OnCheckContainerInfo / OnCheckFolderInfo / OnCheckEcosystem callbacks 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 — shared LockProviderConformanceTests xUnit class that every IWopiLockProvider implementation runs through (#411).
  • Atomic lock CAS contract (#412) — IWopiLockProvider.TryUnlockAndRelockAsync and the new RefreshLockAsync(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.SmokeTests runs against both WopiHost.Web and WopiHost.Validator.
  • WopiHost.Cobalt unit tests (#367) — 42 tests added against the previously-untested Cobalt project.

🐛 Fixes

  • InMemoryFileIds thread-safe + O(1) reverse lookup (#418) — the FileSystem provider's id↔path map was racy and linear on path lookup.
  • JwtAccessTokenService ephemeral dev key wrapped in Lazy<T> (#422).
  • WopiOriginValidationActionFilter auth-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, CheckValidName enforced in rename/delete (#415).
  • CheckValidFileName / CheckValidContainerName unified + 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 shared SHA256.Create() instance.
  • Sample apps gate UseDeveloperExceptionPage behind IsDevelopment (#381).
  • AppHost launch hardening — fixes for dynamic backend ports, Aspire 13.x isProxied:false hangs, Collabora ReferenceExpression container-startup hangs, VS-injected ASPNETCORE_HOSTINGSTARTUPASSEMBLIES, and ASPNETCORE_ENVIRONMENT=Development forwarding 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.FromCanonicalPath no 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.Dispose paths; typed GetWopiSrc; UserInfo cache TTL; IWopiContainer.Size/ChildCount; ChildFile init-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/PackageReference cleanup (#423).

🧪 Test infrastructure

  • xUnit v2 → v3 migration (#372).
  • Shared LockProviderConformanceTests suite (#411) — every IWopiLockProvider impl 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.Blobs 12.26.0 → 12.28.0 (#385).
  • Microsoft.AspNetCore.Http.Abstractions 2.3.0 → 2.3.10 (#388).
  • Microsoft.Extensions.Http.Resilience / Microsoft.Extensions.ServiceDiscovery → 10.6.0 (#398, #402).
  • Microsoft.SourceLink.GitHub 10.0.203 → 10.0.300 (#403).
  • Aspire.Hosting.AppHost / Aspire.Hosting.Azure.Storage 13.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.
  • qlty excludes sample/** and infra/** (#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