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

This commit is contained in:
javier committed 2026-09-21 13:53:13 +02:00
1 parent 256d22df40
commit f2f445827b
40 files changed
+1906 -37

No files matched your search

@@ -29,6 +29,9 @@ public struct SessionController<Context: RequestContext> {
/// 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
@@ -39,6 +42,7 @@ public struct SessionController<Context: RequestContext> {
/// - 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.
@@ -46,6 +50,7 @@ public struct SessionController<Context: RequestContext> {
sessions: SessionStore,
accounts: AccountRepository,
catalogue: CatalogueRepository,
recoveryLinks: RecoveryLinks = .init(),
isSecure: Bool = false,
assetVersion: String? = nil,
analytics: Analytics? = nil
@@ -55,6 +60,7 @@ public struct SessionController<Context: RequestContext> {
self.assetVersion = assetVersion
self.catalogue = catalogue
self.isSecure = isSecure
self.recoveryLinks = recoveryLinks
self.sessions = sessions
}
@@ -81,6 +87,9 @@ extension SessionController: RouterController {
.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
}
@@ -112,6 +121,19 @@ private extension SessionController {
.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
}
@@ -155,6 +177,79 @@ private extension SessionController {
.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(
@@ -237,3 +332,8 @@ private extension RouterPath {
static let signOut: RouterPath = .init(.Route.signOut)
}
}
/// The field the recovery form posts.
private struct RecoveryForm: Decodable {
let email: String
}