sign up page with captcha

This commit is contained in:
not-nullptr 2024-03-12 08:12:21 +00:00
parent 2cf9d54d24
commit 2ddf2c7d31
14 changed files with 370 additions and 174 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
HCAPTCHA_KEY=ES_

30
package-lock.json generated
View file

@ -12,12 +12,16 @@
"@sveltejs/enhanced-img": "^0.1.8",
"better-sqlite3": "^9.4.3",
"cookie": "^0.6.0",
"email-validator": "^2.0.4",
"hcaptcha": "^0.1.1",
"jsonwebtoken": "^9.0.2",
"reflect-metadata": "^0.2.1",
"sequelize": "^6.37.1",
"sqlite3": "^5.1.7",
"svelte-hcaptcha": "^0.1.1",
"typeorm": "^0.3.20",
"uuid": "^9.0.1",
"verify-hcaptcha": "^1.0.0",
"vite-plugin-vsharp": "^1.7.3",
"ws": "^8.16.0"
},
@ -2938,6 +2942,14 @@
"integrity": "sha512-f9iZD1t3CLy1AS6vzM5EKGa6p9pRcOeEFXRFbaG2Ta+Oe7MkfRQ3fsvPYidzHe1h4i0JvIvpcY55C+B6BZNGtQ==",
"dev": true
},
"node_modules/email-validator": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz",
"integrity": "sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==",
"engines": {
"node": ">4.0"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@ -3643,6 +3655,11 @@
"node": ">= 0.4"
}
},
"node_modules/hcaptcha": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/hcaptcha/-/hcaptcha-0.1.1.tgz",
"integrity": "sha512-iMrDmH2VpIEKOrcKWidVjI89FdDKTEdZ7PfPWkP27sTazIIkob8YfdY2ezaufAnWBiUUcvzsn0qF+dyXtBH2Vw=="
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
@ -6290,6 +6307,11 @@
"svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0"
}
},
"node_modules/svelte-hcaptcha": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/svelte-hcaptcha/-/svelte-hcaptcha-0.1.1.tgz",
"integrity": "sha512-iFF3HwfrCRciJnDs4Y9/rpP/BM2U/5zt+vh+9d4tALPAHVkcANiJIKqYuS835pIaTm6gt+xOzjfFI3cgiRI29A=="
},
"node_modules/svelte-hmr": {
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz",
@ -7087,6 +7109,14 @@
"node": ">= 0.8"
}
},
"node_modules/verify-hcaptcha": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/verify-hcaptcha/-/verify-hcaptcha-1.0.0.tgz",
"integrity": "sha512-WRpRjUdybjvpxjciQ8+SQ1qXYIKlWghVA2sabGuX09s2jSvBHv6Dzz4Kzu8eBBCLvwiZ/6+ursx1aN4w7qRG9w==",
"engines": {
"node": ">=12"
}
},
"node_modules/vite": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz",

View file

@ -48,12 +48,16 @@
"@sveltejs/enhanced-img": "^0.1.8",
"better-sqlite3": "^9.4.3",
"cookie": "^0.6.0",
"email-validator": "^2.0.4",
"hcaptcha": "^0.1.1",
"jsonwebtoken": "^9.0.2",
"reflect-metadata": "^0.2.1",
"sequelize": "^6.37.1",
"sqlite3": "^5.1.7",
"svelte-hcaptcha": "^0.1.1",
"typeorm": "^0.3.20",
"uuid": "^9.0.1",
"verify-hcaptcha": "^1.0.0",
"vite-plugin-vsharp": "^1.7.3",
"ws": "^8.16.0"
}

View file

@ -23,6 +23,12 @@ body {
font-family: "DM Sans", sans-serif;
}
html {
scroll-behavior: smooth;
overflow-x: hidden;
scrollbar-gutter: stable;
}
h1,
h2,
h3 {
@ -41,6 +47,17 @@ h3 {
@apply flex w-fit shrink-0 select-none flex-row items-center justify-center gap-4 rounded-xl py-3 pl-7 pr-5 font-bold transition;
}
.button,
.cta-button {
padding: 0.75rem 1.5rem;
}
.button:disabled,
.cta-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.button {
border: 2px solid #46424d;
@apply flex w-fit shrink-0 select-none flex-row items-center justify-center gap-4 rounded-xl py-3 pl-7 pr-5 font-bold transition;
@ -62,3 +79,19 @@ h3 {
background: #c3c3cd;
color: black;
}
.input {
border: 2px solid #46424d;
/* @apply w-full rounded-xl px-4 py-3 text-sm font-bold transition; */
width: 100%;
border-radius: 0.75rem;
padding: 0.5rem 1rem;
font-size: 1rem;
font-weight: 600;
transition: all 0.2s;
background-color: transparent;
}
.link {
@apply text-blue-300 underline transition-all ease-out hover:text-blue-100;
}

View file

@ -0,0 +1,111 @@
<script lang="ts" context="module">
declare global {
interface Window {
sitekey: string;
hcaptchaOnLoad: Function | null;
onSuccess: Function | null;
onError: Function;
onClose: Function;
hcaptcha: any;
}
}
declare var hcaptcha: any;
</script>
<script lang="ts">
import { onDestroy, createEventDispatcher, onMount } from "svelte";
// @ts-ignore
const browser = import.meta.env.SSR === undefined ? true : !import.meta.env.SSR;
const dispatch = createEventDispatcher();
export let sitekey: string;
export let apihost: string = "https://js.hcaptcha.com/1/api.js";
export let hl: string = "";
export let reCaptchaCompat: boolean = false;
export let theme: "light" | "dark" = "light";
export let size: "normal" | "compact" | "invisible" = "normal";
export const reset = () => {
if (mounted && loaded && widgetID) hcaptcha.reset(widgetID);
};
export const execute = (options) => {
if (mounted && loaded && widgetID) return hcaptcha.execute(widgetID, options);
};
// ensure that all captcha divs on a page are uniquely identifiable
const id = Math.floor(Math.random() * 100);
let mounted = false;
let loaded = false;
let widgetID;
// construct the script tag for hCaptcha remote resources
const query = new URLSearchParams({
recaptchacompat: reCaptchaCompat ? "on" : "off",
onload: "hcaptchaOnLoad",
render: "explicit",
});
const scriptSrc = `${apihost}?${query.toString()}`;
onMount(() => {
if (browser && !sitekey) sitekey = window.sitekey;
if (browser) {
window.hcaptchaOnLoad = () => {
// consumers can attach custom on:load handlers
dispatch("load");
loaded = true;
};
window.onSuccess = (token) => {
dispatch("success", {
token: token,
});
};
window.onError = () => {
dispatch("error");
};
window.onClose = () => {
dispatch("close");
};
}
dispatch("mount");
mounted = true;
});
onDestroy(() => {
if (browser) {
window.hcaptchaOnLoad = null;
window.onSuccess = null;
}
// guard against script loading race conditions
// i.e. if component is destroyed before hcaptcha reference is loaded
if (loaded) hcaptcha = null;
});
$: if (mounted && loaded) {
widgetID = hcaptcha.render(`h-captcha-${id}`, {
sitekey,
hl, // force a specific localisation
theme,
callback: "onSuccess",
"error-callback": "onError",
"close-callback": "onClose",
size,
});
}
</script>
<svelte:head>
{#if mounted && !window?.hcaptcha}
<script src={scriptSrc} async defer></script>
{/if}
</svelte:head>
<div id="h-captcha-{id}" />

View file

@ -2,25 +2,35 @@ import { db } from "$lib/server/db";
import "reflect-metadata";
import { building } from "$app/environment";
import type { Handle } from "@sveltejs/kit";
import {WebSocketServer} from "ws";
import { WebSocketServer } from "ws";
let server: WebSocketServer;
function initServer() {
server = new WebSocketServer({
port: 21563,
path: "/net"
});
server.on("connection", (socket) => {
socket.on("message", (data) => {
socket.send(data);
})
})
try {
server = new WebSocketServer({
port: 21563,
path: "/net",
});
server.on("error", (err) => {
console.error("WebSocket server error:", err);
});
server.on("connection", (socket) => {
socket.on("message", (data) => {
socket.send(data);
});
});
} catch {}
}
const runAllTheInitFunctions = async () => {
if (!db.isInitialized) await db.initialize();
if (!server) initServer();
if (!server)
try {
initServer();
} catch {
console.error("Could not initialize WebSocket server");
}
};
if (!building) {

View file

@ -22,4 +22,9 @@ export class SuyuUser extends BaseEntity {
select: false,
})
apiKey: string;
@Column("text", {
select: false,
})
email: string;
}

View file

@ -3,14 +3,34 @@
import { onMount, onDestroy } from "svelte";
import Logo from "../components/LogoWithTextHorizontal.svelte";
import { CodeBranchOutline, DiscordSolid, DownloadOutline } from "flowbite-svelte-icons";
import { browser } from "$app/environment";
let scrolled = false;
let cookies: {
[key: string]: string;
} = {};
if (browser) {
cookies = Object.fromEntries(
document.cookie.split("; ").map((c) => {
const [key, value] = c.split("=");
return [key, value];
}),
);
}
onMount(() => {
const handleScroll = () => {
scrolled = window.scrollY > 0;
};
handleScroll(); // we can't guarantee that the page starts at the top
cookies = Object.fromEntries(
document.cookie.split("; ").map((c) => {
const [key, value] = c.split("=");
return [key, value];
}),
);
window.addEventListener("scroll", handleScroll);
return () => {
@ -59,7 +79,9 @@
>
<DiscordSolid />
</a>
<a href="/account" class="button-sm">Sign in</a>
<a href={cookies.token ? "/account" : "/signup"} class="button-sm"
>{cookies.token ? "Account" : "Sign up"}</a
>
</div>
</nav>
</header>

View file

@ -31,7 +31,7 @@
open-source, forever.
</p>
<div class="flex flex-row gap-4">
<div class="cta-button">
<button class="cta-button">
Download <svg
class=""
style="--icon-color:#000"
@ -46,7 +46,7 @@
d="M5.46967 11.4697C5.17678 11.7626 5.17678 12.2374 5.46967 12.5303C5.76256 12.8232 6.23744 12.8232 6.53033 12.5303L10.5303 8.53033C10.8207 8.23999 10.8236 7.77014 10.5368 7.47624L6.63419 3.47624C6.34492 3.17976 5.87009 3.17391 5.57361 3.46318C5.27713 3.75244 5.27128 4.22728 5.56054 4.52376L8.94583 7.99351L5.46967 11.4697Z"
></path></svg
>
</div>
</button>
<div class="button text-[#8A8F98]">
Contribute <svg
class=""

View file

@ -1,164 +1,42 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import HCaptcha from "$components/HCaptcha.svelte";
import { PUBLIC_SITE_KEY } from "$env/static/public";
import { SuyuAPI } from "$lib/client/api";
import type { CreateAccountResponse } from "$types/api";
import type { PageData } from "./$types";
import Room from "$components/Room.svelte";
let usernameInput = "";
let emailInput = "";
let captchaToken = "";
$: disabled = !usernameInput || !emailInput || !captchaToken;
export let data: PageData;
let base64Token: string;
$: base64Token = data?.token ? btoa(data.token) : "";
let usernameToCreate: string;
let createBtn: HTMLButtonElement;
async function createAccount() {
createBtn.disabled = true;
const response = await SuyuAPI.users.createAccount({ username: usernameToCreate });
if (response.success) {
data = {
...(data || {}),
user: response.user,
token: response.token,
};
// add token cookie
document.cookie = `token=${response.token}; path=/`;
} else {
alert("Failed to create account: " + response.error);
window.location.reload();
}
usernameToCreate = "";
createBtn.disabled = false;
$: {
if (Object.keys(data.user).length === 0) goto("/signup");
}
async function deleteAccount() {
const response = await SuyuAPI.users.deleteAccount();
if (response.success) {
data = {
...(data || {}),
// @ts-expect-error since we're deleting the account, we can't expect the user to still exist
user: undefined,
token: undefined,
};
// remove token cookie
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
} else {
alert("Failed to delete account: " + response.error);
}
}
async function getWsMessage(event: MessageEvent): Promise<string> {
return new Promise((resolve, reject) => {
if (event.data instanceof Blob) {
const reader = new FileReader();
reader.onload = () => {
resolve((reader.result as string) || "");
};
reader.readAsText(event.data);
} else {
resolve(event.data);
}
async function signUp() {
const res = await SuyuAPI.users.createAccount({
username: usernameInput,
email: emailInput,
captchaToken,
});
if (!res.success) {
// TODO: modal
alert(res.error);
return;
}
// set "token" cookie
document.cookie = `token=${res.token}; path=/; max-age=31536000; samesite=strict`;
goto("/account");
}
onMount(() => {
const ws = new WebSocket("wss://sjqr2hlh-21563.uks1.devtunnels.ms/net");
ws.onmessage = async (event) => {
const msg = await getWsMessage(event);
console.log(msg);
};
ws.onopen = () => ws.send("hello, world");
});
async function captchaComplete(event: CustomEvent<any>) {
captchaToken = event.detail.token;
}
</script>
<div class="panel-blur main-panel">
<h2>Online Services</h2>
<p>
{#if data?.token && data?.user && data.user.username}
<p>Username: {data.user.username}</p>
<p>Token: <code>{base64Token}</code></p>
<button class="danger" on:click={deleteAccount}>Delete Account</button>
{:else}
<p>
It appears you don't have an account; please register one to access suyu's online
services.
</p>
<div class="create-account">
<input bind:value={usernameToCreate} type="text" placeholder="Username" />
<button bind:this={createBtn} on:click={createAccount}>Create Account</button>
</div>
{/if}
</p>
<h2>Rooms</h2>
<div class="rooms">
{#if data.rooms.length > 0}
{#each data.rooms as room}
<Room {room} />
{/each}
{:else}
<p>No rooms are currently being hosted.</p>
{/if}
</div>
<div
class="align-center relative flex h-[calc(100vh-200px)] flex-col items-center justify-center gap-6 overflow-hidden"
>
a
</div>
<style>
.main-panel {
position: fixed;
left: 50%;
transform: translate(-50%);
margin-top: 60px;
width: calc(100% - 120px);
height: calc(100% - 240px);
max-height: 1000px;
min-height: 400px;
max-width: 1000px;
padding: 28px 36px;
padding-top: 12px;
display: flex;
flex-direction: column;
}
.main-panel > h2 {
margin-bottom: 16px;
margin-top: 16px;
}
.create-account {
margin-top: 16px;
}
.main-panel code {
background-color: #222429;
border: var(--border-primary);
padding: 2px 8px;
border-radius: 4px;
user-select: all;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre;
width: fit-content;
max-width: 100%;
display: block;
margin-top: 4px;
margin-bottom: 16px;
}
.rooms {
margin-top: -16px;
display: flex;
flex-direction: column;
gap: 24px;
height: 0px;
flex-grow: 1;
overflow-y: auto;
--mask-image: linear-gradient(transparent, black 8px, black calc(100% - 32px), transparent),
linear-gradient(to left, black 8px, transparent 8px);
padding-top: 8px;
padding-bottom: 32px;
mask-image: var(--mask-image);
-webkit-mask-image: var(--mask-image);
background-color: transparent;
}
</style>

View file

@ -12,6 +12,10 @@ import type {
} from "$types/api";
import crypto from "crypto";
import { promisify } from "util";
import { verify } from "hcaptcha";
import { PUBLIC_SITE_KEY } from "$env/static/public";
import { HCAPTCHA_KEY } from "$env/static/private";
import validator from "validator";
const randomBytes = promisify(crypto.randomBytes);
@ -27,24 +31,42 @@ async function genKey(username: string) {
return apiKey;
}
export async function POST({ request }) {
export async function POST({ request, getClientAddress }) {
const body: CreateAccountRequest = await request.json();
if (!body.username) {
if (!body.username || !body.email || !body.captchaToken) {
return json<CreateAccountResponse>({
success: false,
error: "username is required",
error: "missing fields",
});
}
if (!validator.isEmail(body.email)) {
return json<CreateAccountResponse>({
success: false,
error: "invalid email",
});
}
const res = await verify(HCAPTCHA_KEY, body.captchaToken, getClientAddress(), PUBLIC_SITE_KEY);
if (!res.success) {
return json<CreateAccountResponse>({
success: false,
error: "missing fields!",
});
}
// check if user exists
const user = await userRepo.findOne({
where: {
username: body.username,
},
where: [
{
username: body.username,
},
{
email: body.email,
},
],
});
if (user) {
return json<CreateAccountResponse>({
success: false,
error: "username already exists",
error: "user already exists",
});
}
// the api key can only be 80 characters total, including the username and colon
@ -55,6 +77,7 @@ export async function POST({ request }) {
displayName: body.username,
roles: serializeRoles(["user"]),
apiKey: key,
email: body.email,
});
await userRepo.save(createdUser);
return json<CreateAccountResponse>({

View file

@ -0,0 +1,13 @@
import { RoomManager } from "$lib/server/class/Room.js";
import { useAuth } from "$lib/util/api";
export async function load(opts) {
const apiKey = opts.cookies.get("token");
const user = await useAuth(apiKey || "unused");
const rooms = RoomManager.getRooms().map((r) => r.toJSON());
return {
user: { ...user },
rooms,
token: apiKey,
};
}

View file

@ -0,0 +1,64 @@
<script lang="ts">
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import HCaptcha from "$components/HCaptcha.svelte";
import { PUBLIC_SITE_KEY } from "$env/static/public";
import { SuyuAPI } from "$lib/client/api";
import type { PageData } from "./$types";
let usernameInput = "";
let emailInput = "";
let captchaToken = "";
$: disabled = !usernameInput || !emailInput || !captchaToken;
export let data: PageData;
if (Object.keys(data.user).length !== 0 && browser) goto("/account");
async function signUp() {
const res = await SuyuAPI.users.createAccount({
username: usernameInput,
email: emailInput,
captchaToken,
});
if (!res.success) {
// TODO: modal
alert(res.error);
return;
}
// set "token" cookie
document.cookie = `token=${res.token}; path=/; max-age=31536000; samesite=strict`;
goto("/account");
}
async function captchaComplete(event: CustomEvent<any>) {
captchaToken = event.detail.token;
}
</script>
<div
class="align-center relative flex h-[calc(100vh-200px)] flex-col items-center justify-center gap-6 overflow-hidden"
>
<div class="flex h-fit w-full max-w-[500px] flex-col rounded-[2.25rem] bg-black p-10">
<h1 class="text-[60px] md:leading-[1.1]">Sign up</h1>
<div class="mt-4 flex flex-col gap-4">
<p>
suyu believes in user privacy; as such, usernames are distributed on a first-come,
first-serve basis, with no password required. Accounts are used for:
</p>
<ul class="[&>*]:before:mr-3 [&>*]:before:content-['•']">
<li>Creating rooms</li>
<li>Adding friends</li>
</ul>
<p>
Lost your account? <a class="link" href="https://discord.gg/suyu" target="_blank"
>Contact us</a
>.
</p>
<input bind:value={emailInput} class="input" type="text" placeholder="Recovery Email" />
<input bind:value={usernameInput} class="input" type="text" placeholder="Username" />
<HCaptcha on:success={captchaComplete} theme="dark" sitekey={PUBLIC_SITE_KEY} />
<button {disabled} on:click={signUp} class="cta-button mt-2">Sign up</button>
</div>
</div>
</div>

2
src/types/api.d.ts vendored
View file

@ -9,6 +9,8 @@ export interface GenericFailureResponse {
export interface CreateAccountRequest {
username: string;
email: string;
captchaToken: string;
}
export interface CreateAccountResponseSuccess {