diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..577be0c --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +HCAPTCHA_KEY=ES_ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7847346..b989671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 95c26d6..9338ab9 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/app.pcss b/src/app.pcss index 565363a..b361516 100644 --- a/src/app.pcss +++ b/src/app.pcss @@ -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; +} diff --git a/src/components/HCaptcha.svelte b/src/components/HCaptcha.svelte new file mode 100644 index 0000000..7ba4d75 --- /dev/null +++ b/src/components/HCaptcha.svelte @@ -0,0 +1,111 @@ + + + + + + {#if mounted && !window?.hcaptcha} + + {/if} + + +
diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 2e51b8b..d4e5246 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -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) { diff --git a/src/lib/server/schema/index.ts b/src/lib/server/schema/index.ts index c7f2474..a6c256c 100644 --- a/src/lib/server/schema/index.ts +++ b/src/lib/server/schema/index.ts @@ -22,4 +22,9 @@ export class SuyuUser extends BaseEntity { select: false, }) apiKey: string; + + @Column("text", { + select: false, + }) + email: string; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index e4e2a0f..08084d0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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 @@ > - Sign in + {cookies.token ? "Account" : "Sign up"}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index fcbf886..c3f088a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -31,7 +31,7 @@ open-source, forever.

-
+
+
Contribute - 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 { - 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) { + captchaToken = event.detail.token; + } -
-

Online Services

-

- {#if data?.token && data?.user && data.user.username} -

Username: {data.user.username}

-

Token: {base64Token}

- - {:else} -

- It appears you don't have an account; please register one to access suyu's online - services. -

- - {/if} -

-

Rooms

-
- {#if data.rooms.length > 0} - {#each data.rooms as room} - - {/each} - {:else} -

No rooms are currently being hosted.

- {/if} -
+
+ a
- - diff --git a/src/routes/api/user/+server.ts b/src/routes/api/user/+server.ts index d0b4b64..1dcc58e 100644 --- a/src/routes/api/user/+server.ts +++ b/src/routes/api/user/+server.ts @@ -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({ success: false, - error: "username is required", + error: "missing fields", + }); + } + if (!validator.isEmail(body.email)) { + return json({ + success: false, + error: "invalid email", + }); + } + const res = await verify(HCAPTCHA_KEY, body.captchaToken, getClientAddress(), PUBLIC_SITE_KEY); + if (!res.success) { + return json({ + 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({ 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({ diff --git a/src/routes/signup/+page.server.ts b/src/routes/signup/+page.server.ts new file mode 100644 index 0000000..2dc64e8 --- /dev/null +++ b/src/routes/signup/+page.server.ts @@ -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, + }; +} diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte new file mode 100644 index 0000000..bc5e8f5 --- /dev/null +++ b/src/routes/signup/+page.svelte @@ -0,0 +1,64 @@ + + +
+
+

Sign up

+
+

+ 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: +

+
    +
  • Creating rooms
  • +
  • Adding friends
  • +
+

+ Lost your account? Contact us. +

+ + + + +
+
+
diff --git a/src/types/api.d.ts b/src/types/api.d.ts index 307c4ae..0566f6c 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -9,6 +9,8 @@ export interface GenericFailureResponse { export interface CreateAccountRequest { username: string; + email: string; + captchaToken: string; } export interface CreateAccountResponseSuccess {