240 lines
7.6 KiB
Swift
240 lines
7.6 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 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 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)
|
|
}
|
|
}
|