-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSpaFallbackExtensions.cs
More file actions
115 lines (102 loc) · 5.28 KB
/
Copy pathSpaFallbackExtensions.cs
File metadata and controls
115 lines (102 loc) · 5.28 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
using Microsoft.AspNetCore.Routing;
using Microsoft.Net.Http.Headers;
using PortfolioCMS.Core.Services;
namespace PortfolioCMS.Core.Extensions;
public static class SpaFallbackExtensions
{
/// <summary>
/// Serves the SPA shell for real pages and a genuine 404 for everything else, with the head of
/// the page it is serving written into it.
/// <para>
/// Three cases reach this endpoint, in this order:
/// a path with a file extension is a missing static asset (<c>/wp-login.php</c>, <c>/.env</c>)
/// and gets 404 with no body — handing out index.html with 200 makes every scanner probe look
/// like a valid page and lets it cache-fill the CDN with HTML.
/// An extension-less path that <see cref="PublicRoutes"/> knows is a deep link and gets
/// index.html with 200.
/// Anything else is a mistyped or deleted page: index.html with status 404, so the visitor still
/// gets the styled 404 view while a crawler sees the status the page deserves. The client-side
/// <c>noindex</c> stays as the second line of defence for the crawlers that do run scripts.
/// </para>
/// <para>
/// On top of the status code, the shell's SEO block is replaced with the requested page's own
/// title, description, canonical URL and Open Graph tags (<see cref="SpaShell"/>,
/// <see cref="IPageMetaIndex"/>). Without it every URL served the placeholder head that is built
/// into <c>index.html</c>, because the SPA writes its tags only once the view has mounted — so
/// every shared link unfurled as "Portfólió" and every crawler that does not run scripts indexed
/// the same empty page under every address the sitemap publishes.
/// </para>
/// </summary>
/// <param name="indexFile">Absolute path of the built <c>index.html</c>.</param>
public static void MapSpaFallback(this IEndpointRouteBuilder endpoints, string indexFile)
{
var shell = new SpaShell(indexFile, endpoints.ServiceProvider.GetService<ILoggerFactory>()
?.CreateLogger(typeof(SpaShell)));
endpoints.MapFallback(async context =>
{
var path = context.Request.Path.Value ?? "/";
var lastSegment = path[(path.LastIndexOf('/') + 1)..];
if (lastSegment.Contains('.') || !File.Exists(indexFile))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
// Fail open. No index registered, or Redis and the database are both unreachable:
// serving the shell with 200 is the old behaviour, and 404 on every page would be a far
// worse outage than a soft 404.
var known = true;
var routes = context.RequestServices.GetService<IPublicRouteIndex>();
if (routes is not null)
{
try
{
known = PublicRoutes.Exists(path, await routes.GetAsync());
}
catch (Exception ex)
{
Logger(context).LogWarning(
ex, "Public route lookup failed, serving the SPA shell with 200 for {Path}", path);
}
}
context.Response.StatusCode = known
? StatusCodes.Status200OK
: StatusCodes.Status404NotFound;
// The 404 verdict depends on content that can change any minute — a newly published
// project must not stay a 404 in the edge cache until a TTL runs out.
if (!known) context.Response.Headers[HeaderNames.CacheControl] = "no-store";
context.Response.ContentType = "text/html; charset=utf-8";
// Same fail-open rule as the status code: a head that could not be built is the static
// one from the file, which is exactly what every path used to get.
var html = await RenderAsync(context, shell, path, known);
if (html is null) await context.Response.SendFileAsync(indexFile);
else await context.Response.WriteAsync(html);
});
}
private static async Task<string?> RenderAsync(
HttpContext context, SpaShell shell, string path, bool known)
{
var index = context.RequestServices.GetService<IPageMetaIndex>();
if (index is null) return null;
try
{
// A path the route index rejected has no content behind it, so the only head that fits
// it is the 404 one — and it is the one page that says noindex.
var page = known
? PublicPageMeta.Resolve(path, await index.GetAsync())
: PublicPageMeta.NotFound;
if (page is null) return null;
var baseUrl = PublicSiteUrl.For(
context.Request, context.RequestServices.GetRequiredService<IConfiguration>());
return shell.Render(page, baseUrl, path, known ? "" : "noindex, follow");
}
catch (Exception ex)
{
Logger(context).LogWarning(
ex, "Page meta lookup failed, serving the static head for {Path}", path);
return null;
}
}
private static ILogger Logger(HttpContext context) =>
context.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger(typeof(SpaFallbackExtensions));
}