aboutsummaryrefslogtreecommitdiffstats
path: root/src/routes/(static)
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes/(static)')
-rw-r--r--src/routes/(static)/+layout.server.ts9
-rw-r--r--src/routes/(static)/bounties/+page.server.ts44
-rw-r--r--src/routes/(static)/bounties/+page.svelte231
-rw-r--r--src/routes/(static)/docs/+page.server.ts5
-rw-r--r--src/routes/(static)/docs/+page.svelte64
-rw-r--r--src/routes/(static)/forgot/+page.svelte53
-rw-r--r--src/routes/(static)/login/+page.server.ts26
-rw-r--r--src/routes/(static)/login/+page.svelte85
-rw-r--r--src/routes/(static)/register/+page.server.ts40
-rw-r--r--src/routes/(static)/register/+page.svelte61
-rw-r--r--src/routes/(static)/support/+page.svelte31
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 &amp; 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"
- >
- </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>