From 2d16a0734db8a86c7adc882ab48f49e2f97cc020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20N=C3=A4geli?= Date: Wed, 14 Oct 2026 14:42:39 +0200 Subject: [PATCH] Send the www of a custom domain on to the domain A lambda answering at shop.example.com now sends visitors of www.shop.example.com there with a permanent redirect, path and query included, so search engines keep one address. A domain the owner entered with www is served at that name instead, and nothing is sent to it from the name without. A www that is another lambda's domain or one of the platform's own names is left alone. The editor lists the records for the www name beside the domain's, and the README issues the certificate for both names. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 + README.md | 14 ++- src/Frontend/src/control/DomainTab.tsx | 15 +++ src/Frontend/src/locales/de/editor/domain.tsx | 7 +- src/Frontend/src/locales/en/editor/domain.tsx | 8 +- src/Frontend/src/locales/es/editor/domain.tsx | 7 +- src/Frontend/src/locales/fr/editor/domain.tsx | 7 +- src/Frontend/src/locales/id/editor/domain.tsx | 7 +- src/Frontend/src/locales/it/editor/domain.tsx | 7 +- src/Frontend/src/locales/ja/editor/domain.tsx | 7 +- src/Frontend/src/locales/ko/editor/domain.tsx | 7 +- src/Frontend/src/locales/nl/editor/domain.tsx | 7 +- src/Frontend/src/locales/pl/editor/domain.tsx | 7 +- .../src/locales/pt-pt/editor/domain.tsx | 7 +- src/Frontend/src/locales/pt/editor/domain.tsx | 7 +- src/Frontend/src/locales/tr/editor/domain.tsx | 7 +- .../Services/Hosting/DomainRegistry.cs | 42 +++++++-- .../Services/Hosting/DomainRequest.cs | 7 ++ .../Services/Hosting/DomainRouter.cs | 3 + .../Services/Hosting/IDomainRegistry.cs | 5 +- .../Services/Hosting/LambdaAddresses.cs | 7 ++ .../Services/Hosting/LambdaRedirectConcern.cs | 11 ++- .../Hosting/DomainTests.cs | 93 +++++++++++++++++++ 23 files changed, 266 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 348effff..f87f9855 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -174,6 +174,11 @@ domain named after its public key - `https://{key}.genhttp.run/` - **The subdomain serves the lambda only while it has no domain of its own that its tier serves**; with one, it redirects there the same way. A domain of its own works as it did. +- **`www.` in front of a domain redirects to the domain - decided by the + owner**, the same way, unless the owner entered the domain with `www.`: + then that is where it answers, and the name without is not sent there. + Never served at both names (two sites to a search engine), and never + where the `www.` is another lambda's domain or the platform's own name. - **Finding a lambda by an address reads the key as it is spelled (`LambdaKeys.TryRead`), never by the rules for claiming one** (`TryNormalize`): a rule made later - a reserved name, `xn--` - must not diff --git a/README.md b/README.md index d4f6b41a..7d1c2b15 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,11 @@ agents' endpoint and git stay where they are, on the platform. only there: its subdomain sends visitors on to the domain the same way, so there is one address for search engines to keep and for visitors to pass on. Removing the domain, or the tier that serves it, has the subdomain - answer again. + answer again. For the same reason `www.` in front of the domain sends its + visitors on to it - `www.shop.example.com` to `shop.example.com` - unless + the owner entered the domain with `www.`, which is then where it answers, + and nothing is sent there from the name without; or unless that `www.` is + the domain of another lambda, or one of the platform's own names. - **Nothing there.** A subdomain nothing is online at - a key nobody has, a lambda that is offline, a name no key can be - is told so in a plain page that leads to the site, or in a line of JSON for anything but a browser. @@ -1643,7 +1647,7 @@ every other request, so it needs a certificate - issued by hand for now, with the web root above, into a folder of its own: ```bash -certbot certonly --webroot -w /opt/genhttp-lambda/acme -d shop.example.com +certbot certonly --webroot -w /opt/genhttp-lambda/acme -d shop.example.com -d www.shop.example.com mkdir -p /opt/genhttp-lambda/certs/shop.example.com install -m 0644 -o root -g 1001 /etc/letsencrypt/live/shop.example.com/fullchain.pem /opt/genhttp-lambda/certs/shop.example.com/ @@ -1657,6 +1661,12 @@ renewal on that engine. Until the certificate is there, visitors of the domain are redirected to HTTPS and shown the certificate every unknown name is shown, which does not carry their name. +The `www.` name goes on the same certificate: a visitor who types it is sent +on to the domain, and has to trust the server before they can be told so. +Leave it out where its DNS does not point here, or certbot fails for both +names. The challenge for `www.` is sent on to the domain like any other +request once the domain is set, which the certificate authority follows. + Mind the order: once the domain is set, the lambda's subdomain sends its visitors to the domain at once. The challenge is answered for any host as soon as its DNS points here, whether the domain is set or not, so the diff --git a/src/Frontend/src/control/DomainTab.tsx b/src/Frontend/src/control/DomainTab.tsx index 6c112b15..2b191700 100644 --- a/src/Frontend/src/control/DomainTab.tsx +++ b/src/Frontend/src/control/DomainTab.tsx @@ -212,6 +212,9 @@ function Records({ domain, configured, state, checking, onCheck }: { const ours = new Set([ADDRESSES.v4, ADDRESSES.v6]); const pointsHere = resolved.some((address) => ours.has(address)); const elsewhere = resolved.filter((address) => !ours.has(address)); + // typed with www by some, and sent on to the domain by the server - unless + // the owner chose the name with www, which is then the domain itself + const www = domain.startsWith('www.') ? null : `www.${domain}`; return (
@@ -239,10 +242,22 @@ function Records({ domain, configured, state, checking, onCheck }: { + {www && ( + <> + + + + )} + {www && ( +

+ {said.www({www}, {domain})} +

+ )} + {configured && state.dns && (

{pointsHere ? ( diff --git a/src/Frontend/src/locales/de/editor/domain.tsx b/src/Frontend/src/locales/de/editor/domain.tsx index 022f03d5..a5bd27c4 100644 --- a/src/Frontend/src/locales/de/editor/domain.tsx +++ b/src/Frontend/src/locales/de/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: 'Domain auf diesen Server zeigen lassen', check: 'Nochmal prüfen', records: - 'Legen Sie beim DNS-Anbieter der Domain diese zwei Einträge an. Den AAAA-Eintrag können Sie weglassen, wenn die Domain nicht über IPv6 erreichbar sein soll.', + 'Legen Sie beim DNS-Anbieter der Domain diese Einträge an. Die AAAA-Einträge können Sie weglassen, wenn die Domain nicht über IPv6 erreichbar sein soll.', + www: (www, domain) => ( + <> + Besucher, die {www} eingeben, werden zu {domain} weitergeleitet, damit Suchmaschinen eine einzige Adresse behalten. Die zugehörigen Einträge sind optional. + + ), type: 'Typ', name: 'Name', value: 'Wert', diff --git a/src/Frontend/src/locales/en/editor/domain.tsx b/src/Frontend/src/locales/en/editor/domain.tsx index df18d08e..63ecddc9 100644 --- a/src/Frontend/src/locales/en/editor/domain.tsx +++ b/src/Frontend/src/locales/en/editor/domain.tsx @@ -30,7 +30,13 @@ export const domain = { point: 'Point the domain at this server', check: 'Check again', records: - 'At whoever manages the DNS of the domain, add these two records. Leave out the AAAA record if you would rather not be reachable over IPv6.', + 'At whoever manages the DNS of the domain, add these records. Leave out the AAAA records if you would rather not be reachable over IPv6.', + /** Under the DNS records, when the domain was entered without www: why there are records for the name with www as well. */ + www: (www: Node, domain: Node) => ( + <> + Visitors who type {www} are sent on to {domain}, so search engines keep one address. Its records are optional. + + ), type: 'Type', name: 'Name', value: 'Value', diff --git a/src/Frontend/src/locales/es/editor/domain.tsx b/src/Frontend/src/locales/es/editor/domain.tsx index 795a1998..1987cb2e 100644 --- a/src/Frontend/src/locales/es/editor/domain.tsx +++ b/src/Frontend/src/locales/es/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: 'Apunta el dominio a este servidor', check: 'Volver a comprobar', records: - 'En el proveedor que gestiona el DNS del dominio, añade estos dos registros. Omite el registro AAAA si prefieres no ser accesible por IPv6.', + 'En el proveedor que gestiona el DNS del dominio, añade estos registros. Omite los registros AAAA si prefieres no ser accesible por IPv6.', + www: (www, domain) => ( + <> + Los visitantes que escriban {www} serán redirigidos a {domain}, así los buscadores mantienen una sola dirección. Sus registros son opcionales. + + ), type: 'Tipo', name: 'Nombre', value: 'Valor', diff --git a/src/Frontend/src/locales/fr/editor/domain.tsx b/src/Frontend/src/locales/fr/editor/domain.tsx index 2c638650..dc586fc1 100644 --- a/src/Frontend/src/locales/fr/editor/domain.tsx +++ b/src/Frontend/src/locales/fr/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: 'Faites pointer le domaine vers ce serveur', check: 'Revérifier', records: - 'Chez le gestionnaire DNS du domaine, ajoutez ces deux enregistrements. Laissez de côté l’enregistrement AAAA si vous ne voulez pas être joignable en IPv6.', + 'Chez le gestionnaire DNS du domaine, ajoutez ces enregistrements. Laissez de côté les enregistrements AAAA si vous ne voulez pas être joignable en IPv6.', + www: (www, domain) => ( + <> + Les visiteurs qui saisissent {www} sont redirigés vers {domain}, afin que les moteurs de recherche ne retiennent qu’une seule adresse. Ses enregistrements sont facultatifs. + + ), type: 'Type', name: 'Nom', value: 'Valeur', diff --git a/src/Frontend/src/locales/id/editor/domain.tsx b/src/Frontend/src/locales/id/editor/domain.tsx index 0a6f776c..1d7c16c6 100644 --- a/src/Frontend/src/locales/id/editor/domain.tsx +++ b/src/Frontend/src/locales/id/editor/domain.tsx @@ -27,7 +27,12 @@ export const domain: EditorMessages['domain'] = { point: 'Arahkan domain ke server ini', check: 'Cek lagi', records: - 'Di penyedia DNS domain Anda, tambahkan dua record ini. Lewati record AAAA kalau Anda tidak ingin bisa diakses lewat IPv6.', + 'Di penyedia DNS domain Anda, tambahkan record ini. Lewati record AAAA kalau Anda tidak ingin bisa diakses lewat IPv6.', + www: (www, domain) => ( + <> + Pengunjung yang mengetik {www} akan diarahkan ke {domain}, sehingga mesin pencari hanya mengenal satu alamat. Record-nya bersifat opsional. + + ), type: 'Tipe', name: 'Nama', value: 'Nilai', diff --git a/src/Frontend/src/locales/it/editor/domain.tsx b/src/Frontend/src/locales/it/editor/domain.tsx index 2d77e418..593fc78f 100644 --- a/src/Frontend/src/locales/it/editor/domain.tsx +++ b/src/Frontend/src/locales/it/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: 'Punta il dominio su questo server', check: 'Controlla di nuovo', records: - 'Aggiungi questi due record dove gestisci il DNS del dominio. Salta il record AAAA se preferisci non essere raggiungibile via IPv6.', + 'Aggiungi questi record dove gestisci il DNS del dominio. Salta i record AAAA se preferisci non essere raggiungibile via IPv6.', + www: (www, domain) => ( + <> + Chi digita {www} viene reindirizzato a {domain}, così i motori di ricerca mantengono un solo indirizzo. I record per questo nome sono facoltativi. + + ), type: 'Tipo', name: 'Nome', value: 'Valore', diff --git a/src/Frontend/src/locales/ja/editor/domain.tsx b/src/Frontend/src/locales/ja/editor/domain.tsx index 28178769..36139789 100644 --- a/src/Frontend/src/locales/ja/editor/domain.tsx +++ b/src/Frontend/src/locales/ja/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: 'ドメインをこのサーバーに向ける', check: '再確認', records: - 'ドメインのDNSを管理しているサービスで、次の2つのレコードを追加してください。IPv6で接続できなくてもよければ、AAAAレコードは省いてかまいません。', + 'ドメインのDNSを管理しているサービスで、次のレコードを追加してください。IPv6で接続できなくてもよければ、AAAAレコードは省いてかまいません。', + www: (www, domain) => ( + <> + {www}にアクセスした訪問者は{domain}に転送されるため、検索エンジンにはアドレスが1つだけ残ります。こちらのレコードは任意です。 + + ), type: 'タイプ', name: '名前', value: '値', diff --git a/src/Frontend/src/locales/ko/editor/domain.tsx b/src/Frontend/src/locales/ko/editor/domain.tsx index c11231aa..e55b0bbe 100644 --- a/src/Frontend/src/locales/ko/editor/domain.tsx +++ b/src/Frontend/src/locales/ko/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: '도메인이 이 서버를 가리키게 하기', check: '다시 확인', records: - '도메인의 DNS를 관리하는 곳에서 아래 두 레코드를 추가하세요. IPv6로 접속되지 않길 원하면 AAAA 레코드는 빼도 돼요.', + '도메인의 DNS를 관리하는 곳에서 아래 레코드를 추가하세요. IPv6로 접속되지 않길 원하면 AAAA 레코드는 빼도 돼요.', + www: (www, domain) => ( + <> + {www}를 입력해서 들어온 방문자는 {domain}(으)로 자동 이동해요. 그래서 검색 엔진에는 주소가 하나만 남아요. 이 이름의 레코드는 선택 사항이에요. + + ), type: '유형', name: '이름', value: '값', diff --git a/src/Frontend/src/locales/nl/editor/domain.tsx b/src/Frontend/src/locales/nl/editor/domain.tsx index bbc0d12a..bfcf2d4b 100644 --- a/src/Frontend/src/locales/nl/editor/domain.tsx +++ b/src/Frontend/src/locales/nl/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: 'Laat het domein naar deze server wijzen', check: 'Opnieuw controleren', records: - 'Voeg deze twee records toe bij de partij die de DNS van het domein beheert. Laat het AAAA-record weg als je liever niet via IPv6 bereikbaar bent.', + 'Voeg deze records toe bij de partij die de DNS van het domein beheert. Laat de AAAA-records weg als je liever niet via IPv6 bereikbaar bent.', + www: (www, domain) => ( + <> + Bezoekers die {www} intypen, worden doorgestuurd naar {domain}, zodat zoekmachines één adres houden. De bijbehorende records zijn optioneel. + + ), type: 'Type', name: 'Naam', value: 'Waarde', diff --git a/src/Frontend/src/locales/pl/editor/domain.tsx b/src/Frontend/src/locales/pl/editor/domain.tsx index 0333fb51..9caf7e89 100644 --- a/src/Frontend/src/locales/pl/editor/domain.tsx +++ b/src/Frontend/src/locales/pl/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: 'Skieruj domenę na ten serwer', check: 'Sprawdź ponownie', records: - 'U dostawcy DNS domeny dodaj te dwa rekordy. Pomiń rekord AAAA, jeśli nie chcesz, żeby domena była dostępna przez IPv6.', + 'U dostawcy DNS domeny dodaj te rekordy. Pomiń rekordy AAAA, jeśli nie chcesz, żeby domena była dostępna przez IPv6.', + www: (www, domain) => ( + <> + Osoby, które wpiszą {www}, są przekierowywane na {domain}, dzięki czemu wyszukiwarki widzą jeden adres. Rekordy dla {www} są opcjonalne. + + ), type: 'Typ', name: 'Nazwa', value: 'Wartość', diff --git a/src/Frontend/src/locales/pt-pt/editor/domain.tsx b/src/Frontend/src/locales/pt-pt/editor/domain.tsx index 3c03c7c8..7bd64ae1 100644 --- a/src/Frontend/src/locales/pt-pt/editor/domain.tsx +++ b/src/Frontend/src/locales/pt-pt/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: 'Aponta o domínio para este servidor', check: 'Verificar outra vez', records: - 'No serviço que gere o DNS do domínio, adiciona estes dois registos. Deixa de fora o registo AAAA se preferires não estar acessível por IPv6.', + 'No serviço que gere o DNS do domínio, adiciona estes registos. Deixa de fora os registos AAAA se preferires não estar acessível por IPv6.', + www: (www, domain) => ( + <> + Os visitantes que escreverem {www} são reencaminhados para {domain}, para que os motores de pesquisa mantenham um único endereço. Os registos deste nome são opcionais. + + ), type: 'Tipo', name: 'Nome', value: 'Valor', diff --git a/src/Frontend/src/locales/pt/editor/domain.tsx b/src/Frontend/src/locales/pt/editor/domain.tsx index b397cc68..b199fa65 100644 --- a/src/Frontend/src/locales/pt/editor/domain.tsx +++ b/src/Frontend/src/locales/pt/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: 'Aponte o domínio para este servidor', check: 'Verificar de novo', records: - 'No provedor que gerencia o DNS do domínio, adicione estes dois registros. Deixe de fora o registro AAAA se preferir não ficar acessível via IPv6.', + 'No provedor que gerencia o DNS do domínio, adicione estes registros. Deixe de fora os registros AAAA se preferir não ficar acessível via IPv6.', + www: (www, domain) => ( + <> + Os visitantes que digitarem {www} são redirecionados para {domain}, para que os buscadores mantenham um único endereço. Os registros dele são opcionais. + + ), type: 'Tipo', name: 'Nome', value: 'Valor', diff --git a/src/Frontend/src/locales/tr/editor/domain.tsx b/src/Frontend/src/locales/tr/editor/domain.tsx index 09c2eb4c..48dfceaf 100644 --- a/src/Frontend/src/locales/tr/editor/domain.tsx +++ b/src/Frontend/src/locales/tr/editor/domain.tsx @@ -28,7 +28,12 @@ export const domain: EditorMessages['domain'] = { point: 'Alan adını bu sunucuya yönlendirin', check: 'Tekrar kontrol et', records: - 'Alan adınızın DNS kayıtlarını yönettiğiniz yerde şu iki kaydı ekleyin. IPv6 üzerinden erişilebilir olmak istemiyorsanız AAAA kaydını eklemeyin.', + 'Alan adınızın DNS kayıtlarını yönettiğiniz yerde şu kayıtları ekleyin. IPv6 üzerinden erişilebilir olmak istemiyorsanız AAAA kayıtlarını eklemeyin.', + www: (www, domain) => ( + <> + {www} yazan ziyaretçiler {domain} adresine yönlendirilir, böylece arama motorları tek bir adres görür. Bunun kayıtları isteğe bağlıdır. + + ), type: 'Tür', name: 'Ad', value: 'Değer', diff --git a/src/GenHTTP.Lambda/Services/Hosting/DomainRegistry.cs b/src/GenHTTP.Lambda/Services/Hosting/DomainRegistry.cs index 2486d786..0cf67259 100644 --- a/src/GenHTTP.Lambda/Services/Hosting/DomainRegistry.cs +++ b/src/GenHTTP.Lambda/Services/Hosting/DomainRegistry.cs @@ -10,8 +10,8 @@ namespace GenHTTP.Lambda.Services.Hosting; ///

/// Which hosts the server answers for on behalf of a lambda, and which lambda -/// each of them belongs to: the domains of their own, and the subdomains of -/// the hosting domain. +/// each of them belongs to: the domains of their own, www in front of them, +/// and the subdomains of the hosting domain. /// /// /// Asked on every request the server receives, so the domains are held in @@ -29,7 +29,7 @@ namespace GenHTTP.Lambda.Services.Hosting; public sealed class DomainRegistry(IDbContextFactory databases, LambdaAddresses addresses, ILogger logger) : IDomainRegistry { - private volatile Served _served = new(FrozenDictionary.Empty, FrozenDictionary.Empty); + private volatile Served _served = new(FrozenDictionary.Empty, FrozenDictionary.Empty, FrozenDictionary.Empty); /// /// One reload at a time: two running side by side could finish in either @@ -49,7 +49,21 @@ public sealed class DomainRegistry(IDbContextFactory databases, #region Functionality public LambdaHost? Find(string host) - => _served.Domains.TryGetValue(host, out var lambdaId) ? new CustomDomain(host, lambdaId) : addresses.Resolve(host); + { + var served = _served; + + if (served.Domains.TryGetValue(host, out var lambdaId)) + { + return new CustomDomain(host, lambdaId); + } + + if (served.Www.TryGetValue(host, out var domain)) + { + return new WwwOfDomain(host, domain); + } + + return addresses.Resolve(host); + } public string? DomainOf(string publicKey) => _served.ByKey.GetValueOrDefault(publicKey); @@ -71,8 +85,17 @@ public void Reload() var previous = _served.Domains.Count; - _served = new Served(served.ToFrozenDictionary(l => l.Domain, l => l.Id, StringComparer.Ordinal), - served.ToFrozenDictionary(l => l.PublicKey, l => l.Domain, StringComparer.Ordinal)); + var domains = served.ToFrozenDictionary(l => l.Domain, l => l.Id, StringComparer.Ordinal); + + // a domain entered without www is also typed with it, the way + // sites used to be found; one entered with it is the owner's + // choice, and so is a www that is a domain of its own + var www = served.Where(l => !l.Domain.StartsWith("www.", StringComparison.Ordinal)) + .Select(l => (Name: "www." + l.Domain, l.Domain)) + .Where(w => !domains.ContainsKey(w.Name) && !addresses.Owns(w.Name)) + .ToFrozenDictionary(w => w.Name, w => w.Domain, StringComparer.Ordinal); + + _served = new Served(domains, served.ToFrozenDictionary(l => l.PublicKey, l => l.Domain, StringComparer.Ordinal), www); if (previous != _served.Domains.Count) { @@ -86,10 +109,11 @@ public void Reload() #region Types /// - /// The served domains, by name and by the key of their lambda - swapped - /// together, so the two never tell different stories. + /// The served domains, by name and by the key of their lambda, and the + /// domain each www in front of one is sent on to - swapped together, so + /// they never tell different stories. /// - private sealed record Served(FrozenDictionary Domains, FrozenDictionary ByKey); + private sealed record Served(FrozenDictionary Domains, FrozenDictionary ByKey, FrozenDictionary Www); #endregion diff --git a/src/GenHTTP.Lambda/Services/Hosting/DomainRequest.cs b/src/GenHTTP.Lambda/Services/Hosting/DomainRequest.cs index f65d929f..b3e2459a 100644 --- a/src/GenHTTP.Lambda/Services/Hosting/DomainRequest.cs +++ b/src/GenHTTP.Lambda/Services/Hosting/DomainRequest.cs @@ -71,6 +71,13 @@ public abstract record LambdaHost(string Name); /// public sealed record CustomDomain(string Name, long LambdaId) : LambdaHost(Name); +/// +/// www. in front of a domain of its own that its owner entered +/// without it, which sends its visitors on to that domain. +/// +/// The domain it is in front of, the one the lambda answers at +public sealed record WwwOfDomain(string Name, string Domain) : LambdaHost(Name); + /// /// A subdomain of the hosting domain, which is the address of the lambda /// whose public key it is named after - if there is one. diff --git a/src/GenHTTP.Lambda/Services/Hosting/DomainRouter.cs b/src/GenHTTP.Lambda/Services/Hosting/DomainRouter.cs index 69ac2854..a6d498fe 100644 --- a/src/GenHTTP.Lambda/Services/Hosting/DomainRouter.cs +++ b/src/GenHTTP.Lambda/Services/Hosting/DomainRouter.cs @@ -36,6 +36,9 @@ public async ValueTask PrepareAsync(IServer server) public ValueTask HandleAsync(IRequest request) => request.ResolveHost(registry) switch { CustomDomain => domains.HandleAsync(request), + // sent on to the domain before it gets here; were it not, it is still + // somebody's site and never the platform + WwwOfDomain => domains.HandleAsync(request), HostedLambda => hosted.HandleAsync(request), HostingHome => home.HandleAsync(request), _ => platform.HandleAsync(request) diff --git a/src/GenHTTP.Lambda/Services/Hosting/IDomainRegistry.cs b/src/GenHTTP.Lambda/Services/Hosting/IDomainRegistry.cs index 74703a01..512bf48d 100644 --- a/src/GenHTTP.Lambda/Services/Hosting/IDomainRegistry.cs +++ b/src/GenHTTP.Lambda/Services/Hosting/IDomainRegistry.cs @@ -13,8 +13,9 @@ public interface IDomainRegistry int Count { get; } /// - /// What a host belongs to: a lambda's domain, a subdomain of the hosting - /// domain or that domain itself - or nothing, for the platform. + /// What a host belongs to: a lambda's domain, www in front of one, a + /// subdomain of the hosting domain or that domain itself - or nothing, + /// for the platform. /// /// A host in its normalized form, see LambdaHost? Find(string host); diff --git a/src/GenHTTP.Lambda/Services/Hosting/LambdaAddresses.cs b/src/GenHTTP.Lambda/Services/Hosting/LambdaAddresses.cs index 02787a32..0563414f 100644 --- a/src/GenHTTP.Lambda/Services/Hosting/LambdaAddresses.cs +++ b/src/GenHTTP.Lambda/Services/Hosting/LambdaAddresses.cs @@ -94,6 +94,13 @@ public string Of(string publicKey, LambdaTier tier, string? domain) /// public static bool Serves(LambdaTier tier, string? domain) => domain != null && tier == LambdaTier.Premium; + /// + /// Whether a host is the platform's or the hosting domain's - one of its + /// names, the domain itself or below it - and so never a lambda's own. + /// + /// The host in its normalized form, see + public bool Owns(string host) => _platform.Contains(host) || Resolve(host) != null; + /// /// What a host below the hosting domain names, or nothing for a host that /// is not below it - one of the platform's, or a lambda's own domain. diff --git a/src/GenHTTP.Lambda/Services/Hosting/LambdaRedirectConcern.cs b/src/GenHTTP.Lambda/Services/Hosting/LambdaRedirectConcern.cs index 9b07e870..064a3524 100644 --- a/src/GenHTTP.Lambda/Services/Hosting/LambdaRedirectConcern.cs +++ b/src/GenHTTP.Lambda/Services/Hosting/LambdaRedirectConcern.cs @@ -12,8 +12,9 @@ namespace GenHTTP.Lambda.Services.Hosting; /// /// Sends a request for an address a lambda no longer answers at on to the /// one it does, for good: its old path below /lambda/ on the platform, -/// and its subdomain of the hosting domain while it answers at a domain of -/// its own. +/// its subdomain of the hosting domain while it answers at a domain of its +/// own, and www. in front of that domain where its owner entered it +/// without. /// /// /// In front of everything else the server does, the request log and the @@ -30,6 +31,11 @@ namespace GenHTTP.Lambda.Services.Hosting; /// day: where a lambda answers changes when its owner gives it a domain or /// takes it away, and a browser that kept the old answer for good would send /// its visitors to a domain that is no longer the lambda's. +/// +/// The www of a domain is sent on rather than served as well, since a site +/// that answers at two names is two sites to a search engine. Its owner +/// decides which of the two it is: a domain entered with www is served there, +/// and nothing is sent to it from the name without. /// public sealed class LambdaRedirectConcern(IHandler content, IDomainRegistry registry, ILambdaAddresses addresses) : IConcern { @@ -53,6 +59,7 @@ public sealed class LambdaRedirectConcern(IHandler content, IDomainRegistry regi { null => FromPlatform(request), HostedLambda hosted => ToDomain(request, hosted), + WwwOfDomain www => $"https://{www.Domain}{request.Header.Path}{RequestOrigin.Query(request)}", _ => null }; diff --git a/tests/GenHTTP.Lambda.Tests/Hosting/DomainTests.cs b/tests/GenHTTP.Lambda.Tests/Hosting/DomainTests.cs index a2966bfd..7ed83997 100644 --- a/tests/GenHTTP.Lambda.Tests/Hosting/DomainTests.cs +++ b/tests/GenHTTP.Lambda.Tests/Hosting/DomainTests.cs @@ -95,6 +95,99 @@ public async Task ItsAddressSendsVisitorsOnToItsDomain() Assert.AreEqual($"https://{Domain}/start", old.Headers.Location?.ToString(), "the path it had on the platform goes straight there, too"); } + [TestMethod] + public async Task TheWwwOfItsDomainSendsVisitorsOnToTheDomain() + { + await using var fixture = await LambdaFixture.CreateAsync(); + + await ServeAsync(fixture, "shop"); + + using var page = await fixture.GetAsync("/start?ref=old", host: $"WWW.{Domain}:80"); + + Assert.AreEqual(HttpStatusCode.MovedPermanently, page.StatusCode, "one name for search engines to keep, the one the owner entered"); + Assert.AreEqual($"https://{Domain}/start?ref=old", page.Headers.Location?.ToString(), "with the path and the query it was asked for"); + + using var posted = await fixture.SendAsync(HttpMethod.Post, "/items", host: $"www.{Domain}"); + + Assert.AreEqual(HttpStatusCode.PermanentRedirect, posted.StatusCode, "followed with the same method and body"); + Assert.AreEqual($"https://{Domain}/items", posted.Headers.Location?.ToString()); + } + + [TestMethod] + public async Task ADomainEnteredWithWwwIsServedThereAndOnlyThere() + { + await using var fixture = await LambdaFixture.CreateAsync(); + + var lambda = await fixture.CreateLambdaAsync("shop"); + + await fixture.DeployAsync(lambda.PrivateKey, Code); + + fixture.ChangeTier(lambda.PrivateKey, LambdaTier.Premium); + + await SetAsync(fixture, lambda.PrivateKey, $"www.{Domain}"); + + using var www = await fixture.GetAsync("/start", host: $"www.{Domain}"); + + Assert.AreEqual("mine", await www.GetContentAsync(), "the owner chose the name with www"); + + using var bare = await fixture.GetAsync("/start", host: Domain); + + Assert.AreNotEqual(HttpStatusCode.MovedPermanently, bare.StatusCode, "nothing is sent on to a name the owner did not enter"); + Assert.AreNotEqual("mine", await bare.GetContentAsync()); + } + + [TestMethod] + public async Task AWwwThatIsADomainOfItsOwnIsNotSentOn() + { + await using var fixture = await LambdaFixture.CreateAsync(); + + await ServeAsync(fixture, "shop"); + + var other = await fixture.CreateLambdaAsync("other"); + + await fixture.DeployAsync(other.PrivateKey, """return Inline.Create().Get(() => "other");"""); + + fixture.ChangeTier(other.PrivateKey, LambdaTier.Premium); + + await SetAsync(fixture, other.PrivateKey, $"www.{Domain}"); + + using var response = await fixture.GetAsync("/", host: $"www.{Domain}"); + + Assert.AreEqual("other", await response.GetContentAsync()); + } + + [TestMethod] + public async Task TheWwwOfADomainNeverTakesThePlatformsName() + { + await using var fixture = await LambdaFixture.CreateAsync(o => o with { PublicUrl = "https://www.example.org" }); + + var lambda = await fixture.CreateLambdaAsync("apex"); + + await fixture.DeployAsync(lambda.PrivateKey, Code); + + fixture.ChangeTier(lambda.PrivateKey, LambdaTier.Premium); + + await SetAsync(fixture, lambda.PrivateKey, "example.org"); + + using var response = await fixture.GetAsync("/api/v1/system", host: "www.example.org"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "the platform answers at its own name, whoever has the domain below it"); + } + + [TestMethod] + public async Task TheWwwIsNoLongerSentOnOnceTheDomainIsGone() + { + await using var fixture = await LambdaFixture.CreateAsync(); + + var lambda = await ServeAsync(fixture, "shop"); + + using (await fixture.SendAsync(HttpMethod.Delete, $"/api/v1/lambdas/{lambda.PrivateKey}/domain")) { } + + using var response = await fixture.GetAsync("/api/v1/system", host: $"www.{Domain}"); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "any other host is the platform again"); + } + [TestMethod] public async Task ItsAddressServesItAgainOnceTheDomainIsGone() {