Merge branch 'refs/heads/master' into staging
Some checks failed
Push to cloudflare pages / deploy (push) Failing after 28s

# Conflicts:
#	src/lib/server/db/schema.ts
This commit is contained in:
Lara 2025-04-07 13:57:22 +03:00
commit 7c93b925e1
Signed by: laratheprotogen
GPG key ID: 5C0296EB3165F98B
26 changed files with 1516 additions and 5264 deletions

3
.gitignore vendored
View file

@ -8,6 +8,9 @@ node_modules
/.svelte-kit
/build
# IDE FILES
.idea
# OS
.DS_Store
Thumbs.db

4
.idea/workspace.xml generated
View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PropertiesComponent">{}</component>
</project>

1043
bun.lock Normal file

File diff suppressed because it is too large Load diff

2
bunfig.toml Normal file
View file

@ -0,0 +1,2 @@
[run]
bun = true

View file

@ -2,13 +2,8 @@ import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
dbCredentials: { url: process.env.DATABASE_URL! },
verbose: true,
strict: true,
dialect: 'sqlite',
driver: 'd1-http'
dialect: 'sqlite'
});

View file

@ -1,28 +1,11 @@
{
"name": "sveltekit-bsky-handles",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"test:unit": "vitest",
"test": "npm run test:unit -- --run"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250403.0",
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-cloudflare": "^5.0.1",
"@libsql/client": "^0.15.2",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
@ -30,6 +13,7 @@
"@testing-library/svelte": "^5.2.4",
"@types/node": "^22.14.0",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.41.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
@ -46,13 +30,31 @@
"vite": "^6.0.0",
"vitest": "^3.0.0"
},
"dependencies": {
"drizzle-orm": "^0.40.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"packageManager": "pnpm@10.7.1+sha512.2d92c86b7928dc8284f53494fb4201f983da65f0fb4f0d40baafa5cf628fa31dae3e5968f12466f17df7e97310e30f343a648baea1b9b350685dafafffdf5808"
"private": true,
"scripts": {
"dev": "bunx vite dev",
"build": "bunx vite build",
"preview": "bunx vite preview",
"prepare": "bunx svelte-kit sync || echo ''",
"check": "bunx svelte-kit sync && bunx svelte-check --tsconfig ./tsconfig.json",
"check:watch": "bunx svelte-kit sync && bunx svelte-check --tsconfig ./tsconfig.json --watch",
"format": "bunx prettier --write .",
"lint": "bunx prettier --check . && bunx eslint .",
"db:push": "bunx drizzle-kit push",
"db:migrate": "bunx drizzle-kit migrate",
"db:studio": "bunx drizzle-kit studio",
"test:unit": "bunx vitest",
"test": "npm run test:unit -- --run"
},
"type": "module",
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0"
}
}

5185
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1 +1,19 @@
@import url('https://fonts.bunny.net/css?family=inter:400');
@import 'tailwindcss';
:root {
font-family: 'Inter', sans-serif;
}
body {
@apply bg-white text-pretty text-black dark:bg-zinc-900 dark:text-white;
background-size: 40%;
}
.emoji {
@apply inline-block w-6;
}
a {
@apply text-gray-400 hover:text-sky-500 hover:underline;
}

19
src/app.d.ts vendored
View file

@ -1,21 +1,8 @@
import type { D1Database, CacheStorage, Cache } from '@cloudflare/workers-types';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
interface Platform {
env?: {
HANDLES_DB: D1Database;
}
context: {
waitUntil(promise: Promise<unknown>): void;
};
caches: CacheStorage & { default: Cache };
interface Locals {
user: import('$lib/server/auth').SessionValidationResult['user'];
session: import('$lib/server/auth').SessionValidationResult['session'];
}
}
}

26
src/hooks.server.ts Normal file
View file

@ -0,0 +1,26 @@
import type { Handle } from '@sveltejs/kit';
import * as auth from '$lib/server/auth.js';
const handleAuth: Handle = async ({ event, resolve }) => {
const sessionToken = event.cookies.get(auth.sessionCookieName);
if (!sessionToken) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
const { session, user } = await auth.validateSessionToken(sessionToken);
if (session) {
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
} else {
auth.deleteSessionTokenCookie(event);
}
event.locals.user = user;
event.locals.session = session;
return resolve(event);
};
export const handle: Handle = handleAuth;

View file

@ -0,0 +1,22 @@
<script lang="ts">
</script>
<footer class="footer text-center">
<p>
Logo art by <a href="https://bsky.app/profile/did:plc:sqwkomf6gkzbhnctiqexepzx">Aegiscarr</a>
</p>
<p>
&copy; {new Date().getFullYear()} <a href="https://laratheprotogen.dev">Lara The Protogen</a>.
All rights reserved. Meow
</p>
</footer>
<style lang="postcss">
@reference "tailwindcss";
.footer {
@apply w-full p-4 text-center;
position: absolute;
bottom: 0;
z-index: 10;
}
</style>

View file

@ -0,0 +1,36 @@
<script lang="ts">
interface HeadProps {
title: string;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
ogUrl?: string;
articleAuthor?: string;
}
let {
title,
ogTitle = title,
ogDescription,
ogImage,
ogUrl,
articleAuthor
}: HeadProps = $props();
</script>
<svelte:head>
<meta property="og:title" content={ogTitle} />
{#if ogDescription}
<meta property="og:description" content={ogDescription} />
{/if}
{#if ogImage}
<meta property="og:image" content={ogImage} />
{/if}
{#if ogUrl}
<meta property="og:url" content={ogUrl} />
{/if}
{#if articleAuthor}
<meta property="article:author" content={articleAuthor} />
{/if}
<title>{title}</title>
</svelte:head>

BIN
src/lib/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

81
src/lib/server/auth.ts Normal file
View file

@ -0,0 +1,81 @@
import type { RequestEvent } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
const DAY_IN_MS = 1000 * 60 * 60 * 24;
export const sessionCookieName = 'auth-session';
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(18));
const token = encodeBase64url(bytes);
return token;
}
export async function createSession(token: string, userId: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const session: table.Session = {
id: sessionId,
userId,
expiresAt: new Date(Date.now() + DAY_IN_MS * 30)
};
await db.insert(table.session).values(session);
return session;
}
export async function validateSessionToken(token: string) {
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const [result] = await db
.select({
// Adjust user table here to tweak returned data
user: { id: table.user.id, username: table.user.username },
session: table.session
})
.from(table.session)
.innerJoin(table.user, eq(table.session.userId, table.user.id))
.where(eq(table.session.id, sessionId));
if (!result) {
return { session: null, user: null };
}
const { session, user } = result;
const sessionExpired = Date.now() >= session.expiresAt.getTime();
if (sessionExpired) {
await db.delete(table.session).where(eq(table.session.id, session.id));
return { session: null, user: null };
}
const renewSession = Date.now() >= session.expiresAt.getTime() - DAY_IN_MS * 15;
if (renewSession) {
session.expiresAt = new Date(Date.now() + DAY_IN_MS * 30);
await db
.update(table.session)
.set({ expiresAt: session.expiresAt })
.where(eq(table.session.id, session.id));
}
return { session, user };
}
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
export async function invalidateSession(sessionId: string) {
await db.delete(table.session).where(eq(table.session.id, sessionId));
}
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, token, {
expires: expiresAt,
path: '/'
});
}
export function deleteSessionTokenCookie(event: RequestEvent) {
event.cookies.delete(sessionCookieName, {
path: '/'
});
}

View file

@ -1,7 +1,5 @@
import { drizzle } from 'drizzle-orm/d1';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import * as schema from './schema';
import { env } from '$env/dynamic/private';
console.log(env.HANDLES_DB)
export const db = drizzle(env.HANDLES_DB, { schema });
export const db = drizzle(env.DATABASE_URL, { schema });

View file

@ -2,11 +2,27 @@ import { sql } from 'drizzle-orm';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const user = sqliteTable('user', {
id: integer('id').primaryKey({ autoIncrement: true }),
// One handle, per person
did: text('did_plc').unique().notNull(),
handle: text('handle').unique().notNull(),
createdAt: text('created_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
// Eventually I want people to be able to update their handle, but that'll probably require implementing oauth
updatedAt: text('updated_at').notNull().default(sql`(CURRENT_TIMESTAMP)`),
id: text('id').primaryKey(),
did: text('did').notNull(),
handle: text('handle').notNull(),
created_at: text('created_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updated_at: text('updated_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
username: text('username').notNull().unique(),
passwordHash: text('password_hash').notNull()
});
export const session = sqliteTable('session', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
});
export type Session = typeof session.$inferSelect;
export type User = typeof user.$inferSelect;

View file

@ -1,7 +1,40 @@
<script lang="ts">
import '../app.css';
import logo from '$lib/images/logo.png';
import Footer from '$lib/components/Footer.svelte';
import { onMount } from 'svelte';
let { children } = $props();
let div: HTMLDivElement;
onMount(() => {
div.style.backgroundImage = `url("${logo}")`;
});
</script>
{@render children()}
<div class="background" bind:this={div}>
<div class="blur"></div>
<article class="content">
{@render children()}
</article>
<Footer />
</div>
<style lang="postcss">
@reference "tailwindcss";
.background {
@apply relative flex h-screen w-full items-center justify-center bg-cover bg-center bg-no-repeat;
background-size: 40%;
}
.blur {
@apply absolute inset-0 h-full w-full backdrop-blur-lg;
}
.content {
@apply relative z-10 mx-auto flex max-w-screen-md flex-col items-center justify-center rounded-lg bg-neutral-800/80 p-8 text-center;
}
</style>

View file

@ -1,2 +1,32 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script>
import { enhance } from '$app/forms';
import { page } from '$app/state';
</script>
<section>
<h1 class="text-2xl font-bold">Get your own {page.url.hostname}</h1>
<form method="post" action="?/handle" use:enhance class="flex flex-col pt-8">
<label>
Current Username
<input name="username" />
</label>
<label>
New Username
<input type="password" name="password" />
</label>
<button>Submit</button>
</form>
</section>
<style lang="postcss">
@reference "tailwindcss";
label {
@apply flex flex-col text-left text-xl;
}
input {
@apply my-4 rounded-lg bg-neutral-950/50 p-2 outline outline-zinc-700 transition hover:outline-2 hover:outline-sky-500;
}
button {
@apply my-4 rounded-lg bg-neutral-950/50 p-2 outline outline-zinc-700 transition hover:outline-2 hover:outline-sky-500;
}
</style>

View file

View file

@ -0,0 +1,22 @@
import * as auth from '$lib/server/auth';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
if (!event.locals.user) {
return redirect(302, '/demo/lucia/login');
}
return { user: event.locals.user };
};
export const actions: Actions = {
logout: async (event) => {
if (!event.locals.session) {
return fail(401);
}
await auth.invalidateSession(event.locals.session.id);
auth.deleteSessionTokenCookie(event);
return redirect(302, '/demo/lucia/login');
}
};

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageServerData } from './$types';
let { data }: { data: PageServerData } = $props();
</script>
<h1>Hi, {data.user.username}!</h1>
<p>Your user ID is {data.user.id}.</p>
<form method="post" action="?/logout" use:enhance>
<button>Sign out</button>
</form>

View file

@ -0,0 +1,107 @@
import { verify } from '@node-rs/argon2';
import { encodeBase32LowerCase } from '@oslojs/encoding';
import { fail, redirect } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import * as auth from '$lib/server/auth';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
if (event.locals.user) {
return redirect(302, '/demo/lucia');
}
return {};
};
export const actions: Actions = {
login: async (event) => {
const formData = await event.request.formData();
const username = formData.get('username');
const password = formData.get('password');
if (!validateUsername(username)) {
return fail(400, {
message: 'Invalid username (min 3, max 31 characters, alphanumeric only)'
});
}
if (!validatePassword(password)) {
return fail(400, { message: 'Invalid password (min 6, max 255 characters)' });
}
const results = await db.select().from(table.user).where(eq(table.user.username, username));
const existingUser = results.at(0);
if (!existingUser) {
return fail(400, { message: 'Incorrect username or password' });
}
const validPassword = await verify(existingUser.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
if (!validPassword) {
return fail(400, { message: 'Incorrect username or password' });
}
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, existingUser.id);
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
return redirect(302, '/demo/lucia');
}
/*register: async (event) => {
const formData = await event.request.formData();
const username = formData.get('username');
const password = formData.get('password');
if (!validateUsername(username)) {
return fail(400, { message: 'Invalid username' });
}
if (!validatePassword(password)) {
return fail(400, { message: 'Invalid password' });
}
const userId = generateUserId();
const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
try {
await db.insert(table.user).values({ id: userId, username, passwordHash });
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, userId);
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
} catch (e) {
return fail(500, { message: 'An error has occurred' });
}
return redirect(302, '/demo/lucia');
}*/
};
function generateUserId() {
// ID with 120 bits of entropy, or about the same as UUID v4.
const bytes = crypto.getRandomValues(new Uint8Array(15));
const id = encodeBase32LowerCase(bytes);
return id;
}
function validateUsername(username: unknown): username is string {
return (
typeof username === 'string' &&
username.length >= 3 &&
username.length <= 31 &&
/^[a-z0-9_-]+$/.test(username)
);
}
function validatePassword(password: unknown): password is string {
return typeof password === 'string' && password.length >= 6 && password.length <= 255;
}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<h1>Login/Register</h1>
<form method="post" action="?/login" use:enhance>
<label>
Username
<input name="username" />
</label>
<label>
Password
<input type="password" name="password" />
</label>
<button>Login</button>
<button formaction="?/register">Register</button>
</form>
<p style="color: red">{form?.message ?? ''}</p>

View file

@ -0,0 +1 @@
<p>bruh</p>

View file

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-cloudflare';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {

View file

@ -1,14 +0,0 @@
name = "protogen-social-handles"
compatibility_date = "2025-01-01"
compatibility_flags = [ "nodejs_compat" ]
pages_build_output_dir = ".svelte-kit/cloudflare"
[[ env.production.d1_databases ]]
binding = "HANDLES_DB"
database_name = "production-protogen-social-handles"
database_id = "b7e2740b-9f80-4a0a-b641-a1802339af67"
[[ env.preview.d1_databases ]]
binding = "HANDLES_DB"
database_name = "preview-protogen-social-handles"
database_id = "9edbeba1-cec2-4f91-800f-5b82cb727717"