Implemented phase 4 for the POC prototype in the Website service.

This commit is contained in:
javier committed 2026-09-21 12:55:35 +02:00
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?
}
@@ -99,6 +99,11 @@ extension ConfigKey {
public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity"
}
/// A namespace for the site configuration keys.
/// A namespace for the passkey configuration keys.
public enum Passkeys {
/// The configuration key for the origin the sign-in screen is served at, which passkeys are bound to.
public static let origin: ConfigKey = "passkeys.origin"
}
public enum Site {
/// The configuration key for the public origin the site is served at (scheme and host, no trailing slash).
public static let origin: ConfigKey = "site.origin"
@@ -44,6 +44,13 @@ extension String {
/// The TLS token requiring TLS.
public static let tlsRequire = "require"
}
/// A namespace for the passkey default configuration values.
public enum Passkeys {
/// The default origin passkeys are bound to: where the service answers when run locally.
///
/// A deployment sets `passkeys.origin` to its own origin. Browsers only run WebAuthn on HTTPS or on `localhost` — not on `127.0.0.1`.
public static let origin = "http://localhost:8080"
}
/// A namespace for well-known path string constants.
public enum Path {
/// The directory, relative to the working directory, that the website's static files are served from.
@@ -0,0 +1,119 @@
import Foundation
import Hummingbird
/// The passkey ceremonies under way: each a challenge the server issued, waiting for the browser's signed answer.
///
/// A ceremony is kept in memory under a random token in a short-lived cookie, and answers once: taking it ends it, so a challenge is never
/// accepted twice.
public actor PasskeyCeremonies {
// MARK: Constants
/// How long a ceremony waits for its answer: five minutes, as long as the browser's own prompt may.
static let lifetime: TimeInterval = 300
/// The name of the cookie holding a ceremony's token.
static let cookieName = "ccn_ceremony"
// MARK: Properties
/// The clock expiries are judged by.
private let now: @Sendable () -> Date
/// The ceremonies under way, by token.
private var pending: [String: Ceremony] = [:]
// MARK: Initializers
/// Creates an empty set of ceremonies.
/// - Parameter now: the clock expiries are judged by; the system's by default.
public init(
now: @escaping @Sendable () -> Date = { .init() }
) {
self.now = now
}
// MARK: Methods
/// Starts a ceremony, dropping the ones that ran out meanwhile.
/// - Returns: the ceremony's token.
func start(
_ kind: Ceremony.Kind,
challenge: [UInt8]
) -> String {
let token = "\(UUID().uuidString)\(UUID().uuidString)"
.replacingOccurrences(of: "-", with: "")
.lowercased()
pending = pending.filter { $0.value.expiresAt > now() }
pending[token] = .init(
kind: kind,
challenge: challenge,
expiresAt: now().addingTimeInterval(Self.lifetime)
)
return token
}
/// Ends a ceremony, handing it over for its answer to be checked against.
/// - Returns: the ceremony, or `nil` when the token names none still waiting.
func take(
_ token: String
) -> Ceremony? {
guard
let ceremony = pending.removeValue(forKey: token),
ceremony.expiresAt > now()
else {
return nil
}
return ceremony
}
}
// MARK: - Structs
extension PasskeyCeremonies {
/// A challenge issued, and what it was issued for.
struct Ceremony: Sendable, Equatable {
/// What the ceremony does.
let kind: Kind
/// The challenge the browser's answer must sign.
let challenge: [UInt8]
/// When the ceremony stops waiting.
let expiresAt: Date
/// What a ceremony does.
enum Kind: Sendable, Equatable {
/// Signs in with a passkey registered earlier.
case authentication
/// Creates the account of the given email, with its first passkey.
case registration(email: String)
}
}
}
// MARK: - Extensions
extension PasskeyCeremonies {
// MARK: Methods
/// The cookie handing a ceremony's token to the browser; an empty token expires it.
nonisolated static func cookie(
_ token: String,
isSecure: Bool
) -> Cookie {
.init(
name: cookieName,
value: token,
maxAge: token.isEmpty ? 0 : Int(lifetime),
path: String.Route.signIn,
secure: isSecure,
httpOnly: true,
sameSite: .strict
)
}
}
@@ -0,0 +1,120 @@
import Foundation
import NIOCore
/// Where the photos of listed copies are kept: a folder under the static files, which `FileMiddleware` serves.
///
/// Prototype scaffolding. A photo is checked for its type and size and stored as posted — not re-encoded, so it keeps whatever metadata it
/// came with — and the folder lives on the instance's own disk. Both change before real sellers upload.
public struct PhotoStore: Sendable {
// MARK: Constants
/// The largest photo stored, in bytes: 3 MB.
public static let maxBytes = 3 * 1_024 * 1_024
/// The most photos a listing carries: front, back, spine, and the grader's label.
public static let maxCount = 4
/// The folder under the static files the photos are kept in, and the URL path they are served under.
static let folder = "uploads"
// MARK: Properties
/// The folder the static files are served from.
private let staticFilesPath: String
// MARK: Initializers
/// Creates a photo store under the given static files folder.
/// - Parameter staticFilesPath: the folder the static files are served from.
public init(
staticFilesPath: String
) {
self.staticFilesPath = staticFilesPath
}
}
// MARK: - Enumerations
extension PhotoStore {
/// Why a photo was turned down.
enum Refusal: Error, Equatable {
/// More photos than a listing carries.
case tooMany
/// A photo over ``PhotoStore/maxBytes``.
case tooLarge
/// A file that is not a JPEG, PNG, or WebP image.
case notAnImage
}
/// An image type the store keeps, told by the file's leading bytes rather than by its name or declared type.
enum Kind: String {
case jpg
case png
case webp
/// Tells the type of a file from its leading bytes, or `nil` when it is none the store keeps.
init?(
sniffing bytes: [UInt8]
) {
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) {
self = .jpg
} else if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
self = .png
} else if bytes.count >= 12, bytes.starts(with: Array("RIFF".utf8)), Array(bytes[8..<12]) == Array("WEBP".utf8) {
self = .webp
} else {
return nil
}
}
}
}
// MARK: - Extensions
extension PhotoStore {
// MARK: Methods
/// Checks every photo, then stores them all — or none, when one is turned down.
/// - Parameter photos: the files posted, the front first.
/// - Returns: the URL paths the photos are served at, in the order given.
func save(
_ photos: [ByteBuffer]
) throws -> [String] {
guard photos.count <= Self.maxCount else {
throw Refusal.tooMany
}
let kinds = try photos.map { photo in
guard photo.readableBytes <= Self.maxBytes else {
throw Refusal.tooLarge
}
guard let kind = Kind(sniffing: Array(photo.readableBytesView.prefix(12))) else {
throw Refusal.notAnImage
}
return kind
}
guard !photos.isEmpty else {
return []
}
let folder = URL(fileURLWithPath: staticFilesPath).appendingPathComponent(Self.folder)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
// Named by the store alone: nothing of the posted file name reaches the disk or the URL.
return try zip(photos, kinds).map { photo, kind in
let name = "\(UUID().uuidString.lowercased()).\(kind.rawValue)"
try Data(photo.readableBytesView).write(to: folder.appendingPathComponent(name), options: .atomic)
return "/\(Self.folder)/\(name)"
}
}
}