diff options
Diffstat (limited to 'src/routes/(static)')
| -rw-r--r-- | src/routes/(static)/+layout.server.ts | 9 | ||||
| -rw-r--r-- | src/routes/(static)/bounties/+page.server.ts | 44 | ||||
| -rw-r--r-- | src/routes/(static)/bounties/+page.svelte | 231 | ||||
| -rw-r--r-- | src/routes/(static)/docs/+page.server.ts | 5 | ||||
| -rw-r--r-- | src/routes/(static)/docs/+page.svelte | 64 | ||||
| -rw-r--r-- | src/routes/(static)/forgot/+page.svelte | 53 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.server.ts | 26 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.svelte | 85 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.server.ts | 40 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.svelte | 61 | ||||
| -rw-r--r-- | src/routes/(static)/support/+page.svelte | 31 |
11 files changed, 430 insertions, 219 deletions
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)/bounties/+page.server.ts b/src/routes/(static)/bounties/+page.server.ts new file mode 100644 index 00000000..8378e2f2 --- /dev/null +++ b/src/routes/(static)/bounties/+page.server.ts @@ -0,0 +1,44 @@ +import { get } from "$lib/utils"; +export const load = async () => { + const users = [ + { + username: "fustler", + merchant: "rrdrive", + type: "Bakery", + amount: 1000000, + }, + { + username: "laluna", + merchant: "butchersguild", + type: "Butcher Shop", + amount: 1000000, + }, + { + username: "ica547", + merchant: "vanlove", + type: "Sushi Restaurant", + amount: 1000000, + }, + { + username: "reverendhodl", + merchant: "laughingbean", + type: "Cafe", + amount: 1000000, + }, + { + username: "kimsrepair", + merchant: "kimsrepair", + type: "Kims Repair", + amount: 100000, + }, + ]; + + for (const user of users) { + const { username, merchant } = user; + console.log(username, merchant); + user.user = await get(`/users/${username}`); + user.merchant = await get(`/users/${merchant}`); + } + + return { users }; +}; diff --git a/src/routes/(static)/bounties/+page.svelte b/src/routes/(static)/bounties/+page.svelte index b53dc85d..695eae3e 100644 --- a/src/routes/(static)/bounties/+page.svelte +++ b/src/routes/(static)/bounties/+page.svelte @@ -1,96 +1,172 @@ -<div class="space-y-5"> - <h1 class="text-3xl mb-6 pt-28 md:pt-12">Bounties</h1> +<script> + import Avatar from "$comp/Avatar.svelte"; + import { s } from "$lib/utils"; + import Amount from "$comp/Amount.svelte"; - <p> - We're offering ⚡️1,000,000 to the first 10 people that help us onboard - these businesses in Vancouver to take bitcoin payments. - </p> + let { data } = $props(); + let { users } = data; - <ul class="text-center text-lg space-y-3"> - <li>Grocery / Produce Store</li> - <li class="leading-3"> - <span class="line-through">Cafe</span><br /> - <span class="text-xs" - ><a href="/reverendhodl">ReverendHodl</a> for - <a href="/laughingbean">Laughing Bean Coffee Co.</a></span - > - </li> - <li>Bakery</li> - <li class="leading-3"> - <span class="line-through">Butcher Shop</span><br /> - <span class="text-xs" - ><a href="/danielle">danielle</a> for - <a href="/butchersguild">All Natural & Exotic Meats</a></span - > - </li> - <li>Laundromat / Dry Cleaner</li> - <li>Dollar Store / Book Store / Gift Shop</li> - <li class="leading-3"> - <span class="line-through">Sushi Restaurant</span><br /> - <span class="text-xs" - ><a href="/ica547">ica547</a> for - <a href="/metallmash">VanLove Sushi</a></span + let bounties = [ + "Hardware Store", + "Gas Station", + "Dollar Store", + "Book Store", + "Gift Shop", + "Game or Toy Store", + "Pet Store", + "Pharmacy", + ]; + + let small = [ + { + type: "Restaurant", + amount: 50000, + }, + { type: "Cafe", amount: 30000 }, + { type: "Tradesperson or Professional", amount: 25000 }, + { type: "Food Truck", amount: 20000 }, + { type: "Farmer's Market Vendor", amount: 20000 }, + ]; +</script> + +<div class="space-y-5 pb-20"> + <h1 class="text-3xl mb-6 md:pt-12 font-bold">Coinos Bounties</h1> + + <p>Earn sats by referring us to local businesses in Vancouver!</p> + + <h2 class="text-2xl font-bold">Currently Available</h2> + + <div class="space-y-0"> + {#each bounties as b} + <div class="flex max-w-xs sm:max-w-xl"> + <div>First {b}</div> + + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300 ml-auto" + ></iconify-icon>{s(100000, "en-CA")} + </div> + {/each} + </div> + + <hr /> + + <div class="space-y-0"> + {#each small as b} + <div class="flex max-w-xs sm:max-w-xl"> + <div>{b.type}</div> + + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300 ml-auto" + ></iconify-icon>{s(b.amount, "en-CA")} + </div> + {/each} + </div> + + <h2 class="text-2xl font-bold">Previously Awarded</h2> + + <div class="flex flex-col gap-4"> + {#each users as { user, merchant, type, amount }} + <article + class="rounded-2xl border border-neutral-200 bg-white/70 p-4 shadow-sm transition hover:shadow-md dark:border-neutral-800 dark:bg-neutral-900/60 sm:max-w-xl" > - </li> - <li>Pizza by the Slice</li> - <li>Liquor Store / Gas Station</li> - <li>Bubble Tea / Ice Cream Parlour</li> - </ul> + <div class="flex items-start gap-3 min-w-0 flex-wrap sm:flex-nowrap"> + <div> + <div class="flex"> + <div class="shrink-0"> + <Avatar user={merchant} size={12} /> + </div> - <p>Eligible businesses:</p> + <div class="flex items-center gap-2 min-w-0"> + <h3 + class="whitespace-wrap text-base font-semibold max-w-[150px] truncate sm:max-w-full" + title={merchant.display || merchant.username} + > + <a href={`/${merchant.username}`} + >{merchant.display || merchant.username}</a + > + </h3> - <ul class="text-sm ml-4 space-y-2"> - <li> - Have a physical location in Vancouver, North Vancouver, West Vancouver, - Burnaby, New Westminster, or Richmond - </li> - <li>Take walk-in customers</li> - <li>Have regular hours and a website with contact info</li> - <li>Have a 3 star or better rating on Google</li> - <li> - Accept bitcoin during their posted hours without needing to make any - special arrangements - </li> - <li>Use Coinos</li> - </ul> + <span + class="shrink-0 rounded-full border border-neutral-200 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:border-neutral-700 dark:text-neutral-300" + > + {type} + </span> + </div> + </div> + + <div + class="mt-2 flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300 min-w-0" + > + <Avatar {user} size={8} class="shrink-0" /> + <span class="truncate max-w-full" + >Referred by @{user.username}</span + > + </div> + </div> + + <div class="ml-auto flex shrink-0 items-center gap-1 whitespace"> + <iconify-icon + noobserver + icon="ph:lightning-fill" + class="text-yellow-300" + /> + <span class="text-sm font-semibold tabular-nums"> + {s(amount, "en-CA")} + </span> + </div> + </div> + </article> + {/each} + </div> + + <h2 class="text-2xl font-bold">Terms and Conditions</h2> - <p>Each business category can be claimed only once</p> <p>One bounty per person</p> - <p> - The businesses can expect free ongoing support and promotion from us and our - community. - </p> + <div>Eligible businesses should:</div> + + <ul class="ml-4 space-y-0 list-disc"> + <li>Have a physical location in Greater Vancouver</li> + <li>Take walk-ins without appointments</li> + <li>Have a website with contact info</li> + <li>Have at least a 3-star rating on Google</li> + </ul> - <p>The prize money will be offered to the person who makes the referral.</p> + <div>They will be expected to:</div> + + <ul class="ml-4 space-y-0 list-disc"> + <li>Display a window sticker and QR</li> + <li>Train staff on Bitcoin procedures</li> + <li>Accept Bitcoin during posted business hours</li> + </ul> <p> - <b>⚡️250,000</b> will be awarded once the business has met with us and is ready - to take payments + 25% of the bounty will be awarded once the business is ready to take + payments and has been announced </p> + <p> - <b>⚡️750,000</b> will be awarded once the merchant has accepted bitcoin for - 6 months. We'll be sending - <a - href="https://btcmap.org/leaderboard" - class="text-black hover:underline" - target="_blank" - rel="noreferrer">shadowy supertaggers</a - > to check up on them periodically. + The rest will be awarded once they've been accepting Bitcoin for 6 months </p> + <p>Bounties will be awarded at our discretion</p> + <p> - Bounties will be awarded solely at our discretion and we reserve the right - to refuse any applicants without cause + We reserve the right to refuse applicants and change these terms without + cause or notice </p> - <h2 class="text-xl">Ready to begin!?</h2> + <h2 class="text-2xl font-bold">Get Started!</h2> <p> - To make a referral, reach out on one of our socials or email us at <a + Reach out to <a href="mailto:[email protected]" - class="text-black hover:underline">[email protected]</a - > and we'll set up an appointment with you and the business owner to get started. + class="text-blue-600 hover:underline">[email protected]</a + > and let us know who we should contact </p> <p> @@ -98,11 +174,10 @@ which ones are still available. </p> + <p> + Have a lead or idea for something that's not on the list? Reach out! We + offer sats for all sorts of referrals! + </p> + <p>Thanks for participating and helping to orange pill Vancouver!</p> </div> - -<style> - a { - @apply text-blue-400; - } -</style> 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 7ac6e013..7cf7e2cb 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" /> @@ -105,16 +124,22 @@ <div class="text-secondary"> <div>Request params</div> <div class="grid grid-cols-3 ml-2"> - <div class="font-bold">type</div> + <div class="font-bold">type (string, default: lightning)</div> <div class="col-span-2">bitcoin or lightning</div> - <div class="font-bold">amount</div> - <div class="col-span-2">amount in satoshis</div> - <div class="font-bold">webhook</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="font-bold">amount (numeric)</div> + <div class="col-span-2">amount to be paid</div> + <div class="font-bold">webhook (string, optional)</div> <div class="col-span-2"> - (optional) endpoint to hit when the invoice is paid + 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="font-bold">secret</div> - <div class="col-span-2">a secret for the webhook to check</div> </div> </div> @@ -137,8 +162,23 @@ </p> <Code sample="fetchInvoice" /> + <h2 class="text-2xl">POST /lightning/quote</h2> + <p class="text-secondary"> + Find out what a lightning payment will cost before sending it. The routing + fee comes from querying the route ahead of time and is exact; pass it as + <b>fee</b> to POST /payments. Pass <b>amount</b> for an amountless invoice, + or <b>max</b> to solve for the largest amount whose amount + fees uses the + whole balance. + </p> + <Code sample="quote" /> + <p>Sample response</p> + <Code sample="quoteResponse" /> + <h2 class="text-2xl">POST /payments</h2> - <p class="text-secondary">Send a lightning payment</p> + <p class="text-secondary"> + Send a lightning payment. <b>fee</b> caps the routing fee; if omitted, the + route is quoted and the exact fee used. + </p> <Code sample="lightning" /> <p class="text-secondary">Send an internal payment to another user</p> 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 a24abd07..ce343790 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 }) => { @@ -12,13 +12,20 @@ export const load = async ({ parent }) => { export const actions = { login: async ({ cookies, request }) => { const form = await fd(request); - let { username, password, token, loginRedirect } = form; - const user = { username, password, token }; + let { username, password, token, loginRedirect, recaptcha } = form; + username = username.split("@")[0]; + + const user = { + username, + password, + token, + recaptcha, + }; if (loginRedirect === "undefined") loginRedirect = undefined; try { - await login(user, cookies, request.headers.get("cf-connecting-ip")); + await login(user, cookies, request.headers.get("cf-connecting-ip"), request.headers.get("host")); } catch (e) { const { message } = e as Error; return fail(400, { error: "Login failed", message, ...form }); @@ -29,7 +36,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); @@ -37,20 +44,23 @@ 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", "cf-connecting-ip": request.headers.get("cf-connecting-ip"), + "x-forwarded-host": request.headers.get("host"), }, }); + const text = await res.text(); + if (res.status === 401) { - const text = await res.text(); if (text.startsWith("2fa")) throw new Error("2fa"); + throw new Error(text); } - const { user, token } = await res.json(); + const { user, token } = JSON.parse(text); if (!token) throw new Error("Login failed"); const { username, language } = user; diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 1a1f745c..fe2f4533 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"; @@ -14,8 +15,8 @@ import { page } from "$app/stores"; import { sign } from "$lib/nostr"; import { invalidateAll } from "$app/navigation"; - import { sha256 } from "@noble/hashes/sha256"; - import { bytesToHex } from "@noble/hashes/utils"; + import { sha256 } from "@noble/hashes/sha2.js"; + import { bytesToHex } from "@noble/hashes/utils.js"; let { data, form } = $props(); @@ -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,10 +142,27 @@ 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 - class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 mb-20" + class="mx-auto md:shadow-xl rounded-3xl max-w-xl w-full md:w-[480px] md:p-8 mb-20 space-y-5" > <h1 class="text-2xl font-bold text-center">{$t("login.signIn")}</h1> {#if form?.error && !form?.message.includes("2fa")} @@ -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 2c2807bd..2bcf6f2d 100644 --- a/src/routes/(static)/register/+page.server.ts +++ b/src/routes/(static)/register/+page.server.ts @@ -1,5 +1,5 @@ -import { fd, get, login, post } from "$lib/utils"; -import { fail, redirect } from "@sveltejs/kit"; +import { fd, get, register } from "$lib/utils"; +import { redirect } from "@sveltejs/kit"; export const load = async ({ parent }) => { const { user } = await parent(); @@ -14,39 +14,13 @@ export const load = async ({ parent }) => { export const actions = { default: async ({ cookies, request }) => { const ip = request.headers.get("cf-connecting-ip"); - + const host = request.headers.get("host"); const form = await fd(request); - let { picture, username, password, pubkey, loginRedirect } = form; - const user = { picture, username, password, pubkey }; - let error; - + const { picture, username, password, challenge, recaptcha } = form; + let { loginRedirect } = form; if (loginRedirect === "undefined") loginRedirect = undefined; - let sk; - try { - ({ sk } = await post("/register", { user }, { "cf-connecting-ip": ip })); - } catch (e) { - ({ message: error } = e as Error); - } - - try { - await login(user, cookies, ip); - error = null; - } catch (e) { - const { message } = e as Error; - error ||= message; - } - - const expires = new Date(); - expires.setSeconds(expires.getSeconds() + 21000000); - cookies.set("sk", sk, { - path: "/", - expires, - httpOnly: false, - sameSite: "lax", - }); - - if (error) return fail(400, { error }); - redirect(303, loginRedirect || `/${user.username}`); + const user = { picture, username, password, challenge, recaptcha }; + return register(user, ip, cookies, loginRedirect, host); }, }; diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index 43cafe6e..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} @@ -222,7 +273,7 @@ <h1 class="text-2xl font-bold text-center">{$t("login.createAccount")}</h1> <input type="file" - class="hidden" + class="hidden!" bind:this={avatarInput} onchange={(e) => handleFile(e, "picture")} /> @@ -263,9 +314,10 @@ </button> </div> - {#if form?.error} + {#if form?.error || form?.message} <div class="text-red-600 text-center" in:fly> - {form.error} + {form?.message} + {form?.error} </div> {/if} @@ -276,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> |
