Files
ccn/Services/Website/Sources/Library/Public/Controllers/SessionController.swift
T

340 lines
11 KiB
Swift

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 one-time recovery links handed out.
private let recoveryLinks: RecoveryLinks
/// 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.
/// - recoveryLinks: the one-time recovery links handed out; a set of its own by default.
/// - 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,
recoveryLinks: RecoveryLinks = .init(),
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.recoveryLinks = recoveryLinks
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
)
routes.get(.init(.Route.recover), use: recover)
routes.post(.init(.Route.recover), use: sendRecoveryLink)
routes.get(.init("\(String.Route.recover)/:token"), use: followRecoveryLink)
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 let first = segments.first else {
return nil
}
switch first {
case "messages": return "To read and send messages"
case "wants": return "To keep a want list"
case "collection": return "To track your collection"
case "sell": return "To sell on CCN"
case "account": return "To manage your account"
default: break
}
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 one-time recovery link followed: the way to a new passkey while it is good, and word that it ran out otherwise.
@Sendable
func followRecoveryLink(
request: Request,
context: Context
) async throws -> Response {
let token = try context.parameters.require("token")
return recoveryPage(
await recoveryLinks.user(for: token) == nil ? .expired : .renew(token: token),
request: request,
context: context
)
}
/// Handles a request for the recovery screen.
@Sendable
func recover(
request: Request,
context: Context
) async throws -> Response {
recoveryPage(.ask(problem: nil), request: request, context: context)
}
/// Renders a step of the recovery, never stored.
func recoveryPage(
_ step: RecoverPage.Step,
request: Request,
context: Context
) -> Response {
let status: HTTPResponse.Status = switch step {
case .ask(let problem) where problem != nil: .unprocessableContent
case .expired: .gone
default: .ok
}
return HTMLResponse(
status: status,
additionalHeaders: [.cacheControl: "no-store"]
) { [analytics, assetVersion] in
RecoverPage(
step: step,
assetVersion: assetVersion,
analytics: analytics
)
}
.response(from: request, context: context)
}
/// Handles a request for a recovery link.
///
/// **Stubbed**: with no mail to send, the link is shown on the screen — to anyone who asks. It must be mailed, and the screen say the
/// same whether or not the email has an account, before anything real depends on it.
@Sendable
func sendRecoveryLink(
request: Request,
context: Context
) async throws -> Response {
let form = try await URLEncodedFormDecoder().decode(RecoveryForm.self, from: request, context: context)
guard let email = NormalizeEmail()(form.email) else {
return recoveryPage(.ask(problem: "That doesn't look like an email address."), request: request, context: context)
}
var link: String?
if let user = try await accounts.existingAccount(email: email) {
link = .Route.recover(token: await recoveryLinks.make(for: user))
}
return recoveryPage(.sent(link: link), request: 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 account = try await accounts.signIn(email: email)
let token = await sessions.start(for: account.user)
// A new account picks its display name before going on.
var response = Response.redirect(
to: account.isNew ? .Route.welcome(returningTo: returnPath) : 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)
}
}
/// The field the recovery form posts.
private struct RecoveryForm: Decodable {
let email: String
}