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) } }