diff --git a/.gitignore b/.gitignore index a140df4..14df924 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,6 @@ Services/Website/Tests/DB/ fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png -fastlane/test_output \ No newline at end of file +fastlane/test_output +# Photos uploaded to listings while running locally +Services/Website/Resources/Static/uploads/ diff --git a/Services/Website/.env.local b/Services/Website/.env.local index c4ef37a..fe55149 100644 --- a/Services/Website/.env.local +++ b/Services/Website/.env.local @@ -108,4 +108,10 @@ DATABASE_PORT=5432 DATABASE_TLS=off # Username of the database to connect as. -DATABASE_USERNAME=ccn \ No newline at end of file +DATABASE_USERNAME=ccn + +# --- Passkeys ---------------------------------------------------------------- + +# Origin the sign-in screen is served at; browsers bind passkeys to it. +# WebAuthn only runs on HTTPS or on localhost (not 127.0.0.1). +PASSKEYS_ORIGIN=http://localhost:8080 diff --git a/Services/Website/Package.swift b/Services/Website/Package.swift index dee59ec..10890e8 100644 --- a/Services/Website/Package.swift +++ b/Services/Website/Package.swift @@ -44,6 +44,14 @@ let package = Package( url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.25.0" ), + .package( + url: "https://github.com/swift-server/webauthn-swift.git", + from: "1.0.0-alpha.2" + ), + .package( + url: "https://github.com/vapor/multipart-kit.git", + from: "4.7.0" + ), .package( url: "https://github.com/adam-fowler/compress-nio.git", from: "1.4.2" @@ -98,6 +106,14 @@ let package = Package( name: "HummingbirdElementary", package: "hummingbird-elementary" ), + .product( + name: "MultipartKit", + package: "multipart-kit" + ), + .product( + name: "WebAuthn", + package: "webauthn-swift" + ), ], path: "Sources/Library", resources: [ diff --git a/Services/Website/README.md b/Services/Website/README.md index 7f4b020..aa9698a 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -6,7 +6,8 @@ The service: - Serves the landing page at `GET /` (rendered once per supported language with [Elementary](https://github.com/elementary-swift/elementary) and cached), plus one prefixed route per non-default catalog language — `GET /nl` and so on (see [Language editions](#language-editions)). - Serves the public catalogue, rendered per request: search results at `GET /search` (filtered through its query parameters), a listing at `GET /listing/{slug}`, and a seller's shop at `GET /shop/{slug}`; an unknown slug gets the not-found page (see [Catalogue](#catalogue)). - Serves the path to a sale, for signed-in accounts: the offer sheet, the messages, the sale records, and the seller dashboard (see [Path to a sale](#path-to-a-sale)). -- Serves the gate: the sign-in screen at `GET /sign-in`, the sign-in at `POST /sign-in`, and the sign-out at `POST /sign-out` (see [Sign-in](#sign-in)). +- Serves the gate: the sign-in screen at `GET /sign-in`, its passkey ceremonies under `POST /sign-in/passkey/…`, the demo sign-in at `POST /sign-in`, and the sign-out at `POST /sign-out` (see [Sign-in](#sign-in)). +- Serves the public grading guide at `GET /grading-guide`, and the new-listing form at `GET`/`POST /sell/new` for the owner of a shop (see [Selling](#selling)). - Negotiates each request's language from the `lang` query parameter, the leading path segment, then its `Accept-Language` header, against the languages in the `WebsiteLibrary` String Catalog, falling back to the default (`en`) — asked for only where the answer is used, the bare landing route and the not-found page; pages are served from the per-language cache with `Content-Language` and `Vary: Accept-Language` headers. - Builds every page on the shared `Page` scaffolding from `Infrastructure`, which assembles the document head around the page's own markup: the viewport declaration, the optional `description` summary and `rel="canonical"` link, the Open Graph / Twitter link-preview tags, the JSON-LD structured-data script, and the optional analytics tracker (see [Page metadata](#page-metadata)). - Answers a liveness check at `GET /health` with a static JSON payload, and a readiness check at `GET /health/ready` that reports whether the database is reachable (`200` ready / `503` unavailable). @@ -49,8 +50,10 @@ LogRequestsMiddleware → NotFoundMiddleware (negotiates the language, renders the not-found page on .notFound) → FileMiddleware (serves Resources/Static) RootController (GET / → landing page, language negotiated; GET / → its other editions) -CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug} → rendered per request) +CatalogueController (GET /search, GET /listing/{slug}, GET /shop/{slug}, GET /grading-guide → rendered per request) SessionController (GET /sign-in, POST /sign-in, POST /sign-out) +PasskeyController (POST /sign-in/passkey/options, POST /sign-in/passkey/finish → JSON) +ListingFormController (GET /sell/new, POST /sell/new → multipart, for a shop's owner) TradeController (offer sheet, messages, sale records, seller dashboard → signed-in accounts only) HealthController (GET /health → liveness, GET /health/ready → readiness) ``` @@ -69,17 +72,24 @@ The screens follow the **CCN marketplace** design canvas: - **Landing page**: rendered once and cached, so its listing rows are a snapshot taken at launch (`HomeListings`) — a copy sold since still shows there until the next launch. - **Search and shop**: `SearchParameters` turns the query string into a `ListingFilter` and builds every link that changes it — filter pills, tabs, pagination — so the screens work as plain `GET` forms and links. Searches default to listings shipping from the EU. - **Gated actions**: signed out, buy, offer, message, save, and follow link to the sign-in screen with a `return` parameter; signed in, buy, offer, and message lead on (see [Path to a sale](#path-to-a-sale)), while save and follow render disabled. A sold copy, and a seller's own listing, show no actions. -- **Covers**: listings have no photos yet, so `Cover` draws a placeholder from the listing's lettering and tone. +- **Covers**: a listing shows its first photo, or — like every seeded listing — a placeholder `Cover` drawn from its lettering and tone. - **Scripts**: `js/shared.js` only submits the sort select on change and opens the filter rail on older desktop browsers; everything works without it. ### Sign-in -> **The passkey ceremony is stubbed.** `POST /sign-in` trusts the email it is given, and signs in the seeded demo buyer when given none. Replace it with WebAuthn before anything real depends on it. +The sign-in screen offers two ways in: +- **A passkey** (`PasskeyController`, `js/passkey.js`): real WebAuthn, through [webauthn-swift](https://github.com/swift-server/webauthn-swift). An unknown email registers a discoverable passkey and creates its account in the same step; after that the passkey signs in with or without the email. 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. +- **The demo sign-in** (`SessionController`): stubbed. It trusts the email it is given, and signs in the seeded demo buyer when given none. It exists because the seeded accounts have no passkey; remove it before anything real depends on sign-in. -- **Sessions**: `SessionStore` keeps them in memory, keyed by a random token in the `ccn_session` cookie (`HttpOnly`, `SameSite=Lax`, and `Secure` when the site is served over HTTPS). They end with the process. -- **One step signs in and joins**: an unknown email creates the account, named after the email's local part. +How it holds together: +- **Origin**: browsers bind a passkey to the page's origin, so `passkeys.origin` (`PASSKEYS_ORIGIN`) is configuration, never read off a request. It defaults to `http://localhost:8080`; WebAuthn only runs on HTTPS or on `localhost` — not `127.0.0.1`. +- **Ceremonies**: `PasskeyCeremonies` keeps each challenge in memory for five minutes, under a token in a `SameSite=Strict` cookie scoped to `/sign-in`, and hands it over once. +- **Sessions**: `SessionStore` keeps them in memory, keyed by a random token in the `ccn_session` cookie (`HttpOnly`, `SameSite=Lax`, and `Secure` over HTTPS). They end with the process. - **Return to the intent**: the `return` parameter is followed only when it is a rooted path on this site; anything else returns home. - **Per-viewer pages**: the controllers read the viewer from the request and send those pages `Cache-Control: private, no-cache`. The landing page stays cached for signed-out visitors and is rendered per request for signed-in ones. - **Sign-out** is a `POST` form in the header, so a link on another site cannot trigger it. The not-found page always renders signed out. +- **Left out**: the first-sign-in screen (display name, backup passkey), recovery, and managing passkeys from an account screen. + +The signatures need a WebAuthn client, so the ceremonies are checked in a browser rather than in `swift test`: `Tests/Browser/passkeys.py` drives headless Chrome with a virtual authenticator over the DevTools protocol (Python standard library only; the script's header has the commands). ### Path to a sale | Route | Screen | @@ -93,7 +103,14 @@ The screens follow the **CCN marketplace** design canvas: - **The rules live in `Persistence`**: `TradeRepository` decides who may offer, answer, or take a step; `TradeController` only maps a refusal to the sheet re-rendered with a `422`, or to a redirect that changes nothing. - **Privacy**: a conversation or sale the account is no party to is not found, rather than forbidden. - **Demo accounts**: every seeded shop has an owner; sign in as `joost@kestrelrow.example` to sell for Kestrel Row Comics. -- **Left out**: counter-offers, problem reports and disputes, the PDF record, price guidance, vacation mode, and new listings. +- **Left out**: counter-offers, problem reports and disputes, the PDF record, price guidance, and vacation mode. + +### Selling +- **One vocabulary**: `Grading` holds the scale, the conditions, and the eight-box defect checklist. The grading guide prints it, the form offers it, and `ListingForm` applies it — a colour-breaking crease caps a raw book at 7.0, and case scuffs are a slab's alone. +- **The form is the data model**: format, catalogue fields, slabbed or raw, grade, declared defects, photos, price. It posts as `multipart/form-data`, parsed with [MultipartKit](https://github.com/vapor/multipart-kit). Without scripts every field shows, labelled by what it applies to; with them, `js/shared.js` hides the rest. +- **Declared defects** show on the listing and are frozen into a sale's declaration at agreement. +- **Photos**: up to four, 3 MB each, told apart as JPEG, PNG, or WebP by their leading bytes — not by name or declared type — and stored under names of the store's own in `Resources/Static/uploads`, which `FileMiddleware` serves. The fields are checked first, so a form sent back leaves nothing on disk. +- **Prototype limits**: photos are stored as posted — not re-encoded, so they keep their metadata — on the instance's own disk; the container image would need that folder writable. The design's catalogue match, live preview, drafts, and price guidance are left out. ### Page metadata Each page conforms to `Infrastructure`'s `Page` protocol and supplies only its `title`, `content`, `stylesheets`, and `scripts`; the protocol assembles the document around them and renders the head in a fixed order: the viewport declaration, the `analytics` origin preconnect hint, the `summary`, the `canonicalURL` link, the `socialCard` tags, the `structuredData` script, the `analytics` tracker script, then the page `metadata` and the stylesheet links. The body is the content followed by the script tags. diff --git a/Services/Website/Resources/Static/css/guide.css b/Services/Website/Resources/Static/css/guide.css new file mode 100644 index 0000000..1501061 --- /dev/null +++ b/Services/Website/Resources/Static/css/guide.css @@ -0,0 +1,126 @@ +/* CCN — grading guide. */ + +.guide, +.guide__section { + display: flex; + flex-direction: column; +} + +.guide { + gap: var(--space-10); +} + +.guide__head { + display: flex; + flex-direction: column; + gap: var(--space-3); + max-width: 760px; +} + +.guide__scale { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); + gap: var(--space-2); +} + +.guide__grade { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: var(--space-2) var(--space-1); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--card); + color: var(--muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; +} + +.guide__grade strong { + color: var(--ink); + font-family: var(--font-display); + font-size: 18px; + letter-spacing: 0; +} + +.guide__section { + gap: var(--space-5); +} + +.guide__intro { + max-width: 720px; + color: var(--ink-soft); +} + +.guide__references { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.guide__reference { + display: grid; + gap: var(--space-4); + align-items: start; +} + +.guide__reference-grade { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.guide__photo { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 2 / 3; + max-width: 140px; + padding: var(--space-2); + border: 1px dashed var(--rule); + border-radius: var(--radius-sm); + color: var(--muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-align: center; + text-transform: uppercase; +} + +.guide__reference .type-eyebrow { + margin-bottom: var(--space-2); +} + +.guide__allowed { + color: var(--good); +} + +.guide__excluded { + color: var(--bad); +} + +.guide__defects { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: var(--space-4); +} + +.guide__defect { + display: flex; + flex-direction: column; + gap: 6px; + padding: var(--space-4); +} + +@media (min-width: 1024px) { + .guide { + padding-top: var(--space-10); + } + + .guide__reference { + grid-template-columns: 180px 140px minmax(0, 1fr) minmax(0, 1fr); + gap: var(--space-6); + } +} diff --git a/Services/Website/Resources/Static/css/listing.css b/Services/Website/Resources/Static/css/listing.css index 692093d..5a266fa 100644 --- a/Services/Website/Resources/Static/css/listing.css +++ b/Services/Website/Resources/Static/css/listing.css @@ -21,6 +21,13 @@ box-shadow: var(--shadow-cover); } +.listing__photos { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); + padding-top: var(--space-4); +} + .listing__main { display: flex; flex: 1 1 auto; diff --git a/Services/Website/Resources/Static/css/shared.css b/Services/Website/Resources/Static/css/shared.css index 7075174..660b0f8 100644 --- a/Services/Website/Resources/Static/css/shared.css +++ b/Services/Website/Resources/Static/css/shared.css @@ -1541,6 +1541,16 @@ textarea.input { padding-left: 16cqw; } +/* A photo of a copy, cropped to a cover's proportions. */ +.photo { + display: block; + width: 100%; + aspect-ratio: 2 / 3; + border-radius: var(--radius-sm); + background: var(--tint); + object-fit: cover; +} + /* Listing cards */ .listing-card { position: relative; @@ -1747,6 +1757,13 @@ textarea.input { font-weight: 500; } +.select--block { + width: 100%; + height: var(--control-md); + border-color: var(--rule); + font-size: 14px; +} + .sort { display: flex; align-items: center; diff --git a/Services/Website/Resources/Static/css/sign-in.css b/Services/Website/Resources/Static/css/sign-in.css index 8ded02b..df14e4e 100644 --- a/Services/Website/Resources/Static/css/sign-in.css +++ b/Services/Website/Resources/Static/css/sign-in.css @@ -69,6 +69,12 @@ gap: var(--space-7); } +.gate__demo { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + .gate__saved { display: flex; align-items: center; diff --git a/Services/Website/Resources/Static/css/trade.css b/Services/Website/Resources/Static/css/trade.css index 3b8a6cf..8f702b4 100644 --- a/Services/Website/Resources/Static/css/trade.css +++ b/Services/Website/Resources/Static/css/trade.css @@ -782,3 +782,117 @@ a.rows__row:hover { grid-template-columns: 100px minmax(0, 2fr) 70px minmax(0, 1fr) 340px 130px; } } + +/* ========================================================================== + New listing + ========================================================================== */ + +.listing-form, +.listing-form__form, +.listing-form__section, +.listing-form__scope { + display: flex; + flex-direction: column; +} + +.listing-form { + gap: var(--space-6); + max-width: calc(880px + 2 * var(--gutter)); +} + +.listing-form__head { + display: flex; + flex-direction: column; + gap: 6px; +} + +.listing-form__form { + gap: var(--space-5); +} + +.listing-form__section { + gap: var(--space-5); +} + +.listing-form__scope { + gap: var(--space-4); +} + +.listing-form__grid { + display: grid; + gap: var(--space-4); + align-items: start; +} + +.listing-form__modes { + display: grid; + gap: var(--space-3); +} + +.listing-form__mode { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--rule); + border-radius: var(--radius-sm); + cursor: pointer; +} + +.listing-form__mode:has(input:checked) { + border-color: var(--ink); + background: var(--tint); +} + +.listing-form__mode span { + display: flex; + flex-direction: column; + gap: var(--space-1); + font-size: 14px; +} + +.listing-form__checks { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px var(--space-4); +} + +.listing-form__files { + padding: var(--space-4); + border: 1px dashed var(--rule); + border-radius: var(--radius-sm); + background: var(--paper); + font-size: 13px; +} + +.listing-form__submit { + display: flex; + justify-content: flex-end; + gap: var(--space-3); +} + +.sell__actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +@media (min-width: 600px) { + .listing-form__grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .listing-form__modes { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .field--wide { + grid-column: span 2; + } +} + +@media (min-width: 1024px) { + .listing-form { + padding-top: var(--space-8); + } +} diff --git a/Services/Website/Resources/Static/js/passkey.js b/Services/Website/Resources/Static/js/passkey.js new file mode 100644 index 0000000..ed76762 --- /dev/null +++ b/Services/Website/Resources/Static/js/passkey.js @@ -0,0 +1,60 @@ +// CCN — passkey sign-in. Without it, or without WebAuthn, the sign-in screen offers the demo sign-in alone. + +const form = document.querySelector("[data-passkey-form]"); +const supported = window.PublicKeyCredential + && PublicKeyCredential.parseCreationOptionsFromJSON + && PublicKeyCredential.parseRequestOptionsFromJSON; + +if (form && supported) { + const button = form.querySelector("[data-passkey]"); + const status = form.querySelector("[data-passkey-status]"); + + const post = async (path, body) => { + const response = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const answer = await response.json(); + + if (!response.ok) { + throw new Error(answer.error ?? "Something went wrong. Try again."); + } + + return answer; + }; + + button.hidden = false; + button.addEventListener("click", async () => { + button.disabled = true; + status.textContent = ""; + + try { + const { mode, options } = await post("/sign-in/passkey/options", { email: form.elements.email.value }); + let credential; + + if (mode === "create") { + // A discoverable passkey, so the next sign-in needs no email. + options.authenticatorSelection = { residentKey: "required", userVerification: "preferred" }; + credential = await navigator.credentials.create({ + publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options), + }); + } else { + credential = await navigator.credentials.get({ + publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options), + }); + } + + const { location } = await post("/sign-in/passkey/finish", { + credential: credential.toJSON(), + return: form.elements.return.value, + }); + + window.location.assign(location); + } catch (error) { + // Dismissing the browser's prompt is not an error worth a message. + status.textContent = error.name === "NotAllowedError" ? "" : error.message; + button.disabled = false; + } + }); +} diff --git a/Services/Website/Resources/Static/js/shared.js b/Services/Website/Resources/Static/js/shared.js index 1c3ffc3..dc50edb 100644 --- a/Services/Website/Resources/Static/js/shared.js +++ b/Services/Website/Resources/Static/js/shared.js @@ -21,3 +21,22 @@ if (filters) { sync(); desktop.addEventListener("change", sync); } + +// The new-listing form shows every field without scripts; here, only those that apply to the format and grading picked. +const listingForm = document.querySelector(".listing-form__form"); + +if (listingForm) { + const apply = () => { + const format = listingForm.querySelector("input[name=format]:checked")?.value; + const grading = listingForm.querySelector("input[name=grading]:checked")?.value; + const single = format === "single-issues"; + const scopes = { "single-issues": single, collected: !single, slabbed: single && grading === "slabbed" }; + + for (const element of listingForm.querySelectorAll("[data-scope]")) { + element.hidden = !scopes[element.dataset.scope]; + } + }; + + listingForm.addEventListener("change", apply); + apply(); +} diff --git a/Services/Website/Sources/App/Extensions/App+Build.swift b/Services/Website/Sources/App/Extensions/App+Build.swift index f129d5e..a1251c6 100644 --- a/Services/Website/Sources/App/Extensions/App+Build.swift +++ b/Services/Website/Sources/App/Extensions/App+Build.swift @@ -69,6 +69,7 @@ func application( // building both against an empty host. siteOrigin: reader.siteOrigin.isEmpty ? nil : reader.siteOrigin, logLevel: reader.logLevel, + passkeysOrigin: reader.passkeysOrigin, probe: Probe(fluent: fluent), accounts: AccountRepository(fluent: fluent), trade: TradeRepository(fluent: fluent), @@ -183,6 +184,7 @@ private func logger( /// - securityHeaders: the security headers applied to every response. /// - siteOrigin: the public origin the pages derive their canonical URLs and language alternates from, or `nil` to omit them. /// - logLevel: the level the request-logging middleware logs at. +/// - passkeysOrigin: the origin the `PasskeyController` binds passkeys to. /// - probe: the probe consulted by the `HealthController` readiness route. /// - accounts: the repository the `SessionController` finds and creates accounts through. /// - trade: the repository the `TradeController` reads and writes conversations, offers, and sales through. @@ -200,6 +202,7 @@ private func router( securityHeaders: SecurityHeadersMiddleware.Configuration, siteOrigin: String?, logLevel: Logger.Level, + passkeysOrigin: String, probe: Probe, accounts: AccountRepository, trade: TradeRepository, @@ -260,6 +263,19 @@ private func router( assetVersion: assetVersion, analytics: analytics ) + PasskeyController( + origin: passkeysOrigin, + accounts: accounts, + sessions: sessions + ) + ListingFormController( + catalogue: repository, + trade: trade, + sessions: sessions, + photos: PhotoStore(staticFilesPath: staticFilesPath), + assetVersion: assetVersion, + analytics: analytics + ) TradeController( trade: trade, catalogue: repository, diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift index 338cd5f..3d80e30 100644 --- a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift +++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift @@ -264,6 +264,14 @@ package extension ConfigReader { ) } + /// The origin passkeys are bound to, read from the `passkeys.origin` key. + var passkeysOrigin: String { + string( + forKey: .Passkeys.origin, + default: .Passkeys.origin + ) + } + /// The directory the static files are served from, read from the `path.staticFiles` key. var staticFilesPath: String { string( diff --git a/Services/Website/Sources/Library/Internal/Components/ListingCard.swift b/Services/Website/Sources/Library/Internal/Components/ListingCard.swift index f3ca721..65a9a6a 100644 --- a/Services/Website/Sources/Library/Internal/Components/ListingCard.swift +++ b/Services/Website/Sources/Library/Internal/Components/ListingCard.swift @@ -53,7 +53,7 @@ struct ListingCard: HTML { value: "true" ) ) { - Cover(listing.cover) + ListingImage(listing) } div { h3(.class("type-card-title")) { diff --git a/Services/Website/Sources/Library/Internal/Components/ListingImage.swift b/Services/Website/Sources/Library/Internal/Components/ListingImage.swift new file mode 100644 index 0000000..08d64c7 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Components/ListingImage.swift @@ -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) + } + } + +} diff --git a/Services/Website/Sources/Library/Internal/Enumerations/Grading.swift b/Services/Website/Sources/Library/Internal/Enumerations/Grading.swift new file mode 100644 index 0000000..2a1bd50 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Enumerations/Grading.swift @@ -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" + } + } + } +} diff --git a/Services/Website/Sources/Library/Internal/Pages/GradingGuidePage.swift b/Services/Website/Sources/Library/Internal/Pages/GradingGuidePage.swift new file mode 100644 index 0000000..deb6db7 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/GradingGuidePage.swift @@ -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" + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift index a97c77b..0a75c38 100644 --- a/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/ListingPage.swift @@ -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 diff --git a/Services/Website/Sources/Library/Internal/Pages/NewListingPage.swift b/Services/Website/Sources/Library/Internal/Pages/NewListingPage.swift new file mode 100644 index 0000000..e82d366 --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Pages/NewListingPage.swift @@ -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) + } + +} diff --git a/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift b/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift index e484299..d68cfc4 100644 --- a/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/SellerDashboardPage.swift @@ -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")) { diff --git a/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift index 8d52c53..d5cb4b5 100644 --- a/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift +++ b/Services/Website/Sources/Library/Internal/Pages/SignInPage.swift @@ -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] { diff --git a/Services/Website/Sources/Library/Internal/Types/ListingForm.swift b/Services/Website/Sources/Library/Internal/Types/ListingForm.swift new file mode 100644 index 0000000..0e689df --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Types/ListingForm.swift @@ -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 { + 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] + } +} diff --git a/Services/Website/Sources/Library/Internal/Types/MultipartForm.swift b/Services/Website/Sources/Library/Internal/Types/MultipartForm.swift new file mode 100644 index 0000000..849db2c --- /dev/null +++ b/Services/Website/Sources/Library/Internal/Types/MultipartForm.swift @@ -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: "\"")) } + } +} diff --git a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift index 09195d9..250a72c 100644 --- a/Services/Website/Sources/Library/Internal/Types/StaticFile.swift +++ b/Services/Website/Sources/Library/Internal/Types/StaticFile.swift @@ -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) diff --git a/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift b/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift index cfcfb29..2937950 100644 --- a/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift +++ b/Services/Website/Sources/Library/Public/Controllers/CatalogueController.swift @@ -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. diff --git a/Services/Website/Sources/Library/Public/Controllers/ListingFormController.swift b/Services/Website/Sources/Library/Public/Controllers/ListingFormController.swift new file mode 100644 index 0000000..fa2311a --- /dev/null +++ b/Services/Website/Sources/Library/Public/Controllers/ListingFormController.swift @@ -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 { + + // 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 { + 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." + } + } + +} diff --git a/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift b/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift new file mode 100644 index 0000000..968705a --- /dev/null +++ b/Services/Website/Sources/Library/Public/Controllers/PasskeyController.swift @@ -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 { + + // 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 { + 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.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.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: Decodable { + let credential: Credential + let `return`: String? +} diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index d33245c..edec7dd 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -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" diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift index 2ec035c..c3beade 100644 --- a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -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. diff --git a/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift b/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift new file mode 100644 index 0000000..bbb50f1 --- /dev/null +++ b/Services/Website/Sources/Library/Public/Types/PasskeyCeremonies.swift @@ -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 + ) + } + +} diff --git a/Services/Website/Sources/Library/Public/Types/PhotoStore.swift b/Services/Website/Sources/Library/Public/Types/PhotoStore.swift new file mode 100644 index 0000000..df84203 --- /dev/null +++ b/Services/Website/Sources/Library/Public/Types/PhotoStore.swift @@ -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)" + } + } + +} diff --git a/Services/Website/Tests/Browser/cdp.py b/Services/Website/Tests/Browser/cdp.py new file mode 100644 index 0000000..6f3f59c --- /dev/null +++ b/Services/Website/Tests/Browser/cdp.py @@ -0,0 +1,90 @@ +"""Drives headless Chrome over the DevTools protocol with a stdlib-only WebSocket client, to run real WebAuthn ceremonies.""" +import base64, json, os, socket, struct, sys, time, urllib.request + +class CDP: + def __init__(self, url): + host, rest = url[len("ws://"):].split("/", 1) + name, port = host.split(":") + self.sock = socket.create_connection((name, int(port))) + key = base64.b64encode(os.urandom(16)).decode() + self.sock.sendall((f"GET /{rest} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode()) + response = b"" + while b"\r\n\r\n" not in response: + response += self.sock.recv(4096) + assert b" 101 " in response.split(b"\r\n")[0], response + self.buffer = response.split(b"\r\n\r\n", 1)[1] + self.next_id = 0 + + def _read(self, count): + while len(self.buffer) < count: + chunk = self.sock.recv(65536) + if not chunk: + raise ConnectionError("closed") + self.buffer += chunk + data, self.buffer = self.buffer[:count], self.buffer[count:] + return data + + def _frame(self): + first, second = self._read(2) + length = second & 0x7F + if length == 126: + length = struct.unpack(">H", self._read(2))[0] + elif length == 127: + length = struct.unpack(">Q", self._read(8))[0] + return first & 0x0F, first & 0x80, self._read(length) + + def _message(self): + payload = b"" + while True: + opcode, final, data = self._frame() + if opcode in (0x0, 0x1, 0x2): + payload += data + if final: + return json.loads(payload) + + def send(self, method, **params): + self.next_id += 1 + data = json.dumps({"id": self.next_id, "method": method, "params": params}).encode() + mask = os.urandom(4) + header = bytes([0x81]) + if len(data) < 126: + header += bytes([0x80 | len(data)]) + elif len(data) < 65536: + header += bytes([0x80 | 126]) + struct.pack(">H", len(data)) + else: + header += bytes([0x80 | 127]) + struct.pack(">Q", len(data)) + self.sock.sendall(header + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data))) + while True: + message = self._message() + if message.get("id") == self.next_id: + if "error" in message: + raise RuntimeError(f"{method}: {message['error']}") + return message.get("result", {}) + + def evaluate(self, expression): + result = self.send("Runtime.evaluate", expression=expression, awaitPromise=True, returnByValue=True) + if "exceptionDetails" in result: + raise RuntimeError(result["exceptionDetails"].get("exception", {}).get("description", str(result["exceptionDetails"]))) + return result["result"].get("value") + + def wait_for(self, expression, seconds=10): + deadline = time.time() + seconds + while time.time() < deadline: + try: + if self.evaluate(expression): + return True + except RuntimeError: + pass + time.sleep(0.2) + return False + +def connect(port): + for _ in range(50): + try: + targets = json.load(urllib.request.urlopen(f"http://127.0.0.1:{port}/json")) + page = next(t for t in targets if t["type"] == "page") + return CDP(page["webSocketDebuggerUrl"]) + except Exception: + time.sleep(0.2) + sys.exit("Chrome's DevTools endpoint never answered") diff --git a/Services/Website/Tests/Browser/passkeys.py b/Services/Website/Tests/Browser/passkeys.py new file mode 100644 index 0000000..8cff0a5 --- /dev/null +++ b/Services/Website/Tests/Browser/passkeys.py @@ -0,0 +1,76 @@ +"""Runs real passkey ceremonies against a locally running Website, in headless Chrome with a virtual authenticator. + + swift run Website & # serves http://localhost:8080 over the in-memory database + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --remote-debugging-port=9333 \\ + --user-data-dir="$(mktemp -d)" about:blank & + python3 Tests/Browser/passkeys.py + +Needs a fresh server: it joins accounts that must not exist yet. Stdlib only. +""" +import os, sys, time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from cdp import connect + +BASE = "http://localhost:8080" +chrome = connect(9333) +chrome.send("Page.enable") +chrome.send("Runtime.enable") +chrome.send("WebAuthn.enable") +authenticator = chrome.send("WebAuthn.addVirtualAuthenticator", options={ + "protocol": "ctap2", "transport": "internal", "hasResidentKey": True, + "hasUserVerification": True, "isUserVerified": True, "automaticPresenceSimulation": True, +})["authenticatorId"] + +def open_sign_in(return_path): + chrome.send("Page.navigate", url=f"{BASE}/sign-in?return={return_path}") + assert chrome.wait_for("document.querySelector('[data-passkey]') && !document.querySelector('[data-passkey]').hidden"), \ + "the passkey button never appeared" + +def press(email): + chrome.evaluate(f"document.querySelector('#email').value = {email!r}; document.querySelector('[data-passkey]').click(); true") + +def status(): + return chrome.evaluate("document.querySelector('[data-passkey-status]')?.textContent ?? ''") + +def credentials(): + return chrome.send("WebAuthn.getCredentials", authenticatorId=authenticator)["credentials"] + +results = [] +def check(name, ok, detail=""): + results.append(ok) + print(f"{'PASS' if ok else 'FAIL'} {name}{' · ' + str(detail) if detail else ''}") + +# 1. An unknown email registers a passkey and creates the account, then returns to the intent. +open_sign_in("/search") +press("sam.okafor@example.com") +landed = chrome.wait_for("location.pathname === '/search'") +check("join: registers a passkey and lands on the intent", landed, status() if not landed else chrome.evaluate("location.pathname")) +check("join: the page is rendered for the new account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML")) +held = credentials() +check("join: the authenticator holds one discoverable credential", len(held) == 1 and held[0]["isResidentCredential"], f"{len(held)} held") + +# 2. Signed out, the same passkey signs in with no email at all. +chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)") +open_sign_in("/grading-guide") +press("") +landed = chrome.wait_for("location.pathname === '/grading-guide'") +check("sign-in: a discoverable passkey signs in without an email", landed, status() if not landed else "") +check("sign-in: as the same account", "Account of sam.okafor" in chrome.evaluate("document.documentElement.outerHTML")) +check("sign-in: the signature counter moved on", credentials()[0]["signCount"] >= 2, credentials()[0]["signCount"]) + +# 3. By email, the ceremony names the account's credential. +chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)") +open_sign_in("/") +press("Sam.Okafor@Example.com") +check("sign-in: by email, normalized", chrome.wait_for("location.pathname === '/'"), status()) + +# 4. A demo account has no passkey, and none can be added to it. +chrome.evaluate("fetch('/sign-out', {method: 'POST'}).then(() => true)") +open_sign_in("/") +press("mara.v@example.com") +refused = chrome.wait_for("(document.querySelector('[data-passkey-status]')?.textContent ?? '').includes('no passkey')") +check("refusal: an existing account is never handed a new passkey", refused and len(credentials()) == 1, status()) +check("refusal: nobody was signed in", chrome.evaluate("location.pathname") == "/sign-in") + +sys.exit(0 if all(results) else 1) diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift index 7d15dc8..b042586 100644 --- a/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/CataloguePagesTests.swift @@ -175,6 +175,17 @@ struct CataloguePagesTests { #expect(!html.contains("style=")) } + @Test + func `sign-in page hides the passkey button until its script finds WebAuthn, and keeps the demo sign-in working without it`() { + let html = SignInPage(returnPath: "/").render() + + #expect(html.contains(#"type="button" hidden data-passkey"#)) + #expect(html.contains("/js/passkey.js")) + #expect(html.contains(#"role="alert" data-passkey-status"#)) + #expect(html.contains(#"type="submit">Demo sign-in, without a passkey"#)) + #expect(html.contains("joost@kestrelrow.example")) + } + @Test func `sign-in page ties an error to the email field`() { let html = SignInPage( diff --git a/Services/Website/Tests/Library/Cases/Internal/Pages/SellingPagesTests.swift b/Services/Website/Tests/Library/Cases/Internal/Pages/SellingPagesTests.swift new file mode 100644 index 0000000..40bdc89 --- /dev/null +++ b/Services/Website/Tests/Library/Cases/Internal/Pages/SellingPagesTests.swift @@ -0,0 +1,118 @@ +import Elementary +import NIOCore +import Persistence +import Testing + +@testable import WebsiteLibrary + +@Suite( + "Selling pages", + .tags(.page) +) +struct SellingPagesTests { + + // MARK: GradingGuidePage tests + + @Test + func `grading guide prints the whole scale and defines every defect`() { + let html = GradingGuidePage().render() + + for step in Grading.scale { + #expect(html.contains("\(step.grade.gradeLabel)\(step.code)")) + } + + for defect in Grading.Defect.allCases { + #expect(html.contains(defect.label)) + #expect(html.contains(defect.definition)) + } + + #expect(html.contains("Not at this grade")) + #expect(html.contains("/css/guide.css")) + #expect(!html.contains("noindex")) + #expect(!html.contains("style=")) + } + + // MARK: NewListingPage tests + + @Test + func `new-listing form posts as multipart and offers the guide's vocabulary`() { + let html = NewListingPage(shop: .stub()).render() + + #expect(html.contains(#"action="/sell/new" method="post" enctype="multipart/form-data""#)) + #expect(html.contains(#"type="file" name="photos" accept="image/jpeg,image/png,image/webp" multiple"#)) + #expect(html.contains(#"name="format" value="single-issues" checked"#)) + #expect(html.contains(#"name="grading" value="slabbed" checked"#)) + #expect(html.contains(#""#)) + #expect(html.contains(#"name="defects" value="crease""#)) + #expect(html.contains(#"data-scope="collected""#)) + #expect(html.contains(#"href="/grading-guide""#)) + #expect(html.contains("Ships from Amsterdam, NL")) + #expect(!html.contains(#"role="alert""#)) + #expect(!html.contains("style=")) + } + + @Test + func `new-listing form gives back what was typed, with each problem tied to its field`() { + let html = NewListingPage( + shop: .stub(), + form: ListingFormTests.form(["grading": ["raw"], "grade": ["9.4"], "defects": ["crease"], "notes": ["Light "]]), + problems: [.defects: "A colour-breaking crease caps a raw book at 7.0.", .price: "Enter the asking price in whole euros."] + ).render() + + #expect(html.contains("2 things to fix before publishing.")) + #expect(html.contains("Photos have to be picked again")) + #expect(html.contains(#"name="series" value="Nightward""#)) + #expect(html.contains(#"