Implemented phase 5 for the POC prototype in the Website service.
This commit is contained in:
1 parent
89a7a7b8e5
commit
31d2d98d49
46 files changed
+4039
-162
No files matched your search
@@ -0,0 +1,332 @@
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// Serves what an account does for itself: its first sign-in, its profile and passkeys, and opening its shop.
|
||||
///
|
||||
/// - 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 AccountController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The repository accounts and their passkeys are read and changed through.
|
||||
private let accounts: AccountRepository
|
||||
|
||||
private let analytics: Analytics?
|
||||
private let assetVersion: String?
|
||||
|
||||
/// The repository shops are opened through.
|
||||
private let catalogue: CatalogueRepository
|
||||
|
||||
/// The store the signed-in account is read from.
|
||||
private let sessions: SessionStore
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an account controller.
|
||||
/// - Parameters:
|
||||
/// - accounts: the repository accounts and their passkeys are read and changed through.
|
||||
/// - catalogue: the repository shops are opened through.
|
||||
/// - sessions: the store the signed-in account is read from.
|
||||
/// - 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(
|
||||
accounts: AccountRepository,
|
||||
catalogue: CatalogueRepository,
|
||||
sessions: SessionStore,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.accounts = accounts
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension AccountController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(.init(.Route.welcome), use: welcome)
|
||||
routes.post(.init(.Route.welcome), use: introduce)
|
||||
routes.get(.init(.Route.account), use: account)
|
||||
routes.post(.init("\(String.Route.account)/name"), use: rename)
|
||||
routes.post(.init("\(String.Route.account)/passkeys/revoke"), use: revoke)
|
||||
routes.get(.init(.Route.openShop), use: shopForm)
|
||||
routes.post(.init(.Route.openShop), use: openShop)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Account
|
||||
|
||||
private extension AccountController {
|
||||
|
||||
/// Handles a request for the account screen.
|
||||
@Sendable
|
||||
func account(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .found)
|
||||
}
|
||||
|
||||
return try await accountPage(user, request: request, context: context)
|
||||
}
|
||||
|
||||
/// Renders the account screen — with a `422` when it reports a problem.
|
||||
func accountPage(
|
||||
_ user: User,
|
||||
problem: String? = nil,
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let passkeys = try await accounts.passkeys(of: user)
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
|
||||
return HTMLResponse(
|
||||
status: problem == nil ? .ok : .unprocessableContent,
|
||||
additionalHeaders: .perViewer
|
||||
) { [analytics, assetVersion] in
|
||||
AccountPage(
|
||||
user: user,
|
||||
passkeys: passkeys,
|
||||
problem: problem,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles a new display name from the account screen.
|
||||
@Sendable
|
||||
func rename(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal)
|
||||
}
|
||||
|
||||
let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context)
|
||||
|
||||
guard let name = Self.displayName(form.name) else {
|
||||
return try await accountPage(user, problem: Self.nameProblem, request: request, context: context)
|
||||
}
|
||||
|
||||
await sessions.update(try await accounts.rename(user, to: name))
|
||||
|
||||
return .redirect(to: .Route.account, type: .normal)
|
||||
}
|
||||
|
||||
/// Handles the revoking of one of the account's own passkeys.
|
||||
@Sendable
|
||||
func revoke(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.account), type: .normal)
|
||||
}
|
||||
|
||||
let form = try await URLEncodedFormDecoder().decode(RevokeForm.self, from: request, context: context)
|
||||
|
||||
try await accounts.revokePasskey(form.credential, of: user)
|
||||
|
||||
return .redirect(to: "\(String.Route.account)#passkeys", type: .normal)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - First sign-in
|
||||
|
||||
private extension AccountController {
|
||||
|
||||
/// Handles the display name picked on first sign-in, and leads on to what the visitor came for.
|
||||
@Sendable
|
||||
func introduce(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn, type: .normal)
|
||||
}
|
||||
|
||||
let form = try await URLEncodedFormDecoder().decode(NameForm.self, from: request, context: context)
|
||||
let returnPath = String.Route.safeReturn(form.return)
|
||||
|
||||
guard let name = Self.displayName(form.name) else {
|
||||
return welcomePage(name: form.name, returnPath: returnPath, problem: Self.nameProblem, request: request, context: context)
|
||||
}
|
||||
|
||||
await sessions.update(try await accounts.rename(user, to: name))
|
||||
|
||||
return .redirect(to: returnPath, type: .normal)
|
||||
}
|
||||
|
||||
/// Handles a request for the first-sign-in screen.
|
||||
@Sendable
|
||||
func welcome(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn, type: .found)
|
||||
}
|
||||
|
||||
return welcomePage(
|
||||
name: user.displayName,
|
||||
returnPath: .Route.safeReturn(request.uri.queryParameters.get("return")),
|
||||
request: request,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the first-sign-in screen — with a `422` when it reports a problem.
|
||||
func welcomePage(
|
||||
name: String,
|
||||
returnPath: String,
|
||||
problem: String? = nil,
|
||||
request: Request,
|
||||
context: Context
|
||||
) -> Response {
|
||||
HTMLResponse(
|
||||
status: problem == nil ? .ok : .unprocessableContent,
|
||||
additionalHeaders: [.cacheControl: "no-store"]
|
||||
) { [analytics, assetVersion] in
|
||||
WelcomePage(
|
||||
displayName: name,
|
||||
returnPath: returnPath,
|
||||
problem: problem,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Opening a shop
|
||||
|
||||
private extension AccountController {
|
||||
|
||||
/// Handles the open-a-shop form posted: opens the shop, and leads to its first listing.
|
||||
@Sendable
|
||||
func openShop(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .normal)
|
||||
}
|
||||
|
||||
let values = try await URLEncodedFormDecoder().decode([String: String].self, from: request, context: context)
|
||||
let form = ShopForm(values: values)
|
||||
|
||||
switch form.validated() {
|
||||
case .failure(let problems):
|
||||
return await shopPage(form, problems: problems.fields, request: request, context: context)
|
||||
case .success(let draft):
|
||||
do {
|
||||
_ = try await catalogue.openShop(draft, for: user)
|
||||
} catch ShopError.handleTaken {
|
||||
return await shopPage(form, problems: [.handle: "Another shop has that handle."], request: request, context: context)
|
||||
} catch ShopError.alreadyOwner {
|
||||
return .redirect(to: .Route.sellerDashboard, type: .normal)
|
||||
}
|
||||
|
||||
return .redirect(to: NewListingPage.path, type: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a request for the open-a-shop form; an account that owns a shop is led to its dashboard.
|
||||
@Sendable
|
||||
func shopForm(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard await sessions.user(for: request) != nil else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.openShop), type: .found)
|
||||
}
|
||||
|
||||
guard await sessions.viewer(for: request)?.hasShop != true else {
|
||||
return .redirect(to: .Route.sellerDashboard, type: .found)
|
||||
}
|
||||
|
||||
return await shopPage(.init(), request: request, context: context)
|
||||
}
|
||||
|
||||
/// Renders the open-a-shop form — with a `422` when it reports problems.
|
||||
func shopPage(
|
||||
_ form: ShopForm,
|
||||
problems: [ShopForm.Field: String] = [:],
|
||||
request: Request,
|
||||
context: Context
|
||||
) async -> Response {
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
|
||||
return HTMLResponse(
|
||||
status: problems.isEmpty ? .ok : .unprocessableContent,
|
||||
additionalHeaders: .perViewer
|
||||
) { [analytics, assetVersion] in
|
||||
OpenShopPage(
|
||||
form: form,
|
||||
problems: problems,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension AccountController {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static var nameProblem: String { "Enter a display name of 2 to 40 characters." }
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// A display name as posted, trimmed; or `nil` when it is too short or too long.
|
||||
static func displayName(
|
||||
_ name: String
|
||||
) -> String? {
|
||||
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
return (2...40).contains(name.count) ? name : nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
/// The fields a display name form posts.
|
||||
private struct NameForm: Decodable {
|
||||
let name: String
|
||||
let `return`: String?
|
||||
}
|
||||
|
||||
/// The field a passkey's revoke form posts.
|
||||
private struct RevokeForm: Decodable {
|
||||
let credential: String
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// Serves what a collector uses: the want list, for a signed-in account, and the public series screens and comparison.
|
||||
///
|
||||
/// - 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 CollectingController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let analytics: Analytics?
|
||||
private let assetVersion: String?
|
||||
|
||||
/// The repository listings are read through.
|
||||
private let catalogue: CatalogueRepository
|
||||
|
||||
/// The repository wants and series are read and written through.
|
||||
private let collecting: CollectingRepository
|
||||
|
||||
/// The store the signed-in account is read from.
|
||||
private let sessions: SessionStore
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a collecting controller.
|
||||
/// - Parameters:
|
||||
/// - collecting: the repository wants and series are read and written through.
|
||||
/// - catalogue: the repository listings are read through.
|
||||
/// - sessions: the store the signed-in account is read from.
|
||||
/// - 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(
|
||||
collecting: CollectingRepository,
|
||||
catalogue: CatalogueRepository,
|
||||
sessions: SessionStore,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.collecting = collecting
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension CollectingController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(.init(.Route.wants), use: wants)
|
||||
routes.post(.init(.Route.wants), use: addWant)
|
||||
routes.get(.init("\(String.Route.wants)/new"), use: newWant)
|
||||
routes.post(.init("\(String.Route.wants)/:id/alerts"), use: toggleAlerts)
|
||||
routes.post(.init("\(String.Route.wants)/:id/remove"), use: removeWant)
|
||||
routes.get(.init(.Route.series), use: seriesIndex)
|
||||
routes.get(.init("\(String.Route.series)/:slug"), use: series)
|
||||
routes.get(.init(.Route.compare), use: compare)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Wants
|
||||
|
||||
private extension CollectingController {
|
||||
|
||||
/// Handles a want posted from the form.
|
||||
@Sendable
|
||||
func addWant(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal)
|
||||
}
|
||||
|
||||
let form = try await URLEncodedFormDecoder().decode(WantForm.self, from: request, context: context)
|
||||
|
||||
try await collecting.addWant(
|
||||
title: form.title,
|
||||
minGrade: form.minGrade.flatMap(Double.init),
|
||||
maxPriceCents: form.maxPrice.flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }.flatMap { $0 > 0 ? $0 * 100 : nil },
|
||||
slabbedOnly: form.slabbed == "1",
|
||||
for: user
|
||||
)
|
||||
|
||||
return .redirect(to: .Route.wants, type: .normal)
|
||||
}
|
||||
|
||||
/// Handles a request for the add-a-want form, filled from a listing being saved, or with a title — a gap in a series.
|
||||
@Sendable
|
||||
func newWant(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: request.uri.string), type: .found)
|
||||
}
|
||||
|
||||
var draft = WantsPage.Draft(title: request.uri.queryParameters.get("title") ?? "")
|
||||
|
||||
if
|
||||
let slug = request.uri.queryParameters.get("listing"),
|
||||
let listing = try await catalogue.listing(slug: slug)
|
||||
{
|
||||
draft = .init(title: listing.title, minGrade: listing.grade, maxPriceCents: listing.priceCents)
|
||||
}
|
||||
|
||||
return try await wantsPage(user, draft: draft, request: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles the removal of one of the account's wants.
|
||||
@Sendable
|
||||
func removeWant(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal)
|
||||
}
|
||||
|
||||
if let id = context.parameters.get("id", as: UUID.self) {
|
||||
try await collecting.removeWant(id, of: user)
|
||||
}
|
||||
|
||||
return .redirect(to: .Route.wants, type: .normal)
|
||||
}
|
||||
|
||||
/// Handles the switching of a want's alerts.
|
||||
@Sendable
|
||||
func toggleAlerts(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .normal)
|
||||
}
|
||||
|
||||
if let id = context.parameters.get("id", as: UUID.self) {
|
||||
try await collecting.toggleAlerts(ofWant: id, of: user)
|
||||
}
|
||||
|
||||
return .redirect(to: .Route.wants, type: .normal)
|
||||
}
|
||||
|
||||
/// Handles a request for the want list. Looking at it is what makes its matches no longer new.
|
||||
@Sendable
|
||||
func wants(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let user = await sessions.user(for: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: .Route.wants), type: .found)
|
||||
}
|
||||
|
||||
return try await wantsPage(user, draft: nil, request: request, context: context)
|
||||
}
|
||||
|
||||
/// Renders the want list with its matches still marked new, then marks them seen — so the header's bell is quiet on the next page.
|
||||
func wantsPage(
|
||||
_ user: User,
|
||||
draft: WantsPage.Draft?,
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let wants = try await collecting.wants(of: user)
|
||||
|
||||
try await collecting.markWantsSeen(by: user)
|
||||
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
|
||||
return HTMLResponse(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
WantsPage(
|
||||
wants: wants,
|
||||
draft: draft,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Series
|
||||
|
||||
private extension CollectingController {
|
||||
|
||||
/// Handles a request for the comparison of the copies named in the query, up to three.
|
||||
@Sendable
|
||||
func compare(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> HTMLResponse {
|
||||
var copies = [Listing]()
|
||||
|
||||
for slug in request.uri.queryParameters.getAll("copy").prefix(SeriesPage.maxCompared) {
|
||||
if let copy = try await catalogue.listing(slug: slug), copy.isAvailable {
|
||||
copies.append(copy)
|
||||
}
|
||||
}
|
||||
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion, copies] in
|
||||
ComparePage(
|
||||
copies: copies,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a request for a series; an unknown slug is not found.
|
||||
@Sendable
|
||||
func series(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> HTMLResponse {
|
||||
let slug = try context.parameters.require("slug")
|
||||
|
||||
guard let series = try await collecting.series(slug: slug) else {
|
||||
throw HTTPError(.notFound)
|
||||
}
|
||||
|
||||
// Every copy of the series, wherever it ships from: a series screen is the whole market for it.
|
||||
let listings = try await catalogue.listings().filter { $0.seriesSlug == slug }
|
||||
let issue = request.uri.queryParameters.get("issue", as: Int.self)
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
SeriesPage(
|
||||
series: series,
|
||||
listings: listings,
|
||||
issue: issue,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a request for the series index.
|
||||
@Sendable
|
||||
func seriesIndex(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> HTMLResponse {
|
||||
let listings = try await catalogue.listings()
|
||||
let entries = try await collecting.allSeries().map { series in
|
||||
(series, listings.filter { $0.seriesSlug == series.slug })
|
||||
}
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
SeriesIndexPage(
|
||||
entries: entries,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
/// The fields the add-a-want form posts.
|
||||
private struct WantForm: Decodable {
|
||||
let title: String
|
||||
let minGrade: String?
|
||||
let maxPrice: String?
|
||||
let slabbed: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title
|
||||
case minGrade = "min-grade"
|
||||
case maxPrice = "max-price"
|
||||
case slabbed
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ public struct ListingFormController<Context: RequestContext> {
|
||||
/// The repository listings are published through.
|
||||
private let catalogue: CatalogueRepository
|
||||
|
||||
/// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
|
||||
private let onCatalogueChange: @Sendable () async -> Void
|
||||
|
||||
/// The store the photos are kept in.
|
||||
private let photos: PhotoStore
|
||||
|
||||
@@ -40,15 +43,18 @@ public struct ListingFormController<Context: RequestContext> {
|
||||
/// - photos: the store the photos are kept in.
|
||||
/// - 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.
|
||||
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
|
||||
public init(
|
||||
catalogue: CatalogueRepository,
|
||||
trade: TradeRepository,
|
||||
sessions: SessionStore,
|
||||
photos: PhotoStore,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
analytics: Analytics? = nil,
|
||||
onCatalogueChange: @escaping @Sendable () async -> Void = {}
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.onCatalogueChange = onCatalogueChange
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.photos = photos
|
||||
@@ -151,6 +157,8 @@ private extension ListingFormController {
|
||||
throw HTTPError(.internalServerError)
|
||||
}
|
||||
|
||||
await onCatalogueChange()
|
||||
|
||||
return .redirect(to: listing.path, type: .normal)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import WebAuthn
|
||||
/// Serves the passkey ceremonies behind the sign-in screen, as JSON for `js/passkey.js`.
|
||||
///
|
||||
/// One step both signs in and joins: an unknown email registers a passkey and creates its account together; anything else authenticates with a
|
||||
/// passkey registered earlier — a discoverable one when no email is given. A passkey is never added to an existing account: the prototype has
|
||||
/// no other proof of who is asking, so that would hand the account over.
|
||||
/// passkey registered earlier — a discoverable one when no email is given. A passkey is added to an existing account only from that account's
|
||||
/// own session: signed out there is no proof of who is asking, so it would hand the account over.
|
||||
///
|
||||
/// - 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 PasskeyController<Context: RequestContext> {
|
||||
@@ -134,7 +134,35 @@ private extension PasskeyController {
|
||||
|
||||
user = joined
|
||||
|
||||
return try await signedIn(user, returningTo: answer.return)
|
||||
// A new account picks its display name before going on.
|
||||
return try await signedIn(user, returningTo: .Route.welcome(returningTo: .Route.safeReturn(answer.return)))
|
||||
case .addition(let userID):
|
||||
guard let owner = await sessions.user(for: request), owner.id == userID else {
|
||||
return Self.refusal(.unauthorized, "Sign in again to add a passkey.")
|
||||
}
|
||||
|
||||
let answer = try JSONDecoder().decode(Answer<RegistrationCredential>.self, from: body)
|
||||
let credential = try await manager.finishRegistration(
|
||||
challenge: ceremony.challenge,
|
||||
credentialCreationData: answer.credential,
|
||||
confirmCredentialIDNotRegisteredYet: { [accounts] in
|
||||
try await accounts.isUnregistered(credentialID: Self.base64URL($0))
|
||||
}
|
||||
)
|
||||
|
||||
try await accounts.addPasskey(
|
||||
to: owner,
|
||||
credentialID: Self.base64URL(credential.id),
|
||||
publicKey: credential.publicKey,
|
||||
signCount: credential.signCount
|
||||
)
|
||||
|
||||
// The bare path: from the account screen, one ending in a fragment would not reload it.
|
||||
var response = Self.json(.ok, #"{"location":"\#(String.Route.account)"}"#)
|
||||
|
||||
response.setCookie(PasskeyCeremonies.cookie("", isSecure: isSecure))
|
||||
|
||||
return response
|
||||
case .authentication:
|
||||
let answer = try JSONDecoder().decode(Answer<AuthenticationCredential>.self, from: body)
|
||||
let credentialID = Self.base64URL(answer.credential.id.asString())
|
||||
@@ -167,8 +195,23 @@ private extension PasskeyController {
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let typed = (try? await request.decode(as: Start.self, context: context))?.email?
|
||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let start = try? await request.decode(as: Start.self, context: context)
|
||||
let typed = start?.email?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
|
||||
// From the account screen: another passkey for the account signed in.
|
||||
if start?.add == true {
|
||||
guard let owner = await sessions.user(for: request) else {
|
||||
return Self.refusal(.unauthorized, "Sign in again to add a passkey.")
|
||||
}
|
||||
|
||||
let options = manager.beginRegistration(user: .init(
|
||||
id: Array(owner.id.uuidString.utf8),
|
||||
name: owner.email,
|
||||
displayName: owner.displayName
|
||||
))
|
||||
|
||||
return try await begin(.addition(userID: owner.id), options: options, challenge: options.challenge, mode: "create")
|
||||
}
|
||||
|
||||
// No email: whichever passkey the browser holds for this site.
|
||||
guard !typed.isEmpty else {
|
||||
@@ -299,6 +342,7 @@ private extension PasskeyController {
|
||||
/// What the script posts to start a ceremony.
|
||||
private struct Start: Decodable {
|
||||
let email: String?
|
||||
let add: Bool?
|
||||
}
|
||||
|
||||
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
|
||||
|
||||
@@ -19,17 +19,14 @@ 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?
|
||||
/// The landing pages. Whatever changes the catalogue refreshes them through it.
|
||||
public let home: HomePages
|
||||
|
||||
/// Negotiates the language the bare route answers in.
|
||||
private let negotiate: Negotiate
|
||||
|
||||
/// The landing page, rendered once per supported language and reused for every request.
|
||||
private let responses: LocalizedHTMLCollectionResponse
|
||||
/// The store the signed-in viewer is read from, or `nil` to serve everyone the cached page.
|
||||
private let sessions: SessionStore?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
@@ -47,28 +44,14 @@ public struct RootController<Context: RequestContext> {
|
||||
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.home = .init(
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics,
|
||||
listings: listings
|
||||
)
|
||||
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
|
||||
) {
|
||||
page($0, nil)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -156,14 +139,19 @@ private extension RootController {
|
||||
context: Context
|
||||
) async -> Response {
|
||||
guard let viewer = await sessions?.viewer(for: request) else {
|
||||
return responses.response(
|
||||
for: language,
|
||||
return await home.response(
|
||||
in: language,
|
||||
request: request
|
||||
)
|
||||
}
|
||||
|
||||
return HTMLResponse(additionalHeaders: .perViewer) { [page] in
|
||||
page(.init(identifier: language), viewer)
|
||||
let page = await home.page(
|
||||
in: language,
|
||||
for: viewer
|
||||
)
|
||||
|
||||
return HTMLResponse(additionalHeaders: .perViewer) {
|
||||
page
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
@@ -194,8 +194,13 @@ private extension SessionController {
|
||||
)
|
||||
}
|
||||
|
||||
let token = await sessions.start(for: try await accounts.account(email: email))
|
||||
var response = Response.redirect(to: returnPath, type: .normal)
|
||||
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))
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ public struct TradeController<Context: RequestContext> {
|
||||
/// The repository listings and shops are read through.
|
||||
private let catalogue: CatalogueRepository
|
||||
|
||||
/// Told whenever the controller changes what is for sale, so pages cached elsewhere can catch up.
|
||||
private let onCatalogueChange: @Sendable () async -> Void
|
||||
|
||||
/// The store the signed-in account is read from.
|
||||
private let sessions: SessionStore
|
||||
|
||||
@@ -35,14 +38,17 @@ public struct TradeController<Context: RequestContext> {
|
||||
/// - sessions: the store the signed-in account is read from.
|
||||
/// - 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.
|
||||
/// - onCatalogueChange: told whenever the controller changes what is for sale; does nothing by default.
|
||||
public init(
|
||||
trade: TradeRepository,
|
||||
catalogue: CatalogueRepository,
|
||||
sessions: SessionStore,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
analytics: Analytics? = nil,
|
||||
onCatalogueChange: @escaping @Sendable () async -> Void = {}
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.onCatalogueChange = onCatalogueChange
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.sessions = sessions
|
||||
@@ -95,7 +101,11 @@ private extension TradeController {
|
||||
}
|
||||
|
||||
do {
|
||||
return .redirect(to: .Route.sale(try await trade.buy(listing: slug, buyer: user)), type: .normal)
|
||||
let number = try await trade.buy(listing: slug, buyer: user)
|
||||
|
||||
await onCatalogueChange()
|
||||
|
||||
return .redirect(to: .Route.sale(number), type: .normal)
|
||||
} catch let error as TradeError {
|
||||
return try await sheet(.buy, slug: slug, error: error, viewer: viewer, request: request, context: context)
|
||||
}
|
||||
@@ -409,6 +419,10 @@ private extension TradeController {
|
||||
let form = try await URLEncodedFormDecoder().decode(AnswerForm.self, from: request, context: context)
|
||||
let number = try? await trade.respond(toOffer: id, accepting: form.answer == "accept", by: user)
|
||||
|
||||
if number.flatMap(\.self) != nil {
|
||||
await onCatalogueChange()
|
||||
}
|
||||
|
||||
return .redirect(
|
||||
to: number.flatMap(\.self).map(String.Route.sale) ?? .Route.safeReturn(form.return),
|
||||
type: .normal
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import Infrastructure
|
||||
|
||||
/// The landing page, rendered once per language and cached — and rendered again whenever the catalogue changes.
|
||||
///
|
||||
/// A sale takes a copy off the page and a new listing puts one on, so whatever changes the catalogue calls ``refresh(with:)``.
|
||||
public actor HomePages {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// What a page is built from, given its locale, its listings, and its viewer.
|
||||
private let page: @Sendable (Locale, HomeListings, Viewer?) -> IndexPage
|
||||
|
||||
/// The listings the pages show in their rows.
|
||||
private var listings: HomeListings
|
||||
|
||||
/// The pages rendered for signed-out visitors, one per language.
|
||||
private var responses: LocalizedHTMLCollectionResponse
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Renders the landing pages.
|
||||
/// - Parameters:
|
||||
/// - assetVersion: the version token appended to the pages' asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - siteOrigin: the public origin the pages derive their canonical URL and language alternates from, or `nil` (the default) to omit them.
|
||||
/// - analytics: the analytics tracker the pages embed, or `nil` (the default) to omit it.
|
||||
/// - listings: the listings the pages show in their rows; none by default.
|
||||
public init(
|
||||
assetVersion: String? = nil,
|
||||
siteOrigin: String? = nil,
|
||||
analytics: Analytics? = nil,
|
||||
listings: HomeListings = .init()
|
||||
) {
|
||||
let page: @Sendable (Locale, HomeListings, Viewer?) -> IndexPage = {
|
||||
.init(
|
||||
locale: $0,
|
||||
assetVersion: assetVersion,
|
||||
siteOrigin: siteOrigin,
|
||||
analytics: analytics,
|
||||
listings: $1,
|
||||
viewer: $2
|
||||
)
|
||||
}
|
||||
|
||||
self.listings = listings
|
||||
self.page = page
|
||||
self.responses = Self.render(page, listings)
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Renders the pages again, around new listings.
|
||||
public func refresh(
|
||||
with listings: HomeListings
|
||||
) {
|
||||
self.listings = listings
|
||||
self.responses = Self.render(page, listings)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension HomePages {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The page rendered around a signed-in viewer, with the current listings.
|
||||
func page(
|
||||
in language: String,
|
||||
for viewer: Viewer
|
||||
) -> IndexPage {
|
||||
page(.init(identifier: language), listings, viewer)
|
||||
}
|
||||
|
||||
/// The cached response for a signed-out visitor.
|
||||
func response(
|
||||
in language: String,
|
||||
request: Request
|
||||
) -> Response {
|
||||
responses.response(
|
||||
for: language,
|
||||
request: request
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension HomePages {
|
||||
|
||||
/// Renders the signed-out pages. The bare route negotiates its language, so they declare `Vary: Accept-Language`.
|
||||
static func render(
|
||||
_ page: @Sendable (Locale, HomeListings, Viewer?) -> IndexPage,
|
||||
_ listings: HomeListings
|
||||
) -> LocalizedHTMLCollectionResponse {
|
||||
.init(
|
||||
bundle: .module,
|
||||
variesOnAcceptLanguage: true
|
||||
) {
|
||||
page($0, listings, nil)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -86,6 +86,8 @@ extension PasskeyCeremonies {
|
||||
|
||||
/// What a ceremony does.
|
||||
enum Kind: Sendable, Equatable {
|
||||
/// Adds a passkey to the signed-in account of the given identifier.
|
||||
case addition(userID: UUID)
|
||||
/// Signs in with a passkey registered earlier.
|
||||
case authentication
|
||||
/// Creates the account of the given email, with its first passkey.
|
||||
@@ -109,7 +111,7 @@ extension PasskeyCeremonies {
|
||||
name: cookieName,
|
||||
value: token,
|
||||
maxAge: token.isEmpty ? 0 : Int(lifetime),
|
||||
path: String.Route.signIn,
|
||||
path: "\(String.Route.signIn)/passkey",
|
||||
secure: isSecure,
|
||||
httpOnly: true,
|
||||
sameSite: .strict
|
||||
|
||||
@@ -10,6 +10,9 @@ public actor SessionStore {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The repository a viewer's new want matches are read through, or `nil` to leave them out.
|
||||
private let collecting: CollectingRepository?
|
||||
|
||||
/// The repository a viewer's unread count and shop are read through, or `nil` to leave both out.
|
||||
private let trade: TradeRepository?
|
||||
|
||||
@@ -19,10 +22,14 @@ public actor SessionStore {
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an empty session store.
|
||||
/// - Parameter trade: the repository a viewer's unread count and shop are read through, or `nil` (the default) to leave both out.
|
||||
/// - Parameters:
|
||||
/// - trade: the repository a viewer's unread count and shop are read through, or `nil` (the default) to leave both out.
|
||||
/// - collecting: the repository a viewer's new want matches are read through, or `nil` (the default) to leave them out.
|
||||
public init(
|
||||
trade: TradeRepository? = nil
|
||||
trade: TradeRepository? = nil,
|
||||
collecting: CollectingRepository? = nil
|
||||
) {
|
||||
self.collecting = collecting
|
||||
self.trade = trade
|
||||
}
|
||||
|
||||
@@ -50,6 +57,15 @@ public actor SessionStore {
|
||||
return token
|
||||
}
|
||||
|
||||
/// Replaces an account in every session it holds, after it was renamed.
|
||||
public func update(
|
||||
_ user: User
|
||||
) {
|
||||
for (token, held) in users where held.id == user.id {
|
||||
users[token] = user
|
||||
}
|
||||
}
|
||||
|
||||
/// The account the session with the given token belongs to, or `nil` when there is no such session.
|
||||
public func user(
|
||||
for token: String
|
||||
@@ -114,6 +130,7 @@ extension SessionStore {
|
||||
return .init(
|
||||
user,
|
||||
shopSlug: (try? await trade?.shop(ownedBy: user))?.slug,
|
||||
newWantMatches: (try? await collecting?.newMatchCount(for: user)) ?? 0,
|
||||
unreadMessages: (try? await trade?.unreadCount(for: user)) ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user