diff options
Diffstat (limited to 'src')
28 files changed, 578 insertions, 223 deletions
diff --git a/src/components/About.svelte b/src/components/About.svelte index a1c03204..331a5188 100644 --- a/src/components/About.svelte +++ b/src/components/About.svelte @@ -11,7 +11,7 @@ { alt: "Nostr", src: "/images/nostr.svg", - url: "https://coinos.io/566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8", + url: "https://coinos.io/98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7", }, { alt: "Github", @@ -22,15 +22,6 @@ }, { name: "cole", socials: [] }, ]; - - let locations = $state(); - onMount(async () => { - try { - ({ locations } = await fetch("/locations").then((r) => r.json())); - } catch (e) { - console.log(e); - } - }); </script> <div class="flex justify-center"> @@ -59,9 +50,7 @@ <div class="mt-2 flex justify-center items-center space-x-5"> {#each member.socials as { alt, src, url }} - <a - href={url} - class={`rounded-full p-2 hover:opacity-80`} + <a href={url} class={`rounded-full p-2 hover:opacity-80`} ><img {src} {alt} class="w-8" /></a > {/each} @@ -77,29 +66,27 @@ </div> </div> -{#if locations?.length} - <div> - <h3 class="text-5xl font-medium mb-5 text-center"> - {$t("about.locations.header")} - </h3> - <p - class="text-secondary text-xl w-full md:w-10/12 lg:w-2/3 2xl:w-[800px] mx-auto mb-10" - > - {@html $t("about.locations.description")} - </p> +<div> + <h3 class="text-5xl font-medium mb-5 text-center"> + {$t("about.locations.header")} + </h3> + <p + class="text-secondary text-xl w-full md:w-10/12 lg:w-2/3 2xl:w-[800px] mx-auto mb-10" + > + {@html $t("about.locations.description")} + </p> - <div class="relative"> - <a href="/map" class="contents"> - <img src="/map.png" class="mx-auto" alt="Map" /> - </a> + <div class="relative"> + <a href="/map" class="contents"> + <img src="/map.png" class="mx-auto" alt="Map" /> + </a> - <a href="/map" class="contents"> - <button - class="bg-black text-white border border-4 rounded-full px-6 py-2 font-bold hover:bg-secondary absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 text-2xl" - > - {$t("about.locations.viewMap")} - </button> - </a> - </div> + <a href="/map" class="contents"> + <button + class="bg-black text-white border border-4 rounded-full px-6 py-2 font-bold hover:bg-secondary absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 text-2xl" + > + {$t("about.locations.viewMap")} + </button> + </a> </div> -{/if} +</div> diff --git a/src/components/Nostr.svelte b/src/components/Nostr.svelte index a616194b..f6c870f9 100644 --- a/src/components/Nostr.svelte +++ b/src/components/Nostr.svelte @@ -25,8 +25,8 @@ let cancel = () => { eventToSign.set(); showNsec = false; - pubkey = undefined; connectUrl = undefined; + $signer = "cancel"; }; let signerAvailable = $derived( diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 00000000..bd9bbc01 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,14 @@ +import { ipStore } from "$lib/server/ip"; +import { setIpGetter } from "$lib/utils"; +import type { Handle } from "@sveltejs/kit"; + +setIpGetter(() => ipStore.getStore()); + +export const handle: Handle = async ({ event, resolve }) => { + const ip = + event.request.headers.get("cf-connecting-ip") || + event.request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + event.getClientAddress(); + + return ipStore.run(ip, () => resolve(event)); +}; diff --git a/src/lib/nostr.ts b/src/lib/nostr.ts index 6a8b179a..beebba02 100644 --- a/src/lib/nostr.ts +++ b/src/lib/nostr.ts @@ -117,8 +117,14 @@ export const sign = async (event, user) => { eventToSign.set(event); let unsubscribe; - const { method, params } = await new Promise((r) => { - unsubscribe = $signer.subscribe((v) => v?.ready && r(v)); + const { method, params } = await new Promise((r, j) => { + unsubscribe = $signer.subscribe((v) => { + if (v === "cancel") { + j("cancelled"); + $signer.set(undefined); + } + v?.ready && r(v); + }); }); await unsubscribe(); const signedEvent = await signingMethods[method](event, params); diff --git a/src/lib/parse.ts b/src/lib/parse.ts index 9c18504a..6ff4556c 100644 --- a/src/lib/parse.ts +++ b/src/lib/parse.ts @@ -1,10 +1,10 @@ import { PUBLIC_DOMAIN } from "$env/static/public"; import { decode } from "$lib/bip21"; -import { get, isLiquid, post, sats } from "$lib/utils"; +import { auth, get, isLiquid, post, sats } from "$lib/utils"; import { redirect } from "@sveltejs/kit"; import { validate } from "bitcoin-address-validation"; -export default async (s, host) => { +export default async (s, host, cookies) => { if (!s) return; let t = s.trim(); let amount; @@ -58,7 +58,10 @@ export default async (s, host) => { if (t.toLowerCase().startsWith("lnurl")) redirect(307, `/ln/${t}`); if (t.includes(":")) t = t.split(":")[1]; - if (t.toLowerCase().startsWith("ln")) { + if (t.toLowerCase().startsWith("lnr")) { + const { iid } = await post("/sendinvoice", { invreq: t }, auth(cookies)); + invoice = { id: iid }; + } else if (t.toLowerCase().startsWith("ln")) { try { invoice = await get(`/invoice/${t}`); if (invoice.user.username === "mint") throw new Error("mint payment"); diff --git a/src/lib/server/ip.ts b/src/lib/server/ip.ts new file mode 100644 index 00000000..cfd933ac --- /dev/null +++ b/src/lib/server/ip.ts @@ -0,0 +1,2 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +export const ipStore = new AsyncLocalStorage<string>(); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 28a219e1..b981a4d4 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -21,11 +21,22 @@ export function scroll(section: any) { const base = browser ? "" : PUBLIC_COINOS_URL; +let getIp: (() => string | undefined) | undefined; + +export const setIpGetter = (fn: () => string | undefined) => { + getIp = fn; +}; + +const ipHeaders = (): Record<string, string> => { + const ip = getIp?.(); + return ip ? { "cf-connecting-ip": ip } : {}; +}; + export const punk = (k: string) => `${Math.floor((parseInt(k.slice(-2), 16) / 256) * 64) + 1}.webp`; export const g = (url: string, fetch: any, headers: object) => - fetch(base + url, { headers }) + fetch(base + url, { headers: { ...ipHeaders(), ...headers } }) .then((r: Response) => r.text()) .then((body: string) => { try { @@ -36,7 +47,7 @@ export const g = (url: string, fetch: any, headers: object) => }); export const get = (url: string, headers = {}) => { - return fetch(base + url, { headers }) + return fetch(base + url, { headers: { ...ipHeaders(), ...headers } }) .then(async (r) => { if (r.ok) return r.text(); throw new Error(await r.text()); @@ -58,6 +69,7 @@ export const post = async ( headers = { "content-type": "application/json", accept: "application/json", + ...ipHeaders(), ...headers, }; @@ -148,7 +160,13 @@ export const info = (m: string) => { }; export const login = async ( - user: { username: string; password: string }, + user: { + username: string; + password: string; + token?: string; + challenge?: string; + recaptcha?: string; + }, cookies, ip: string, ) => { @@ -193,6 +211,20 @@ export const auth = (cookies) => ({ authorization: `Bearer ${cookies.get("token")}`, }); +export const isInvalidTokenError = (e: unknown) => { + const message = (e as Error)?.message || ""; + if (!message) return false; + const normalized = message.toLowerCase(); + return ( + normalized.includes("jwt") || + normalized.includes("invalid token") || + normalized.includes("token expired") || + normalized.includes("expired token") || + normalized.includes("unauthorized") || + normalized.includes("not authorized") + ); +}; + export const btc = (fiat: number, rate: number): number => Math.round((fiat * sats) / rate); diff --git a/src/locales/en.json b/src/locales/en.json index ae25ccc8..229c107c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -212,7 +212,7 @@ "resetSent": "A password reset link will be sent to your email if we have it on file.", "continue": "Continue", "reset": "Password reset", - "resetDesc": "We can send a reset link if you added and verified your email otherwise please reach out to", + "resetDesc": "Please send an email with your username and any other details you can recall to", "optional": "Optional", "submit": "Submit", "token": "2FA Token", diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index e28d8b56..8b694241 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -1,4 +1,4 @@ -import { auth, get, sleep } from "$lib/utils"; +import { auth, get, isInvalidTokenError, sleep } from "$lib/utils"; import { error, redirect } from "@sveltejs/kit"; export async function load({ cookies, request, url, params }) { @@ -12,8 +12,21 @@ export async function load({ cookies, request, url, params }) { user = await get("/me", auth(cookies)); } catch (e) { const { message } = e as Error; - if (message.startsWith("Rate")) await sleep(3000); - user = await get("/me", auth(cookies)); + if (message.startsWith("Rate")) { + await sleep(3000); + try { + user = await get("/me", auth(cookies)); + } catch (retryError) { + if (isInvalidTokenError(retryError) && pathname !== "/logout") { + redirect(307, "/logout"); + } + throw retryError; + } + } else if (isInvalidTokenError(e) && pathname !== "/logout") { + redirect(307, "/logout"); + } else { + throw e; + } } } diff --git a/src/routes/(app)/admin/+layout.server.ts b/src/routes/(app)/admin/+layout.server.ts index d4aa181d..a2da1a90 100644 --- a/src/routes/(app)/admin/+layout.server.ts +++ b/src/routes/(app)/admin/+layout.server.ts @@ -1,10 +1,16 @@ -import { auth, get } from "$lib/utils"; +import { auth, get, isInvalidTokenError } from "$lib/utils"; import { redirect } from "@sveltejs/kit"; export async function load({ cookies }) { const token = cookies.get("token"); if (!token) redirect(307, "/login"); - const user = await get("/me", auth(cookies)); + let user; + try { + user = await get("/me", auth(cookies)); + } catch (e) { + if (isInvalidTokenError(e)) redirect(307, "/logout"); + throw e; + } if (!user) redirect(307, "/login"); return { user }; diff --git a/src/routes/(app)/pos/+page.server.ts b/src/routes/(app)/pos/+page.server.ts index f7a3fda3..1df1bb33 100644 --- a/src/routes/(app)/pos/+page.server.ts +++ b/src/routes/(app)/pos/+page.server.ts @@ -1,11 +1,20 @@ import { PUBLIC_COINOS_URL } from "$env/static/public"; -import { fd } from "$lib/utils"; +import { auth, fd, get } from "$lib/utils"; import { hex } from "@scure/base"; +export async function load({ cookies }) { + let token; + + try { + token = await get("/ro", auth(cookies)); + } catch (e) {} + + return { token }; +} + export const actions = { default: async ({ fetch, request }) => { const body = await fd(request); - console.log("POSTING", body); const res = await fetch(`${PUBLIC_COINOS_URL}/flash`, { method: "POST", body: JSON.stringify(body), @@ -16,6 +25,6 @@ export const actions = { }); const buf = new Uint8Array(await res.arrayBuffer()); - return { data: hex.encode(buf) }; + return { bytes: hex.encode(buf) }; }, }; diff --git a/src/routes/(app)/pos/+page.svelte b/src/routes/(app)/pos/+page.svelte index 760a89b4..6f3c5d24 100644 --- a/src/routes/(app)/pos/+page.svelte +++ b/src/routes/(app)/pos/+page.svelte @@ -3,103 +3,220 @@ import { ESPLoader, Transport } from "esptool-js"; import { hex } from "@scure/base"; + // Convert Uint8Array → JS "binary string" in safe chunks function bs(uint8Array) { let binaryString = ""; - const chunkSize = 0x8000; // 32,768 bytes (safe limit) - + const chunkSize = 0x8000; for (let i = 0; i < uint8Array.length; i += chunkSize) { const chunk = uint8Array.subarray(i, i + chunkSize); binaryString += String.fromCharCode(...chunk); } - return binaryString; } - let { form } = $props(); + let { data, form } = $props(); + let { token } = $state(data); - const address = 0x3d0000; - const baudrate = 921600; + // --- Defaults --- + const LITTLEFS_ADDRESS_DEFAULT = 0x3d0000; // your existing LittleFS offset + const FW_ADDRESS_DEFAULT = 0x10000; // typical app offset on ESP32-C3 + const CONNECT_BAUD = 115200; // initial + const WORK_BAUD = 921600; // fast flashing - let file = $state(); - let ssid = $state(); - let key = $state(); - let token = $state(); + // --- Shared state --- let esploader = $state(); - let data = $derived(form ? hex.decode(form.data) : undefined); - let chip, transport; - // let data = $state(); + let connected = $state(false); + let portInfo = $state(""); + + // --- Existing config path (generated server-side to hex in form.bytes) --- + let bytes = $derived(form ? hex.decode(form.bytes) : undefined); + let littlefsAddress = $state(LITTLEFS_ADDRESS_DEFAULT); + let configProgress = $state(0); + let configDone = $state(false); + + // --- New firmware flashing path --- + let fwFile = $state(null); + let fwBytes = $state(); // Uint8Array + let fwAddress = $state(FW_ADDRESS_DEFAULT); + let fwEraseAll = $state(false); + let fwProgress = $state(0); + let fwDone = $state(false); + let fwError = $state(""); + + // Simple tab toggle + let tab = $state("config"); // "config" | "firmware" + let connect = async () => { try { - let device = await navigator.serial.requestPort({}); + const device = await navigator.serial.requestPort({}); transport = new Transport(device, true); - esploader = new ESPLoader({ transport, - baudrate, + baudrate: CONNECT_BAUD, }); chip = await esploader.main(); + + connected = true; + portInfo = (await device.getInfo?.()) ? JSON.stringify(await device.getInfo()) : "Connected"; } catch (e) { console.log(e); + connected = false; } }; - let progress = $state(); - let flash = async () => { - let fileArray = [{ data: bs(data), address }]; + let flashConfig = async () => { + if (!esploader || !bytes) return; + configDone = false; + configProgress = 0; - const flashOptions = { + const fileArray = [{ data: bs(bytes), address: Number(littlefsAddress) }]; + + await esploader.writeFlash({ fileArray, flashSize: "keep", eraseAll: false, compress: true, - reportProgress: (fileIndex, written, total) => { - progress = parseInt((written / total) * 100); + reportProgress: (_fileIndex, written, total) => { + configProgress = Math.floor((written / total) * 100); }, + }); + + configDone = true; + }; + + function onPickFw(e) { + fwError = ""; + fwDone = false; + const f = e.target.files?.[0]; + fwFile = f || null; + if (!fwFile) { + fwBytes = undefined; + return; + } + const reader = new FileReader(); + reader.onload = () => { + const buf = new Uint8Array(reader.result); + fwBytes = buf; + }; + reader.onerror = () => { + fwError = "Failed to read file."; + fwBytes = undefined; }; + reader.readAsArrayBuffer(fwFile); + } + + let flashFirmware = async () => { + if (!esploader) { + fwError = "Not connected."; + return; + } + if (!fwBytes?.length) { + fwError = "No firmware selected."; + return; + } + fwError = ""; + fwDone = false; + fwProgress = 0; - await esploader.writeFlash(flashOptions); + try { + const fileArray = [{ data: bs(fwBytes), address: Number(fwAddress) }]; + + await esploader.writeFlash({ + fileArray, + flashSize: "keep", + eraseAll: Boolean(fwEraseAll), + compress: true, + reportProgress: (_fileIndex, written, total) => { + fwProgress = Math.floor((written / total) * 100); + }, + }); + + fwDone = true; + } catch (e) { + console.error(e); + fwError = e?.message || "Flash failed."; + } }; </script> <div class="container px-4 max-w-lg mx-auto space-y-5 mt-20"> <h1 class="px-3 md:px-0 text-center text-3xl md:text-4xl font-semibold"> - Flash config + ESP32-C3 Flasher </h1> - {#if esploader} - {#if data} - {#if progress} - <div class="text-center text-2xl">Done!</div> + {#if !connected} + <div class="text-center"> + <button class="btn" onclick={connect}>Connect</button> + <div class="text-sm mt-2 opacity-70">{portInfo}</div> + </div> +{/if} + + {#if connected} + <!-- Tabs --> + <div class="flex gap-2 justify-center"> + <button class="btn" class:btn-neutral={tab === "config"} onclick={() => (tab = "config")}>Flash config</button> + <button class="btn" class:btn-neutral={tab === "firmware"} onclick={() => (tab = "firmware")}>Flash firmware</button> + </div> + + {#if tab === "config"} + <!-- Existing CONFIG FLOW --> + {#if bytes} + {#if configDone} + <div class="text-center text-2xl">Config written ✔</div> + {:else} + <div>Generated config file (<b>{bytes.length} bytes</b>)</div> + <label class="label mt-2">LittleFS address (hex)</label> + <input class="input" bind:value={littlefsAddress} onchange={(e) => (littlefsAddress = Number(e.target.value))} /> + <div class="mt-3"> + <button class="btn" onclick={flashConfig}>Flash config</button> + </div> + {#if configProgress > 0}<div class="mt-2">Progress: {configProgress}%</div>{/if} + {/if} {:else} - <div>Generated config file (<b>{data.length * 2} bytes</b>)</div> - <button class="btn" onclick={flash}>Flash</button> + <form method="POST" use:enhance class="space-y-2"> + <input name="ssid" placeholder="Wifi SSID" class="input" /> + <input name="key" placeholder="Wifi password" class="input" /> + <input name="token" bind:value={token} placeholder="Coinos API token" class="input" /> + <button type="submit" class="btn">Generate Config</button> + </form> {/if} {:else} - <form method="POST" use:enhance class="space-y-2"> - <input - name="ssid" - bind:value={ssid} - placeholder="Wifi SSID" - class="input" - /> - <input - name="key" - bind:value={key} - placeholder="Wifi password" - class="input" - /> - <input - name="token" - bind:value={token} - placeholder="Coinos API token" - class="input" - /> - <button type="submit" class="btn">Generate Config</button> - </form> + <!-- NEW FIRMWARE FLOW --> + <div class="space-y-3"> + <label class="label">Select .ino.bin (firmware)</label> + <input type="file" accept=".bin,application/octet-stream" class="input" onchange={onPickFw} /> + + <label class="label">Firmware address (hex, default 0x10000)</label> + <input class="input" bind:value={fwAddress} onchange={(e) => (fwAddress = Number(e.target.value))} /> + + <label class="flex items-center gap-2 mt-2"> + <input type="checkbox" bind:checked={fwEraseAll} /> + <span>Erase all flash (usually NOT needed)</span> + </label> + + <div class="mt-3"> + <button class="btn" disabled={!fwBytes} onclick={flashFirmware}>Flash firmware</button> + </div> + + {#if fwProgress > 0 && !fwDone} + <div>Progress: {fwProgress}%</div> + {/if} + {#if fwDone} + <div class="text-center text-2xl">Firmware written ✔</div> + {/if} + {#if fwError} + <div class="text-red-600">{fwError}</div> + {/if} + </div> {/if} - {:else} - <button class="btn" onclick={connect}>Connect</button> {/if} </div> + +<style> + .btn { @apply px-4 py-2 rounded-2xl shadow; } + .btn-neutral { @apply bg-gray-200; } + .input { @apply w-full px-3 py-2 border rounded-xl; } + .label { @apply text-sm opacity-70; } +</style> diff --git a/src/routes/(app)/receive/[...token]/+page.server.ts b/src/routes/(app)/receive/[...token]/+page.server.ts index 05b2dad8..052d507f 100644 --- a/src/routes/(app)/receive/[...token]/+page.server.ts +++ b/src/routes/(app)/receive/[...token]/+page.server.ts @@ -1,17 +1,17 @@ +import parse from "$lib/parse"; import { fd } from "$lib/utils"; import { fail, redirect } from "@sveltejs/kit"; -import parse from "$lib/parse"; -export async function load({ params, parent, url }) { +export async function load({ cookies, params, parent, url }) { const { user } = await parent(); if (!user) redirect(307, `/register?redirect=${url.pathname}`); - await parse(params.token, url.host); + await parse(params.token, url.host, cookies); } export const actions = { - default: async ({ request, url }) => { + default: async ({ cookies, request, url }) => { const { text } = await fd(request); - await parse(text, url.host); + await parse(text, url.host, cookies); return fail(400, { error: "default" }); }, }; diff --git a/src/routes/(app)/reset/[code]/+page.server.ts b/src/routes/(app)/reset/[code]/+page.server.ts index 533e21f5..35d49be9 100644 --- a/src/routes/(app)/reset/[code]/+page.server.ts +++ b/src/routes/(app)/reset/[code]/+page.server.ts @@ -7,11 +7,12 @@ export const actions = { let error; const { code } = params; - const { password } = await fd(request); + const { password, recaptcha } = await fd(request); try { const user = await post("/reset", { code, password }); user.password = password; + user.recaptcha = recaptcha; try { await login(user, cookies, ip); diff --git a/src/routes/(app)/reset/[code]/+page.svelte b/src/routes/(app)/reset/[code]/+page.svelte index df9d1144..ce43a79d 100644 --- a/src/routes/(app)/reset/[code]/+page.svelte +++ b/src/routes/(app)/reset/[code]/+page.svelte @@ -1,14 +1,71 @@ <script> + import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public"; + import { browser } from "$app/environment"; + import { onDestroy, onMount, tick } from "svelte"; import { t } from "$lib/translations"; import PasswordInput from "$comp/PasswordInput.svelte"; import Icon from "$comp/Icon.svelte"; import Spinner from "$comp/Spinner.svelte"; import { fly } from "svelte/transition"; import { password } from "$lib/store"; + import { fail } from "$lib/utils"; let { form } = $props(); let email; let loading; + let recaptchaToken = ""; + let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY; + let isTor = browser && location.hostname.endsWith(".onion"); + + onMount(() => { + if (!isTor && recaptchaSiteKey) { + let s = document.createElement("script"); + s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey; + document.head.appendChild(s); + } + }); + + const getRecaptchaToken = () => + new Promise((resolve, reject) => { + if (isTor) return resolve(""); + if (!browser || !grecaptcha) return reject(new Error("captcha unavailable")); + grecaptcha.ready(() => { + grecaptcha + .execute(recaptchaSiteKey, { action: "reset" }) + .then(resolve) + .catch(reject); + }); + }); + + const handleSubmit = async (e) => { + e.preventDefault(); + loading = true; + try { + recaptchaToken = await getRecaptchaToken(); + await tick(); + e.currentTarget.submit(); + } catch (err) { + fail(err.message || "captcha failed"); + loading = false; + } + }; + + onDestroy(() => { + if (!browser) return; + const nodeBadge = document.querySelector(".grecaptcha-badge"); + if (nodeBadge) { + document.body.removeChild(nodeBadge.parentNode); + } + + const scriptSelector = + "script[src='https://www.google.com/recaptcha/api.js?render=" + + recaptchaSiteKey + + "']"; + const script = document.querySelector(scriptSelector); + if (script) { + script.remove(); + } + }); </script> <div class="pt-10"> @@ -30,7 +87,8 @@ </div> {/if} - <form class="space-y-5" method="POST"> + <form class="space-y-5" method="POST" onsubmit={handleSubmit}> + <input type="hidden" name="recaptcha" value={recaptchaToken} /> <PasswordInput bind:value={$password} placeholder={$t("login.password")} diff --git a/src/routes/(app)/send/[...text]/+page.server.ts b/src/routes/(app)/send/[...text]/+page.server.ts index 9f0891d9..59d53dbe 100644 --- a/src/routes/(app)/send/[...text]/+page.server.ts +++ b/src/routes/(app)/send/[...text]/+page.server.ts @@ -9,16 +9,16 @@ export async function load({ cookies, depends, params, parent, url }) { if (!user) redirect(307, "/login"); const { text } = params; - await parse(text, url.host); + await parse(text, url.host, cookies); const contacts = await get(`/contacts/${limit}`, auth(cookies)); return { contacts }; } export const actions = { - default: async ({ request, url }) => { + default: async ({ cookies, request, url }) => { const { text } = await fd(request); - await parse(text, url.host); + await parse(text, url.host, cookies); return fail(400, { error: "default" }); }, }; diff --git a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte index a5cc168c..2b7c008a 100644 --- a/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte +++ b/src/routes/(app)/send/lightning/[payreq]/[...memo]/+page.svelte @@ -28,9 +28,9 @@ let toggle = () => (show = !show); let amount = $derived(form?.amount || data.amount); let maxfee = $state( - Math.max(5, Math.round(untrack(() => amount) * 0.005 || 0)), + Math.max(5, Math.round(untrack(() => amount) * 0.02 || 0)), ); - $effect(() => (maxfee = Math.max(5, Math.round(amount * 0.005) || 0))); + $effect(() => (maxfee = Math.max(5, Math.round(amount * 0.02) || 0))); </script> <div class="container px-4 max-w-xl mx-auto text-center space-y-2"> diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte index 24c54a97..e603cbf4 100644 --- a/src/routes/(app)/settings/+layout.svelte +++ b/src/routes/(app)/settings/+layout.svelte @@ -176,8 +176,7 @@ event = await sign(event, user); send(event); } catch (e) { - console.log(e); - warning("Nostr profile could not be updated"); + warning("Nostr profile not updated"); } } @@ -252,9 +251,7 @@ {$t("user.settings.header")} </h1> - <div - class="flex flex-wrap justify-around items-center border-b pb-3" - > + <div class="flex flex-wrap justify-around items-center border-b pb-3"> {#each tabs as { name, key }} <a href={`/settings/${name}`} diff --git a/src/routes/(static)/+layout.server.ts b/src/routes/(static)/+layout.server.ts index c780b5e0..5de752fa 100644 --- a/src/routes/(static)/+layout.server.ts +++ b/src/routes/(static)/+layout.server.ts @@ -1,4 +1,5 @@ -import { get, auth } from "$lib/utils"; +import { auth, get, isInvalidTokenError } from "$lib/utils"; +import { redirect } from "@sveltejs/kit"; export async function load({ cookies }) { const token = cookies.get("token"); @@ -7,7 +8,11 @@ export async function load({ cookies }) { if (token) { try { user = await get("/me", auth(cookies)); - } catch (e) {} + } catch (e) { + if (isInvalidTokenError(e)) { + redirect(307, "/logout"); + } + } } return { user }; diff --git a/src/routes/(static)/docs/+page.server.ts b/src/routes/(static)/docs/+page.server.ts index 86f95824..93ba544c 100644 --- a/src/routes/(static)/docs/+page.server.ts +++ b/src/routes/(static)/docs/+page.server.ts @@ -1,4 +1,7 @@ +import { auth, get } from "$lib/utils"; export const load = async ({ cookies }) => { const token = cookies.get("token"); - return { token }; + let ro; + if (token) ro = await get("/ro", auth(cookies)); + return { ro, token }; }; diff --git a/src/routes/(static)/docs/+page.svelte b/src/routes/(static)/docs/+page.svelte index f53787e5..c323f9bd 100644 --- a/src/routes/(static)/docs/+page.svelte +++ b/src/routes/(static)/docs/+page.svelte @@ -7,7 +7,7 @@ let { data } = $props(); - let { user, token } = data; + let { user, ro, token } = data; let api = PUBLIC_DOMAIN.includes("localhost") ? `${PUBLIC_COINOS_URL}` @@ -16,6 +16,8 @@ let tokenSample = token ? `export token="${token}"` : `export token=<your auth token>`; + + let roSample = `export token="${ro}"`; </script> {#snippet cp(something)} @@ -59,7 +61,7 @@ or get one from the /login endpoint. {/if} - Save it in a variable called <b>$token</b> to run the examples. + Save it in a variable called <b>$token</b> to run the examples. It gives full access to your account so keep it safe. </p> <div @@ -69,6 +71,19 @@ {@render cp(tokenSample)} </div> + {#if ro} + <p class="text-secondary"> + This read-only token has permission to create invoices and fetch + payments but can't be used to make withdrawals + </p> + <div + class="bg-primary text-primary-content rounded-lg p-4 flex gap-4 items-center" + > + <div class="w-full break-all">{roSample}</div> + {@render cp(roSample)} + </div> + {/if} + <h2 class="text-2xl">POST /register</h2> <p class="text-secondary"> Register a new user account with a username and password @@ -92,9 +107,13 @@ <div class="font-bold">display</div> <div class="col-span-2">string - spaces allowed</div> <div class="font-bold">currency</div> - <div class="col-span-2">string - three-letter ISO currency code e.g. CAD or USD</div> + <div class="col-span-2"> + string - three-letter ISO currency code e.g. CAD or USD + </div> <div class="font-bold">language</div> - <div class="col-span-2">string - two-letter language code e.g. en, fr, es</div> + <div class="col-span-2"> + string - two-letter language code e.g. en, fr, es + </div> </div> <Code sample="update" /> @@ -108,7 +127,9 @@ <div class="font-bold">type (string, default: lightning)</div> <div class="col-span-2">bitcoin or lightning</div> <div class="font-bold">fiat (boolean, default: false)</div> - <div class="col-span-2">whether the amount is denominated in satoshis or local currency</div> + <div class="col-span-2"> + whether the amount is denominated in satoshis or local currency + </div> <div class="font-bold">amount (numeric)</div> <div class="col-span-2">amount to be paid</div> <div class="font-bold">webhook (string, optional)</div> @@ -116,7 +137,9 @@ remote endpoint to call when the invoice is paid </div> <div class="font-bold">secret (string, optional)</div> - <div class="col-span-2">a secret value passed to the webhook to authenticate the request</div> + <div class="col-span-2"> + a secret value passed to the webhook to authenticate the request + </div> </div> </div> diff --git a/src/routes/(static)/forgot/+page.svelte b/src/routes/(static)/forgot/+page.svelte index 45180543..49a837ae 100644 --- a/src/routes/(static)/forgot/+page.svelte +++ b/src/routes/(static)/forgot/+page.svelte @@ -6,8 +6,6 @@ import Spinner from "$comp/Spinner.svelte"; import { fly } from "svelte/transition"; - let { form } = $props(); - let email = $state(); let loading; </script> @@ -16,51 +14,8 @@ > <h1 class="text-2xl font-bold text-center">{$t("login.reset")}</h1> - {#if form?.error} - <div class="text-red-600 text-center" in:fly> - {form.error} - </div> - {/if} - - {#if form?.success} - <div class="text-secondary"> - {$t("login.resetSent")} - </div> - - <div class="flex w-full"> - <a href="/login" class="btn"> - {$t("login.continue")} - </a> - </div> - {:else} - <form class="space-y-5" method="POST" use:enhance> - <div> - <input - placeholder={$t("login.email")} - name="email" - type="text" - required - class="bg-primary" - bind:value={email} - use:focus - autocapitalize="none" - /> - </div> - - <div class="text-secondary"> - {$t("login.resetDesc")} - <a href="mailto:[email protected]" class="font-bold" - >[email protected]</a - > - </div> - - <button type="submit" class="btn" disabled={loading}> - {#if loading} - <Spinner /> - {:else} - {$t("login.submit")} - {/if} - </button> - </form> - {/if} + <div class="text-secondary"> + {$t("login.resetDesc")} + <a href="mailto:[email protected]" class="font-bold">[email protected]</a> + </div> </div> diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts index ffe6335d..2469cb83 100644 --- a/src/routes/(static)/login/+page.server.ts +++ b/src/routes/(static)/login/+page.server.ts @@ -1,5 +1,5 @@ import { PUBLIC_COINOS_URL } from "$env/static/public"; -import { fd, get, login } from "$lib/utils"; +import { fd, get, login, post } from "$lib/utils"; import { fail, redirect } from "@sveltejs/kit"; export const load = async ({ parent }) => { @@ -11,10 +11,15 @@ export const load = async ({ parent }) => { export const actions = { login: async ({ cookies, request }) => { - console.log(request.headers.get('referer')); const form = await fd(request); - let { username, password, token, loginRedirect } = form; - const user = { username, password, token }; + let { username, password, token, loginRedirect, recaptcha } = form; + + const user = { + username, + password, + token, + recaptcha, + }; if (loginRedirect === "undefined") loginRedirect = undefined; @@ -30,7 +35,7 @@ export const actions = { nostr: async ({ cookies, fetch, request }) => { const form = await fd(request); - let { challenge, event, loginRedirect } = form; + let { challenge, event, loginRedirect, recaptcha } = form; if (loginRedirect === "null") loginRedirect = undefined; event = JSON.parse(event); @@ -38,7 +43,7 @@ export const actions = { const res = await fetch(`${PUBLIC_COINOS_URL}/nostrAuth`, { method: "POST", - body: JSON.stringify({ challenge, event }), + body: JSON.stringify({ challenge, event, recaptcha }), headers: { "content-type": "application/json", accept: "application/json", diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 825f6a88..04044060 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -1,8 +1,9 @@ <script> + import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public"; import { browser } from "$app/environment"; import handler from "$lib/handler"; import { applyAction, deserialize } from "$app/forms"; - import { onMount, tick } from "svelte"; + import { onDestroy, onMount, tick } from "svelte"; import { fly } from "svelte/transition"; import { enhance } from "$app/forms"; import Pinpad from "$comp/Pinpad.svelte"; @@ -37,8 +38,18 @@ }); let { challenge } = $derived(data); + let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY; + let isTor = browser && location.hostname.endsWith(".onion"); let token = $state(""); + onMount(() => { + if (!isTor && recaptchaSiteKey) { + let s = document.createElement("script"); + s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey; + document.head.appendChild(s); + } + }); + let cancel = () => (need2fa = false); let username = $state(), @@ -53,28 +64,36 @@ let revealPassword = $state(false); - async function handleSubmit(e) { - e.preventDefault(); - let data = new FormData(this); - let user = Object.fromEntries(data); - - for (let k in user) { - data.set(k, user[k]); - } - - const response = await fetch("?/login", { - method: "POST", - body: data, + const getRecaptchaToken = () => + new Promise((resolve, reject) => { + if (isTor) return resolve(""); + if (!browser || !grecaptcha) return reject(new Error("captcha unavailable")); + grecaptcha.ready(() => { + grecaptcha + .execute(recaptchaSiteKey, { action: "login" }) + .then(resolve) + .catch(reject); + }); }); - const result = deserialize(await response.text()); - - if (result.type === "success") { - await invalidateAll(); + const enhanceLogin = async ({ formData, cancel }) => { + try { + const recaptcha = await getRecaptchaToken(); + formData.set("recaptcha", recaptcha); + } catch (err) { + fail(err.message || "captcha failed"); + cancel(); + return; } - applyAction(result); - } + return async ({ result }) => { + if (result.type === "success") { + await invalidateAll(); + } + + applyAction(result); + }; + }; $effect(() => { token?.length === 6 && @@ -104,10 +123,12 @@ const formData = new FormData(); try { + const recaptcha = await getRecaptchaToken(); formData.append("loginRedirect", redirect); formData.append("token", token); formData.append("event", JSON.stringify(signedEvent)); formData.append("challenge", challenge); + formData.append("recaptcha", recaptcha); let response = await fetch("/login?/nostr", { method: "POST", @@ -121,6 +142,23 @@ fail(e.message); } }; + + onDestroy(() => { + if (!browser) return; + const nodeBadge = document.querySelector(".grecaptcha-badge"); + if (nodeBadge) { + document.body.removeChild(nodeBadge.parentNode); + } + + const scriptSelector = + "script[src='https://www.google.com/recaptcha/api.js?render=" + + recaptchaSiteKey + + "']"; + const script = document.querySelector(scriptSelector); + if (script) { + script.remove(); + } + }); </script> <div @@ -134,9 +172,8 @@ {/if} <form - use:enhance + use:enhance={enhanceLogin} class="space-y-5" - onsubmit={handleSubmit} method="POST" action="?/login" > diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts index cddd97ea..d2dbe299 100644 --- a/src/routes/(static)/register/+page.server.ts +++ b/src/routes/(static)/register/+page.server.ts @@ -15,11 +15,11 @@ export const actions = { default: async ({ cookies, request }) => { const ip = request.headers.get("cf-connecting-ip"); const form = await fd(request); - const { picture, username, password } = form; + const { picture, username, password, challenge, recaptcha } = form; let { loginRedirect } = form; if (loginRedirect === "undefined") loginRedirect = undefined; - const user = { picture, username, password }; + const user = { picture, username, password, challenge, recaptcha }; return register(user, ip, cookies, loginRedirect); }, }; diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index b0368d47..bb3af128 100644 --- a/src/routes/(static)/register/+page.svelte +++ b/src/routes/(static)/register/+page.svelte @@ -1,5 +1,6 @@ <script> - import { onMount } from "svelte"; + import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public"; + import { onDestroy, onMount } from "svelte"; import { browser } from "$app/environment"; import punks from "$lib/punks"; import { upload } from "$lib/upload"; @@ -28,6 +29,16 @@ let { form, data } = $props(); let { challenge } = $derived(data); + let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY; + let isTor = browser && location.hostname.endsWith(".onion"); + + onMount(() => { + if (!isTor && recaptchaSiteKey) { + let s = document.createElement("script"); + s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey; + document.head.appendChild(s); + } + }); onMount(() => { if (browser) { @@ -93,6 +104,18 @@ btn = $state(); let loading = $state(); + const getRecaptchaToken = () => + new Promise((resolve, reject) => { + if (isTor) return resolve(""); + if (!browser || !grecaptcha) return reject(new Error("captcha unavailable")); + grecaptcha.ready(() => { + grecaptcha + .execute(recaptchaSiteKey, { action: "register" }) + .then(resolve) + .catch(reject); + }); + }); + async function handleSubmit(e) { e.preventDefault(); @@ -106,6 +129,15 @@ data.set(k, user[k]); } + try { + const recaptcha = await getRecaptchaToken(); + data.set("recaptcha", recaptcha); + } catch (err) { + fail(err.message || "captcha failed"); + loading = false; + return; + } + data.set("picture", `${$page.url.origin}/api/public/${punks[index]}.webp`); if ($avatar) { try { @@ -189,10 +221,12 @@ const formData = new FormData(); try { + const recaptcha = await getRecaptchaToken(); formData.append("loginRedirect", redirect); formData.append("token", token); formData.append("event", JSON.stringify(signedEvent)); formData.append("challenge", challenge); + formData.append("recaptcha", recaptcha); let response = await fetch("/login?/nostr", { method: "POST", @@ -210,6 +244,23 @@ fail(e.message); } }; + + onDestroy(() => { + if (!browser) return; + const nodeBadge = document.querySelector(".grecaptcha-badge"); + if (nodeBadge) { + document.body.removeChild(nodeBadge.parentNode); + } + + const scriptSelector = + "script[src='https://www.google.com/recaptcha/api.js?render=" + + recaptchaSiteKey + + "']"; + const script = document.querySelector(scriptSelector); + if (script) { + script.remove(); + } + }); </script> {#if need2fa} @@ -277,6 +328,7 @@ value={$loginRedirect || $page.url.searchParams.get("redirect")} /> <input type="hidden" name="token" value={token} /> + <input type="hidden" name="challenge" value={challenge} /> <label for="username" diff --git a/src/routes/(static)/support/+page.svelte b/src/routes/(static)/support/+page.svelte index b9028cbf..4cf9706d 100644 --- a/src/routes/(static)/support/+page.svelte +++ b/src/routes/(static)/support/+page.svelte @@ -1,6 +1,7 @@ <script> + import { PUBLIC_RECAPTCHA_SITE_KEY } from "$env/static/public"; import { browser } from "$app/environment"; - import { onDestroy } from "svelte"; + import { onDestroy, onMount } from "svelte"; import { fail, success, post } from "$lib/utils"; import { scale } from "svelte/transition"; import Icon from "$comp/Icon.svelte"; @@ -13,10 +14,30 @@ let email = $state(); let message = $state(); let sent = $state(); - let recaptchaSiteKey = "6LfCd8YkAAAAANmVJgzN3SQY3n3fv1RhiS5PgMYM"; + let recaptchaSiteKey = PUBLIC_RECAPTCHA_SITE_KEY; + let isTor = browser && location.hostname.endsWith(".onion"); + + onMount(() => { + if (!isTor && recaptchaSiteKey) { + let s = document.createElement("script"); + s.src = "https://www.google.com/recaptcha/api.js?render=" + recaptchaSiteKey; + document.head.appendChild(s); + } + }); let submit = (e) => { e.preventDefault(); + if (isTor) { + post("/post/email", { + username: user?.username || `${username} (unverified)`, + email, + message, + token: "", + }) + .then(() => (sent = true)) + .catch(() => fail("problem submitting")); + return; + } grecaptcha.ready(() => { grecaptcha.execute(recaptchaSiteKey, { action: "submit" }).then((token) => post("/post/email", { @@ -49,12 +70,6 @@ }); </script> -<svelte:head - ><script - src="https://www.google.com/recaptcha/api.js?render=6LfCd8YkAAAAANmVJgzN3SQY3n3fv1RhiS5PgMYM" - ></script></svelte:head -> - <div class="container max-w-lg mx-auto space-y-8 p-4 pb-20"> {#if sent} <h1 class="text-center text-3xl lg:text-4xl font-semibold">Thank you!</h1> diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 584e836c..c1f77695 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -3,7 +3,8 @@ import { loadTranslations, locales, } from "$lib/translations/index"; -import { auth, get, sleep } from "$lib/utils"; +import { auth, get, isInvalidTokenError, sleep } from "$lib/utils"; +import { redirect } from "@sveltejs/kit"; export const load = async ({ cookies, request, url }) => { const { host, pathname } = url; @@ -13,8 +14,22 @@ export const load = async ({ cookies, request, url }) => { try { user = await get("/me", auth(cookies)); } catch (e) { - if (e.message.startsWith("Rate")) await sleep(3000); - user = await get("/me", auth(cookies)); + const { message } = e as Error; + if (message.startsWith("Rate")) { + await sleep(3000); + try { + user = await get("/me", auth(cookies)); + } catch (retryError) { + if (isInvalidTokenError(retryError) && pathname !== "/logout") { + redirect(307, "/logout"); + } + throw retryError; + } + } else if (isInvalidTokenError(e) && pathname !== "/logout") { + redirect(307, "/logout"); + } else { + throw e; + } } } |
