Repository navigation
Expand file tree
/
Copy pathShellEndpoints.cs
More file actions
160 lines (146 loc) · 8.05 KB
/
Copy pathShellEndpoints.cs
File metadata and controls
160 lines (146 loc) · 8.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
using Kododo.Reiho.AspNetCore.SPA;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Patterns;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Kododo.Way.Shell;
public static class ShellEndpoints
{
/// <summary>Segment under a Way's path its module files are served from.</summary>
internal const string ModulePath = "_way";
extension(IServiceCollection services)
{
/// <summary>
/// Adds the shell. Way libraries call this themselves; call it in the app to configure the shell. Safe to call
/// more than once: every <paramref name="configure"/> applies.
/// </summary>
public IServiceCollection AddWayShell(Action<ShellOptions>? configure = null)
{
services.TryAddSingleton<WayRegistry>();
services.AddOptions<ShellOptions>();
if (configure is not null) services.Configure(configure);
if (!services.Any(d => d.ImplementationType == typeof(ShellStartupCheck)))
services.AddHostedService<ShellStartupCheck>();
return services;
}
}
extension(IEndpointRouteBuilder endpoints)
{
/// <summary>
/// For Way libraries: mounts the shell with only <paramref name="module"/> at <paramref name="path"/>, and the
/// module's files. The Way maps its API on the returned group, so authorization the host adds to it covers the
/// page, the module and the API. Outside the Development environment the group answers 403 until the host
/// decides on its authorization.
/// </summary>
public RouteGroupBuilder MapWay(WayModule module, string path)
{
ArgumentNullException.ThrowIfNull(module);
var registry = Registry(endpoints);
var way = registry.Add(module, Normalize(path));
MapAssets(endpoints, registry);
var group = endpoints.MapGroup(way.Path).WithMetadata(new WayEndpointMetadata(module.Id));
ShellAccess.Protect(group, module.Name);
group.MapGroup($"/{ModulePath}").MapEmbeddedSpa(module.Assembly, o => o.RootPath = module.ResourceRoot);
// The other Ways the user may open come along, so the module can link and navigate to them.
group.MapEmbeddedSpa(typeof(ShellEndpoints).Assembly, o =>
ShellPage.Configure(o, way.Path, context => VisibleWays(context, registry, way), _ => module.Name, "single", module.Id));
return group;
}
/// <summary>
/// Mounts the panel at <paramref name="path"/>: one page with every Way mounted in the app that the current
/// user may open, as decided by the authorization on each Way. Protect the panel itself on the returned group,
/// e.g. <c>app.UseWayShell("/admin").RequireAuthorization("Admin")</c>. At <c>/</c> the panel is the app's home
/// page and answers only there; every other address stays the host's.
/// </summary>
public RouteGroupBuilder UseWayShell(string path = "/admin")
{
var registry = Registry(endpoints);
MapAssets(endpoints, registry);
var panelPath = Normalize(path, allowRoot: true);
var group = endpoints.MapGroup(panelPath.Length == 0 ? "/" : panelPath).WithMetadata(new ShellPanelMetadata());
ShellAccess.Protect(group, "The Way.Shell panel");
group.MapEmbeddedSpa(typeof(ShellEndpoints).Assembly, o =>
ShellPage.Configure(o, panelPath, context => VisibleWays(context, registry, current: null), _ => "Admin", "panel", wayId: null));
if (panelPath.Length == 0)
// The page is all the panel serves (its files come from AssetsPath): at the root, Reiho's catch-all would
// answer every address the host does not map, with the panel's 404 or its sign-in instead of the host's.
((IEndpointConventionBuilder)group).Add(endpoint =>
{
if (endpoint is not RouteEndpointBuilder { RequestDelegate: { } inner } route) return;
route.RoutePattern = RoutePatternFactory.Parse("/");
// Under a path base the app's own address without the trailing slash (/app) has an empty path, which
// Reiho cannot cut the route's "/" from.
route.RequestDelegate = context =>
{
if (!context.Request.Path.HasValue) context.Request.Path = "/";
return inner(context);
};
});
return group;
}
}
/// <summary>The Ways the current user may open, in the order they were mounted; <paramref name="current"/> always.</summary>
private static async Task<IReadOnlyList<RegisteredWay>> VisibleWays(HttpContext context, WayRegistry registry, RegisteredWay? current)
{
var visible = new List<RegisteredWay>();
foreach (var way in registry.Ways)
if (way == current || await ShellAccess.CanOpen(context, way)) visible.Add(way);
return visible;
}
private static WayRegistry Registry(IEndpointRouteBuilder endpoints) =>
endpoints.ServiceProvider.GetService<WayRegistry>()
?? throw new InvalidOperationException("Way.Shell is not registered. Call services.AddWayShell() (Way libraries do it in their Add… method).");
/// <summary>The shared libraries and the shell app, once per app and without authorization: they are public files.</summary>
private static void MapAssets(IEndpointRouteBuilder endpoints, WayRegistry registry)
{
lock (registry)
{
if (registry.AssetsMapped) return;
registry.AssetsMapped = true;
}
var options = endpoints.ServiceProvider.GetRequiredService<IOptions<ShellOptions>>().Value;
endpoints.MapGroup(Normalize(options.AssetsPath))
.AllowAnonymous()
.MapEmbeddedSpa(typeof(ShellEndpoints).Assembly, o => o.RootPath = ShellPage.AssetsRootPath);
}
/// <summary><c>/segments</c> without a trailing slash; the root is <c>""</c> where <paramref name="allowRoot"/>.</summary>
internal static string Normalize(string path, bool allowRoot = false)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
var trimmed = "/" + path.Trim().Trim('/');
if (trimmed != "/") return trimmed;
return allowRoot ? "" : throw new ArgumentException("A Way cannot be mounted at the root of the app.", nameof(path));
}
}
/// <summary>Warns at startup about every Way or panel that will answer 403 because nobody decided on its authorization.</summary>
internal sealed class ShellStartupCheck(
IServiceProvider services,
IHostApplicationLifetime lifetime,
ILogger<ShellStartupCheck> logger) : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
lifetime.ApplicationStarted.Register(Check);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private void Check()
{
var endpoints = services.GetService<EndpointDataSource>()?.Endpoints ?? [];
var undecided = endpoints
.Where(e => e.Metadata.GetMetadata<WayEndpointMetadata>() is not null || e.Metadata.GetMetadata<ShellPanelMetadata>() is not null)
.Where(e => !ShellAccess.IsDecided(services, e.Metadata))
.Select(e => e.Metadata.GetMetadata<WayEndpointMetadata>()?.Id ?? "the panel")
.Distinct();
foreach (var name in undecided)
logger.LogWarning(
"Way.Shell: {Way} answers 403 because its endpoints neither require authorization nor allow anonymous " +
"access. Call RequireAuthorization(...) on what Use…Way() or UseWayShell() returns, or set ShellOptions.AllowAnonymous.",
name);
}
}