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

@@ -53,7 +53,7 @@ struct ListingCard: HTML {
value: "true"
)
) {
Cover(listing.cover)
ListingImage(listing)
}
div {
h3(.class("type-card-title")) {
@@ -0,0 +1,44 @@
import Elementary
import Persistence
/// What a listing looks like: its first photo, or its placeholder cover while it has none.
struct ListingImage: HTML {
// MARK: Properties
/// The listing shown.
let listing: Listing
/// A modifier class positioning or sizing the image, or `nil` for none.
let modifier: String?
// MARK: Initializers
/// Creates a listing's image.
/// - Parameters:
/// - listing: the listing shown.
/// - modifier: a modifier class positioning or sizing the image, or `nil` (the default) for none.
init(
_ listing: Listing,
modifier: String? = nil
) {
self.listing = listing
self.modifier = modifier
}
// MARK: Computed
var body: some HTML {
if let photo = listing.photos.first {
img(
.class(["photo", modifier].compactMap(\.self).joined(separator: " ")),
.src(photo),
.alt("Photo of \(listing.title), front"),
.custom(name: "loading", value: "lazy")
)
} else {
Cover(listing.cover, modifier: modifier)
}
}
}
@@ -0,0 +1,81 @@
/// The grading guide's vocabulary: the scale raw books are graded on, and the defects every listing declares.
///
/// Single-sourced: the public guide prints it, the listing form offers it, and the form's checks apply it.
enum Grading {
// MARK: Constants
/// The conditions a collected edition — ungraded — is sold in, best first.
static let conditions = ["Sealed", "Like new", "Very good", "Good", "Acceptable"]
/// The companies that grade and slab.
static let graders = ["CGC", "CBCS", "PGX"]
/// The highest grade a raw book with a colour-breaking crease can carry.
static let creaseCap = 7.0
/// The page qualities a grader states, best first.
static let pageQualities = ["White", "Off-white to white", "Off-white", "Cream"]
/// The scale, best first: each grade and its code.
static let scale: [(grade: Double, code: String)] = [
(10.0, "GM"), (9.8, "NM/M"), (9.6, "NM+"), (9.4, "NM"), (9.2, "NM-"), (9.0, "VF/NM"),
(8.5, "VF+"), (8.0, "VF"), (7.5, "VF-"), (7.0, "FN/VF"), (6.5, "FN+"), (6.0, "FN"),
(5.0, "VG/FN"), (4.0, "VG"), (3.0, "GD/VG"), (2.0, "GD"), (1.0, "FR"), (0.5, "PR"),
]
/// The grades the guide illustrates: what each allows, and what rules a book out of it.
static let references: [(grade: Double, name: String, allowed: String, excluded: String)] = [
(9.8, "Near mint / mint", "Nearly perfect. Flat, glossy, sharp corners, tight staples. Up to one tiny bindery tear.", "Any spine stress you can see without tilting the book. Any crease."),
(9.4, "Near mint", "A few very small spine ticks, a light corner bend. Full gloss.", "Colour breaks. Writing. Foxing."),
(8.0, "Very fine", "Light wear: a few spine ticks, minor corner blunting, slight gloss loss.", "Colour-breaking creases longer than 1 cm. Tears."),
(6.5, "Fine +", "Moderate wear. A colour-breaking crease or two, some spine roll, small arrival date is fine.", "Missing pieces. Detached cover."),
(4.0, "Very good", "Well read. Multiple creases, rounded corners, possible small tear or staple rust.", "Water damage across the cover. Missing pages."),
(2.0, "Good", "Complete but heavily worn. Tears, writing, cover may be loose at one staple.", "Anything missing — that's Fair or Poor."),
]
}
// MARK: - Enumerations
extension Grading {
/// A box on the defect checklist.
enum Defect: String, CaseIterable, Sendable {
case spineStress = "spine-stress"
case crease
case cornerWear = "corner-wear"
case foxing
case tears
case writing
case detached
case caseScuffs = "case-scuffs"
/// What the box means.
var definition: String {
switch self {
case .spineStress: "Small non-colour-breaking lines along the spine from opening the book."
case .crease: "A fold that has cracked the ink. Caps a raw book at 7.0 or below."
case .cornerWear: "Rounded or frayed corners."
case .foxing: "Brown spots or tide marks; usually humidity."
case .tears: "Any loss of paper. Describe size and location."
case .writing: "Arrival dates, names, distributor stamps."
case .detached: "Cover loose at one or both staples; rusted staples."
case .caseScuffs: "Slabs only: scratches or cracks on the holder, not the book."
}
}
/// The box's name, as listings declare it.
var label: String {
switch self {
case .spineStress: "Spine stress / ticks"
case .crease: "Colour-breaking crease"
case .cornerWear: "Corner wear / blunting"
case .foxing: "Foxing / staining"
case .tears: "Tears or missing pieces"
case .writing: "Writing / stamps"
case .detached: "Detached cover / staples"
case .caseScuffs: "Case scuffs / cracks"
}
}
}
}
@@ -0,0 +1,159 @@
import Elementary
import Foundation
import Infrastructure
/// The public grading guide: the scale raw books are graded on, reference grades, and the defect checklist every listing answers.
struct GradingGuidePage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The signed-in viewer, or `nil` when signed out.
let viewer: Viewer?
// MARK: Initializers
/// Creates the grading guide.
/// - Parameters:
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init(
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.locale = .init(identifier: "en")
self.viewer = viewer
}
}
// MARK: - Page
extension GradingGuidePage: Page {
// MARK: Constants
/// The path the page is served at.
static let path = String.Route.gradingGuide
// MARK: Computed
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class("page guide")) {
div(.class("guide__head")) {
p(.class("type-eyebrow")) {
"CCN grading guide · public"
}
h1(.class("type-h1")) {
"How CCN sellers grade raw books"
}
p(.class("type-lead")) {
"Slabbed books carry the grader's number and a cert you can verify. Raw books are graded by the seller against this scale, and every defect on the checklist below must be declared. A declared defect is never grounds for a dispute; an undeclared one always is."
}
}
ol(
.class("guide__scale"),
.custom(name: "aria-label", value: "The grading scale, best first")
) {
ForEach(Grading.scale) { step in
li(.class("guide__grade")) {
strong {
step.grade.gradeLabel
}
span {
step.code
}
}
}
}
section(.class("guide__section")) {
h2(.class("type-h2")) {
"Reference grades"
}
ul(.class("guide__references")) {
ForEach(Grading.references) { reference in
li(.class("card guide__reference")) {
div(.class("guide__reference-grade")) {
span(.class("type-stat")) {
reference.grade.gradeLabel
}
span(.class("type-label")) {
reference.name
}
}
// The design leaves the example photos as placeholders; so does the prototype.
div(
.class("guide__photo"),
.custom(name: "aria-hidden", value: "true")
) {
"Photo: \(reference.grade.gradeLabel) example"
}
div {
h3(.class("type-eyebrow guide__allowed")) {
"Allowed"
}
p(.class("type-body-sm")) {
reference.allowed
}
}
div {
h3(.class("type-eyebrow guide__excluded")) {
"Not at this grade"
}
p(.class("type-body-sm")) {
reference.excluded
}
}
}
}
}
}
section(.class("guide__section")) {
h2(.class("type-h2")) {
"The defect checklist, defined"
}
p(.class("type-body-sm guide__intro")) {
"These are the eight boxes on every listing form. Tick everything that applies and say where it is; the photos should show it."
}
dl(.class("guide__defects")) {
ForEach(Grading.Defect.allCases) { defect in
div(.class("card guide__defect")) {
dt(.class("type-label")) {
defect.label
}
dd(.class("type-caption")) {
defect.definition
}
}
}
}
}
}
}
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.guide]
}
var summary: String? {
"The scale CCN sellers grade raw comics on, with reference grades and the defect checklist every listing declares."
}
var title: String {
"Grading guide — CCN"
}
}
@@ -62,7 +62,21 @@ extension ListingPage: Page {
breadcrumb
div(.class("listing__body")) {
div(.class("listing__media")) {
Cover(listing.cover, modifier: "cover--hero")
ListingImage(listing, modifier: "cover--hero")
if listing.photos.count > 1 {
ul(.class("listing__photos")) {
ForEach(Array(listing.photos.dropFirst())) { photo in
li {
img(
.class("photo"),
.src(photo),
.alt("Another photo of \(listing.title)"),
.custom(name: "loading", value: "lazy")
)
}
}
}
}
}
div(.class("listing__main")) {
div(.class("listing__intro")) {
@@ -300,6 +314,7 @@ private extension ListingPage {
("Printing", listing.printingDetail),
(listing.grade == nil ? "Condition" : "Grade", grade),
("Page quality", listing.pageQuality),
("Declared defects", listing.defects.isEmpty ? nil : listing.defects.joined(separator: "; ")),
("Key notes", listing.keyNotes),
("Ships", "\(listing.shop.shipsNote.prefix(1).uppercased())\(listing.shop.shipsNote.dropFirst()) · \(listing.shop.handlingDays) business days handling"),
].compactMap { term, value in
@@ -0,0 +1,445 @@
import Elementary
import Foundation
import Infrastructure
import Persistence
/// The new-listing form: what the copy is, its condition against the grading guide, photos of it, and its price.
///
/// Every field applies to some format or grading; without scripts they all show, labelled by what they are for, and the server checks what
/// applies. With scripts, `js/shared.js` hides the ones that do not.
struct NewListingPage {
// MARK: Properties
let analytics: Analytics?
let assetVersion: String?
let locale: Locale
/// The values the form is filled with.
let form: ListingForm
/// What is wrong with the form, by field.
let problems: [ListingForm.Field: String]
/// The viewer's shop.
let shop: Shop
/// The signed-in viewer.
let viewer: Viewer?
// MARK: Initializers
/// Creates the new-listing form.
/// - Parameters:
/// - shop: the viewer's shop.
/// - form: the values the form is filled with; an empty form by default.
/// - problems: what is wrong with the form, by field; nothing by default.
/// - viewer: the signed-in viewer.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
init(
shop: Shop,
form: ListingForm = .init(),
problems: [ListingForm.Field: String] = [:],
viewer: Viewer? = nil,
assetVersion: String? = nil,
analytics: Analytics? = nil
) {
self.analytics = analytics
self.assetVersion = assetVersion
self.form = form
self.locale = .init(identifier: "en")
self.problems = problems
self.shop = shop
self.viewer = viewer
}
}
// MARK: - Page
extension NewListingPage: Page {
// MARK: Constants
/// The path the page is served at, and the form posted to.
static let path = "\(String.Route.sellerDashboard)/new"
// MARK: Computed
var content: some HTML {
SiteLayout(viewer: viewer) {
div(.class("page listing-form")) {
div(.class("listing-form__head")) {
p(.class("type-eyebrow")) {
"\(shop.name) · seller tools"
}
h1(.class("type-h1")) {
"New listing"
}
}
if !problems.isEmpty {
div(
.class("notice notice--bad"),
.custom(name: "role", value: "alert")
) {
p {
strong {
problems.count == 1 ? "One thing to fix before publishing." : "\(problems.count) things to fix before publishing."
}
form.value(.photos) == nil ? " Photos have to be picked again: a browser never keeps a chosen file." : ""
}
}
}
Elementary.form(
.class("listing-form__form"),
.action(Self.path),
.method(.post),
.custom(name: "enctype", value: "multipart/form-data")
) {
what
condition
photos
price
div(.class("listing-form__submit")) {
a(
.class("button"),
.href(.Route.sellerDashboard)
) {
"Cancel"
}
button(
.class("button button--primary button--lg"),
.type(.submit)
) {
"Publish listing"
}
}
}
}
}
}
@HTMLBuilder
var metadata: some HTML {
meta(
.name("robots"),
.content("noindex")
)
siteMetadata
}
var scripts: [any Asset] {
[StaticFile.shared]
}
var stylesheets: [any Asset] {
[StaticFile.shared, StaticFile.trade]
}
var title: String {
"New listing · \(shop.name) — CCN"
}
}
// MARK: - Sections
private extension NewListingPage {
var what: some HTML {
section(.class("card listing-form__section")) {
h2(.class("type-panel")) {
"1. What is it"
}
fieldset(.class("field")) {
legend(.class("field__label")) {
"Format"
}
div(.class("segmented")) {
ForEach(Format.allCases) { format in
label(.class("segmented__option")) {
input(
.type(.radio),
.name(ListingForm.Field.format.rawValue),
.value(format.rawValue)
)
.attributes(.checked, when: form.has(.format, value: format.rawValue))
span {
format.singular
}
}
}
}
problem(.format)
}
div(.class("listing-form__grid")) {
text(.series, "Series or title", placeholder: "Nightward", span: 2)
text(.issue, "Issue or volume", placeholder: "1", hint: "Required for single issues")
text(.publisher, "Publisher", placeholder: "Meridian Comics", span: 2)
text(.year, "Cover year", placeholder: "2019", numeric: true)
select(.printing, "Printing", options: [("1", "1st printing"), ("2", "2nd printing"), ("3", "3rd printing"), ("0", "Later / unknown")])
select(.edition, "Edition", options: [("", "Not applicable"), ("Direct", "Direct"), ("Newsstand", "Newsstand")], scope: "single-issues")
text(.keyNotes, "Key notes", placeholder: "1st app. of the Nightward", hint: "Leave empty unless the issue is a key")
}
}
}
var condition: some HTML {
section(.class("card listing-form__section")) {
div(.class("section-head")) {
h2(.class("type-panel")) {
"2. Condition"
}
a(
.class("link-ink"),
.href(.Route.gradingGuide)
) {
"CCN grading guide"
}
}
div(
.class("listing-form__scope"),
.custom(name: "data-scope", value: "single-issues")
) {
p(.class("type-eyebrow")) {
"Single issues"
}
fieldset(.class("listing-form__modes")) {
legend(.class("visually-hidden")) {
"Slabbed or raw"
}
mode(.slabbed, "Slabbed (graded)", "CGC, CBCS or PGX. Buyers see the cert number to verify.")
mode(.raw, "Raw (seller-graded)", "You grade it against the guide and declare every defect.")
}
div(.class("listing-form__grid")) {
select(.grade, "Grade", options: [("", "Pick a grade")] + Grading.scale.map { ($0.grade.gradeLabel, "\($0.grade.gradeLabel) · \($0.code)") })
select(.grader, "Grader", options: [("", "—")] + Grading.graders.map { ($0, $0) }, scope: "slabbed")
select(.pageQuality, "Page quality", options: [("", "Not stated")] + Grading.pageQualities.map { ($0, $0) }, scope: "slabbed")
text(.cert, "Cert number", placeholder: "4312087-002", scope: "slabbed")
}
}
div(
.class("listing-form__scope"),
.custom(name: "data-scope", value: "collected")
) {
p(.class("type-eyebrow")) {
"Manga, trade paperbacks and hardcovers"
}
div(.class("listing-form__grid")) {
select(.condition, "Condition", options: [("", "Pick a condition")] + Grading.conditions.map { ($0, $0) })
}
}
fieldset(.class("field")) {
legend(.class("field__label")) {
"Defects"
}
p(.class("field__hint")) {
"Tick everything that applies. A buyer can't dispute a defect you declared; an undeclared one is always grounds."
}
div(.class("listing-form__checks")) {
ForEach(Grading.Defect.allCases) { defect in
label(.class("check")) {
input(
.type(.checkbox),
.name(ListingForm.Field.defects.rawValue),
.value(defect.rawValue)
)
.attributes(.checked, when: form.has(.defects, value: defect.rawValue))
defect == .caseScuffs ? "\(defect.label) (slab)" : defect.label
}
}
}
problem(.defects)
}
}
}
var photos: some HTML {
section(.class("card listing-form__section")) {
h2(.class("type-panel")) {
"3. Photos of this copy"
}
div(.class("field")) {
label(
.class("field__label"),
.for("photos")
) {
"Up to \(PhotoStore.maxCount) photos — front first, then back, spine, and the label for slabs"
}
input(
.class("listing-form__files"),
.id("photos"),
.type(.file),
.name(ListingForm.Field.photos.rawValue),
.custom(name: "accept", value: "image/jpeg,image/png,image/webp"),
.custom(name: "multiple", value: "")
)
p(.class("field__hint")) {
"JPEG, PNG or WebP, up to 3 MB each. No stock images. Without photos the listing shows a placeholder cover."
}
problem(.photos)
}
div(.class("field")) {
label(
.class("field__label"),
.for("notes")
) {
"Seller's notes (provenance, case condition, anything a photo can't show)"
}
textarea(
.class("input"),
.id("notes"),
.name(ListingForm.Field.notes.rawValue),
.custom(name: "rows", value: "3"),
.custom(name: "maxlength", value: "1000")
) {
form.value(.notes) ?? ""
}
problem(.notes)
}
}
}
var price: some HTML {
section(.class("card listing-form__section")) {
h2(.class("type-panel")) {
"4. Price and shipping"
}
div(.class("listing-form__grid")) {
text(.price, "Asking price (EUR)", placeholder: "240", numeric: true)
select(.offers, "Offers", options: [("1", "Accept offers · auto-decline under 80%"), ("0", "Fixed price, no offers")])
text(.shipping, "Tracked shipping to NL (EUR)", placeholder: "6", numeric: true)
}
p(.class("gate-note")) {
Icon(.info, size: 16)
span {
"Ships from \(shop.location) · \(shop.handlingDays) business days handling. Payment is settled between you and the buyer off-platform; CCN records the sale, the agreed price and shipping, and holds the message history for both sides."
}
}
}
}
}
// MARK: - Fields
private extension NewListingPage {
/// A way of grading a single issue, as a radio card.
func mode(
_ mode: Grading.Mode,
_ title: String,
_ text: String
) -> some HTML {
label(.class("listing-form__mode")) {
input(
.type(.radio),
.name(ListingForm.Field.grading.rawValue),
.value(mode.rawValue)
)
.attributes(.checked, when: form.has(.grading, value: mode.rawValue))
span {
strong {
title
}
span(.class("type-small")) {
text
}
}
}
}
/// What is wrong with a field, tied to it by its identifier; nothing when it is fine.
@HTMLBuilder
func problem(
_ field: ListingForm.Field
) -> some HTML {
if let problem = problems[field] {
p(
.class("field__error"),
.id("\(field.rawValue)-error")
) {
problem
}
}
}
/// A select, showing the form's value. A scoped one applies to one format or grading only.
func select(
_ field: ListingForm.Field,
_ title: String,
options: [(value: String, label: String)],
scope: String? = nil
) -> some HTML {
div(.class("field")) {
label(
.class("field__label"),
.for(field.rawValue)
) {
title
}
Elementary.select(
.class("select select--block"),
.id(field.rawValue),
.name(field.rawValue)
) {
ForEach(options) { option in
Elementary.option(.value(option.value)) {
option.label
}
.attributes(.selected, when: form.value(field) == option.value)
}
}
.attributes(
.custom(name: "aria-invalid", value: "true"),
.custom(name: "aria-describedby", value: "\(field.rawValue)-error"),
when: problems[field] != nil
)
problem(field)
}
.attributes(.custom(name: "data-scope", value: scope ?? ""), when: scope != nil)
}
/// A text field, showing the form's value.
func text(
_ field: ListingForm.Field,
_ title: String,
placeholder: String,
hint: String? = nil,
numeric: Bool = false,
span: Int = 1,
scope: String? = nil
) -> some HTML {
div(.class(span == 2 ? "field field--wide" : "field")) {
label(
.class("field__label"),
.for(field.rawValue)
) {
title
}
input(
.class("input"),
.id(field.rawValue),
.type(.text),
.name(field.rawValue),
.value(form.value(field) ?? ""),
.placeholder(placeholder)
)
.attributes(.custom(name: "inputmode", value: "numeric"), when: numeric)
.attributes(
.custom(name: "aria-invalid", value: "true"),
.custom(name: "aria-describedby", value: "\(field.rawValue)-error"),
when: problems[field] != nil
)
if let hint, problems[field] == nil {
p(.class("field__hint")) {
hint
}
}
problem(field)
}
.attributes(.custom(name: "data-scope", value: scope ?? ""), when: scope != nil)
}
}
@@ -86,11 +86,19 @@ extension SellerDashboardPage: Page {
summary
}
}
a(
.class("button button--outline"),
.href(shop.path)
) {
"View public shop"
div(.class("sell__actions")) {
a(
.class("button button--outline"),
.href(shop.path)
) {
"View public shop"
}
a(
.class("button button--primary"),
.href(NewListingPage.path)
) {
"New listing"
}
}
}
ul(.class("tiles")) {
@@ -5,7 +5,8 @@ import Persistence
/// The one passkey screen, which both signs in and joins. Drawn bare, without the site's navigation: its only way out is back.
///
/// The prototype stubs the passkey ceremony — see ``SessionController``.
/// A real passkey creates an account, or signs in one created with a passkey — see ``PasskeyController``. The demo accounts have none, so
/// the stubbed demo sign-in stays beside it — see ``SessionController``.
struct SignInPage {
// MARK: Properties
@@ -86,20 +87,29 @@ extension SignInPage: Page {
form(
.class("gate__form"),
.action(.Route.signIn),
.method(.post)
.method(.post),
.custom(name: "data-passkey-form", value: "")
) {
input(
.type(.hidden),
.name("return"),
.value(returnPath)
)
// Shown by `js/passkey.js` once the browser is known to do WebAuthn.
button(
.class("button button--primary button--xl button--block"),
.type(.submit)
.type(.button),
.hidden,
.custom(name: "data-passkey", value: "")
) {
Icon(.key, size: 18)
"Continue with passkey"
}
p(
.class("field__error"),
.custom(name: "role", value: "alert"),
.custom(name: "data-passkey-status", value: "")
) {}
div(.class("field")) {
label(
.class("field__label"),
@@ -128,24 +138,21 @@ extension SignInPage: Page {
error
}
}
div(.class("gate__saved")) {
Icon(.key, size: 16)
span {
strong {
AccountRepository.demoEmail
}
span(.class("gate__saved-note")) {
" · saved passkey"
}
}
}
p(.class("field__hint")) {
"Unknown email? We create your account and passkey in the same step — nothing else to fill in."
}
}
}
p(.class("notice notice--warn")) {
"Prototype: passkeys are stubbed. Continue signs you in as the saved demo buyer; an email signs in as that account, or creates it. To sell, use joost@kestrelrow.example."
div(.class("notice notice--warn gate__demo")) {
p {
"Prototype: the demo accounts have no passkey. The demo sign-in trusts the email above — or signs in the demo buyer, \(AccountRepository.demoEmail), without one. To sell, use joost@kestrelrow.example."
}
button(
.class("button button--outline button--block"),
.type(.submit)
) {
"Demo sign-in, without a passkey"
}
}
}
p(.class("gate__recover")) {
a(.href(.Route.recover)) {
@@ -167,7 +174,7 @@ extension SignInPage: Page {
}
var scripts: [any Asset] {
[StaticFile.shared]
[StaticFile.shared, StaticFile.passkey]
}
var stylesheets: [any Asset] {
@@ -0,0 +1,256 @@
import Foundation
import Persistence
/// The new-listing form as posted: the raw values, kept to fill the form back in, and what is wrong with them.
///
/// The form is the listing's data model. Its checks apply the grading guide: a raw book is graded on the scale, a colour-breaking crease caps
/// it, and case scuffs are a slab's alone.
struct ListingForm: Sendable, Equatable {
// MARK: Properties
/// The text values posted, by field name; a field posted more than once keeps every value.
let values: [String: [String]]
// MARK: Initializers
/// Creates the form from the values posted; an empty form by default, with the most common choices made.
init(
values: [String: [String]] = [
Field.format.rawValue: [Format.singleIssues.rawValue],
Field.grading.rawValue: [Grading.Mode.slabbed.rawValue],
Field.printing.rawValue: ["1"],
Field.offers.rawValue: ["1"],
]
) {
self.values = values
}
/// Creates the form from a multipart body's text parts.
init(
_ multipart: MultipartForm
) {
var values = [String: [String]]()
for field in Field.allCases {
values[field.rawValue] = multipart.values(field.rawValue)
}
self.values = values
}
}
// MARK: - Enumerations
extension ListingForm {
/// A field of the form.
enum Field: String, CaseIterable, Sendable {
case cert
case condition
case defects
case edition
case format
case grade
case grader
case grading
case issue
case keyNotes = "key-notes"
case notes
case offers
case pageQuality = "page-quality"
case photos
case price
case printing
case publisher
case series
case shipping
case year
}
}
extension Grading {
/// How a single issue is graded.
enum Mode: String, Sendable {
/// By the seller, against the guide.
case raw
/// By a grading company, in a slab.
case slabbed
}
}
// MARK: - Extensions
extension ListingForm {
// MARK: Computed
/// The format chosen, or `nil` when none valid was.
var format: Format? {
value(.format).flatMap(Format.init)
}
/// Whether the copy is a slabbed single issue.
var isSlabbed: Bool {
format == .singleIssues && value(.grading) == Grading.Mode.slabbed.rawValue
}
// MARK: Methods
/// Whether a field holds a value.
func has(
_ field: Field,
value: String
) -> Bool {
values[field.rawValue]?.contains(value) == true
}
/// The first value of a field, or `nil` when it was left blank.
func value(
_ field: Field
) -> String? {
values[field.rawValue]?.first
}
/// Checks the form.
/// - Parameters:
/// - photos: the URL paths of the photos stored for the listing.
/// - currentYear: the year cover years are checked against.
/// - Returns: the draft the form describes, or what is wrong, by field.
func validated(
photos: [String],
currentYear: Int = Calendar(identifier: .gregorian).component(.year, from: .init())
) -> Result<ListingDraft, Problems> {
var problems = [Field: String]()
func required(
_ field: Field,
_ name: String,
max: Int = 80
) -> String {
guard let value = value(field) else {
problems[field] = "Enter \(name)."
return ""
}
if value.count > max {
problems[field] = "Keep \(name) under \(max) characters."
}
return value
}
let series = required(.series, "the series or title")
let publisher = required(.publisher, "the publisher")
let year = value(.year).flatMap(Int.init) ?? 0
if !(1_900...currentYear + 1).contains(year) {
problems[.year] = "Enter a cover year between 1900 and \(currentYear + 1)."
}
guard let format else {
problems[.format] = "Pick a format."
return .failure(.init(fields: problems))
}
let defects = (values[Field.defects.rawValue] ?? []).compactMap(Grading.Defect.init)
var grader: String?
var grade: Double?
var condition = ""
if format == .singleIssues {
if value(.issue) == nil {
problems[.issue] = "Enter the issue number."
}
let step = value(.grade)
.flatMap(Double.init)
.flatMap { chosen in Grading.scale.first { $0.grade == chosen } }
grade = step?.grade
condition = step?.code ?? ""
if step == nil {
problems[.grade] = "Pick a grade from the scale."
}
if isSlabbed {
grader = value(.grader).flatMap { Grading.graders.contains($0) ? $0 : nil }
if grader == nil {
problems[.grader] = "Pick the grading company."
}
if value(.cert) == nil {
problems[.cert] = "Enter the cert number on the label, so buyers can verify the grade."
}
} else {
if defects.contains(.caseScuffs) {
problems[.defects] = "Case scuffs are for slabs; this copy is raw."
} else if defects.contains(.crease), let grade, grade > Grading.creaseCap {
problems[.defects] = "A colour-breaking crease caps a raw book at \(Grading.creaseCap.gradeLabel). Lower the grade, or untick the crease."
}
}
} else {
condition = value(.condition).flatMap { Grading.conditions.contains($0) ? $0 : nil } ?? ""
if condition.isEmpty {
problems[.condition] = "Pick the condition."
}
}
let price = value(.price).flatMap(Int.init) ?? 0
let shipping = value(.shipping).flatMap(Int.init) ?? -1
if !(1...100_000).contains(price) {
problems[.price] = "Enter the asking price in whole euros."
}
if !(0...100).contains(shipping) {
problems[.shipping] = "Enter the shipping cost to NL in whole euros; 0 for free."
}
if (value(.notes) ?? "").count > 1_000 {
problems[.notes] = "Keep the notes under 1,000 characters."
}
guard problems.isEmpty else {
return .failure(.init(fields: problems))
}
return .success(.init(
series: series,
issue: value(.issue) ?? "",
publisher: publisher,
year: year,
format: format.rawValue,
printing: value(.printing).flatMap(Int.init).map { (1...3).contains($0) ? $0 : 0 } ?? 0,
edition: format == .singleIssues ? (value(.edition) ?? "") : "",
grader: grader,
grade: grade,
condition: condition,
certNumber: isSlabbed ? value(.cert) : nil,
pageQuality: isSlabbed ? value(.pageQuality).flatMap { Grading.pageQualities.contains($0) ? $0 : nil } : nil,
keyNotes: value(.keyNotes),
defects: defects.map(\.label),
sellerNotes: value(.notes) ?? "",
priceCents: price * 100,
shippingCents: shipping * 100,
acceptsOffers: value(.offers) == "1",
photos: photos
))
}
}
// MARK: - Structs
extension ListingForm {
/// What is wrong with a form, by field.
struct Problems: Error, Equatable, Sendable {
/// The problem with each field that has one.
var fields: [Field: String]
}
}
@@ -0,0 +1,104 @@
import MultipartKit
import NIOCore
/// The parts of a `multipart/form-data` body: what a form with file fields posts.
struct MultipartForm: Sendable {
// MARK: Properties
/// The parts, in the order posted.
let parts: [MultipartPart]
// MARK: Initializers
/// Parses a body.
/// - Parameters:
/// - body: the request's body, collected.
/// - contentType: the request's `Content-Type`, which names the boundary between the parts.
/// - Returns: `nil` when the content type is not multipart, or the body does not parse.
init?(
body: ByteBuffer,
contentType: String?
) {
guard
let contentType,
contentType.lowercased().hasPrefix("multipart/form-data"),
let boundary = contentType
.split(separator: ";")
.map({ $0.trimmingCharacters(in: .whitespaces) })
.first(where: { $0.lowercased().hasPrefix("boundary=") })?
.dropFirst("boundary=".count)
.trimmingCharacters(in: .init(charactersIn: "\""))
else {
return nil
}
let parser = MultipartParser(boundary: boundary)
var parts = [MultipartPart]()
var headers = HTTPHeaders()
var buffer = ByteBuffer()
parser.onHeader = { headers.replaceOrAdd(name: $0, value: $1) }
parser.onBody = { buffer.writeBuffer(&$0) }
parser.onPartComplete = {
parts.append(.init(headers: headers, body: buffer))
headers = [:]
buffer = .init()
}
do {
try parser.execute(body)
} catch {
return nil
}
self.parts = parts
}
}
// MARK: - Extensions
extension MultipartForm {
// MARK: Methods
/// The bodies of the file parts of a name that carry a file: a file field left empty posts a part with no file name.
func files(
_ name: String
) -> [ByteBuffer] {
parts
.filter { $0.name == name && !($0.filename ?? "").isEmpty && $0.body.readableBytes > 0 }
.map(\.body)
}
/// The first text value of a name, trimmed, or `nil` when there is none or it is blank.
func value(
_ name: String
) -> String? {
values(name).first
}
/// Every text value of a name, trimmed, the blank ones dropped.
func values(
_ name: String
) -> [String] {
parts
.filter { $0.name == name && $0.filename == nil }
.map { String(buffer: $0.body).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
private extension MultipartPart {
/// The file name the part was posted with, or `nil` for a text field.
var filename: String? {
headers
.first(name: "Content-Disposition")?
.split(separator: ";")
.map { $0.trimmingCharacters(in: .whitespaces) }
.first { $0.lowercased().hasPrefix("filename=") }
.map { String($0.dropFirst("filename=".count)).trimmingCharacters(in: .init(charactersIn: "\"")) }
}
}
@@ -54,12 +54,14 @@ extension StaticFile {
.fontAnton,
.fontArchivo,
.fontOswald,
.guide,
.icon,
.icon192,
.icon512,
.index,
.listing,
.notFound,
.passkey,
.robots,
.search,
.shared,
@@ -121,6 +123,9 @@ extension StaticFile {
/// The `font/oswald-variable.woff2` display face (weights 500–700).
static let fontOswald = Self("oswald-variable", as: .woff2)
/// The `css/guide.css` stylesheet for the grading guide.
static let guide = Self("guide", as: .css)
/// The `icon.svg` icon.
static let icon = Self("icon", as: .svg)
@@ -139,6 +144,9 @@ extension StaticFile {
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
static let notFound = Self("not-found", as: .css, .js)
/// The `js/passkey.js` script running the passkey ceremonies on the sign-in page.
static let passkey = Self("passkey", as: .js)
/// The `robots.txt` crawler directives.
static let robots = Self("robots", as: .txt)
@@ -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)"
}
}
}