diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-09-26 00:19:53 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-09-26 00:19:53 +0900 |
| commit | 874749df46cc291ca61f2f98092ccabfdeffbf16 (patch) | |
| tree | e4cb53b5bfa23974e7935eb2616e0e005954f782 | |
| parent | 999b7a3133fccc3f120ac5ae24c02bb38a4ecce9 (diff) | |
Single-user auth: any registered passkey logs in as the owner. First
registration needs a one-time token that the app writes to
data/registration-token when no passkey exists yet; once logged in,
more passkeys can be added without a token.
Logged in, /favorites manages a list of terms (e.g. band names).
Events whose title or artist contains a term (NFKC + case-insensitive)
get a ★, and the event list gains an "お気に入りのみ" filter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Deploy.md | 16 | ||||
| -rw-r--r-- | app/components/EventCard.tsx | 1 | ||||
| -rw-r--r-- | app/components/EventListRow.tsx | 1 | ||||
| -rw-r--r-- | app/components/FilterBar.tsx | 19 | ||||
| -rw-r--r-- | app/lib/auth.server.ts | 304 | ||||
| -rw-r--r-- | app/lib/db.server.ts | 64 | ||||
| -rw-r--r-- | app/routes.ts | 3 | ||||
| -rw-r--r-- | app/routes/api.auth.$step.ts | 63 | ||||
| -rw-r--r-- | app/routes/events.$id.tsx | 1 | ||||
| -rw-r--r-- | app/routes/events._index.tsx | 15 | ||||
| -rw-r--r-- | app/routes/events.by-date.tsx | 8 | ||||
| -rw-r--r-- | app/routes/favorites.tsx | 161 | ||||
| -rw-r--r-- | app/routes/login.tsx | 117 | ||||
| -rw-r--r-- | app/routes/venues.tsx | 1 | ||||
| -rw-r--r-- | package-lock.json | 334 | ||||
| -rw-r--r-- | package.json | 2 |
17 files changed, 1098 insertions, 13 deletions
@@ -13,3 +13,4 @@ # Generated Markdown event files (dev reference, auto-generated) /events/ +registration-token @@ -66,6 +66,22 @@ git remote add hetzner golive:/var/git/tokyo-livehouse-events.git git push hetzner master ``` +## パスキー(ログイン) + +利用者は1人。どのパスキーでログインしても同じオーナー扱い。ログインするとお気に入りワード(`/favorites`)が使える。 + +```bash +# 初回登録: パスキー未登録なら /login へのアクセス時にトークンが自動生成される +curl -s https://golive.yyamashita.com/login > /dev/null +ssh root@localhost 'cat /app/tokyo-livehouse-events/data/registration-token' +# → https://golive.yyamashita.com/login?token=<トークン> を開いて登録(トークンは1回で消える) + +# 全デバイスを失った場合: トークンを手で作って再登録 +ssh root@localhost 'openssl rand -base64 24 | tr "+/" "-_" > /app/tokyo-livehouse-events/data/registration-token' +``` + +2台目以降は、ログイン済みの状態で `/favorites` →「パスキーを追加」。 + ## ローカル開発 ```bash diff --git a/app/components/EventCard.tsx b/app/components/EventCard.tsx index b23742d..ad55a57 100644 --- a/app/components/EventCard.tsx +++ b/app/components/EventCard.tsx @@ -30,6 +30,7 @@ export default function EventCard({ event }: Props) { <div className="flex-1 p-4 flex flex-col gap-2"> {/* Title */} <h2 className="font-semibold text-sm leading-snug group-hover:text-indigo-300 transition-colors line-clamp-2"> + {event.is_favorite ? <span className="text-amber-400" title="お気に入り">★ </span> : null} {event.title} </h2> diff --git a/app/components/EventListRow.tsx b/app/components/EventListRow.tsx index 30800fb..42f3e8b 100644 --- a/app/components/EventListRow.tsx +++ b/app/components/EventListRow.tsx @@ -31,6 +31,7 @@ export default function EventListRow({ event }: Props) { {/* Title + artist */} <span className="flex-1 min-w-0"> <span className="block truncate font-medium text-gray-100 group-hover:text-indigo-300 transition-colors"> + {event.is_favorite ? <span className="text-amber-400" title="お気に入り">★ </span> : null} {event.title} </span> {event.artist && ( diff --git a/app/components/FilterBar.tsx b/app/components/FilterBar.tsx index 7a95d2c..f897e67 100644 --- a/app/components/FilterBar.tsx +++ b/app/components/FilterBar.tsx @@ -6,9 +6,11 @@ interface Props { areas?: string[]; defaultDateFrom?: string; defaultDateTo?: string; + /** Show the "favorites only" toggle (logged in only) */ + showFavorites?: boolean; } -export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateTo }: Props) { +export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateTo, showFavorites }: Props) { const [searchParams] = useSearchParams(); return ( @@ -98,6 +100,19 @@ export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateT </select> </div> + {showFavorites && ( + <label className="flex items-center gap-1.5 py-1.5 text-sm text-gray-300 cursor-pointer"> + <input + name="fav" + type="checkbox" + value="1" + defaultChecked={searchParams.get("fav") === "1"} + className="accent-amber-400" + /> + <span className="text-amber-400">★</span> お気に入りのみ + </label> + )} + <button type="submit" className="rounded-md bg-gray-700 px-4 py-1.5 text-sm font-medium hover:bg-gray-600 transition-colors" @@ -118,5 +133,5 @@ export default function FilterBar({ venues, areas, defaultDateFrom, defaultDateT } function hasFilters(params: URLSearchParams): boolean { - return ["keyword", "venue_id", "date_from", "date_to", "capacity_range", "area"].some((k) => params.get(k)); + return ["keyword", "venue_id", "date_from", "date_to", "capacity_range", "area", "fav"].some((k) => params.get(k)); } diff --git a/app/lib/auth.server.ts b/app/lib/auth.server.ts new file mode 100644 index 0000000..e0e93b8 --- /dev/null +++ b/app/lib/auth.server.ts @@ -0,0 +1,304 @@ +/** + * Single-user passkey auth + favorites. + * + * - どのパスキーでログインしても同じ「オーナー」としてログインする(ユーザーは1人) + * - 未ログイン時のパスキー登録は、DATA_DIR/registration-token に書かれたトークンが必要 + * - パスキーが1つも無ければ起動後の初回アクセス時に自動生成される + * - 全デバイスを失った場合は、このファイルを手で作れば再登録できる + * - ログイン済みなら追加のパスキー(別デバイス)をトークン無しで登録できる + */ +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, + type AuthenticationResponseJSON, + type RegistrationResponseJSON, +} from "@simplewebauthn/server"; +import { DATA_DIR, getDb, normalizeTerm } from "./db.server"; + +const SESSION_COOKIE = "sid"; +const CHALLENGE_COOKIE = "wa_chal"; +const SESSION_DAYS = 90; +const CHALLENGE_MINUTES = 5; +const RP_NAME = "ライブに行くしかない"; +const TOKEN_PATH = path.join(DATA_DIR, "registration-token"); + +// ---------- Request helpers ---------- + +/** Origin/RP ID as seen by the browser. Behind Caddy, the app itself receives plain http. */ +function relyingParty(request: Request): { origin: string; rpID: string } { + const url = new URL(request.url); + const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? url.host; + const proto = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(/:$/, ""); + return { origin: `${proto}://${host}`, rpID: host.replace(/:\d+$/, "") }; +} + +function readCookie(request: Request, name: string): string | null { + const header = request.headers.get("cookie"); + if (!header) return null; + for (const part of header.split(";")) { + const [k, ...v] = part.trim().split("="); + if (k === name) return decodeURIComponent(v.join("=")); + } + return null; +} + +function cookie(request: Request, name: string, value: string, maxAgeSec: number): string { + const secure = relyingParty(request).origin.startsWith("https:") ? "; Secure" : ""; + return `${name}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSec}${secure}`; +} + +function sqlTimeIn(seconds: number): string { + return new Date(Date.now() + seconds * 1000).toISOString().replace("T", " ").slice(0, 19); +} + +// ---------- Sessions ---------- + +export function isLoggedIn(request: Request): boolean { + const sid = readCookie(request, SESSION_COOKIE); + if (!sid) return false; + const row = getDb() + .prepare("SELECT 1 FROM sessions WHERE id = ? AND expires_at > datetime('now')") + .get(sid); + return !!row; +} + +export function requireLogin(request: Request): void { + if (!isLoggedIn(request)) { + const url = new URL(request.url); + throw new Response(null, { + status: 302, + headers: { Location: `/login?next=${encodeURIComponent(url.pathname + url.search)}` }, + }); + } +} + +function createSession(request: Request): string { + const db = getDb(); + db.prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run(); + const sid = crypto.randomBytes(32).toString("base64url"); + const maxAge = SESSION_DAYS * 86400; + db.prepare("INSERT INTO sessions (id, expires_at) VALUES (?, ?)").run(sid, sqlTimeIn(maxAge)); + return cookie(request, SESSION_COOKIE, sid, maxAge); +} + +export function logout(request: Request): string { + const sid = readCookie(request, SESSION_COOKIE); + if (sid) getDb().prepare("DELETE FROM sessions WHERE id = ?").run(sid); + return cookie(request, SESSION_COOKIE, "", 0); +} + +// ---------- Challenges (one-time, short-lived) ---------- + +function storeChallenge(request: Request, challenge: string): string { + const db = getDb(); + db.prepare("DELETE FROM auth_challenges WHERE expires_at <= datetime('now')").run(); + const id = crypto.randomBytes(16).toString("base64url"); + const maxAge = CHALLENGE_MINUTES * 60; + db.prepare("INSERT INTO auth_challenges (id, challenge, expires_at) VALUES (?, ?, ?)").run( + id, challenge, sqlTimeIn(maxAge) + ); + return cookie(request, CHALLENGE_COOKIE, id, maxAge); +} + +function consumeChallenge(request: Request): string | null { + const id = readCookie(request, CHALLENGE_COOKIE); + if (!id) return null; + const db = getDb(); + const row = db + .prepare("SELECT challenge FROM auth_challenges WHERE id = ? AND expires_at > datetime('now')") + .get(id) as { challenge: string } | undefined; + db.prepare("DELETE FROM auth_challenges WHERE id = ?").run(id); + return row?.challenge ?? null; +} + +// ---------- Registration token ---------- + +function passkeyCount(): number { + return (getDb().prepare("SELECT COUNT(*) AS n FROM passkeys").get() as { n: number }).n; +} + +/** Ensure a registration token exists while no passkey is registered (first-time setup). */ +export function ensureSetupToken(): void { + if (passkeyCount() > 0 || fs.existsSync(TOKEN_PATH)) return; + fs.writeFileSync(TOKEN_PATH, crypto.randomBytes(24).toString("base64url") + "\n", { mode: 0o600 }); + console.log(`[auth] No passkey registered. Registration token written to ${TOKEN_PATH}`); +} + +function checkToken(token: string | null): boolean { + if (!token || !fs.existsSync(TOKEN_PATH)) return false; + const expected = Buffer.from(fs.readFileSync(TOKEN_PATH, "utf8").trim()); + const given = Buffer.from(token.trim()); + return expected.length === given.length && crypto.timingSafeEqual(expected, given); +} + +export function canRegister(request: Request, token: string | null): boolean { + return isLoggedIn(request) || checkToken(token); +} + +// ---------- WebAuthn ceremonies ---------- + +interface PasskeyRow { + id: string; + public_key: Buffer; + counter: number; + transports: string | null; +} + +const OWNER_ID = new TextEncoder().encode("owner"); + +export async function registrationOptions(request: Request) { + const { rpID } = relyingParty(request); + const existing = getDb().prepare("SELECT id, transports FROM passkeys").all() as PasskeyRow[]; + const options = await generateRegistrationOptions({ + rpName: RP_NAME, + rpID, + userID: OWNER_ID, + userName: "owner", + userDisplayName: "オーナー", + attestationType: "none", + excludeCredentials: existing.map((p) => ({ + id: p.id, + transports: p.transports ? JSON.parse(p.transports) : undefined, + })), + authenticatorSelection: { residentKey: "required", userVerification: "preferred" }, + }); + return { options, setCookie: storeChallenge(request, options.challenge) }; +} + +export async function verifyRegistration( + request: Request, + response: RegistrationResponseJSON, + label: string | null, + usedToken: boolean +): Promise<string> { + const expectedChallenge = consumeChallenge(request); + if (!expectedChallenge) throw new Error("challenge expired"); + const { origin, rpID } = relyingParty(request); + const result = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false, + }); + if (!result.verified) throw new Error("registration not verified"); + + const { credential } = result.registrationInfo; + getDb() + .prepare("INSERT INTO passkeys (id, public_key, counter, transports, label) VALUES (?, ?, ?, ?, ?)") + .run( + credential.id, + Buffer.from(credential.publicKey), + credential.counter, + credential.transports ? JSON.stringify(credential.transports) : null, + label?.slice(0, 60) || null + ); + + // Token is single-use + if (usedToken) fs.rmSync(TOKEN_PATH, { force: true }); + + return createSession(request); +} + +export async function authenticationOptions(request: Request) { + const { rpID } = relyingParty(request); + const options = await generateAuthenticationOptions({ rpID, userVerification: "preferred" }); + return { options, setCookie: storeChallenge(request, options.challenge) }; +} + +export async function verifyAuthentication( + request: Request, + response: AuthenticationResponseJSON +): Promise<string> { + const expectedChallenge = consumeChallenge(request); + if (!expectedChallenge) throw new Error("challenge expired"); + const db = getDb(); + const passkey = db.prepare("SELECT * FROM passkeys WHERE id = ?").get(response.id) as + | PasskeyRow + | undefined; + if (!passkey) throw new Error("unknown passkey"); + + const { origin, rpID } = relyingParty(request); + const result = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: passkey.id, + publicKey: new Uint8Array(passkey.public_key), + counter: passkey.counter, + transports: passkey.transports ? JSON.parse(passkey.transports) : undefined, + }, + requireUserVerification: false, + }); + if (!result.verified) throw new Error("authentication not verified"); + + db.prepare("UPDATE passkeys SET counter = ?, last_used_at = datetime('now') WHERE id = ?").run( + result.authenticationInfo.newCounter, + passkey.id + ); + return createSession(request); +} + +// ---------- Passkey management ---------- + +export interface PasskeyInfo { + id: string; + label: string | null; + created_at: string; + last_used_at: string | null; +} + +export function listPasskeys(): PasskeyInfo[] { + return getDb() + .prepare("SELECT id, label, created_at, last_used_at FROM passkeys ORDER BY created_at") + .all() as PasskeyInfo[]; +} + +/** Refuses to delete the last passkey so the owner can't lock themselves out. */ +export function deletePasskey(id: string): boolean { + if (passkeyCount() <= 1) return false; + return getDb().prepare("DELETE FROM passkeys WHERE id = ?").run(id).changes > 0; +} + +// ---------- Favorites ---------- + +export interface Favorite { + id: number; + term: string; + upcoming_count: number; +} + +export function listFavorites(): Favorite[] { + return getDb() + .prepare( + `SELECT f.id, f.term, + (SELECT COUNT(*) FROM events e + WHERE e.date >= date('now', 'localtime') + AND instr(nfkc_lower(e.title || ' ' || COALESCE(e.artist, '')), f.term_norm) > 0 + ) AS upcoming_count + FROM favorites f ORDER BY f.term COLLATE NOCASE` + ) + .all() as Favorite[]; +} + +export function addFavorite(term: string): boolean { + const trimmed = term.trim().slice(0, 100); + const norm = normalizeTerm(trimmed); + if (!norm) return false; + return ( + getDb() + .prepare("INSERT OR IGNORE INTO favorites (term, term_norm) VALUES (?, ?)") + .run(trimmed, norm).changes > 0 + ); +} + +export function deleteFavorite(id: number): void { + getDb().prepare("DELETE FROM favorites WHERE id = ?").run(id); +} diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts index 82a0025..9d6597d 100644 --- a/app/lib/db.server.ts +++ b/app/lib/db.server.ts @@ -9,11 +9,22 @@ const DB_PATH = process.env.DB_PATH let _db: Database.Database | null = null; -function getDb(): Database.Database { +/** Directory holding the DB file — also used for other runtime state files. */ +export const DATA_DIR = path.dirname(DB_PATH); + +/** Normalization used for favorite matching: full/half-width and case insensitive. */ +export function normalizeTerm(s: string): string { + return s.normalize("NFKC").toLowerCase().trim(); +} + +export function getDb(): Database.Database { if (!_db) { _db = new Database(DB_PATH); _db.pragma("journal_mode = WAL"); _db.pragma("foreign_keys = ON"); + _db.function("nfkc_lower", { deterministic: true }, (s: unknown) => + typeof s === "string" ? normalizeTerm(s) : null + ); initSchema(_db); } return _db; @@ -63,6 +74,36 @@ function initSchema(db: Database.Database) { CREATE INDEX IF NOT EXISTS idx_scrape_logs_run_id ON scrape_logs(run_id); CREATE INDEX IF NOT EXISTS idx_scrape_logs_venue_id ON scrape_logs(venue_id); + + -- Single-user auth: any registered passkey logs in as "the" owner + CREATE TABLE IF NOT EXISTS passkeys ( + id TEXT PRIMARY KEY, -- credential ID (base64url) + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, -- JSON array + label TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used_at TEXT + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS auth_challenges ( + id TEXT PRIMARY KEY, + challenge TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS favorites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + term TEXT NOT NULL, + term_norm TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `); // Migration: add capacity column to existing venues tables @@ -99,6 +140,8 @@ export interface Event { description: string | null; source_url: string | null; fetched_at: string; + /** 1 if title/artist matches a favorite term. Only computed when with_favorites is set. */ + is_favorite?: number; } export interface EventInput { @@ -173,13 +216,24 @@ export interface QueryEventsParams { keyword?: string; capacity_range?: CapacityRange; area?: string; + /** Compute is_favorite for each row */ + with_favorites?: boolean; + /** Only events matching a favorite term */ + favorites_only?: boolean; limit?: number; offset?: number; } +const FAVORITE_MATCH_SQL = `EXISTS ( + SELECT 1 FROM favorites f + WHERE instr(nfkc_lower(e.title || ' ' || COALESCE(e.artist, '')), f.term_norm) > 0 +)`; + export function queryEvents(params: QueryEventsParams = {}): Event[] { - const { date_from, date_to, venue_id, keyword, capacity_range, area, limit = 60, offset = 0 } = - params; + const { + date_from, date_to, venue_id, keyword, capacity_range, area, + with_favorites, favorites_only, limit = 60, offset = 0, + } = params; const clauses: string[] = []; const args: unknown[] = []; @@ -211,12 +265,16 @@ export function queryEvents(params: QueryEventsParams = {}): Event[] { clauses.push("v.area = ?"); args.push(area); } + if (favorites_only) { + clauses.push(FAVORITE_MATCH_SQL); + } const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; return getDb() .prepare( `SELECT e.*, v.name AS venue_name, v.area AS venue_area + ${with_favorites ? `, ${FAVORITE_MATCH_SQL} AS is_favorite` : ""} FROM events e JOIN venues v ON e.venue_id = v.id ${where} ORDER BY e.date ASC, e.start_time ASC diff --git a/app/routes.ts b/app/routes.ts index 21a950d..d45106c 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -8,6 +8,8 @@ export default [ route(":id", "routes/events.$id.tsx"), ]), route("venues", "routes/venues.tsx"), + route("favorites", "routes/favorites.tsx"), + route("login", "routes/login.tsx"), ...prefix("api", [ route("scrape", "routes/api.scrape.ts"), route("scrape-status", "routes/api.scrape-status.ts"), @@ -17,5 +19,6 @@ export default [ route("venues", "routes/api.venues.ts"), route("openapi.json", "routes/api.openapi.ts"), route("image", "routes/api.image.ts"), + route("auth/:step", "routes/api.auth.$step.ts"), ]), ] satisfies RouteConfig; diff --git a/app/routes/api.auth.$step.ts b/app/routes/api.auth.$step.ts new file mode 100644 index 0000000..64940f2 --- /dev/null +++ b/app/routes/api.auth.$step.ts @@ -0,0 +1,63 @@ +/** + * Resource route: POST /api/auth/:step + * + * register-options / register-verify — パスキー登録(ログイン済み or 登録トークン必須) + * login-options / login-verify — パスキーでログイン + * logout — セッション破棄して /events へ + */ +import { redirect } from "react-router"; +import type { Route } from "./+types/api.auth.$step"; +import { + authenticationOptions, + canRegister, + isLoggedIn, + logout, + registrationOptions, + verifyAuthentication, + verifyRegistration, +} from "~/lib/auth.server"; + +export async function action({ request, params }: Route.ActionArgs) { + switch (params.step) { + case "register-options": { + const { token } = await request.json(); + if (!canRegister(request, token ?? null)) return forbidden(); + const { options, setCookie } = await registrationOptions(request); + return Response.json(options, { headers: { "Set-Cookie": setCookie } }); + } + case "register-verify": { + const { token, response, label } = await request.json(); + const loggedIn = isLoggedIn(request); + if (!canRegister(request, token ?? null)) return forbidden(); + return withSession(() => + verifyRegistration(request, response, label ?? null, !loggedIn) + ); + } + case "login-options": { + const { options, setCookie } = await authenticationOptions(request); + return Response.json(options, { headers: { "Set-Cookie": setCookie } }); + } + case "login-verify": { + const { response } = await request.json(); + return withSession(() => verifyAuthentication(request, response)); + } + case "logout": + return redirect("/events", { headers: { "Set-Cookie": logout(request) } }); + default: + throw new Response("Not Found", { status: 404 }); + } +} + +async function withSession(verify: () => Promise<string>): Promise<Response> { + try { + const setCookie = await verify(); + return Response.json({ ok: true }, { headers: { "Set-Cookie": setCookie } }); + } catch (e) { + console.warn("[auth]", e); + return Response.json({ ok: false, error: (e as Error).message }, { status: 400 }); + } +} + +function forbidden(): Response { + return Response.json({ ok: false, error: "登録トークンが無効です" }, { status: 403 }); +} diff --git a/app/routes/events.$id.tsx b/app/routes/events.$id.tsx index 87836ed..6837430 100644 --- a/app/routes/events.$id.tsx +++ b/app/routes/events.$id.tsx @@ -73,6 +73,7 @@ export default function EventDetail() { <Link to="/events" className="hover:text-white transition-colors">イベント</Link> <Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link> <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link> + <Link to="/favorites" className="hover:text-white transition-colors">★ お気に入り</Link> </nav> </header> diff --git a/app/routes/events._index.tsx b/app/routes/events._index.tsx index 33568df..563b0c0 100644 --- a/app/routes/events._index.tsx +++ b/app/routes/events._index.tsx @@ -6,6 +6,7 @@ import EventListRow from "~/components/EventListRow"; import FilterBar from "~/components/FilterBar"; import ScrapeButton from "~/components/ScrapeButton"; import { useVenueFilter } from "~/lib/venue-filter"; +import { isLoggedIn } from "~/lib/auth.server"; function defaultWindow() { const today = new Date(); @@ -27,19 +28,24 @@ export async function loader({ request }: Route.LoaderArgs) { const keyword = url.searchParams.get("keyword") ?? undefined; const capacity_range = (url.searchParams.get("capacity_range") ?? undefined) as CapacityRange | undefined; const area = url.searchParams.get("area") ?? undefined; + const loggedIn = isLoggedIn(request); + const favorites_only = loggedIn && url.searchParams.get("fav") === "1"; const page = Math.max(1, parseInt(url.searchParams.get("page") ?? "1", 10)); const limit = 30; const offset = (page - 1) * limit; - const events = queryEvents({ date_from, date_to, venue_id, keyword, capacity_range, area, limit, offset }); + const events = queryEvents({ + date_from, date_to, venue_id, keyword, capacity_range, area, + with_favorites: loggedIn, favorites_only, limit, offset, + }); const venues = getVenues(); const areas = [...new Set(venues.filter((v) => v.area).map((v) => v.area as string))].sort(); - return { events, venues, areas, page, hasMore: events.length === limit, date_from, date_to }; + return { events, venues, areas, page, hasMore: events.length === limit, date_from, date_to, loggedIn }; } export default function EventsIndex() { - const { events, venues, areas, page, hasMore, date_from, date_to } = useLoaderData<typeof loader>(); + const { events, venues, areas, page, hasMore, date_from, date_to, loggedIn } = useLoaderData<typeof loader>(); const [searchParams, setSearchParams] = useSearchParams(); const view = (searchParams.get("view") ?? "card") as "card" | "list"; const { hiddenIds } = useVenueFilter(); @@ -67,6 +73,7 @@ export default function EventsIndex() { <Link to="/events" className="hover:text-white transition-colors">イベント</Link> <Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link> <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link> + <Link to="/favorites" className="hover:text-white transition-colors">★ お気に入り</Link> </nav> </header> @@ -94,7 +101,7 @@ export default function EventsIndex() { </div> </div> - <FilterBar venues={venues} areas={areas} defaultDateFrom={date_from} defaultDateTo={date_to} /> + <FilterBar venues={venues} areas={areas} defaultDateFrom={date_from} defaultDateTo={date_to} showFavorites={loggedIn} /> {hiddenEventCount > 0 && ( <p className="mt-3 text-xs text-gray-500"> diff --git a/app/routes/events.by-date.tsx b/app/routes/events.by-date.tsx index ee4a873..5c7876f 100644 --- a/app/routes/events.by-date.tsx +++ b/app/routes/events.by-date.tsx @@ -2,6 +2,7 @@ import { useLoaderData, useSearchParams, Link, Form } from "react-router"; import { queryEvents, getVenues, type CapacityRange } from "~/lib/db.server"; import type { Event } from "~/lib/db.server"; import { useVenueFilter } from "~/lib/venue-filter"; +import { isLoggedIn } from "~/lib/auth.server"; function todayJst(): string { // JST = UTC+9 @@ -38,7 +39,10 @@ export async function loader({ request }: { request: Request }) { const capacity_range = (url.searchParams.get("capacity_range") ?? undefined) as CapacityRange | undefined; const area = url.searchParams.get("area") ?? undefined; - const events = queryEvents({ date_from: date, date_to: date, keyword, venue_id, capacity_range, area, limit: 500 }); + const events = queryEvents({ + date_from: date, date_to: date, keyword, venue_id, capacity_range, area, + with_favorites: isLoggedIn(request), limit: 500, + }); const venues = getVenues(); const areas = [...new Set(venues.filter((v) => v.area).map((v) => v.area as string))].sort(); @@ -89,6 +93,7 @@ export default function EventsByDate() { <Link to="/events" className="hover:text-white transition-colors">イベント</Link> <Link to="/events/by-date" className="text-white">日付別</Link> <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link> + <Link to="/favorites" className="hover:text-white transition-colors">★ お気に入り</Link> </nav> </header> @@ -265,6 +270,7 @@ function VenueEventRow({ event }: { event: Event }) { {/* Title + artist */} <span className="flex-1 min-w-0"> <span className="block truncate text-sm font-medium text-gray-100 group-hover:text-indigo-300 transition-colors"> + {event.is_favorite ? <span className="text-amber-400" title="お気に入り">★ </span> : null} {event.title} </span> {event.artist && ( diff --git a/app/routes/favorites.tsx b/app/routes/favorites.tsx new file mode 100644 index 0000000..e2f620d --- /dev/null +++ b/app/routes/favorites.tsx @@ -0,0 +1,161 @@ +import { Form, Link, useLoaderData, useNavigation } from "react-router"; +import type { Route } from "./+types/favorites"; +import { + addFavorite, + deleteFavorite, + deletePasskey, + listFavorites, + listPasskeys, + requireLogin, +} from "~/lib/auth.server"; + +export async function loader({ request }: Route.LoaderArgs) { + requireLogin(request); + return { favorites: listFavorites(), passkeys: listPasskeys() }; +} + +export async function action({ request }: Route.ActionArgs) { + requireLogin(request); + const form = await request.formData(); + switch (form.get("intent")) { + case "add": { + const added = addFavorite(String(form.get("term") ?? "")); + return { error: added ? null : "空欄か、登録済みのワードです" }; + } + case "delete": + deleteFavorite(Number(form.get("id"))); + return { error: null }; + case "delete-passkey": + return { + error: deletePasskey(String(form.get("id"))) ? null : "最後のパスキーは削除できません", + }; + } + return { error: null }; +} + +export default function Favorites({ actionData }: Route.ComponentProps) { + const { favorites, passkeys } = useLoaderData<typeof loader>(); + const navigation = useNavigation(); + const adding = navigation.formData?.get("intent") === "add"; + + return ( + <div className="min-h-screen bg-gray-950 text-gray-100"> + <header className="border-b border-gray-800 px-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0"> + <Link to="/" className="text-xl font-bold tracking-tight text-white"> + 🎸 ライブに行くしかない + </Link> + <nav className="flex gap-4 sm:gap-6 text-sm text-gray-400"> + <Link to="/events" className="hover:text-white transition-colors">イベント</Link> + <Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link> + <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link> + <Link to="/favorites" className="text-white font-medium">★ お気に入り</Link> + </nav> + </header> + + <main className="max-w-2xl mx-auto px-4 py-8 flex flex-col gap-10"> + <section> + <div className="mb-4 flex items-center justify-between"> + <h1 className="text-2xl font-bold">お気に入り</h1> + <Link + to="/events?fav=1" + className="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium hover:bg-indigo-500 transition-colors" + > + 該当イベントを見る → + </Link> + </div> + <p className="mb-4 text-xs text-gray-400"> + バンド名などを登録すると、タイトルか出演者に含まれるイベントに ★ が付きます(全角/半角・大文字/小文字は区別しません)。 + </p> + + <Form method="post" className="flex gap-2" key={favorites.length}> + <input type="hidden" name="intent" value="add" /> + <input + name="term" + required + maxLength={100} + placeholder="バンド名、イベント名..." + className="flex-1 rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> + <button + type="submit" + disabled={adding} + className="rounded-md bg-gray-700 px-4 py-1.5 text-sm font-medium hover:bg-gray-600 disabled:opacity-50 transition-colors" + > + 追加 + </button> + </Form> + {actionData?.error && <p className="mt-2 text-sm text-red-400">{actionData.error}</p>} + + {favorites.length === 0 ? ( + <p className="mt-6 text-sm text-gray-500">まだ登録されていません</p> + ) : ( + <ul className="mt-4 flex flex-col gap-1.5"> + {favorites.map((f) => ( + <li + key={f.id} + className="flex items-center gap-3 rounded-lg px-4 py-2.5 bg-gray-800/40 border border-gray-700/30 text-sm" + > + <Link + to={`/events?keyword=${encodeURIComponent(f.term)}`} + className="flex-1 min-w-0 truncate hover:text-indigo-300 transition-colors" + > + {f.term} + </Link> + <span className={`text-xs tabular-nums ${f.upcoming_count > 0 ? "text-indigo-300" : "text-gray-600"}`}> + {f.upcoming_count} 件 + </span> + <Form method="post"> + <input type="hidden" name="intent" value="delete" /> + <input type="hidden" name="id" value={f.id} /> + <button type="submit" className="text-gray-500 hover:text-red-400 transition-colors" title="削除"> + ✕ + </button> + </Form> + </li> + ))} + </ul> + )} + </section> + + <section className="border-t border-gray-800 pt-8"> + <h2 className="mb-3 text-lg font-semibold">パスキー</h2> + <ul className="flex flex-col gap-1.5"> + {passkeys.map((p) => ( + <li key={p.id} className="flex items-center gap-3 rounded-lg px-4 py-2.5 bg-gray-800/40 border border-gray-700/30 text-sm"> + <span className="flex-1 min-w-0 truncate">{p.label ?? "(名前なし)"}</span> + <span className="text-xs text-gray-500"> + {p.last_used_at ? `最終使用 ${p.last_used_at.slice(0, 10)}` : `登録 ${p.created_at.slice(0, 10)}`} + </span> + {passkeys.length > 1 && ( + <Form method="post"> + <input type="hidden" name="intent" value="delete-passkey" /> + <input type="hidden" name="id" value={p.id} /> + <button type="submit" className="text-gray-500 hover:text-red-400 transition-colors" title="削除"> + ✕ + </button> + </Form> + )} + </li> + ))} + </ul> + <div className="mt-4 flex gap-3"> + <Link + to="/login" + className="rounded-md border border-gray-700 px-4 py-1.5 text-sm text-gray-300 hover:text-white transition-colors" + > + パスキーを追加 + </Link> + <Form method="post" action="/api/auth/logout"> + <button + type="submit" + className="rounded-md border border-gray-700 px-4 py-1.5 text-sm text-gray-400 hover:text-white transition-colors" + > + ログアウト + </button> + </Form> + </div> + </section> + </main> + </div> + ); +} diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 0000000..69353ea --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,117 @@ +import { useState } from "react"; +import { Link, useLoaderData, useNavigate } from "react-router"; +import { startAuthentication, startRegistration } from "@simplewebauthn/browser"; +import type { Route } from "./+types/login"; +import { ensureSetupToken, isLoggedIn } from "~/lib/auth.server"; + +export async function loader({ request }: Route.LoaderArgs) { + ensureSetupToken(); + const url = new URL(request.url); + const next = url.searchParams.get("next"); + return { + loggedIn: isLoggedIn(request), + token: url.searchParams.get("token"), + // Only allow same-site relative redirects + next: next && /^\/(?![/\\])/.test(next) ? next : "/favorites", + }; +} + +async function postJson(path: string, body: unknown) { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok || data.ok === false) throw new Error(data.error ?? `HTTP ${res.status}`); + return data; +} + +export default function Login() { + const { loggedIn, token, next } = useLoaderData<typeof loader>(); + const navigate = useNavigate(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const [label, setLabel] = useState(""); + const canRegister = loggedIn || !!token; + + async function run(fn: () => Promise<void>) { + setBusy(true); + setError(null); + try { + await fn(); + navigate(next); + } catch (e) { + const err = e as Error; + setError(err.name === "NotAllowedError" ? "キャンセルされました" : err.message); + } finally { + setBusy(false); + } + } + + const login = () => + run(async () => { + const optionsJSON = await postJson("/api/auth/login-options", {}); + const response = await startAuthentication({ optionsJSON }); + await postJson("/api/auth/login-verify", { response }); + }); + + const register = () => + run(async () => { + const optionsJSON = await postJson("/api/auth/register-options", { token }); + const response = await startRegistration({ optionsJSON }); + await postJson("/api/auth/register-verify", { token, response, label: label || null }); + }); + + return ( + <div className="min-h-screen bg-gray-950 text-gray-100 flex items-center justify-center px-4"> + <div className="w-full max-w-sm rounded-xl border border-gray-800 bg-gray-900 p-6 flex flex-col gap-5"> + <Link to="/events" className="text-lg font-bold tracking-tight text-white"> + 🎸 ライブに行くしかない + </Link> + + {!loggedIn && ( + <button + onClick={login} + disabled={busy} + className="rounded-md bg-indigo-600 px-4 py-2.5 text-sm font-medium hover:bg-indigo-500 disabled:opacity-50 transition-colors" + > + 🔑 パスキーでログイン + </button> + )} + + {loggedIn && !token && ( + <p className="text-sm text-gray-300"> + ログイン済みです。{" "} + <Link to="/favorites" className="text-indigo-400 hover:text-indigo-300 underline"> + お気に入りへ + </Link> + </p> + )} + + {canRegister && ( + <div className="flex flex-col gap-2 border-t border-gray-800 pt-5"> + <p className="text-xs text-gray-400"> + {loggedIn ? "このデバイスのパスキーを追加登録" : "パスキーを新規登録"} + </p> + <input + value={label} + onChange={(e) => setLabel(e.target.value)} + placeholder="デバイス名(例: iPhone)" + className="rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> + <button + onClick={register} + disabled={busy} + className="rounded-md bg-gray-700 px-4 py-2 text-sm font-medium hover:bg-gray-600 disabled:opacity-50 transition-colors" + > + パスキーを登録 + </button> + </div> + )} + + {error && <p className="text-sm text-red-400">{error}</p>} + </div> + </div> + ); +} diff --git a/app/routes/venues.tsx b/app/routes/venues.tsx index 9b8093a..690b1c2 100644 --- a/app/routes/venues.tsx +++ b/app/routes/venues.tsx @@ -26,6 +26,7 @@ export default function Venues() { <Link to="/events" className="hover:text-white transition-colors">イベント</Link> <Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link> <Link to="/venues" className="text-white font-medium">会場一覧</Link> + <Link to="/favorites" className="hover:text-white transition-colors">★ お気に入り</Link> </nav> </header> diff --git a/package-lock.json b/package-lock.json index 2ae510b..4ddb459 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "dependencies": { "@react-router/node": "^7.3.0", "@react-router/serve": "^7.3.0", + "@simplewebauthn/browser": "^14.0.0", + "@simplewebauthn/server": "^14.0.2", "better-sqlite3": "^12.9.0", "cheerio": "^1.2.0", "isbot": "^5.1.36", @@ -924,6 +926,12 @@ "node": ">=18" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -986,6 +994,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, "node_modules/@mjackson/node-fetch-server": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", @@ -1067,6 +1081,239 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.9.5.tgz", + "integrity": "sha512-KnNE4l033gbJ3ZzfYk7fJpSZwhd5xT+dPAnrB8bcwqylHiLN2zjZ0OyFao0LDIOI/ZdXRabV0n3Ww/KvpgZq6w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-asym-key": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.5.tgz", + "integrity": "sha512-U3Q2bUWYNVpfk+oOhPl/+OSHxXaBTQBwMf93jJ7oPi/sbmcY5CnARdjGMwLoXT/ggWSryo5t7A7o3/Gfz0U2WA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-pkcs8": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.5.tgz", + "integrity": "sha512-4Ivz0fsyFofe0bslNsYcGN9+iETdHX4v+BFwIPr+K/BAt61sHXECP63ff9FntU9zcfVQELJFdUG9so44OjUlBQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "@peculiar/asn1-x509-attr": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.5.tgz", + "integrity": "sha512-9dO6PcoJeI+1xJ4rG7eINMa6f9Ix/QdRs5UGZ0D7v/R3TzmsbVr5cqAgfYw4xgS9U54ouHYqEz0bIZBbM/UY1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.5.tgz", + "integrity": "sha512-OD+HLlcV7wCL64w0rheX/C0J31DbJce0nf8ulv3UNftQ617JEZE3sQ4cMN6PotXWlJ1M5GQx90G3bywtdKOstw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.5.tgz", + "integrity": "sha512-btcH7lbcD4410qnjHvv7F0kEuMNKbX8CFAZSs9CDPWHKzT1byLPHYeR40AnUw9z4CzsijMY0daBFYUvVx75sdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.5", + "@peculiar/asn1-pkcs8": "^2.9.5", + "@peculiar/asn1-rsa": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.5.tgz", + "integrity": "sha512-c7UghAGQdBfAHL9JdwcJkAc/lDJKNXfCdYXPXlf8BZdD/fVJjJnXKHxX4gyLhkFTvnGhME7caieSgc2ROtqYTQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.5.tgz", + "integrity": "sha512-4R/zMgLSHbQIZRBwP1KzX6iuNHmW3xFWwu9TS9A00N60fmQqp9DkkqnQFyd0mAYMQTrQefuzQ/yP4U8sOLGUgQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.5", + "@peculiar/asn1-pfx": "^2.9.5", + "@peculiar/asn1-pkcs8": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "@peculiar/asn1-x509-attr": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.5.tgz", + "integrity": "sha512-kHwBLVMkm9ljlM521ovS7y4z4mr8xuHTdNyyoPlDvckBwFKSgs9HqKBryLBeqHqL2YpIgl+Vspl7kzNGG2phzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.5.tgz", + "integrity": "sha512-Ez3wLKVjaxdsLcgeWN4OE31QkM7oBOgKuuBJxldRYAkfYw2C+8zJPcSd/SThnbhszsEOlAmoaS5kIIkN29fGKQ==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.5.tgz", + "integrity": "sha512-NP3ecvN6zTDlwCvogIWL7MJN9AZNuskkIbnlhrlkB97Mn2EqirBUQ8GKzsOmdAL3pRvjGu1Uk/a4DNfiZrWxhw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.5.tgz", + "integrity": "sha512-PlbL1a74RZiry7puPCZ5hdk/puvivxYJPbjadr2i7DBjHCJFEKCfTRPKL3DILeZFtzJjn95J5EBtSc4/TB9/6Q==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-post-quantum": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.5.tgz", + "integrity": "sha512-CTznDkboSgwRpXSFhta+Pe5cQ12JtojdMrmOZTWYkBSvbW/1R5pflPIHkpSlaILIfGcAaxIVpXq880xCfJYzow==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-asym-key": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz", + "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-csr": "^2.9.4", + "@peculiar/asn1-ecc": "^2.9.4", + "@peculiar/asn1-pkcs9": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1536,6 +1783,33 @@ "win32" ] }, + "node_modules/@simplewebauthn/browser": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-14.0.0.tgz", + "integrity": "sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==", + "license": "MIT" + }, + "node_modules/@simplewebauthn/server": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.2.tgz", + "integrity": "sha512-g+m/xv9/8FRWsDY22CwsTYRzt3WwS9fJrR3P7eY3Ti01FXH5FQ36Xn55v6D+5tb6VajlqOj4hywP/Vxsb6a3Sg==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", + "@peculiar/x509": "^2.1.0", + "reflect-metadata": "^0.2.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -1905,6 +2179,20 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", @@ -4190,6 +4478,24 @@ "once": "^1.3.1" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -4329,6 +4635,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -4912,9 +5224,7 @@ "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "optional": true + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "node_modules/tsx": { "version": "4.21.0", @@ -5420,6 +5730,24 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 5232259..ea13320 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "dependencies": { "@react-router/node": "^7.3.0", "@react-router/serve": "^7.3.0", + "@simplewebauthn/browser": "^14.0.0", + "@simplewebauthn/server": "^14.0.2", "better-sqlite3": "^12.9.0", "cheerio": "^1.2.0", "isbot": "^5.1.36", |
