Implemented phase 2 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 11:44:58 +02:00
1 parent 09c7b8a1a7
commit 43dcc03da6
28 files changed
+1468 -65

No files matched your search

@@ -21,21 +21,27 @@ public struct CatalogueController<Context: RequestContext> {
/// 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
)
@@ -1,5 +1,6 @@
import Foundation
import Hummingbird
import HummingbirdElementary
import Infrastructure
import Localization
@@ -18,6 +19,12 @@ public struct RootController<Context: RequestContext> {
// 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<Context: RequestContext> {
/// - 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)
}
}
@@ -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<Context: RequestContext> {
// 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<Context> {
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)
}
}
@@ -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)
}
}