diff --git a/docs/plugin-type-catalog.md b/docs/plugin-type-catalog.md new file mode 100644 index 00000000..249e4c14 --- /dev/null +++ b/docs/plugin-type-catalog.md @@ -0,0 +1,116 @@ +![WebExpress](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png) + +# Plugin type catalog + +An application built on WebExpress often has extension points of its own: export formats, automation actions, integrations with other systems. Each of them asks the same question the framework asks for its pages, fragments and health checks: *which plugin contributes which implementation?* `PluginTypeCatalog` (namespace `WebExpress.WebCore.WebPlugin`) answers it, so a manager does not have to scan the plugin assemblies, follow the plugin and application events and keep track of who provides what by hand. + +The catalog is a building block, not a manager and not a base class. A manager holds one, or several, as a field and delegates to it. + +## When to use it + +Use a catalog when the implementations of an extension point are **types in plugins** that should be found without any registration: a plugin ships a `public sealed` class, and the extension point knows it once the plugin is loaded. Do not use it for UI building blocks - fragments carry a section and a scope and are rendered by the `FragmentManager` - nor for configuration entries, which are no types (for those, see `Register` below). + +## The convention + +A type of a plugin's assembly is an item when it is + +- a class that is `public` at top level - a nested type is not, because `Type.IsPublic` is false for it, +- `sealed`, not abstract and not an open generic, +- assignable to the contract (by default `TItem`), +- and, without a factory, has a public parameterless constructor. + +A type that misses any of these is skipped **silently**, the way a page or a fragment that does not follow its convention is. A plugin should therefore have a test that every item it ships follows the convention - otherwise an item is simply missing in a running host. + +A type that is found but cannot be used - its constructor throws, it names no key, a second type of the same plugin uses the same key, or the assembly loads only in part - is written to the server log as a warning and skipped. The other items of the plugin are still collected. + +## Keys and ownership + +Every item has a key, answered by the function the catalog is created with. Keys are trimmed and compared without case. + +Several plugins may offer an item under the same key - an add-on replacing a standard implementation, for instance. The catalog keeps every offer in the order the plugins arrived, and the item **in use** under a key is the latest offer. When a plugin leaves, it takes only what it offered: + +- a key it still provides falls back to the latest remaining offer of another plugin, and is removed only when nobody offers it any more; +- a key another plugin took over is left alone. + +`Register(item)` files an item that is no type of its own - one per configuration entry, or a stand-in in a test. It competes like an offer (the latest offer wins), but it belongs to no plugin: no plugin removal takes it away, only `Unregister(key)` does, and the catalog never disposes it. An item the catalog constructed itself is disposed when it leaves the catalog, if it implements `IDisposable`. + +## Following a host + +`Attach(componentHub, httpServerContext)` does both passes a manager needs, because managers are built before all plugins have arrived: + +1. it collects the plugins already loaded (`IPluginManager.Plugins`), and +2. it subscribes to `AddPlugin` and `RemovePlugin` for the ones that come and go later. + +Collecting a plugin twice does nothing, so the two passes never double an item. `Dispose()` unsubscribes, raises `Removed` for every item in use and disposes the items it built. Without a plugin manager - in a unit test - `Collect(assembly)` collects one assembly; its items belong to no plugin. + +## Reading and change signalling + +- `Find(key)`, `Contains(key)` and `OwnerOf(key)` answer the item in use under a key and the plugin that provides it (`null` for a registered item). +- `Items` is a snapshot of the items in use, in the order their offers arrived. +- `Version` moves whenever an item comes, goes or is replaced - a cached picker or menu compares it instead of the items. +- `Added` and `Removed` report each change, carrying the key, the item and the plugin. The leaving item is reported before the arriving one, and both events are raised after the catalog released its lock, so a handler may call back into it. + +The catalog is thread-safe; types are scanned and items constructed outside its lock. + +## Items bound to an application + +The framework's own extension points are scoped to an application: a health check, for example, is bound once per plugin and per application the plugin is associated with. A catalog created with `PluginTypeBinding.Application` does the same. It also follows `AddApplication` and `RemoveApplication`, keeps a key per application, and offers `Find(application, key)`, `GetItems(application)`, `Collect(plugin, applications)`, `Withdraw(application)` and `Register(application, item)`. A catalog bound per plugin - the default, and the right choice for most application extension points - pays nothing for this. + +A factory builds items that need their context, or that wrap the discovered type instead of being it. It receives a `PluginTypeOrigin` (type, plugin, application); answering `null` declines the type. The `HealthManager` uses one to wrap each `IHealth` type in a binding that activates the component only when it is checked. + +## Example + +An application defines an extension point - every `public sealed IExportFormat` of any plugin: + +```csharp +public interface IExportFormat +{ + string Key { get; } + + byte[] Export(IEnumerable rows); +} + +public sealed class ExportFormatManager : IExportFormatManager +{ + private readonly PluginTypeCatalog _formats = new(x => x.Key); + + private ExportFormatManager(IComponentHub componentHub, IHttpServerContext httpServerContext) + { + _formats.Attach(componentHub, httpServerContext); + } + + public IReadOnlyList Formats => _formats.Items; + + public IExportFormat Get(string key) => _formats.Find(key); + + public void Dispose() => _formats.Dispose(); +} +``` + +A plugin contributes a format by shipping a class - nothing else: + +```csharp +public sealed class CsvExportFormat : IExportFormat +{ + public string Key => "csv"; + + public byte[] Export(IEnumerable rows) => ...; +} +``` + +And it tests that its items are found: + +```csharp +[Fact] +public void EveryExportFormatIsFindable() +{ + using var catalog = new PluginTypeCatalog(x => x.Key); + catalog.Collect(typeof(CsvExportFormat).Assembly); + + Assert.NotNull(catalog.Find("csv")); +} +``` + +## Candidates in the framework + +The `HealthManager` discovers its checks through a catalog bound per application. The other managers that scan plugin assemblies still carry their own discovery and are candidates for the same move: `MetricsManager`, `JobManager`, `ThemeManager`, `SettingPageManager`, `FragmentManager`, `PageManager`, `RestApiManager`, `IncludeManager`, `EventManager`, `SocketManager` and `StatusPageManager`. Those that file their items by attributes and routes rather than by a key (pages, REST APIs) need more than a catalog and may keep their own bookkeeping. diff --git a/docs/toc.yml b/docs/toc.yml index cfa06b00..6d87cf84 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -6,5 +6,7 @@ href: user-guide.md - name: Health Checks href: https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#health-model +- name: Plugin Type Catalog + href: plugin-type-catalog.md - name: Tutorials href: tutorials.md diff --git a/docs/tutorials.md b/docs/tutorials.md index 0606f9f7..fea5398d 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -14,5 +14,8 @@ Begin with our basic tutorial: This tutorial will guide you through the initial steps of creating and running your first `WebExpress` application. +# Contributing an item from a plugin +An application can open an extension point to other plugins without any registration: it defines an interface and collects its implementations with a `PluginTypeCatalog`, and a plugin contributes one by shipping a `public sealed` class with a parameterless constructor. The [Plugin type catalog](plugin-type-catalog.md) page walks through an export-format example - the manager, a contributed format and the test that keeps it findable. + Stay tuned for more exciting and educational tutorials to help you unlock the full potential of `WebExpress`. Happy coding and best of luck with your projects! diff --git a/docs/user-guide.md b/docs/user-guide.md index e89c5c4c..d6ce969c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -20,6 +20,10 @@ We hope you enjoy using `WebExpress.WebCore` and find it valuable for your proje The container health endpoint is documented in the [Health model](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#health-model) section of the Development Guide. WebCore provides `/health` globally, and applications contribute critical dependency checks as public sealed `IHealth` components in `WebExpress.WebCore.WebHealth`. The `HealthManager` discovers these components through the plugin and application lifecycle. The guide includes the component model, a database example, the HTTP contract, and Docker and Kubernetes probe configuration. +## Plugin type catalog + +An application's own extension points - every `public sealed` implementation of an interface any plugin ships - are collected by `PluginTypeCatalog` in `WebExpress.WebCore.WebPlugin`, the building block the `HealthManager` uses for its checks. A manager holds it as a field: it sweeps the loaded plugins, follows the ones arriving and leaving, keeps track of which plugin provides which key and falls back to another plugin's offer when the provider leaves. The [Plugin type catalog](plugin-type-catalog.md) page describes the convention, the ownership rules, the binding per application and an example. + ## Metrics The Prometheus endpoint is documented in the [Metrics model](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#metrics-model) section of the Development Guide. WebCore provides `/metrics` globally with request, error, login, active user, process, and runtime metrics. Applications record their own values with `MetricCounter`, `MetricGauge`, and `MetricHistogram` instruments, or report them at scrape time as public sealed `IMetric` components in `WebExpress.WebCore.WebMetrics`. The `MetricsManager` discovers these components through the plugin and application lifecycle and labels every series with its application. The guide includes an LDAP example, the list of framework metrics, the HTTP contract, and Prometheus, Kubernetes, and alerting configuration. diff --git a/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs b/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs index b4dc6956..a54ca596 100644 --- a/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs @@ -26,6 +26,11 @@ internal sealed class AuthenticationFixture : IDisposable internal IdentityManager Manager => (IdentityManager)Hub.IdentityManager; internal TestClock Clock { get; } = new(); + /// + /// Gets the application groups an created afterwards is configured with. + /// + internal Dictionary Groups { get; } = []; + /// /// Creates an isolated signing authority and durable token directory for each test. /// @@ -63,7 +68,10 @@ internal IdentityManager Instance() /// The configuration containing the authentication section. private IConfigurationRoot Configuration() { - return new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + var groups = Groups.SelectMany(group => group.Value.Select((id, index) => + new KeyValuePair($"WebExpress:Authentication:ApplicationGroups:{group.Key}:{index}", id))); + + return new ConfigurationBuilder().AddInMemoryCollection(groups).AddInMemoryCollection(new Dictionary { ["WebExpress:Authentication:Issuer"] = Settings.Issuer, ["WebExpress:Authentication:Audience"] = Settings.Audience, diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index eebad947..f8e915b1 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -197,6 +197,7 @@ public static WebMessage.HttpContext CreateHttpContextMock(string content = "") var requestFeature = new HttpRequestFeature { + Scheme = "http", Headers = new HeaderDictionary { ["Host"] = "localhost", diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestForeignApplicationBinding.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestForeignApplicationBinding.cs new file mode 100644 index 00000000..92ddf04d --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestForeignApplicationBinding.cs @@ -0,0 +1,368 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Text; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebFragment; +using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebRestApi; +using WebExpress.WebCore.WebSitemap; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Tests that a plugin which names an application of another plugin through an application + /// attribute serves its endpoints under that application, although the application is not + /// defined in the plugin's own assembly. + /// + public class UnitTestForeignApplicationBinding + { + private const string AssemblyName = "WebExpress.WebCore.Test.Foreign"; + private const string AssetContent = "/* foreign */"; + + /// + /// Tests that page, rest api, fragment and asset of a plugin loaded after the foreign + /// application are bound to that application. A contributing plugin serves all of them under + /// its id, below the application's context path. The plugin's name is an i18n key, which + /// must not leak into the route: its colon would end up in the path and the pages would + /// drift away from the assets. + /// + [Fact] + public void PluginAfterApplication() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var pluginManager = componentHub.PluginManager as PluginManager; + var foreign = ForeignAssembly.Create(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplicationA)).Single(); + + // act + var plugins = pluginManager.Register(foreign).ToList(); + componentHub.SitemapManager.Refresh(); + + // validation + Assert.Single(plugins); + Assert.Contains(application, componentHub.PluginManager.GetAssociatedApplications(plugins[0])); + Assert.Equal("webexpress.webcore.test.foreign.www.extension", Search(componentHub, "http://localhost:8080/server/appa/webexpress.webcore.test.foreign/extension")); + Assert.Equal("webexpress.webcore.test.foreign.www.api._1_.data", Search(componentHub, "http://localhost:8080/server/appa/webexpress.webcore.test.foreign/api/1/data")); + Assert.Null(Search(componentHub, "http://localhost:8080/server/appa/foreign:plugin.name/extension")); + Assert.Single(componentHub.FragmentManager.GetFragments(application, foreign.Fragment)); + Assert.Equal(AssetContent, Request(componentHub, "http://localhost:8080/server/appa/webexpress.webcore.test.foreign/assets/css/foreign.css")); + } + + /// + /// Tests that the plugin is not bound to an application it does not name. + /// + [Fact] + public void UnnamedApplicationStaysUnbound() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var pluginManager = componentHub.PluginManager as PluginManager; + var foreign = ForeignAssembly.Create(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplicationB)).Single(); + + // act + pluginManager.Register(foreign); + componentHub.SitemapManager.Refresh(); + + // validation + Assert.Empty(componentHub.FragmentManager.GetFragments(application, foreign.Fragment)); + Assert.Null(Search(componentHub, "http://localhost:8080/server/appb/webexpress.webcore.test.foreign/extension")); + } + + /// + /// Resolves a uri through the sitemap. + /// + /// The hub to search. + /// The requested uri. + /// The endpoint id, or null when the uri is not served. + private static string Search(IComponentHub componentHub, string uri) + { + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var result = componentHub.SitemapManager.SearchResource(new System.Uri(uri), new SearchContext() + { + HttpServerContext = httpServerContext, + Culture = httpServerContext.Culture, + HttpContext = UnitTestFixture.CreateHttpContextMock() + }); + + return result?.EndpointContext?.EndpointId?.ToString(); + } + + /// + /// Requests a uri and returns the body as text. + /// + /// The hub that answers. + /// The requested uri. + /// The response body, or null when nothing answers. + private static string Request(IComponentHub componentHub, string uri) + { + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var result = componentHub.SitemapManager.SearchResource(new System.Uri(uri), new SearchContext() + { + HttpServerContext = httpServerContext, + Culture = httpServerContext.Culture, + HttpContext = UnitTestFixture.CreateHttpContextMock() + }); + + if (result?.EndpointContext is null) + { + return null; + } + + var response = componentHub.EndpointManager.HandleRequest(UnitTestFixture.CreateRequestMock("", uri), result.EndpointContext); + + return response is ResponseNotFound ? null : Encoding.UTF8.GetString(response.Content as byte[]); + } + + /// + /// A plugin assembly of its own, emitted at test time. Its types cannot live in the test + /// assembly, because the test plugin would discover them and bind them to every test application. + /// + private sealed class ForeignAssembly : Assembly + { + private readonly Assembly _emitted; + private readonly Type[] _types; + + /// + /// Gets the emitted fragment type. + /// + public Type Fragment { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The dynamic assembly that holds the types. + /// The emitted types. + /// The emitted fragment type. + private ForeignAssembly(Assembly emitted, Type[] types, Type fragment) + { + _emitted = emitted; + _types = types; + Fragment = fragment; + } + + /// + /// Emits the plugin, a page and a rest api under WWW and a fragment, each a sealed subclass of a base + /// in the test assembly. + /// + /// The plugin assembly. + public static ForeignAssembly Create() + { + var builder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(AssemblyName), AssemblyBuilderAccess.Run); + var module = builder.DefineDynamicModule(AssemblyName); + + var plugin = Emit(module, $"{AssemblyName}.ForeignPlugin", typeof(ForeignPluginBase), + Attribute(typeof(ApplicationAttribute)), + new CustomAttributeBuilder(typeof(NameAttribute).GetConstructor([typeof(string)]), ["foreign:plugin.name"])); + var page = Emit(module, $"{AssemblyName}.WWW.Extension", typeof(ForeignPageBase)); + var restApi = Emit(module, $"{AssemblyName}.WWW.Api._1_.Data", typeof(ForeignRestApiBase)); + var fragment = Emit(module, $"{AssemblyName}.ForeignFragment", typeof(ForeignFragmentBase), Attribute(typeof(SectionAttribute))); + + return new ForeignAssembly(builder, [plugin, page, restApi, fragment], fragment); + } + + /// + /// Emits a public sealed subclass with a public parameterless constructor. + /// + /// The dynamic module. + /// The full name of the type. + /// The base type. + /// The attributes of the type. + /// The created type. + private static Type Emit(ModuleBuilder module, string name, Type parent, params CustomAttributeBuilder[] attributes) + { + var type = module.DefineType(name, TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class, parent); + type.DefineDefaultConstructor(MethodAttributes.Public); + + foreach (var attribute in attributes) + { + type.SetCustomAttribute(attribute); + } + + return type.CreateType(); + } + + /// + /// Describes an attribute with a parameterless constructor. + /// + /// The attribute type. + /// The attribute description. + private static CustomAttributeBuilder Attribute(Type attributeType) + { + return new CustomAttributeBuilder(attributeType.GetConstructor(Type.EmptyTypes), []); + } + + /// + /// Gets the name of the emitted assembly. + /// + public override string FullName => _emitted.FullName; + + /// + /// Gets no location, since the assembly exists in memory only. + /// + public override string Location => string.Empty; + + /// + /// Returns the name of the emitted assembly. + /// + /// The assembly name. + public override AssemblyName GetName() => _emitted.GetName(); + + /// + /// Returns the name of the emitted assembly. + /// + /// Whether the codebase is the copied one. + /// The assembly name. + public override AssemblyName GetName(bool copiedName) => _emitted.GetName(copiedName); + + /// + /// Returns the emitted types, which discovery reads. + /// + /// The types. + public override Type[] GetTypes() => _types; + + /// + /// Returns the emitted types, which plugin and application discovery read. + /// + /// The types. + public override Type[] GetExportedTypes() => _types; + + /// + /// Returns no assembly attributes. + /// + /// Ignored. + /// An empty array. + public override object[] GetCustomAttributes(bool inherit) => Array.Empty(); + + /// + /// Returns no assembly attributes, as an array of the asked type, which the attribute helpers cast to. + /// + /// The attribute type. + /// Ignored. + /// An empty array of the type. + public override object[] GetCustomAttributes(Type attributeType, bool inherit) => (object[])Array.CreateInstance(attributeType, 0); + + /// + /// Tells that no assembly attribute is defined. + /// + /// The attribute type. + /// Ignored. + /// False. + public override bool IsDefined(Type attributeType, bool inherit) => false; + + /// + /// Returns no assembly attribute data. + /// + /// An empty list. + public override IList GetCustomAttributesData() => []; + + /// + /// Returns the one embedded asset of the plugin. + /// + /// The resource names. + public override string[] GetManifestResourceNames() => [$"{AssemblyName}.Assets.css\\foreign.css"]; + + /// + /// Returns the content of the embedded asset. + /// + /// The resource name. + /// The content, or null for another name. + public override Stream GetManifestResourceStream(string name) + { + return name == $"{AssemblyName}.Assets.css\\foreign.css" + ? new MemoryStream(Encoding.UTF8.GetBytes(AssetContent)) + : null; + } + } + } + + /// + /// The base of the emitted foreign plugin. Abstract, so the test plugin does not discover it. + /// + public abstract class ForeignPluginBase : IPlugin + { + /// + /// Called when the plugin starts working. + /// + public void Run() + { + } + + /// + /// Releases the plugin. + /// + public void Dispose() + { + } + } + + /// + /// The base of the emitted foreign page. Abstract, so the test plugin does not discover it. + /// + public abstract class ForeignPageBase : Page + { + /// + /// Processing of the page. + /// + /// The context for rendering the page. + /// The visual tree to be rendered. + public override void Process(IRenderContext renderContext, TestVisualTree visualTree) + { + } + + /// + /// Releases the page. + /// + public override void Dispose() + { + } + } + + /// + /// The base of the emitted foreign rest api. Abstract, so the test plugin does not discover it. + /// + public abstract class ForeignRestApiBase : IRestApi + { + /// + /// Releases the rest api. + /// + public void Dispose() + { + } + } + + /// + /// The base of the emitted foreign fragment. Abstract, so the test plugin does not discover it. + /// + public abstract class ForeignFragmentBase : IFragment + { + /// + /// Gets the id. + /// + public string Id => string.Empty; + + /// + /// Renders the fragment. + /// + /// The context in which rendering occurs. + /// The visual tree used for rendering the fragment. + /// The fragment's markup. + public IHtmlNode Render(IRenderContext renderContext, IVisualTree visualTree) + { + return new HtmlText("ForeignFragment"); + } + + /// + /// Releases the fragment. + /// + public void Dispose() + { + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityValidation.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityValidation.cs new file mode 100644 index 00000000..a346ad81 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityValidation.cs @@ -0,0 +1,212 @@ +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Tests the two ways a signed identity is reconsidered beyond its signature: application + /// groups that accept each other's tokens, and validators asked on every request. + /// + public class UnitTestIdentityValidation + { + /// + /// Tests that one sign-in serves every application of its group, and no other application. + /// + [Fact] + public void GroupedApplicationsAcceptEachOthersTokens() + { + // arrange + using var fixture = new AuthenticationFixture(); + var a = fixture.Application; + var b = Application(fixture, typeof(TestApplicationB)); + var c = Application(fixture, typeof(TestApplicationC)); + fixture.Groups["suite"] = [a.ApplicationId, b.ApplicationId]; + var manager = fixture.Instance(); + var pair = manager.Issue(new Identity(Guid.NewGuid(), "alice"), a); + var request = fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"); + request.ApplicationContext = b; + + // act + var inGroup = manager.ValidateAccessToken(pair.AccessToken, b); + var outsideGroup = manager.ValidateAccessToken(pair.AccessToken, c); + var current = manager.GetCurrentIdentity(request); + var renewed = manager.Refresh(pair.RefreshToken, b); + var renewedAgain = manager.Refresh(pair.RefreshToken, a); + + // validation + Assert.NotNull(inGroup); + Assert.Null(outsideGroup); + Assert.Equal("alice", current?.Name); + Assert.NotNull(renewed); + Assert.NotNull(manager.ValidateAccessToken(renewed.AccessToken, a)); + Assert.Null(renewedAgain); + } + + /// + /// Tests that a renewal in a member of two overlapping groups keeps the trust boundary of + /// the sign-in: a token issued in A and renewed in B is not accepted by C, which trusts B but not A. + /// + [Fact] + public void RenewalKeepsTheIssuingApplication() + { + // arrange + using var fixture = new AuthenticationFixture(); + var a = fixture.Application; + var b = Application(fixture, typeof(TestApplicationB)); + var c = Application(fixture, typeof(TestApplicationC)); + fixture.Groups["ab"] = [a.ApplicationId, b.ApplicationId]; + fixture.Groups["bc"] = [b.ApplicationId, c.ApplicationId]; + var manager = fixture.Instance(); + var pair = manager.Issue(new Identity(Guid.NewGuid(), "alice"), a); + + // act + var renewed = manager.Refresh(pair.RefreshToken, b); + + // validation + Assert.Null(manager.ValidateAccessToken(pair.AccessToken, c)); + Assert.NotNull(renewed); + Assert.NotNull(manager.ValidateAccessToken(renewed.AccessToken, a)); + Assert.NotNull(manager.ValidateAccessToken(renewed.AccessToken, b)); + Assert.Null(manager.ValidateAccessToken(renewed.AccessToken, c)); + Assert.Null(manager.Refresh(renewed.RefreshToken, c)); + } + + /// + /// Tests that without a group a token stays bound to the application it was issued in. + /// + [Fact] + public void UngroupedApplicationsKeepTheirTokens() + { + // arrange + using var fixture = new AuthenticationFixture(); + var b = Application(fixture, typeof(TestApplicationB)); + var pair = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), fixture.Application); + + // act + var identity = fixture.Manager.ValidateAccessToken(pair.AccessToken, b); + + // validation + Assert.Null(identity); + } + + /// + /// Tests that a validator is asked on every request, sees the grant of the sign-in, and + /// turns a refused identity into an anonymous request, also when it throws. + /// + [Fact] + public void ValidatorDecidesOnEveryRequest() + { + // arrange + using var fixture = new AuthenticationFixture(); + var pair = fixture.Manager.Login(MockIdentityFactory.GetIdentity("Alice"), fixture.Request()); + var cookie = $"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"; + var validator = new DelegateValidator(_ => true); + fixture.Manager.RegisterValidator(validator, fixture.Application); + + // act + var accepted = fixture.Manager.GetCurrentIdentity(fixture.Request(cookie)); + var context = validator.Last; + validator.Answer = _ => false; + var refused = fixture.Manager.GetCurrentIdentity(fixture.Request(cookie)); + validator.Answer = _ => throw new InvalidOperationException("directory offline"); + var failed = fixture.Manager.GetCurrentIdentity(fixture.Request(cookie)); + var unregistered = fixture.Manager.UnregisterValidator(validator, fixture.Application); + var afterwards = fixture.Manager.GetCurrentIdentity(fixture.Request(cookie)); + + // validation + Assert.NotNull(accepted); + Assert.Equal("access", context.TokenUse); + Assert.False(string.IsNullOrEmpty(context.Grant)); + Assert.Same(fixture.Application, context.ApplicationContext); + Assert.Null(refused); + Assert.Null(failed); + Assert.True(unregistered); + Assert.NotNull(afterwards); + } + + /// + /// Tests that a refused identity is not renewed and its cookies are removed. + /// + [Fact] + public void RefusedIdentityIsNotRenewed() + { + // arrange + using var fixture = new AuthenticationFixture(); + var pair = fixture.Manager.Login(MockIdentityFactory.GetIdentity("Alice"), fixture.Request()); + fixture.Manager.RegisterValidator(new DelegateValidator(_ => false), fixture.Application); + var request = fixture.Request($"Cookie: {IdentityManager.RefreshCookieName}={pair.RefreshToken}\r\n", "POST", IdentityManager.RefreshPath); + var response = new ResponseOK(); + + // act + var renewed = fixture.Manager.Refresh(request); + fixture.Manager.ApplyAuthenticationCookies(request, response); + + // validation + Assert.Null(renewed); + Assert.Equal("", response.Header.Cookies[IdentityManager.AccessCookieName]?.Value); + } + + /// + /// Tests that a validator a plugin defines is discovered for the plugin's applications. + /// + [Fact] + public void PluginValidatorIsDiscovered() + { + // arrange + using var fixture = new AuthenticationFixture(); + var blocked = fixture.Manager.Issue(new Identity(Guid.NewGuid(), TestIdentityValidator.BlockedName), fixture.Application).AccessToken; + var allowed = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), fixture.Application).AccessToken; + + // act + var blockedIdentity = fixture.Manager.GetCurrentIdentity(fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={blocked}\r\n")); + var allowedIdentity = fixture.Manager.GetCurrentIdentity(fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={allowed}\r\n")); + + // validation + Assert.Null(blockedIdentity); + Assert.NotNull(allowedIdentity); + } + + /// + /// Returns the application of a type. + /// + /// The fixture whose hub knows the applications. + /// The application type. + /// The application context. + private static IApplicationContext Application(AuthenticationFixture fixture, Type type) + { + return fixture.Hub.ApplicationManager.GetApplications(type).First(); + } + + /// + /// A validator whose answer the test sets. + /// + /// The first answer. + private sealed class DelegateValidator(Func answer) : IIdentityValidator + { + /// + /// Gets or sets the answer. + /// + public Func Answer { get; set; } = answer; + + /// + /// Gets the context of the last question. + /// + public IdentityValidationContext Last { get; private set; } + + /// + /// Records the question and answers it. + /// + /// The identity to confirm. + /// The answer the test set. + public bool Validate(IdentityValidationContext context) + { + Last = context; + + return Answer(context); + } + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageBuilder.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageBuilder.cs new file mode 100644 index 00000000..6722e5f1 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageBuilder.cs @@ -0,0 +1,287 @@ +using System.IO.Compression; +using WebExpress.WebCore.WebPackage; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Unit tests for the package builder. + /// + public sealed class UnitTestPackageBuilder : IDisposable + { + private readonly string _root = Path.Combine(Path.GetTempPath(), "wx-pkgbuilder-" + Guid.NewGuid().ToString("N")); + + /// + /// Removes the build tree of the test. + /// + public void Dispose() + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, true); + } + } + + /// + /// Tests that a Release package takes the Release output, even though a stale Debug + /// output and the obj copy of the same file are found first in directory order. + /// + [Fact] + public void ArtifactPrefersRequestedConfiguration() + { + // arrange + Spec("WebExpress", "Core.dll"); + Write("src/App/bin/Debug/net10.0/Core.dll", "debug"); + Write("src/App/obj/Release/net10.0/Core.dll", "obj"); + Write("src/App/bin/Release/net10.0/Core.dll", "release"); + + // act + var entries = Build("Release"); + + // assert + Assert.Equal("release", entries["bin/Core.dll"]); + } + + /// + /// Tests that the output of another configuration never stands in for a missing one. + /// + [Fact] + public void ArtifactOfOtherConfigurationOnlyFails() + { + // arrange + Spec("WebExpress", "Core.dll"); + Write("src/App/bin/Debug/net10.0/Core.dll", "debug"); + + // act + var ex = Assert.Throws(() => Build("Release")); + + // assert + Assert.Contains("Core.dll", ex.Message); + } + + /// + /// Tests that a missing artifact fails and leaves no archive behind. + /// + [Fact] + public void MissingArtifactFailsWithoutArchive() + { + // arrange + Spec("WebExpress", "Present.dllAbsent.dll"); + Write("bin/Release/net10.0/Present.dll", "x"); + + // act + var ex = Assert.Throws(() => Build("Release")); + + // assert + Assert.Contains("Absent.dll", ex.Message); + Assert.DoesNotContain("Present.dll", ex.Message); + Assert.False(Directory.Exists(Path.Combine(_root, "pkg")) && Directory.EnumerateFiles(Path.Combine(_root, "pkg")).Any()); + } + + /// + /// Tests that a name only matches whole path segments. + /// + [Fact] + public void ArtifactMatchesWholeFileNameOnly() + { + // arrange + Spec("WebExpress", "Core.dll"); + Write("bin/Release/net10.0/MyCore.dll", "other"); + + // act & assert + Assert.Throws(() => Build("Release")); + } + + /// + /// Tests that the lookup stays inside the folder of the spec file. + /// + [Fact] + public void ReadmeAboveSpecFolderIsNotUsed() + { + // arrange + Spec("WebExpress", "README.md", "repo"); + Write("README.md", "foreign"); + + // act + var ex = Assert.Throws(() => Build("Release", "repo")); + + // assert + Assert.Contains("README.md", ex.Message); + } + + /// + /// Tests that the readme next to the spec wins over one deeper in the tree. + /// + [Fact] + public void ReadmePrefersShallowestFile() + { + // arrange + Spec("WebExpress", "README.md"); + Write("pkg/README.md", "package folder"); + Write("README.md", "root"); + + // act + var entries = Build("Release"); + + // assert + Assert.Equal("root", entries["readme.md"]); + } + + /// + /// Tests that a plugin without build output for a requested target fails. + /// + [Fact] + public void MissingPluginOutputFails() + { + // arrange + Spec("My.Plugin", "src/Plugin"); + Write("src/Plugin/bin/Release/net10.0/Plugin.dll", "x"); + + // act + var ex = Assert.Throws(() => Build("Release", targets: "net10.0;net9.0")); + + // assert + Assert.Contains("net9.0", ex.Message); + Assert.DoesNotContain(Path.Combine("Release", "net10.0"), ex.Message); + } + + /// + /// Tests that a plugin package carries the output of every requested target. + /// + [Fact] + public void PluginOutputIsPacked() + { + // arrange + Spec("My.Plugin", "src/Plugin"); + Write("src/Plugin/bin/Release/net10.0/Plugin.dll", "plugin"); + Write("src/Plugin/bin/Debug/net10.0/Plugin.dll", "debug"); + + // act + var entries = Build("Release", archive: "My.Plugin.1.0.0.wxp"); + + // assert + Assert.Equal("plugin", entries["lib/Plugin/net10.0/Plugin.dll"]); + Assert.True(entries.ContainsKey("My.Plugin.spec")); + } + + /// + /// Tests that a license copied to the build output does not add a second entry. + /// + [Fact] + public void LicenseCopyInOutputIsIgnored() + { + // arrange + Spec("WebExpress", string.Empty); + Write("licenses/Lib.lic", "source"); + Write("src/App/bin/Release/net10.0/Lib.lic", "copy"); + + // act + var entries = Build("Release"); + + // assert + Assert.Equal("source", entries["licenses/Lib.txt"]); + Assert.Single(entries.Keys, x => x.StartsWith("licenses/")); + } + + /// + /// Tests that the output of another target framework never stands in for the requested one. + /// + [Fact] + public void ArtifactOfOtherTargetFrameworkFails() + { + // arrange + Spec("WebExpress", "Core.dll"); + Write("bin/Release/net8.0/Core.dll", "net8"); + Write("bin/Release/net10.0-windows/Core.dll", "windows"); + + // act + var ex = Assert.Throws(() => Build("Release")); + + // assert + Assert.Contains("Core.dll", ex.Message); + } + + /// + /// Tests that an ordinary subfolder of the configuration output is not taken for a target + /// framework folder. + /// + [Fact] + public void ArtifactInOutputSubfolderIsUsed() + { + // arrange + Spec("WebExpress", "settings/app.json"); + Write("bin/Release/settings/app.json", "output"); + + // act + var entries = Build("Release"); + + // assert + Assert.Equal("output", entries["bin/settings/app.json"]); + } + + /// + /// Tests that an empty plugin output fails, because the package would name the plugin + /// without carrying any of its files. + /// + [Fact] + public void EmptyPluginOutputFails() + { + // arrange + Spec("My.Plugin", "src/Plugin"); + Directory.CreateDirectory(Path.Combine(_root, "src/Plugin/bin/Release/net10.0")); + + // act + var ex = Assert.Throws(() => Build("Release", archive: "My.Plugin.1.0.0.wxp")); + + // assert + Assert.Contains("is empty", ex.Message); + } + + /// + /// Tests that a plugin path cannot reach the build output of a project outside of the spec folder. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PluginOutsideSpecFolderFails(bool absolute) + { + // arrange + Write("neighbour/bin/Release/net10.0/Foreign.dll", "foreign"); + var plugin = absolute ? Path.Combine(_root, "neighbour") : "../neighbour"; + Spec("My.Plugin", $"{plugin}", "repo"); + + // act + var ex = Assert.Throws(() => Build("Release", "repo", archive: "My.Plugin.1.0.0.wxp")); + + // assert + Assert.Contains("outside of the spec folder", ex.Message); + Assert.False(Directory.Exists(Path.Combine(_root, "pkg")) && Directory.EnumerateFiles(Path.Combine(_root, "pkg")).Any()); + } + + private void Spec(string id, string elements, string folder = "") + { + Write(Path.Combine(folder, "test.spec"), + $"{id}1.0.0{elements}"); + } + + private void Write(string relativePath, string content) + { + var path = Path.Combine(_root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, content); + } + + private Dictionary Build(string config, string folder = "", string targets = "net10.0", string archive = "WebExpress.1.0.0.zip") + { + var output = Path.Combine(_root, "pkg"); + PackageBuilder.Create(Path.Combine(_root, folder, "test.spec"), config, targets, output); + + using var zip = ZipFile.OpenRead(Path.Combine(output, archive)); + + return zip.Entries.ToDictionary(x => x.FullName, x => + { + using var reader = new StreamReader(x.Open()); + return reader.ReadToEnd(); + }); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPluginTypeCatalog.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPluginTypeCatalog.cs new file mode 100644 index 00000000..a54d74f1 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPluginTypeCatalog.cs @@ -0,0 +1,500 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Verifies that the catalog finds exactly the types of the convention, isolates defective + /// ones, keeps track of who provides which key, and follows the plugins and applications of a host. + /// + [Collection("NonParallelTests")] + public class UnitTestPluginTypeCatalog + { + /// + /// Only public, top-level, sealed, closed, assignable types with a parameterless constructor are items. + /// + [Fact] + public void Collect_FollowsTheConvention() + { + // arrange + using var catalog = new PluginTypeCatalog(x => x.Key); + var plugin = new HealthTestPluginContext("plugin", typeof(AlphaProbe), typeof(UnsealedProbe), typeof(NestedProbe), + typeof(GenericProbe<>), typeof(ConstructorProbe), typeof(ICatalogProbe), typeof(string)); + + // act + catalog.Collect(plugin); + + // validation + Assert.Equal("alpha", Assert.Single(catalog.Items).Key); + Assert.Same(plugin, catalog.OwnerOf("alpha")); + } + + /// + /// A factory builds the items, receives the origin, and frees the convention from the constructor. + /// + [Fact] + public void Factory_BuildsTheItemsFromTheirOrigin() + { + // arrange + var origins = new List(); + using var catalog = new PluginTypeCatalog(x => x.Key, typeof(ICatalogProbe), origin => + { + origins.Add(origin); + return new ConstructorProbe("made-" + origin.Type.Name.ToLowerInvariant()); + }); + var plugin = new HealthTestPluginContext("plugin", typeof(AlphaProbe), typeof(ConstructorProbe)); + + // act + catalog.Collect(plugin); + + // validation + Assert.Equal(["made-alphaprobe", "made-constructorprobe"], catalog.Items.Select(x => x.Key).Order()); + Assert.All(origins, x => Assert.Same(plugin, x.PluginContext)); + Assert.All(origins, x => Assert.Null(x.ApplicationContext)); + } + + /// + /// A constructor that throws, an item without a key and a second item under a key of the + /// same plugin are logged and skipped; the plugin's other items are still offered. + /// + [Fact] + public void Collect_LogsDefectiveTypesAndKeepsTheOthers() + { + // arrange + var context = UnitTestFixture.CreateHttpServerContextMock(); + var hub = UnitTestFixture.CreateComponentHubMock(context); + using var catalog = new PluginTypeCatalog(x => x.Key); + catalog.Attach(hub, context); + var plugin = new HealthTestPluginContext("defective", typeof(ThrowingProbe), typeof(KeylessProbe), typeof(AlphaProbe), typeof(AlphaTwinProbe), typeof(BetaProbe)); + + // act + catalog.Collect(plugin); + + // validation + Assert.Equal(["alpha", "beta"], catalog.Items.Select(x => x.Key).Order()); + Assert.IsType(catalog.Find("alpha")); + var messages = context.Log.GetRecentEntries().Select(x => x.Message).ToArray(); + Assert.Contains(messages, x => x.Contains(typeof(ThrowingProbe).FullName) && x.Contains("probe constructor failed")); + Assert.Contains(messages, x => x.Contains(typeof(KeylessProbe).FullName)); + Assert.Contains(messages, x => x.Contains(typeof(AlphaTwinProbe).FullName)); + } + + /// + /// Keys are trimmed and compared without case. + /// + [Fact] + public void Keys_AreTrimmedAndCaseInsensitive() + { + // arrange + using var catalog = new PluginTypeCatalog(x => x.Key); + + // act + catalog.Collect(new HealthTestPluginContext("plugin", typeof(AlphaProbe))); + + // validation + Assert.NotNull(catalog.Find(" ALPHA ")); + Assert.True(catalog.Contains("Alpha")); + Assert.Null(catalog.Find(" ")); + Assert.Null(catalog.Find(null)); + } + + /// + /// The latest offer wins; a plugin that leaves takes only what it still provides, and a key + /// falls back to the offer below it before it leaves. + /// + [Fact] + public void Ownership_FollowsTheOffers() + { + // arrange + using var catalog = new PluginTypeCatalog(x => x.Key); + var first = new HealthTestPluginContext("first", typeof(AlphaProbe), typeof(BetaProbe)); + var second = new HealthTestPluginContext("second", typeof(AlphaTwinProbe)); + + // act / validation: the second plugin takes over the key + catalog.Collect(first); + catalog.Collect(second); + Assert.IsType(catalog.Find("alpha")); + Assert.Same(second, catalog.OwnerOf("alpha")); + + // the first leaves: the second still provides the key, the first's own item goes + catalog.Withdraw(first); + Assert.IsType(catalog.Find("alpha")); + Assert.Null(catalog.Find("beta")); + + // offered again, it arrives last and takes over + catalog.Collect(first); + Assert.IsType(catalog.Find("alpha")); + + // the provider leaves: the key falls back to the other offer + catalog.Withdraw(first); + Assert.IsType(catalog.Find("alpha")); + Assert.Same(second, catalog.OwnerOf("alpha")); + + // the last offer leaves: the key leaves + catalog.Withdraw(second); + Assert.Null(catalog.Find("alpha")); + Assert.Empty(catalog.Items); + } + + /// + /// A registered item is nobody's: it competes as an offer, no plugin removal takes it, and + /// only unregistering does. + /// + [Fact] + public void Register_SurvivesThePlugins() + { + // arrange + using var catalog = new PluginTypeCatalog(x => x.Key); + var plugin = new HealthTestPluginContext("plugin", typeof(AlphaProbe)); + var later = new HealthTestPluginContext("later", typeof(AlphaTwinProbe)); + var registered = new ConstructorProbe("alpha"); + + // act / validation + catalog.Collect(plugin); + catalog.Register(registered); + Assert.Same(registered, catalog.Find("alpha")); + Assert.Null(catalog.OwnerOf("alpha")); + + catalog.Collect(later); + Assert.IsType(catalog.Find("alpha")); + + catalog.Withdraw(later); + catalog.Withdraw(plugin); + Assert.Same(registered, catalog.Find("alpha")); + + var replacement = new ConstructorProbe("ALPHA"); + catalog.Register(replacement); + Assert.Same(replacement, Assert.Single(catalog.Items)); + + Assert.True(catalog.Unregister("alpha")); + Assert.False(catalog.Unregister("alpha")); + Assert.Empty(catalog.Items); + Assert.Throws(() => catalog.Register(new ConstructorProbe(" "))); + } + + /// + /// Every change moves the version and raises the events - the leaving item first - after + /// the lock was released, so a handler may wait for another thread reading the catalog. + /// + [Fact] + public void Events_AndVersion_FollowEveryChange() + { + // arrange + using var catalog = new PluginTypeCatalog(x => x.Key); + var first = new HealthTestPluginContext("first", typeof(AlphaProbe)); + var second = new HealthTestPluginContext("second", typeof(AlphaTwinProbe)); + var events = new List(); + var readable = true; + catalog.Added += (_, e) => + { + events.Add($"+{e.Key}:{e.PluginContext?.PluginId}"); + readable &= Task.Run(() => catalog.Find(e.Key)).Wait(TimeSpan.FromSeconds(5)); + }; + catalog.Removed += (_, e) => events.Add($"-{e.Key}:{e.PluginContext?.PluginId}"); + var versions = new List { catalog.Version }; + + // act + catalog.Collect(first); + versions.Add(catalog.Version); + catalog.Collect(second); + versions.Add(catalog.Version); + catalog.Collect(second); + versions.Add(catalog.Version); + catalog.Withdraw(second); + versions.Add(catalog.Version); + catalog.Dispose(); + versions.Add(catalog.Version); + + // validation + Assert.True(readable); + Assert.Equal(["+alpha:first", "-alpha:first", "+alpha:second", "-alpha:second", "+alpha:first", "-alpha:first"], events); + Assert.True(versions[1] > versions[0]); + Assert.True(versions[2] > versions[1]); + Assert.Equal(versions[2], versions[3]); + Assert.True(versions[4] > versions[3]); + Assert.True(versions[5] > versions[4]); + } + + /// + /// Items the catalog built are disposed when they leave it; registered items belong to + /// their caller. + /// + [Fact] + public void Disposal_ReleasesOnlyWhatTheCatalogBuilt() + { + // arrange + DisposableProbe.Disposed = 0; + var catalog = new PluginTypeCatalog(x => x.Key); + var plugin = new HealthTestPluginContext("plugin", typeof(DisposableProbe)); + var registered = new DisposableProbe { Key = "registered" }; + + // act + catalog.Collect(plugin); + catalog.Register(registered); + catalog.Withdraw(plugin); + var afterWithdraw = DisposableProbe.Disposed; + catalog.Collect(plugin); + catalog.Dispose(); + catalog.Collect(plugin); + + // validation + Assert.Equal(1, afterWithdraw); + Assert.Equal(2, DisposableProbe.Disposed); + Assert.Empty(catalog.Items); + } + + /// + /// Attaching sweeps the plugins already loaded and follows the ones arriving later and the + /// ones leaving; a disposed catalog no longer follows. + /// + [Fact] + public void Attach_SweepsAndFollowsThePlugins() + { + // arrange + var context = UnitTestFixture.CreateHttpServerContextMock(); + var hub = UnitTestFixture.CreateComponentHubMock(context); + var plugins = (WebPlugin.PluginManager)hub.PluginManager; + using var early = new PluginTypeCatalog(x => x.Key); + var disposed = new PluginTypeCatalog(x => x.Key); + early.Attach(hub, context); + disposed.Attach(hub, context); + disposed.Dispose(); + + // act + plugins.Register(); + using var late = new PluginTypeCatalog(x => x.Key); + late.Attach(hub, context); + var found = (early.Find("assembly"), late.Find("assembly")); + foreach (var plugin in hub.PluginManager.Plugins.ToArray()) + { + plugins.Remove(plugin); + } + + // validation + Assert.IsType(found.Item1); + Assert.IsType(found.Item2); + Assert.Empty(early.Items); + Assert.Empty(late.Items); + Assert.Empty(disposed.Items); + Assert.Throws(() => late.Attach(hub, context)); + } + + /// + /// An assembly without a plugin is collected for a test that has no plugin manager. + /// + [Fact] + public void Collect_AnAssemblyWithoutAPlugin() + { + // arrange + using var catalog = new PluginTypeCatalog(x => x.Key); + + // act + catalog.Collect(typeof(AssemblyProbe).Assembly); + catalog.Collect(typeof(AssemblyProbe).Assembly); + + // validation + Assert.IsType(Assert.Single(catalog.Items)); + Assert.Null(catalog.OwnerOf("assembly")); + } + + /// + /// Bound per application, a plugin offers its items to each of its applications separately, + /// and an application that leaves takes only its own. + /// + [Fact] + public void ApplicationBinding_KeepsTheItemsPerApplication() + { + // arrange + var first = new ApplicationContext { ApplicationId = "first" }; + var second = new ApplicationContext { ApplicationId = "second" }; + using var catalog = new PluginTypeCatalog(x => x.Key, typeof(ICatalogProbe), + origin => new ConstructorProbe("probe") { Application = origin.ApplicationContext }, PluginTypeBinding.Application); + var plugin = new HealthTestPluginContext("plugin", typeof(AlphaProbe)); + + // act + catalog.Collect(plugin, [first, second, first, null]); + var forFirst = catalog.Find(first, "probe"); + var forSecond = catalog.Find(second, "probe"); + catalog.Withdraw(first); + + // validation + Assert.Same(first, ((ConstructorProbe)forFirst).Application); + Assert.Same(second, ((ConstructorProbe)forSecond).Application); + Assert.Null(catalog.Find("probe")); + Assert.Empty(catalog.GetItems(first)); + Assert.Same(forSecond, Assert.Single(catalog.GetItems(second))); + Assert.Same(plugin, catalog.OwnerOf(second, "probe")); + + catalog.Withdraw(plugin); + Assert.Empty(catalog.Items); + } + + /// + /// An operation that has no meaning for the binding is refused instead of filing items no + /// lookup would find. + /// + [Fact] + public void Binding_RefusesTheOperationsOfTheOther() + { + // arrange + using var perPlugin = new PluginTypeCatalog(x => x.Key); + using var perApplication = new PluginTypeCatalog(x => x.Key, typeof(ICatalogProbe), _ => null, PluginTypeBinding.Application); + var plugin = new HealthTestPluginContext("plugin", typeof(AlphaProbe)); + + // validation + Assert.Throws(() => perPlugin.Collect(plugin, [new ApplicationContext()])); + Assert.Throws(() => perPlugin.Register(new ApplicationContext(), new AlphaProbe())); + Assert.Throws(() => perApplication.Register(new AlphaProbe())); + Assert.Throws(() => perApplication.Collect(typeof(AlphaProbe).Assembly)); + Assert.Throws(() => new PluginTypeCatalog(x => x.Key, typeof(ICatalogProbe), null)); + } + + /// + /// A public type nested in another is not top-level and therefore no item. + /// + public sealed class NestedProbe : ICatalogProbe + { + /// + public string Key => "nested"; + } + } + + /// + /// The contract of the probe items. + /// + public interface ICatalogProbe + { + /// + /// Gets the key of the item. + /// + string Key { get; } + } + + /// + /// The contract of the probe the test assembly's plugins offer. + /// + public interface IAssemblyProbe + { + /// + /// Gets the key of the item. + /// + string Key { get; } + } + + /// + /// The one item of the test assembly under . + /// + public sealed class AssemblyProbe : IAssemblyProbe + { + /// + public string Key => "assembly"; + } + + /// + /// An item following the convention. + /// + public sealed class AlphaProbe : ICatalogProbe + { + /// + public string Key => "alpha"; + } + + /// + /// A second item under the key of . + /// + public sealed class AlphaTwinProbe : ICatalogProbe + { + /// + public string Key => "alpha"; + } + + /// + /// An item under a key of its own. + /// + public sealed class BetaProbe : ICatalogProbe + { + /// + public string Key => "beta"; + } + + /// + /// An unsealed type, which is no item. + /// + public class UnsealedProbe : ICatalogProbe + { + /// + public string Key => "unsealed"; + } + + /// + /// An open generic type, which is no item. + /// + /// Unused. + public sealed class GenericProbe : ICatalogProbe + { + /// + public string Key => "generic"; + } + + /// + /// A type without a parameterless constructor, an item only through a factory. + /// + /// The key. + public sealed class ConstructorProbe(string key) : ICatalogProbe + { + /// + public string Key => key; + + /// + /// Gets or sets the application the factory built it for. + /// + public IApplicationContext Application { get; set; } + } + + /// + /// A type whose constructor throws. + /// + public sealed class ThrowingProbe : ICatalogProbe + { + /// + /// Fails, the way a defective item of a plugin does. + /// + public ThrowingProbe() + { + throw new InvalidOperationException("probe constructor failed"); + } + + /// + public string Key => "throwing"; + } + + /// + /// A type that names no key. + /// + public sealed class KeylessProbe : ICatalogProbe + { + /// + public string Key => " "; + } + + /// + /// A disposable item counting its disposals. + /// + public sealed class DisposableProbe : ICatalogProbe, IDisposable + { + /// + /// Gets or sets the number of disposals of every instance. + /// + public static int Disposed { get; set; } + + /// + public string Key { get; init; } = "disposable"; + + /// + public void Dispose() + { + Disposed++; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestGetRequest.cs b/src/WebExpress.WebCore.Test/Message/UnitTestGetRequest.cs index ab0e4739..d82e7ff3 100644 --- a/src/WebExpress.WebCore.Test/Message/UnitTestGetRequest.cs +++ b/src/WebExpress.WebCore.Test/Message/UnitTestGetRequest.cs @@ -18,7 +18,7 @@ public void General() var content = UnitTestFixture.GetEmbeddedResource("general.get"); var request = UnitTestFixture.CreateRequestMock(content); - Assert.Equal("http://localhost:8080/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); + Assert.Equal("http://localhost/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); } /// @@ -30,7 +30,7 @@ public void Less() var content = UnitTestFixture.GetEmbeddedResource("less.get"); var request = UnitTestFixture.CreateRequestMock(content); - Assert.Equal("http://localhost:8080/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); + Assert.Equal("http://localhost/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); } /// @@ -42,7 +42,7 @@ public void Massive() var content = UnitTestFixture.GetEmbeddedResource("massive.get"); var request = UnitTestFixture.CreateRequestMock(content); - Assert.Equal("http://localhost:8080/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); + Assert.Equal("http://localhost/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); } /// @@ -55,7 +55,7 @@ public void GetParameter() var request = UnitTestFixture.CreateRequestMock(content); var param = request?.GetParameter("a")?.Value; - Assert.Equal("http://localhost:8080/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); + Assert.Equal("http://localhost/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); Assert.Equal("1", param); } @@ -70,7 +70,7 @@ public void GetParameterWithUmlaut() var a = request?.GetParameter("a")?.Value; var b = request?.GetParameter("b")?.Value; - Assert.Equal("http://localhost:8080/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); + Assert.Equal("http://localhost/abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A", request.Uri?.ToString()); Assert.Equal("ä", a); Assert.Equal("ö ü", b); } diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestRequest.cs b/src/WebExpress.WebCore.Test/Message/UnitTestRequest.cs index a37e81cb..5abaca52 100644 --- a/src/WebExpress.WebCore.Test/Message/UnitTestRequest.cs +++ b/src/WebExpress.WebCore.Test/Message/UnitTestRequest.cs @@ -8,6 +8,41 @@ namespace WebExpress.WebCore.Test.Message { public class UnitTestRequest { + /// + /// Prevents form actions from inheriting a container listener port absent from the public host. + /// + /// The HTTP scheme received by the server. + /// The public host header, including an optional explicit port. + /// The independently configured sitemap base URI. + /// The request URI that form rendering must receive. + [Theory] + [InlineData("http", "vila", "http://vila/", "http://vila/vila/settings/mode?tab=1")] + [InlineData("http", "vila", null, "http://vila/vila/settings/mode?tab=1")] + [InlineData("http", "vila:80", "http://vila/", "http://vila/vila/settings/mode?tab=1")] + [InlineData("http", "vila:9090", null, "http://vila:9090/vila/settings/mode?tab=1")] + [InlineData("https", "vila", null, "https://vila/vila/settings/mode?tab=1")] + [InlineData("https", "vila:8443", null, "https://vila:8443/vila/settings/mode?tab=1")] + public void RequestUriPreservesPublicPort(string scheme, string host, string externalUri, string expected) + { + // arrange + var features = Prepare(null, RequestMethod.GET, "?tab=1"); + features.Get().LocalPort = 8080; + var requestFeature = features.Get(); + requestFeature.Scheme = scheme; + requestFeature.Headers.Host = host; + requestFeature.RawTarget = "/vila/settings/mode?tab=1"; + var context = Fixture.UnitTestFixture.CreateHttpServerContextMock(externalUri: externalUri); + + // act + var httpContext = new WebMessage.HttpContext(features, context); + var request = httpContext.Request; + + // validation + Assert.Equal(expected, request.Uri.ToString()); + Assert.Equal(expected, httpContext.Uri.ToString()); + Assert.Equal(8080, ((IPEndPoint)request.LocalEndPoint).Port); + } + /// /// Creates the feature collection. /// diff --git a/src/WebExpress.WebCore.Test/TestIdentityValidator.cs b/src/WebExpress.WebCore.Test/TestIdentityValidator.cs new file mode 100644 index 00000000..6657605b --- /dev/null +++ b/src/WebExpress.WebCore.Test/TestIdentityValidator.cs @@ -0,0 +1,41 @@ +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebIdentity; + +namespace WebExpress.WebCore.Test +{ + /// + /// A validator the test plugin defines, so discovery can be observed: it refuses only the + /// account named , which no other test signs in with. + /// + public sealed class TestIdentityValidator : IIdentityValidator + { + /// + /// The name of the account the validator refuses. + /// + public const string BlockedName = "Mallory"; + + /// + /// Gets the application the validator was constructed for. + /// + public IApplicationContext ApplicationContext { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The application, injected by the discovery. + public TestIdentityValidator(IApplicationContext applicationContext) + { + ApplicationContext = applicationContext; + } + + /// + /// Refuses the blocked account. + /// + /// The identity to confirm. + /// False for the blocked account. + public bool Validate(IdentityValidationContext context) + { + return context.Identity.Name != BlockedName; + } + } +} diff --git a/src/WebExpress.WebCore.Test/WebLog/UnitTestLog.cs b/src/WebExpress.WebCore.Test/WebLog/UnitTestLog.cs index 2879d199..b58e86ad 100644 --- a/src/WebExpress.WebCore.Test/WebLog/UnitTestLog.cs +++ b/src/WebExpress.WebCore.Test/WebLog/UnitTestLog.cs @@ -45,6 +45,95 @@ public void CountersAreTracked() Assert.Equal(3, log.ErrorCount); } + /// + /// Ensures reflection failures retain their nested cause and original throw site in every output. + /// + [Fact] + public void ExceptionPreservesNestedCauseInConsoleAndFile() + { + // arrange + var dir = CreateTempDirectory(); + var log = new Log { LogMode = LogMode.Append }; + var originalOutput = Console.Out; + using var output = new StringWriter(); + var exception = Record.Exception(() => Activator.CreateInstance(typeof(FailingPlugin))); + try + { + Console.SetOut(output); + log.Begin(dir, "exception.log"); + + // act + log.Exception(exception); + log.Close(); + + // validation + var messages = new[] + { + output.ToString(), + File.ReadAllText(Path.Combine(dir, "exception.log")), + string.Join(Environment.NewLine, log.GetRecentEntries().Select(entry => entry.Message)) + }; + foreach (var message in messages) + { + Assert.Contains("System.Reflection.TargetInvocationException", message); + Assert.Contains("System.InvalidOperationException", message); + Assert.Contains("System.IO.IOException", message); + Assert.Contains(FailingPlugin.FailureMessage, message); + Assert.Contains(nameof(FailingPlugin) + "..ctor", message); + } + Assert.Equal(1, log.ExceptionCount); + Assert.Equal(1, log.ErrorCount); + } + finally + { + Console.SetOut(originalOutput); + log.Close(); + Directory.Delete(dir, true); + } + } + + /// + /// Ensures parallel failures retain every cause rather than only the first inner exception. + /// + [Fact] + public void ExceptionPreservesAllAggregateCauses() + { + // arrange + var log = new Log { LogMode = LogMode.Off }; + var exception = new AggregateException( + new InvalidOperationException("first failure"), + new IOException("second failure")); + + // act + log.Exception(exception); + + // validation + var message = string.Join(Environment.NewLine, log.GetRecentEntries().Select(entry => entry.Message)); + Assert.Contains("System.InvalidOperationException: first failure", message); + Assert.Contains("System.IO.IOException: second failure", message); + Assert.Equal(1, log.ExceptionCount); + Assert.Equal(1, log.ErrorCount); + } + + /// + /// Reproduces constructor failures wrapped by reflection during plugin activation. + /// + private sealed class FailingPlugin + { + /// + /// Provides a cause longer than the console width to detect diagnostic truncation. + /// + public static string FailureMessage => new string('x', 500) + " root cause"; + + /// + /// Fails with nested diagnostic context to reproduce an activation error. + /// + public FailingPlugin() + { + throw new InvalidOperationException("Plugin initialization failed.", new IOException(FailureMessage)); + } + } + /// /// Tests that clearing resets all counters. /// diff --git a/src/WebExpress.WebCore.Test/WebSetting/UnitTestSettingsLog.cs b/src/WebExpress.WebCore.Test/WebSetting/UnitTestSettingsLog.cs new file mode 100644 index 00000000..cfbc8eb7 --- /dev/null +++ b/src/WebExpress.WebCore.Test/WebSetting/UnitTestSettingsLog.cs @@ -0,0 +1,38 @@ +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.WebSetting +{ + /// + /// Protects startup diagnostics from credential disclosure and ambiguous multiline values. + /// + public class UnitTestSettingsLog + { + /// + /// Keeps routing values visible while masking credentials in nested configuration sections. + /// + /// The merged configuration path. + /// The configured value. + /// The safe diagnostic representation. + [Theory] + [InlineData("WebExpress:ExternalUri", "http://vila/", "http://vila/")] + [InlineData("WebExpress:Endpoints:0:Uri", "http://0.0.0.0:8080/", "http://0.0.0.0:8080/")] + [InlineData("WebExpress:ExternalUri", null, "(not configured)")] + [InlineData("WebExpress:Endpoints:0:Password", "test-secret", "[redacted]")] + [InlineData("WebExpress:Authentication:SigningKey", "test-secret", "[redacted]")] + [InlineData("Plugins:example:ClientSecret", "test-secret", "[redacted]")] + [InlineData("Plugins:example:ConnectionString", "test-secret", "[redacted]")] + [InlineData("Plugins:example:Uri", "https://user:password@example.com/", "[redacted]")] + [InlineData("Plugins:example:Label", "first\r\nsecond", "first\\r\\nsecond")] + public void FormatValuePreservesDiagnostics(string key, string value, string expected) + { + // arrange + var configurationKey = key; + + // act + var result = SettingsLog.FormatValue(configurationKey, value); + + // validation + Assert.Equal(expected, result); + } + } +} diff --git a/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs index a575b55c..fdba2b5b 100644 --- a/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs +++ b/src/WebExpress.WebCore.Test/WebUri/UnitTestUri.cs @@ -414,5 +414,29 @@ public void BindParametersQuery() Assert.Equal("/a/${testparametera}/b?testparameterb=", uri.ToString()); Assert.Equal("/a/10/b?testparameterb=20", bind.ToString()); } + + /// + /// Tests that a path variable which already carries a value keeps it, so a link to + /// record 7 rendered on the page of record 42 still leads to 7. + /// + [Fact] + public void BindParametersKeepsBoundVariable() + { + // arrange + var pathSegments = new IUriPathSegment[] + { + new UriPathSegmentConstant("a"), + new UriPathSegmentVariableInt() + }; + + var uri = new UriEndpoint(pathSegments) + .BindParameters(new TestParameterA() { Value = "7" }); + + // act + var bind = uri.BindParameters(new TestParameterA() { Value = "42" }); + + // validation + Assert.Equal("/a/7", bind.ToString()); + } } } diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index 1804bd20..0d23cae6 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -275,6 +275,11 @@ root.title=Anwendungen root.introduction=Wählen Sie eine Anwendung aus, um fortzufahren. root.empty=Es sind keine Anwendungen verfügbar. +plugintypecatalog.type_failed=Der Typ '{0}' von '{1}' wurde übersprungen: {2} +plugintypecatalog.key_missing=Der Typ '{0}' von '{1}' wurde übersprungen, weil er keinen Schlüssel nennt. +plugintypecatalog.key_duplicate=Der Typ '{0}' von '{1}' wurde übersprungen, weil ein anderer Typ dort bereits den Schlüssel '{2}' verwendet. +plugintypecatalog.types_incomplete=Nicht alle Typen von '{0}' konnten geladen werden: {1} + health.host_unavailable=Der HTTP-Host wird gestartet oder beendet. health.component_unavailable=Ein Komponentenmanager konnte nicht initialisiert werden. health.application_unavailable=Die Anwendung '{0}' des Plugins '{1}' konnte nicht erzeugt werden: {2} diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index 729ce90c..da85f3c3 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -275,6 +275,11 @@ root.title=Applications root.introduction=Select an application to continue. root.empty=No applications are available. +plugintypecatalog.type_failed=The type '{0}' of '{1}' was skipped: {2} +plugintypecatalog.key_missing=The type '{0}' of '{1}' was skipped because it names no key. +plugintypecatalog.key_duplicate=The type '{0}' of '{1}' was skipped because another type of it already uses the key '{2}'. +plugintypecatalog.types_incomplete=Not every type of '{0}' could be loaded: {1} + health.host_unavailable=The HTTP host is starting or stopping. health.component_unavailable=A component manager failed to initialize. health.application_unavailable=The application '{0}' of the plugin '{1}' could not be created: {2} diff --git a/src/WebExpress.WebCore/WebAsset/AssetManager.cs b/src/WebExpress.WebCore/WebAsset/AssetManager.cs index 9e08930d..7868be9e 100644 --- a/src/WebExpress.WebCore/WebAsset/AssetManager.cs +++ b/src/WebExpress.WebCore/WebAsset/AssetManager.cs @@ -126,7 +126,7 @@ private void Register(IPluginContext pluginContext) } } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// @@ -168,7 +168,7 @@ private void Register(IPluginContext pluginContext, IEnumerable (ApplicationContext: x, Prefix: GetPluginRoute(x, pluginContext).Concat(new UriPathSegmentConstant("assets")))) + .Select(x => (ApplicationContext: x, Prefix: EndpointManager.CreatePluginRoute(x, pluginContext).Concat(new UriPathSegmentConstant("assets")))) .ToArray(); foreach (var resource in embeddedResources) @@ -226,25 +226,6 @@ private void Register(IPluginContext pluginContext, IEnumerable - /// Returns the route a plugin's endpoints are addressed under within an application. - /// A plugin that owns the application serves straight from the application route, - /// while every other plugin contributing to that application is given a segment of - /// its own so two plugins cannot collide on the same file name. - /// - /// The context of the application. - /// The context of the plugin. - /// The route the plugin's assets are mounted under. - private static IRoute GetPluginRoute(IApplicationContext applicationContext, IPluginContext pluginContext) - { - return applicationContext.Route.Concat - ( - applicationContext.PluginContext != pluginContext - ? pluginContext.PluginId.ToString() - : null - ); - } - /// /// Returns the route the given embedded file of a plugin is served from within an /// application. Consumers that have to link an embedded file - an include rendering a @@ -264,7 +245,7 @@ public IRoute GetAssetRoute(IApplicationContext applicationContext, IPluginConte return null; } - return GetPluginRoute(applicationContext, pluginContext).Concat(file); + return EndpointManager.CreatePluginRoute(applicationContext, pluginContext).Concat(file); } /// diff --git a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs index 9a6b0308..4f08f012 100644 --- a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs @@ -10,6 +10,7 @@ using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebUri; namespace WebExpress.WebCore.WebEndpoint @@ -162,6 +163,28 @@ private static Type[] GetAssemblyTypes(Assembly assembly) return _assemblyTypes.GetOrAdd(assembly, a => a.GetTypes()); } + /// + /// Returns the route a plugin's endpoints are addressed under within an application. + /// A plugin that owns the application serves straight from the application route, + /// while every other plugin contributing to that application is given a segment of + /// its own so two plugins cannot collide on the same path. The segment is the plugin + /// id and never the plugin name: the name is a display text, often an i18n key whose + /// colon does not belong into a path, and pages, rest apis and assets of one plugin + /// must share one prefix. + /// + /// The context of the application. + /// The context of the plugin. + /// The route the plugin's endpoints are mounted under. + public static IRoute CreatePluginRoute(IApplicationContext applicationContext, IPluginContext pluginContext) + { + return applicationContext.Route.Concat + ( + applicationContext.PluginContext != pluginContext + ? pluginContext.PluginId.ToString() + : null + ); + } + /// /// Returns the route of an endpoint based on the class type, application context and segment attributes. /// diff --git a/src/WebExpress.WebCore/WebEvent/EventManager.cs b/src/WebExpress.WebCore/WebEvent/EventManager.cs index 45eaedd1..3298a75d 100644 --- a/src/WebExpress.WebCore/WebEvent/EventManager.cs +++ b/src/WebExpress.WebCore/WebEvent/EventManager.cs @@ -122,7 +122,7 @@ private void Register(IPluginContext pluginContext) return; } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index 020f1fda..6ff8e171 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -219,7 +219,17 @@ public int Execution(string[] args) return 1; } - PackageBuilder.Create(argumentDict["spec"], argumentDict["config"], argumentDict["target"], argumentDict["output"]); + try + { + PackageBuilder.Create(argumentDict["spec"], argumentDict["config"], argumentDict["target"], argumentDict["output"]); + } + catch (Exception ex) + { + // the post build step only shows the exit code, so the reason has to be on the console + Console.Error.WriteLine($"*** PackageBuilder: {ex.Message}"); + + return 1; + } return 0; } @@ -407,6 +417,13 @@ private bool OnInitialization(string args, string settingsFile) _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.uri"), args: v.Uri); } + _httpServer.HttpServerContext.Log?.Info(message: $"External URI: {SettingsLog.FormatValue("WebExpress:ExternalUri", settings.ExternalUri)}"); + _httpServer.HttpServerContext.Log?.Info(message: $"Context path: {settings.ContextPath ?? "(empty)"}"); + foreach (var parameter in configuration.AsEnumerable().Where(x => x.Value is not null).OrderBy(x => x.Key, StringComparer.Ordinal)) + { + _httpServer.HttpServerContext.Log?.Info(message: $"Configuration {parameter.Key}: {SettingsLog.FormatValue(parameter.Key, parameter.Value)}"); + } + _httpServer.HttpServerContext.Log?.Separator('='); Directory.CreateDirectory(packageBase); diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index 3e4e707d..da06c282 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -3,8 +3,7 @@ Library WebExpress.WebCore - 2.0.0.0 - 2.0.0.0 + 2.0.1-alpha net10.0 any https://github.com/webexpress-framework/WebExpress.git @@ -14,7 +13,6 @@ true True Core library of the WebExpress web server. - 2.0.0-alpha https://github.com/webexpress-framework/WebExpress icon.png README.md @@ -30,15 +28,18 @@ true - $(PackageVersion) - - $(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath) + $(Version) + + $(MSBuildProjectDirectory)\$(BaseIntermediateOutputPath)sbom-components + - + + + @@ -75,6 +76,17 @@ + + + + "downloadDependencies":\s*\[[^{}\]]*(\{[^{}]*\}[^{}\]]*)*\] + $([System.IO.File]::ReadAllText('$(ProjectAssetsFile)')) + $([System.Text.RegularExpressions.Regex]::Replace('$(SbomRestoreGraph)', '$(SbomDownloadDependencies)', '"downloadDependencies": []')) + + + + + diff --git a/src/WebExpress.WebCore/WebFragment/FragmentManager.cs b/src/WebExpress.WebCore/WebFragment/FragmentManager.cs index 56febb4a..76300cf9 100644 --- a/src/WebExpress.WebCore/WebFragment/FragmentManager.cs +++ b/src/WebExpress.WebCore/WebFragment/FragmentManager.cs @@ -76,7 +76,7 @@ private void Register(IPluginContext pluginContext) return; } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// @@ -183,7 +183,7 @@ private void Register(IPluginContext pluginContext, IEnumerable /// Discovers health components through plugin and application events and aggregates their availability. /// + /// + /// The discovery is a bound per application: one binding + /// per public sealed type, contributing plugin and associated application. + /// The key names the plugin and the type, so no two plugins ever shadow each other's checks. + /// public class HealthManager : IHealthManager, ISystemComponent { private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; private readonly Lock _sync = new(); - private readonly List _items = []; - private long _revision; + private readonly PluginTypeCatalog _catalog; private bool _disposed; /// @@ -41,10 +45,7 @@ public IEnumerable HealthChecks { get { - lock (_sync) - { - return _items.Select(x => x.HealthContext).ToArray(); - } + return _catalog.Items.Select(x => x.HealthContext).ToArray(); } } @@ -58,10 +59,39 @@ private HealthManager(IComponentHub componentHub, IHttpServerContext httpServerC { _componentHub = componentHub; _httpServerContext = httpServerContext; - _componentHub.PluginManager.AddPlugin += OnAddPlugin; - _componentHub.PluginManager.RemovePlugin += OnRemovePlugin; - _componentHub.ApplicationManager.AddApplication += OnAddApplication; - _componentHub.ApplicationManager.RemoveApplication += OnRemoveApplication; + _catalog = new PluginTypeCatalog(KeyOf, typeof(IHealth), Create, PluginTypeBinding.Application); + _catalog.Added += (_, e) => AddHealth?.Invoke(this, e.Item.HealthContext); + _catalog.Removed += (_, e) => RemoveHealth?.Invoke(this, e.Item.HealthContext); + _catalog.Attach(componentHub, httpServerContext); + } + + /// + /// Keys a binding by its plugin and type, so checks of different plugins never replace + /// each other even when their types share a name. + /// + /// The binding. + /// The key. + private static string KeyOf(HealthItem item) + { + return $"{item.HealthContext.PluginContext?.PluginId}/{item.HealthClass.FullName}"; + } + + /// + /// Builds a binding without activating the component, so a failing constructor surfaces as + /// an unsuccessful check rather than as a missing one. + /// + /// The discovered type and its plugin and application. + /// The binding. + private HealthItem Create(PluginTypeOrigin origin) + { + var context = new HealthContext + { + PluginContext = origin.PluginContext, + ApplicationContext = origin.ApplicationContext, + HealthId = new ComponentId(origin.Type.FullName.ToLowerInvariant()) + }; + + return new HealthItem(origin.Type, context, _componentHub, _httpServerContext); } /// @@ -71,7 +101,7 @@ private HealthManager(IComponentHub componentHub, IHttpServerContext httpServerC /// A snapshot of the health components bound to the application. public IEnumerable GetHealthChecks(IApplicationContext applicationContext) { - return HealthChecks.Where(x => x.ApplicationContext == applicationContext).ToArray(); + return _catalog.GetItems(applicationContext).Select(x => x.HealthContext).ToArray(); } /// @@ -81,41 +111,7 @@ public IEnumerable GetHealthChecks(IApplicationContext applicati /// The applications associated with the plugin. internal void Register(IPluginContext pluginContext, IEnumerable applicationContexts) { - var healthTypes = pluginContext.Assembly.GetTypes() - .Where(x => x.IsClass && x.IsPublic && x.IsSealed && !x.ContainsGenericParameters && - typeof(IHealth).IsAssignableFrom(x)).ToArray(); - - foreach (var applicationContext in applicationContexts.Distinct()) - { - foreach (var healthType in healthTypes) - { - HealthContext context; - lock (_sync) - { - if (_disposed) - { - return; - } - - if (_items.Any(x => x.HealthContext.PluginContext == pluginContext && - x.HealthContext.ApplicationContext == applicationContext && x.HealthClass == healthType)) - { - continue; - } - - context = new HealthContext - { - PluginContext = pluginContext, - ApplicationContext = applicationContext, - HealthId = new ComponentId(healthType.FullName.ToLowerInvariant()) - }; - _items.Add(new HealthItem(healthType, context, _componentHub, _httpServerContext)); - _revision++; - } - - AddHealth?.Invoke(this, context); - } - } + _catalog.Collect(pluginContext, applicationContexts); } /// @@ -135,8 +131,8 @@ public async Task CheckAsync(CancellationToken cancellationToken = default return false; } - items = _items.ToArray(); - revision = _revision; + revision = _catalog.Version; + items = [.. _catalog.Items]; } var frameworkHealthy = CheckFramework(); @@ -145,7 +141,7 @@ public async Task CheckAsync(CancellationToken cancellationToken = default // lifecycle state may change while dependency checks are waiting lock (_sync) { - return !_disposed && revision == _revision && frameworkHealthy && results.All(x => x) && CheckFramework(); + return !_disposed && revision == _catalog.Version && frameworkHealthy && results.All(x => x) && CheckFramework(); } } @@ -203,74 +199,7 @@ private bool CheckFramework() /// The plugin whose component bindings must be removed. internal void Remove(IPluginContext pluginContext) { - Remove(x => x.HealthContext.PluginContext == pluginContext); - } - - /// - /// Detaches matching bindings before component disposal can execute application code. - /// - /// The ownership condition identifying bindings to remove. - private void Remove(Func predicate) - { - HealthItem[] removed; - lock (_sync) - { - removed = _items.Where(predicate).ToArray(); - foreach (var item in removed) - { - _items.Remove(item); - _revision++; - } - } - - foreach (var item in removed) - { - item.Dispose(); - RemoveHealth?.Invoke(this, item.HealthContext); - } - } - - /// - /// Discovers checks for applications associated with a newly available plugin. - /// - /// The plugin manager raising the lifecycle event. - /// The plugin whose components are available. - private void OnAddPlugin(object sender, IPluginContext pluginContext) - { - Register(pluginContext, _componentHub.PluginManager.GetAssociatedApplications(pluginContext)); - } - - /// - /// Discovers checks from plugins already associated with a newly available application. - /// - /// The application manager raising the lifecycle event. - /// The application whose dependencies must participate in probes. - private void OnAddApplication(object sender, IApplicationContext applicationContext) - { - foreach (var pluginContext in _componentHub.PluginManager.GetPlugins(applicationContext)) - { - Register(pluginContext, [applicationContext]); - } - } - - /// - /// Prevents an unloaded plugin from retaining health component instances. - /// - /// The plugin manager raising the lifecycle event. - /// The plugin whose bindings must be removed. - private void OnRemovePlugin(object sender, IPluginContext pluginContext) - { - Remove(pluginContext); - } - - /// - /// Prevents removed applications from leaving checks contributed by any plugin in the host. - /// - /// The application manager raising the lifecycle event. - /// The application whose bindings must be removed. - private void OnRemoveApplication(object sender, IApplicationContext applicationContext) - { - Remove(x => x.HealthContext.ApplicationContext == applicationContext); + _catalog.Withdraw(pluginContext); } /// @@ -286,13 +215,10 @@ public void Dispose() } _disposed = true; - _componentHub.PluginManager.AddPlugin -= OnAddPlugin; - _componentHub.PluginManager.RemovePlugin -= OnRemovePlugin; - _componentHub.ApplicationManager.AddApplication -= OnAddApplication; - _componentHub.ApplicationManager.RemoveApplication -= OnRemoveApplication; } - Remove(_ => true); + // detaches every binding and disposes it once its active check finished + _catalog.Dispose(); GC.SuppressFinalize(this); } } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs index ed26c6e2..7c34e6d4 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs @@ -117,6 +117,23 @@ public interface IIdentityManager : IComponentManager /// The current signed-in identity. IIdentity GetCurrentIdentity(IRequest request); + /// + /// Adds a validator that confirms every identity of the application on each request. Plugins + /// need not call this: their public sealed validators are discovered for the applications + /// they serve. + /// + /// The validator. + /// The application whose requests it confirms. + void RegisterValidator(IIdentityValidator validator, IApplicationContext applicationContext); + + /// + /// Removes a validator from the application. + /// + /// The validator. + /// The application it was added to. + /// True when the validator was registered. + bool UnregisterValidator(IIdentityValidator validator, IApplicationContext applicationContext); + /// /// Checks whether the specified identity satisfies all policies associated with the given endpoint context. /// diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityValidator.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityValidator.cs new file mode 100644 index 00000000..46542b8a --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityValidator.cs @@ -0,0 +1,25 @@ +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Confirms on every request that a signed identity may still act. A signed access token is a + /// snapshot that stays valid until it expires; a validator closes that window for what the + /// application knows better than the token - an account that was disabled, a grant that was + /// withdrawn - without waiting for the next renewal. + /// + /// + /// A plugin's public sealed class implementing the interface is discovered for every + /// application the plugin serves and constructed once per application; its constructor may + /// take the component hub, the server context, the application context and the plugin + /// context. A validator runs on every authenticated request, so it answers from a cache or + /// an index rather than from a slow store. A validator that throws refuses the identity. + /// + public interface IIdentityValidator + { + /// + /// Decides whether the identity of a request is still accepted. + /// + /// The identity, its token's grant and the request. + /// True to accept the identity, false to treat the request as anonymous. + bool Validate(IdentityValidationContext context); + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs index 796c8800..cc0c1b28 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs @@ -119,10 +119,11 @@ public IdentityTokenPair Refresh(IRequest request) state.Changed = true; return null; } - state.Identity = ValidateAccessToken(pair.AccessToken, request.ApplicationContext); - state.Pair = pair; + // a grant that may no longer act is not renewed either; its cookies are removed as above + state.Identity = Confirm(ValidateAccessToken(pair.AccessToken, request.ApplicationContext), pair.AccessToken, request); + state.Pair = state.Identity is not null ? pair : null; state.Changed = true; - return pair; + return state.Pair; } /// @@ -150,7 +151,7 @@ public IIdentity GetCurrentIdentity(IRequest request) { if (request?.ApplicationContext is null) { return null; } if (_authenticationStates.TryGetValue(request, out var state)) { return state.Identity; } - var identity = AuthenticateRequest(request); + var identity = Confirm(AuthenticateRequest(request, out var token), token, request); if (identity is not null) { Metrics?.RecordActiveUser(identity.Id); @@ -165,18 +166,20 @@ public IIdentity GetCurrentIdentity(IRequest request) /// Verifies the credential a request carries, preferring an explicit authorization header over the browser cookie. /// /// The HTTP request whose authentication context is being evaluated. + /// The credential the request carries, or null. /// The verified request identity, or null when no accepted credential authenticates it. - private IIdentity AuthenticateRequest(IRequest request) + private IIdentity AuthenticateRequest(IRequest request, out string token) { var authorization = request.Header.Authorization; // an explicit, invalid authorization header must never fall back to browser credentials if (authorization is not null) { + token = authorization.Token; return string.Equals(authorization.Type, "Bearer", StringComparison.OrdinalIgnoreCase) ? ValidatePersonalAccessToken(authorization.Token, request.ApplicationContext) : null; } - var cookie = CookieValue(request, AccessCookie); - return cookie is null ? null : ValidateAccessToken(cookie, request.ApplicationContext); + token = CookieValue(request, AccessCookie); + return token is null ? null : ValidateAccessToken(token, request.ApplicationContext); } /// diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Token.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Token.cs index f6cac315..2cb6e0f0 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Token.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Token.cs @@ -32,8 +32,10 @@ public partial class IdentityManager /// The lifetime of a signed authorization snapshot. /// The absolute lifetime of a login grant. /// The maximum lifetime of a personal credential. + /// The configured groups of applications that accept each other's tokens. private sealed record TokenAuthority(SigningCredentials Signing, string Issuer, string Audience, - TimeSpan AccessLifetime, TimeSpan RefreshLifetime, TimeSpan PersonalLifetime); + TimeSpan AccessLifetime, TimeSpan RefreshLifetime, TimeSpan PersonalLifetime, + IReadOnlyList Groups); /// /// Gets or sets the UTC clock that enforces signed expiration boundaries, replaceable so tests can cross deadlines without sleeping. @@ -62,9 +64,13 @@ private TokenAuthority Authority { throw new ArgumentException("Authentication token lifetimes are invalid."); } + var groups = (settings.ApplicationGroups ?? []) + .Select(x => (x.Value ?? []).Where(id => !string.IsNullOrWhiteSpace(id)).Select(id => id.Trim()).ToArray()) + .Where(x => x.Length > 1) + .ToArray(); _authority = new TokenAuthority(new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256), settings.Issuer, settings.Audience, settings.AccessTokenLifetime, settings.RefreshTokenLifetime, - settings.MaximumPersonalAccessTokenLifetime); + settings.MaximumPersonalAccessTokenLifetime, groups); return _authority; } } @@ -151,7 +157,23 @@ public IdentityTokenPair Refresh(string token, IApplicationContext applicationCo return null; } var rememberMe = !jwt.TryGetPayloadValue("remember_me", out var persistent) || persistent; - return IssuePair(identity, applicationContext, grant, expires, rememberMe); + // the renewed pair keeps the audience of the application the sign-in was issued for: + // renewing in a member of an overlapping group must not hand the grant to the + // applications that only trust the renewing one + return IssuePair(identity, applicationContext, grant, expires, rememberMe, IssuedFor(jwt)); + } + + /// + /// Returns the application a token was issued for, read from its audience. + /// + /// The verified token. + /// The application id, or null when the audience is not one of this deployment. + private string IssuedFor(JsonWebToken jwt) + { + var prefix = RequireAuthority().Audience + "/"; + var audience = jwt.Audiences.FirstOrDefault(x => x.StartsWith(prefix, StringComparison.Ordinal)); + + return audience?[prefix.Length..]; } /// @@ -265,16 +287,18 @@ private string SignPersonalAccessToken(IIdentity identity, IApplicationContext a /// The identifier of the original login grant. /// The absolute deadline beyond which the login grant cannot be renewed. /// Whether browser credentials persist beyond the browser session. + /// The application whose audience the pair carries; null for the given application. /// The signed token pair constrained by the absolute grant deadline. - private IdentityTokenPair IssuePair(IIdentity identity, IApplicationContext applicationContext, string grant, DateTimeOffset deadline, bool rememberMe) + private IdentityTokenPair IssuePair(IIdentity identity, IApplicationContext applicationContext, string grant, DateTimeOffset deadline, bool rememberMe, + string issuedFor = null) { ArgumentNullException.ThrowIfNull(identity); var accessDeadline = Clock.GetUtcNow() + RequireAuthority().AccessLifetime; if (accessDeadline > deadline) { accessDeadline = deadline; } var pair = new IdentityTokenPair { - AccessToken = SignIdentity(identity, applicationContext, "access", accessDeadline, grant, deadline), - RefreshToken = SignIdentity(identity, applicationContext, "refresh", deadline, grant, deadline, rememberMe), + AccessToken = SignIdentity(identity, applicationContext, "access", accessDeadline, grant, deadline, issuedFor: issuedFor), + RefreshToken = SignIdentity(identity, applicationContext, "refresh", deadline, grant, deadline, rememberMe, issuedFor), AccessTokenExpiresAt = accessDeadline, RefreshTokenExpiresAt = deadline, RememberMe = rememberMe @@ -296,9 +320,10 @@ private IdentityTokenPair IssuePair(IIdentity identity, IApplicationContext appl /// The identifier of the original login grant. /// The expiration deadline shared by credentials from one login grant. /// Whether a refresh credential may restore persistent browser cookies. + /// The application whose audience the credential carries; null for the given application. /// The signed credential containing the trusted identity snapshot. private string SignIdentity(IIdentity identity, IApplicationContext applicationContext, string purpose, DateTimeOffset expires, - string grant = null, DateTimeOffset? grantExpires = null, bool rememberMe = true) + string grant = null, DateTimeOffset? grantExpires = null, bool rememberMe = true, string issuedFor = null) { var claims = new Dictionary { @@ -309,7 +334,7 @@ private string SignIdentity(IIdentity identity, IApplicationContext applicationC if (purpose == "refresh") { claims["remember_me"] = rememberMe; } if (identity.Email is not null) { claims["email"] = identity.Email; } if (grant is not null) { claims["grant"] = grant; claims["grant_exp"] = grantExpires.Value.ToUnixTimeSeconds(); } - return SignToken(claims, applicationContext, purpose, expires); + return SignToken(claims, applicationContext, purpose, expires, issuedFor); } /// @@ -319,8 +344,10 @@ private string SignIdentity(IIdentity identity, IApplicationContext applicationC /// The application that scopes token audiences and authentication. /// The token purpose separating access, renewal, and browser correlation. /// The signed expiration deadline of the credential. + /// The application whose audience the credential carries; null for the given application. /// The signed JWT bound to the deployment trust boundary. - private string SignToken(Dictionary claims, IApplicationContext applicationContext, string purpose, DateTimeOffset expires) + private string SignToken(Dictionary claims, IApplicationContext applicationContext, string purpose, DateTimeOffset expires, + string issuedFor = null) { var authority = RequireAuthority(); ArgumentNullException.ThrowIfNull(applicationContext); @@ -329,7 +356,7 @@ private string SignToken(Dictionary claims, IApplicationContext claims["token_use"] = purpose; return _tokenHandler.CreateToken(new SecurityTokenDescriptor { - Issuer = authority.Issuer, Audience = authority.Audience + "/" + applicationContext.ApplicationId, + Issuer = authority.Issuer, Audience = authority.Audience + "/" + (issuedFor ?? applicationContext.ApplicationId), IssuedAt = Clock.GetUtcNow().UtcDateTime, NotBefore = Clock.GetUtcNow().UtcDateTime, Expires = expires.UtcDateTime, Claims = claims, SigningCredentials = authority.Signing, TokenType = "wx-" + purpose + "+jwt" @@ -352,7 +379,7 @@ private JsonWebToken ValidateToken(string token, IApplicationContext application var result = _tokenHandler.ValidateTokenAsync(token, new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = authority.Issuer, - ValidateAudience = true, ValidAudience = authority.Audience + "/" + applicationContext.ApplicationId, + ValidateAudience = true, ValidAudiences = AcceptedAudiences(authority, applicationContext, purpose), ValidateIssuerSigningKey = true, IssuerSigningKey = authority.Signing.Key, RequireSignedTokens = true, RequireExpirationTime = true, ValidAlgorithms = [SecurityAlgorithms.HmacSha256], ValidTypes = ["wx-" + purpose + "+jwt"], @@ -366,6 +393,29 @@ private JsonWebToken ValidateToken(string token, IApplicationContext application return jwt; } + /// + /// Returns the audiences a token of the purpose is accepted with in the application: its own, + /// and for the credentials of a sign-in also those of the applications grouped with it. A + /// challenge correlates one browser round trip with one application and stays its own. + /// + /// The signing authority. + /// The application the token is presented to. + /// The token purpose. + /// The accepted audiences. + private static IEnumerable AcceptedAudiences(TokenAuthority authority, IApplicationContext applicationContext, string purpose) + { + var own = applicationContext.ApplicationId; + var applications = purpose == "challenge" + ? [own] + : authority.Groups + .Where(x => x.Contains(own, StringComparer.OrdinalIgnoreCase)) + .SelectMany(x => x) + .Append(own) + .Distinct(StringComparer.OrdinalIgnoreCase); + + return [.. applications.Select(x => authority.Audience + "/" + x)]; + } + /// /// Prevents authentication from silently falling back to ephemeral signing configuration. /// diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Validation.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Validation.cs new file mode 100644 index 00000000..f5122081 --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Validation.cs @@ -0,0 +1,176 @@ +using Microsoft.IdentityModel.JsonWebTokens; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Asks the validators of an application, on every request, whether a signed identity may + /// still act, so a disabled account or a withdrawn grant stops working before the token expires. + /// + public partial class IdentityManager + { + private readonly object _validatorGate = new(); + private readonly List<(IPluginContext Plugin, IApplicationContext Application, IIdentityValidator Validator)> _validators = []; + + /// + /// Adds a validator that is asked about every identity of the application. + /// + /// The validator. + /// The application whose requests it confirms. + public void RegisterValidator(IIdentityValidator validator, IApplicationContext applicationContext) + { + ArgumentNullException.ThrowIfNull(validator); + ArgumentNullException.ThrowIfNull(applicationContext); + + lock (_validatorGate) + { + if (!_validators.Any(x => x.Application == applicationContext && ReferenceEquals(x.Validator, validator))) + { + _validators.Add((null, applicationContext, validator)); + } + } + } + + /// + /// Removes a validator from the application. + /// + /// The validator. + /// The application it was added to. + /// True when the validator was registered. + public bool UnregisterValidator(IIdentityValidator validator, IApplicationContext applicationContext) + { + lock (_validatorGate) + { + return _validators.RemoveAll(x => x.Application == applicationContext && ReferenceEquals(x.Validator, validator)) > 0; + } + } + + /// + /// Constructs the validators a plugin defines, once for each of its applications. + /// + /// The plugin. + /// The applications the plugin serves. + private void RegisterValidators(IPluginContext pluginContext, IEnumerable applicationContexts) + { + var types = pluginContext?.Assembly?.GetTypes() + .Where(x => x.IsClass && x.IsSealed && x.IsPublic && !x.ContainsGenericParameters && typeof(IIdentityValidator).IsAssignableFrom(x)) + .ToArray() ?? []; + + if (types.Length == 0) + { + return; + } + + foreach (var applicationContext in applicationContexts ?? []) + { + foreach (var type in types) + { + lock (_validatorGate) + { + if (_validators.Any(x => x.Plugin == pluginContext && x.Application == applicationContext && x.Validator.GetType() == type)) + { + continue; + } + } + + var dependencies = new object[] { _componentHub, _httpServerContext, applicationContext, pluginContext }; + var constructor = type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .OrderByDescending(x => x.GetParameters().Length) + .FirstOrDefault(x => x.GetParameters().All(p => dependencies.Any(p.ParameterType.IsInstanceOfType))); + + if (constructor is null) + { + continue; + } + + var validator = (IIdentityValidator)constructor.Invoke(constructor.GetParameters() + .Select(p => dependencies.First(p.ParameterType.IsInstanceOfType)).ToArray()); + + lock (_validatorGate) + { + _validators.Add((pluginContext, applicationContext, validator)); + } + } + } + } + + /// + /// Drops the validators of a removed plugin or application, so none outlives the code it belongs to. + /// + /// The removed plugin, or null. + /// The removed application, or null. + private void RemoveValidators(IPluginContext pluginContext, IApplicationContext applicationContext) + { + lock (_validatorGate) + { + _validators.RemoveAll(x => (pluginContext is not null && x.Plugin == pluginContext) || + (applicationContext is not null && x.Application == applicationContext)); + } + } + + /// + /// Asks every validator of the request's application about a verified identity. + /// + /// The identity the token carries, or null. + /// The verified token the identity was read from. + /// The request. + /// The identity when every validator accepts it; otherwise, null. + private IIdentity Confirm(IIdentity identity, string token, IRequest request) + { + if (identity is null) + { + return null; + } + + IIdentityValidator[] validators; + lock (_validatorGate) + { + validators = [.. _validators.Where(x => x.Application == request.ApplicationContext).Select(x => x.Validator)]; + } + + if (validators.Length == 0) + { + return identity; + } + + // the token was verified before, reading it again only takes the claims apart + var jwt = new JsonWebToken(token); + var context = new IdentityValidationContext + { + Identity = identity, + Request = request, + ApplicationContext = request.ApplicationContext, + Grant = jwt.TryGetPayloadValue("grant", out var grant) ? grant : null, + TokenUse = jwt.TryGetPayloadValue("token_use", out var use) ? use : null, + IssuedAt = new DateTimeOffset(jwt.IssuedAt, TimeSpan.Zero) + }; + + foreach (var validator in validators) + { + try + { + if (!validator.Validate(context)) + { + return null; + } + } + catch (Exception ex) + { + // a validator that cannot answer refuses: letting the identity through would + // turn its outage into access for a disabled account + _httpServerContext?.Log?.Exception(ex); + + return null; + } + } + + return identity; + } + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs index 7bee035f..8001a630 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs @@ -78,7 +78,7 @@ private void Register(IPluginContext pluginContext) return; } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// @@ -107,6 +107,8 @@ private void Register(IPluginContext pluginContext, IEnumerable @@ -353,6 +356,7 @@ private void OnRemovePlugin(object sender, IPluginContext e) private void OnRemoveApplication(object sender, IApplicationContext e) { Remove(e); + RemoveValidators(null, e); } /// diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityProviderManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityProviderManager.cs index e1504c03..c3f78e2f 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityProviderManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityProviderManager.cs @@ -89,7 +89,7 @@ public bool Unregister(IIdentityProvider provider, IApplicationContext applicati /// The plugin whose authentication source bindings are affected. private void OnAddPlugin(object sender, IPluginContext plugin) { - foreach (var application in _hub.ApplicationManager.GetApplications(plugin)) { Discover(plugin, application); } + foreach (var application in _hub.GetBoundApplications(plugin)) { Discover(plugin, application); } } /// diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityTokenStoreManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityTokenStoreManager.cs index 530cacba..fd30f529 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityTokenStoreManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityTokenStoreManager.cs @@ -138,7 +138,7 @@ private IIdentityTokenStore DefaultStore() /// The plugin whose store bindings are affected. private void OnAddPlugin(object sender, IPluginContext plugin) { - foreach (var application in _hub.ApplicationManager.GetApplications(plugin)) { Discover(plugin, application); } + foreach (var application in _hub.GetBoundApplications(plugin)) { Discover(plugin, application); } } /// diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityValidationContext.cs b/src/WebExpress.WebCore/WebIdentity/IdentityValidationContext.cs new file mode 100644 index 00000000..5079e10d --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IdentityValidationContext.cs @@ -0,0 +1,45 @@ +using System; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// What an is asked about: the identity a verified token + /// carries, the login grant the token belongs to and the request it arrived with. + /// + public sealed class IdentityValidationContext + { + /// + /// Gets the identity the token carries. + /// + public IIdentity Identity { get; init; } + + /// + /// Gets the request the token arrived with. + /// + public IRequest Request { get; init; } + + /// + /// Gets the application the request is addressed to. It may differ from the application + /// the token was issued in when both belong to one application group. + /// + public IApplicationContext ApplicationContext { get; init; } + + /// + /// Gets the login grant the token belongs to, which every token of one sign-in shares + /// across renewals; null for a personal access token, which has no grant. + /// + public string Grant { get; init; } + + /// + /// Gets the kind of token: "access" for a browser sign-in, "pat" for a personal access token. + /// + public string TokenUse { get; init; } + + /// + /// Gets the time the token was issued. + /// + public DateTimeOffset IssuedAt { get; init; } + } +} diff --git a/src/WebExpress.WebCore/WebInclude/IncludeManager.cs b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs index 95b930a5..9d21f909 100644 --- a/src/WebExpress.WebCore/WebInclude/IncludeManager.cs +++ b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs @@ -78,7 +78,7 @@ private void Register(IPluginContext pluginContext) return; } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// diff --git a/src/WebExpress.WebCore/WebJob/JobManager.cs b/src/WebExpress.WebCore/WebJob/JobManager.cs index bd1d5033..15618c22 100644 --- a/src/WebExpress.WebCore/WebJob/JobManager.cs +++ b/src/WebExpress.WebCore/WebJob/JobManager.cs @@ -96,7 +96,7 @@ private void Register(IPluginContext pluginContext) return; } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// diff --git a/src/WebExpress.WebCore/WebLog/Log.cs b/src/WebExpress.WebCore/WebLog/Log.cs index 4cb4f7e7..76ff1423 100644 --- a/src/WebExpress.WebCore/WebLog/Log.cs +++ b/src/WebExpress.WebCore/WebLog/Log.cs @@ -281,7 +281,7 @@ private void WriteToConsole(LogLevel level, string text) break; } - Console.WriteLine(text.Length > _separatorWidth ? string.Concat(text.AsSpan(0, _separatorWidth - 3), "...") : text.PadRight(_width, ' ')); + Console.WriteLine(level != LogLevel.Exception && text.Length > _separatorWidth ? string.Concat(text.AsSpan(0, _separatorWidth - 3), "...") : text.PadRight(_width, ' ')); Console.ResetColor(); } } @@ -534,9 +534,9 @@ public void FatalError(string message, [CallerMemberName] string instance = null } /// - /// Logs an exception message. + /// Preserves exception types, nested causes and stack traces for failure diagnosis. /// - /// The exception + /// The exception whose complete diagnostic details are required. /// >Method/ function that wants to log. /// The line number. /// The source file. @@ -547,10 +547,7 @@ public void Exception(Exception exception, [CallerMemberName] string instance = var methodInfo = new StackTrace().GetFrame(1)?.GetMethod(); var className = methodInfo?.ReflectedType.Name; - Add(LogLevel.Exception, exception?.Message.Trim(), $"{className}.{instance}", line, file); - Add(LogLevel.Exception, exception?.StackTrace is not null - ? exception?.StackTrace.Trim() - : exception?.Message.Trim(), $"{className}.{instance}", line, file); + Add(LogLevel.Exception, exception?.ToString(), $"{className}.{instance}", line, file); } catch (Exception ex) { diff --git a/src/WebExpress.WebCore/WebMessage/HttpContext.cs b/src/WebExpress.WebCore/WebMessage/HttpContext.cs index f6026872..f8c9154d 100644 --- a/src/WebExpress.WebCore/WebMessage/HttpContext.cs +++ b/src/WebExpress.WebCore/WebMessage/HttpContext.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using System; using System.Net; using System.Text; @@ -67,7 +68,8 @@ public HttpContext(IFeatureCollection contextFeatures, IHttpServerContext httpSe var connectionFeature = contextFeatures.Get(); var requestFeature = contextFeatures.Get(); var header = new RequestHeaderFields(contextFeatures); - var baseUri = new UriBuilder(requestFeature.Scheme, HostNameOf(header.Host), connectionFeature.LocalPort).Uri; + var requestHost = new HostString(header.Host); + var baseUri = new UriBuilder(requestFeature.Scheme, HostNameOf(header.Host), requestHost.Port ?? -1).Uri; Features = contextFeatures; Id = connectionFeature.ConnectionId; diff --git a/src/WebExpress.WebCore/WebMessage/RequestBase.cs b/src/WebExpress.WebCore/WebMessage/RequestBase.cs index 80d40296..76cbaefd 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestBase.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestBase.cs @@ -171,7 +171,8 @@ internal RequestBase(IFeatureCollection contextFeatures, RequestHeaderFields hea new UriAuthority() { Host = requestHost.Host, - Port = requestHost.Port ?? connectionFeature.LocalPort + // an omitted host port denotes the scheme default, never the internal listener port + Port = requestHost.Port }, requestFeature.RawTarget ); diff --git a/src/WebExpress.WebCore/WebMessage/RequestOriginGuard.cs b/src/WebExpress.WebCore/WebMessage/RequestOriginGuard.cs index ca79d495..77fa8ab4 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestOriginGuard.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestOriginGuard.cs @@ -85,9 +85,9 @@ public bool IsAllowed(IRequest request, bool webSocket = false) } /// - /// Determines the origin the browser addressed. It is taken from the host header rather - /// than the request uri, which carries the local listening port and would differ from - /// the browser's view whenever a container or proxy maps the port. + /// Determines the origin the browser addressed from the host header and request scheme. + /// The transport endpoint is deliberately excluded because container port mappings + /// can expose a different public port. /// /// The incoming request. /// The origin, or null when it cannot be determined. diff --git a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs index 6091ea93..1eb2c5b9 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Xml; using System.Xml.Serialization; using WebExpress.WebCore.WebPackage.Model; @@ -12,6 +14,14 @@ namespace WebExpress.WebCore.WebPackage /// /// Class for generating webexpress packages from a build environment. /// + /// + /// Every file named by the spec is resolved before the archive is written, so a spec that + /// names a missing file fails the build instead of producing a package that breaks only + /// when it is installed. Files and plugin outputs are looked up below the folder of the spec + /// file and never outside of it, and build output only counts when it belongs to the requested + /// configuration and target framework: a stale bin/Debug must not end up in a Release + /// package, nor a net8.0 build in a net10.0 one. + /// public static class PackageBuilder { /// @@ -19,6 +29,12 @@ public static class PackageBuilder /// public const string SettingsDirectory = "settings"; + /// + /// Recognizes the output folder of a target framework moniker, e.g. net10.0, net8.0-windows, + /// net472, netstandard2.0 or netcoreapp3.1, as opposed to an ordinary subfolder of the output. + /// + private static readonly Regex TargetFrameworkFolder = new(@"^net(standard|coreapp)?\d+(\.\d+)*(-.+)?$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + /// /// Creates a webex package. /// @@ -26,6 +42,13 @@ public static class PackageBuilder /// The config. Debug or Release. /// The target frameworks. Semicolon separated list of target framework moniker (TFM). /// The output directory. + /// + /// The spec file, or a file or plugin output named by it, does not exist or the plugin output + /// is empty. No archive is written. + /// + /// + /// A plugin path of the spec leads outside of the spec folder. No archive is written. + /// public static void Create(string specFile, string config, string targets, string outputDirectory) { Console.WriteLine($"*** PackageBuilder: specFile '{specFile}'."); @@ -44,27 +67,45 @@ public static void Create(string specFile, string config, string targets, string throw new FileNotFoundException("The specified spec file does not exist.", specFile); } - var rootDirectory = Path.GetDirectoryName(specFile); - - // secure XML deserialization by prohibiting DTD processing - using var fileStream = File.OpenRead(specFile); - var serializer = new XmlSerializer(typeof(PackageItemSpec)); - using var xmlReader = XmlReader.Create(fileStream, new XmlReaderSettings + if (string.IsNullOrWhiteSpace(outputDirectory)) { - DtdProcessing = DtdProcessing.Prohibit, - XmlResolver = null - }); + throw new ArgumentException("outputDirectory must not be null or empty.", nameof(outputDirectory)); + } - var package = (PackageItemSpec)serializer.Deserialize(xmlReader) ?? - throw new InvalidOperationException("Failed to deserialize the spec file."); + var rootDirectory = Path.GetDirectoryName(Path.GetFullPath(specFile)); + var package = ReadSpec(specFile); var isCorePackage = string.Equals(package.Id, "WebExpress", StringComparison.Ordinal); var zipFileType = isCorePackage ? "zip" : "wxp"; + var zipBinarys = isCorePackage ? "bin" : "lib"; + var targetList = targets?.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; Console.WriteLine($"*** PackageBuilder: Creates a webex package '{package.Id}' in directory '{outputDirectory}'."); - if (string.IsNullOrWhiteSpace(outputDirectory)) + var files = ListFiles(rootDirectory); + var missing = new List(); + + var readme = Resolve(files, rootDirectory, package.Readme, "readme", config, targetList, missing); + var privacyPolicy = Resolve(files, rootDirectory, package.PrivacyPolicy, "privacypolicy", config, targetList, missing); + var icon = Resolve(files, rootDirectory, package.Icon, "icon", config, targetList, missing); + var licenses = FindLicenses(files, rootDirectory); + var artifacts = (package.Artifacts ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => (Item: x, File: Resolve(files, rootDirectory, x, "artifact", config, targetList, missing))) + .ToList(); + var settings = (package.Settings ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => (Item: x, File: Resolve(files, rootDirectory, x, "settings", config, targetList, missing))) + .ToList(); + var pluginOutputs = ResolvePluginOutputs(package, rootDirectory, config, targetList, missing); + + if (missing.Count > 0) { - throw new ArgumentException("outputDirectory must not be null or empty.", nameof(outputDirectory)); + foreach (var item in missing) + { + Console.WriteLine($"*** PackageBuilder: Not found: {item}."); + } + + throw new FileNotFoundException($"The package '{package.Id}' was not created, because the spec names {missing.Count} file(s) that were not found: {string.Join("; ", missing)}."); } if (!Directory.Exists(outputDirectory)) @@ -77,318 +118,342 @@ public static void Create(string specFile, string config, string targets, string var safeVersion = SanitizeFileNameComponent(package.Version); var archiveFilePath = Path.Combine(outputDirectory, $"{safeId}.{safeVersion}.{zipFileType}"); - using var zipFileStream = new FileStream(archiveFilePath, FileMode.Create, FileAccess.Write, FileShare.None); - using var archive = new ZipArchive(zipFileStream, ZipArchiveMode.Create, true); - - // find readme - if (!string.IsNullOrWhiteSpace(package.Readme)) + try { - var file = Find(rootDirectory, Path.GetFileName(package.Readme)); - if (!string.IsNullOrWhiteSpace(file) && File.Exists(file)) + using var zipFileStream = new FileStream(archiveFilePath, FileMode.Create, FileAccess.Write, FileShare.None); + using var archive = new ZipArchive(zipFileStream, ZipArchiveMode.Create, true); + + if (readme is not null) { - ReadmeToZip(archive, file); + AddFileToZip(archive, "readme.md", readme); + Console.WriteLine($"*** PackageBuilder: Create the readme file '{readme}'."); } - } - // privacy policy - if (!string.IsNullOrWhiteSpace(package.PrivacyPolicy)) - { - var file = Find(rootDirectory, Path.GetFileName(package.PrivacyPolicy)); - if (!string.IsNullOrWhiteSpace(file) && File.Exists(file)) + if (privacyPolicy is not null) { - PrivacyPolicyToZip(archive, file); + AddFileToZip(archive, "privacypolicy.md", privacyPolicy); + Console.WriteLine($"*** PackageBuilder: Create the privacy policy file '{privacyPolicy}'."); } - } - // find icon - if (!string.IsNullOrWhiteSpace(package.Icon)) - { - var file = Find(rootDirectory, Path.GetFileName(package.Icon)); - if (!string.IsNullOrWhiteSpace(file) && File.Exists(file)) + if (icon is not null) { - IconToZip(archive, file); + AddFileToZip(archive, $"icon{Path.GetExtension(icon)}", icon); + Console.WriteLine($"*** PackageBuilder: Create the icon file '{icon}'."); } - } - // find licenses - if (!string.IsNullOrWhiteSpace(rootDirectory) && Directory.Exists(rootDirectory)) - { - try + foreach (var license in licenses) { - foreach (var licFilePath in Directory.GetFiles(rootDirectory, "*.lic", SearchOption.AllDirectories) - .Where(x => !string.IsNullOrWhiteSpace(x) && File.Exists(x))) + AddFileToZip(archive, $"licenses/{Path.GetFileNameWithoutExtension(license)}.txt", license); + Console.WriteLine($"*** PackageBuilder: Create the licenses file '{license}'."); + } + + if (!isCorePackage) + { + SpecToZip(archive, package, zipBinarys); + } + + foreach (var (plugin, target, directory) in pluginOutputs) + { + foreach (var fileName in Directory.GetFiles(directory, "*", SearchOption.AllDirectories)) { - LicensesToZip(archive, licFilePath); + var relativePath = Path.GetRelativePath(directory, fileName); + AddFileToZip(archive, $"{zipBinarys}/{plugin}/{target}/{relativePath}", fileName); + Console.WriteLine($"*** PackageBuilder: Copy the output file '{relativePath}' to {plugin}."); } } - catch (Exception) + + foreach (var (item, fileName) in artifacts) { - // ignore errors while enumerating license files + // preserve relative subpaths in artifacts while ensuring safe zip paths + AddFileToZip(archive, $"{zipBinarys}/{item}", fileName); + Console.WriteLine($"*** PackageBuilder: Create the artifact file '{fileName}'."); } - } - if (!isCorePackage) + foreach (var (_, fileName) in settings) + { + // kept apart from the libraries, so the package manager can deploy them to the + // settings directory of the server without extracting them next to the code + AddFileToZip(archive, $"{SettingsDirectory}/{Path.GetFileName(fileName)}", fileName); + Console.WriteLine($"*** PackageBuilder: Create the settings file '{fileName}'."); + } + } + catch { - SpecToZip(archive, package); + // a half written archive looks like a valid package to anyone who copies it on + TryDelete(archiveFilePath); + throw; } - - ProjectToZip(archive, package, rootDirectory, config, targets); - ArtifactsToZip(archive, package, rootDirectory); - SettingsToZip(archive, package, rootDirectory); } /// - /// Create the readme file. + /// Reads the spec file. /// - /// The zip archive. - /// The readme file path. - private static void ReadmeToZip(ZipArchive archive, string filePath) + /// The spec file (*.spec). + /// The package described by the spec file. + private static PackageItemSpec ReadSpec(string specFile) { - // always use fixed entry name and stream file contents - AddFileToZip(archive, "readme.md", filePath); - Console.WriteLine($"*** PackageBuilder: Create the readme file."); - } + // secure XML deserialization by prohibiting DTD processing + using var fileStream = File.OpenRead(specFile); + var serializer = new XmlSerializer(typeof(PackageItemSpec)); + using var xmlReader = XmlReader.Create(fileStream, new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }); - /// - /// Create the privacy policy file. - /// - /// The zip archive. - /// The privacy policy file path. - private static void PrivacyPolicyToZip(ZipArchive archive, string filePath) - { - // always use fixed entry name and stream file contents - AddFileToZip(archive, "privacypolicy.md", filePath); - Console.WriteLine($"*** PackageBuilder: Create the privacy policy file."); + return (PackageItemSpec)serializer.Deserialize(xmlReader) ?? + throw new InvalidOperationException("Failed to deserialize the spec file."); } /// - /// Create the icon file. + /// Lists every file below the root directory once, so each lookup of the spec does not + /// walk the tree again. /// - /// The zip archive. - /// The icon file path. - private static void IconToZip(ZipArchive archive, string filePath) + /// The folder of the spec file. + /// The paths relative to the root, with forward slashes. + private static List ListFiles(string rootDirectory) { - // build entry name with original extension - var ext = Path.GetExtension(filePath); - var entryName = $"icon{ext}"; - AddFileToZip(archive, entryName, filePath); - Console.WriteLine($"*** PackageBuilder: Create the icon file."); - } + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint + }; - /// - /// Create the licenses file. - /// - /// The zip archive. - /// The licenses file path. - private static void LicensesToZip(ZipArchive archive, string filePath) - { - // convert license file to a normalized .txt entry name while keeping contents as-is - var name = Path.GetFileNameWithoutExtension(filePath); - var entryName = $"licenses/{name}.txt"; - AddFileToZip(archive, entryName, filePath); - Console.WriteLine($"*** PackageBuilder: Create the licenses file."); + return [.. Directory + .EnumerateFiles(rootDirectory, "*", options) + .Select(x => Path.GetRelativePath(rootDirectory, x).Replace('\\', '/'))]; } /// - /// Create the spec file. + /// Resolves a file named by the spec and records it as missing when it cannot be found. /// - /// The zip archive. - /// The package. - private static void SpecToZip(ZipArchive archive, PackageItemSpec package) + /// The files below the root directory. + /// The folder of the spec file. + /// The file name or trailing path from the spec; empty when the spec omits it. + /// The spec element, used in the error message. + /// The build configuration. + /// The target frameworks. + /// Collects the items that were not found. + /// The full path of the file, or null if the spec omits it or it was not found. + private static string Resolve(IReadOnlyList files, string rootDirectory, string item, string kind, string config, IReadOnlyCollection targets, List missing) { - var zipBinarys = string.Equals(package?.Id, "WebExpress", StringComparison.Ordinal) ? "bin" : "lib"; - var specEntryName = $"{SanitizeFileNameComponent(package?.Id)}.spec"; - var sanitizedEntryName = SanitizeEntryPath(specEntryName); - - var zipArchiveEntry = archive.CreateEntry(sanitizedEntryName, CompressionLevel.Fastest); - var serializer = new XmlSerializer(typeof(PackageItemSpec)); - using var zipStream = zipArchiveEntry.Open(); + if (string.IsNullOrWhiteSpace(item)) + { + return null; + } - // safely derive icon name if present - var iconName = !string.IsNullOrWhiteSpace(package?.Icon) ? $"icon{Path.GetExtension(package.Icon)}" : null; + var file = Find(files, rootDirectory, item, config, targets); - var newPackage = new PackageItemSpec() + if (file is null) { - Id = package?.Id, - Version = package?.Version, - Title = package?.Title, - Authors = package?.Authors, - License = package?.License, - LicenseUrl = package?.LicenseUrl, - Icon = iconName, - Readme = "readme.md", - Description = package?.Description, - Tags = package?.Tags, - Plugins = package?.Plugins?.Select(x => $"{zipBinarys}/{SanitizeFileNameComponent(Path.GetFileName(x))}").ToArray(), - Dependencies = package?.Dependencies, - Settings = package?.Settings?.Select(x => $"{SettingsDirectory}/{SanitizeFileNameComponent(Path.GetFileName(x))}").ToArray() - }; + missing.Add($"<{kind}>{item}"); + } - serializer.Serialize(zipStream, newPackage); + return file; + } - Console.WriteLine($"*** PackageBuilder: Create the spec file."); + /// + /// Finds a file by its name or trailing path below the root directory. + /// + /// + /// The same file name usually exists several times in a build tree - in every + /// configuration's output, in obj, and as the source item that was copied to the output. + /// The candidates are ranked: output of the requested configuration and target first, + /// then output of the requested configuration, then files outside of any build output; + /// output of another configuration or target framework and anything in obj never qualifies. Within a rank, + /// the shallowest path wins, so the readme in the root beats one in a subfolder. + /// + /// The files below the root directory. + /// The folder of the spec file. + /// The file name or trailing path to match. + /// The build configuration. + /// The target frameworks. + /// The full path of the file, if found; otherwise null. + private static string Find(IReadOnlyList files, string rootDirectory, string fileName, string config, IReadOnlyCollection targets) + { + // matching on a segment boundary keeps "WebExpress.dll" from hitting "MyWebExpress.dll" + var tail = "/" + fileName.Replace('\\', '/').TrimStart('/'); + + var match = files + .Where(x => ("/" + x).EndsWith(tail, StringComparison.OrdinalIgnoreCase)) + .Select(x => (Path: x, Rank: Rank(x, config, targets))) + .Where(x => x.Rank >= 0) + .OrderBy(x => x.Rank) + .ThenBy(x => x.Path.Count(c => c == '/')) + .ThenBy(x => x.Path, StringComparer.Ordinal) + .Select(x => x.Path) + .FirstOrDefault(); + + return match is null ? null : Path.GetFullPath(Path.Combine(rootDirectory, match)); } /// - /// Copy the plugin lib files to zip. + /// Ranks a candidate file by where it sits in the build tree. /// - /// The zip archive. - /// The package. - /// The root path. - /// The config. Debug or Release. - /// The target frameworks. Semicolon separated list of target framework moniker (TFM). - private static void ProjectToZip(ZipArchive archive, PackageItemSpec package, string path, string config, string targets) + /// The path relative to the root directory, with forward slashes. + /// The build configuration. + /// The target frameworks. + /// 0 to 2, lower is better; -1 if the file must not be packed. + private static int Rank(string relativePath, string config, IReadOnlyCollection targets) { - var zipBinarys = string.Equals(package?.Id, "WebExpress", StringComparison.Ordinal) ? "bin" : "lib"; + var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + var directories = segments.Length - 1; - foreach (var plugin in package?.Plugins ?? Enumerable.Empty()) + for (var i = 0; i < directories; i++) { - var pluginName = Path.GetFileName(plugin); - var safePluginName = SanitizeFileNameComponent(pluginName); - - foreach (var target in targets?.Split(';', StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty()) + if (segments[i].Equals("obj", StringComparison.OrdinalIgnoreCase)) { - var safeTarget = SanitizeFileNameComponent(target); - var dir = Path.Combine(path ?? string.Empty, plugin, "bin", config ?? string.Empty, target); - - if (!Directory.Exists(dir)) - { - // skip missing output directories - continue; - } + return -1; + } - string[] files = []; - try - { - files = Directory.GetFiles(dir, "*.*", SearchOption.AllDirectories); - } - catch (Exception) + if (segments[i].Equals("bin", StringComparison.OrdinalIgnoreCase)) + { + if (i + 1 >= directories || !segments[i + 1].Equals(config, StringComparison.OrdinalIgnoreCase)) { - // ignore errors while enumerating plugin output files - continue; + return -1; } - foreach (var fileName in files.Where(x => !string.IsNullOrWhiteSpace(x) && File.Exists(x))) + if (i + 2 < directories) { - // compute relative path robustly - string relativePath; - try + if (targets.Contains(segments[i + 2], StringComparer.OrdinalIgnoreCase)) { - relativePath = Path.GetRelativePath(dir, fileName); + return 0; } - catch + + // a dll built for another framework may not even load on the requested one + if (TargetFrameworkFolder.IsMatch(segments[i + 2])) { - // fallback to file name if relative path fails - relativePath = Path.GetFileName(fileName); + return -1; } - - var entryPathRaw = $"{zipBinarys}/{safePluginName}/{safeTarget}/{relativePath}"; - var entryPath = SanitizeEntryPath(entryPathRaw); - - AddFileToZip(archive, entryPath, fileName); - - Console.WriteLine($"*** PackageBuilder: Copy the output file '{relativePath}' to {safePluginName}."); } + + return 1; } } + + return 2; } /// - /// Create the artifact files. + /// Checks whether a path lies inside the root directory. /// - /// The zip archive. - /// The package. - /// The root path. - private static void ArtifactsToZip(ZipArchive archive, PackageItemSpec package, string path) + /// The path to check. + /// The folder of the spec file. + /// True if the path is the root directory or below it. + private static bool IsWithin(string path, string rootDirectory) { - var zipBinarys = string.Equals(package?.Id, "WebExpress", StringComparison.Ordinal) ? "bin" : "lib"; - - foreach (var item in package?.Artifacts ?? Enumerable.Empty()) - { - var fileName = Find(path, item); + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootDirectory)) + Path.DirectorySeparatorChar; + var full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)) + Path.DirectorySeparatorChar; - if (!string.IsNullOrWhiteSpace(fileName) && File.Exists(fileName)) - { - // preserve relative subpaths in artifacts while ensuring safe zip paths - var entryPath = SanitizeEntryPath($"{zipBinarys}/{item}"); - AddFileToZip(archive, entryPath, fileName); + return full.StartsWith(root, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } - Console.WriteLine($"*** PackageBuilder: Create the artifact file '{fileName}'."); - } - } + /// + /// Finds the license files (*.lic) of the package. + /// + /// The files below the root directory. + /// The folder of the spec file. + /// The full paths, at most one per license name. + private static List FindLicenses(IReadOnlyList files, string rootDirectory) + { + // build output may carry copies; they would add a second entry of the same name + return [.. files + .Where(x => x.EndsWith(".lic", StringComparison.OrdinalIgnoreCase)) + .Where(x => Rank(x, null, []) == 2) + .OrderBy(x => x, StringComparer.Ordinal) + .GroupBy(x => Path.GetFileNameWithoutExtension(x), StringComparer.OrdinalIgnoreCase) + .Select(x => Path.GetFullPath(Path.Combine(rootDirectory, x.First())))]; } /// - /// Create the settings files. They are kept apart from the libraries under their own - /// directory, so the package manager can deploy them to the settings directory of the - /// server without extracting them next to the code. + /// Resolves the build output directories of the plugins named by the spec. /// - /// The zip archive. /// The package. - /// The root path. - private static void SettingsToZip(ZipArchive archive, PackageItemSpec package, string path) + /// The folder of the spec file. + /// The build configuration. + /// The target frameworks. + /// Collects the plugin outputs that were not found or are empty. + /// One entry per plugin and target, with the sanitized names used inside the package. + /// A plugin path leads outside of the spec folder. + private static List<(string Plugin, string Target, string Directory)> ResolvePluginOutputs(PackageItemSpec package, string rootDirectory, string config, IReadOnlyCollection targets, List missing) { - foreach (var item in package?.Settings ?? Enumerable.Empty()) + var outputs = new List<(string, string, string)>(); + + foreach (var plugin in (package.Plugins ?? []).Where(x => !string.IsNullOrWhiteSpace(x))) { - var fileName = Find(path, item); + // an absolute path or "../" would pull the build output of a foreign project into the package + var pluginDirectory = Path.GetFullPath(Path.Combine(rootDirectory, plugin)); - if (!string.IsNullOrWhiteSpace(fileName) && File.Exists(fileName)) + if (!IsWithin(pluginDirectory, rootDirectory)) { - var entryPath = SanitizeEntryPath($"{SettingsDirectory}/{Path.GetFileName(fileName)}"); - AddFileToZip(archive, entryPath, fileName); + throw new InvalidOperationException($"{plugin} points to '{pluginDirectory}', outside of the spec folder '{rootDirectory}'."); + } - Console.WriteLine($"*** PackageBuilder: Create the settings file '{fileName}'."); + // the same name the spec inside the package gets (see SpecToZip) + var pluginName = SanitizeFileNameComponent(Path.GetFileName(plugin.TrimEnd('/', '\\'))); + + foreach (var target in targets) + { + var directory = Path.Combine(pluginDirectory, "bin", config ?? string.Empty, target); + + if (!Directory.Exists(directory)) + { + missing.Add($"{plugin} (no build output in '{directory}')"); + } + else if (!Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories).Any()) + { + // the spec of the package would name the plugin while the package carries none of its files + missing.Add($"{plugin} (build output in '{directory}' is empty)"); + } + else + { + outputs.Add((pluginName, SanitizeFileNameComponent(target), directory)); + } } } + + return outputs; } /// - /// Find a file by walking up the directory tree and searching recursively at each level. + /// Create the spec file. /// - /// The starting path. - /// The file name or trailing path to match. - /// The file name, if found; otherwise null. - private static string Find(string path, string fileName) + /// The zip archive. + /// The package. + /// The directory of the binaries inside the package. + private static void SpecToZip(ZipArchive archive, PackageItemSpec package, string zipBinarys) { - // validate input - if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(fileName)) - { - return null; - } + var specEntryName = $"{SanitizeFileNameComponent(package?.Id)}.spec"; + var sanitizedEntryName = SanitizeEntryPath(specEntryName); - var normalizedTail = fileName.Replace('\\', '/'); + var zipArchiveEntry = archive.CreateEntry(sanitizedEntryName, CompressionLevel.Fastest); + var serializer = new XmlSerializer(typeof(PackageItemSpec)); + using var zipStream = zipArchiveEntry.Open(); - // walk upwards until root - while (!string.IsNullOrEmpty(path)) - { - try - { - var matches = Directory - .GetFiles(path, "*.*", SearchOption.AllDirectories) - .Select(x => x.Replace('\\', '/')) - .Where(x => x.EndsWith(normalizedTail, StringComparison.OrdinalIgnoreCase)); + // safely derive icon name if present + var iconName = !string.IsNullOrWhiteSpace(package?.Icon) ? $"icon{Path.GetExtension(package.Icon)}" : null; - foreach (var f in matches) - { - return f; - } - } - catch (Exception) - { - // ignore errors while enumerating and continue with parent - } + var newPackage = new PackageItemSpec() + { + Id = package?.Id, + Version = package?.Version, + Title = package?.Title, + Authors = package?.Authors, + License = package?.License, + LicenseUrl = package?.LicenseUrl, + Icon = iconName, + Readme = "readme.md", + Description = package?.Description, + Tags = package?.Tags, + Plugins = package?.Plugins?.Select(x => $"{zipBinarys}/{SanitizeFileNameComponent(Path.GetFileName(x))}").ToArray(), + Dependencies = package?.Dependencies, + Settings = package?.Settings?.Select(x => $"{SettingsDirectory}/{SanitizeFileNameComponent(Path.GetFileName(x))}").ToArray() + }; - try - { - path = Directory.GetParent(path)?.FullName; - } - catch - { - path = null; - } - } + serializer.Serialize(zipStream, newPackage); - return null; + Console.WriteLine($"*** PackageBuilder: Create the spec file."); } /// @@ -408,6 +473,25 @@ private static void AddFileToZip(ZipArchive archive, string entryPath, string so fs.CopyTo(zipStream); } + /// + /// Deletes a file and ignores the failure, because it runs while another error is + /// already on its way to the caller. + /// + /// The file to delete. + private static void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + /// /// Sanitizes a filename component by removing invalid characters and path separators. /// diff --git a/src/WebExpress.WebCore/WebPage/PageManager.cs b/src/WebExpress.WebCore/WebPage/PageManager.cs index 53e58ce3..9958c2c8 100644 --- a/src/WebExpress.WebCore/WebPage/PageManager.cs +++ b/src/WebExpress.WebCore/WebPage/PageManager.cs @@ -286,7 +286,7 @@ private void Register(IPluginContext pluginContext) return; } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// @@ -449,12 +449,7 @@ var attribute in pageType // assign the page to existing applications foreach (var applicationContext in applicationContexts) { - var prefix = applicationContext.Route.Concat - ( - applicationContext.PluginContext != pluginContext - ? pluginContext.PluginName.ToLower() - : "" - ); + var prefix = EndpointManager.CreatePluginRoute(applicationContext, pluginContext); var routePath = EndpointManager.CreateEndpointRoute(pageType, prefix, segment); var pageContext = new PageContext() { diff --git a/src/WebExpress.WebCore/WebPlugin/PluginBinding.cs b/src/WebExpress.WebCore/WebPlugin/PluginBinding.cs new file mode 100644 index 00000000..a4c4c970 --- /dev/null +++ b/src/WebExpress.WebCore/WebPlugin/PluginBinding.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; + +namespace WebExpress.WebCore.WebPlugin +{ + /// + /// Decides which applications a plugin's endpoints are bound to when the plugin arrives. + /// A plugin serves the applications defined in its own assembly and those it names through + /// an application attribute, so a plugin can extend an application shipped by another plugin. + /// + internal static class PluginBinding + { + /// + /// Returns the applications a newly added plugin must be bound to. Applications that are + /// registered after the plugin are bound later through the application event; the managers' + /// containment checks keep that second path from binding a pair twice. + /// + /// The component hub that knows the applications and plugins. + /// The plugin whose endpoints are bound. + /// Every application of the plugin, each one once. + public static IEnumerable GetBoundApplications(this IComponentHub componentHub, IPluginContext pluginContext) + { + var own = componentHub?.ApplicationManager?.GetApplications(pluginContext) ?? []; + var associated = componentHub?.PluginManager?.GetAssociatedApplications(pluginContext) ?? []; + + return [.. own.Concat(associated).Where(x => x is not null).Distinct()]; + } + } +} diff --git a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs index b2114759..bf23dae3 100644 --- a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs @@ -330,7 +330,7 @@ internal IEnumerable Register(Assembly assembly, PluginLoadConte var pluginContext = new PluginContext() { - Assembly = type.Assembly, + Assembly = assembly, PluginId = id, PluginName = name, Manufacturer = type.Assembly.GetCustomAttribute()?.Company, diff --git a/src/WebExpress.WebCore/WebPlugin/PluginTypeBinding.cs b/src/WebExpress.WebCore/WebPlugin/PluginTypeBinding.cs new file mode 100644 index 00000000..5f84227d --- /dev/null +++ b/src/WebExpress.WebCore/WebPlugin/PluginTypeBinding.cs @@ -0,0 +1,21 @@ +namespace WebExpress.WebCore.WebPlugin +{ + /// + /// Says what an item of a belongs to, because the + /// framework's own extension points are scoped to an application while most extension + /// points of an application are not, and the second kind must not pay for the first. + /// + public enum PluginTypeBinding + { + /// + /// One item per type and plugin, shared by the whole host. A key names one item. + /// + Plugin, + + /// + /// One item per type, plugin and application the plugin is associated with. A key names + /// one item per application, and an application that leaves takes its items along. + /// + Application + } +} diff --git a/src/WebExpress.WebCore/WebPlugin/PluginTypeCatalog.cs b/src/WebExpress.WebCore/WebPlugin/PluginTypeCatalog.cs new file mode 100644 index 00000000..95f4987d --- /dev/null +++ b/src/WebExpress.WebCore/WebPlugin/PluginTypeCatalog.cs @@ -0,0 +1,1039 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; +using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; + +namespace WebExpress.WebCore.WebPlugin +{ + /// + /// Collects the types a plugin contributes to an extension point, so a manager - of the + /// framework or of an application - does not have to repeat the scan of the plugin + /// assemblies, the plugin and application events and the bookkeeping of who provides what. + /// + /// + /// + /// A type is an item when it is a class, public at top level (a nested type is not, because + /// is false for it), sealed, not abstract, not an open generic and + /// assignable to the contract - and, without a factory, has a public parameterless + /// constructor. A type that misses any of these is skipped silently, the way the + /// framework skips a page or a fragment that does not follow its convention; a plugin should + /// therefore test that every item it ships is found. + /// + /// + /// A catalog is meant to be held as a field of a manager, never inherited from: the manager + /// attaches it in its constructor, disposes it in its own Dispose and exposes what its + /// interface promises. That leaves a manager free to choose its base class and to hold several + /// catalogs. + /// + /// + /// Every plugin's offer is kept, in the order the plugins arrived, and the item in use under a + /// key is the latest offer of it. A plugin that leaves takes only what it offered: a key it + /// still provides falls back to the latest remaining offer of another plugin, and is removed + /// only when nobody offers it any more; a key another plugin took over is left alone. An item + /// given to is an offer of nobody and no plugin removal takes + /// it away. + /// + /// + /// The catalog is thread-safe. Types are scanned and items constructed outside its lock, and + /// and are raised after it was released, so a + /// constructor or a handler may call back into the catalog. Items the catalog constructed are + /// disposed when they leave it, if they are disposable; registered items belong to their caller. + /// + /// + /// The type of the items. + public sealed class PluginTypeCatalog : IDisposable + where TItem : class + { + private readonly Func _keyOf; + private readonly Type _contract; + private readonly Func _factory; + private readonly Lock _sync = new(); + private readonly List _offers = []; + private readonly Dictionary _inUse = new(SlotComparer.Instance); + private IComponentHub _componentHub; + private IHttpServerContext _httpServerContext; + private long _version; + private bool _disposed; + + /// + /// Occurs when an item becomes the one in use under its key - offered first, taking over + /// from another offer, or falling back after the offer above it left. + /// + public event EventHandler> Added; + + /// + /// Occurs when an item stops being the one in use under its key - its plugin or + /// application left, another offer took over, or the catalog was disposed. + /// + public event EventHandler> Removed; + + /// + /// Gets what the items belong to. + /// + public PluginTypeBinding Binding { get; } + + /// + /// Gets a number that moves whenever an item comes, goes or is replaced, so a cached + /// projection of the items - a picker, a menu - can tell it is stale without comparing. + /// + public long Version => Interlocked.Read(ref _version); + + /// + /// Gets the items in use, in the order their offers arrived. In a catalog bound per + /// application these are the items of every application. + /// + public IReadOnlyList Items + { + get + { + lock (_sync) + { + return Snapshot(x => true); + } + } + } + + /// + /// Creates a catalog of the public sealed implementations of , + /// built through their parameterless constructor and shared by the whole host - the shape + /// of most extension points an application defines. + /// + /// Answers the key of an item. Keys are trimmed and compared without + /// case; an item without one is logged and skipped. + public PluginTypeCatalog(Func keyOf) + : this(keyOf, typeof(TItem), null, PluginTypeBinding.Plugin) + { + } + + /// + /// Creates a catalog whose items are built from the discovered types by a factory - for a + /// type that needs its plugin or application to be constructed, or an item that wraps the + /// discovered type rather than being it. + /// + /// Answers the key of an item. Keys are trimmed and compared without + /// case; an item without one is logged and skipped. + /// The type a discovered type must be assignable to. + /// Builds the item of a discovered type; uses the + /// public parameterless constructor, which then also becomes part of the convention, and + /// requires the contract to be an item type. A factory answering + /// declines the type. + /// What the items belong to. + /// The key function or the contract is missing. + /// Without a factory, the contract is no item type. + public PluginTypeCatalog(Func keyOf, Type contract, Func factory, PluginTypeBinding binding = PluginTypeBinding.Plugin) + { + ArgumentNullException.ThrowIfNull(keyOf); + ArgumentNullException.ThrowIfNull(contract); + + if (factory is null && !typeof(TItem).IsAssignableFrom(contract)) + { + throw new ArgumentException($"Without a factory, the contract must be assignable to {typeof(TItem).FullName}.", nameof(contract)); + } + + _keyOf = keyOf; + _contract = contract; + _factory = factory; + Binding = binding; + } + + /// + /// Follows the plugins of a host: collects the plugins already loaded, because a manager is + /// built before the plugins arrive and each pass alone misses half of them, and subscribes + /// to their arrival and removal - and, in a catalog bound per application, to those of the + /// applications. + /// + /// The hub whose plugins are followed. + /// The host context whose log receives the skipped types. May be null. + /// The catalog already follows a hub. + public void Attach(IComponentHub componentHub, IHttpServerContext httpServerContext) + { + ArgumentNullException.ThrowIfNull(componentHub); + + lock (_sync) + { + if (_disposed) + { + return; + } + + if (_componentHub is not null) + { + throw new InvalidOperationException("The catalog already follows a component hub."); + } + + _componentHub = componentHub; + _httpServerContext = httpServerContext; + } + + var plugins = componentHub.PluginManager; + + if (plugins is null) + { + return; + } + + // subscribe before the sweep, so a plugin arriving in between is not missed; collecting + // a plugin twice does nothing + plugins.AddPlugin += OnAddPlugin; + plugins.RemovePlugin += OnRemovePlugin; + + if (Binding == PluginTypeBinding.Application && componentHub.ApplicationManager is { } applications) + { + applications.AddApplication += OnAddApplication; + applications.RemoveApplication += OnRemoveApplication; + } + + foreach (var plugin in plugins.Plugins?.ToArray() ?? []) + { + Collect(plugin); + } + } + + /// + /// Collects the items of an assembly that is no plugin - the way a unit test, which has no + /// plugin manager, gets the items a manager would find in a host. Such items belong to no + /// plugin and stay until the catalog is disposed. + /// + /// The assembly. + /// The catalog is bound per application. + public void Collect(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + Demand(PluginTypeBinding.Plugin); + + CollectOffer(null, null, assembly); + } + + /// + /// Collects the items of a plugin. A catalog bound per application collects them for the + /// applications the attached hub associates with the plugin. Collecting a plugin again does + /// nothing. + /// + /// The plugin. + public void Collect(IPluginContext pluginContext) + { + ArgumentNullException.ThrowIfNull(pluginContext); + + if (Binding == PluginTypeBinding.Plugin) + { + CollectOffer(pluginContext, null, pluginContext.Assembly); + + return; + } + + IComponentHub componentHub; + + lock (_sync) + { + componentHub = _componentHub; + } + + Collect(pluginContext, componentHub?.PluginManager?.GetAssociatedApplications(pluginContext) ?? []); + } + + /// + /// Collects the items of a plugin for the given applications - for a manager that decides + /// the association itself. Collecting a plugin again for an application does nothing. + /// + /// The plugin. + /// The applications. + /// The catalog is bound per plugin. + public void Collect(IPluginContext pluginContext, IEnumerable applicationContexts) + { + ArgumentNullException.ThrowIfNull(pluginContext); + Demand(PluginTypeBinding.Application); + + foreach (var applicationContext in (applicationContexts ?? []).Where(x => x is not null).Distinct()) + { + CollectOffer(pluginContext, applicationContext, pluginContext.Assembly); + } + } + + /// + /// Takes out what a plugin offered. Only the keys it still provides change: each falls back + /// to the latest remaining offer of another plugin, or leaves. + /// + /// The plugin. + public void Withdraw(IPluginContext pluginContext) + { + if (pluginContext is not null) + { + WithdrawOffers(x => !x.Registered && ReferenceEquals(x.PluginContext, pluginContext)); + } + } + + /// + /// Takes out every item bound to an application, the registered ones included, because + /// nothing can ask for them once the application is gone. + /// + /// The application. + public void Withdraw(IApplicationContext applicationContext) + { + if (applicationContext is not null) + { + WithdrawOffers(x => ReferenceEquals(x.ApplicationContext, applicationContext)); + } + } + + /// + /// Files an item that is no type of its own - one per configuration entry, a test's + /// stand-in. It becomes the latest offer of its key and replaces an item registered under + /// the same key before; it belongs to no plugin, no plugin removal takes it away, and the + /// catalog never disposes it. + /// + /// The item. + /// The item has no key. + /// The catalog is bound per application. + public void Register(TItem item) + { + Demand(PluginTypeBinding.Plugin); + + RegisterOffer(null, item); + } + + /// + /// Files an item that is no type of its own for an application, with the rules of + /// ; it leaves with the application. + /// + /// The application. + /// The item. + /// The item has no key. + /// The catalog is bound per plugin. + public void Register(IApplicationContext applicationContext, TItem item) + { + ArgumentNullException.ThrowIfNull(applicationContext); + Demand(PluginTypeBinding.Application); + + RegisterOffer(applicationContext, item); + } + + /// + /// Takes out the item registered under a key. A plugin offering the key takes over. + /// + /// The key. + /// when an item was registered under the key. + public bool Unregister(string key) + { + return Unregister(null, key); + } + + /// + /// Takes out the item registered under a key for an application. + /// + /// The application, or for a catalog bound per plugin. + /// The key. + /// when an item was registered under the key. + public bool Unregister(IApplicationContext applicationContext, string key) + { + var normalized = Normalize(key); + + if (normalized is null) + { + return false; + } + + return WithdrawOffers(x => x.Registered + && ReferenceEquals(x.ApplicationContext, applicationContext) + && x.Items.ContainsKey(normalized)) > 0; + } + + /// + /// Returns the item in use under a key. + /// + /// The key. May be blank. + /// The item, or . + public TItem Find(string key) + { + return Find(null, key); + } + + /// + /// Returns the item in use under a key for an application. + /// + /// The application, or for a catalog bound per plugin. + /// The key. May be blank. + /// The item, or . + public TItem Find(IApplicationContext applicationContext, string key) + { + var normalized = Normalize(key); + + if (normalized is null) + { + return null; + } + + lock (_sync) + { + return _inUse.TryGetValue(new Slot(applicationContext, normalized), out var offer) + ? offer.Items[normalized] + : null; + } + } + + /// + /// Answers whether an item is in use under a key. + /// + /// The key. May be blank. + /// when one is. + public bool Contains(string key) + { + return Find(null, key) is not null; + } + + /// + /// Answers whether an item is in use under a key for an application. + /// + /// The application, or for a catalog bound per plugin. + /// The key. May be blank. + /// when one is. + public bool Contains(IApplicationContext applicationContext, string key) + { + return Find(applicationContext, key) is not null; + } + + /// + /// Returns the plugin whose offer is in use under a key - to tell a diagnostic which plugin + /// provides an item, or which one shadows another. + /// + /// The key. May be blank. + /// The plugin, or when nothing is in use under the key or the + /// item was registered or collected from an assembly without a plugin. + public IPluginContext OwnerOf(string key) + { + return OwnerOf(null, key); + } + + /// + /// Returns the plugin whose offer is in use under a key for an application. + /// + /// The application, or for a catalog bound per plugin. + /// The key. May be blank. + /// The plugin, or . + public IPluginContext OwnerOf(IApplicationContext applicationContext, string key) + { + var normalized = Normalize(key); + + if (normalized is null) + { + return null; + } + + lock (_sync) + { + return _inUse.TryGetValue(new Slot(applicationContext, normalized), out var offer) + ? offer.PluginContext + : null; + } + } + + /// + /// Returns the items in use for an application, in the order their offers arrived. + /// + /// The application. + /// A copy of the items. + public IReadOnlyList GetItems(IApplicationContext applicationContext) + { + lock (_sync) + { + return Snapshot(x => ReferenceEquals(x.ApplicationContext, applicationContext)); + } + } + + /// + /// Stops following the host and takes out every item, raising for the + /// ones in use and disposing the ones it constructed. A disposed catalog collects nothing. + /// + public void Dispose() + { + IComponentHub componentHub; + List changes; + List removed; + + lock (_sync) + { + if (_disposed) + { + return; + } + + _disposed = true; + componentHub = _componentHub; + + removed = [.. _offers]; + _offers.Clear(); + changes = [.. _inUse.Select(x => new Change(x.Key, x.Value, null))]; + _inUse.Clear(); + + if (changes.Count > 0) + { + _version++; + } + } + + if (componentHub?.PluginManager is { } plugins) + { + plugins.AddPlugin -= OnAddPlugin; + plugins.RemovePlugin -= OnRemovePlugin; + } + + if (Binding == PluginTypeBinding.Application && componentHub?.ApplicationManager is { } applications) + { + applications.AddApplication -= OnAddApplication; + applications.RemoveApplication -= OnRemoveApplication; + } + + Raise(changes); + Release(removed); + } + + /// + /// Refuses an operation that has no meaning for the catalog's binding, because answering it + /// silently would file items where no lookup finds them. + /// + /// The binding the operation needs. + private void Demand(PluginTypeBinding binding) + { + if (Binding != binding) + { + throw new InvalidOperationException($"The operation needs a catalog bound per {binding.ToString().ToLowerInvariant()}, this one is bound per {Binding.ToString().ToLowerInvariant()}."); + } + } + + /// + /// Discovers and files the items of one plugin (or assembly) and application. The types + /// are scanned and the items built outside the lock; an offer that arrived meanwhile wins + /// and the duplicate is discarded. + /// + /// The plugin, or null for an assembly without one. + /// The application, or null for a catalog bound per plugin. + /// The assembly to scan. + private void CollectOffer(IPluginContext pluginContext, IApplicationContext applicationContext, Assembly assembly) + { + if (assembly is null) + { + return; + } + + lock (_sync) + { + if (_disposed || _offers.Any(x => x.IsOfferOf(pluginContext, applicationContext, assembly))) + { + return; + } + } + + var offer = new Offer(pluginContext, applicationContext, assembly, registered: false, Discover(pluginContext, applicationContext, assembly)); + List changes = null; + + lock (_sync) + { + if (!_disposed && !_offers.Any(x => x.IsOfferOf(pluginContext, applicationContext, assembly))) + { + _offers.Add(offer); + changes = Resolve(offer.Items.Keys.Select(x => new Slot(applicationContext, x))); + } + } + + if (changes is null) + { + Release([offer]); + + return; + } + + Raise(changes); + } + + /// + /// Files a registered item as the latest offer of its key, replacing an item registered + /// under the same key before. + /// + /// The application, or null for a catalog bound per plugin. + /// The item. + private void RegisterOffer(IApplicationContext applicationContext, TItem item) + { + ArgumentNullException.ThrowIfNull(item); + + var key = Normalize(_keyOf(item)) ?? throw new ArgumentException("The item has no key.", nameof(item)); + var offer = new Offer(null, applicationContext, null, registered: true, new Dictionary(StringComparer.OrdinalIgnoreCase) { [key] = item }); + List changes; + + lock (_sync) + { + if (_disposed) + { + return; + } + + _offers.RemoveAll(x => x.Registered && ReferenceEquals(x.ApplicationContext, applicationContext) && x.Items.ContainsKey(key)); + _offers.Add(offer); + changes = Resolve([new Slot(applicationContext, key)]); + } + + Raise(changes); + } + + /// + /// Takes out the offers a predicate selects and lets every key they touched fall back. + /// + /// Selects the offers. + /// The number of offers taken out. + private int WithdrawOffers(Func predicate) + { + List removed; + List changes; + + lock (_sync) + { + if (_disposed) + { + return 0; + } + + removed = [.. _offers.Where(predicate)]; + + if (removed.Count == 0) + { + return 0; + } + + foreach (var offer in removed) + { + _offers.Remove(offer); + } + + changes = Resolve(removed.SelectMany(x => x.Items.Keys.Select(y => new Slot(x.ApplicationContext, y)))); + } + + Raise(changes); + Release(removed); + + return removed.Count; + } + + /// + /// Brings the items in use for the given slots in step with the offers: each slot takes the + /// latest offer of its key. Must be called under the lock. + /// + /// The slots whose offers changed. + /// The changes of the items in use, for the events. + private List Resolve(IEnumerable slots) + { + var changes = new List(); + + foreach (var slot in slots.Distinct(SlotComparer.Instance)) + { + _inUse.TryGetValue(slot, out var before); + + var after = _offers.LastOrDefault(x => ReferenceEquals(x.ApplicationContext, slot.ApplicationContext) && x.Items.ContainsKey(slot.Key)); + + if (ReferenceEquals(before, after)) + { + continue; + } + + if (after is null) + { + _inUse.Remove(slot); + } + else + { + _inUse[slot] = after; + } + + changes.Add(new Change(slot, before, after)); + } + + if (changes.Count > 0) + { + _version++; + } + + return changes; + } + + /// + /// Returns the items in use of the offers a predicate selects, in the order the offers + /// arrived. Must be called under the lock. + /// + /// Selects the offers. + /// A copy of the items. + private List Snapshot(Func predicate) + { + var items = new List(); + + foreach (var offer in _offers.Where(predicate)) + { + foreach (var (key, item) in offer.Items) + { + if (_inUse.TryGetValue(new Slot(offer.ApplicationContext, key), out var inUse) && ReferenceEquals(inUse, offer)) + { + items.Add(item); + } + } + } + + return items; + } + + /// + /// Raises the events of a set of changes, outside the lock, the leaving item before the + /// arriving one, so a handler that mirrors the catalog never holds two items for one key. + /// + /// The changes. + private void Raise(IEnumerable changes) + { + foreach (var change in changes) + { + if (change.Before is { } before) + { + Removed?.Invoke(this, new PluginTypeCatalogEventArgs(change.Slot.Key, before.Items[change.Slot.Key], before.PluginContext, before.ApplicationContext)); + } + + if (change.After is { } after) + { + Added?.Invoke(this, new PluginTypeCatalogEventArgs(change.Slot.Key, after.Items[change.Slot.Key], after.PluginContext, after.ApplicationContext)); + } + } + } + + /// + /// Disposes the items of offers that left the catalog, where the catalog constructed them. + /// + /// The offers. + private void Release(IEnumerable offers) + { + foreach (var offer in offers.Where(x => !x.Registered)) + { + foreach (var item in offer.Items.Values) + { + Release(item); + } + } + } + + /// + /// Disposes an item the catalog constructed, keeping a faulty disposal from stopping the others. + /// + /// The item. + private void Release(TItem item) + { + if (item is not IDisposable disposable) + { + return; + } + + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + _httpServerContext?.Log?.Exception(ex); + } + } + + /// + /// Scans an assembly and builds an item of every type that follows the convention. A type + /// that fails to load, to construct or to name its key is logged and skipped, so one + /// defective item does not take the others of its plugin along. + /// + /// The plugin, or null. + /// The application, or null. + /// The assembly. + /// The items, keyed. + private Dictionary Discover(IPluginContext pluginContext, IApplicationContext applicationContext, Assembly assembly) + { + var items = new Dictionary(StringComparer.OrdinalIgnoreCase); + var source = pluginContext?.PluginId?.ToString() ?? assembly.GetName().Name; + + foreach (var type in TypesOf(assembly, source)) + { + TItem item; + + try + { + item = _factory is null + ? (TItem)Activator.CreateInstance(type) + : _factory(new PluginTypeOrigin(type, pluginContext, applicationContext)); + } + catch (Exception ex) + { + var cause = ex is TargetInvocationException { InnerException: { } inner } ? inner : ex; + Warn("webexpress.webcore:plugintypecatalog.type_failed", type.FullName, source, cause.Message); + + continue; + } + + if (item is null) + { + continue; + } + + string key; + + try + { + key = Normalize(_keyOf(item)); + } + catch (Exception ex) + { + Warn("webexpress.webcore:plugintypecatalog.type_failed", type.FullName, source, ex.Message); + Release(item); + + continue; + } + + if (key is null) + { + Warn("webexpress.webcore:plugintypecatalog.key_missing", type.FullName, source); + Release(item); + + continue; + } + + if (!items.TryAdd(key, item)) + { + // two types of one plugin under one key: the plugin is defective, keep the first + Warn("webexpress.webcore:plugintypecatalog.key_duplicate", type.FullName, source, key); + Release(item); + } + } + + return items; + } + + /// + /// Returns the types of an assembly that follow the convention. An assembly that loads only + /// in part yields the types that loaded. + /// + /// The assembly. + /// The name the log gives the assembly. + /// The types. + private IEnumerable TypesOf(Assembly assembly, string source) + { + Type[] types; + + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + Warn("webexpress.webcore:plugintypecatalog.types_incomplete", source, ex.LoaderExceptions.FirstOrDefault(x => x is not null)?.Message ?? ex.Message); + types = [.. ex.Types.Where(x => x is not null)]; + } + catch (Exception ex) + { + Warn("webexpress.webcore:plugintypecatalog.types_incomplete", source, ex.Message); + + return []; + } + + return types.Where(IsItemType).ToArray(); + } + + /// + /// Answers whether a type follows the convention. + /// + /// The type. + /// when it is an item type. + private bool IsItemType(Type type) + { + return type.IsClass + && type.IsPublic + && type.IsSealed + && !type.IsAbstract + && !type.ContainsGenericParameters + && _contract.IsAssignableFrom(type) + && (_factory is not null || type.GetConstructor(Type.EmptyTypes) is not null); + } + + /// + /// Writes a warning to the host's log, when the catalog follows one. + /// + /// The i18n key of the message. + /// The arguments. + private void Warn(string key, params object[] args) + { + _httpServerContext?.Log?.Warning(I18N.Translate(key, args)); + } + + /// + /// Trims a key and maps a blank one to null. + /// + /// The key. + /// The normalized key, or null. + private static string Normalize(string key) + { + return string.IsNullOrWhiteSpace(key) ? null : key.Trim(); + } + + /// + /// Collects a plugin that arrived. + /// + /// The plugin manager. + /// The plugin. + private void OnAddPlugin(object sender, IPluginContext pluginContext) + { + if (pluginContext is not null) + { + Collect(pluginContext); + } + } + + /// + /// Takes out what a plugin that left offered. + /// + /// The plugin manager. + /// The plugin. + private void OnRemovePlugin(object sender, IPluginContext pluginContext) + { + Withdraw(pluginContext); + } + + /// + /// Collects, for an application that arrived, the plugins already associated with it. + /// + /// The application manager. + /// The application. + private void OnAddApplication(object sender, IApplicationContext applicationContext) + { + if (applicationContext is null) + { + return; + } + + IComponentHub componentHub; + + lock (_sync) + { + componentHub = _componentHub; + } + + foreach (var plugin in componentHub?.PluginManager?.GetPlugins(applicationContext)?.ToArray() ?? []) + { + Collect(plugin, [applicationContext]); + } + } + + /// + /// Takes out the items of an application that left. + /// + /// The application manager. + /// The application. + private void OnRemoveApplication(object sender, IApplicationContext applicationContext) + { + Withdraw(applicationContext); + } + + /// + /// What one plugin (or assembly, or registration) offers for one application. + /// + private sealed class Offer + { + /// + /// Gets the plugin, or null for a registration or an assembly without a plugin. + /// + public IPluginContext PluginContext { get; } + + /// + /// Gets the application, or null for a catalog bound per plugin. + /// + public IApplicationContext ApplicationContext { get; } + + /// + /// Gets the scanned assembly, or null for a registration. + /// + public Assembly Assembly { get; } + + /// + /// Gets whether the item was given to Register rather than discovered. + /// + public bool Registered { get; } + + /// + /// Gets the items, keyed. + /// + public IReadOnlyDictionary Items { get; } + + /// + /// Captures an offer. + /// + /// The plugin. + /// The application. + /// The assembly. + /// Whether it is a registration. + /// The items. + public Offer(IPluginContext pluginContext, IApplicationContext applicationContext, Assembly assembly, bool registered, IReadOnlyDictionary items) + { + PluginContext = pluginContext; + ApplicationContext = applicationContext; + Assembly = assembly; + Registered = registered; + Items = items; + } + + /// + /// Answers whether this is the discovered offer of a plugin (or assembly) for an application. + /// + /// The plugin. + /// The application. + /// The assembly. + /// when it is. + public bool IsOfferOf(IPluginContext pluginContext, IApplicationContext applicationContext, Assembly assembly) + { + return !Registered + && ReferenceEquals(ApplicationContext, applicationContext) + && (pluginContext is null + ? PluginContext is null && ReferenceEquals(Assembly, assembly) + : ReferenceEquals(PluginContext, pluginContext)); + } + } + + /// + /// A key of an application (null for a catalog bound per plugin). + /// + /// The application. + /// The normalized key. + private readonly record struct Slot(IApplicationContext ApplicationContext, string Key); + + /// + /// A change of the offer in use for a slot. + /// + /// The slot. + /// The offer in use before, or null. + /// The offer in use after, or null. + private readonly record struct Change(Slot Slot, Offer Before, Offer After); + + /// + /// Compares slots by the identity of their application and the key without case, because an + /// application context is identified by reference throughout the framework. + /// + private sealed class SlotComparer : IEqualityComparer + { + /// + /// Gets the comparer. + /// + public static SlotComparer Instance { get; } = new(); + + /// + public bool Equals(Slot x, Slot y) + { + return ReferenceEquals(x.ApplicationContext, y.ApplicationContext) + && StringComparer.OrdinalIgnoreCase.Equals(x.Key, y.Key); + } + + /// + public int GetHashCode(Slot obj) + { + return HashCode.Combine(RuntimeHelpers.GetHashCode(obj.ApplicationContext), StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Key ?? string.Empty)); + } + } + } +} diff --git a/src/WebExpress.WebCore/WebPlugin/PluginTypeCatalogEventArgs.cs b/src/WebExpress.WebCore/WebPlugin/PluginTypeCatalogEventArgs.cs new file mode 100644 index 00000000..febcceb5 --- /dev/null +++ b/src/WebExpress.WebCore/WebPlugin/PluginTypeCatalogEventArgs.cs @@ -0,0 +1,53 @@ +using System; +using WebExpress.WebCore.WebApplication; + +namespace WebExpress.WebCore.WebPlugin +{ + /// + /// Reports a change of the item in use under a key of a , + /// so a manager can raise its own events or drop what it derived from the item without + /// comparing snapshots. + /// + /// The type of the catalog's items. + public sealed class PluginTypeCatalogEventArgs : EventArgs + { + /// + /// Gets the key the item is filed under. + /// + public string Key { get; } + + /// + /// Gets the item that started or stopped being the one in use. + /// + public TItem Item { get; } + + /// + /// Gets the plugin that offers the item, or for an item given to + /// or collected from an assembly + /// without a plugin. + /// + public IPluginContext PluginContext { get; } + + /// + /// Gets the application the item is bound to, or for a catalog + /// bound per plugin. + /// + public IApplicationContext ApplicationContext { get; } + + /// + /// Captures one change, because the catalog raises its events after it released its lock + /// and the state may have moved on by the time a handler runs. + /// + /// The key. + /// The item. + /// The offering plugin. + /// The bound application. + public PluginTypeCatalogEventArgs(string key, TItem item, IPluginContext pluginContext, IApplicationContext applicationContext) + { + Key = key; + Item = item; + PluginContext = pluginContext; + ApplicationContext = applicationContext; + } + } +} diff --git a/src/WebExpress.WebCore/WebPlugin/PluginTypeOrigin.cs b/src/WebExpress.WebCore/WebPlugin/PluginTypeOrigin.cs new file mode 100644 index 00000000..67d4240c --- /dev/null +++ b/src/WebExpress.WebCore/WebPlugin/PluginTypeOrigin.cs @@ -0,0 +1,17 @@ +using System; +using WebExpress.WebCore.WebApplication; + +namespace WebExpress.WebCore.WebPlugin +{ + /// + /// Describes where a discovered type was found, so a factory of a + /// can build an item that needs its context - its + /// plugin, its application - instead of relying on a parameterless constructor. + /// + /// The discovered type. + /// The plugin whose assembly holds the type, or + /// when an assembly was collected without a plugin. + /// The application the item is bound to, or + /// for a catalog bound per plugin. + public sealed record PluginTypeOrigin(Type Type, IPluginContext PluginContext, IApplicationContext ApplicationContext); +} diff --git a/src/WebExpress.WebCore/WebResource/ResourceManager.cs b/src/WebExpress.WebCore/WebResource/ResourceManager.cs index d75fe005..69203449 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceManager.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceManager.cs @@ -138,7 +138,7 @@ private void Register(IPluginContext pluginContext) } } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// @@ -254,11 +254,7 @@ var attribute in resourceType // assign the resource to existing applications foreach (var applicationContext in applicationContexts) { - var prefix = applicationContext.Route.Concat( - applicationContext.PluginContext != pluginContext - ? pluginContext.PluginName.ToLower() - : "" - ); + var prefix = EndpointManager.CreatePluginRoute(applicationContext, pluginContext); var routePath = EndpointManager.CreateEndpointRoute(resourceType, prefix, segment); var resourceContext = new ResourceContext() diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs b/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs index d57c5e24..dc666c24 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiManager.cs @@ -367,7 +367,7 @@ private void Register(IPluginContext pluginContext) return; } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } } @@ -533,12 +533,7 @@ var attribute in restApiType // assign the rest api to existing applications foreach (var applicationContext in applicationContexts) { - var prefix = applicationContext.Route.Concat - ( - applicationContext.PluginContext != pluginContext - ? pluginContext.PluginName.ToLower() - : "" - ); + var prefix = EndpointManager.CreatePluginRoute(applicationContext, pluginContext); var routePath = EndpointManager.CreateEndpointRoute ( diff --git a/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs b/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs index 3e32e1b9..1d966115 100644 --- a/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs +++ b/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace WebExpress.WebCore.WebSetting { @@ -56,5 +57,17 @@ public sealed class AuthenticationSettings /// Chooses an application for the root authentication endpoints when none is supplied. /// public string ApplicationId { get; set; } + + /// + /// Names groups of applications whose tokens are accepted by each other, so one sign-in + /// serves all of them. The browser keeps one access cookie for the whole host; without a + /// group a sign-in to one application would end the sign-in to the other. Grant revocations + /// and refresh replay markers have to reach every member, so the members share one token + /// store - the default store does, a plugin-supplied store has to. + /// + /// + /// "ApplicationGroups": { "kleenestar": [ "kleenestar.core", "kleenestar.portal" ] } + /// + public Dictionary> ApplicationGroups { get; set; } = new(StringComparer.OrdinalIgnoreCase); } } diff --git a/src/WebExpress.WebCore/WebSetting/SettingsLog.cs b/src/WebExpress.WebCore/WebSetting/SettingsLog.cs new file mode 100644 index 00000000..9b50946f --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/SettingsLog.cs @@ -0,0 +1,39 @@ +using System; +using System.Linq; + +namespace WebExpress.WebCore.WebSetting +{ + /// + /// Keeps startup configuration diagnostics useful without copying credentials into logs. + /// + internal static class SettingsLog + { + /// + /// Masks credential settings and escapes line breaks so each parameter occupies one log entry. + /// + /// The full configuration path used to identify credential sections. + /// The merged configuration value, or null when it is not configured. + /// The value suitable for startup diagnostics. + internal static string FormatValue(string key, string value) + { + if (value is null) + { + return "(not configured)"; + } + + var sensitive = new[] { "password", "secret", "credential", "connectionstring", "signingkey", "privatekey", "apikey", "bearertoken", "accesstoken", "refreshtoken" }; + if (sensitive.Any(part => key.Contains(part, StringComparison.OrdinalIgnoreCase))) + { + return "[redacted]"; + } + + // uri settings can contain credentials even when their configuration key is harmless + if (System.Uri.TryCreate(value, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.UserInfo)) + { + return "[redacted]"; + } + + return value.Replace("\r", "\\r").Replace("\n", "\\n"); + } + } +} diff --git a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs index 113e18e7..2b344045 100644 --- a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs +++ b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs @@ -229,9 +229,9 @@ private void Register(IPluginContext pluginContext) return; } - RegisterCategory(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); - RegisterGroup(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); - RegisterPage(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + RegisterCategory(pluginContext, _componentHub.GetBoundApplications(pluginContext)); + RegisterGroup(pluginContext, _componentHub.GetBoundApplications(pluginContext)); + RegisterPage(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// @@ -598,7 +598,7 @@ var attribute in settingPageType // assign the setting page to existing applications foreach (var applicationContext in applicationContexts) { - var prefix = applicationContext.Route.Concat(applicationContext.PluginContext != pluginContext ? pluginContext.PluginName.ToLower() : ""); + var prefix = EndpointManager.CreatePluginRoute(applicationContext, pluginContext); var routePath = EndpointManager.CreateEndpointRoute(settingPageType, prefix, segment); diff --git a/src/WebExpress.WebCore/WebSocket/SocketManager.cs b/src/WebExpress.WebCore/WebSocket/SocketManager.cs index d84ded70..7de5eaba 100644 --- a/src/WebExpress.WebCore/WebSocket/SocketManager.cs +++ b/src/WebExpress.WebCore/WebSocket/SocketManager.cs @@ -283,7 +283,7 @@ private void Register(IPluginContext pluginContext) return; } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// @@ -412,12 +412,7 @@ var attribute in socketType // assign the socket to existing applications foreach (var applicationContext in applicationContexts) { - var prefix = applicationContext.Route.Concat - ( - applicationContext.PluginContext != pluginContext - ? pluginContext.PluginName.ToLower() - : "" - ); + var prefix = EndpointManager.CreatePluginRoute(applicationContext, pluginContext); var routePath = EndpointManager.CreateEndpointRoute(socketType, prefix, null); var socketContext = new SocketContext() { diff --git a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs index 1abd42cf..1d75a811 100644 --- a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs +++ b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs @@ -104,7 +104,7 @@ private void Register(IPluginContext pluginContext) } } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// diff --git a/src/WebExpress.WebCore/WebTheme/ThemeManager.cs b/src/WebExpress.WebCore/WebTheme/ThemeManager.cs index fd9b897d..444c83d8 100644 --- a/src/WebExpress.WebCore/WebTheme/ThemeManager.cs +++ b/src/WebExpress.WebCore/WebTheme/ThemeManager.cs @@ -109,7 +109,7 @@ private void Register(IPluginContext pluginContext) return; } - Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); + Register(pluginContext, _componentHub.GetBoundApplications(pluginContext)); } /// diff --git a/src/WebExpress.WebCore/WebUri/IUriPathSegmentTrail.cs b/src/WebExpress.WebCore/WebUri/IUriPathSegmentTrail.cs new file mode 100644 index 00000000..ae7f7998 --- /dev/null +++ b/src/WebExpress.WebCore/WebUri/IUriPathSegmentTrail.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using WebExpress.WebCore.WebPage; + +namespace WebExpress.WebCore.WebUri +{ + /// + /// A path segment that stands for more than one step of the trail. The segment of a page in + /// a hierarchy - a wiki page, an org unit - is a single segment of the address, but the + /// visitor expects to see every ancestor in the breadcrumb, each a link of its own. A + /// breadcrumb renders the crumbs of such a segment in place of the one the segment would give. + /// + /// + /// The segment is reached through a segment attribute whose ToPathSegment returns it; + /// a variable segment is copied and bound before the trail is asked for, so + /// holds the value of the current request. + /// + public interface IUriPathSegmentTrail : IUriPathSegment + { + /// + /// Returns the crumbs the segment contributes, from the outermost to the innermost. + /// + /// The context of the page the breadcrumb is rendered on. + /// The crumbs; a hidden crumb is skipped. + IEnumerable GetTrail(IRenderContext renderContext); + } +} diff --git a/src/WebExpress.WebCore/WebUri/UriData.cs b/src/WebExpress.WebCore/WebUri/UriData.cs new file mode 100644 index 00000000..6ed74fc9 --- /dev/null +++ b/src/WebExpress.WebCore/WebUri/UriData.cs @@ -0,0 +1,43 @@ +namespace WebExpress.WebCore.WebUri +{ + /// + /// A data url (RFC 2397), which carries its content instead of naming a location. It is kept + /// as written: parsed as an endpoint it would lose everything but its scheme, and a picture + /// sent inline would be served as an empty address. + /// + public class UriData : UriEndpoint + { + /// + /// Gets the data url as written. + /// + public string Value { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The data url, starting with "data:". + public UriData(string value) + { + Value = value; + } + + /// + /// Returns the data url itself, which has no variable parts to fill. + /// + /// The parameters, which are ignored. + /// This data url. + public override IUri BindParameters(params WebParameter.IParameter[] parameters) + { + return this; + } + + /// + /// Returns the data url as written. + /// + /// The data url. + public override string ToString() + { + return Value; + } + } +} diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 5ac580d7..a1b9de6e 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -468,7 +468,10 @@ public bool StartsWith(IUri uri) } /// - /// Creates a new endpoint uri and fills it with the given parameters. + /// Creates a new endpoint uri and fills it with the given parameters. Only path + /// variables that carry no value yet are filled: a bound segment stays a variable, so + /// binding the request of the current page onto a link that already names another + /// record would otherwise turn it into a link to the current record. /// /// /// The parameters that fill in the variable parts of the uri. @@ -492,6 +495,7 @@ public virtual IUri BindParameters(params IParameter[] parameters) pathSegments = pathSegments.Select(x => { if (x is IUriPathSegmentVariable variable && + string.IsNullOrEmpty(variable.Value) && variable.VariableName.Equals(key, StringComparison.OrdinalIgnoreCase)) { var copy = variable.Copy() as IUriPathSegmentVariable; diff --git a/src/WebExpress.WebCore/WebUri/UriTrailCrumb.cs b/src/WebExpress.WebCore/WebUri/UriTrailCrumb.cs new file mode 100644 index 00000000..0bf371f5 --- /dev/null +++ b/src/WebExpress.WebCore/WebUri/UriTrailCrumb.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using WebExpress.WebCore.WebIcon; + +namespace WebExpress.WebCore.WebUri +{ + /// + /// One step of a breadcrumb that a contributes. A crumb + /// may carry a menu, for instance the siblings of a page, so the trail is also a way sideways. + /// + public sealed class UriTrailCrumb + { + /// + /// Gets the text, which may be an internationalization key. + /// + public string Text { get; init; } + + /// + /// Gets the icon, or null. + /// + public IIcon Icon { get; init; } + + /// + /// Gets the address the crumb links to, or null for a crumb that is text only. + /// + public IUri Uri { get; init; } + + /// + /// Gets whether the crumb is left out of the breadcrumb, for a step that only exists + /// in the address. + /// + public bool Hidden { get; init; } + + /// + /// Gets the entries of the crumb's menu; empty for a crumb without one. + /// + public IEnumerable Menu { get; init; } = []; + } +}