summaryrefslogtreecommitdiff
path: root/app/lib
diff options
context:
space:
mode:
Diffstat (limited to 'app/lib')
-rw-r--r--app/lib/auth.server.ts304
-rw-r--r--app/lib/db.server.ts64
2 files changed, 365 insertions, 3 deletions
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