Implemented phase 4 for the POC prototype in the Website service.
This commit is contained in:
1 parent
95e0a455e8
commit
9403931109
42 files changed
+3407
-36
No files matched your search
+3
-1
@@ -58,4 +58,6 @@ Services/Website/Tests/DB/
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots/**/*.png
|
||||
fastlane/test_output
|
||||
fastlane/test_output
|
||||
# Photos uploaded to listings while running locally
|
||||
Services/Website/Resources/Static/uploads/
|
||||
@@ -108,4 +108,10 @@ DATABASE_PORT=5432
|
||||
DATABASE_TLS=off
|
||||
|
||||
# Username of the database to connect as.
|
||||
DATABASE_USERNAME=ccn
|
||||
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
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 /<lang> → 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<AppRequestContext>.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<AppRequestContext>(
|
||||
origin: passkeysOrigin,
|
||||
accounts: accounts,
|
||||
sessions: sessions
|
||||
)
|
||||
ListingFormController<AppRequestContext>(
|
||||
catalogue: repository,
|
||||
trade: trade,
|
||||
sessions: sessions,
|
||||
photos: PhotoStore(staticFilesPath: staticFilesPath),
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
TradeController<AppRequestContext>(
|
||||
trade: trade,
|
||||
catalogue: repository,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -53,7 +53,7 @@ struct ListingCard: HTML {
|
||||
value: "true"
|
||||
)
|
||||
) {
|
||||
Cover(listing.cover)
|
||||
ListingImage(listing)
|
||||
}
|
||||
div {
|
||||
h3(.class("type-card-title")) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Elementary
|
||||
import Persistence
|
||||
|
||||
/// What a listing looks like: its first photo, or its placeholder cover while it has none.
|
||||
struct ListingImage: HTML {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The listing shown.
|
||||
let listing: Listing
|
||||
|
||||
/// A modifier class positioning or sizing the image, or `nil` for none.
|
||||
let modifier: String?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a listing's image.
|
||||
/// - Parameters:
|
||||
/// - listing: the listing shown.
|
||||
/// - modifier: a modifier class positioning or sizing the image, or `nil` (the default) for none.
|
||||
init(
|
||||
_ listing: Listing,
|
||||
modifier: String? = nil
|
||||
) {
|
||||
self.listing = listing
|
||||
self.modifier = modifier
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var body: some HTML {
|
||||
if let photo = listing.photos.first {
|
||||
img(
|
||||
.class(["photo", modifier].compactMap(\.self).joined(separator: " ")),
|
||||
.src(photo),
|
||||
.alt("Photo of \(listing.title), front"),
|
||||
.custom(name: "loading", value: "lazy")
|
||||
)
|
||||
} else {
|
||||
Cover(listing.cover, modifier: modifier)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/// The grading guide's vocabulary: the scale raw books are graded on, and the defects every listing declares.
|
||||
///
|
||||
/// Single-sourced: the public guide prints it, the listing form offers it, and the form's checks apply it.
|
||||
enum Grading {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The conditions a collected edition — ungraded — is sold in, best first.
|
||||
static let conditions = ["Sealed", "Like new", "Very good", "Good", "Acceptable"]
|
||||
|
||||
/// The companies that grade and slab.
|
||||
static let graders = ["CGC", "CBCS", "PGX"]
|
||||
|
||||
/// The highest grade a raw book with a colour-breaking crease can carry.
|
||||
static let creaseCap = 7.0
|
||||
|
||||
/// The page qualities a grader states, best first.
|
||||
static let pageQualities = ["White", "Off-white to white", "Off-white", "Cream"]
|
||||
|
||||
/// The scale, best first: each grade and its code.
|
||||
static let scale: [(grade: Double, code: String)] = [
|
||||
(10.0, "GM"), (9.8, "NM/M"), (9.6, "NM+"), (9.4, "NM"), (9.2, "NM-"), (9.0, "VF/NM"),
|
||||
(8.5, "VF+"), (8.0, "VF"), (7.5, "VF-"), (7.0, "FN/VF"), (6.5, "FN+"), (6.0, "FN"),
|
||||
(5.0, "VG/FN"), (4.0, "VG"), (3.0, "GD/VG"), (2.0, "GD"), (1.0, "FR"), (0.5, "PR"),
|
||||
]
|
||||
|
||||
/// The grades the guide illustrates: what each allows, and what rules a book out of it.
|
||||
static let references: [(grade: Double, name: String, allowed: String, excluded: String)] = [
|
||||
(9.8, "Near mint / mint", "Nearly perfect. Flat, glossy, sharp corners, tight staples. Up to one tiny bindery tear.", "Any spine stress you can see without tilting the book. Any crease."),
|
||||
(9.4, "Near mint", "A few very small spine ticks, a light corner bend. Full gloss.", "Colour breaks. Writing. Foxing."),
|
||||
(8.0, "Very fine", "Light wear: a few spine ticks, minor corner blunting, slight gloss loss.", "Colour-breaking creases longer than 1 cm. Tears."),
|
||||
(6.5, "Fine +", "Moderate wear. A colour-breaking crease or two, some spine roll, small arrival date is fine.", "Missing pieces. Detached cover."),
|
||||
(4.0, "Very good", "Well read. Multiple creases, rounded corners, possible small tear or staple rust.", "Water damage across the cover. Missing pages."),
|
||||
(2.0, "Good", "Complete but heavily worn. Tears, writing, cover may be loose at one staple.", "Anything missing — that's Fair or Poor."),
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
extension Grading {
|
||||
/// A box on the defect checklist.
|
||||
enum Defect: String, CaseIterable, Sendable {
|
||||
case spineStress = "spine-stress"
|
||||
case crease
|
||||
case cornerWear = "corner-wear"
|
||||
case foxing
|
||||
case tears
|
||||
case writing
|
||||
case detached
|
||||
case caseScuffs = "case-scuffs"
|
||||
|
||||
/// What the box means.
|
||||
var definition: String {
|
||||
switch self {
|
||||
case .spineStress: "Small non-colour-breaking lines along the spine from opening the book."
|
||||
case .crease: "A fold that has cracked the ink. Caps a raw book at 7.0 or below."
|
||||
case .cornerWear: "Rounded or frayed corners."
|
||||
case .foxing: "Brown spots or tide marks; usually humidity."
|
||||
case .tears: "Any loss of paper. Describe size and location."
|
||||
case .writing: "Arrival dates, names, distributor stamps."
|
||||
case .detached: "Cover loose at one or both staples; rusted staples."
|
||||
case .caseScuffs: "Slabs only: scratches or cracks on the holder, not the book."
|
||||
}
|
||||
}
|
||||
|
||||
/// The box's name, as listings declare it.
|
||||
var label: String {
|
||||
switch self {
|
||||
case .spineStress: "Spine stress / ticks"
|
||||
case .crease: "Colour-breaking crease"
|
||||
case .cornerWear: "Corner wear / blunting"
|
||||
case .foxing: "Foxing / staining"
|
||||
case .tears: "Tears or missing pieces"
|
||||
case .writing: "Writing / stamps"
|
||||
case .detached: "Detached cover / staples"
|
||||
case .caseScuffs: "Case scuffs / cracks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
|
||||
/// The public grading guide: the scale raw books are graded on, reference grades, and the defect checklist every listing answers.
|
||||
struct GradingGuidePage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let analytics: Analytics?
|
||||
let assetVersion: String?
|
||||
let locale: Locale
|
||||
|
||||
/// The signed-in viewer, or `nil` when signed out.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the grading guide.
|
||||
/// - Parameters:
|
||||
/// - viewer: the signed-in viewer, or `nil` (the default) when signed out.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||
init(
|
||||
viewer: Viewer? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.locale = .init(identifier: "en")
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension GradingGuidePage: Page {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The path the page is served at.
|
||||
static let path = String.Route.gradingGuide
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page guide")) {
|
||||
div(.class("guide__head")) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"CCN grading guide · public"
|
||||
}
|
||||
h1(.class("type-h1")) {
|
||||
"How CCN sellers grade raw books"
|
||||
}
|
||||
p(.class("type-lead")) {
|
||||
"Slabbed books carry the grader's number and a cert you can verify. Raw books are graded by the seller against this scale, and every defect on the checklist below must be declared. A declared defect is never grounds for a dispute; an undeclared one always is."
|
||||
}
|
||||
}
|
||||
ol(
|
||||
.class("guide__scale"),
|
||||
.custom(name: "aria-label", value: "The grading scale, best first")
|
||||
) {
|
||||
ForEach(Grading.scale) { step in
|
||||
li(.class("guide__grade")) {
|
||||
strong {
|
||||
step.grade.gradeLabel
|
||||
}
|
||||
span {
|
||||
step.code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
section(.class("guide__section")) {
|
||||
h2(.class("type-h2")) {
|
||||
"Reference grades"
|
||||
}
|
||||
ul(.class("guide__references")) {
|
||||
ForEach(Grading.references) { reference in
|
||||
li(.class("card guide__reference")) {
|
||||
div(.class("guide__reference-grade")) {
|
||||
span(.class("type-stat")) {
|
||||
reference.grade.gradeLabel
|
||||
}
|
||||
span(.class("type-label")) {
|
||||
reference.name
|
||||
}
|
||||
}
|
||||
// The design leaves the example photos as placeholders; so does the prototype.
|
||||
div(
|
||||
.class("guide__photo"),
|
||||
.custom(name: "aria-hidden", value: "true")
|
||||
) {
|
||||
"Photo: \(reference.grade.gradeLabel) example"
|
||||
}
|
||||
div {
|
||||
h3(.class("type-eyebrow guide__allowed")) {
|
||||
"Allowed"
|
||||
}
|
||||
p(.class("type-body-sm")) {
|
||||
reference.allowed
|
||||
}
|
||||
}
|
||||
div {
|
||||
h3(.class("type-eyebrow guide__excluded")) {
|
||||
"Not at this grade"
|
||||
}
|
||||
p(.class("type-body-sm")) {
|
||||
reference.excluded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
section(.class("guide__section")) {
|
||||
h2(.class("type-h2")) {
|
||||
"The defect checklist, defined"
|
||||
}
|
||||
p(.class("type-body-sm guide__intro")) {
|
||||
"These are the eight boxes on every listing form. Tick everything that applies and say where it is; the photos should show it."
|
||||
}
|
||||
dl(.class("guide__defects")) {
|
||||
ForEach(Grading.Defect.allCases) { defect in
|
||||
div(.class("card guide__defect")) {
|
||||
dt(.class("type-label")) {
|
||||
defect.label
|
||||
}
|
||||
dd(.class("type-caption")) {
|
||||
defect.definition
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.shared]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.guide]
|
||||
}
|
||||
|
||||
var summary: String? {
|
||||
"The scale CCN sellers grade raw comics on, with reference grades and the defect checklist every listing declares."
|
||||
}
|
||||
|
||||
var title: String {
|
||||
"Grading guide — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -62,7 +62,21 @@ extension ListingPage: Page {
|
||||
breadcrumb
|
||||
div(.class("listing__body")) {
|
||||
div(.class("listing__media")) {
|
||||
Cover(listing.cover, modifier: "cover--hero")
|
||||
ListingImage(listing, modifier: "cover--hero")
|
||||
if listing.photos.count > 1 {
|
||||
ul(.class("listing__photos")) {
|
||||
ForEach(Array(listing.photos.dropFirst())) { photo in
|
||||
li {
|
||||
img(
|
||||
.class("photo"),
|
||||
.src(photo),
|
||||
.alt("Another photo of \(listing.title)"),
|
||||
.custom(name: "loading", value: "lazy")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div(.class("listing__main")) {
|
||||
div(.class("listing__intro")) {
|
||||
@@ -300,6 +314,7 @@ private extension ListingPage {
|
||||
("Printing", listing.printingDetail),
|
||||
(listing.grade == nil ? "Condition" : "Grade", grade),
|
||||
("Page quality", listing.pageQuality),
|
||||
("Declared defects", listing.defects.isEmpty ? nil : listing.defects.joined(separator: "; ")),
|
||||
("Key notes", listing.keyNotes),
|
||||
("Ships", "\(listing.shop.shipsNote.prefix(1).uppercased())\(listing.shop.shipsNote.dropFirst()) · \(listing.shop.handlingDays) business days handling"),
|
||||
].compactMap { term, value in
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// The new-listing form: what the copy is, its condition against the grading guide, photos of it, and its price.
|
||||
///
|
||||
/// Every field applies to some format or grading; without scripts they all show, labelled by what they are for, and the server checks what
|
||||
/// applies. With scripts, `js/shared.js` hides the ones that do not.
|
||||
struct NewListingPage {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
let analytics: Analytics?
|
||||
let assetVersion: String?
|
||||
let locale: Locale
|
||||
|
||||
/// The values the form is filled with.
|
||||
let form: ListingForm
|
||||
|
||||
/// What is wrong with the form, by field.
|
||||
let problems: [ListingForm.Field: String]
|
||||
|
||||
/// The viewer's shop.
|
||||
let shop: Shop
|
||||
|
||||
/// The signed-in viewer.
|
||||
let viewer: Viewer?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the new-listing form.
|
||||
/// - Parameters:
|
||||
/// - shop: the viewer's shop.
|
||||
/// - form: the values the form is filled with; an empty form by default.
|
||||
/// - problems: what is wrong with the form, by field; nothing by default.
|
||||
/// - viewer: the signed-in viewer.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker embedded in the document head, or `nil` (the default) to omit it.
|
||||
init(
|
||||
shop: Shop,
|
||||
form: ListingForm = .init(),
|
||||
problems: [ListingForm.Field: String] = [:],
|
||||
viewer: Viewer? = nil,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.form = form
|
||||
self.locale = .init(identifier: "en")
|
||||
self.problems = problems
|
||||
self.shop = shop
|
||||
self.viewer = viewer
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Page
|
||||
|
||||
extension NewListingPage: Page {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The path the page is served at, and the form posted to.
|
||||
static let path = "\(String.Route.sellerDashboard)/new"
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
var content: some HTML {
|
||||
SiteLayout(viewer: viewer) {
|
||||
div(.class("page listing-form")) {
|
||||
div(.class("listing-form__head")) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"\(shop.name) · seller tools"
|
||||
}
|
||||
h1(.class("type-h1")) {
|
||||
"New listing"
|
||||
}
|
||||
}
|
||||
if !problems.isEmpty {
|
||||
div(
|
||||
.class("notice notice--bad"),
|
||||
.custom(name: "role", value: "alert")
|
||||
) {
|
||||
p {
|
||||
strong {
|
||||
problems.count == 1 ? "One thing to fix before publishing." : "\(problems.count) things to fix before publishing."
|
||||
}
|
||||
form.value(.photos) == nil ? " Photos have to be picked again: a browser never keeps a chosen file." : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Elementary.form(
|
||||
.class("listing-form__form"),
|
||||
.action(Self.path),
|
||||
.method(.post),
|
||||
.custom(name: "enctype", value: "multipart/form-data")
|
||||
) {
|
||||
what
|
||||
condition
|
||||
photos
|
||||
price
|
||||
div(.class("listing-form__submit")) {
|
||||
a(
|
||||
.class("button"),
|
||||
.href(.Route.sellerDashboard)
|
||||
) {
|
||||
"Cancel"
|
||||
}
|
||||
button(
|
||||
.class("button button--primary button--lg"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Publish listing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@HTMLBuilder
|
||||
var metadata: some HTML {
|
||||
meta(
|
||||
.name("robots"),
|
||||
.content("noindex")
|
||||
)
|
||||
siteMetadata
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.shared]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
[StaticFile.shared, StaticFile.trade]
|
||||
}
|
||||
|
||||
var title: String {
|
||||
"New listing · \(shop.name) — CCN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private extension NewListingPage {
|
||||
|
||||
var what: some HTML {
|
||||
section(.class("card listing-form__section")) {
|
||||
h2(.class("type-panel")) {
|
||||
"1. What is it"
|
||||
}
|
||||
fieldset(.class("field")) {
|
||||
legend(.class("field__label")) {
|
||||
"Format"
|
||||
}
|
||||
div(.class("segmented")) {
|
||||
ForEach(Format.allCases) { format in
|
||||
label(.class("segmented__option")) {
|
||||
input(
|
||||
.type(.radio),
|
||||
.name(ListingForm.Field.format.rawValue),
|
||||
.value(format.rawValue)
|
||||
)
|
||||
.attributes(.checked, when: form.has(.format, value: format.rawValue))
|
||||
span {
|
||||
format.singular
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
problem(.format)
|
||||
}
|
||||
div(.class("listing-form__grid")) {
|
||||
text(.series, "Series or title", placeholder: "Nightward", span: 2)
|
||||
text(.issue, "Issue or volume", placeholder: "1", hint: "Required for single issues")
|
||||
text(.publisher, "Publisher", placeholder: "Meridian Comics", span: 2)
|
||||
text(.year, "Cover year", placeholder: "2019", numeric: true)
|
||||
select(.printing, "Printing", options: [("1", "1st printing"), ("2", "2nd printing"), ("3", "3rd printing"), ("0", "Later / unknown")])
|
||||
select(.edition, "Edition", options: [("", "Not applicable"), ("Direct", "Direct"), ("Newsstand", "Newsstand")], scope: "single-issues")
|
||||
text(.keyNotes, "Key notes", placeholder: "1st app. of the Nightward", hint: "Leave empty unless the issue is a key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var condition: some HTML {
|
||||
section(.class("card listing-form__section")) {
|
||||
div(.class("section-head")) {
|
||||
h2(.class("type-panel")) {
|
||||
"2. Condition"
|
||||
}
|
||||
a(
|
||||
.class("link-ink"),
|
||||
.href(.Route.gradingGuide)
|
||||
) {
|
||||
"CCN grading guide"
|
||||
}
|
||||
}
|
||||
div(
|
||||
.class("listing-form__scope"),
|
||||
.custom(name: "data-scope", value: "single-issues")
|
||||
) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"Single issues"
|
||||
}
|
||||
fieldset(.class("listing-form__modes")) {
|
||||
legend(.class("visually-hidden")) {
|
||||
"Slabbed or raw"
|
||||
}
|
||||
mode(.slabbed, "Slabbed (graded)", "CGC, CBCS or PGX. Buyers see the cert number to verify.")
|
||||
mode(.raw, "Raw (seller-graded)", "You grade it against the guide and declare every defect.")
|
||||
}
|
||||
div(.class("listing-form__grid")) {
|
||||
select(.grade, "Grade", options: [("", "Pick a grade")] + Grading.scale.map { ($0.grade.gradeLabel, "\($0.grade.gradeLabel) · \($0.code)") })
|
||||
select(.grader, "Grader", options: [("", "—")] + Grading.graders.map { ($0, $0) }, scope: "slabbed")
|
||||
select(.pageQuality, "Page quality", options: [("", "Not stated")] + Grading.pageQualities.map { ($0, $0) }, scope: "slabbed")
|
||||
text(.cert, "Cert number", placeholder: "4312087-002", scope: "slabbed")
|
||||
}
|
||||
}
|
||||
div(
|
||||
.class("listing-form__scope"),
|
||||
.custom(name: "data-scope", value: "collected")
|
||||
) {
|
||||
p(.class("type-eyebrow")) {
|
||||
"Manga, trade paperbacks and hardcovers"
|
||||
}
|
||||
div(.class("listing-form__grid")) {
|
||||
select(.condition, "Condition", options: [("", "Pick a condition")] + Grading.conditions.map { ($0, $0) })
|
||||
}
|
||||
}
|
||||
fieldset(.class("field")) {
|
||||
legend(.class("field__label")) {
|
||||
"Defects"
|
||||
}
|
||||
p(.class("field__hint")) {
|
||||
"Tick everything that applies. A buyer can't dispute a defect you declared; an undeclared one is always grounds."
|
||||
}
|
||||
div(.class("listing-form__checks")) {
|
||||
ForEach(Grading.Defect.allCases) { defect in
|
||||
label(.class("check")) {
|
||||
input(
|
||||
.type(.checkbox),
|
||||
.name(ListingForm.Field.defects.rawValue),
|
||||
.value(defect.rawValue)
|
||||
)
|
||||
.attributes(.checked, when: form.has(.defects, value: defect.rawValue))
|
||||
defect == .caseScuffs ? "\(defect.label) (slab)" : defect.label
|
||||
}
|
||||
}
|
||||
}
|
||||
problem(.defects)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var photos: some HTML {
|
||||
section(.class("card listing-form__section")) {
|
||||
h2(.class("type-panel")) {
|
||||
"3. Photos of this copy"
|
||||
}
|
||||
div(.class("field")) {
|
||||
label(
|
||||
.class("field__label"),
|
||||
.for("photos")
|
||||
) {
|
||||
"Up to \(PhotoStore.maxCount) photos — front first, then back, spine, and the label for slabs"
|
||||
}
|
||||
input(
|
||||
.class("listing-form__files"),
|
||||
.id("photos"),
|
||||
.type(.file),
|
||||
.name(ListingForm.Field.photos.rawValue),
|
||||
.custom(name: "accept", value: "image/jpeg,image/png,image/webp"),
|
||||
.custom(name: "multiple", value: "")
|
||||
)
|
||||
p(.class("field__hint")) {
|
||||
"JPEG, PNG or WebP, up to 3 MB each. No stock images. Without photos the listing shows a placeholder cover."
|
||||
}
|
||||
problem(.photos)
|
||||
}
|
||||
div(.class("field")) {
|
||||
label(
|
||||
.class("field__label"),
|
||||
.for("notes")
|
||||
) {
|
||||
"Seller's notes (provenance, case condition, anything a photo can't show)"
|
||||
}
|
||||
textarea(
|
||||
.class("input"),
|
||||
.id("notes"),
|
||||
.name(ListingForm.Field.notes.rawValue),
|
||||
.custom(name: "rows", value: "3"),
|
||||
.custom(name: "maxlength", value: "1000")
|
||||
) {
|
||||
form.value(.notes) ?? ""
|
||||
}
|
||||
problem(.notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var price: some HTML {
|
||||
section(.class("card listing-form__section")) {
|
||||
h2(.class("type-panel")) {
|
||||
"4. Price and shipping"
|
||||
}
|
||||
div(.class("listing-form__grid")) {
|
||||
text(.price, "Asking price (EUR)", placeholder: "240", numeric: true)
|
||||
select(.offers, "Offers", options: [("1", "Accept offers · auto-decline under 80%"), ("0", "Fixed price, no offers")])
|
||||
text(.shipping, "Tracked shipping to NL (EUR)", placeholder: "6", numeric: true)
|
||||
}
|
||||
p(.class("gate-note")) {
|
||||
Icon(.info, size: 16)
|
||||
span {
|
||||
"Ships from \(shop.location) · \(shop.handlingDays) business days handling. Payment is settled between you and the buyer off-platform; CCN records the sale, the agreed price and shipping, and holds the message history for both sides."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Fields
|
||||
|
||||
private extension NewListingPage {
|
||||
|
||||
/// A way of grading a single issue, as a radio card.
|
||||
func mode(
|
||||
_ mode: Grading.Mode,
|
||||
_ title: String,
|
||||
_ text: String
|
||||
) -> some HTML {
|
||||
label(.class("listing-form__mode")) {
|
||||
input(
|
||||
.type(.radio),
|
||||
.name(ListingForm.Field.grading.rawValue),
|
||||
.value(mode.rawValue)
|
||||
)
|
||||
.attributes(.checked, when: form.has(.grading, value: mode.rawValue))
|
||||
span {
|
||||
strong {
|
||||
title
|
||||
}
|
||||
span(.class("type-small")) {
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What is wrong with a field, tied to it by its identifier; nothing when it is fine.
|
||||
@HTMLBuilder
|
||||
func problem(
|
||||
_ field: ListingForm.Field
|
||||
) -> some HTML {
|
||||
if let problem = problems[field] {
|
||||
p(
|
||||
.class("field__error"),
|
||||
.id("\(field.rawValue)-error")
|
||||
) {
|
||||
problem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A select, showing the form's value. A scoped one applies to one format or grading only.
|
||||
func select(
|
||||
_ field: ListingForm.Field,
|
||||
_ title: String,
|
||||
options: [(value: String, label: String)],
|
||||
scope: String? = nil
|
||||
) -> some HTML {
|
||||
div(.class("field")) {
|
||||
label(
|
||||
.class("field__label"),
|
||||
.for(field.rawValue)
|
||||
) {
|
||||
title
|
||||
}
|
||||
Elementary.select(
|
||||
.class("select select--block"),
|
||||
.id(field.rawValue),
|
||||
.name(field.rawValue)
|
||||
) {
|
||||
ForEach(options) { option in
|
||||
Elementary.option(.value(option.value)) {
|
||||
option.label
|
||||
}
|
||||
.attributes(.selected, when: form.value(field) == option.value)
|
||||
}
|
||||
}
|
||||
.attributes(
|
||||
.custom(name: "aria-invalid", value: "true"),
|
||||
.custom(name: "aria-describedby", value: "\(field.rawValue)-error"),
|
||||
when: problems[field] != nil
|
||||
)
|
||||
problem(field)
|
||||
}
|
||||
.attributes(.custom(name: "data-scope", value: scope ?? ""), when: scope != nil)
|
||||
}
|
||||
|
||||
/// A text field, showing the form's value.
|
||||
func text(
|
||||
_ field: ListingForm.Field,
|
||||
_ title: String,
|
||||
placeholder: String,
|
||||
hint: String? = nil,
|
||||
numeric: Bool = false,
|
||||
span: Int = 1,
|
||||
scope: String? = nil
|
||||
) -> some HTML {
|
||||
div(.class(span == 2 ? "field field--wide" : "field")) {
|
||||
label(
|
||||
.class("field__label"),
|
||||
.for(field.rawValue)
|
||||
) {
|
||||
title
|
||||
}
|
||||
input(
|
||||
.class("input"),
|
||||
.id(field.rawValue),
|
||||
.type(.text),
|
||||
.name(field.rawValue),
|
||||
.value(form.value(field) ?? ""),
|
||||
.placeholder(placeholder)
|
||||
)
|
||||
.attributes(.custom(name: "inputmode", value: "numeric"), when: numeric)
|
||||
.attributes(
|
||||
.custom(name: "aria-invalid", value: "true"),
|
||||
.custom(name: "aria-describedby", value: "\(field.rawValue)-error"),
|
||||
when: problems[field] != nil
|
||||
)
|
||||
if let hint, problems[field] == nil {
|
||||
p(.class("field__hint")) {
|
||||
hint
|
||||
}
|
||||
}
|
||||
problem(field)
|
||||
}
|
||||
.attributes(.custom(name: "data-scope", value: scope ?? ""), when: scope != nil)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -86,11 +86,19 @@ extension SellerDashboardPage: Page {
|
||||
summary
|
||||
}
|
||||
}
|
||||
a(
|
||||
.class("button button--outline"),
|
||||
.href(shop.path)
|
||||
) {
|
||||
"View public shop"
|
||||
div(.class("sell__actions")) {
|
||||
a(
|
||||
.class("button button--outline"),
|
||||
.href(shop.path)
|
||||
) {
|
||||
"View public shop"
|
||||
}
|
||||
a(
|
||||
.class("button button--primary"),
|
||||
.href(NewListingPage.path)
|
||||
) {
|
||||
"New listing"
|
||||
}
|
||||
}
|
||||
}
|
||||
ul(.class("tiles")) {
|
||||
|
||||
@@ -5,7 +5,8 @@ import Persistence
|
||||
|
||||
/// The one passkey screen, which both signs in and joins. Drawn bare, without the site's navigation: its only way out is back.
|
||||
///
|
||||
/// The prototype stubs the passkey ceremony — see ``SessionController``.
|
||||
/// A real passkey creates an account, or signs in one created with a passkey — see ``PasskeyController``. The demo accounts have none, so
|
||||
/// the stubbed demo sign-in stays beside it — see ``SessionController``.
|
||||
struct SignInPage {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -86,20 +87,29 @@ extension SignInPage: Page {
|
||||
form(
|
||||
.class("gate__form"),
|
||||
.action(.Route.signIn),
|
||||
.method(.post)
|
||||
.method(.post),
|
||||
.custom(name: "data-passkey-form", value: "")
|
||||
) {
|
||||
input(
|
||||
.type(.hidden),
|
||||
.name("return"),
|
||||
.value(returnPath)
|
||||
)
|
||||
// Shown by `js/passkey.js` once the browser is known to do WebAuthn.
|
||||
button(
|
||||
.class("button button--primary button--xl button--block"),
|
||||
.type(.submit)
|
||||
.type(.button),
|
||||
.hidden,
|
||||
.custom(name: "data-passkey", value: "")
|
||||
) {
|
||||
Icon(.key, size: 18)
|
||||
"Continue with passkey"
|
||||
}
|
||||
p(
|
||||
.class("field__error"),
|
||||
.custom(name: "role", value: "alert"),
|
||||
.custom(name: "data-passkey-status", value: "")
|
||||
) {}
|
||||
div(.class("field")) {
|
||||
label(
|
||||
.class("field__label"),
|
||||
@@ -128,24 +138,21 @@ extension SignInPage: Page {
|
||||
error
|
||||
}
|
||||
}
|
||||
div(.class("gate__saved")) {
|
||||
Icon(.key, size: 16)
|
||||
span {
|
||||
strong {
|
||||
AccountRepository.demoEmail
|
||||
}
|
||||
span(.class("gate__saved-note")) {
|
||||
" · saved passkey"
|
||||
}
|
||||
}
|
||||
}
|
||||
p(.class("field__hint")) {
|
||||
"Unknown email? We create your account and passkey in the same step — nothing else to fill in."
|
||||
}
|
||||
}
|
||||
}
|
||||
p(.class("notice notice--warn")) {
|
||||
"Prototype: passkeys are stubbed. Continue signs you in as the saved demo buyer; an email signs in as that account, or creates it. To sell, use joost@kestrelrow.example."
|
||||
div(.class("notice notice--warn gate__demo")) {
|
||||
p {
|
||||
"Prototype: the demo accounts have no passkey. The demo sign-in trusts the email above — or signs in the demo buyer, \(AccountRepository.demoEmail), without one. To sell, use joost@kestrelrow.example."
|
||||
}
|
||||
button(
|
||||
.class("button button--outline button--block"),
|
||||
.type(.submit)
|
||||
) {
|
||||
"Demo sign-in, without a passkey"
|
||||
}
|
||||
}
|
||||
}
|
||||
p(.class("gate__recover")) {
|
||||
a(.href(.Route.recover)) {
|
||||
@@ -167,7 +174,7 @@ extension SignInPage: Page {
|
||||
}
|
||||
|
||||
var scripts: [any Asset] {
|
||||
[StaticFile.shared]
|
||||
[StaticFile.shared, StaticFile.passkey]
|
||||
}
|
||||
|
||||
var stylesheets: [any Asset] {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
import Persistence
|
||||
|
||||
/// The new-listing form as posted: the raw values, kept to fill the form back in, and what is wrong with them.
|
||||
///
|
||||
/// The form is the listing's data model. Its checks apply the grading guide: a raw book is graded on the scale, a colour-breaking crease caps
|
||||
/// it, and case scuffs are a slab's alone.
|
||||
struct ListingForm: Sendable, Equatable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The text values posted, by field name; a field posted more than once keeps every value.
|
||||
let values: [String: [String]]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates the form from the values posted; an empty form by default, with the most common choices made.
|
||||
init(
|
||||
values: [String: [String]] = [
|
||||
Field.format.rawValue: [Format.singleIssues.rawValue],
|
||||
Field.grading.rawValue: [Grading.Mode.slabbed.rawValue],
|
||||
Field.printing.rawValue: ["1"],
|
||||
Field.offers.rawValue: ["1"],
|
||||
]
|
||||
) {
|
||||
self.values = values
|
||||
}
|
||||
|
||||
/// Creates the form from a multipart body's text parts.
|
||||
init(
|
||||
_ multipart: MultipartForm
|
||||
) {
|
||||
var values = [String: [String]]()
|
||||
|
||||
for field in Field.allCases {
|
||||
values[field.rawValue] = multipart.values(field.rawValue)
|
||||
}
|
||||
|
||||
self.values = values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
extension ListingForm {
|
||||
/// A field of the form.
|
||||
enum Field: String, CaseIterable, Sendable {
|
||||
case cert
|
||||
case condition
|
||||
case defects
|
||||
case edition
|
||||
case format
|
||||
case grade
|
||||
case grader
|
||||
case grading
|
||||
case issue
|
||||
case keyNotes = "key-notes"
|
||||
case notes
|
||||
case offers
|
||||
case pageQuality = "page-quality"
|
||||
case photos
|
||||
case price
|
||||
case printing
|
||||
case publisher
|
||||
case series
|
||||
case shipping
|
||||
case year
|
||||
}
|
||||
}
|
||||
|
||||
extension Grading {
|
||||
/// How a single issue is graded.
|
||||
enum Mode: String, Sendable {
|
||||
/// By the seller, against the guide.
|
||||
case raw
|
||||
/// By a grading company, in a slab.
|
||||
case slabbed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension ListingForm {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The format chosen, or `nil` when none valid was.
|
||||
var format: Format? {
|
||||
value(.format).flatMap(Format.init)
|
||||
}
|
||||
|
||||
/// Whether the copy is a slabbed single issue.
|
||||
var isSlabbed: Bool {
|
||||
format == .singleIssues && value(.grading) == Grading.Mode.slabbed.rawValue
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Whether a field holds a value.
|
||||
func has(
|
||||
_ field: Field,
|
||||
value: String
|
||||
) -> Bool {
|
||||
values[field.rawValue]?.contains(value) == true
|
||||
}
|
||||
|
||||
/// The first value of a field, or `nil` when it was left blank.
|
||||
func value(
|
||||
_ field: Field
|
||||
) -> String? {
|
||||
values[field.rawValue]?.first
|
||||
}
|
||||
|
||||
/// Checks the form.
|
||||
/// - Parameters:
|
||||
/// - photos: the URL paths of the photos stored for the listing.
|
||||
/// - currentYear: the year cover years are checked against.
|
||||
/// - Returns: the draft the form describes, or what is wrong, by field.
|
||||
func validated(
|
||||
photos: [String],
|
||||
currentYear: Int = Calendar(identifier: .gregorian).component(.year, from: .init())
|
||||
) -> Result<ListingDraft, Problems> {
|
||||
var problems = [Field: String]()
|
||||
|
||||
func required(
|
||||
_ field: Field,
|
||||
_ name: String,
|
||||
max: Int = 80
|
||||
) -> String {
|
||||
guard let value = value(field) else {
|
||||
problems[field] = "Enter \(name)."
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
if value.count > max {
|
||||
problems[field] = "Keep \(name) under \(max) characters."
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
let series = required(.series, "the series or title")
|
||||
let publisher = required(.publisher, "the publisher")
|
||||
let year = value(.year).flatMap(Int.init) ?? 0
|
||||
|
||||
if !(1_900...currentYear + 1).contains(year) {
|
||||
problems[.year] = "Enter a cover year between 1900 and \(currentYear + 1)."
|
||||
}
|
||||
|
||||
guard let format else {
|
||||
problems[.format] = "Pick a format."
|
||||
|
||||
return .failure(.init(fields: problems))
|
||||
}
|
||||
|
||||
let defects = (values[Field.defects.rawValue] ?? []).compactMap(Grading.Defect.init)
|
||||
var grader: String?
|
||||
var grade: Double?
|
||||
var condition = ""
|
||||
|
||||
if format == .singleIssues {
|
||||
if value(.issue) == nil {
|
||||
problems[.issue] = "Enter the issue number."
|
||||
}
|
||||
|
||||
let step = value(.grade)
|
||||
.flatMap(Double.init)
|
||||
.flatMap { chosen in Grading.scale.first { $0.grade == chosen } }
|
||||
|
||||
grade = step?.grade
|
||||
condition = step?.code ?? ""
|
||||
|
||||
if step == nil {
|
||||
problems[.grade] = "Pick a grade from the scale."
|
||||
}
|
||||
|
||||
if isSlabbed {
|
||||
grader = value(.grader).flatMap { Grading.graders.contains($0) ? $0 : nil }
|
||||
|
||||
if grader == nil {
|
||||
problems[.grader] = "Pick the grading company."
|
||||
}
|
||||
|
||||
if value(.cert) == nil {
|
||||
problems[.cert] = "Enter the cert number on the label, so buyers can verify the grade."
|
||||
}
|
||||
} else {
|
||||
if defects.contains(.caseScuffs) {
|
||||
problems[.defects] = "Case scuffs are for slabs; this copy is raw."
|
||||
} else if defects.contains(.crease), let grade, grade > Grading.creaseCap {
|
||||
problems[.defects] = "A colour-breaking crease caps a raw book at \(Grading.creaseCap.gradeLabel). Lower the grade, or untick the crease."
|
||||
}
|
||||
}
|
||||
} else {
|
||||
condition = value(.condition).flatMap { Grading.conditions.contains($0) ? $0 : nil } ?? ""
|
||||
|
||||
if condition.isEmpty {
|
||||
problems[.condition] = "Pick the condition."
|
||||
}
|
||||
}
|
||||
|
||||
let price = value(.price).flatMap(Int.init) ?? 0
|
||||
let shipping = value(.shipping).flatMap(Int.init) ?? -1
|
||||
|
||||
if !(1...100_000).contains(price) {
|
||||
problems[.price] = "Enter the asking price in whole euros."
|
||||
}
|
||||
|
||||
if !(0...100).contains(shipping) {
|
||||
problems[.shipping] = "Enter the shipping cost to NL in whole euros; 0 for free."
|
||||
}
|
||||
|
||||
if (value(.notes) ?? "").count > 1_000 {
|
||||
problems[.notes] = "Keep the notes under 1,000 characters."
|
||||
}
|
||||
|
||||
guard problems.isEmpty else {
|
||||
return .failure(.init(fields: problems))
|
||||
}
|
||||
|
||||
return .success(.init(
|
||||
series: series,
|
||||
issue: value(.issue) ?? "",
|
||||
publisher: publisher,
|
||||
year: year,
|
||||
format: format.rawValue,
|
||||
printing: value(.printing).flatMap(Int.init).map { (1...3).contains($0) ? $0 : 0 } ?? 0,
|
||||
edition: format == .singleIssues ? (value(.edition) ?? "") : "",
|
||||
grader: grader,
|
||||
grade: grade,
|
||||
condition: condition,
|
||||
certNumber: isSlabbed ? value(.cert) : nil,
|
||||
pageQuality: isSlabbed ? value(.pageQuality).flatMap { Grading.pageQualities.contains($0) ? $0 : nil } : nil,
|
||||
keyNotes: value(.keyNotes),
|
||||
defects: defects.map(\.label),
|
||||
sellerNotes: value(.notes) ?? "",
|
||||
priceCents: price * 100,
|
||||
shippingCents: shipping * 100,
|
||||
acceptsOffers: value(.offers) == "1",
|
||||
photos: photos
|
||||
))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
extension ListingForm {
|
||||
/// What is wrong with a form, by field.
|
||||
struct Problems: Error, Equatable, Sendable {
|
||||
/// The problem with each field that has one.
|
||||
var fields: [Field: String]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import MultipartKit
|
||||
import NIOCore
|
||||
|
||||
/// The parts of a `multipart/form-data` body: what a form with file fields posts.
|
||||
struct MultipartForm: Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The parts, in the order posted.
|
||||
let parts: [MultipartPart]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Parses a body.
|
||||
/// - Parameters:
|
||||
/// - body: the request's body, collected.
|
||||
/// - contentType: the request's `Content-Type`, which names the boundary between the parts.
|
||||
/// - Returns: `nil` when the content type is not multipart, or the body does not parse.
|
||||
init?(
|
||||
body: ByteBuffer,
|
||||
contentType: String?
|
||||
) {
|
||||
guard
|
||||
let contentType,
|
||||
contentType.lowercased().hasPrefix("multipart/form-data"),
|
||||
let boundary = contentType
|
||||
.split(separator: ";")
|
||||
.map({ $0.trimmingCharacters(in: .whitespaces) })
|
||||
.first(where: { $0.lowercased().hasPrefix("boundary=") })?
|
||||
.dropFirst("boundary=".count)
|
||||
.trimmingCharacters(in: .init(charactersIn: "\""))
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let parser = MultipartParser(boundary: boundary)
|
||||
var parts = [MultipartPart]()
|
||||
var headers = HTTPHeaders()
|
||||
var buffer = ByteBuffer()
|
||||
|
||||
parser.onHeader = { headers.replaceOrAdd(name: $0, value: $1) }
|
||||
parser.onBody = { buffer.writeBuffer(&$0) }
|
||||
parser.onPartComplete = {
|
||||
parts.append(.init(headers: headers, body: buffer))
|
||||
headers = [:]
|
||||
buffer = .init()
|
||||
}
|
||||
|
||||
do {
|
||||
try parser.execute(body)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.parts = parts
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension MultipartForm {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The bodies of the file parts of a name that carry a file: a file field left empty posts a part with no file name.
|
||||
func files(
|
||||
_ name: String
|
||||
) -> [ByteBuffer] {
|
||||
parts
|
||||
.filter { $0.name == name && !($0.filename ?? "").isEmpty && $0.body.readableBytes > 0 }
|
||||
.map(\.body)
|
||||
}
|
||||
|
||||
/// The first text value of a name, trimmed, or `nil` when there is none or it is blank.
|
||||
func value(
|
||||
_ name: String
|
||||
) -> String? {
|
||||
values(name).first
|
||||
}
|
||||
|
||||
/// Every text value of a name, trimmed, the blank ones dropped.
|
||||
func values(
|
||||
_ name: String
|
||||
) -> [String] {
|
||||
parts
|
||||
.filter { $0.name == name && $0.filename == nil }
|
||||
.map { String(buffer: $0.body).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension MultipartPart {
|
||||
/// The file name the part was posted with, or `nil` for a text field.
|
||||
var filename: String? {
|
||||
headers
|
||||
.first(name: "Content-Disposition")?
|
||||
.split(separator: ";")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.first { $0.lowercased().hasPrefix("filename=") }
|
||||
.map { String($0.dropFirst("filename=".count)).trimmingCharacters(in: .init(charactersIn: "\"")) }
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,14 @@ extension StaticFile {
|
||||
.fontAnton,
|
||||
.fontArchivo,
|
||||
.fontOswald,
|
||||
.guide,
|
||||
.icon,
|
||||
.icon192,
|
||||
.icon512,
|
||||
.index,
|
||||
.listing,
|
||||
.notFound,
|
||||
.passkey,
|
||||
.robots,
|
||||
.search,
|
||||
.shared,
|
||||
@@ -121,6 +123,9 @@ extension StaticFile {
|
||||
/// The `font/oswald-variable.woff2` display face (weights 500–700).
|
||||
static let fontOswald = Self("oswald-variable", as: .woff2)
|
||||
|
||||
/// The `css/guide.css` stylesheet for the grading guide.
|
||||
static let guide = Self("guide", as: .css)
|
||||
|
||||
/// The `icon.svg` icon.
|
||||
static let icon = Self("icon", as: .svg)
|
||||
|
||||
@@ -139,6 +144,9 @@ extension StaticFile {
|
||||
/// The `css/not-found.css` stylesheet and `js/not-found.js` script for the not-found page.
|
||||
static let notFound = Self("not-found", as: .css, .js)
|
||||
|
||||
/// The `js/passkey.js` script running the passkey ceremonies on the sign-in page.
|
||||
static let passkey = Self("passkey", as: .js)
|
||||
|
||||
/// The `robots.txt` crawler directives.
|
||||
static let robots = Self("robots", as: .txt)
|
||||
|
||||
|
||||
@@ -67,6 +67,10 @@ extension CatalogueController: RouterController {
|
||||
.Catalogue.shop,
|
||||
use: shop
|
||||
)
|
||||
routes.get(
|
||||
.Catalogue.gradingGuide,
|
||||
use: gradingGuide
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
@@ -84,6 +88,23 @@ private extension CatalogueController {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Handles a request for the grading guide.
|
||||
@Sendable
|
||||
func gradingGuide(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> HTMLResponse {
|
||||
let viewer = await sessions?.viewer(for: request)
|
||||
|
||||
return .init(additionalHeaders: .perViewer) { [analytics, assetVersion] in
|
||||
GradingGuidePage(
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a request for a listing's detail; an unknown slug is not found.
|
||||
@Sendable
|
||||
func listing(
|
||||
@@ -172,6 +193,8 @@ private extension CatalogueController {
|
||||
private extension RouterPath {
|
||||
/// A namespace for the ``CatalogueController`` route paths.
|
||||
enum Catalogue {
|
||||
/// The path of the grading guide.
|
||||
static let gradingGuide: RouterPath = .init(GradingGuidePage.path)
|
||||
/// The path of a listing's detail.
|
||||
static let listing: RouterPath = .init(.Route.listing(":\(String.slug)"))
|
||||
/// The path of the search results.
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import Hummingbird
|
||||
import HummingbirdElementary
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
|
||||
/// Serves the new-listing form, for the owner of a shop: the form itself, and its publication.
|
||||
///
|
||||
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
|
||||
public struct ListingFormController<Context: RequestContext> {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The largest form accepted, in bytes: every photo at its limit, and room for the fields.
|
||||
private static var maxBodyBytes: Int { PhotoStore.maxCount * PhotoStore.maxBytes + 1_024 * 1_024 }
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let analytics: Analytics?
|
||||
private let assetVersion: String?
|
||||
|
||||
/// The repository listings are published through.
|
||||
private let catalogue: CatalogueRepository
|
||||
|
||||
/// The store the photos are kept in.
|
||||
private let photos: PhotoStore
|
||||
|
||||
/// The store the signed-in account is read from.
|
||||
private let sessions: SessionStore
|
||||
|
||||
/// The repository the seller's shop is read through.
|
||||
private let trade: TradeRepository
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a listing form controller.
|
||||
/// - Parameters:
|
||||
/// - catalogue: the repository listings are published through.
|
||||
/// - trade: the repository the seller's shop is read through.
|
||||
/// - sessions: the store the signed-in account is read from.
|
||||
/// - photos: the store the photos are kept in.
|
||||
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the default) to leave them unversioned.
|
||||
/// - analytics: the analytics tracker the page embeds, or `nil` (the default) to omit it.
|
||||
public init(
|
||||
catalogue: CatalogueRepository,
|
||||
trade: TradeRepository,
|
||||
sessions: SessionStore,
|
||||
photos: PhotoStore,
|
||||
assetVersion: String? = nil,
|
||||
analytics: Analytics? = nil
|
||||
) {
|
||||
self.analytics = analytics
|
||||
self.assetVersion = assetVersion
|
||||
self.catalogue = catalogue
|
||||
self.photos = photos
|
||||
self.sessions = sessions
|
||||
self.trade = trade
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension ListingFormController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.get(
|
||||
.init(NewListingPage.path),
|
||||
use: show
|
||||
)
|
||||
routes.post(
|
||||
.init(NewListingPage.path),
|
||||
use: publish
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ListingFormController {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Renders the form — with a `422` when it reports problems.
|
||||
func page(
|
||||
shop: Shop,
|
||||
form: ListingForm = .init(),
|
||||
problems: [ListingForm.Field: String] = [:],
|
||||
viewer: Viewer,
|
||||
request: Request,
|
||||
context: Context
|
||||
) -> Response {
|
||||
HTMLResponse(
|
||||
status: problems.isEmpty ? .ok : .unprocessableContent,
|
||||
additionalHeaders: .perViewer
|
||||
) { [analytics, assetVersion] in
|
||||
NewListingPage(
|
||||
shop: shop,
|
||||
form: form,
|
||||
problems: problems,
|
||||
viewer: viewer,
|
||||
assetVersion: assetVersion,
|
||||
analytics: analytics
|
||||
)
|
||||
}
|
||||
.response(from: request, context: context)
|
||||
}
|
||||
|
||||
/// Handles the form posted: checks it, stores its photos, publishes the listing, and leads to it.
|
||||
///
|
||||
/// The fields are checked before any photo is stored, so a form sent back leaves nothing behind on disk.
|
||||
@Sendable
|
||||
func publish(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let (user, shop, viewer) = try await seller(of: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: NewListingPage.path), type: .normal)
|
||||
}
|
||||
|
||||
let body = try await request.body.collect(upTo: Self.maxBodyBytes)
|
||||
|
||||
guard let multipart = MultipartForm(body: body, contentType: request.headers[.contentType]) else {
|
||||
throw HTTPError(.badRequest)
|
||||
}
|
||||
|
||||
let form = ListingForm(multipart)
|
||||
|
||||
if case .failure(let problems) = form.validated(photos: []) {
|
||||
return page(shop: shop, form: form, problems: problems.fields, viewer: viewer, request: request, context: context)
|
||||
}
|
||||
|
||||
let paths: [String]
|
||||
|
||||
do {
|
||||
paths = try photos.save(multipart.files(ListingForm.Field.photos.rawValue))
|
||||
} catch let refusal as PhotoStore.Refusal {
|
||||
return page(shop: shop, form: form, problems: [.photos: refusal.explanation], viewer: viewer, request: request, context: context)
|
||||
}
|
||||
|
||||
guard
|
||||
case .success(let draft) = form.validated(photos: paths),
|
||||
let listing = try await catalogue.publish(draft, by: user)
|
||||
else {
|
||||
throw HTTPError(.internalServerError)
|
||||
}
|
||||
|
||||
return .redirect(to: listing.path, type: .normal)
|
||||
}
|
||||
|
||||
/// The signed-in seller a request comes from: the account, its shop, and the viewer the page shows; `nil` signed out.
|
||||
/// - Throws: a `403` for an account that owns no shop.
|
||||
func seller(
|
||||
of request: Request
|
||||
) async throws -> (User, Shop, Viewer)? {
|
||||
guard
|
||||
let user = await sessions.user(for: request),
|
||||
let viewer = await sessions.viewer(for: request)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let shop = try await trade.shop(ownedBy: user) else {
|
||||
throw HTTPError(.forbidden, message: "Only a shop's owner lists copies.")
|
||||
}
|
||||
|
||||
return (user, shop, viewer)
|
||||
}
|
||||
|
||||
/// Handles a request for the empty form.
|
||||
@Sendable
|
||||
func show(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard let (_, shop, viewer) = try await seller(of: request) else {
|
||||
return .redirect(to: .Route.signIn(returningTo: NewListingPage.path), type: .found)
|
||||
}
|
||||
|
||||
return page(shop: shop, viewer: viewer, request: request, context: context)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension PhotoStore.Refusal {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// Why the photo was turned down, as the seller reads it.
|
||||
var explanation: String {
|
||||
switch self {
|
||||
case .tooMany: "Pick up to \(PhotoStore.maxCount) photos."
|
||||
case .tooLarge: "Each photo can be up to 3 MB."
|
||||
case .notAnImage: "Photos have to be JPEG, PNG or WebP images."
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import Infrastructure
|
||||
import Persistence
|
||||
import Utility
|
||||
import WebAuthn
|
||||
|
||||
/// Serves the passkey ceremonies behind the sign-in screen, as JSON for `js/passkey.js`.
|
||||
///
|
||||
/// One step both signs in and joins: an unknown email registers a passkey and creates its account together; anything else authenticates with a
|
||||
/// passkey registered earlier — a discoverable one when no email is given. A passkey is never added to an existing account: the prototype has
|
||||
/// no other proof of who is asking, so that would hand the account over.
|
||||
///
|
||||
/// - Note: `Context` is the request context the routes are resolved against, and must match the context of the router the routes are added to.
|
||||
public struct PasskeyController<Context: RequestContext> {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The repository accounts and their passkeys are read and created through.
|
||||
private let accounts: AccountRepository
|
||||
|
||||
/// The ceremonies under way.
|
||||
private let ceremonies: PasskeyCeremonies
|
||||
|
||||
/// Whether the cookies are limited to HTTPS.
|
||||
private let isSecure: Bool
|
||||
|
||||
/// The origin the sign-in screen is served at, which passkeys are bound to.
|
||||
private let origin: String
|
||||
|
||||
/// The store sessions are started in.
|
||||
private let sessions: SessionStore
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a passkey controller.
|
||||
/// - Parameters:
|
||||
/// - origin: the origin the sign-in screen is served at, e.g. `https://ccn.example`. Browsers bind a passkey to it, and an answer
|
||||
/// signed for any other origin is turned down — so it is configuration, never read off a request.
|
||||
/// - accounts: the repository accounts and their passkeys are read and created through.
|
||||
/// - sessions: the store sessions are started in.
|
||||
/// - ceremonies: the ceremonies under way; a set of its own by default.
|
||||
public init(
|
||||
origin: String,
|
||||
accounts: AccountRepository,
|
||||
sessions: SessionStore,
|
||||
ceremonies: PasskeyCeremonies = .init()
|
||||
) {
|
||||
self.accounts = accounts
|
||||
self.ceremonies = ceremonies
|
||||
self.isSecure = origin.hasPrefix("https://")
|
||||
self.origin = origin
|
||||
self.sessions = sessions
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - RouterController
|
||||
|
||||
extension PasskeyController: RouterController {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
public var routes: RouteCollection<Context> {
|
||||
let routes = RouteCollection(context: Context.self)
|
||||
|
||||
routes.post(
|
||||
.init("\(String.Route.signIn)/passkey/options"),
|
||||
use: options
|
||||
)
|
||||
routes.post(
|
||||
.init("\(String.Route.signIn)/passkey/finish"),
|
||||
use: finish
|
||||
)
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension PasskeyController {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The WebAuthn relying party the ceremonies are run as. Built per request: the manager is not `Sendable`, and holds no state.
|
||||
var manager: WebAuthnManager {
|
||||
.init(configuration: .init(
|
||||
relyingPartyID: URLComponents(string: origin)?.host ?? "localhost",
|
||||
relyingPartyName: "CCN",
|
||||
relyingPartyOrigin: origin
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Handles the browser's signed answer: checks it against the ceremony it belongs to, then starts the session.
|
||||
@Sendable
|
||||
func finish(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
guard
|
||||
let token = request.cookies[PasskeyCeremonies.cookieName]?.value,
|
||||
let ceremony = await ceremonies.take(token)
|
||||
else {
|
||||
return Self.refusal(.badRequest, "That sign-in ran out. Try again.")
|
||||
}
|
||||
|
||||
let body = try await request.body.collect(upTo: 64 * 1_024)
|
||||
let user: User
|
||||
|
||||
do {
|
||||
switch ceremony.kind {
|
||||
case .registration(let email):
|
||||
let answer = try JSONDecoder().decode(Answer<RegistrationCredential>.self, from: body)
|
||||
let credential = try await manager.finishRegistration(
|
||||
challenge: ceremony.challenge,
|
||||
credentialCreationData: answer.credential,
|
||||
confirmCredentialIDNotRegisteredYet: { [accounts] in
|
||||
try await accounts.isUnregistered(credentialID: Self.base64URL($0))
|
||||
}
|
||||
)
|
||||
|
||||
guard let joined = try await accounts.join(
|
||||
email: email,
|
||||
credentialID: Self.base64URL(credential.id),
|
||||
publicKey: credential.publicKey,
|
||||
signCount: credential.signCount
|
||||
) else {
|
||||
return Self.refusal(.conflict, "That email already has an account.")
|
||||
}
|
||||
|
||||
user = joined
|
||||
|
||||
return try await signedIn(user, returningTo: answer.return)
|
||||
case .authentication:
|
||||
let answer = try JSONDecoder().decode(Answer<AuthenticationCredential>.self, from: body)
|
||||
let credentialID = Self.base64URL(answer.credential.id.asString())
|
||||
|
||||
guard let passkey = try await accounts.passkey(credentialID: credentialID) else {
|
||||
return Self.refusal(.unauthorized, "That passkey isn't registered with CCN.")
|
||||
}
|
||||
|
||||
let verified = try manager.finishAuthentication(
|
||||
credential: answer.credential,
|
||||
expectedChallenge: ceremony.challenge,
|
||||
credentialPublicKey: passkey.publicKey,
|
||||
credentialCurrentSignCount: passkey.signCount
|
||||
)
|
||||
|
||||
try await accounts.recordUse(ofPasskey: credentialID, signCount: verified.newSignCount)
|
||||
|
||||
return try await signedIn(passkey.user, returningTo: answer.return)
|
||||
}
|
||||
} catch is DecodingError {
|
||||
return Self.refusal(.badRequest, "That answer couldn't be read.")
|
||||
} catch is WebAuthnError {
|
||||
return Self.refusal(.unauthorized, "That passkey couldn't be verified.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the start of a ceremony: issues the challenge, and tells the script whether to create a passkey or use one.
|
||||
@Sendable
|
||||
func options(
|
||||
request: Request,
|
||||
context: Context
|
||||
) async throws -> Response {
|
||||
let typed = (try? await request.decode(as: Start.self, context: context))?.email?
|
||||
.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
|
||||
// No email: whichever passkey the browser holds for this site.
|
||||
guard !typed.isEmpty else {
|
||||
return try await begin(.authentication, options: manager.beginAuthentication(), mode: "get")
|
||||
}
|
||||
|
||||
guard let email = NormalizeEmail()(typed) else {
|
||||
return Self.refusal(.unprocessableContent, "That doesn't look like an email address.")
|
||||
}
|
||||
|
||||
guard try await accounts.existingAccount(email: email) != nil else {
|
||||
let options = manager.beginRegistration(user: .init(
|
||||
id: Array(UUID().uuidString.utf8),
|
||||
name: email,
|
||||
displayName: String(email.prefix { $0 != "@" })
|
||||
))
|
||||
|
||||
return try await begin(.registration(email: email), options: options, challenge: options.challenge, mode: "create")
|
||||
}
|
||||
|
||||
let passkeys = try await accounts.passkeys(email: email)
|
||||
|
||||
guard !passkeys.isEmpty else {
|
||||
return Self.refusal(.conflict, "That account has no passkey. The prototype's demo accounts sign in with the demo sign-in below.")
|
||||
}
|
||||
|
||||
return try await begin(
|
||||
.authentication,
|
||||
options: manager.beginAuthentication(allowCredentials: passkeys.compactMap { passkey in
|
||||
Self.bytes(base64URL: passkey.credentialID).map { .init(type: .publicKey, id: $0) }
|
||||
}),
|
||||
mode: "get"
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts an authentication ceremony and answers with its options.
|
||||
func begin(
|
||||
_ kind: PasskeyCeremonies.Ceremony.Kind,
|
||||
options: PublicKeyCredentialRequestOptions,
|
||||
mode: String
|
||||
) async throws -> Response {
|
||||
try await begin(kind, options: options, challenge: options.challenge, mode: mode)
|
||||
}
|
||||
|
||||
/// Starts a ceremony and answers with its options, as `{"mode": …, "options": …}`, and the ceremony's cookie.
|
||||
func begin(
|
||||
_ kind: PasskeyCeremonies.Ceremony.Kind,
|
||||
options: some Encodable,
|
||||
challenge: [UInt8],
|
||||
mode: String
|
||||
) async throws -> Response {
|
||||
let token = await ceremonies.start(kind, challenge: challenge)
|
||||
let options = String(decoding: try JSONEncoder().encode(options), as: UTF8.self)
|
||||
var response = Self.json(.ok, #"{"mode":"\#(mode)","options":\#(options)}"#)
|
||||
|
||||
response.setCookie(PasskeyCeremonies.cookie(token, isSecure: isSecure))
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/// Starts the session of a signed-in account, and answers with where to go next.
|
||||
func signedIn(
|
||||
_ user: User,
|
||||
returningTo path: String?
|
||||
) async throws -> Response {
|
||||
let token = await sessions.start(for: user)
|
||||
let location = String(decoding: try JSONEncoder().encode(String.Route.safeReturn(path)), as: UTF8.self)
|
||||
var response = Self.json(.ok, #"{"location":\#(location)}"#)
|
||||
|
||||
response.setCookie(SessionStore.cookie(token, isSecure: isSecure))
|
||||
response.setCookie(PasskeyCeremonies.cookie("", isSecure: isSecure))
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/// A base64 or base64url string as base64url without padding: the one form credential identifiers are kept and compared in.
|
||||
static func base64URL(
|
||||
_ string: String
|
||||
) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
/// The bytes a base64url string encodes, or `nil` when it encodes none.
|
||||
static func bytes(
|
||||
base64URL string: String
|
||||
) -> [UInt8]? {
|
||||
var base64 = string
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
|
||||
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
|
||||
|
||||
return Data(base64Encoded: base64).map(Array.init)
|
||||
}
|
||||
|
||||
/// A JSON response, never stored.
|
||||
static func json(
|
||||
_ status: HTTPResponse.Status,
|
||||
_ body: String
|
||||
) -> Response {
|
||||
.init(
|
||||
status: status,
|
||||
headers: [
|
||||
.contentType: "application/json; charset=utf-8",
|
||||
.cacheControl: "no-store",
|
||||
],
|
||||
body: .init(byteBuffer: .init(string: body))
|
||||
)
|
||||
}
|
||||
|
||||
/// A refusal, as `{"error": …}`, for the script to show.
|
||||
static func refusal(
|
||||
_ status: HTTPResponse.Status,
|
||||
_ message: String
|
||||
) -> Response {
|
||||
let message = (try? JSONEncoder().encode(message)).map { String(decoding: $0, as: UTF8.self) } ?? "\"\""
|
||||
|
||||
return json(status, #"{"error":\#(message)}"#)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
/// What the script posts to start a ceremony.
|
||||
private struct Start: Decodable {
|
||||
let email: String?
|
||||
}
|
||||
|
||||
/// What the script posts to finish a ceremony: the browser's credential, and where to go afterwards.
|
||||
private struct Answer<Credential: Decodable>: Decodable {
|
||||
let credential: Credential
|
||||
let `return`: String?
|
||||
}
|
||||
@@ -99,6 +99,11 @@ extension ConfigKey {
|
||||
public static let strictTransportSecurity: ConfigKey = "security.strictTransportSecurity"
|
||||
}
|
||||
/// A namespace for the site configuration keys.
|
||||
/// A namespace for the passkey configuration keys.
|
||||
public enum Passkeys {
|
||||
/// The configuration key for the origin the sign-in screen is served at, which passkeys are bound to.
|
||||
public static let origin: ConfigKey = "passkeys.origin"
|
||||
}
|
||||
public enum Site {
|
||||
/// The configuration key for the public origin the site is served at (scheme and host, no trailing slash).
|
||||
public static let origin: ConfigKey = "site.origin"
|
||||
|
||||
@@ -44,6 +44,13 @@ extension String {
|
||||
/// The TLS token requiring TLS.
|
||||
public static let tlsRequire = "require"
|
||||
}
|
||||
/// A namespace for the passkey default configuration values.
|
||||
public enum Passkeys {
|
||||
/// The default origin passkeys are bound to: where the service answers when run locally.
|
||||
///
|
||||
/// A deployment sets `passkeys.origin` to its own origin. Browsers only run WebAuthn on HTTPS or on `localhost` — not on `127.0.0.1`.
|
||||
public static let origin = "http://localhost:8080"
|
||||
}
|
||||
/// A namespace for well-known path string constants.
|
||||
public enum Path {
|
||||
/// The directory, relative to the working directory, that the website's static files are served from.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
|
||||
/// The passkey ceremonies under way: each a challenge the server issued, waiting for the browser's signed answer.
|
||||
///
|
||||
/// A ceremony is kept in memory under a random token in a short-lived cookie, and answers once: taking it ends it, so a challenge is never
|
||||
/// accepted twice.
|
||||
public actor PasskeyCeremonies {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// How long a ceremony waits for its answer: five minutes, as long as the browser's own prompt may.
|
||||
static let lifetime: TimeInterval = 300
|
||||
|
||||
/// The name of the cookie holding a ceremony's token.
|
||||
static let cookieName = "ccn_ceremony"
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The clock expiries are judged by.
|
||||
private let now: @Sendable () -> Date
|
||||
|
||||
/// The ceremonies under way, by token.
|
||||
private var pending: [String: Ceremony] = [:]
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an empty set of ceremonies.
|
||||
/// - Parameter now: the clock expiries are judged by; the system's by default.
|
||||
public init(
|
||||
now: @escaping @Sendable () -> Date = { .init() }
|
||||
) {
|
||||
self.now = now
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Starts a ceremony, dropping the ones that ran out meanwhile.
|
||||
/// - Returns: the ceremony's token.
|
||||
func start(
|
||||
_ kind: Ceremony.Kind,
|
||||
challenge: [UInt8]
|
||||
) -> String {
|
||||
let token = "\(UUID().uuidString)\(UUID().uuidString)"
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
.lowercased()
|
||||
|
||||
pending = pending.filter { $0.value.expiresAt > now() }
|
||||
pending[token] = .init(
|
||||
kind: kind,
|
||||
challenge: challenge,
|
||||
expiresAt: now().addingTimeInterval(Self.lifetime)
|
||||
)
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
/// Ends a ceremony, handing it over for its answer to be checked against.
|
||||
/// - Returns: the ceremony, or `nil` when the token names none still waiting.
|
||||
func take(
|
||||
_ token: String
|
||||
) -> Ceremony? {
|
||||
guard
|
||||
let ceremony = pending.removeValue(forKey: token),
|
||||
ceremony.expiresAt > now()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ceremony
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Structs
|
||||
|
||||
extension PasskeyCeremonies {
|
||||
/// A challenge issued, and what it was issued for.
|
||||
struct Ceremony: Sendable, Equatable {
|
||||
/// What the ceremony does.
|
||||
let kind: Kind
|
||||
/// The challenge the browser's answer must sign.
|
||||
let challenge: [UInt8]
|
||||
/// When the ceremony stops waiting.
|
||||
let expiresAt: Date
|
||||
|
||||
/// What a ceremony does.
|
||||
enum Kind: Sendable, Equatable {
|
||||
/// Signs in with a passkey registered earlier.
|
||||
case authentication
|
||||
/// Creates the account of the given email, with its first passkey.
|
||||
case registration(email: String)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension PasskeyCeremonies {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// The cookie handing a ceremony's token to the browser; an empty token expires it.
|
||||
nonisolated static func cookie(
|
||||
_ token: String,
|
||||
isSecure: Bool
|
||||
) -> Cookie {
|
||||
.init(
|
||||
name: cookieName,
|
||||
value: token,
|
||||
maxAge: token.isEmpty ? 0 : Int(lifetime),
|
||||
path: String.Route.signIn,
|
||||
secure: isSecure,
|
||||
httpOnly: true,
|
||||
sameSite: .strict
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
|
||||
/// Where the photos of listed copies are kept: a folder under the static files, which `FileMiddleware` serves.
|
||||
///
|
||||
/// Prototype scaffolding. A photo is checked for its type and size and stored as posted — not re-encoded, so it keeps whatever metadata it
|
||||
/// came with — and the folder lives on the instance's own disk. Both change before real sellers upload.
|
||||
public struct PhotoStore: Sendable {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// The largest photo stored, in bytes: 3 MB.
|
||||
public static let maxBytes = 3 * 1_024 * 1_024
|
||||
|
||||
/// The most photos a listing carries: front, back, spine, and the grader's label.
|
||||
public static let maxCount = 4
|
||||
|
||||
/// The folder under the static files the photos are kept in, and the URL path they are served under.
|
||||
static let folder = "uploads"
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The folder the static files are served from.
|
||||
private let staticFilesPath: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a photo store under the given static files folder.
|
||||
/// - Parameter staticFilesPath: the folder the static files are served from.
|
||||
public init(
|
||||
staticFilesPath: String
|
||||
) {
|
||||
self.staticFilesPath = staticFilesPath
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Enumerations
|
||||
|
||||
extension PhotoStore {
|
||||
/// Why a photo was turned down.
|
||||
enum Refusal: Error, Equatable {
|
||||
/// More photos than a listing carries.
|
||||
case tooMany
|
||||
/// A photo over ``PhotoStore/maxBytes``.
|
||||
case tooLarge
|
||||
/// A file that is not a JPEG, PNG, or WebP image.
|
||||
case notAnImage
|
||||
}
|
||||
|
||||
/// An image type the store keeps, told by the file's leading bytes rather than by its name or declared type.
|
||||
enum Kind: String {
|
||||
case jpg
|
||||
case png
|
||||
case webp
|
||||
|
||||
/// Tells the type of a file from its leading bytes, or `nil` when it is none the store keeps.
|
||||
init?(
|
||||
sniffing bytes: [UInt8]
|
||||
) {
|
||||
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) {
|
||||
self = .jpg
|
||||
} else if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
self = .png
|
||||
} else if bytes.count >= 12, bytes.starts(with: Array("RIFF".utf8)), Array(bytes[8..<12]) == Array("WEBP".utf8) {
|
||||
self = .webp
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extensions
|
||||
|
||||
extension PhotoStore {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Checks every photo, then stores them all — or none, when one is turned down.
|
||||
/// - Parameter photos: the files posted, the front first.
|
||||
/// - Returns: the URL paths the photos are served at, in the order given.
|
||||
func save(
|
||||
_ photos: [ByteBuffer]
|
||||
) throws -> [String] {
|
||||
guard photos.count <= Self.maxCount else {
|
||||
throw Refusal.tooMany
|
||||
}
|
||||
|
||||
let kinds = try photos.map { photo in
|
||||
guard photo.readableBytes <= Self.maxBytes else {
|
||||
throw Refusal.tooLarge
|
||||
}
|
||||
|
||||
guard let kind = Kind(sniffing: Array(photo.readableBytesView.prefix(12))) else {
|
||||
throw Refusal.notAnImage
|
||||
}
|
||||
|
||||
return kind
|
||||
}
|
||||
|
||||
guard !photos.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
let folder = URL(fileURLWithPath: staticFilesPath).appendingPathComponent(Self.folder)
|
||||
|
||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
|
||||
// Named by the store alone: nothing of the posted file name reaches the disk or the URL.
|
||||
return try zip(photos, kinds).map { photo, kind in
|
||||
let name = "\(UUID().uuidString.lowercased()).\(kind.rawValue)"
|
||||
|
||||
try Data(photo.readableBytesView).write(to: folder.appendingPathComponent(name), options: .atomic)
|
||||
|
||||
return "/\(Self.folder)/\(name)"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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</button>"#))
|
||||
#expect(html.contains("joost@kestrelrow.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `sign-in page ties an error to the email field`() {
|
||||
let html = SignInPage(
|
||||
|
||||
@@ -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("<strong>\(step.grade.gradeLabel)</strong><span>\(step.code)</span>"))
|
||||
}
|
||||
|
||||
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(#"<option value="9.4">9.4 · NM</option>"#))
|
||||
#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 <wear>"]]),
|
||||
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(#"<option value="9.4" selected>"#))
|
||||
#expect(html.contains(#"name="grading" value="raw" checked"#))
|
||||
#expect(html.contains(#"name="defects" value="crease" checked"#))
|
||||
#expect(html.contains(#"id="price-error">Enter the asking price in whole euros."#))
|
||||
#expect(html.contains(#"aria-invalid="true" aria-describedby="price-error""#))
|
||||
#expect(html.contains("Light <wear>"))
|
||||
}
|
||||
|
||||
// MARK: Photo tests
|
||||
|
||||
@Test
|
||||
func `listing shows its first photo in place of the placeholder cover`() {
|
||||
let bare = ListingCard(.stub()).render()
|
||||
let shot = ListingCard(.stub(photos: ["/uploads/a.jpg", "/uploads/b.jpg"])).render()
|
||||
let page = ListingPage(listing: .stub(defects: ["Spine stress / ticks"], photos: ["/uploads/a.jpg", "/uploads/b.jpg"]), related: []).render()
|
||||
|
||||
#expect(bare.contains("cover--navy"))
|
||||
#expect(!bare.contains("<img"))
|
||||
#expect(shot.contains(#"<img class="photo" src="/uploads/a.jpg" alt="Photo of Nightward #1, front""#))
|
||||
#expect(!shot.contains("cover--navy"))
|
||||
#expect(page.contains(#"src="/uploads/b.jpg""#))
|
||||
#expect(page.contains("<dt>Declared defects</dt><dd>Spine stress / ticks</dd>"))
|
||||
}
|
||||
|
||||
// MARK: MultipartForm tests
|
||||
|
||||
@Test
|
||||
func `multipart form tells text from files, and drops what was left blank`() throws {
|
||||
let body = [
|
||||
"--x", #"Content-Disposition: form-data; name="series""#, "", " Nightward ",
|
||||
"--x", #"Content-Disposition: form-data; name="defects""#, "", "crease",
|
||||
"--x", #"Content-Disposition: form-data; name="defects""#, "", "foxing",
|
||||
"--x", #"Content-Disposition: form-data; name="issue""#, "", "",
|
||||
"--x", #"Content-Disposition: form-data; name="photos"; filename="front.jpg""#, "Content-Type: image/jpeg", "", "JPEGDATA",
|
||||
"--x", #"Content-Disposition: form-data; name="photos"; filename="""#, "Content-Type: application/octet-stream", "", "",
|
||||
"--x--", "",
|
||||
].joined(separator: "\r\n")
|
||||
let form = try #require(MultipartForm(body: .init(string: body), contentType: #"multipart/form-data; boundary="x""#))
|
||||
|
||||
#expect(form.value("series") == "Nightward")
|
||||
#expect(form.values("defects") == ["crease", "foxing"])
|
||||
#expect(form.value("issue") == nil)
|
||||
#expect(form.values("photos").isEmpty)
|
||||
#expect(form.files("photos").map { String(buffer: $0) } == ["JPEGDATA"])
|
||||
#expect(ListingForm(form).has(.defects, value: "foxing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `multipart form is nothing without a multipart content type`() {
|
||||
#expect(MultipartForm(body: .init(string: "a=b"), contentType: "application/x-www-form-urlencoded") == nil)
|
||||
#expect(MultipartForm(body: .init(), contentType: nil) == nil)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"ListingForm type",
|
||||
.tags(.type)
|
||||
)
|
||||
struct ListingFormTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `describes a slabbed single issue`() throws {
|
||||
let draft = try Self.form().validated(photos: ["/uploads/a.jpg"], currentYear: 2026).get()
|
||||
|
||||
#expect(draft.series == "Nightward")
|
||||
#expect(draft.issue == "1")
|
||||
#expect(draft.grader == "CGC")
|
||||
#expect(draft.grade == 9.8)
|
||||
#expect(draft.condition == "NM/M")
|
||||
#expect(draft.certNumber == "4312087-002")
|
||||
#expect(draft.pageQuality == "White")
|
||||
#expect(draft.priceCents == 24_000)
|
||||
#expect(draft.shippingCents == 600)
|
||||
#expect(draft.acceptsOffers)
|
||||
#expect(draft.photos == ["/uploads/a.jpg"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `describes a raw single issue by the scale's code, dropping what only a slab states`() throws {
|
||||
let draft = try Self.form(["grading": ["raw"], "grade": ["8.0"], "defects": ["spine-stress", "corner-wear", "bogus"]])
|
||||
.validated(photos: [], currentYear: 2026)
|
||||
.get()
|
||||
|
||||
#expect(draft.grader == nil)
|
||||
#expect(draft.certNumber == nil)
|
||||
#expect(draft.pageQuality == nil)
|
||||
#expect(draft.condition == "VF")
|
||||
#expect(draft.defects == ["Spine stress / ticks", "Corner wear / blunting"])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `describes a collected edition by its condition, ungraded`() throws {
|
||||
let draft = try Self.form(["format": ["hardcovers"], "issue": [], "condition": ["Sealed"], "offers": ["0"]])
|
||||
.validated(photos: [], currentYear: 2026)
|
||||
.get()
|
||||
|
||||
#expect(draft.issue.isEmpty)
|
||||
#expect(draft.grade == nil)
|
||||
#expect(draft.grader == nil)
|
||||
#expect(draft.condition == "Sealed")
|
||||
#expect(draft.edition.isEmpty)
|
||||
#expect(!draft.acceptsOffers)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `applies the grading guide to a raw book`() {
|
||||
#expect(Self.problems(["grading": ["raw"], "grade": ["9.4"], "defects": ["crease"]]) == [.defects])
|
||||
#expect(Self.problems(["grading": ["raw"], "grade": ["7.0"], "defects": ["crease"]]).isEmpty)
|
||||
#expect(Self.problems(["grading": ["raw"], "defects": ["case-scuffs"]]) == [.defects])
|
||||
// A slab's crease is the grader's business, and its case can be scuffed.
|
||||
#expect(Self.problems(["defects": ["crease", "case-scuffs"]]).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `names every field that is missing or out of range`() {
|
||||
#expect(Self.problems([
|
||||
"series": [], "publisher": [], "year": ["1850"], "issue": [], "grade": ["9.7"], "grader": ["ACME"],
|
||||
"cert": [], "price": ["0"], "shipping": ["-1"],
|
||||
]) == [.series, .publisher, .year, .issue, .grade, .grader, .cert, .price, .shipping])
|
||||
#expect(Self.problems(["format": ["vinyl"]]) == [.format])
|
||||
#expect(Self.problems(["format": ["manga"], "condition": ["Mint"]]) == [.condition])
|
||||
#expect(Self.problems(["year": ["2028"]]) == [.year])
|
||||
#expect(Self.problems(["price": ["12.50"]]) == [.price])
|
||||
#expect(Self.problems(["notes": [String(repeating: "a", count: 1_001)]]) == [.notes])
|
||||
}
|
||||
|
||||
@Test
|
||||
func `starts out with the common choices made`() {
|
||||
let form = ListingForm()
|
||||
|
||||
#expect(form.format == .singleIssues)
|
||||
#expect(form.isSlabbed)
|
||||
#expect(form.has(.offers, value: "1"))
|
||||
#expect(form.value(.series) == nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
extension ListingFormTests {
|
||||
|
||||
/// A valid form for a slabbed Nightward #1, with the given fields replaced.
|
||||
static func form(
|
||||
_ overrides: [String: [String]] = [:]
|
||||
) -> ListingForm {
|
||||
.init(values: [
|
||||
"format": ["single-issues"], "series": ["Nightward"], "issue": ["1"], "publisher": ["Meridian Comics"],
|
||||
"year": ["2019"], "printing": ["1"], "edition": ["Direct"], "grading": ["slabbed"], "grade": ["9.8"],
|
||||
"grader": ["CGC"], "page-quality": ["White"], "cert": ["4312087-002"], "price": ["240"],
|
||||
"shipping": ["6"], "offers": ["1"],
|
||||
].merging(overrides) { $1 })
|
||||
}
|
||||
|
||||
/// The fields a form with the given overrides has problems with.
|
||||
static func problems(
|
||||
_ overrides: [String: [String]]
|
||||
) -> Set<ListingForm.Field> {
|
||||
guard case .failure(let problems) = form(overrides).validated(photos: [], currentYear: 2026) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return Set(problems.fields.keys)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -98,12 +98,14 @@ private extension StaticFileTests {
|
||||
(.fontAnton, ["font/anton-400.woff2"]),
|
||||
(.fontArchivo, ["font/archivo-variable.woff2"]),
|
||||
(.fontOswald, ["font/oswald-variable.woff2"]),
|
||||
(.guide, ["css/guide.css"]),
|
||||
(.icon, ["icon.svg"]),
|
||||
(.icon192, ["icon-192.png"]),
|
||||
(.icon512, ["icon-512.png"]),
|
||||
(.index, ["css/index.css", "js/index.js"]),
|
||||
(.listing, ["css/listing.css"]),
|
||||
(.notFound, ["css/not-found.css", "js/not-found.js"]),
|
||||
(.passkey, ["js/passkey.js"]),
|
||||
(.robots, ["robots.txt"]),
|
||||
(.search, ["css/search.css"]),
|
||||
(.shared, ["css/shared.css", "js/shared.js"]),
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Logging
|
||||
import NIOCore
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"ListingFormController controller",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct ListingFormControllerTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
private let seller = "joost@kestrelrow.example"
|
||||
|
||||
/// The fields of a valid raw Nightward #3.
|
||||
private let fields = [
|
||||
("format", "single-issues"), ("series", "Nightward"), ("issue", "3"), ("publisher", "Meridian Comics"),
|
||||
("year", "2019"), ("printing", "1"), ("grading", "raw"), ("grade", "9.4"), ("defects", "spine-stress"),
|
||||
("notes", "Two light ticks."), ("price", "30"), ("shipping", "5"), ("offers", "1"),
|
||||
]
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test
|
||||
func `keeps the form to a shop's owner`() async throws {
|
||||
try await withApp { client, _ in
|
||||
try await client.execute(uri: "/sell/new", method: .get) { response in
|
||||
#expect(response.status == .found)
|
||||
#expect(response.headers[.location] == "/sign-in?return=/sell/new")
|
||||
}
|
||||
|
||||
let mara = try await signIn(client)
|
||||
|
||||
try await client.execute(uri: "/sell/new", method: .get, headers: [.cookie: mara]) { response in
|
||||
#expect(response.status == .forbidden)
|
||||
}
|
||||
|
||||
let joost = try await signIn(client, email: seller)
|
||||
|
||||
try await client.execute(uri: "/sell/new", method: .get, headers: [.cookie: joost]) { response in
|
||||
#expect(response.status == .ok)
|
||||
#expect(String(buffer: response.body).contains("Kestrel Row Comics · seller tools"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `publishes a listing with its photos, into the shop and the search`() async throws {
|
||||
try await withApp { client, folder in
|
||||
let joost = try await signIn(client, email: seller)
|
||||
let location = try await post(client, fields, photos: [PhotoStoreTests.jpeg, PhotoStoreTests.png], as: joost) { response in
|
||||
#expect(response.status == .seeOther)
|
||||
|
||||
return try #require(response.headers[.location])
|
||||
}
|
||||
|
||||
#expect(location == "/listing/nightward-3-raw-nm")
|
||||
|
||||
try await client.execute(uri: location, method: .get) { response in
|
||||
let body = String(buffer: response.body)
|
||||
let photos = body.matches(of: /\/uploads\/[0-9a-f-]+\.(jpg|png)/).map { String($0.output.0) }
|
||||
|
||||
#expect(body.contains("Nightward #3"))
|
||||
#expect(body.contains("<dt>Declared defects</dt><dd>Spine stress / ticks</dd>"))
|
||||
#expect(Set(photos).count == 2)
|
||||
|
||||
for photo in photos {
|
||||
#expect(FileManager.default.fileExists(atPath: folder + photo))
|
||||
}
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/search?q=nightward+3", method: .get) { response in
|
||||
#expect(String(buffer: response.body).contains(#"href="/listing/nightward-3-raw-nm""#))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `gives the form back with a 422, storing no photo, when a field is wrong`() async throws {
|
||||
try await withApp { client, folder in
|
||||
let joost = try await signIn(client, email: seller)
|
||||
let wrong = fields.map { $0.0 == "grade" ? ("grade", "9.4") : $0 } + [("defects", "crease")]
|
||||
|
||||
try await post(client, wrong, photos: [PhotoStoreTests.jpeg], as: joost) { response in
|
||||
let body = String(buffer: response.body)
|
||||
|
||||
#expect(response.status == .unprocessableContent)
|
||||
#expect(body.contains("caps a raw book at 7.0"))
|
||||
#expect(body.contains(#"name="series" value="Nightward""#))
|
||||
}
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `turns down a file that is not an image, publishing nothing`() async throws {
|
||||
try await withApp { client, _ in
|
||||
let joost = try await signIn(client, email: seller)
|
||||
|
||||
try await post(client, fields, photos: [ByteBuffer(string: "<script>alert(1)</script>")], as: joost) { response in
|
||||
#expect(response.status == .unprocessableContent)
|
||||
#expect(String(buffer: response.body).contains("Photos have to be JPEG, PNG or WebP images."))
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/listing/nightward-3-raw-nm", method: .get) { response in
|
||||
#expect(response.status == .notFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `takes no listing from a signed-out visitor, a buyer, or a body that is not multipart`() async throws {
|
||||
try await withApp { client, _ in
|
||||
try await post(client, fields, photos: [], as: nil) { response in
|
||||
#expect(response.status == .seeOther)
|
||||
#expect(response.headers[.location] == "/sign-in?return=/sell/new")
|
||||
}
|
||||
|
||||
let mara = try await signIn(client)
|
||||
|
||||
try await post(client, fields, photos: [], as: mara) { response in
|
||||
#expect(response.status == .forbidden)
|
||||
}
|
||||
|
||||
let joost = try await signIn(client, email: seller)
|
||||
|
||||
try await client.execute(
|
||||
uri: "/sell/new",
|
||||
method: .post,
|
||||
headers: [.cookie: joost, .contentType: "application/x-www-form-urlencoded"],
|
||||
body: ByteBuffer(string: "series=Nightward")
|
||||
) { response in
|
||||
#expect(response.status == .badRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension ListingFormControllerTests {
|
||||
|
||||
/// Posts the form as multipart — the fields, then the photos — as an account, or signed out.
|
||||
func post<T>(
|
||||
_ client: any TestClientProtocol,
|
||||
_ fields: [(String, String)],
|
||||
photos: [ByteBuffer],
|
||||
as cookie: String?,
|
||||
_ check: @escaping @Sendable (TestResponse) throws -> T
|
||||
) async throws -> T {
|
||||
var body = ByteBuffer()
|
||||
|
||||
for (name, value) in fields {
|
||||
body.writeString("--bnd\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n")
|
||||
}
|
||||
|
||||
for (index, photo) in photos.enumerated() {
|
||||
var photo = photo
|
||||
|
||||
body.writeString("--bnd\r\nContent-Disposition: form-data; name=\"photos\"; filename=\"p\(index)\"\r\nContent-Type: image/jpeg\r\n\r\n")
|
||||
body.writeBuffer(&photo)
|
||||
body.writeString("\r\n")
|
||||
}
|
||||
|
||||
body.writeString("--bnd--\r\n")
|
||||
|
||||
var headers: HTTPFields = [.contentType: "multipart/form-data; boundary=bnd"]
|
||||
|
||||
headers[.cookie] = cookie
|
||||
|
||||
return try await client.execute(uri: "/sell/new", method: .post, headers: headers, body: body, testCallback: check)
|
||||
}
|
||||
|
||||
/// Signs in — as the demo buyer, without an email — returning the session cookie as a `Cookie` header value.
|
||||
func signIn(
|
||||
_ client: any TestClientProtocol,
|
||||
email: String = ""
|
||||
) async throws -> String {
|
||||
try await client.execute(
|
||||
uri: "/sign-in",
|
||||
method: .post,
|
||||
headers: [.contentType: "application/x-www-form-urlencoded"],
|
||||
body: ByteBuffer(string: "email=\(email)&return=/")
|
||||
) { response in
|
||||
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the body against the session, catalogue, and listing form routes, over a seeded in-memory database and a temporary photo folder.
|
||||
func withApp(
|
||||
_ body: @escaping @Sendable (any TestClientProtocol, String) async throws -> Void
|
||||
) async throws {
|
||||
try await PhotoStoreTests.withFolder { folder in
|
||||
let fluent = try Service(
|
||||
driver: .inMemory,
|
||||
logger: Logger(label: "test")
|
||||
)()
|
||||
|
||||
do {
|
||||
await PrepareDB()(for: fluent)
|
||||
|
||||
try await fluent.migrate()
|
||||
|
||||
let catalogue = CatalogueRepository(fluent: fluent)
|
||||
let trade = TradeRepository(fluent: fluent)
|
||||
let sessions = SessionStore(trade: trade)
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addRoutes(CatalogueController<WebsiteRequestContext>(repository: catalogue, sessions: sessions).routes)
|
||||
router.addRoutes(SessionController<WebsiteRequestContext>(sessions: sessions, accounts: .init(fluent: fluent), catalogue: catalogue).routes)
|
||||
router.addRoutes(ListingFormController<WebsiteRequestContext>(
|
||||
catalogue: catalogue,
|
||||
trade: trade,
|
||||
sessions: sessions,
|
||||
photos: .init(staticFilesPath: folder)
|
||||
).routes)
|
||||
|
||||
try await Application(router: router).test(.router) { client in
|
||||
try await body(client, folder)
|
||||
}
|
||||
} catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import Foundation
|
||||
import Hummingbird
|
||||
import HummingbirdTesting
|
||||
import Logging
|
||||
import NIOCore
|
||||
import Persistence
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
/// The ceremonies' signatures need a WebAuthn client: `Tests/Browser/passkeys.py` runs them in Chrome. These cover what does not.
|
||||
@Suite(
|
||||
"PasskeyController controller",
|
||||
.tags(.controller)
|
||||
)
|
||||
struct PasskeyControllerTests {
|
||||
|
||||
// MARK: Options tests
|
||||
|
||||
@Test
|
||||
func `offers whichever passkey the browser holds when given no email`() async throws {
|
||||
try await withApp { client in
|
||||
try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
|
||||
let body = try Self.object(response)
|
||||
let options = try #require(body["options"] as? [String: Any])
|
||||
|
||||
#expect(response.status == .ok)
|
||||
#expect(response.headers[.cacheControl] == "no-store")
|
||||
#expect(body["mode"] as? String == "get")
|
||||
#expect(options["rpId"] as? String == "localhost")
|
||||
#expect((options["challenge"] as? String)?.isEmpty == false)
|
||||
#expect(options["allowCredentials"] == nil)
|
||||
#expect(response.headers[.setCookie]?.hasPrefix("ccn_ceremony=") == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `registers a passkey for an unknown email, normalized`() async throws {
|
||||
try await withApp { client in
|
||||
try await client.execute(
|
||||
uri: "/sign-in/passkey/options",
|
||||
method: .post,
|
||||
headers: Self.json,
|
||||
body: ByteBuffer(string: #"{"email": " Sam@Example.COM "}"#)
|
||||
) { response in
|
||||
let body = try Self.object(response)
|
||||
let options = try #require(body["options"] as? [String: Any])
|
||||
let user = try #require(options["user"] as? [String: Any])
|
||||
let party = try #require(options["rp"] as? [String: Any])
|
||||
|
||||
#expect(body["mode"] as? String == "create")
|
||||
#expect(user["name"] as? String == "sam@example.com")
|
||||
#expect(user["displayName"] as? String == "sam")
|
||||
#expect(party["id"] as? String == "localhost")
|
||||
#expect(party["name"] as? String == "CCN")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `issues a fresh challenge every time`() async throws {
|
||||
try await withApp { client in
|
||||
var challenges = Set<String>()
|
||||
|
||||
for _ in 1...3 {
|
||||
let challenge = try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
|
||||
try #require((try Self.object(response)["options"] as? [String: Any])?["challenge"] as? String)
|
||||
}
|
||||
|
||||
challenges.insert(challenge)
|
||||
}
|
||||
|
||||
#expect(challenges.count == 3)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: zip(
|
||||
[#"{"email": "nope"}"#, #"{"email": "mara.v@example.com"}"#],
|
||||
[HTTPResponse.Status.unprocessableContent, .conflict]
|
||||
))
|
||||
func `starts no ceremony for what is not an email, or for an account without a passkey`(
|
||||
body: String,
|
||||
expects status: HTTPResponse.Status
|
||||
) async throws {
|
||||
try await withApp { client in
|
||||
try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: body)) { response in
|
||||
let error = try Self.object(response)["error"] as? String
|
||||
|
||||
#expect(response.status == status)
|
||||
#expect(response.headers[.setCookie] == nil)
|
||||
#expect(error?.isEmpty == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Finish tests
|
||||
|
||||
@Test
|
||||
func `signs nobody in without a ceremony, or on an answer it cannot read — and hears a ceremony out once`() async throws {
|
||||
try await withApp { client in
|
||||
try await client.execute(uri: "/sign-in/passkey/finish", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
|
||||
#expect(response.status == .badRequest)
|
||||
#expect(response.headers[.setCookie] == nil)
|
||||
}
|
||||
|
||||
let cookie = try await client.execute(uri: "/sign-in/passkey/options", method: .post, headers: Self.json, body: ByteBuffer(string: "{}")) { response in
|
||||
String(try #require(response.headers[.setCookie]).prefix { $0 != ";" })
|
||||
}
|
||||
var headers = Self.json
|
||||
|
||||
headers[.cookie] = cookie
|
||||
|
||||
try await client.execute(
|
||||
uri: "/sign-in/passkey/finish",
|
||||
method: .post,
|
||||
headers: headers,
|
||||
body: ByteBuffer(string: #"{"credential": {"id": "x"}}"#)
|
||||
) { response in
|
||||
let error = try Self.object(response)["error"] as? String
|
||||
|
||||
#expect(response.status == .badRequest)
|
||||
#expect(error == "That answer couldn't be read.")
|
||||
#expect(response.headers[values: .setCookie].allSatisfy { !$0.hasPrefix("ccn_session=") })
|
||||
}
|
||||
|
||||
try await client.execute(uri: "/sign-in/passkey/finish", method: .post, headers: headers, body: ByteBuffer(string: "{}")) { response in
|
||||
let error = try Self.object(response)["error"] as? String
|
||||
|
||||
#expect(error == "That sign-in ran out. Try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension PasskeyControllerTests {
|
||||
|
||||
static let json: HTTPFields = [.contentType: "application/json"]
|
||||
|
||||
/// The JSON object a response carries.
|
||||
static func object(
|
||||
_ response: TestResponse
|
||||
) throws -> [String: Any] {
|
||||
try #require(try JSONSerialization.jsonObject(with: Data(buffer: response.body)) as? [String: Any])
|
||||
}
|
||||
|
||||
/// Runs the body against the passkey routes, over a migrated, seeded in-memory database.
|
||||
func withApp(
|
||||
_ body: @escaping @Sendable (any TestClientProtocol) async throws -> Void
|
||||
) async throws {
|
||||
let fluent = try Service(
|
||||
driver: .inMemory,
|
||||
logger: Logger(label: "test")
|
||||
)()
|
||||
|
||||
do {
|
||||
await PrepareDB()(for: fluent)
|
||||
|
||||
try await fluent.migrate()
|
||||
|
||||
let router = Router(context: WebsiteRequestContext.self)
|
||||
|
||||
router.addRoutes(PasskeyController<WebsiteRequestContext>(
|
||||
origin: "http://localhost:8080",
|
||||
accounts: .init(fluent: fluent),
|
||||
sessions: .init()
|
||||
).routes)
|
||||
|
||||
try await Application(router: router).test(.router, body)
|
||||
} catch {
|
||||
try? await fluent.shutdown()
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
try await fluent.shutdown()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"PasskeyCeremonies type",
|
||||
.tags(.type)
|
||||
)
|
||||
struct PasskeyCeremoniesTests {
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `answers a ceremony once`() async {
|
||||
let ceremonies = PasskeyCeremonies()
|
||||
let token = await ceremonies.start(.registration(email: "sam@example.com"), challenge: [1, 2, 3])
|
||||
let first = await ceremonies.take(token)
|
||||
let second = await ceremonies.take(token)
|
||||
let unknown = await ceremonies.take("unknown")
|
||||
|
||||
#expect(first?.kind == .registration(email: "sam@example.com"))
|
||||
#expect(first?.challenge == [1, 2, 3])
|
||||
#expect(second == nil)
|
||||
#expect(unknown == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `lets a ceremony run out after five minutes`() async {
|
||||
let clock = Clock()
|
||||
let ceremonies = PasskeyCeremonies(now: clock.now)
|
||||
let early = await ceremonies.start(.authentication, challenge: [1])
|
||||
let late = await ceremonies.start(.authentication, challenge: [2])
|
||||
|
||||
clock.advance(by: 299)
|
||||
|
||||
let inTime = await ceremonies.take(early)
|
||||
|
||||
clock.advance(by: 1)
|
||||
|
||||
let tooLate = await ceremonies.take(late)
|
||||
|
||||
#expect(inTime != nil)
|
||||
#expect(tooLate == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `hands out unguessable tokens, in a cookie kept to the sign-in path`() async {
|
||||
let ceremonies = PasskeyCeremonies()
|
||||
let first = await ceremonies.start(.authentication, challenge: [])
|
||||
let second = await ceremonies.start(.authentication, challenge: [])
|
||||
let cookie = PasskeyCeremonies.cookie(first, isSecure: true).description
|
||||
|
||||
#expect(first.count == 64)
|
||||
#expect(first != second)
|
||||
#expect(cookie.contains("Path=/sign-in"))
|
||||
#expect(cookie.contains("HttpOnly"))
|
||||
#expect(cookie.contains("SameSite=Strict"))
|
||||
#expect(cookie.contains("Secure"))
|
||||
#expect(cookie.contains("Max-Age=300"))
|
||||
#expect(PasskeyCeremonies.cookie("", isSecure: false).description.contains("Max-Age=0"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// A clock the tests move by hand.
|
||||
final class Clock: @unchecked Sendable {
|
||||
|
||||
private let lock = NSLock()
|
||||
private var date = Date(timeIntervalSince1970: 1_790_000_000)
|
||||
|
||||
/// The clock's reading.
|
||||
@Sendable
|
||||
func now() -> Date {
|
||||
lock.withLock { date }
|
||||
}
|
||||
|
||||
/// Moves the clock forward.
|
||||
func advance(
|
||||
by interval: TimeInterval
|
||||
) {
|
||||
lock.withLock { date.addTimeInterval(interval) }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import Testing
|
||||
|
||||
@testable import WebsiteLibrary
|
||||
|
||||
@Suite(
|
||||
"PhotoStore type",
|
||||
.tags(.type)
|
||||
)
|
||||
struct PhotoStoreTests {
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
static let jpeg = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF, 0xE0] + Array(repeating: UInt8(0), count: 32))
|
||||
static let png = ByteBuffer(bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] + Array(repeating: UInt8(0), count: 32))
|
||||
static let webp = ByteBuffer(bytes: Array("RIFF".utf8) + [0, 0, 0, 0] + Array("WEBPVP8 ".utf8))
|
||||
|
||||
// MARK: Methods tests
|
||||
|
||||
@Test
|
||||
func `stores photos under names of its own, by the type their bytes tell`() throws {
|
||||
try Self.withFolder { folder in
|
||||
let paths = try PhotoStore(staticFilesPath: folder).save([Self.jpeg, Self.png, Self.webp])
|
||||
|
||||
#expect(paths.map { $0.split(separator: ".").last } == ["jpg", "png", "webp"])
|
||||
#expect(paths.allSatisfy { $0.hasPrefix("/uploads/") })
|
||||
#expect(Set(paths).count == 3)
|
||||
|
||||
for path in paths {
|
||||
#expect(FileManager.default.fileExists(atPath: folder + path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
ByteBuffer(string: "<script>alert(1)</script>"),
|
||||
ByteBuffer(string: "GIF89a"),
|
||||
ByteBuffer(bytes: [0xFF, 0xD8]),
|
||||
ByteBuffer(string: "RIFF0000WAVE"),
|
||||
])
|
||||
func `turns down what is not a JPEG, PNG, or WebP image, storing nothing`(
|
||||
file: ByteBuffer
|
||||
) throws {
|
||||
try Self.withFolder { folder in
|
||||
#expect(throws: PhotoStore.Refusal.notAnImage) {
|
||||
try PhotoStore(staticFilesPath: folder).save([Self.jpeg, file])
|
||||
}
|
||||
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `turns down too many photos, and one too large`() throws {
|
||||
try Self.withFolder { folder in
|
||||
let store = PhotoStore(staticFilesPath: folder)
|
||||
let large = ByteBuffer(bytes: [0xFF, 0xD8, 0xFF] + Array(repeating: UInt8(0), count: PhotoStore.maxBytes))
|
||||
|
||||
#expect(throws: PhotoStore.Refusal.tooMany) {
|
||||
try store.save(Array(repeating: Self.jpeg, count: PhotoStore.maxCount + 1))
|
||||
}
|
||||
#expect(throws: PhotoStore.Refusal.tooLarge) {
|
||||
try store.save([large])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `stores nothing, and makes no folder, without photos`() throws {
|
||||
try Self.withFolder { folder in
|
||||
let paths = try PhotoStore(staticFilesPath: folder).save([])
|
||||
|
||||
#expect(paths.isEmpty)
|
||||
#expect(!FileManager.default.fileExists(atPath: folder + "/uploads"))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
extension PhotoStoreTests {
|
||||
|
||||
/// Runs the body against an empty temporary folder, removed after.
|
||||
static func withFolder<T>(
|
||||
_ body: (String) async throws -> T
|
||||
) async rethrows -> T {
|
||||
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("ccn-photos-\(UUID().uuidString)")
|
||||
|
||||
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
}
|
||||
|
||||
return try await body(folder.path)
|
||||
}
|
||||
|
||||
/// Runs the body against an empty temporary folder, removed after.
|
||||
static func withFolder<T>(
|
||||
_ body: (String) throws -> T
|
||||
) rethrows -> T {
|
||||
let folder = FileManager.default.temporaryDirectory.appendingPathComponent("ccn-photos-\(UUID().uuidString)")
|
||||
|
||||
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: folder)
|
||||
}
|
||||
|
||||
return try body(folder.path)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -14,7 +14,9 @@ extension Listing {
|
||||
keyNotes: String? = nil,
|
||||
priceCents: Int = 24_000,
|
||||
acceptsOffers: Bool = true,
|
||||
soldAt: Date? = nil
|
||||
soldAt: Date? = nil,
|
||||
defects: [String] = [],
|
||||
photos: [String] = []
|
||||
) -> Self {
|
||||
.init(
|
||||
slug: slug, shop: shop, title: title, seriesTitle: "Nightward (2019 series)", issueLabel: "#1",
|
||||
@@ -25,7 +27,9 @@ extension Listing {
|
||||
acceptsOffers: acceptsOffers,
|
||||
cover: .init(tone: "navy", title: "NIGHT\nWARD", imprint: "MERIDIAN", number: "1"),
|
||||
listedAt: .init(timeIntervalSince1970: 0),
|
||||
soldAt: soldAt
|
||||
soldAt: soldAt,
|
||||
defects: defects,
|
||||
photos: photos
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user