From 43dcc03da65372302216f2431eb75f08f200cf04 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 21 Sep 2026 11:44:58 +0200 Subject: [PATCH] Implemented phase 2 for the POC prototype in the Website service. --- Services/Website/README.md | 13 +- .../Website/Resources/Static/css/shared.css | 33 +++ .../Website/Resources/Static/css/sign-in.css | 107 ++++++++ .../Sources/App/Extensions/App+Build.swift | 17 +- .../Library/Internal/Components/Icon.swift | 5 +- .../Internal/Components/SiteHeader.swift | 15 +- .../Internal/Components/Unavailable.swift | 41 +++ .../Extensions/HTTPFields+Constants.swift | 8 + .../Internal/Extensions/String+Routes.swift | 68 +++++ .../Library/Internal/Pages/IndexPage.swift | 10 +- .../Library/Internal/Pages/ListingPage.swift | 65 +++-- .../Library/Internal/Pages/SearchPage.swift | 9 +- .../Library/Internal/Pages/ShopPage.swift | 37 +-- .../Library/Internal/Pages/SignInPage.swift | 181 ++++++++++++++ .../Library/Internal/Types/StaticFile.swift | 4 + .../Library/Internal/Types/Viewer.swift | 20 ++ .../Controllers/CatalogueController.swift | 21 +- .../Public/Controllers/RootController.swift | 73 ++++-- .../Controllers/SessionController.swift | 234 ++++++++++++++++++ .../Library/Public/Types/SessionStore.swift | 100 ++++++++ Services/Website/Tests/App/AppTests.swift | 30 +++ .../Internal/Components/SiteHeaderTests.swift | 2 + .../Extensions/ListingDisplayTests.swift | 24 ++ .../Internal/Pages/CataloguePagesTests.swift | 63 +++++ .../Internal/Types/StaticFileTests.swift | 1 + .../Cases/Internal/Types/ViewerTests.swift | 23 ++ .../Controllers/SessionControllerTests.swift | 233 +++++++++++++++++ .../Public/Types/SessionStoreTests.swift | 96 +++++++ 28 files changed, 1468 insertions(+), 65 deletions(-) create mode 100644 Services/Website/Resources/Static/css/sign-in.css create mode 100644 Services/Website/Sources/Library/Internal/Components/Unavailable.swift create mode 100644 Services/Website/Sources/Library/Internal/Extensions/HTTPFields+Constants.swift create mode 100644 Services/Website/Sources/Library/Internal/Pages/SignInPage.swift create mode 100644 Services/Website/Sources/Library/Public/Controllers/SessionController.swift create mode 100644 Services/Website/Sources/Library/Public/Types/SessionStore.swift create mode 100644 Services/Website/Tests/Library/Cases/Public/Controllers/SessionControllerTests.swift create mode 100644 Services/Website/Tests/Library/Cases/Public/Types/SessionStoreTests.swift diff --git a/Services/Website/README.md b/Services/Website/README.md index 811abdb..2677044 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -5,6 +5,7 @@ The **CCN** public website service — a [Hummingbird](https://github.com/hummin The service: - Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached), plus one prefixed route per non-default catalog language — `GET /nl` and so on (see [Language editions](#language-editions)). - Serves the public catalogue, rendered per request: search results at `GET /search` (filtered through its query parameters), a listing at `GET /listing/{slug}`, and a seller's shop at `GET /shop/{slug}`; an unknown slug gets the not-found page (see [Catalogue](#catalogue)). +- Serves the gate: the sign-in screen at `GET /sign-in`, the sign-in at `POST /sign-in`, and the sign-out at `POST /sign-out` (see [Sign-in](#sign-in)). - Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`) — asked for only where the answer is used, the bare landing route and the not-found page; pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers. - Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)). - Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable). @@ -48,6 +49,7 @@ LogRequestsMiddleware → FileMiddleware (serves Resources/Static) RootController (GET / → landing page, language negotiated; GET / → its other editions) CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug} → rendered per request) +SessionController (GET /sign-in, POST /sign-in, POST /sign-out) HealthController (GET /health → liveness, GET /health/ready → readiness) ``` @@ -64,10 +66,19 @@ The screens follow the **CCN marketplace** design canvas: - **Data**: `Persistence`'s `CatalogueRepository` reads the shops and listings; the in-memory backend is migrated and seeded with demo data at launch. - **Landing page**: rendered once and cached, so its listing rows are a snapshot taken at launch (`HomeListings`). - **Search and shop**: `SearchParameters` turns the query string into a `ListingFilter` and builds every link that changes it — filter pills, tabs, pagination — so the screens work as plain `GET` forms and links. Searches default to listings shipping from the EU. -- **Gated actions**: buy, offer, message, save, and follow link to the sign-in path with a `return` parameter; the sign-in screen itself is not built yet. +- **Gated actions**: signed out, buy, offer, message, save, and follow link to the sign-in screen with a `return` parameter; signed in, buy, offer, and message link to their own paths (not built yet), while save and follow render disabled. - **Covers**: listings have no photos yet, so `Cover` draws a placeholder from the listing's lettering and tone. - **Scripts**: `js/shared.js` only submits the sort select on change and opens the filter rail on older desktop browsers; everything works without it. +### Sign-in +> **The passkey ceremony is stubbed.** `POST /sign-in` trusts the email it is given, and signs in the seeded demo buyer when given none. Replace it with WebAuthn before anything real depends on it. + +- **Sessions**: `SessionStore` keeps them in memory, keyed by a random token in the `ccn_session` cookie (`HttpOnly`, `SameSite=Lax`, and `Secure` when the site is served over HTTPS). They end with the process. +- **One step signs in and joins**: an unknown email creates the account, named after the email's local part. +- **Return to the intent**: the `return` parameter is followed only when it is a rooted path on this site; anything else returns home. +- **Per-viewer pages**: the controllers read the viewer from the request and send those pages `Cache-Control: private, no-cache`. The landing page stays cached for signed-out visitors and is rendered per request for signed-in ones. +- **Sign-out** is a `POST` form in the header, so a link on another site cannot trigger it. The not-found page always renders signed out. + ### Page metadata Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags. diff --git a/Services/Website/Resources/Static/css/shared.css b/Services/Website/Resources/Static/css/shared.css index 173b4b4..a9b5250 100644 --- a/Services/Website/Resources/Static/css/shared.css +++ b/Services/Website/Resources/Static/css/shared.css @@ -558,6 +558,12 @@ input[type="radio"] { font-size: 15px; } +.button--xl { + height: 56px; + gap: 10px; + font-size: 16px; +} + .button--block { width: 100%; } @@ -688,6 +694,13 @@ input[type="radio"] { line-height: 1.2; } +.field__error { + color: var(--bad); + font-size: 12px; + font-weight: 600; + line-height: 1.45; +} + .field__hint { color: var(--muted); font-size: 12px; @@ -705,6 +718,12 @@ input[type="radio"] { font-size: 14px; } +.input--lg { + height: 50px; + padding: 0 14px; + font-size: 16px; +} + .input--sm { height: var(--control-sm); padding: 0 10px; @@ -1030,6 +1049,20 @@ textarea.input { color: #ffffff; } +/* The sign-out is a form, so it cannot be triggered by a link on another site. */ +.site-header__sign-out { + display: flex; + margin: 0; +} + +.site-header__sign-out button { + min-height: var(--control-md); + padding: 0 var(--space-1); + border: 0; + background: none; + cursor: pointer; +} + .site-header__account { display: inline-flex; align-items: center; diff --git a/Services/Website/Resources/Static/css/sign-in.css b/Services/Website/Resources/Static/css/sign-in.css new file mode 100644 index 0000000..8ded02b --- /dev/null +++ b/Services/Website/Resources/Static/css/sign-in.css @@ -0,0 +1,107 @@ +/* CCN — sign-in page. Drawn bare: no site header or footer. */ + +.gate { + display: flex; + flex: 1 0 auto; + flex-direction: column; + width: 100%; + max-width: 438px; + margin: 0 auto; +} + +.gate__bar { + position: relative; + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + height: 56px; + padding: 0 var(--space-1); +} + +.gate__close { + position: absolute; + left: var(--space-1); + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--control-md); + height: var(--control-md); + color: var(--ink); +} + +.gate__wordmark { + font-family: var(--font-lettering); + font-size: 24px; + letter-spacing: 0.04em; + line-height: 1; +} + +.gate__main { + display: flex; + flex-direction: column; + gap: var(--space-7); + padding: var(--space-5) var(--space-6) var(--space-6); +} + +.gate__intro { + display: flex; + flex-direction: column; + gap: 10px; +} + +.gate__intro .type-eyebrow { + letter-spacing: 0.1em; +} + +/* The heading stays at its phone size: the screen is a phone-width column at every breakpoint. */ +.gate__intro .type-h1 { + font-size: 36px; +} + +.gate__lead { + color: var(--ink-soft); +} + +.gate__form { + display: flex; + flex-direction: column; + gap: var(--space-7); +} + +.gate__saved { + display: flex; + align-items: center; + gap: var(--space-3); + height: var(--control-lg); + padding: 0 var(--space-3); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--card); + box-shadow: var(--shadow-sheet); + font-size: 14px; +} + +.gate__saved strong { + font-weight: 600; +} + +.gate__saved-note { + color: var(--muted); +} + +.gate__recover { + padding-top: var(--space-4); + font-size: 13px; + text-align: center; +} + +.gate__recover a { + color: var(--muted); +} + +@media (min-width: 600px) { + .gate { + padding-top: var(--space-10); + } +} diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index 9a63787..1d833f9 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -70,6 +70,7 @@ func application( siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin, logLevel: reader.logLevel, probe: Probe(fluent: fluent), + accounts: AccountRepository(fluent: fluent), repository: repository, listings: await homeListings( from: repository, @@ -182,6 +183,7 @@ private func logger( /// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them. /// - logLevel: the level the request-logging middleware logs at. /// - probe: the probe consulted by the `HealthController` readiness route. +/// - accounts: the repository the `SessionController` finds and creates accounts through. /// - repository: the repository the `CatalogueController` reads the shops and listings through. /// - listings: the listings the landing page shows in its rows. /// - Returns: the configured router. @@ -197,6 +199,7 @@ private func router( siteOrigin: String?, logLevel: Logger.Level, probe: Probe, + accounts: AccountRepository, repository: CatalogueRepository, listings: HomeListings ) -> Router { @@ -206,6 +209,7 @@ private func router( context: AppRequestContext.self, options: .autoGenerateHeadEndpoints ) + let sessions = SessionStore() router.addMiddleware { LogRequestsMiddleware(logLevel) @@ -235,10 +239,21 @@ private func router( assetVersion: assetVersion, siteOrigin: siteOrigin, analytics: analytics, - listings: listings + listings: listings, + sessions: sessions ) CatalogueController( repository: repository, + sessions: sessions, + assetVersion: assetVersion, + analytics: analytics + ) + SessionController( + sessions: sessions, + accounts: accounts, + catalogue: repository, + // Limited to HTTPS wherever the site is served over it; plain-HTTP local development keeps the cookie usable. + isSecure: httpsRedirect.trustForwardedProto && httpsRedirect.origin.hasPrefix("https://"), assetVersion: assetVersion, analytics: analytics ) diff --git a/Services/Website/Sources/Library/Internal/Components/Icon.swift b/Services/Website/Sources/Library/Internal/Components/Icon.swift index 5039ce8..ddda1b7 100644 --- a/Services/Website/Sources/Library/Internal/Components/Icon.swift +++ b/Services/Website/Sources/Library/Internal/Components/Icon.swift @@ -56,6 +56,8 @@ extension Icon { case bookmark /// A check mark. case check + /// A cross. + case close /// A page of text. case document /// Filter lines. @@ -85,6 +87,7 @@ extension Icon.Glyph { case .bell: #""# case .bookmark: #""# case .check: #""# + case .close: #""# case .document: #""# case .filter: #""# case .key: #""# @@ -100,7 +103,7 @@ extension Icon.Glyph { var strokeWidth: String { switch self { case .check: "3.5" - case .filter, .key, .search, .sort: "2.2" + case .close, .filter, .key, .search, .sort: "2.2" default: "2" } } diff --git a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift index 6abc898..b3eb8bc 100644 --- a/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift +++ b/Services/Website/Sources/Library/Internal/Components/SiteHeader.swift @@ -218,12 +218,25 @@ private extension SiteHeader { ) ) { span(.class("from-desktop")) { - viewer.displayName + viewer.firstName } span(.class("site-header__avatar")) { viewer.initials } } + // Not in the design, whose sign-out lives on the account screen the prototype leaves out. + form( + .class("site-header__sign-out"), + .action(.Route.signOut), + .method(.post) + ) { + button( + .class("site-header__text-link"), + .type(.submit) + ) { + "Sign out" + } + } } /// An icon link with a count badge, omitted at zero. diff --git a/Services/Website/Sources/Library/Internal/Components/Unavailable.swift b/Services/Website/Sources/Library/Internal/Components/Unavailable.swift new file mode 100644 index 0000000..0569394 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/Unavailable.swift @@ -0,0 +1,41 @@ +import Elementary + +/// An action the prototype shows but does not build: a button that is visibly, and audibly, unavailable. +struct Unavailable: HTML { + + // MARK: Properties + + /// The action's name. + let label: String + + /// A modifier class sizing or styling the button, or `nil` for none. + let modifier: String? + + // MARK: Initializers + + /// Creates an unavailable action. + /// - Parameters: + /// - label: the action's name. + /// - modifier: a modifier class sizing or styling the button, or `nil` (the default) for none. + init( + _ label: String, + modifier: String? = nil + ) { + self.label = label + self.modifier = modifier + } + + // MARK: Computed + + var body: some HTML { + button( + .class(["button", modifier].compactMap(\.self).joined(separator: " ")), + .type(.button), + .disabled, + .title("Not part of the prototype") + ) { + label + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Extensions/HTTPFields+Constants.swift b/Services/Website/Sources/Library/Internal/Extensions/HTTPFields+Constants.swift new file mode 100644 index 0000000..135667a --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Extensions/HTTPFields+Constants.swift @@ -0,0 +1,8 @@ +import HTTPTypes + +extension HTTPFields { + /// The headers of a page rendered for its viewer: kept out of shared caches, and revalidated before reuse. + static let perViewer: Self = [ + .cacheControl: "private, no-cache" + ] +} diff --git a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift index fa00708..1b158a5 100644 --- a/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift +++ b/Services/Website/Sources/Library/Internal/Extensions/String+Routes.swift @@ -33,11 +33,34 @@ extension String { static let series = "/series" /// The passkey sign-in screen. static let signIn = "/sign-in" + /// The path a sign-out is posted to. + static let signOut = "/sign-out" /// The terms of service. static let terms = "/terms" /// The want list. static let wants = "/wants" + /// The path buying a listing outright starts at. + /// - Parameter slug: the listing's URL slug. + static func buy( + _ slug: String + ) -> String { + "\(listing(slug))/buy" + } + + /// The path a gated action leads to: the action itself when signed in, else the sign-in screen, returning to where the action was taken. + /// - Parameters: + /// - path: the action's own path. + /// - origin: the path of the screen the action is taken on. + /// - viewer: the signed-in viewer, or `nil` when signed out. + static func gated( + _ path: String, + from origin: String, + viewer: Viewer? + ) -> String { + viewer == nil ? signIn(returningTo: origin) : path + } + /// The path of a listing's detail screen. /// - Parameter slug: the listing's URL slug. static func listing( @@ -46,6 +69,30 @@ extension String { "/listing/\(slug)" } + /// The path of a new message thread about a listing. + /// - Parameter slug: the listing's URL slug. + static func message( + listing slug: String + ) -> String { + "\(messages)/new?listing=\(slug)" + } + + /// The path of a new message thread with a shop. + /// - Parameter slug: the shop's URL slug. + static func message( + shop slug: String + ) -> String { + "\(messages)/new?shop=\(slug)" + } + + /// The path of the offer sheet for a listing. + /// - Parameter slug: the listing's URL slug. + static func offer( + _ slug: String + ) -> String { + "\(listing(slug))/offer" + } + /// The path of a shop's public screen. /// - Parameter slug: the shop's URL slug. static func shop( @@ -54,6 +101,27 @@ extension String { "/shop/\(slug)" } + /// The path to return to after signing in: the one asked for when it stays on this site, else the home screen. + /// + /// Only a rooted path is followed. Anything else — another origin, a scheme-relative `//host`, a backslash a browser reads as a slash — + /// would turn the sign-in into an open redirect. + /// - Parameter path: the path asked for, as the `return` parameter carried it. + static func safeReturn( + _ path: String? + ) -> String { + guard + let path, + path.hasPrefix("/"), + !path.hasPrefix("//"), + !path.contains("\\"), + !path.unicodeScalars.contains(where: { $0.properties.generalCategory == .control }) + else { + return home + } + + return path + } + /// The path of the sign-in screen, returning to the given path afterwards. /// - Parameter path: the path the visitor's gated action started from. static func signIn( diff --git a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift index fe1d0a5..342e88a 100644 --- a/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/IndexPage.swift @@ -24,6 +24,9 @@ struct IndexPage { /// The public origin the page derives its canonical URL and language alternates from, or `nil` to omit them. let siteOrigin: String? + /// The signed-in viewer, or `nil` when signed out. + let viewer: Viewer? + /// Resolves the page's text from the bundled String Catalog for the page's ``locale``. private let localize: Localize @@ -36,13 +39,16 @@ struct IndexPage { /// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them. /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. /// - listings: the listings the page shows in its rows; none by default. + /// - viewer: the signed-in viewer, or `nil` (the default) when signed out. init( locale: Locale, assetVersion: String? = nil, siteOrigin: String? = nil, analytics: Analytics? = nil, - listings: HomeListings = .init() + listings: HomeListings = .init(), + viewer: Viewer? = nil ) { + self.viewer = viewer self.analytics = analytics self.assetVersion = assetVersion self.listings = listings @@ -90,7 +96,7 @@ extension IndexPage: Page { // MARK: Properties var content: some HTML { - SiteLayout { + SiteLayout(viewer: viewer) { div(.class("page home")) { section(.class("hero")) { div(.class("hero__text")) { diff --git a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift index 6654d38..61ea2e2 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift @@ -18,20 +18,26 @@ struct ListingPage { /// Other listings from the same shop. let related: [Listing] + /// The signed-in viewer, or `nil` when signed out. + let viewer: Viewer? + // MARK: Initializers /// Creates the detail screen of a listing. /// - Parameters: /// - listing: the listing shown. /// - related: other listings from the same shop. + /// - viewer: the signed-in viewer, or `nil` (the default) when signed out. /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. init( listing: Listing, related: [Listing], + viewer: Viewer? = nil, assetVersion: String? = nil, analytics: Analytics? = nil ) { + self.viewer = viewer self.analytics = analytics self.assetVersion = assetVersion self.listing = listing @@ -48,7 +54,10 @@ extension ListingPage: Page { // MARK: Computed var content: some HTML { - SiteLayout(active: listing.kind) { + SiteLayout( + active: listing.kind, + viewer: viewer + ) { div(.class("page listing")) { breadcrumb div(.class("listing__body")) { @@ -201,48 +210,54 @@ private extension ListingPage { listing.acceptsOffers ? " · accepts offers" : "" } } - a( - .class("button button--sm"), - .href(signIn) - ) { - Icon(.bookmark, size: 16) - "Save" + if viewer == nil { + a( + .class("button button--sm"), + .href(.Route.signIn(returningTo: listing.path)) + ) { + Icon(.bookmark, size: 16) + "Save" + } + } else { + Unavailable("Save", modifier: "button--sm") } } div(.class("buy-box__actions")) { a( .class("button button--primary button--lg"), - .href(signIn) + .href(gated(.Route.buy(listing.slug))) ) { "Buy now" } if listing.acceptsOffers { a( .class("button button--outline button--lg"), - .href(signIn) + .href(gated(.Route.offer(listing.slug))) ) { "Make an offer" } } a( .class("button button--bare button--lg"), - .href(signIn) + .href(gated(.Route.message(listing: listing.slug))) ) { Icon(.message, size: 16) "Message seller" } } - p(.class("gate-note")) { - Icon(.lock, size: 16) - span { - "Buying, offers, saving and messages need a signed-in account. " - a( - .class("gate-note__link"), - .href(signIn) - ) { - "Sign in or join with a passkey" + if viewer == nil { + p(.class("gate-note")) { + Icon(.lock, size: 16) + span { + "Buying, offers, saving and messages need a signed-in account. " + a( + .class("gate-note__link"), + .href(.Route.signIn(returningTo: listing.path)) + ) { + "Sign in or join with a passkey" + } + " — browsing stays open to everyone." } - " — browsing stays open to everyone." } } } @@ -302,9 +317,13 @@ private extension ListingPage { } } - /// The sign-in screen, returning to this listing. - var signIn: String { - .Route.signIn(returningTo: listing.path) + // MARK: Methods + + /// The path a gated action on this listing leads to. + func gated( + _ path: String + ) -> String { + .Route.gated(path, from: listing.path, viewer: viewer) } } diff --git a/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift b/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift index aa0ffe5..f30a550 100644 --- a/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/SearchPage.swift @@ -30,6 +30,9 @@ struct SearchPage { /// The number of shops the matching listings come from. let shopCount: Int + /// The signed-in viewer, or `nil` when signed out. + let viewer: Viewer? + // MARK: Initializers /// Creates the search results screen for a search and the listings matching it. @@ -37,15 +40,18 @@ struct SearchPage { /// - parameters: the search the screen answers. /// - matches: every listing matching the search; the screen shows the page the parameters ask for. /// - publishers: every publisher a listing can be filtered by. + /// - viewer: the signed-in viewer, or `nil` (the default) when signed out. /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. init( parameters: SearchParameters, matches: [Listing], publishers: [String], + viewer: Viewer? = nil, assetVersion: String? = nil, analytics: Analytics? = nil ) { + self.viewer = viewer let slice = Pagination.slice( matches, page: parameters.page, @@ -82,7 +88,8 @@ extension SearchPage: Page { var content: some HTML { SiteLayout( active: formats.count == 1 ? formats.first : nil, - query: parameters.value(.query) + query: parameters.value(.query), + viewer: viewer ) { div(.class("page search")) { div(.class("search__head")) { diff --git a/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift b/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift index b42a336..4b8d017 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ShopPage.swift @@ -27,6 +27,9 @@ struct ShopPage { /// The shop shown. let shop: Shop + /// The signed-in viewer, or `nil` when signed out. + let viewer: Viewer? + // MARK: Initializers /// Creates the public screen of a shop. @@ -35,6 +38,7 @@ struct ShopPage { /// - inventory: every listing of the shop, counted per format on the tabs. /// - matches: the shop's listings matching the parameters; the screen shows the page they ask for. /// - parameters: the tab, quick filters, and sort order applied. + /// - viewer: the signed-in viewer, or `nil` (the default) when signed out. /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. init( @@ -42,9 +46,11 @@ struct ShopPage { inventory: [Listing], matches: [Listing], parameters: SearchParameters, + viewer: Viewer? = nil, assetVersion: String? = nil, analytics: Analytics? = nil ) { + self.viewer = viewer let slice = Pagination.slice( matches, page: parameters.page, @@ -78,7 +84,7 @@ extension ShopPage: Page { // MARK: Computed var content: some HTML { - SiteLayout { + SiteLayout(viewer: viewer) { div(.class("page shop")) { section(.class("shop__head")) { span(.class("monogram monogram--xl")) { @@ -116,20 +122,24 @@ extension ShopPage: Page { div(.class("shop__actions")) { a( .class("button button--primary"), - .href(signIn) + .href(.Route.gated(.Route.message(shop: shop.slug), from: shop.path, viewer: viewer)) ) { Icon(.message, size: 16) "Message shop" } - a( - .class("button button--outline"), - .href(signIn) - ) { - "Follow shop" - } - span(.class("type-small shop__gate")) { - Icon(.key, size: 13) - "Both need a passkey sign-in" + if viewer == nil { + a( + .class("button button--outline"), + .href(.Route.signIn(returningTo: shop.path)) + ) { + "Follow shop" + } + span(.class("type-small shop__gate")) { + Icon(.key, size: 13) + "Both need a passkey sign-in" + } + } else { + Unavailable("Follow shop", modifier: "button--outline") } } } @@ -286,11 +296,6 @@ private extension ShopPage { // MARK: Computed - /// The sign-in screen, returning to this shop. - var signIn: String { - .Route.signIn(returningTo: shop.path) - } - var stats: [(value: String, label: String)] { [ ("\(counts.values.reduce(0, +))", "listings"), diff --git a/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift new file mode 100644 index 0000000..75f1f40 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift @@ -0,0 +1,181 @@ +import Elementary +import Foundation +import Infrastructure +import Persistence + +/// The one passkey screen, which both signs in and joins. Drawn bare, without the site's navigation: its only way out is back. +/// +/// The prototype stubs the passkey ceremony — see ``SessionController``. +struct SignInPage { + + // MARK: Properties + + let analytics: Analytics? + let assetVersion: String? + let locale: Locale + + /// What went wrong with the last attempt, or `nil` when nothing did. + let error: String? + + /// What the visitor was about to do, or `nil` when they came here directly. + let intent: String? + + /// The path the visitor returns to, signed in or not. + let returnPath: String + + // MARK: Initializers + + /// Creates the sign-in screen. + /// - Parameters: + /// - returnPath: the path the visitor returns to, signed in or not. + /// - intent: what the visitor was about to do, or `nil` (the default) when they came here directly. + /// - error: what went wrong with the last attempt, or `nil` (the default) when nothing did. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it. + init( + returnPath: String, + intent: String? = nil, + error: String? = nil, + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.analytics = analytics + self.assetVersion = assetVersion + self.error = error + self.intent = intent + self.locale = .init(identifier: "en") + self.returnPath = returnPath + } + +} + +// MARK: - Page + +extension SignInPage: Page { + + // MARK: Computed + + var content: some HTML { + div(.class("gate")) { + header(.class("gate__bar")) { + a( + .class("gate__close"), + .href(returnPath), + .custom(name: "aria-label", value: "Close and go back") + ) { + Icon(.close) + } + span(.class("gate__wordmark")) { + "CCN" + } + } + main(.class("gate__main")) { + div(.class("gate__intro")) { + if let intent { + p(.class("type-eyebrow")) { + intent + } + } + h1(.class("type-h1")) { + "Sign in or join" + } + p(.class("gate__lead")) { + "No password. Your device confirms it's you." + } + } + form( + .class("gate__form"), + .action(.Route.signIn), + .method(.post) + ) { + input( + .type(.hidden), + .name("return"), + .value(returnPath) + ) + button( + .class("button button--primary button--xl button--block"), + .type(.submit) + ) { + Icon(.key, size: 18) + "Continue with passkey" + } + div(.class("field")) { + label( + .class("field__label"), + .for("email") + ) { + "New here, or on a new device? Enter your email" + } + input( + .class("input input--lg"), + .id("email"), + .type(.email), + .name("email"), + .placeholder("you@example.com"), + .custom(name: "autocomplete", value: "username webauthn") + ) + .attributes( + .custom(name: "aria-invalid", value: "true"), + .custom(name: "aria-describedby", value: "email-error"), + when: error != nil + ) + if let error { + p( + .class("field__error"), + .id("email-error") + ) { + error + } + } + div(.class("gate__saved")) { + Icon(.key, size: 16) + span { + strong { + AccountRepository.demoEmail + } + span(.class("gate__saved-note")) { + " · saved passkey" + } + } + } + p(.class("field__hint")) { + "Unknown email? We create your account and passkey in the same step — nothing else to fill in." + } + } + } + p(.class("notice notice--warn")) { + "Prototype: passkeys are stubbed. Continue signs you in as the saved demo account; an email signs in as that account, or creates it." + } + p(.class("gate__recover")) { + a(.href(.Route.recover)) { + "Lost access to your passkeys?" + } + } + } + } + } + + /// Kept out of search results: the screen is a step, not a destination. + @HTMLBuilder + var metadata: some HTML { + meta( + .name("robots"), + .content("noindex") + ) + siteMetadata + } + + var scripts: [any Asset] { + [StaticFile.shared] + } + + var stylesheets: [any Asset] { + [StaticFile.shared, StaticFile.signIn] + } + + var title: String { + "Sign in or join — CCN" + } + +} diff --git a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift index 31b3ef4..70a7cac 100644 --- a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift @@ -64,6 +64,7 @@ extension StaticFile { .search, .shared, .shop, + .signIn, .site, .sitemap, ] @@ -149,6 +150,9 @@ extension StaticFile { /// The `css/shop.css` stylesheet for the shop page. static let shop = Self("shop", as: .css) + /// The `css/sign-in.css` stylesheet for the sign-in page. + static let signIn = Self("sign-in", as: .css) + /// The `site.webmanifest` web application manifest. static let site = Self("site", as: .webmanifest) diff --git a/Services/Website/Sources/Library/Internal/Types/Viewer.swift b/Services/Website/Sources/Library/Internal/Types/Viewer.swift index 04fda7c..5192046 100644 --- a/Services/Website/Sources/Library/Internal/Types/Viewer.swift +++ b/Services/Website/Sources/Library/Internal/Types/Viewer.swift @@ -1,3 +1,5 @@ +import Persistence + /// The signed-in person a page is rendered for. struct Viewer: Sendable { @@ -35,8 +37,26 @@ struct Viewer: Sendable { extension Viewer { + // MARK: Initializers + + /// Creates the viewer an account signs in as. + /// - Parameter user: the signed-in account. + init( + _ user: User + ) { + self.init(displayName: user.displayName) + } + // MARK: Computed + /// The first word of the display name, shown beside the avatar. + var firstName: String { + displayName + .split(separator: " ") + .first + .map(String.init) ?? displayName + } + /// The uppercased initials of the first two words of the display name. var initials: String { displayName diff --git a/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift b/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift index 080d07f..cfcfb29 100644 --- a/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift @@ -21,21 +21,27 @@ public struct CatalogueController { /// The repository the shops and listings are read through. private let repository: CatalogueRepository + /// The store the signed-in viewer is read from, or `nil` to render every page signed out. + private let sessions: SessionStore? + // MARK: Initializers /// Creates a catalogue controller. /// - Parameters: /// - repository: the repository the shops and listings are read through. + /// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to render every page signed out. /// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned. /// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it. public init( repository: CatalogueRepository, + sessions: SessionStore? = nil, assetVersion: String? = nil, analytics: Analytics? = nil ) { self.analytics = analytics self.assetVersion = assetVersion self.repository = repository + self.sessions = sessions } } @@ -93,10 +99,13 @@ private extension CatalogueController { .filter { $0.slug != listing.slug } .prefix(Self.relatedCount) - return .init { [analytics, assetVersion] in + let viewer = await sessions?.viewer(for: request) + + return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in ListingPage( listing: listing, related: Array(related), + viewer: viewer, assetVersion: assetVersion, analytics: analytics ) @@ -113,11 +122,14 @@ private extension CatalogueController { let matches = try await repository.listings(matching: parameters.filter) let publishers = try await repository.publishers() - return .init { [analytics, assetVersion] in + let viewer = await sessions?.viewer(for: request) + + return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in SearchPage( parameters: parameters, matches: matches, publishers: publishers, + viewer: viewer, assetVersion: assetVersion, analytics: analytics ) @@ -138,12 +150,15 @@ private extension CatalogueController { let inventory = try await repository.listings(matching: .init(shop: shop.slug)) let matches = try await repository.listings(matching: parameters.filter(inShop: shop.slug)) - return .init { [analytics, assetVersion] in + let viewer = await sessions?.viewer(for: request) + + return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in ShopPage( shop: shop, inventory: inventory, matches: matches, parameters: parameters, + viewer: viewer, assetVersion: assetVersion, analytics: analytics ) diff --git a/Services/Website/Sources/Library/Public/Controllers/RootController.swift b/Services/Website/Sources/Library/Public/Controllers/RootController.swift index 4a78ad0..56241ab 100644 --- a/Services/Website/Sources/Library/Public/Controllers/RootController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/RootController.swift @@ -1,5 +1,6 @@ import Foundation import Hummingbird +import HummingbirdElementary import Infrastructure import Localization @@ -18,6 +19,12 @@ public struct RootController { // MARK: Properties + /// What a page rendered per request is built from, the same as the cached ones. + private let page: @Sendable (Locale, Viewer?) -> IndexPage + + /// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page. + private let sessions: SessionStore? + /// Negotiates the language the bare route answers in. private let negotiate: Negotiate @@ -32,26 +39,35 @@ public struct RootController { /// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them. /// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it. /// - listings: the listings the landing page shows in its rows; none by default. + /// - sessions: the store the signed-in viewer is read from, or `nil` (the default) to serve everyone the cached page. public init( assetVersion: String? = nil, siteOrigin: String? = nil, analytics: Analytics? = nil, - listings: HomeListings = .init() + listings: HomeListings = .init(), + sessions: SessionStore? = nil ) { + let page: @Sendable (Locale, Viewer?) -> IndexPage = { + .init( + locale: $0, + assetVersion: assetVersion, + siteOrigin: siteOrigin, + analytics: analytics, + listings: listings, + viewer: $1 + ) + } + self.negotiate = .init(bundle: .module) + self.page = page + self.sessions = sessions // The bare route negotiates, so the pages declare `Vary: Accept-Language`; the prefixed editions share the cache // and carry it too. self.responses = .init( bundle: .module, variesOnAcceptLanguage: true ) { - IndexPage( - locale: $0, - assetVersion: assetVersion, - siteOrigin: siteOrigin, - analytics: analytics, - listings: listings - ) + page($0, nil) } } @@ -103,10 +119,11 @@ private extension RootController { func index( request: Request, context: Context - ) -> Response { - responses.response( - for: negotiate(for: request), - request: request + ) async -> Response { + await respond( + in: negotiate(for: request), + request: request, + context: context ) } @@ -118,13 +135,37 @@ private extension RootController { /// - Returns: the handler answering requests for that edition of the page. func index( in language: Language - ) -> @Sendable (Request, Context) -> Response { - { request, _ in - responses.response( - for: language.identifier, + ) -> @Sendable (Request, Context) async -> Response { + { request, context in + await respond( + in: language.identifier, + request: request, + context: context + ) + } + } + + /// Answers with the landing page in the given language: the cached page signed out, and one rendered around the viewer signed in. + /// - Parameters: + /// - language: the identifier of the language to answer in. + /// - request: the incoming request. + /// - context: the context the request is resolved against. + func respond( + in language: String, + request: Request, + context: Context + ) async -> Response { + guard let viewer = await sessions?.viewer(for: request) else { + return responses.response( + for: language, request: request ) } + + return HTMLResponse(additionalHeaders: .perViewer) { [page] in + page(.init(identifier: language), viewer) + } + .response(from: request, context: context) } } diff --git a/Services/Website/Sources/Library/Public/Controllers/SessionController.swift b/Services/Website/Sources/Library/Public/Controllers/SessionController.swift new file mode 100644 index 0000000..69ea216 --- /dev/null +++ b/Services/Website/Sources/Library/Public/Controllers/SessionController.swift @@ -0,0 +1,234 @@ +import Hummingbird +import HummingbirdElementary +import Infrastructure +import Persistence +import Utility + +/// Serves the gate: the sign-in screen, the sign-in itself, and the sign-out. +/// +/// The passkey ceremony is **stubbed**: the prototype trusts whatever email it is given, and signs in the demo account when given none. +/// Everything around the stub is real — the session, its cookie, and the return to whatever the visitor was about to do. +/// +/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to. +public struct SessionController { + + // MARK: Properties + + /// The repository accounts are found and created through. + private let accounts: AccountRepository + + /// The analytics tracker the page embeds, or `nil` to omit it. + private let analytics: Analytics? + + /// The version token appended to the page's asset URLs, or `nil` to leave them unversioned. + private let assetVersion: String? + + /// The repository the visitor's intent is described from. + private let catalogue: CatalogueRepository + + /// Whether the session cookie is limited to HTTPS. + private let isSecure: Bool + + /// The store sessions are started and ended in. + private let sessions: SessionStore + + // MARK: Initializers + + /// Creates a session controller. + /// - Parameters: + /// - sessions: the store sessions are started and ended in. + /// - accounts: the repository accounts are found and created through. + /// - catalogue: the repository the visitor's intent is described from. + /// - isSecure: whether the session cookie is limited to HTTPS; `false` by default, for plain-HTTP local development. + /// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned. + /// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it. + public init( + sessions: SessionStore, + accounts: AccountRepository, + catalogue: CatalogueRepository, + isSecure: Bool = false, + assetVersion: String? = nil, + analytics: Analytics? = nil + ) { + self.accounts = accounts + self.analytics = analytics + self.assetVersion = assetVersion + self.catalogue = catalogue + self.isSecure = isSecure + self.sessions = sessions + } + +} + +// MARK: - RouterController + +extension SessionController: RouterController { + + // MARK: Properties + + public var routes: RouteCollection { + let routes = RouteCollection(context: Context.self) + + routes.get( + .Session.signIn, + use: show + ) + routes.post( + .Session.signIn, + use: signIn + ) + routes.post( + .Session.signOut, + use: signOut + ) + + return routes + } + +} + +// MARK: - Helpers + +private extension SessionController { + + // MARK: Structs + + /// The fields the sign-in form posts. + struct SignInForm: Decodable { + /// The email typed in, or `nil` when the saved passkey is used. + let email: String? + /// The path to return to. + let `return`: String? + } + + // MARK: Methods + + /// What the visitor was about to do, read off the path they return to, or `nil` when it names no listing or shop. + func intent( + returningTo path: String + ) async throws -> String? { + let segments = path + .prefix { $0 != "?" } + .split(separator: "/") + .map(String.init) + + guard segments.count >= 2 else { + return nil + } + + switch segments[0] { + case "listing": + return try await catalogue + .listing(slug: segments[1]) + .map { "To buy, offer on or ask about \($0.title)" } + case "shop": + return try await catalogue + .shop(slug: segments[1]) + .map { "To message or follow \($0.name)" } + default: + return nil + } + } + + /// Renders the sign-in screen, never stored: it reflects the visitor's intent and their last attempt. + func page( + returnPath: String, + error: String? = nil, + status: HTTPResponse.Status = .ok, + request: Request, + context: Context + ) async throws -> Response { + let intent = try await intent(returningTo: returnPath) + + return HTMLResponse( + status: status, + additionalHeaders: [.cacheControl: "no-store"] + ) { [analytics, assetVersion] in + SignInPage( + returnPath: returnPath, + intent: intent, + error: error, + assetVersion: assetVersion, + analytics: analytics + ) + } + .response(from: request, context: context) + } + + /// Handles a request for the sign-in screen; a visitor already signed in is sent straight back. + @Sendable + func show( + request: Request, + context: Context + ) async throws -> Response { + let returnPath = String.Route.safeReturn(request.uri.queryParameters.get("return")) + + guard await sessions.viewer(for: request) == nil else { + return .redirect(to: returnPath, type: .found) + } + + return try await page( + returnPath: returnPath, + request: request, + context: context + ) + } + + /// Handles a sign-in: starts a session for the account — the demo one when no email is given — and returns to the visitor's intent. + @Sendable + func signIn( + request: Request, + context: Context + ) async throws -> Response { + let form = try await URLEncodedFormDecoder().decode(SignInForm.self, from: request, context: context) + let returnPath = String.Route.safeReturn(form.return) + let typed = form.email?.trimmingCharacters(in: .whitespaces) ?? "" + + guard let email = typed.isEmpty ? AccountRepository.demoEmail : NormalizeEmail()(typed) else { + return try await page( + returnPath: returnPath, + error: "That doesn't look like an email address.", + status: .unprocessableContent, + request: request, + context: context + ) + } + + let token = await sessions.start(for: try await accounts.account(email: email)) + var response = Response.redirect(to: returnPath, type: .normal) + + response.setCookie(SessionStore.cookie(token, isSecure: isSecure)) + + return response + } + + /// Handles a sign-out: ends the session, expires its cookie, and returns home. + @Sendable + func signOut( + request: Request, + context: Context + ) async throws -> Response { + if let token = SessionStore.token(in: request) { + await sessions.end(token) + } + + var response = Response.redirect(to: .Route.home, type: .normal) + + response.setCookie(SessionStore.cookie("", isSecure: isSecure)) + + return response + } + +} + +// MARK: - Constants + +private extension RouterPath { + /// A namespace for the ``SessionController`` route paths. + enum Session { + /// The path of the sign-in screen, and of the sign-in posted from it. + static let signIn: RouterPath = .init(.Route.signIn) + /// The path a sign-out is posted to. + static let signOut: RouterPath = .init(.Route.signOut) + } +} diff --git a/Services/Website/Sources/Library/Public/Types/SessionStore.swift b/Services/Website/Sources/Library/Public/Types/SessionStore.swift new file mode 100644 index 0000000..8e8bd2e --- /dev/null +++ b/Services/Website/Sources/Library/Public/Types/SessionStore.swift @@ -0,0 +1,100 @@ +import Foundation +import Hummingbird +import Persistence + +/// The signed-in sessions, kept in memory: a token held in a cookie, naming an account. +/// +/// Prototype scaffolding. Sessions end with the process — as the in-memory database does — and move to storage shared between instances +/// together with real passkeys. +public actor SessionStore { + + // MARK: Properties + + /// The accounts signed in, by session token. + private var users: [String: User] = [:] + + // MARK: Initializers + + /// Creates an empty session store. + public init() {} + + // MARK: Methods + + /// Ends the session with the given token, if there is one. + public func end( + _ token: String + ) { + users[token] = nil + } + + /// Starts a session for an account. + /// - Parameter user: the account signing in. + /// - Returns: the session's token, unguessable and unique. + public func start( + for user: User + ) -> String { + let token = "\(UUID().uuidString)\(UUID().uuidString)" + .replacingOccurrences(of: "-", with: "") + .lowercased() + + users[token] = user + + return token + } + + /// The account the session with the given token belongs to, or `nil` when there is no such session. + public func user( + for token: String + ) -> User? { + users[token] + } + +} + +// MARK: - Extensions + +extension SessionStore { + + // MARK: Constants + + /// The name of the cookie holding the session token. + static let cookieName = "ccn_session" + + // MARK: Methods + + /// The cookie handing a session token to the browser: hidden from scripts, and sent along with same-site requests and top-level navigation alone. + /// - Parameters: + /// - token: the session's token; an empty one expires the cookie. + /// - isSecure: whether the cookie is limited to HTTPS. + nonisolated static func cookie( + _ token: String, + isSecure: Bool + ) -> Cookie { + .init( + name: cookieName, + value: token, + maxAge: token.isEmpty ? 0 : nil, + path: "/", + secure: isSecure, + httpOnly: true, + sameSite: .lax + ) + } + + /// The session token the request carries, or `nil` when it carries none. + nonisolated static func token( + in request: Request + ) -> String? { + request.cookies[cookieName]?.value + } + + /// The signed-in viewer the request comes from, or `nil` when it carries no live session. + func viewer( + for request: Request + ) -> Viewer? { + Self.token(in: request) + .flatMap(user) + .map(Viewer.init) + } + +} diff --git a/Services/Website/Tests/App/AppTests.swift b/Services/Website/Tests/App/AppTests.swift index 563baa1..6dc603b 100644 --- a/Services/Website/Tests/App/AppTests.swift +++ b/Services/Website/Tests/App/AppTests.swift @@ -97,6 +97,36 @@ struct AppTests { } } + @Test + func `sign-in to start a session the other pages recognize`() async throws { + try await app( + staticFilesPath: staticFilesPath + ).test(.router) { client in + let cookie = try await client.execute( + uri: "/sign-in", + method: .post, + headers: [.contentType: "application/x-www-form-urlencoded"], + body: ByteBuffer(string: "return=/listing/nightward-1-cgc-9-8") + ) { response in + #expect(response.status == .seeOther) + #expect(response.headers[.location] == "/listing/nightward-1-cgc-9-8") + + return String(try #require(response.headers[.setCookie]).prefix { $0 != ";" }) + } + + try await client.execute( + uri: "/listing/nightward-1-cgc-9-8", + method: .get, + headers: [.cookie: cookie] + ) { response in + let body = String(buffer: response.body) + + #expect(body.contains(#"aria-label="Account of Mara Visser""#)) + #expect(body.contains(#"href="/listing/nightward-1-cgc-9-8/offer""#)) + } + } + } + @Test func `error page to be served for an unknown listing`() async throws { try await app( diff --git a/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift b/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift index 441abf1..b1fef3e 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Components/SiteHeaderTests.swift @@ -33,6 +33,8 @@ struct SiteHeaderTests { #expect(html.contains(#"aria-label="Messages, 3 unread""#)) #expect(html.contains(#"href="/sell""#)) #expect(html.contains(">MV<")) + #expect(html.contains(">Mara<")) + #expect(html.contains(#"action="/sign-out" method="post""#)) #expect(!html.contains(#"href="/sign-in""#)) } diff --git a/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift b/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift index fdb8daf..99c3532 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Extensions/ListingDisplayTests.swift @@ -74,6 +74,30 @@ struct ListingDisplayTests { #expect(cents.euros == price) } + @Test(arguments: zip( + ["/listing/a?x=1", "/", "//evil.example", "https://evil.example", "/\\evil.example", "listing/a", "", "/a\nb"], + ["/listing/a?x=1", "/", "/", "/", "/", "/", "/", "/"] + )) + func `follows a return path only within the site`( + _ path: String, + expects safe: String + ) { + #expect(String.Route.safeReturn(path) == safe) + } + + @Test + func `returns home without a return path`() { + #expect(String.Route.safeReturn(nil) == "/") + } + + @Test + func `leads a gated action to itself signed in, and to the sign-in signed out`() { + let offer = String.Route.offer("a") + + #expect(String.Route.gated(offer, from: "/listing/a", viewer: .init(displayName: "Mara")) == "/listing/a/offer") + #expect(String.Route.gated(offer, from: "/listing/a", viewer: nil) == "/sign-in?return=/listing/a") + } + @Test func `returns to the gated action's path after sign-in`() { #expect(String.Route.signIn(returningTo: "/shop/a?keys=1&page=2") == "/sign-in?return=/shop/a%3Fkeys%3D1%26page%3D2") diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift index 37281ca..7d15dc8 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift @@ -121,8 +121,71 @@ struct CataloguePagesTests { #expect(html.contains("
Condition
")) } + @Test + func `listing page opens its actions to a signed-in viewer`() { + let html = ListingPage( + listing: .stub(), + related: [], + viewer: .init(displayName: "Mara Visser") + ).render() + + #expect(html.contains(#"href="/listing/nightward-1/buy""#)) + #expect(html.contains(#"href="/listing/nightward-1/offer""#)) + #expect(html.contains(#"href="/messages/new?listing=nightward-1""#)) + #expect(html.contains(#"aria-label="Account of Mara Visser""#)) + #expect(!html.contains("gate-note")) + #expect(html.contains(#"disabled title="Not part of the prototype">Save"#)) + } + // MARK: ShopPage tests + @Test + func `shop page opens its actions to a signed-in viewer`() { + let html = ShopPage( + shop: .stub(), + inventory: [], + matches: [], + parameters: .init(), + viewer: .init(displayName: "Mara Visser") + ).render() + + #expect(html.contains(#"href="/messages/new?shop=kestrel-row-comics""#)) + #expect(!html.contains("Both need a passkey sign-in")) + #expect(!html.contains("/sign-in?return=")) + } + + // MARK: SignInPage tests + + @Test + func `sign-in page is bare, states the intent, and carries the return path`() { + let html = SignInPage( + returnPath: "/listing/nightward-1", + intent: "To buy Nightward #1" + ).render() + + #expect(html.contains("To buy Nightward #1")) + #expect(html.contains(#"action="/sign-in" method="post""#)) + #expect(html.contains(#"name="return" value="/listing/nightward-1""#)) + #expect(html.contains(#"href="/listing/nightward-1" aria-label="Close and go back""#)) + #expect(html.contains(#"content="noindex""#)) + #expect(html.contains("/css/sign-in.css")) + #expect(!html.contains("site-header")) + #expect(!html.contains("site-footer")) + #expect(!html.contains("aria-invalid")) + #expect(!html.contains("style=")) + } + + @Test + func `sign-in page ties an error to the email field`() { + let html = SignInPage( + returnPath: "/", + error: "That doesn't look like an email address." + ).render() + + #expect(html.contains(#"aria-invalid="true" aria-describedby="email-error""#)) + #expect(html.contains(#"id="email-error">That doesn't look like an email address.

"#)) + } + @Test func `shop page counts its tabs from the whole inventory and lists the matches`() { let inventory = [ diff --git a/Services/Website/Tests/Library/Cases/Internal/Types/StaticFileTests.swift b/Services/Website/Tests/Library/Cases/Internal/Types/StaticFileTests.swift index 213db36..d0c17f1 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Types/StaticFileTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Types/StaticFileTests.swift @@ -108,6 +108,7 @@ private extension StaticFileTests { (.search, ["css/search.css"]), (.shared, ["css/shared.css", "js/shared.js"]), (.shop, ["css/shop.css"]), + (.signIn, ["css/sign-in.css"]), (.site, ["site.webmanifest"]), (.sitemap, ["sitemap.xml"]), ] diff --git a/Services/Website/Tests/Library/Cases/Internal/Types/ViewerTests.swift b/Services/Website/Tests/Library/Cases/Internal/Types/ViewerTests.swift index e978df4..c78ceec 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Types/ViewerTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Types/ViewerTests.swift @@ -1,3 +1,4 @@ +import Persistence import Testing @testable import WebsiteLibrary @@ -8,6 +9,17 @@ import Testing ) struct ViewerTests { + // MARK: Initializers tests + + @Test + func `signs in as the account's display name`() { + let viewer = Viewer(User(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser")) + + #expect(viewer.displayName == "Mara Visser") + #expect(viewer.newWantMatches == 0) + #expect(viewer.unreadMessages == 0) + } + // MARK: Computed tests @Test(arguments: zip( @@ -21,4 +33,15 @@ struct ViewerTests { #expect(Viewer(displayName: displayName).initials == initials) } + @Test(arguments: zip( + ["Mara Visser", "Mara", ""], + ["Mara", "Mara", ""] + )) + func `first name`( + of displayName: String, + expects firstName: String + ) { + #expect(Viewer(displayName: displayName).firstName == firstName) + } + } diff --git a/Services/Website/Tests/Library/Cases/Public/Controllers/SessionControllerTests.swift b/Services/Website/Tests/Library/Cases/Public/Controllers/SessionControllerTests.swift new file mode 100644 index 0000000..6056aab --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Public/Controllers/SessionControllerTests.swift @@ -0,0 +1,233 @@ +import Hummingbird +import HummingbirdTesting +import Logging +import NIOCore +import Persistence +import Testing + +@testable import WebsiteLibrary + +@Suite( + "SessionController controller", + .tags(.controller) +) +struct SessionControllerTests { + + // MARK: Functional tests + + @Test + func `serves the sign-in screen, never stored, stating what the visitor was about to do`() async throws { + try await withApp { client in + try await client.execute( + uri: "/sign-in?return=/listing/nightward-1-cgc-9-8", + method: .get + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .ok) + #expect(response.headers[.cacheControl] == "no-store") + #expect(body.contains("To buy, offer on or ask about Nightward #1")) + #expect(body.contains(#"name="return" value="/listing/nightward-1-cgc-9-8""#)) + } + } + } + + @Test + func `signs in the demo account without an email and returns to the intent, signed in`() async throws { + try await withApp { client in + let cookie = try await signIn(client, body: "return=/shop/kestrel-row-comics") { response in + #expect(response.status == .seeOther) + #expect(response.headers[.location] == "/shop/kestrel-row-comics") + } + + try await client.execute( + uri: "/shop/kestrel-row-comics", + method: .get, + headers: [.cookie: cookie] + ) { response in + let body = String(buffer: response.body) + + #expect(response.headers[.cacheControl] == "private, no-cache") + #expect(body.contains(#"aria-label="Account of Mara Visser""#)) + #expect(!body.contains("Both need a passkey sign-in")) + } + } + } + + @Test + func `joins an unknown email, normalized, in the same step`() async throws { + try await withApp { client in + let cookie = try await signIn(client, body: "email=+Sam%40Example.COM+&return=/") + + try await client.execute( + uri: "/search", + method: .get, + headers: [.cookie: cookie] + ) { response in + #expect(String(buffer: response.body).contains(#"aria-label="Account of sam""#)) + } + } + } + + @Test + func `turns down what is not an email, and starts no session`() async throws { + try await withApp { client in + try await client.execute( + uri: "/sign-in", + method: .post, + headers: [.contentType: "application/x-www-form-urlencoded"], + body: ByteBuffer(string: "email=nope&return=/search") + ) { response in + let body = String(buffer: response.body) + + #expect(response.status == .unprocessableContent) + #expect(response.headers[.setCookie] == nil) + #expect(body.contains(#"aria-invalid="true""#)) + #expect(body.contains(#"name="return" value="/search""#)) + } + } + } + + @Test(arguments: ["//evil.example/x", "https://evil.example", "evil"]) + func `returns home rather than off the site`( + returnPath: String + ) async throws { + try await withApp { client in + _ = try await signIn(client, body: "return=\(returnPath)") { response in + #expect(response.headers[.location] == "/") + } + } + } + + @Test + func `sends a signed-in visitor straight back from the sign-in screen`() async throws { + try await withApp { client in + let cookie = try await signIn(client, body: "return=/") + + try await client.execute( + uri: "/sign-in?return=/search", + method: .get, + headers: [.cookie: cookie] + ) { response in + #expect(response.status == .found) + #expect(response.headers[.location] == "/search") + } + } + } + + @Test + func `signs out: ends the session, expires the cookie, and returns home`() async throws { + try await withApp { client in + let cookie = try await signIn(client, body: "return=/") + + try await client.execute( + uri: "/sign-out", + method: .post, + headers: [.cookie: cookie] + ) { response in + #expect(response.status == .seeOther) + #expect(response.headers[.location] == "/") + #expect(response.headers[.setCookie]?.contains("Max-Age=0") == true) + } + + // The token itself is dead, not just forgotten by the browser. + try await client.execute( + uri: "/search", + method: .get, + headers: [.cookie: cookie] + ) { response in + #expect(!String(buffer: response.body).contains("Account of")) + } + } + } + + @Test + func `serves the landing page cached signed out, and rendered around the viewer signed in`() async throws { + try await withApp { client in + try await client.execute( + uri: "/", + method: .get + ) { response in + #expect(response.headers[.cacheControl] == "public, no-cache") + #expect(response.headers[.eTag] != nil) + } + + let cookie = try await signIn(client, body: "return=/") + + try await client.execute( + uri: "/", + method: .get, + headers: [.cookie: cookie] + ) { response in + #expect(response.headers[.cacheControl] == "private, no-cache") + #expect(response.headers[.eTag] == nil) + #expect(String(buffer: response.body).contains(#"aria-label="Account of Mara Visser""#)) + } + } + } + +} + +// MARK: - Helpers + +private extension SessionControllerTests { + + /// Posts the sign-in form and returns the session cookie it sets, as a `Cookie` header value. + func signIn( + _ client: any TestClientProtocol, + body: String, + expecting: @escaping @Sendable (TestResponse) throws -> Void = { _ in } + ) async throws -> String { + try await client.execute( + uri: "/sign-in", + method: .post, + headers: [.contentType: "application/x-www-form-urlencoded"], + body: ByteBuffer(string: body) + ) { response in + try expecting(response) + + let cookie = try #require(response.headers[.setCookie]) + + #expect(cookie.contains("HttpOnly")) + + return String(cookie.prefix { $0 != ";" }) + } + } + + /// Runs the body against the session, catalogue, and root routes sharing one session store, over a migrated, seeded in-memory database. + func withApp( + _ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void + ) async throws { + let fluent = try Service( + driver: .inMemory, + logger: Logger(label: "test") + )() + + do { + await PrepareDB()(for: fluent) + + try await fluent.migrate() + + let catalogue = CatalogueRepository(fluent: fluent) + let router = Router(context: WebsiteRequestContext.self) + let sessions = SessionStore() + + router.addRoutes(RootController(sessions: sessions).routes) + router.addRoutes(CatalogueController(repository: catalogue, sessions: sessions).routes) + router.addRoutes(SessionController( + sessions: sessions, + accounts: .init(fluent: fluent), + catalogue: catalogue + ).routes) + + try await Application(router: router).test(.router, body) + } catch { + try? await fluent.shutdown() + + throw error + } + + try await fluent.shutdown() + } + +} diff --git a/Services/Website/Tests/Library/Cases/Public/Types/SessionStoreTests.swift b/Services/Website/Tests/Library/Cases/Public/Types/SessionStoreTests.swift new file mode 100644 index 0000000..be31785 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Public/Types/SessionStoreTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Hummingbird +import Persistence +import Testing + +@testable import WebsiteLibrary + +@Suite( + "SessionStore type", + .tags(.type) +) +struct SessionStoreTests { + + // MARK: Constants + + private let user = User(id: .init(), email: "mara.v@example.com", displayName: "Mara Visser") + + // MARK: Methods tests + + @Test + func `starts a session under an unguessable, unique token`() async { + let sessions = SessionStore() + let first = await sessions.start(for: user) + let second = await sessions.start(for: user) + + #expect(first.count == 64) + #expect(first.allSatisfy { $0.isHexDigit }) + let found = await sessions.user(for: first) + + #expect(first != second) + #expect(found == user) + } + + @Test + func `forgets a session once ended`() async { + let sessions = SessionStore() + let token = await sessions.start(for: user) + + await sessions.end(token) + + let ended = await sessions.user(for: token) + let unknown = await sessions.user(for: "unknown") + + #expect(ended == nil) + #expect(unknown == nil) + } + + @Test + func `reads the viewer from the request's session cookie`() async { + let sessions = SessionStore() + let token = await sessions.start(for: user) + + let signedIn = await sessions.viewer(for: request(cookie: "theme=dark; ccn_session=\(token)")) + let forged = await sessions.viewer(for: request(cookie: "ccn_session=forged")) + let signedOut = await sessions.viewer(for: request(cookie: nil)) + + #expect(signedIn?.displayName == "Mara Visser") + #expect(forged == nil) + #expect(signedOut == nil) + } + + @Test + func `hides the cookie from scripts and other sites`() { + let cookie = SessionStore.cookie("abc", isSecure: true).description + + #expect(cookie.hasPrefix("ccn_session=abc")) + #expect(cookie.contains("HttpOnly")) + #expect(cookie.contains("SameSite=Lax")) + #expect(cookie.contains("Secure")) + #expect(cookie.contains("Path=/")) + #expect(!SessionStore.cookie("abc", isSecure: false).description.contains("Secure")) + } + + @Test + func `expires the cookie when handed no token`() { + #expect(SessionStore.cookie("", isSecure: false).description.contains("Max-Age=0")) + } + +} + +// MARK: - Helpers + +private extension SessionStoreTests { + + /// A request carrying the given `Cookie` header, or none. + func request( + cookie: String? + ) -> Request { + var head = HTTPRequest(method: .get, scheme: nil, authority: nil, path: "/") + + head.headerFields[.cookie] = cookie + + return .init(head: head, body: .init(buffer: .init())) + } + +}