diff --git a/package.json b/package.json index 38d9ec185..b6f6c523a 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "localforage": "^1.10.0", "maplibre-gl": "^4.7.1", "marked": "^18.0.0", + "nostr-tools": "^2.23.3", "opening_hours": "^3.12.0", "qrcode": "^1.5.4", "svelte-i18n": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b1fa8944..b8b7e1e8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: marked: specifier: ^18.0.0 version: 18.0.0 + nostr-tools: + specifier: ^2.23.3 + version: 2.23.3(typescript@5.9.3) opening_hours: specifier: ^3.12.0 version: 3.12.0(typescript@5.9.3) @@ -146,7 +149,7 @@ importers: version: 1.5.6 jsdom: specifier: ^29.0.2 - version: 29.0.2 + version: 29.0.2(@noble/hashes@2.0.1) lint-staged: specifier: ^16.4.0 version: 16.4.0 @@ -173,7 +176,7 @@ importers: version: 5.4.21(@types/node@22.19.17)(lightningcss@1.32.0) vitest: specifier: ^3.2.4 - version: 3.2.4(@types/node@22.19.17)(jiti@2.6.1)(jsdom@29.0.2)(lightningcss@1.32.0)(yaml@2.8.2) + version: 3.2.4(@types/node@22.19.17)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1))(lightningcss@1.32.0)(yaml@2.8.2) packages: @@ -970,6 +973,18 @@ packages: resolution: {integrity: sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==} hasBin: true + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@playwright/test@1.59.1': resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} engines: {node: '>=18'} @@ -1106,6 +1121,15 @@ packages: cpu: [x64] os: [win32] + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + + '@scure/bip32@2.0.1': + resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + + '@scure/bip39@2.0.1': + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2027,6 +2051,17 @@ packages: next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} + nostr-tools@2.23.3: + resolution: {integrity: sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -2952,7 +2987,9 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@exodus/bytes@1.15.0': {} + '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': + optionalDependencies: + '@noble/hashes': 2.0.1 '@formatjs/ecma402-abstract@2.3.6': dependencies: @@ -3045,6 +3082,14 @@ snapshots: rw: 1.3.3 tinyqueue: 3.0.0 + '@noble/ciphers@2.1.1': {} + + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/hashes@2.0.1': {} + '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 @@ -3128,6 +3173,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@scure/base@2.0.0': {} + + '@scure/bip32@2.0.1': + dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@standard-schema/spec@1.1.0': {} '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': @@ -3518,10 +3576,10 @@ snapshots: es5-ext: 0.10.64 type: 2.7.3 - data-urls@7.0.0: + data-urls@7.0.0(@noble/hashes@2.0.1): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) transitivePeerDependencies: - '@noble/hashes' @@ -3837,9 +3895,9 @@ snapshots: dependencies: function-bind: 1.1.2 - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): dependencies: - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) transitivePeerDependencies: - '@noble/hashes' @@ -3894,17 +3952,17 @@ snapshots: js-tokens@9.0.1: {} - jsdom@29.0.2: + jsdom@29.0.2(@noble/hashes@2.0.1): dependencies: '@asamuzakjp/css-color': 5.1.8 '@asamuzakjp/dom-selector': 7.0.8 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) css-tree: 3.2.1 - data-urls: 7.0.0 + data-urls: 7.0.0(@noble/hashes@2.0.1) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) is-potential-custom-element-name: 1.0.1 lru-cache: 11.3.2 parse5: 8.0.0 @@ -3915,7 +3973,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -4111,6 +4169,20 @@ snapshots: next-tick@1.1.0: {} + nostr-tools@2.23.3(typescript@5.9.3): + dependencies: + '@noble/ciphers': 2.1.1 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.9.3 + + nostr-wasm@0.1.0: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -4499,7 +4571,7 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@22.19.17)(lightningcss@1.32.0) - vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(jsdom@29.0.2)(lightningcss@1.32.0)(yaml@2.8.2): + vitest@3.2.4(@types/node@22.19.17)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1))(lightningcss@1.32.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -4526,7 +4598,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.17 - jsdom: 29.0.2 + jsdom: 29.0.2(@noble/hashes@2.0.1) transitivePeerDependencies: - jiti - less @@ -4555,9 +4627,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1: + whatwg-url@16.0.1(@noble/hashes@2.0.1): dependencies: - '@exodus/bytes': 1.15.0 + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 869d4cf46..e72ad138a 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -20,7 +20,8 @@ const materialExceptions: Record = { currency_bitcoin: "material-symbols:currency-bitcoin", close_round: "ic:round-close", my_location: "material-symbols:my-location-rounded", - bookmark_filled: "ic:baseline-bookmark", + bookmark_filled: "ic:baseline-bookmark-added", + account_circle_filled: "ic:baseline-account-circle", }; const faBrandIcons = ["x-twitter", "instagram", "facebook", "twitter"]; diff --git a/src/components/SaveButton.svelte b/src/components/SaveButton.svelte index fb7d77a28..7eda3ffc1 100644 --- a/src/components/SaveButton.svelte +++ b/src/components/SaveButton.svelte @@ -3,7 +3,7 @@ import Icon from "$components/Icon.svelte"; import api from "$lib/axios"; import { _ } from "$lib/i18n"; import { session } from "$lib/session"; -import { errToast } from "$lib/utils"; +import { errToast, successToast } from "$lib/utils"; // The numeric ID of the item to save/unsave. export let id: number; @@ -56,6 +56,7 @@ async function toggle() { let current = previousSession; if (!current) { current = await session.signUp(); + successToast($_("save.accountCreated")); } const nextSaved = diff --git a/src/components/auth/BackupModal.svelte b/src/components/auth/BackupModal.svelte new file mode 100644 index 000000000..19ec8ac76 --- /dev/null +++ b/src/components/auth/BackupModal.svelte @@ -0,0 +1,115 @@ + + + e.key === "Escape" && dispatch("close")} /> + + diff --git a/src/components/layout/Header.svelte b/src/components/layout/Header.svelte index 5727857e3..5cdd3e9ae 100644 --- a/src/components/layout/Header.svelte +++ b/src/components/layout/Header.svelte @@ -1,12 +1,11 @@ + +
+ + + {#if open} + (open = false)} + > +
+ {#if $session} + + + {$_("nav.mySaved")} + + + {#if $session.autoGenerated} + + {/if} + +
+ + + {:else} + + + {$_("nav.login")} + + {/if} +
+
+ {/if} +
+ +{#if showBackup} + (showBackup = false)} /> +{/if} diff --git a/src/lib/api-base.ts b/src/lib/api-base.ts new file mode 100644 index 000000000..d22e86626 --- /dev/null +++ b/src/lib/api-base.ts @@ -0,0 +1,14 @@ +// Resolved API base URL. Defaults to the production endpoint; override via +// VITE_API_BASE_URL in .env for local development. +// +// Client-side: relative paths like "/btcmap-api-proxy" work fine — the +// browser resolves them against the current origin, hitting the Vite dev +// proxy. SvelteKit's event.fetch in +page.server.ts load functions also +// handles relative URLs. +// +// Server-side (axios in +server.ts, module-level SSR code): relative paths +// fail because Node has no browser origin. For these callers, set an +// absolute URL instead (e.g. "http://127.0.0.1:8000"). +export const API_BASE: string = ( + import.meta.env.VITE_API_BASE_URL || "https://api.btcmap.org" +).replace(/\/+$/, ""); diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 86118cf2f..d99f50604 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -35,7 +35,12 @@ "taggingIssues": "Tagging Issues", "communities": "Communities", "countries": "Countries", - "saved": "Saved" + "saved": "Saved", + "mySaved": "My Saved", + "account": "Account", + "login": "Log in", + "logout": "Log out", + "backupAccount": "Back up account" }, "saved": { "title": "My Saved", @@ -46,6 +51,42 @@ "noAreas": "No saved areas yet.", "loadError": "Failed to load some saved items." }, + "login": { + "title": "Log in", + "username": "Username", + "password": "Password", + "submit": "Log in", + "loggingIn": "Logging in...", + "failed": "Invalid username or password.", + "error": "Something went wrong. Please try again.", + "noAccount": "Don't have an account?", + "createAccount": "Create one here", + "or": "or", + "nostrExtension": "Sign in with Nostr extension", + "nostrSigning": "Waiting for signature...", + "nostrFailed": "Nostr signature was rejected.", + "nostrError": "Something went wrong signing in with Nostr.", + "nsecToggle": "Sign in with a private key", + "nsecLabel": "Private key (nsec)", + "nsecSubmit": "Sign in with private key", + "nsecWarning": "Your key is used locally to sign a login event and is never stored or sent to any server. Prefer a Nostr extension if you have one.", + "nsecInvalid": "That doesn't look like a valid nsec key." + }, + "backup": { + "title": "Back up account", + "description": "Save these credentials to log in on another device.", + "username": "Username", + "password": "Password", + "copy": "Copy", + "show": "Show password", + "hide": "Hide password", + "unavailable": "Not available (old account)", + "warning": "These credentials are the only way to recover your saved places.", + "copied": "Copied to clipboard" + }, + "save": { + "accountCreated": "Account created — your saved places are stored on this device." + }, "search": { "placeholderWorldwide": "Search worldwide...", "placeholderNearby": "Search nearby...", diff --git a/src/lib/nostr.ts b/src/lib/nostr.ts new file mode 100644 index 000000000..315bc39fc --- /dev/null +++ b/src/lib/nostr.ts @@ -0,0 +1,68 @@ +import { decode } from "nostr-tools/nip19"; +import type { EventTemplate, VerifiedEvent } from "nostr-tools/pure"; +import { finalizeEvent } from "nostr-tools/pure"; + +import { API_BASE } from "$lib/api-base"; + +// URL the API verifies in the NIP-98 event's "u" tag. Must match what the +// server sees, including scheme and no trailing slash. +export const NOSTR_AUTH_URL = `${API_BASE}/v4/auth/nostr`; + +// NIP-07 extension interface (window.nostr) — minimal subset we use. +// Extensions like Alby, nos2x inject this on page load. +type NostrExtension = { + getPublicKey: () => Promise; + signEvent: (event: EventTemplate) => Promise; +}; + +declare global { + interface Window { + nostr?: NostrExtension; + } +} + +// Detect a NIP-07-compatible browser extension. Returns null on SSR. +export function getNostrExtension(): NostrExtension | null { + if (typeof window === "undefined") return null; + return window.nostr ?? null; +} + +// Build the unsigned NIP-98 event template for POST /v4/auth/nostr. +// kind 27235, with "u" (url) and "method" tags per the spec. +function buildAuthTemplate(): EventTemplate { + return { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["u", NOSTR_AUTH_URL], + ["method", "POST"], + ], + content: "", + }; +} + +// Sign via a NIP-07 browser extension. Throws if no extension is present or +// the user rejects the signature request. +export async function signAuthWithExtension(): Promise { + const ext = getNostrExtension(); + if (!ext) throw new Error("No Nostr extension detected"); + return ext.signEvent(buildAuthTemplate()); +} + +// Sign locally with a raw secret key (hex or nsec-decoded Uint8Array). The +// key is used once and discarded — never stored. +export function signAuthWithSecretKey(secretKey: Uint8Array): VerifiedEvent { + return finalizeEvent(buildAuthTemplate(), secretKey); +} + +// Decode a bech32 nsec... string into its 32-byte secret key. Throws on any +// non-nsec input (npub, invalid bech32, wrong length) — callers surface a +// generic "invalid key" message rather than leaking details. +export function decodeNsec(nsec: string): Uint8Array { + const trimmed = nsec.trim(); + const decoded = decode(trimmed); + if (decoded.type !== "nsec") { + throw new Error("Not an nsec"); + } + return decoded.data; +} diff --git a/src/lib/session.test.ts b/src/lib/session.test.ts index de36fad7a..031ad350d 100644 --- a/src/lib/session.test.ts +++ b/src/lib/session.test.ts @@ -20,11 +20,15 @@ async function createTestSession() { // pre-populated localStorage function seedStorage(data: { username: string; + password?: string; token: string; savedPlaces: number[]; savedAreas?: number[]; }) { - localStorage.setItem("btcmap_session", JSON.stringify(data)); + localStorage.setItem( + "btcmap_session", + JSON.stringify({ password: "pw", autoGenerated: true, ...data }), + ); } describe("session store", () => { @@ -119,7 +123,6 @@ describe("session store", () => { describe("loadFromStorage backfill", () => { it("backfills savedAreas when missing from old session", async () => { - // Simulate an old session without savedAreas localStorage.setItem( "btcmap_session", JSON.stringify({ @@ -136,6 +139,23 @@ describe("session store", () => { expect(current?.savedPlaces).toEqual([1, 2]); }); + it("backfills password when missing from old session", async () => { + localStorage.setItem( + "btcmap_session", + JSON.stringify({ + username: "old-user", + token: "old-tok", + savedPlaces: [1], + savedAreas: [], + }), + ); + const session = await createTestSession(); + session.init(); + + const current = get(session); + expect(current?.password).toBe(""); + }); + it("returns null for invalid data", async () => { localStorage.setItem("btcmap_session", "not-json"); const session = await createTestSession(); diff --git a/src/lib/session.ts b/src/lib/session.ts index 31ba7f30b..55dcd8dcb 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -10,18 +10,23 @@ import api from "$lib/axios"; // they lose access to their saved items. A backup flow (set password, link // Nostr) will be added later. // -// SECURITY — localStorage token blast radius: -// Storing the Bearer token in localStorage is acceptable here ONLY because -// the account is a throwaway with no recoverable data or PII. The token -// grants access to its own saved_places/saved_areas and nothing else. -// DO NOT reuse this pattern for real user accounts with durable data, -// payment info, or elevated roles — an XSS would exfiltrate the token. -// For real accounts, migrate to httpOnly cookies or similar. +// SECURITY — localStorage blast radius: +// The Bearer token is always stored in localStorage. The password is +// stored only for auto-generated accounts (so the backup modal can +// show it). Manual logins store an empty password — the user already +// knows their credentials. An XSS could exfiltrate the token (and +// the password for auto-generated accounts). This is acceptable for +// throwaway accounts with only saved_places/saved_areas data. +// DO NOT reuse this pattern for real user accounts +// with durable data, payment info, or elevated roles. For real +// accounts, migrate to httpOnly cookies or similar. export type Session = { username: string; + password: string; token: string; savedPlaces: number[]; savedAreas: number[]; + autoGenerated: boolean; }; const STORAGE_KEY = "btcmap_session"; @@ -43,6 +48,17 @@ function loadFromStorage(): Session | null { if (!Array.isArray(parsed.savedAreas)) { parsed.savedAreas = []; } + // Backfill password for sessions created before backup was available. + // Empty string means the password is lost — the user can still use + // their current token but can't back up or log in on another device. + if (typeof parsed.password !== "string") { + parsed.password = ""; + } + // Backfill autoGenerated for old sessions — assume auto-generated + // since the login flow didn't exist when they were created. + if (typeof parsed.autoGenerated !== "boolean") { + parsed.autoGenerated = true; + } return parsed as Session; } catch { return null; @@ -83,9 +99,11 @@ function createSessionStore() { const session: Session = { username, + password, token, savedPlaces: [], savedAreas: [], + autoGenerated: true, }; saveToStorage(session); set(session); @@ -193,6 +211,60 @@ function createSessionStore() { return result; }, + // Replace the current session with a different account (login flow). + // Saved items are populated separately after login. + login: (username: string, password: string, token: string) => { + const session: Session = { + username, + password, + token, + savedPlaces: [], + savedAreas: [], + autoGenerated: false, + }; + saveToStorage(session); + set(session); + }, + + // Fetch saved places + areas from the server and hydrate the store. + // Best-effort: partial failures are logged, not thrown. Call after + // login() so there's an active session to populate. + loadSavedItemsFromServer: async (token: string): Promise => { + const headers = { Authorization: `Bearer ${token}` }; + const [placesRes, areasRes] = await Promise.allSettled([ + api.get("/api/session/saved-places", { headers }), + api.get("/api/session/saved-areas", { headers }), + ]); + + const applyIds = (ids: number[], key: "savedPlaces" | "savedAreas") => { + update((current) => { + if (!current) return current; + const next = { ...current, [key]: ids }; + saveToStorage(next); + return next; + }); + }; + + if ( + placesRes.status === "fulfilled" && + Array.isArray(placesRes.value.data) + ) { + applyIds( + placesRes.value.data.map((p: { id: number }) => p.id), + "savedPlaces", + ); + } + if ( + areasRes.status === "fulfilled" && + Array.isArray(areasRes.value.data) + ) { + applyIds( + areasRes.value.data.map((a: { id: number }) => a.id), + "savedAreas", + ); + } + }, + // Clear the session (logout / forget account). No recovery. clear: () => { saveToStorage(null); diff --git a/src/routes/api/session/login/+server.ts b/src/routes/api/session/login/+server.ts new file mode 100644 index 000000000..c86055375 --- /dev/null +++ b/src/routes/api/session/login/+server.ts @@ -0,0 +1,43 @@ +import { error, json } from "@sveltejs/kit"; + +import { API_BASE } from "$lib/api-base"; +import api from "$lib/axios"; + +import type { RequestHandler } from "./$types"; + +// POST /api/session/login +// Authenticates with username + password and returns a Bearer token. +// Proxies POST /v4/users/{username}/tokens to avoid CORS preflight issues. +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json(); + const { username, password } = body; + + if (!username || typeof username !== "string" || username.length > 100) { + error(400, "Missing or invalid username"); + } + if (!password || typeof password !== "string" || password.length > 200) { + error(400, "Missing or invalid password"); + } + + const tokenRes = await api + .post( + `${API_BASE}/v4/users/${encodeURIComponent(username)}/tokens`, + {}, + { headers: { Authorization: `Bearer ${password}` } }, + ) + .catch((err) => { + const status = err?.response?.status; + if (status === 401 || status === 403) { + error(401, "Invalid username or password"); + } + console.error("Failed to create token:", err?.response?.status); + error(502, "Failed to log in"); + }); + + const token = tokenRes.data?.token; + if (typeof token !== "string") { + error(502, "Token creation returned no token"); + } + + return json({ token }); +}; diff --git a/src/routes/api/session/nostr/+server.ts b/src/routes/api/session/nostr/+server.ts new file mode 100644 index 000000000..18697579d --- /dev/null +++ b/src/routes/api/session/nostr/+server.ts @@ -0,0 +1,58 @@ +import { error, json } from "@sveltejs/kit"; + +import api from "$lib/axios"; +import { NOSTR_AUTH_URL } from "$lib/nostr"; + +import type { RequestHandler } from "./$types"; + +// A signed NIP-98 event is ~400 bytes. 4 KB leaves generous headroom for +// unusual tag sets while rejecting obvious junk bodies before we try to parse. +const MAX_BODY_BYTES = 4096; + +// POST /api/session/nostr +// Exchanges a signed NIP-98 event (kind 27235) for a BTC Map Bearer token. +// Proxies to POST /v4/auth/nostr to avoid browser CORS preflight issues. +// If the npub is unknown, the API creates a new account linked to that pubkey. +// +// Browser-side contract is unchanged: the client posts {signed_event} as +// JSON to this route. The hop to btcmap-api uses the canonical NIP-98 +// Authorization: Nostr header (no body), per the spec and +// what the API extractor expects. +export const POST: RequestHandler = async ({ request }) => { + const contentLength = Number(request.headers.get("content-length")); + if (!Number.isFinite(contentLength) || contentLength > MAX_BODY_BYTES) { + error(413, "Request body too large"); + } + + const body = await request.json(); + const { signed_event } = body; + + if (!signed_event || typeof signed_event !== "object") { + error(400, "Missing or invalid signed_event"); + } + + const eventB64 = Buffer.from(JSON.stringify(signed_event), "utf-8").toString( + "base64", + ); + + const res = await api + .post(NOSTR_AUTH_URL, undefined, { + headers: { Authorization: `Nostr ${eventB64}` }, + }) + .catch((err) => { + const status = err?.response?.status; + if (status === 401 || status === 403) { + error(401, "Invalid Nostr signature"); + } + console.error("Failed to exchange Nostr event:", status); + error(502, "Failed to authenticate with Nostr"); + }); + + const token = res.data?.token; + const username = res.data?.username; + if (typeof token !== "string" || typeof username !== "string") { + error(502, "Nostr auth returned invalid response"); + } + + return json({ token, username }); +}; diff --git a/src/routes/api/session/saved-areas/+server.ts b/src/routes/api/session/saved-areas/+server.ts index ba17023dd..f32577a6d 100644 --- a/src/routes/api/session/saved-areas/+server.ts +++ b/src/routes/api/session/saved-areas/+server.ts @@ -1,5 +1,6 @@ import { error, json } from "@sveltejs/kit"; +import { API_BASE } from "$lib/api-base"; import api from "$lib/axios"; import type { RequestHandler } from "./$types"; @@ -13,7 +14,7 @@ export const GET: RequestHandler = async ({ request }) => { } const res = await api - .get("https://api.btcmap.org/v4/areas/saved", { + .get(`${API_BASE}/v4/areas/saved`, { headers: { Authorization: token }, }) .catch((err) => { @@ -39,7 +40,7 @@ export const PUT: RequestHandler = async ({ request }) => { } const res = await api - .put("https://api.btcmap.org/v4/areas/saved", ids, { + .put(`${API_BASE}/v4/areas/saved`, ids, { headers: { Authorization: token }, }) .catch((err) => { diff --git a/src/routes/api/session/saved-places/+server.ts b/src/routes/api/session/saved-places/+server.ts index 9f5911ab0..f88084344 100644 --- a/src/routes/api/session/saved-places/+server.ts +++ b/src/routes/api/session/saved-places/+server.ts @@ -1,5 +1,6 @@ import { error, json } from "@sveltejs/kit"; +import { API_BASE } from "$lib/api-base"; import api from "$lib/axios"; import type { RequestHandler } from "./$types"; @@ -13,7 +14,7 @@ export const GET: RequestHandler = async ({ request }) => { } const res = await api - .get("https://api.btcmap.org/v4/places/saved", { + .get(`${API_BASE}/v4/places/saved`, { headers: { Authorization: token }, }) .catch((err) => { @@ -42,7 +43,7 @@ export const PUT: RequestHandler = async ({ request }) => { } const res = await api - .put("https://api.btcmap.org/v4/places/saved", ids, { + .put(`${API_BASE}/v4/places/saved`, ids, { headers: { Authorization: token }, }) .catch((err) => { diff --git a/src/routes/api/session/signup/+server.ts b/src/routes/api/session/signup/+server.ts index 11326a625..1b514b346 100644 --- a/src/routes/api/session/signup/+server.ts +++ b/src/routes/api/session/signup/+server.ts @@ -1,5 +1,6 @@ import { error, json } from "@sveltejs/kit"; +import { API_BASE } from "$lib/api-base"; import api from "$lib/axios"; import type { RequestHandler } from "./$types"; @@ -19,7 +20,7 @@ export const POST: RequestHandler = async ({ request }) => { // Step 1: Create user const userRes = await api - .post("https://api.btcmap.org/v4/users", { password }) + .post(`${API_BASE}/v4/users`, { password }) .catch((err) => { console.error("Failed to create user:", err?.response?.data ?? err); error(502, "Failed to create account"); @@ -33,7 +34,7 @@ export const POST: RequestHandler = async ({ request }) => { // Step 2: Create token (password is sent as Bearer for this endpoint) const tokenRes = await api .post( - `https://api.btcmap.org/v4/users/${encodeURIComponent(username)}/tokens`, + `${API_BASE}/v4/users/${encodeURIComponent(username)}/tokens`, {}, { headers: { Authorization: `Bearer ${password}` } }, ) diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 000000000..b2666055c --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,274 @@ + + + + {$_("login.title")} | BTC Map + + +
+
+

+ {$_("login.title")} +

+ +
+ {#if hasNostrExtension} + + {/if} + + {#if showNsecInput} +
+ + +

+ {$_("login.nsecWarning")} +

+ +
+ {:else} + + {/if} + +
+
+ {$_("login.or")} +
+
+
+ +
+
+ + +
+ +
+ + +
+ + +
+ +

+ {$_("login.noAccount")} + + {$_("login.createAccount")} + +

+
+
diff --git a/src/routes/saved/+page.svelte b/src/routes/user/saved/+page.svelte similarity index 99% rename from src/routes/saved/+page.svelte rename to src/routes/user/saved/+page.svelte index 4b859e31c..d54473a5a 100644 --- a/src/routes/saved/+page.svelte +++ b/src/routes/user/saved/+page.svelte @@ -38,7 +38,7 @@ onMount(async () => { session.init(); if (!$session) { - goto("/map"); + goto("/login"); return; }