Implemented phase 4 for the POC prototype in the Website service.
This commit is contained in:
1 parent
95e0a455e8
commit
9403931109
42 files changed
+3407
-36
No files matched your search
@@ -67,6 +67,10 @@ extension CatalogueController: RouterController {
|
||||
.Catalogue.shop,
|
||||
use: shop
|
||||
)
|
||||
routes.get(
|
||||
.Catalogue.gradingGuide,
|
||||
use: gradingGuide
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
@@ -84,6 +88,23 @@ private extension CatalogueController {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Handles a request for the grading guide.
|
||||
@Sendable
|
||||
func gradingGuide(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> HTMLResponse {
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
GradingGuidePage(
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a request for a listing's detail; an unknown slug is not found.
|
||||
@Sendable
|
||||
func listing(
|
||||
@@ -172,6 +193,8 @@ private extension CatalogueController {
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``CatalogueController`` route paths.
|
||||
enum Catalogue {
|
||||
/// The path of the grading guide.
|
||||
static let gradingGuide: RouterPath = .init(GradingGuidePage.path)
|
||||
/// The path of a listing's detail.
|
||||
static let listing: RouterPath = .init(.Route.listing(":\(String.slug)"))
|
||||
/// The path of the search results.
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// Serves the new-listing form, for the owner of a shop: the form itself, and its publication.
|
||||
///
|
||||
/// - 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 ListingFormController<Context: RequestContext> {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The largest form accepted, in bytes: every photo at its limit, and room for the fields.
|
||||
private static var maxBodyBytes: Int { PhotoStore.maxCount * PhotoStore.maxBytes + 1_024 * 1_024 }
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let analytics: Analytics?
|
||||
private let assetVersion: String?
|
||||
|
||||
/// The repository listings are published through.
|
||||
private let catalogue: CatalogueRepository
|
||||
|
||||
/// The store the photos are kept in.
|
||||
private let photos: PhotoStore
|
||||
|
||||
/// The store the signed-in account is read from.
|
||||
private let sessions: SessionStore
|
||||
|
||||
/// The repository the seller's shop is read through.
|
||||
private let trade: TradeRepository
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a listing form controller.
|
||||
/// - Parameters:
|
||||
/// - catalogue: the repository listings are published through.
|
||||
/// - trade: the repository the seller's shop is read through.
|
||||
/// - sessions: the store the signed-in account is read from.
|
||||
/// - 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.
|
||||
public init(
|
||||
catalogue: CatalogueRepository,
|
||||
trade: TradeRepository,
|
||||
sessions: SessionStore,
|
||||
photos: PhotoStore,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.photos = photos
|
||||
self.sessions = sessions
|
||||
self.trade = trade
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension ListingFormController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(
|
||||
.init(NewListingPage.path),
|
||||
use: show
|
||||
)
|
||||
routes.post(
|
||||
.init(NewListingPage.path),
|
||||
use: publish
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ListingFormController {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Renders the form — with a `422` when it reports problems.
|
||||
func page(
|
||||
shop: Shop,
|
||||
form: ListingForm = .init(),
|
||||
problems: [ListingForm.Field: String] = [:],
|
||||
viewer: Viewer,
|
||||
request: Request,
|
||||
context: Context
|
||||
) -> Response {
|
||||
HTMLResponse(
|
||||
status: problems.isEmpty ? .ok : .unprocessableContent,
|
||||
additionalHeaders: .perViewer
|
||||
) { [analytics, assetVersion] in
|
||||
NewListingPage(
|
||||
shop: shop,
|
||||
form: form,
|
||||
problems: problems,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles the form posted: checks it, stores its photos, publishes the listing, and leads to it.
|
||||
///
|
||||
/// The fields are checked before any photo is stored, so a form sent back leaves nothing behind on disk.
|
||||
@Sendable
|
||||
func publish(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let (user, shop, viewer) = try await seller(of: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: NewListingPage.path), type: .normal)
|
||||
}
|
||||
|
||||
let body = try await request.body.collect(upTo: Self.maxBodyBytes)
|
||||
|
||||
guard let multipart = MultipartForm(body: body, contentType: request.headers[.contentType]) else {
|
||||
throw HTTPError(.badRequest)
|
||||
}
|
||||
|
||||
let form = ListingForm(multipart)
|
||||
|
||||
if case .failure(let problems) = form.validated(photos: []) {
|
||||
return page(shop: shop, form: form, problems: problems.fields, viewer: viewer, request: request, context: context)
|
||||
}
|
||||
|
||||
let paths: [String]
|
||||
|
||||
do {
|
||||
paths = try photos.save(multipart.files(ListingForm.Field.photos.rawValue))
|
||||
} catch let refusal as PhotoStore.Refusal {
|
||||
return page(shop: shop, form: form, problems: [.photos: refusal.explanation], viewer: viewer, request: request, context: context)
|
||||
}
|
||||
|
||||
guard
|
||||
case .success(let draft) = form.validated(photos: paths),
|
||||
let listing = try await catalogue.publish(draft, by: user)
|
||||
else {
|
||||
throw HTTPError(.internalServerError)
|
||||
}
|
||||
|
||||
return .redirect(to: listing.path, type: .normal)
|
||||
}
|
||||
|
||||
/// The signed-in seller a request comes from: the account, its shop, and the viewer the page shows; `nil` signed out.
|
||||
/// - Throws: a `403` for an account that owns no shop.
|
||||
func seller(
|
||||
of request: Request
|
||||
) async throws -> (User, Shop, Viewer)? {
|
||||
guard
|
||||
let user = await sessions.user(for: request),
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let shop = try await trade.shop(ownedBy: user) else {
|
||||
throw HTTPError(.forbidden, message: "Only a shop's owner lists copies.")
|
||||
}
|
||||
|
||||
return (user, shop, viewer)
|
||||
}
|
||||
|
||||
/// Handles a request for the empty form.
|
||||
@Sendable
|
||||
func show(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let (_, shop, viewer) = try await seller(of: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: NewListingPage.path), type: .found)
|
||||
}
|
||||
|
||||
return page(shop: shop, viewer: viewer, request: request, context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension PhotoStore.Refusal {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// Why the photo was turned down, as the seller reads it.
|
||||
var explanation: String {
|
||||
switch self {
|
||||
case .tooMany: "Pick up to \(PhotoStore.maxCount) photos."
|
||||
case .tooLarge: "Each photo can be up to 3 MB."
|
||||
case .notAnImage: "Photos have to be JPEG, PNG or WebP images."
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
import Utility
|
||||
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.
|
||||
///
|
||||
/// - 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> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The repository accounts and their passkeys are read and created through.
|
||||
private let accounts: AccountRepository
|
||||
|
||||
/// The ceremonies under way.
|
||||
private let ceremonies: PasskeyCeremonies
|
||||
|
||||
/// Whether the cookies are limited to HTTPS.
|
||||
private let isSecure: Bool
|
||||
|
||||
/// The origin the sign-in screen is served at, which passkeys are bound to.
|
||||
private let origin: String
|
||||
|
||||
/// The store sessions are started in.
|
||||
private let sessions: SessionStore
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a passkey controller.
|
||||
/// - Parameters:
|
||||
/// - origin: the origin the sign-in screen is served at, e.g. `https://ccn.example`. Browsers bind a passkey to it, and an answer
|
||||
/// signed for any other origin is turned down — so it is configuration, never read off a request.
|
||||
/// - accounts: the repository accounts and their passkeys are read and created through.
|
||||
/// - sessions: the store sessions are started in.
|
||||
/// - ceremonies: the ceremonies under way; a set of its own by default.
|
||||
public init(
|
||||
origin: String,
|
||||
accounts: AccountRepository,
|
||||
sessions: SessionStore,
|
||||
ceremonies: PasskeyCeremonies = .init()
|
||||
) {
|
||||
self.accounts = accounts
|
||||
self.ceremonies = ceremonies
|
||||
self.isSecure = origin.hasPrefix("https://")
|
||||
self.origin = origin
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension PasskeyController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.post(
|
||||
.init("\(String.Route.signIn)/passkey/options"),
|
||||
use: options
|
||||
)
|
||||
routes.post(
|
||||
.init("\(String.Route.signIn)/passkey/finish"),
|
||||
use: finish
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension PasskeyController {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The WebAuthn relying party the ceremonies are run as. Built per request: the manager is not `Sendable`, and holds no state.
|
||||
var manager: WebAuthnManager {
|
||||
.init(configuration: .init(
|
||||
relyingPartyID: URLComponents(string: origin)?.host ?? "localhost",
|
||||
relyingPartyName: "CCN",
|
||||
relyingPartyOrigin: origin
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Handles the browser's signed answer: checks it against the ceremony it belongs to, then starts the session.
|
||||
@Sendable
|
||||
func finish(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard
|
||||
let token = request.cookies[PasskeyCeremonies.cookieName]?.value,
|
||||
let ceremony = await ceremonies.take(token)
|
||||
else {
|
||||
return Self.refusal(.badRequest, "That sign-in ran out. Try again.")
|
||||
}
|
||||
|
||||
let body = try await request.body.collect(upTo: 64 * 1_024)
|
||||
let user: User
|
||||
|
||||
do {
|
||||
switch ceremony.kind {
|
||||
case .registration(let email):
|
||||
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))
|
||||
}
|
||||
)
|
||||
|
||||
guard let joined = try await accounts.join(
|
||||
email: email,
|
||||
credentialID: Self.base64URL(credential.id),
|
||||
publicKey: credential.publicKey,
|
||||
signCount: credential.signCount
|
||||
) else {
|
||||
return Self.refusal(.conflict, "That email already has an account.")
|
||||
}
|
||||
|
||||
user = joined
|
||||
|
||||
return try await signedIn(user, returningTo: answer.return)
|
||||
case .authentication:
|
||||
let answer = try JSONDecoder().decode(Answer<AuthenticationCredential>.self, from: body)
|
||||
let credentialID = Self.base64URL(answer.credential.id.asString())
|
||||
|
||||
guard let passkey = try await accounts.passkey(credentialID: credentialID) else {
|
||||
return Self.refusal(.unauthorized, "That passkey isn't registered with CCN.")
|
||||
}
|
||||
|
||||
let verified = try manager.finishAuthentication(
|
||||
credential: answer.credential,
|
||||
expectedChallenge: ceremony.challenge,
|
||||
credentialPublicKey: passkey.publicKey,
|
||||
credentialCurrentSignCount: passkey.signCount
|
||||
)
|
||||
|
||||
try await accounts.recordUse(ofPasskey: credentialID, signCount: verified.newSignCount)
|
||||
|
||||
return try await signedIn(passkey.user, returningTo: answer.return)
|
||||
}
|
||||
} catch is DecodingError {
|
||||
return Self.refusal(.badRequest, "That answer couldn't be read.")
|
||||
} catch is WebAuthnError {
|
||||
return Self.refusal(.unauthorized, "That passkey couldn't be verified.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the start of a ceremony: issues the challenge, and tells the script whether to create a passkey or use one.
|
||||
@Sendable
|
||||
func options(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let typed = (try? await request.decode(as: Start.self, context: context))?.email?
|
||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
|
||||
// No email: whichever passkey the browser holds for this site.
|
||||
guard !typed.isEmpty else {
|
||||
return try await begin(.authentication, options: manager.beginAuthentication(), mode: "get")
|
||||
}
|
||||
|
||||
guard let email = NormalizeEmail()(typed) else {
|
||||
return Self.refusal(.unprocessableContent, "That doesn't look like an email address.")
|
||||
}
|
||||
|
||||
guard try await accounts.existingAccount(email: email) != nil else {
|
||||
let options = manager.beginRegistration(user: .init(
|
||||
id: Array(UUID().uuidString.utf8),
|
||||
name: email,
|
||||
displayName: String(email.prefix { $0 != "@" })
|
||||
))
|
||||
|
||||
return try await begin(.registration(email: email), options: options, challenge: options.challenge, mode: "create")
|
||||
}
|
||||
|
||||
let passkeys = try await accounts.passkeys(email: email)
|
||||
|
||||
guard !passkeys.isEmpty else {
|
||||
return Self.refusal(.conflict, "That account has no passkey. The prototype's demo accounts sign in with the demo sign-in below.")
|
||||
}
|
||||
|
||||
return try await begin(
|
||||
.authentication,
|
||||
options: manager.beginAuthentication(allowCredentials: passkeys.compactMap { passkey in
|
||||
Self.bytes(base64URL: passkey.credentialID).map { .init(type: .publicKey, id: $0) }
|
||||
}),
|
||||
mode: "get"
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts an authentication ceremony and answers with its options.
|
||||
func begin(
|
||||
_ kind: PasskeyCeremonies.Ceremony.Kind,
|
||||
options: PublicKeyCredentialRequestOptions,
|
||||
mode: String
|
||||
) async throws -> Response {
|
||||
try await begin(kind, options: options, challenge: options.challenge, mode: mode)
|
||||
}
|
||||
|
||||
/// Starts a ceremony and answers with its options, as `{"mode": …, "options": …}`, and the ceremony's cookie.
|
||||
func begin(
|
||||
_ kind: PasskeyCeremonies.Ceremony.Kind,
|
||||
options: some Encodable,
|
||||
challenge: [UInt8],
|
||||
mode: String
|
||||
) async throws -> Response {
|
||||
let token = await ceremonies.start(kind, challenge: challenge)
|
||||
let options = String(decoding: try JSONEncoder().encode(options), as: UTF8.self)
|
||||
var response = Self.json(.ok, #"{"mode":"\#(mode)","options":\#(options)}"#)
|
||||
|
||||
response.setCookie(PasskeyCeremonies.cookie(token, isSecure: isSecure))
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/// Starts the session of a signed-in account, and answers with where to go next.
|
||||
func signedIn(
|
||||
_ user: User,
|
||||
returningTo path: String?
|
||||
) async throws -> Response {
|
||||
let token = await sessions.start(for: user)
|
||||
let location = String(decoding: try JSONEncoder().encode(String.Route.safeReturn(path)), as: UTF8.self)
|
||||
var response = Self.json(.ok, #"{"location":\#(location)}"#)
|
||||
|
||||
response.setCookie(SessionStore.cookie(token, isSecure: isSecure))
|
||||
response.setCookie(PasskeyCeremonies.cookie("", isSecure: isSecure))
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/// A base64 or base64url string as base64url without padding: the one form credential identifiers are kept and compared in.
|
||||
static func base64URL(
|
||||
_ string: String
|
||||
) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
/// The bytes a base64url string encodes, or `nil` when it encodes none.
|
||||
static func bytes(
|
||||
base64URL string: String
|
||||
) -> [UInt8]? {
|
||||
var base64 = string
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
|
||||
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
||||
|
||||
return Data(base64Encoded: base64).map(Array.init)
|
||||
}
|
||||
|
||||
/// A JSON response, never stored.
|
||||
static func json(
|
||||
_ status: HTTPResponse.Status,
|
||||
_ body: String
|
||||
) -> Response {
|
||||
.init(
|
||||
status: status,
|
||||
headers: [
|
||||
.contentType: "application/json; charset=utf-8",
|
||||
.cacheControl: "no-store",
|
||||
],
|
||||
body: .init(byteBuffer: .init(string: body))
|
||||
)
|
||||
}
|
||||
|
||||
/// A refusal, as `{"error": …}`, for the script to show.
|
||||
static func refusal(
|
||||
_ status: HTTPResponse.Status,
|
||||
_ message: String
|
||||
) -> Response {
|
||||
let message = (try? JSONEncoder().encode(message)).map { String(decoding: $0, as: UTF8.self) } ?? "\"\""
|
||||
|
||||
return json(status, #"{"error":\#(message)}"#)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
/// What the script posts to start a ceremony.
|
||||
private struct Start: Decodable {
|
||||
let email: String?
|
||||
}
|
||||
|
||||
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
|
||||
private struct Answer<Credential: Decodable>: Decodable {
|
||||
let credential: Credential
|
||||
let `return`: String?
|
||||
}
|
||||
Reference in new issue
Block a user