Implement most of status system (still kinda bootycheeks at checking if

you're connected), fionally fix prettierrc
This commit is contained in:
Soph :3 2026-01-04 20:26:42 +02:00
parent 126acf52f3
commit e64910d895
29 changed files with 2001 additions and 879 deletions

View file

@ -1,27 +1,81 @@
import type { Handle } from '@sveltejs/kit';
import * as auth from '$lib/server/auth';
import { kvStore } from '$lib/server/db';
import { _sendToSubscribers } from './routes/api/updates/+server';
import { Status } from '$lib';
const handleAuth: Handle = async ({ event, resolve }) => {
const sessionToken = event.cookies.get(auth.sessionCookieName);
const sessionToken = event.cookies.get(auth.sessionCookieName);
if (!sessionToken) {
event.locals.user = null;
event.locals.session = null;
if (!sessionToken) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
return resolve(event);
}
const { session, user } = await auth.validateSessionToken(sessionToken);
if (session) {
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
} else {
auth.deleteSessionTokenCookie(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;
event.locals.user = user;
event.locals.session = session;
return resolve(event);
if (event.locals.user) {
const id = event.locals.user.id;
const overwrite = event.locals.user.statusOverwrite as 1 | 2 | 3 | undefined;
const now = Date.now();
const lastActiveKey = `user-${id}-last-active`;
const stateKey = `user-${id}-state`;
const timerKey = `user-${id}-offline-timer`;
const lastActive = kvStore.get<number>(lastActiveKey) ?? 0;
const state = kvStore.get<1 | 2 | 3>(stateKey) ?? Status.OFFLINE;
// always update activity
kvStore.set(lastActiveKey, now);
if (overwrite !== Status.OFFLINE) {
const shouldSend = state === Status.OFFLINE && now - lastActive > 5000;
if (shouldSend) {
const outgoingStatus = overwrite === Status.DND ? Status.DND : Status.ONLINE;
_sendToSubscribers(id, {
type: 'status',
id,
status: outgoingStatus
});
kvStore.set(stateKey, outgoingStatus);
}
}
const existingTimer = kvStore.get<NodeJS.Timeout>(timerKey);
if (existingTimer) {
clearTimeout(existingTimer);
}
const offlineTimer = setTimeout(() => {
const last = kvStore.get<number>(lastActiveKey) ?? 0;
if (Date.now() - last >= 10_000) {
_sendToSubscribers(id, {
type: 'status',
id,
status: Status.OFFLINE
});
kvStore.set(stateKey, Status.OFFLINE);
kvStore.delete(timerKey);
}
}, 10_000);
kvStore.set(timerKey, offlineTimer + '');
}
return resolve(event);
};
export const handle: Handle = handleAuth;

View file

@ -1,22 +1,27 @@
<script lang="ts">
import { type Data, type OverviewUser } from "$lib";
import * as Collapsible from "$lib/components/ui/collapsible/index.js";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Tabs from "$lib/components/ui/tabs/index.js";
import * as Card from "$lib/components/ui/card/index.js";
import MessagesSquare from "@lucide/svelte/icons/messages-square";
import MinusIcon from "@lucide/svelte/icons/minus";
import PlusIcon from "@lucide/svelte/icons/plus";
import { type Data, type OverviewUser } from '$lib';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import MessagesSquare from '@lucide/svelte/icons/messages-square';
import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus';
import UserRoundPlus from '@lucide/svelte/icons/user-round-plus';
import UsersRound from '@lucide/svelte/icons/users-round';
import CirclePlus from '@lucide/svelte/icons/circle-plus';
import Input from "./ui/input/input.svelte";
import Button, { buttonVariants } from "./ui/button/button.svelte";
import User from "./extra/User.svelte";
import type { SessionValidationResult } from "$lib/server/auth";
import Input from './ui/input/input.svelte';
import Button, { buttonVariants } from './ui/button/button.svelte';
import User from './extra/User.svelte';
import type { SessionValidationResult } from '$lib/server/auth';
let { currentPage = $bindable<string|null>(), data, user, ...restProps }: {currentPage: string|null, data: Data, user: SessionValidationResult['user'] }= $props();
let {
currentPage = $bindable<string | null>(),
data,
user,
...restProps
}: { currentPage: string | null; data: Data; user: SessionValidationResult['user'] } = $props();
</script>
<Sidebar.Root {...restProps}>
@ -25,7 +30,7 @@
<Sidebar.MenuItem>
<Sidebar.MenuButton size="lg">
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
class="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
>
<MessagesSquare class="size-4" />
</div>
@ -33,193 +38,170 @@
<span class="font-medium">chat.sad.ovh</span>
</div>
</Sidebar.MenuButton>
<div class="w-full flex gap-2 justify-center">
<div class="flex w-full justify-center gap-2">
<Dialog.Root>
<Dialog.Trigger>
<Button
variant={user!.friendRequests.length > 0 ? 'destructive' : 'outline'}
size="icon"
>
<UserRoundPlus />
</Button>
</Dialog.Trigger>
<Dialog.Root>
<Dialog.Trigger>
<Button variant={user!.friendRequests.length > 0 ? "destructive" : "outline"} size="icon">
<UserRoundPlus />
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Add a friend</Dialog.Title>
<Dialog.Description>
Add a friend using their username or manage pending requests.
</Dialog.Description>
</Dialog.Header>
</Button>
</Dialog.Trigger>
<!-- input to add a new friend -->
<form method="POST" action="?/addFriend" class="mb-4">
<Input name="username" placeholder="username" required class="mb-2" />
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: 'outline' })}>Cancel</Dialog.Close>
<Button type="submit">Send request</Button>
</Dialog.Footer>
</form>
<!-- Tabs for Friend Requests -->
<Tabs.Root value="outgoing">
<Tabs.List>
<Tabs.Trigger value="outgoing">Outgoing</Tabs.Trigger>
<Tabs.Trigger value="incoming">Incoming</Tabs.Trigger>
</Tabs.List>
<Dialog.Content class="sm:max-w-[425px]">
<Dialog.Header>
<Dialog.Title>Add a friend</Dialog.Title>
<Dialog.Description>
Add a friend using their username or manage pending requests.
</Dialog.Description>
</Dialog.Header>
<!-- Outgoing Requests -->
<Tabs.Content value="outgoing">
{#if user.friendRequests.filter((r) => r.fromUser === user.id).length === 0}
<p class="text-sm text-muted-foreground">No outgoing requests</p>
{:else}
{#each user.friendRequests.filter((r) => r.fromUser === user.id) as request (request.id)}
<Card.Root class="mb-2">
<Card.Header>
<Card.Title>{request.username}</Card.Title>
<Card.Description>Request sent</Card.Description>
</Card.Header>
<Card.Footer>
<form method="POST" action="?/cancelFriendRequest">
<input type="hidden" name="requestId" value={request.id} />
<Button type="submit" variant="outline" size="sm">Cancel</Button>
</form>
</Card.Footer>
</Card.Root>
{/each}
{/if}
</Tabs.Content>
<!-- input to add a new friend -->
<form method="POST" action="?/addFriend" class="mb-4">
<Input name="username" placeholder="username" required class="mb-2" />
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: "outline" })}>
Cancel
</Dialog.Close>
<Button type="submit">Send request</Button>
</Dialog.Footer>
</form>
<!-- Incoming Requests -->
<Tabs.Content value="incoming">
{#if user.friendRequests.filter((r) => r.toUser === user.id).length === 0}
<p class="text-sm text-muted-foreground">No incoming requests</p>
{:else}
{#each user.friendRequests.filter((r) => r.toUser === user.id) as request (request.id)}
<Card.Root class="mb-2">
<Card.Header>
<Card.Title>{request.username}</Card.Title>
<Card.Description>Sent you a friend request</Card.Description>
</Card.Header>
<Card.Footer class="flex gap-2">
<!-- accept friend -->
<form method="POST" action="?/addFriend">
<input type="hidden" name="userId" value={request.fromUser} />
<Button type="submit" size="sm">Accept</Button>
</form>
<!-- decline friend -->
<form method="POST" action="?/cancelFriendRequest">
<input type="hidden" name="requestId" value={request.id} />
<Button type="submit" variant="outline" size="sm">Decline</Button>
</form>
</Card.Footer>
</Card.Root>
{/each}
{/if}
</Tabs.Content>
</Tabs.Root>
</Dialog.Content>
</Dialog.Root>
<!-- Tabs for Friend Requests -->
<Tabs.Root value="outgoing">
<Tabs.List>
<Tabs.Trigger value="outgoing">Outgoing</Tabs.Trigger>
<Tabs.Trigger value="incoming">Incoming</Tabs.Trigger>
</Tabs.List>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="outline" size="icon">
<UsersRound />
</Button>
</Dialog.Trigger>
<!-- Outgoing Requests -->
<Tabs.Content value="outgoing">
{#if user.friendRequests.filter(r => r.fromUser === user.id).length === 0}
<p class="text-sm text-muted-foreground">No outgoing requests</p>
{:else}
{#each user.friendRequests.filter(r => r.fromUser === user.id) as request (request.id)}
<Card.Root class="mb-2">
<Card.Header>
<Card.Title>{request.username}</Card.Title>
<Card.Description>Request sent</Card.Description>
</Card.Header>
<Card.Footer>
<form method="POST" action="?/cancelFriendRequest">
<input type="hidden" name="requestId" value={request.id} />
<Button type="submit" variant="outline" size="sm">
Cancel
</Button>
</form>
</Card.Footer>
</Card.Root>
{/each}
{/if}
</Tabs.Content>
<Dialog.Content class="sm:max-w-[425px]">
<form method="POST" action="?/createGroup">
<Dialog.Header>
<Dialog.Title>Create a group</Dialog.Title>
<Dialog.Description>Add friends into your group!</Dialog.Description>
</Dialog.Header>
<!-- Incoming Requests -->
<Tabs.Content value="incoming">
{#if user.friendRequests.filter(r => r.toUser === user.id).length === 0}
<p class="text-sm text-muted-foreground">No incoming requests</p>
{:else}
{#each user.friendRequests.filter(r => r.toUser === user.id) as request (request.id)}
<Card.Root class="mb-2">
<Card.Header>
<Card.Title>{request.username}</Card.Title>
<Card.Description>Sent you a friend request</Card.Description>
</Card.Header>
<Card.Footer class="flex gap-2">
<!-- accept friend -->
<form method="POST" action="?/addFriend">
<input type="hidden" name="userId" value={request.fromUser} />
<Button type="submit" size="sm">Accept</Button>
</form>
<!-- decline friend -->
<form method="POST" action="?/cancelFriendRequest">
<input type="hidden" name="requestId" value={request.id} />
<Button type="submit" variant="outline" size="sm">Decline</Button>
</form>
</Card.Footer>
</Card.Root>
{/each}
{/if}
</Tabs.Content>
</Tabs.Root>
</Dialog.Content>
{#each data.friends as friend (friend.id)}
<label class="flex items-center gap-2">
<input type="checkbox" name="member" value={friend.id} />
<User user={friend} />
</label>
{/each}
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: 'outline' })}>Cancel</Dialog.Close>
<Button type="submit">Create group</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
</Dialog.Root>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="outline" size="icon">
<CirclePlus />
</Button>
</Dialog.Trigger>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="outline" size="icon">
<UsersRound />
</Button>
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-[425px]">
<form method="POST" action="?/joinServer">
<Dialog.Header>
<Dialog.Title>Join a server</Dialog.Title>
<Dialog.Description>Enter an invite link.</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="sm:max-w-[425px]">
<form method="POST" action="?/createGroup">
<Dialog.Header>
<Dialog.Title>Create a group</Dialog.Title>
<Dialog.Description>
Add friends into your group!
</Dialog.Description>
</Dialog.Header>
<Input name="invite" placeholder="invite link" required />
{#each data.friends as friend (friend.id)}
<label class="flex items-center gap-2">
<input
type="checkbox"
name="member"
value={friend.id}
/>
<User user={friend} />
</label>
{/each}
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: 'outline' })}>Cancel</Dialog.Close>
<Button type="submit">Join</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: "outline" })}>
Cancel
</Dialog.Close>
<Button type="submit">Create group</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="outline" size="icon">
<PlusIcon />
</Button>
</Dialog.Trigger>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="outline" size="icon">
<CirclePlus />
</Button>
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-[425px]">
<form method="POST" action="?/createServer">
<Dialog.Header>
<Dialog.Title>Create a server</Dialog.Title>
<Dialog.Description>Name your new server.</Dialog.Description>
</Dialog.Header>
<Dialog.Content class="sm:max-w-[425px]">
<form method="POST" action="?/joinServer">
<Dialog.Header>
<Dialog.Title>Join a server</Dialog.Title>
<Dialog.Description>
Enter an invite link.
</Dialog.Description>
</Dialog.Header>
<Input name="invite" placeholder="invite link" required />
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: "outline" })}>
Cancel
</Dialog.Close>
<Button type="submit">Join</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root>
<Dialog.Trigger>
<Button variant="outline" size="icon">
<PlusIcon />
</Button>
</Dialog.Trigger>
<Dialog.Content class="sm:max-w-[425px]">
<form method="POST" action="?/createServer">
<Dialog.Header>
<Dialog.Title>Create a server</Dialog.Title>
<Dialog.Description>
Name your new server.
</Dialog.Description>
</Dialog.Header>
<Input name="name" placeholder="Server name" required />
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: "outline" })}>
Cancel
</Dialog.Close>
<Button type="submit">Create</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
<Input name="name" placeholder="Server name" required />
<Dialog.Footer>
<Dialog.Close class={buttonVariants({ variant: 'outline' })}>Cancel</Dialog.Close>
<Button type="submit">Create</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
</div>
</Sidebar.MenuItem>
</Sidebar.Menu>
@ -230,26 +212,24 @@
<Collapsible.Root open={true} class="group/collapsible">
<Sidebar.MenuItem>
<Collapsible.Trigger>
<Sidebar.MenuButton>
Friends
<PlusIcon
class="ms-auto group-data-[state=open]/collapsible:hidden"
/>
<MinusIcon
class="ms-auto group-data-[state=closed]/collapsible:hidden"
/>
</Sidebar.MenuButton>
<Sidebar.MenuButton>
Friends
<PlusIcon class="ms-auto group-data-[state=open]/collapsible:hidden" />
<MinusIcon class="ms-auto group-data-[state=closed]/collapsible:hidden" />
</Sidebar.MenuButton>
</Collapsible.Trigger>
<Collapsible.Content>
<Sidebar.MenuSub>
{#each data.friends as friend (friend.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton>
<User onclick={(e) => {
e.preventDefault();
currentPage = friend.id;
}} user={friend}></User>
<User
onclick={(e) => {
e.preventDefault();
currentPage = friend.id;
}}
user={friend}
></User>
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/each}
@ -263,12 +243,8 @@
<Collapsible.Trigger>
<Sidebar.MenuButton>
Groups
<PlusIcon
class="ms-auto group-data-[state=open]/collapsible:hidden"
/>
<MinusIcon
class="ms-auto group-data-[state=closed]/collapsible:hidden"
/>
<PlusIcon class="ms-auto group-data-[state=open]/collapsible:hidden" />
<MinusIcon class="ms-auto group-data-[state=closed]/collapsible:hidden" />
</Sidebar.MenuButton>
</Collapsible.Trigger>
<Collapsible.Content>
@ -276,10 +252,13 @@
{#each data.groups as group (group.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton>
<a onclick={(e) => {
e.preventDefault();
currentPage = group.id;
}} href="##">
<a
onclick={(e) => {
e.preventDefault();
currentPage = group.id;
}}
href="##"
>
{group.name} ({group.members} members)
</a>
</Sidebar.MenuSubButton>
@ -295,12 +274,8 @@
<Collapsible.Trigger>
<Sidebar.MenuButton>
Servers
<PlusIcon
class="ms-auto group-data-[state=open]/collapsible:hidden"
/>
<MinusIcon
class="ms-auto group-data-[state=closed]/collapsible:hidden"
/>
<PlusIcon class="ms-auto group-data-[state=open]/collapsible:hidden" />
<MinusIcon class="ms-auto group-data-[state=closed]/collapsible:hidden" />
</Sidebar.MenuButton>
</Collapsible.Trigger>
<Collapsible.Content>
@ -308,11 +283,19 @@
{#each data.servers as server (server.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton>
<a onclick={(e) => {
e.preventDefault();
currentPage = server.id;
}} href="##" class="flex items-center gap-2">
<img src={"https://api.dicebear.com/7.x/pixel-art/svg?seed=" + server.name} alt={server.name} class="size-6 rounded-full" />
<a
onclick={(e) => {
e.preventDefault();
currentPage = server.id;
}}
href="##"
class="flex items-center gap-2"
>
<img
src={'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + server.name}
alt={server.name}
class="size-6 rounded-full"
/>
{server.name}
</a>
</Sidebar.MenuSubButton>

View file

@ -0,0 +1,9 @@
import { MediaQuery } from "svelte/reactivity";
const DEFAULT_MOBILE_BREAKPOINT = 768;
export class IsMobile extends MediaQuery {
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
super(`max-width: ${breakpoint - 1}px`);
}
}

View file

@ -1,48 +1,48 @@
import { definePrefix, type Puuid } from "./puuid"
import { definePrefix, type Puuid } from './puuid';
export const UserID = definePrefix("user");
export const GroupID = definePrefix("group");
export const ServerID = definePrefix("srv");
export const FriendRequestID = definePrefix("frq");
export const UserID = definePrefix('user');
export const GroupID = definePrefix('group');
export const ServerID = definePrefix('srv');
export const FriendRequestID = definePrefix('frq');
export type UserId = Puuid<"user">;
export type GroupId = Puuid<"group">;
export type ServerId = Puuid<"srv">;
export type FriendRequestID = Puuid<"frq">;
export type UserId = Puuid<'user'>;
export type GroupId = Puuid<'group'>;
export type ServerId = Puuid<'srv'>;
export type FriendRequestID = Puuid<'frq'>;
export const Status: Record<string, 1|2|3> = {
export const Status: Record<string, 1 | 2 | 3> = {
OFFLINE: 1,
DND: 2,
ONLINE: 3
}
};
export type OverviewUser = {
id: string;
username: string;
image: string;
id: string;
username: string;
image: string;
};
export type OverviewServer = {
id: string;
name: string;
id: string;
name: string;
ownerId: string;
image: string;
};
export type OverviewGroup = {
id: string;
name: string;
id: string;
name: string;
ownerId: string;
members: number;
image: string;
};
export interface OverviewData {
friends: OverviewUser[],
groups: OverviewGroup[],
servers: OverviewServer[],
};
export interface UserWithStatus extends OverviewUser {
status: 1|2|3,
statusMessage: string
status: 1 | 2 | 3;
statusMessage: string;
}
export interface OverviewData {
friends: UserWithStatus[];
groups: OverviewGroup[];
servers: OverviewServer[];
}

View file

@ -1,42 +1,36 @@
import { v4 as uuidv4 } from "uuid";
import { v7 as uuidv7 } from "uuid";
import { v4 as uuidv4 } from 'uuid';
import { v7 as uuidv7 } from 'uuid';
type Brand<K, T> = K & { __brand: T };
export type Puuid<Prefix extends string> = Brand<
string,
{ prefix: Prefix }
>;
export type Puuid<Prefix extends string> = Brand<string, { prefix: Prefix }>;
export function definePrefix<const P extends string>(prefix: P) {
const withPrefix = (uuid: string) =>
`${prefix}_${uuid}` as Puuid<P>;
const withPrefix = (uuid: string) => `${prefix}_${uuid}` as Puuid<P>;
return {
prefix,
is(value: string): value is Puuid<P> {
return value.startsWith(prefix + "_");
},
return {
prefix,
is(value: string): value is Puuid<P> {
return value.startsWith(prefix + '_');
},
newV7(): Puuid<P> {
return withPrefix(uuidv7());
},
newV7(): Puuid<P> {
return withPrefix(uuidv7());
},
newV4(): Puuid<P> {
return withPrefix(uuidv4());
},
newV4(): Puuid<P> {
return withPrefix(uuidv4());
},
parse(value: string): Puuid<P> {
if (!value.startsWith(prefix + "_")) {
throw new Error(
`Invalid prefix, expected "${prefix}_"`
);
}
return value as Puuid<P>;
},
parse(value: string): Puuid<P> {
if (!value.startsWith(prefix + '_')) {
throw new Error(`Invalid prefix, expected "${prefix}_"`);
}
return value as Puuid<P>;
},
inner(id: Puuid<P>): string {
return id.slice(prefix.length + 1);
},
};
inner(id: Puuid<P>): string {
return id.slice(prefix.length + 1);
}
};
}

View file

@ -10,141 +10,151 @@ 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;
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;
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 [row] = await db
.select({
user: {
id: table.user.id,
username: table.user.username,
friends: table.user.friends,
servers: table.user.servers,
groups: table.user.groups,
},
session: table.session,
})
.from(table.session)
.innerJoin(table.user, eq(table.session.userId, table.user.id))
.where(eq(table.session.id, sessionId));
const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
const [row] = await db
.select({
user: {
id: table.user.id,
username: table.user.username,
friends: table.user.friends,
servers: table.user.servers,
groups: table.user.groups,
statusOverwrite: table.user.statusOverwrite
},
session: table.session
})
.from(table.session)
.innerJoin(table.user, eq(table.session.userId, table.user.id))
.where(eq(table.session.id, sessionId));
if (!row) {
return { session: null, user: null };
}
const { session, user } = row;
if (!row) {
return { session: null, user: null };
}
const { session, user } = row;
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 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));
}
const friends = (user.friends as string[]).length
? await db
.select({
id: table.user.id,
username: table.user.username,
})
.from(table.user)
.where(inArray(table.user.id, (user.friends as string[])))
: [];
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));
}
const friends = (user.friends as string[]).length
? await db
.select({
id: table.user.id,
username: table.user.username
})
.from(table.user)
.where(inArray(table.user.id, user.friends as string[]))
: [];
const servers = (user.servers as string[]).length
? await db
.select({
id: table.server.id,
name: table.server.name,
ownerId: table.server.owner,
})
.from(table.server)
.where(inArray(table.server.id, (user.servers as string[])))
: [];
const groups = (user.groups as string[]).length
? await db
.select({
id: table.group.id,
name: table.group.name,
ownerId: table.group.owner,
members: table.group.members
})
.from(table.group)
.where(inArray(table.group.id, (user.groups as string[])))
: [];
const servers = (user.servers as string[]).length
? await db
.select({
id: table.server.id,
name: table.server.name,
ownerId: table.server.owner
})
.from(table.server)
.where(inArray(table.server.id, user.servers as string[]))
: [];
const groups = (user.groups as string[]).length
? await db
.select({
id: table.group.id,
name: table.group.name,
ownerId: table.group.owner,
members: table.group.members
})
.from(table.group)
.where(inArray(table.group.id, user.groups as string[]))
: [];
const friendRequests = await db
.select({
id: table.friendRequest.id,
fromUser: table.friendRequest.fromUser,
toUser: table.friendRequest.toUser,
})
.from(table.friendRequest)
.where(or(eq(table.friendRequest.fromUser, user.id), eq(table.friendRequest.toUser, user.id)))
const friendRequests = await db
.select({
id: table.friendRequest.id,
fromUser: table.friendRequest.fromUser,
toUser: table.friendRequest.toUser
})
.from(table.friendRequest)
.where(or(eq(table.friendRequest.fromUser, user.id), eq(table.friendRequest.toUser, user.id)));
return { session, user: {...user, servers, friends, groups: groups.map(z => { return { ...z, members: (z.members as string[]).length}}), friendRequests} };
return {
session,
user: {
...user,
servers,
friends,
groups: groups.map((z) => {
return { ...z, members: (z.members as string[]).length };
}),
friendRequests
}
};
}
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
export async function invalidateSession(sessionId: string) {
await db.delete(table.session).where(eq(table.session.id, sessionId));
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: '/'
});
event.cookies.set(sessionCookieName, token, {
expires: expiresAt,
path: '/'
});
}
export function deleteSessionTokenCookie(event: RequestEvent) {
event.cookies.delete(sessionCookieName, {
path: '/'
});
event.cookies.delete(sessionCookieName, {
path: '/'
});
}
export function validateUsername(username: unknown): username is string {
return (
typeof username === 'string' &&
username.length >= 3 &&
username.length <= 31 &&
/^[a-z0-9_-]+$/.test(username)
);
return (
typeof username === 'string' &&
username.length >= 3 &&
username.length <= 31 &&
/^[a-z0-9_-]+$/.test(username)
);
}
export function validatePassword(password: unknown): password is string {
return typeof password === 'string' && password.length >= 6 && password.length <= 255;
return typeof password === 'string' && password.length >= 6 && password.length <= 255;
}
export function validateEmail(email: unknown): email is string {
return (
typeof email === 'string' &&
email.length >= 5 &&
email.length <= 254 &&
/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(email)
);
return (
typeof email === 'string' &&
email.length >= 5 &&
email.length <= 254 &&
/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(email)
);
}

View file

@ -1,8 +1,8 @@
import { drizzle } from 'drizzle-orm/bun-sqlite';
import { Database } from 'bun:sqlite';
import { BunSqliteKeyValue } from "bun-sqlite-key-value"
import { BunSqliteKeyValue } from 'bun-sqlite-key-value';
const sqlite = new Database('database.db');
export const db = drizzle(sqlite);
export const kvStore = new BunSqliteKeyValue("./kvStore.db")
export const kvStore = new BunSqliteKeyValue('./kvStore.db');

View file

@ -1,14 +1,16 @@
import { Status } from '../../index.ts';
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
export const user = sqliteTable('user', {
id: text('id').primaryKey(),
username: text('username').notNull().unique(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
friends: text('friends', { mode: "json"}).default([]).notNull(),
id: text('id').primaryKey(),
username: text('username').notNull().unique(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
statusOverwrite: integer('status_overwrite').default(Status.ONLINE).notNull(),
friends: text('friends', { mode: 'json' }).default([]).notNull(),
servers: text('servers', { mode: "json"}).default([]).notNull(), // string[] of ServerIDs
groups: text('groups', { mode: "json"}).default([]).notNull(), // string[] of GroupIDs
servers: text('servers', { mode: 'json' }).default([]).notNull(), // string[] of ServerIDs
groups: text('groups', { mode: 'json' }).default([]).notNull() // string[] of GroupIDs
});
export const session = sqliteTable('session', {
@ -19,39 +21,66 @@ export const session = sqliteTable('session', {
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
});
export const server = sqliteTable("server", {
export const server = sqliteTable('server', {
id: text('id').primaryKey(),
name: text('name').notNull(),
owner: text('owner').notNull().references(() => user.id),
members: text('members', { mode: "json"}).default([]).notNull(),
channels: text('channels', { mode: "json"}).default([]).notNull(), // string[] of ChannelIDs
})
owner: text('owner')
.notNull()
.references(() => user.id),
members: text('members', { mode: 'json' }).default([]).notNull(),
channels: text('channels', { mode: 'json' }).default([]).notNull() // string[] of ChannelIDs
});
export const group = sqliteTable("group", {
export const group = sqliteTable('group', {
id: text('id').primaryKey(),
name: text('name').notNull(),
owner: text('owner').notNull().references(() => user.id),
members: text('members', { mode: "json"}).default([]).notNull(),
})
owner: text('owner')
.notNull()
.references(() => user.id),
members: text('members', { mode: 'json' }).default([]).notNull()
});
export const channel = sqliteTable("channel", {
export const channel = sqliteTable('channel', {
id: text('id').primaryKey(),
name: text('name').notNull(),
serverId: text('server_id').notNull().references(() => server.id),
messages: text('messages', { mode: "json"}).default([]).notNull(),
})
serverId: text('server_id')
.notNull()
.references(() => server.id),
messages: text('messages', { mode: 'json' }).default([]).notNull()
});
export const friendRequest = sqliteTable("friendRequest", {
export const directMessage = sqliteTable('directMessage', {
id: text('id').primaryKey(),
fromUser: text('from_user').notNull().references(() => user.id),
toUser: text('to_user').notNull().references(() => user.id),
})
name: text('name').notNull(),
serverId: text('server_id')
.notNull()
.references(() => server.id),
messages: text('messages', { mode: 'json' }).default([]).notNull()
});
export const invite = sqliteTable("invite", {
export const friendRequest = sqliteTable('friendRequest', {
id: text('id').primaryKey(),
serverId: text('server_id').notNull().references(() => server.id),
code: text('code').notNull(),
})
fromUser: text('from_user')
.notNull()
.references(() => user.id),
fromUsername: text('from_username')
.notNull()
.references(() => user.id),
toUsername: text('to_username')
.notNull()
.references(() => user.id),
toUser: text('to_user')
.notNull()
.references(() => user.id)
});
export const invite = sqliteTable('invite', {
id: text('id').primaryKey(),
serverId: text('server_id')
.notNull()
.references(() => server.id),
code: text('code').notNull()
});
export type Session = typeof session.$inferSelect;
export type User = typeof user.$inferSelect;
export type Group = typeof group.$inferSelect;

View file

@ -1,13 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };

View file

@ -1,7 +1,7 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { ModeWatcher } from "mode-watcher";
import { ModeWatcher } from 'mode-watcher';
let { children } = $props();
</script>

View file

@ -3,7 +3,7 @@ import { fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
if (event.request.url.endsWith("/?logout")) {
if (event.request.url.endsWith('/?logout')) {
if (!event.locals.session) {
return fail(401);
}

View file

@ -1,43 +1,53 @@
<script lang="ts">
import Title from "$lib/components/extra/Title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import type { PageServerData } from './$types';
import Title from '$lib/components/extra/Title.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import type { PageServerData } from './$types';
let { data }: { data: PageServerData } = $props();
let { data }: { data: PageServerData } = $props();
</script>
<div class="p-6 md:w-1/2">
<Title class="text-5xl"></Title>
<div class="pb-2">A extremely simple chatting application. Supports mobile devices (as a PWA), and also works on almost every single screen size (responsive design!)</div>
<Title class="text-5xl"></Title>
<div class="pb-2">
A extremely simple chatting application. Supports mobile devices (as a PWA), and also works on
almost every single screen size (responsive design!)
</div>
<div class="pb-2">
Featurelist:
<ul class="list-disc pl-6">
<li>Lorem Ipsum is simply dummy text of the printing and typesetting industry. </li>
<li>Lorem Ipsum has been the industry's standard dummy text ever since the 1500s,</li>
<li>when an unknown printer took a galley of type and scrambled it to make a type specimen book.</li>
<li>It has survived not only five centuries, but also the leap into electronic typesetting,</li>
<li>remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages,</li>
</ul>
<div class="pb-2">
Featurelist:
<ul class="list-disc pl-6">
<li>Lorem Ipsum is simply dummy text of the printing and typesetting industry.</li>
<li>Lorem Ipsum has been the industry's standard dummy text ever since the 1500s,</li>
<li>
when an unknown printer took a galley of type and scrambled it to make a type specimen book.
</li>
<li>
It has survived not only five centuries, but also the leap into electronic typesetting,
</li>
<li>
remaining essentially unchanged. It was popularised in the 1960s with the release of
Letraset sheets containing Lorem Ipsum passages,
</li>
</ul>
</div>
<div class="pb-2">
Screenshots
<div class="grid w-76 grid-cols-2 grid-rows-2 gap-2">
<img src="https://placehold.co/150x150" alt="temporary placeholder image" />
<img src="https://placehold.co/150x150" alt="temporary placeholder image" />
<img src="https://placehold.co/150x150" alt="temporary placeholder image" />
<img src="https://placehold.co/150x150" alt="temporary placeholder image" />
</div>
</div>
<div class="pb-2">
Screenshots
<div class="grid grid-cols-2 grid-rows-2 w-76 gap-2">
<img src="https://placehold.co/150x150" alt="temporary placeholder image"/>
<img src="https://placehold.co/150x150" alt="temporary placeholder image"/>
<img src="https://placehold.co/150x150" alt="temporary placeholder image"/>
<img src="https://placehold.co/150x150" alt="temporary placeholder image"/>
</div>
</div>
<div class="flex flex-col gap-3 w-[calc(80%)]">
{#if data.user?.id}
<Button href='/app'>Enter</Button>
<Button href='/?logout'>Log out</Button>
{:else}
<Button href='/login'>Log in</Button> <Button href='/register'>Register</Button>
{/if}
</div>
<div class="flex w-[calc(80%)] flex-col gap-3">
{#if data.user?.id}
<Button href="/app">Enter</Button>
<Button href="/?logout">Log out</Button>
{:else}
<Button href="/login">Log in</Button> <Button href="/register">Register</Button>
{/if}
</div>
</div>

View file

@ -0,0 +1,3 @@
export async function GET() {
return new Response('OK!');
}

View file

@ -2,25 +2,23 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const { groupOrServerId, channelId } = params;
const { groupOrServerId, channelId } = params;
const isGroup = !channelId;
const isGroup = !channelId;
// fake messages
const messages = Array.from({ length: 5 }, (_, i) => ({
id: crypto.randomUUID(),
authorId: `user_${Math.floor(Math.random() * 10)}`,
content: isGroup
? `Group message #${i + 1}`
: `Server message #${i + 1}`,
timestamp: Date.now() - Math.floor(Math.random() * 100000),
}));
// fake messages
const messages = Array.from({ length: 5 }, (_, i) => ({
id: crypto.randomUUID(),
authorId: `user_${Math.floor(Math.random() * 10)}`,
content: isGroup ? `Group message #${i + 1}` : `Server message #${i + 1}`,
timestamp: Date.now() - Math.floor(Math.random() * 100000)
}));
return json({
type: isGroup ? 'group' : 'server',
groupId: isGroup ? groupOrServerId : null,
serverId: isGroup ? null : groupOrServerId,
channelId: channelId ?? null,
messages,
});
return json({
type: isGroup ? 'group' : 'server',
groupId: isGroup ? groupOrServerId : null,
serverId: isGroup ? null : groupOrServerId,
channelId: channelId ?? null,
messages
});
};

View file

@ -1,17 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { Status } from '$lib';
import { kvStore } from '$lib/server/db';
export const GET: RequestHandler = async ({ params }) => {
const { userId } = params;
const { userId } = params;
const status = Object.values(Status)[Math.floor(Math.random() * Object.values(Status).length)];
return json({
userId,
status,
lastActive: Date.now() - Math.floor(Math.random() * 600000),
customStatus: Math.random() > 0.5 ? 'vibing 🟢' : null,
});
return json({
userId,
status: kvStore.get('user-' + userId + '-state'),
//@TODO Implement statusmessage
statusMessage: Math.random() > 0.5 ? 'vibing 🟢' : null
});
};

View file

@ -0,0 +1,48 @@
interface SubscribedTo {
subscribed: string[];
userId: string;
controller: ReadableStreamDefaultController;
}
export const _clients = new Map<string, SubscribedTo>();
export function _sendToSubscribers(userId: string, payload: unknown) {
for (const client of _clients) {
if (client[1].subscribed.includes(userId)) {
try {
client[1].controller.enqueue(`data: ${JSON.stringify(payload)}\n\n`);
} catch {
_clients.delete(client[0]);
}
}
}
}
export async function GET({ locals, request }) {
if (!locals.user) {
return new Response('No authentication', { status: 401 });
}
const subscribed = locals.user.friends.map((z) => z.id);
const reqId = crypto.randomUUID();
const stream = new ReadableStream({
start(controller) {
_clients.set(reqId, { subscribed, userId: locals.user!.id, controller });
console.log(`SSE Client opened. ${_clients.size}`);
controller.enqueue(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
request.signal.addEventListener('abort', () => {
_clients.delete(reqId);
console.log(`SSE Client aborted. ${_clients.size}`);
});
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
});
}

View file

@ -6,18 +6,17 @@ import * as table from '$lib/server/db/schema';
import { FriendRequestID, ServerID } from '$lib';
import { eq } from 'drizzle-orm';
import { and } from 'drizzle-orm';
import { User } from '$lib/server/db/schema';
import { type User } from '$lib/server/db/schema';
export const load: PageServerLoad = async () => {
const user = requireLogin();
return { user };
const user = requireLogin();
return { user };
};
export const actions = {
addFriend: async ({ request, locals }) => {
const data = await request.formData();
const username = data.get('username');
const userId = data.get('userId');
addFriend: async ({ request, locals }) => {
const data = await request.formData();
const username = data.get('username');
const userId = data.get('userId');
if (username && !userId) {
if (typeof username !== 'string' || username.length < 3) {
@ -26,162 +25,193 @@ export const actions = {
}
let user: User[];
if(username) {
user = await db.select().from(table.user).where(eq(table.user.username, username.toString())).limit(1);
} else if(userId) {
user = await db.select().from(table.user).where(eq(table.user.id, userId.toString())).limit(1);
} else {
return fail(400, { error: 'Missing username or userId' });
if (username) {
user = await db
.select()
.from(table.user)
.where(eq(table.user.username, username.toString()))
.limit(1);
} else if (userId) {
user = await db
.select()
.from(table.user)
.where(eq(table.user.id, userId.toString()))
.limit(1);
} else {
return fail(400, { error: 'Missing username or userId' });
}
if (locals.user?.friends.find((z) => z.id == user[0].id)) {
return fail(400, { error: 'Already friends' });
}
if(user?.length == 0)
return fail(400, { error: 'User not found' });
if (user?.length == 0) return fail(400, { error: 'User not found' });
const friendRequest = await db.select()
.from(table.friendRequest)
.where(and(eq(table.friendRequest.fromUser, user[0].id), eq(table.friendRequest.toUser, locals.user!.id)))
const friendRequest = await db
.select()
.from(table.friendRequest)
.where(
and(
eq(table.friendRequest.fromUser, user[0].id),
eq(table.friendRequest.toUser, locals.user!.id)
)
)
.limit(1);
// user has already sent a request to us
// means we want to accept it
//
if(friendRequest?.length != 0) {
await db.delete(table.friendRequest)
.where(and(eq(table.friendRequest.fromUser, user[0].id), eq(table.friendRequest.toUser, locals.user!.id)))
// user has already sent a request to us
// means we want to accept it
//
if (friendRequest?.length != 0) {
await db
.delete(table.friendRequest)
.where(
and(
eq(table.friendRequest.fromUser, user[0].id),
eq(table.friendRequest.toUser, locals.user!.id)
)
)
.limit(1);
// add other guy to us
await db.transaction(async (tx) => {
await tx.update(table.user)
.set({ friends: locals.user?.friends.map(z => z.id).concat(user[0].id) })
.where(eq(table.user.id, locals.user!.id));
// add other guy to us
await db.transaction(async (tx) => {
await tx
.update(table.user)
.set({ friends: locals.user?.friends.map((z) => z.id).concat(user[0].id) })
.where(eq(table.user.id, locals.user!.id));
await tx.update(table.user)
.set({ friends: (user[0].friends as string[]).concat(locals.user!.id) })
.where(eq(table.user.id, user[0].id));
});
await tx
.update(table.user)
.set({ friends: (user[0].friends as string[]).concat(locals.user!.id) })
.where(eq(table.user.id, user[0].id));
});
return {success: true}
}
return { success: true };
}
// a request from us has already been sent to user
if(locals.user?.friendRequests.find(z => z.toUser == user[0].id && z.fromUser == locals.user!.id))
return fail(400, { error: 'Already sent request' });
// a request from us has already been sent to user
if (
locals.user?.friendRequests.find(
(z) => z.toUser == user[0].id && z.fromUser == locals.user!.id
)
)
return fail(400, { error: 'Already sent request' });
await db.insert(table.friendRequest).values({
id: FriendRequestID.newV4(),
fromUser: locals.user!.id,
toUser: user[0].id,
});
await db.insert(table.friendRequest).values({
id: FriendRequestID.newV4(),
fromUser: locals.user!.id,
toUser: user[0].id
});
return { success: true };
},
cancelFriendRequest: async ({ request, locals }) => {
const data = await request.formData();
const requestId = data.get('requestId');
return { success: true };
},
cancelFriendRequest: async ({ request, locals }) => {
const data = await request.formData();
const requestId = data.get('requestId');
if (typeof requestId !== 'string') {
return fail(400, { error: 'Invalid request ID' });
}
if (typeof requestId !== 'string') {
return fail(400, { error: 'Invalid request ID' });
}
// fetch the friend request
const friendRequest = await db.select()
.from(table.friendRequest)
.where(eq(table.friendRequest.id, requestId))
.limit(1);
// fetch the friend request
const friendRequest = await db
.select()
.from(table.friendRequest)
.where(eq(table.friendRequest.id, requestId))
.limit(1);
if (!friendRequest?.length) {
return fail(404, { error: 'Friend request not found' });
}
if (!friendRequest?.length) {
return fail(404, { error: 'Friend request not found' });
}
const fr = friendRequest[0];
const fr = friendRequest[0];
// only allow cancelling if it's related to current user
if (fr.fromUser !== locals.user!.id && fr.toUser !== locals.user!.id) {
return fail(403, { error: 'Not allowed' });
}
// only allow cancelling if it's related to current user
if (fr.fromUser !== locals.user!.id && fr.toUser !== locals.user!.id) {
return fail(403, { error: 'Not allowed' });
}
// delete the request
await db.delete(table.friendRequest)
.where(eq(table.friendRequest.id, requestId))
.limit(1);
// delete the request
await db.delete(table.friendRequest).where(eq(table.friendRequest.id, requestId)).limit(1);
return { success: true };
},
createGroup: async ({ request, locals }) => {
const data = await request.formData();
const members = data.getAll('member');
return { success: true };
},
createGroup: async ({ request, locals }) => {
const data = await request.formData();
const members = data.getAll('member');
if (!members.length) {
return fail(400, { error: 'No members selected' });
}
if (!members.length) {
return fail(400, { error: 'No members selected' });
}
console.log(data, members, locals)
return { success: true };
},
return { success: true };
},
joinServer: async ({ request, locals }) => {
const data = await request.formData();
const invite = data.get('invite');
joinServer: async ({ request, locals }) => {
const data = await request.formData();
const invite = data.get('invite');
if (typeof invite !== 'string') {
return fail(400, { error: 'Invalid invite' });
}
if (typeof invite !== 'string') {
return fail(400, { error: 'Invalid invite' });
}
const inv = await db.select().from(table.invite).where(eq(table.invite.code, invite)).limit(1);
const inv = await db.select().from(table.invite).where(eq(table.invite.code, invite)).limit(1);
if (inv?.length == 0) return fail(400, { error: 'Invalid invite' });
if(inv?.length == 0)
return fail(400, { error: 'Invalid invite' });
const server = await db
.select()
.from(table.server)
.where(eq(table.server.id, inv[0].serverId))
.limit(1);
const server = await db.select().from(table.server).where(eq(table.server.id, inv[0].serverId)).limit(1);
if (server?.length == 0) return fail(400, { error: 'Invalid server' });
if(server?.length == 0)
return fail(400, { error: 'Invalid server' });
if(locals.user!.servers.some(z => z.id == server[0].id))
return fail(400, { error: 'Already in server' });
if (locals.user!.servers.some((z) => z.id == server[0].id))
return fail(400, { error: 'Already in server' });
await db.transaction(async (tx) => {
await tx.update(table.user)
.set({servers: locals.user!.servers.map(z => z.id).concat([server[0].id])})
.where(eq(table.user.id, locals.user!.id));
await tx
.update(table.user)
.set({ servers: locals.user!.servers.map((z) => z.id).concat([server[0].id]) })
.where(eq(table.user.id, locals.user!.id));
await tx.update(table.server)
.set({members: (server[0].members as string[]).concat([locals.user!.id])})
.where(eq(table.server.id, server[0].id));
})
await tx
.update(table.server)
.set({ members: (server[0].members as string[]).concat([locals.user!.id]) })
.where(eq(table.server.id, server[0].id));
});
return { success: true };
},
return { success: true };
},
createServer: async ({ request, locals }) => {
const data = await request.formData();
const name = data.get('name');
createServer: async ({ request, locals }) => {
const data = await request.formData();
const name = data.get('name');
if (typeof name !== 'string' || name.length < 3) {
return fail(400, { error: 'Server name too short' });
}
if (typeof name !== 'string' || name.length < 3) {
return fail(400, { error: 'Server name too short' });
}
const serverId = ServerID.newV4();
await db.insert(table.server)
.values({id: serverId, name, owner: locals.user!.id, members: [ locals.user!.id ]});
await db
.insert(table.server)
.values({ id: serverId, name, owner: locals.user!.id, members: [locals.user!.id] });
await db.update(table.user)
.set({servers: locals.user!.servers.map(z => z.id).concat([serverId])})
.where(eq(table.user.id, locals.user!.id));
await db
.update(table.user)
.set({ servers: locals.user!.servers.map((z) => z.id).concat([serverId]) })
.where(eq(table.user.id, locals.user!.id));
redirect(303, `/app`);
}
redirect(303, `/app`);
}
} satisfies Actions;
function requireLogin() {
const { locals } = getRequestEvent();
const { locals } = getRequestEvent();
if (!locals.user) {
return redirect(302, '/login');
}
if (!locals.user) {
return redirect(302, '/login');
}
return locals.user;
return locals.user;
}

View file

@ -1,18 +1,27 @@
<script lang="ts">
import { Status, type OverviewData,
GroupID, UserID, ServerID,
type GroupId, type ServerId, type UserId,
type OverviewUser, type OverviewGroup, type OverviewServer,
type UserWithStatus} from "$lib";
import {
Status,
type OverviewData,
GroupID,
UserID,
ServerID,
type GroupId,
type ServerId,
type UserId,
type OverviewUser,
type OverviewGroup,
type OverviewServer,
type UserWithStatus
} from '$lib';
import type { PageServerData } from './$types';
import AppSidebar from "$lib/components/app-sidebar.svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { onMount } from "svelte";
import type { ActionData } from './$types';
import AppSidebar from '$lib/components/app-sidebar.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import { onMount } from 'svelte';
import type { ActionData } from './$types';
let errorOpen = $state(true);
let { form, data }: { form: ActionData, data: PageServerData } = $props();
let currentPageID: (UserId|GroupId|ServerId)|null = $state(null);
let { form, data }: { form: ActionData; data: PageServerData } = $props();
let currentPageID: (UserId | GroupId | ServerId) | null = $state(null);
let currentPage: OverviewUser | OverviewGroup | OverviewServer | undefined = $state();
const overview_data: OverviewData = $state({
@ -21,84 +30,101 @@
servers: []
});
console.log(form, data, overview_data)
$effect(() => {
if (currentPageID) {
if(UserID.is(currentPageID)) {
currentPage = overview_data.friends.find(friend => friend.id === currentPageID);
} else if(GroupID.is(currentPageID)) {
currentPage = overview_data.groups.find(group => group.id === currentPageID);
} else if(ServerID.is(currentPageID)) {
currentPage = overview_data.servers.find(server => server.id === currentPageID);
if (UserID.is(currentPageID)) {
currentPage = overview_data.friends.find((friend) => friend.id === currentPageID);
} else if (GroupID.is(currentPageID)) {
currentPage = overview_data.groups.find((group) => group.id === currentPageID);
} else if (ServerID.is(currentPageID)) {
currentPage = overview_data.servers.find((server) => server.id === currentPageID);
}
} else {
currentPage = undefined;
}
});
onMount(()=>{
onMount(() => {
async function run() {
overview_data.servers = data.user.servers.map(z => {
overview_data.servers = data.user.servers.map((z) => {
return {
id: ServerID.parse(z.id),
name: z.name,
ownerId: z.ownerId,
image: "https://api.dicebear.com/7.x/pixel-art/svg?seed=" + z.name,
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + z.name
};
})
overview_data.groups = data.user.groups.map(z => {
});
overview_data.groups = data.user.groups.map((z) => {
return {
id: GroupID.parse(z.id),
name: z.name,
ownerId: z.ownerId,
members: z.members,
image: "https://api.dicebear.com/7.x/pixel-art/svg?seed=" + z.name,
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + z.name
};
})
});
overview_data.friends = await Promise.all(
data.user.friends.map(async (friend) => {
const res = await fetch(`/api/status/${friend.id}`);
data.user.friends.map(async (friend) => {
const res = await fetch(`/api/status/${friend.id}`);
if (!res.ok) {
throw new Error(`Failed to fetch status for ${friend.id}`);
}
if (!res.ok) {
throw new Error(`Failed to fetch status for ${friend.id}`);
}
const status = await res.json();
const status = await res.json();
return {
id: UserID.parse(friend.id),
username: friend.username,
status: status.status,
customStatus: status.customStatus,
image: "https://api.dicebear.com/7.x/pixel-art/svg?seed=" + friend.username
};
})
return {
id: UserID.parse(friend.id),
username: friend.username,
status: status.status,
statusMessage: status.statusMessage,
image: 'https://api.dicebear.com/7.x/pixel-art/svg?seed=' + friend.username
};
})
);
}
run();
});
onMount(() => {
const sse = new EventSource('/api/updates');
sse.addEventListener('message', (e) => {
const json = JSON.parse(e.data) as
| { type: 'connected' }
| { type: 'status'; id: string; status: 1 | 2 | 3 };
if (json.type == 'connected') {
console.log('SSE connected.');
}
if (json.type == 'status') {
const friend = overview_data.friends.find((z) => z.id == json.id);
if (friend) {
friend.status = json.status;
}
}
});
});
</script>
{#if form}
<AlertDialog.Root bind:open={errorOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{form?.error ? "Ran into an error." : "Success!"}</AlertDialog.Title>
<AlertDialog.Description>
{form?.error || "Action completed succesfully."}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Close</AlertDialog.Cancel>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<AlertDialog.Root bind:open={errorOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>{form?.error ? 'Ran into an error.' : 'Success!'}</AlertDialog.Title>
<AlertDialog.Description>
{form?.error || 'Action completed succesfully.'}
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Close</AlertDialog.Cancel>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{/if}
<Sidebar.Provider>
<AppSidebar bind:currentPage={currentPageID} user={data.user} data={overview_data} />
@ -107,24 +133,32 @@
<Sidebar.Trigger class="-ms-1" />
{#if currentPageID && currentPage}
{#if ServerID.is(currentPageID)}
{@const server = (currentPage as OverviewServer)}
{@const server = currentPage as OverviewServer}
<img src={server!.image} alt={server!.name} class="size-6 rounded-full" />
<img src={server!.image} alt={server!.name} class="size-6 rounded-full" />
<h1>{server!.name}</h1>
<h1>{server!.name}</h1>
{:else if UserID.is(currentPageID)}
{@const friend = (currentPage as UserWithStatus)}
{@const friend = currentPage as UserWithStatus}
<img src={friend.image} alt={friend!.username} class="size-6 rounded-full" />
<img src={friend.image} alt={friend!.username} class="size-6 rounded-full" />
<h1>{friend!.username} [{friend.status == Status.ONLINE ? "Online!" : friend.status == Status.DND ? "DND" : friend.status == Status.OFFLINE ? "Offline" : "Unknown"}]</h1>
<h1>
{friend!.username} [{friend.status == Status.ONLINE
? 'Online!'
: friend.status == Status.DND
? 'DND'
: friend.status == Status.OFFLINE
? 'Offline'
: 'Unknown'}]
</h1>
{:else if GroupID.is(currentPageID)}
{@const group = (currentPage as OverviewGroup)}
{@const group = currentPage as OverviewGroup}
<h1>{group!.name} ({group.members} member{group.members > 1 ? "s" : ""})</h1>
<h1>{group!.name} ({group.members} member{group.members > 1 ? 's' : ''})</h1>
{/if}
{/if}
</header>
<h1> this is like lowkirkounely the content, i should put messages and shi here</h1>
<h1>this is like lowkirkounely the content, i should put messages and shi here</h1>
</Sidebar.Inset>
</Sidebar.Provider>

View file

@ -7,56 +7,59 @@ 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 {};
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');
login: async (event) => {
const formData = await event.request.formData();
const username = formData.get('username');
const password = formData.get('password');
let username_is_email = false;
if (!auth.validateUsername(username)) {
if (!auth.validateUsername(username)) {
if (auth.validateEmail(username)) {
username_is_email = true;
} else {
return fail(400, {
message: 'Invalid username (min 3, max 31 characters, alphanumeric only)'
});
return fail(400, {
message: 'Invalid username (min 3, max 31 characters, alphanumeric only)'
});
}
}
if (!auth.validatePassword(password)) {
return fail(400, { message: 'Invalid password (min 6, max 255 characters)' });
}
}
if (!auth.validatePassword(password)) {
return fail(400, { message: 'Invalid password (min 6, max 255 characters)' });
}
const results = await db.select()
.from(table.user)
.where(username_is_email ? eq(table.user.email, username) : eq(table.user.username, username));
const results = await db
.select()
.from(table.user)
.where(
username_is_email ? eq(table.user.email, username) : eq(table.user.username, username)
);
const existingUser = results.at(0);
if (!existingUser) {
return fail(400, { message: 'Incorrect username or password' });
}
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 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);
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, existingUser.id);
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
return redirect(302, '/app');
}
return redirect(302, '/app');
}
};

View file

@ -1,26 +1,24 @@
<script lang="ts">
import Title from "$lib/components/extra/Title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import type { ActionData } from './$types';
import Title from '$lib/components/extra/Title.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let { form }: { form: ActionData } = $props();
</script>
<div class="flex min-h-screen flex-col items-center justify-center gap-2">
<div class='w-1/2 p-2 bg-secondary rounded-md '>
<div class="text-center pb-2">
<Title class="text-2xl"></Title>
</div>
<form method="post" action="?/login">
<Input name="username" class="mb-2" type="text" placeholder="Username or E-mail"></Input>
<Input name="password" class="mb-2" type="password" placeholder="Password"></Input>
<Button type="submit" formaction="?/login" class="w-full">Login</Button>
</form>
<p style="color: red">{form?.message ?? ''}</p>
<div class="w-1/2 rounded-md bg-secondary p-2">
<div class="pb-2 text-center">
<Title class="text-2xl"></Title>
</div>
<form method="post" action="?/login">
<Input name="username" class="mb-2" type="text" placeholder="Username or E-mail"></Input>
<Input name="password" class="mb-2" type="password" placeholder="Password"></Input>
<Button type="submit" formaction="?/login" class="w-full">Login</Button>
</form>
<p style="color: red">{form?.message ?? ''}</p>
</div>
</div>

View file

@ -9,59 +9,58 @@ import { eq } from 'drizzle-orm';
import { UserID } from '$lib';
export const load: PageServerLoad = async (event) => {
if (event.locals.user) {
return redirect(302, '/demo/lucia');
}
return {};
if (event.locals.user) {
return redirect(302, '/demo/lucia');
}
return {};
};
export const actions: Actions = {
register: async (event) => {
const formData = await event.request.formData();
const username = formData.get('username');
const email = formData.get('email');
const password = formData.get('password');
register: async (event) => {
const formData = await event.request.formData();
const username = formData.get('username');
const email = formData.get('email');
const password = formData.get('password');
if (!auth.validateUsername(username)) {
return fail(400, { message: 'Invalid username' });
}
if (!auth.validateUsername(username)) {
return fail(400, { message: 'Invalid username' });
}
if(!auth.validateEmail(email)) {
return fail(400, { message: 'Invalid email' });
}
if (!auth.validateEmail(email)) {
return fail(400, { message: 'Invalid email' });
}
if (!auth.validatePassword(password)) {
return fail(400, { message: 'Invalid password' });
}
if (!auth.validatePassword(password)) {
return fail(400, { message: 'Invalid password' });
}
const userId = UserID.newV4();
const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const results = await db.select()
.from(table.user)
.where(or(eq(table.user.email, email),
eq(table.user.username, username)));
const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const results = await db
.select()
.from(table.user)
.where(or(eq(table.user.email, email), eq(table.user.username, username)));
const existingUser = results.at(0);
if (existingUser) {
return fail(400, { message: 'Username or email already registered!' });
}
const existingUser = results.at(0);
if (existingUser) {
return fail(400, { message: 'Username or email already registered!' });
}
try {
await db.insert(table.user)
.values({ id: userId, email, username, passwordHash });
try {
await db.insert(table.user).values({ id: userId, email, username, passwordHash });
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, userId);
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
} catch {
return fail(500, { message: 'An error has occurred' });
}
return redirect(302, '/app');
}
const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, userId);
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
} catch {
return fail(500, { message: 'An error has occurred' });
}
return redirect(302, '/app');
}
};

View file

@ -1,25 +1,25 @@
<script lang="ts">
import Title from "$lib/components/extra/Title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import type { ActionData } from './$types';
import Title from '$lib/components/extra/Title.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import Input from '$lib/components/ui/input/input.svelte';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let { form }: { form: ActionData } = $props();
</script>
<div class="flex min-h-screen flex-col items-center justify-center gap-2">
<div class='w-1/2 p-2 bg-secondary rounded-md '>
<div class="text-center pb-2">
<Title class="text-2xl"></Title>
</div>
<form method="post" action="?/register">
<Input name="username" class="mb-2" type="text" placeholder="Username"></Input>
<Input name="email" class="mb-2" type="email" placeholder="E-mail"></Input>
<Input name="password" class="mb-2" type="password" placeholder="Password"></Input>
<Button type="submit" formaction="?/register" class="w-full">Register</Button>
</form>
<p style="color: red">{form?.message ?? ''}</p>
<div class="w-1/2 rounded-md bg-secondary p-2">
<div class="pb-2 text-center">
<Title class="text-2xl"></Title>
</div>
<form method="post" action="?/register">
<Input name="username" class="mb-2" type="text" placeholder="Username"></Input>
<Input name="email" class="mb-2" type="email" placeholder="E-mail"></Input>
<Input name="password" class="mb-2" type="password" placeholder="Password"></Input>
<Button type="submit" formaction="?/register" class="w-full">Register</Button>
</form>
<p style="color: red">{form?.message ?? ''}</p>
</div>
</div>