basic db boilerplate + type safety for api

This commit is contained in:
not-nullptr 2024-03-10 03:18:28 +00:00
parent b91e72626f
commit c5686166d7
15 changed files with 269 additions and 12 deletions

1
package-lock.json generated
View file

@ -11,6 +11,7 @@
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
"better-sqlite3": "^9.4.3",
"reflect-metadata": "^0.2.1",
"sequelize": "^6.37.1",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.20",

View file

@ -43,6 +43,7 @@
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
"better-sqlite3": "^9.4.3",
"reflect-metadata": "^0.2.1",
"sequelize": "^6.37.1",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.20",

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

@ -0,0 +1,11 @@
import { db } from "$lib/server/db";
import "reflect-metadata";
import { building } from "$app/environment";
const runAllTheInitFunctions = async () => {
await db.initialize();
};
if (!building) {
await runAllTheInitFunctions();
}

View file

@ -25,7 +25,30 @@ h4,
h5,
h6 {
font-family: var(--header-font) !important;
font-size: 64px;
}
h1 {
font-size: 48px;
}
h2 {
font-size: 36px;
}
h3 {
font-size: 24px;
}
h4 {
font-size: 18px;
}
h5 {
font-size: 16px;
}
h6 {
font-size: 14px;
}
body {
@ -70,3 +93,26 @@ button:active {
transition: 0.05s ease-in-out filter;
filter: brightness(0.9);
}
.panel-blur {
background: color-mix(in srgb, var(--color-primary), transparent 50%);
border: var(--border-primary);
border-radius: 16px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 0 48px 0 rgba(39, 56, 75, 0.5);
}
input[type="text"],
input[type="password"] {
padding: 8px 16px;
background-color: var(--color-primary);
border: var(--border-primary);
border-radius: 16px;
color: var(--text-color);
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: solid 2px var(--text-color);
}

View file

@ -1,6 +1,11 @@
import { DataSource } from "typeorm";
import { SuyuUser } from "../schema";
export const db = new DataSource({
type: "better-sqlite3",
database: "db.sqlite",
entities: [SuyuUser],
synchronize: true,
subscribers: [],
migrations: [],
});

View file

@ -0,0 +1,4 @@
import { db } from "../db";
import { SuyuUser } from "../schema";
export const userRepo = db.getRepository(SuyuUser);

View file

@ -1,20 +1,25 @@
import type { Role } from "$types/db";
import { Column, Entity, PrimaryColumn } from "typeorm";
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class SuyuUser {
@PrimaryColumn()
export class SuyuUser extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
@Column("text")
username: string;
@Column()
@Column("text")
displayName: string;
@Column()
@Column("text")
avatarUrl: string;
@Column()
roles: Role[];
@Column("text")
roles: string;
@Column("text", {
select: false,
})
apiKey: string;
}

View file

@ -0,0 +1,17 @@
import type { Role } from "$types/db";
export function json<T>(body: T): Response {
return new Response(JSON.stringify(body), {
headers: {
"content-type": "application/json;charset=UTF-8",
},
});
}
export function serializeRoles(roles: Role[]): string {
return roles.join("|");
}
export function deserializeRoles(roles: string): Role[] {
return roles.split("|") as Role[];
}

15
src/lib/util/api/index.ts Normal file
View file

@ -0,0 +1,15 @@
import { userRepo } from "$lib/server/repo";
import type { SuyuUser } from "$lib/server/schema";
export async function useAuth(request: Request | string): Promise<SuyuUser | null> {
const apiKey = typeof request === "string" ? request : request.headers.get("Authorization");
if (!apiKey) {
return null;
}
const user = await userRepo.findOne({
where: {
apiKey,
},
});
return user;
}

View file

@ -16,7 +16,7 @@
</script>
{#if !isNavExcluded}
<div class="header">
<div class="header panel-blur">
<div class="left">
<LogoWithTextHorizontal on:click={() => goto("/")} size={50} />
</div>
@ -66,10 +66,10 @@
width: 100%;
height: 80px;
background-color: color-mix(in srgb, var(--color-primary), transparent 50%);
border: none;
border-radius: 0;
border-bottom: var(--border-primary);
z-index: 1000;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
align-items: center;
display: flex;
padding: 0 32px 0 16px;

View file

@ -0,0 +1,10 @@
import { useAuth } from "$lib/util/api";
export async function load(opts) {
const apiKey = opts.cookies.get("api_key");
const user = await useAuth(apiKey || "unused");
return {
user: { ...user },
a: "B",
};
}

View file

@ -0,0 +1,68 @@
<script lang="ts">
import { onMount } from "svelte";
import { SuyuAPI } from "$lib/client/api";
import type { CreateAccountResponse } from "$types/api";
import type { PageData } from "./$types";
export let data: PageData;
let usernameToCreate: string;
async function createAccount() {
const response = await SuyuAPI.users.createAccount({ username: usernameToCreate });
if (response.success) {
data = {
...(data || {}),
user: response.user,
};
// add api_key cookie
document.cookie = `api_key=${response.token}; path=/`;
} else {
alert("Failed to create account: " + response.error);
window.location.reload();
}
}
</script>
<div class="panel-blur main-panel">
<h2>Account Settings</h2>
<p>
{#if data?.user}
<p>Username: {data.user.username}</p>
{:else}
<p>
It appears you don't have an account; please register one to access suyu's online
services.
</p>
<div class="create-account">
<input bind:value={usernameToCreate} type="text" placeholder="Username" />
<button on:click={createAccount}>Create Account</button>
</div>
{/if}
</p>
</div>
<style>
.main-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, calc(-50% + 40px));
width: calc(100% - 120px);
height: calc(100% - 240px);
max-height: 600px;
min-height: 400px;
max-width: 1000px;
padding: 28px 36px;
padding-top: 12px;
}
.main-panel > h2 {
margin-bottom: 16px;
margin-top: 16px;
}
.create-account {
margin-top: 16px;
}
</style>

View file

@ -0,0 +1,45 @@
// TODO: refactor into external utils (ie Suyu.createAccount() or something???)
import { userRepo } from "$lib/server/repo";
import type { SuyuUser } from "$lib/server/schema";
import { json, serializeRoles } from "$lib/server/util";
import type { CreateAccountRequest, CreateAccountResponse } from "$types/api";
import crypto from "crypto";
import { promisify } from "util";
const randomBytes = promisify(crypto.randomBytes);
export async function POST({ request }) {
const body: CreateAccountRequest = await request.json();
if (!body.username) {
return json<CreateAccountResponse>({
success: false,
error: "username is required",
});
}
// check if user exists
const user = await userRepo.findOne({
where: {
username: body.username,
},
});
if (user) {
return json<CreateAccountResponse>({
success: false,
error: "username already exists",
});
}
const createdUser: SuyuUser = userRepo.create({
username: body.username,
avatarUrl: `https://avatars.githubusercontent.com/u/${Math.floor(Math.random() * 100000000)}?v=4`,
displayName: body.username,
roles: serializeRoles(["user"]),
apiKey: `${body.username}:${(await randomBytes(32)).toString("hex")}`,
});
await userRepo.save(createdUser);
return json<CreateAccountResponse>({
success: true,
token: createdUser.apiKey,
user: createdUser,
});
}

View file

@ -0,0 +1,17 @@
import { json } from "$lib/server/util";
import { useAuth } from "$lib/util/api";
import type { GetUserResponse } from "$types/api";
export async function GET({ request }) {
const user = await useAuth(request);
if (!user) {
return json<GetUserResponse>({
success: false,
error: "unauthorized",
});
}
return json<GetUserResponse>({
success: true,
user,
});
}

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

@ -18,3 +18,15 @@ export interface CreateAccountResponseFailure {
}
export type CreateAccountResponse = CreateAccountResponseSuccess | CreateAccountResponseFailure;
export interface GetUserResponseSuccess {
success: true;
user: SuyuUser;
}
export interface GetUserResponseFailure {
success: false;
error: string;
}
export type GetUserResponse = GetUserResponseSuccess | GetUserResponseFailure;