initial commit, lots of random shit

This commit is contained in:
Erin 2023-11-11 19:09:24 -05:00
commit 7bb18f0a71
27 changed files with 3394 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

13
LICENSE.txt Normal file
View file

@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

1
README.md Normal file
View file

@ -0,0 +1 @@
doofus

35
dprint.json Normal file
View file

@ -0,0 +1,35 @@
{
"typescript": {
"lineWidth": 80,
"useTabs": true,
"indentWidth": 4,
"quoteStyle": "alwaysSingle",
"quoteProps": "consistent",
"useBraces": "always",
"arrowFunction.useParentheses": "preferNone",
"enumDeclaration.memberSpacing": "newLine",
"spaceSurroundingProperties": false,
"exportDeclaration.spaceSurroundingNamedExports": false,
"importDeclaration.spaceSurroundingNamedImports": false,
"constructor.spaceBeforeParentheses": true,
"functionDeclaration.spaceBeforeParentheses": true,
"functionExpression.spaceBeforeParentheses": true,
"getAccessor.spaceBeforeParentheses": true,
"setAccessor.spaceBeforeParentheses": true,
"method.spaceBeforeParentheses": true
},
"json": {
"useTabs": true,
"indentWidth": 4
},
"markdown": {},
"excludes": [
"**/node_modules",
"**/*-lock.json"
],
"plugins": [
"https://plugins.dprint.dev/typescript-0.86.1.wasm",
"https://plugins.dprint.dev/json-0.17.4.wasm",
"https://plugins.dprint.dev/markdown-0.15.3.wasm"
]
}

2655
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

31
package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "graph-editor",
"version": "0.0.0",
"private": true,
"license": "WTFPL",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"keygen": "node -p \"crypto.randomBytes(32).toString('hex')\""
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/kit": "^1.20.4",
"dprint": "^0.42.5",
"svelte": "^4.0.5",
"svelte-check": "^3.4.3",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^4.4.2"
},
"type": "module",
"dependencies": {
"pg": "^8.11.3",
"typeorm": "^0.3.17",
"typeorm-encrypted": "^0.8.0",
"ulid": "^2.3.0"
}
}

17
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,17 @@
// See https://kit.svelte.dev/docs/types#app
import type {DataSource} from 'typeorm';
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
db: DataSource;
}
// interface PageData {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,142 @@
// stolen from
// https://github.com/r-anime/misato/blob/main/src/web/routes/discordAuth.js but
// it's fine to relicense because im the only one who ever contributed to that
// file lmao. also hopefully i will rewrite this to be better at extending to
// other oauth providers in the future
import {env} from '$env/dynamic/private';
/** The redirect URI for Discord to send the user back to. */
const discordRedirectURI = `${env.HOST}/auth/discord/callback`;
/** The base of the URI that starts the OAuth flow. State is attached later. */
// Long URIs suck
const discordAuthURIBase = 'https://discordapp.com/api/oauth2/authorize'
+ `?client_id=${env.DISCORD_CLIENT_ID}`
+ '&response_type=code'
+ `&redirect_uri=${encodeURIComponent(discordRedirectURI)}`
+ `&scope=${encodeURIComponent(['identify', 'guilds'].join(' '))}`
+ '&prompt=consent'; // Special Discord-only thing to always show prompt
/** Generates an auth URI to redirect the user to given a state. */
function authURI (state: string) {
return `${discordAuthURIBase}&state=${encodeURIComponent(state)}`;
}
/** Generates a formdata body from key-value pairs. */
function formData (content: Record<string, string>) {
return Object.entries(content)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&');
}
/**
* Generates an avatar URL for the user. Discord avatars are weird so I split
* this out.
* @see https://discordapp.com/developers/docs/reference#image-formatting
*/
export function discordAvatarURL (userInfo: {
id: string;
avatar?: string;
discriminator: string;
}) {
if (userInfo.avatar) {
return `https://cdn.discordapp.com/avatars/${userInfo.id}/${userInfo.avatar}.png`;
}
return `https://cdn.discordapp.com/embed/avatars/${
parseInt(userInfo.discriminator, 10) % 5
}.png`;
}
/** OAuth tokens returned by Discord. */
export interface DiscordTokens {
/** The access token used to authorize to Discord */
accessToken: string;
/** The refresh token used to get a new access token when the current one expires */
refreshToken: string;
/** The string "Bearer"; other token types aren't relevant here */
tokenType: string;
/** space-separated list of authorized scopes */
scope: string;
/** Expiration date of the access token */
expiresAt: Date;
}
/** Exchanges a code for an access/refresh token pair. */
export async function fetchDiscordTokens (
code: string,
): Promise<DiscordTokens> {
const response = await fetch('https://discordapp.com/api/oauth2/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData({
client_id: env.DISCORD_CLIENT_ID,
client_secret: env.DISCORD_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: discordRedirectURI,
scope: 'identify', // Discord-specific: scope is required here too and must match
}),
});
if (response.status !== 200) {
throw new Error(
`Discord gave non-200 response status when requesting tokens: ${response.status}`,
);
}
const data = await response.json();
if (data.error) {
throw new Error(
`Discord gave an error when requesting tokens: ${data.error}`,
);
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
tokenType: data.token_type,
scope: data.scope,
expiresAt: new Date(Date.now() + data.expires_in * 1000),
};
}
/** Information about a Discord user. */
export interface DiscordUserInfo {
id: string;
username: string;
discriminator: string;
avatarURL: string;
}
/**
* Fetches information about the user given their access token.
* @param {string} accessToken
* @returns {Promise<object>} Object has keys `name`, `avatarURL`, and
* `created`.
*/
export async function fetchDiscordUserInfo (
fetch: any,
accessToken: string,
): Promise<DiscordUserInfo> {
const response = await fetch('https://discordapp.com/api/v6/users/@me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.status !== 200) {
throw new Error(
`Discord gave non-200 status when fetching user info: ${response.status}`,
);
}
const data = await response.json();
return {
username: data.username,
discriminator: data.discriminator,
id: data.id,
avatarURL: discordAvatarURL(data),
};
}

33
src/lib/server/db.ts Normal file
View file

@ -0,0 +1,33 @@
import {DataSource} from 'typeorm';
import {AuthMethod} from '$lib/server/entity/AuthMethod';
import {AuthSession} from '$lib/server/entity/AuthSession';
import {Edge} from '$lib/server/entity/Edge';
import {Node} from '$lib/server/entity/Node';
import {User} from '$lib/server/entity/User';
import {env} from '$env/dynamic/private';
import {AuthState} from './entity/AuthState';
const dataSource = new DataSource({
type: 'postgres',
url: env.DB_URL,
entities: [
AuthMethod,
AuthSession,
AuthState,
Node,
Edge,
User,
],
synchronize: true,
logging: false,
});
// initialize the data source immediately
const dataSourceInitializedPromise = dataSource.initialize();
export async function getDataSource () {
await dataSourceInitializedPromise;
return dataSource;
}

View file

@ -0,0 +1,28 @@
import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
import {ulidMonotonic} from '$lib/server/ulid';
import {User} from './User';
export enum AuthProvider {
DISCORD,
}
@Entity()
export class AuthMethod {
@PrimaryColumn({type: 'varchar', length: 26})
id = ulidMonotonic();
@Column({type: 'enum', enum: AuthProvider, nullable: false})
provider!: AuthProvider;
@Column({type: 'varchar', length: 500, nullable: false})
userIdentifier!: string;
@ManyToOne(() => User, user => user.authMethods)
user!: User;
@BeforeInsert()
private beforeInsert () {
this.id = ulidMonotonic();
}
}

View file

@ -0,0 +1,54 @@
import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
import {EncryptionTransformer} from 'typeorm-encrypted';
import {AuthProvider} from '$lib/server/entity/AuthMethod';
import {ulidMonotonic} from '$lib/server/ulid';
import {env} from '$env/dynamic/private';
@Entity()
export class AuthSession {
/** The ID of the session */
// TODO should we be using ulids for this or something totally random
@PrimaryColumn({type: 'varchar', length: 26, nullable: false})
id = ulidMonotonic();
/** The authentication provider used to start this session */
@Column({type: 'enum', enum: AuthProvider, nullable: false})
provider!: AuthProvider;
/** The access token */
@Column({
type: 'varchar',
length: 300,
nullable: false,
transformer: new EncryptionTransformer({
algorithm: 'aes-256-cbc',
key: env.ENCRYPTION_KEY!,
ivLength: 16,
}),
})
accessToken!: string;
/** The refresh token */
@Column({
type: 'varchar',
length: 300,
nullable: true,
transformer: new EncryptionTransformer({
algorithm: 'aes-256-cbc',
key: env.ENCRYPTION_KEY!,
ivLength: 16,
}),
})
refreshToken!: string;
/** When the access token expires */
@Column({type: 'timestamptz', nullable: true})
accessTokenExpiresAt!: Date;
@BeforeInsert()
private beforeInsert () {
this.id = ulidMonotonic();
}
}

View file

@ -0,0 +1,20 @@
import * as crypto from 'node:crypto';
import {BeforeInsert, Column, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
import {AuthProvider} from '$lib/server/entity/AuthMethod';
import {ulidMonotonic} from '$lib/server/ulid';
@Entity()
export class AuthState {
/** The ID of the state session */
@PrimaryColumn({type: 'varchar', length: 26, nullable: false})
id = ulidMonotonic();
/** The authentication provider this state has been sent to */
@Column({type: 'enum', enum: AuthProvider, nullable: false})
provider!: AuthProvider;
/** The state value itself */
@Column({type: 'varchar', length: 32, nullable: false})
state = crypto.randomBytes(16).toString('hex');
}

View file

@ -0,0 +1,29 @@
import {BeforeInsert, Entity, ManyToOne, PrimaryColumn} from 'typeorm';
import {Node} from '$lib/server/entity/Node';
import {ulidMonotonic} from '$lib/server/ulid';
@Entity()
export class Edge {
@PrimaryColumn({type: 'varchar', length: 26})
id = ulidMonotonic();
@ManyToOne(() => Node, node => node.outgoingEdges, {
nullable: false,
cascade: true,
onDelete: 'CASCADE',
})
head!: Node;
@ManyToOne(() => Node, node => node.incomingEdges, {
nullable: false,
cascade: true,
onDelete: 'CASCADE',
})
tail!: Node;
@BeforeInsert()
beforeInsert () {
this.id = ulidMonotonic();
}
}

View file

@ -0,0 +1,30 @@
import {BeforeInsert, Column, Entity, OneToMany, PrimaryColumn} from 'typeorm';
import {Edge} from '$lib/server/entity/Edge';
import {ulidMonotonic} from '$lib/server/ulid';
@Entity()
export class Node {
@PrimaryColumn({type: 'varchar', length: 26})
id = ulidMonotonic();
@Column({type: 'varchar', length: 500, nullable: false})
name!: string;
@OneToMany(type => Edge, edge => edge.head, {
nullable: false,
onDelete: 'CASCADE',
})
outgoingEdges!: Edge[];
@OneToMany(type => Edge, edge => edge.tail, {
nullable: false,
onDelete: 'CASCADE',
})
incomingEdges!: Edge[];
@BeforeInsert()
private beforeInsert () {
this.id = ulidMonotonic();
}
}

View file

@ -0,0 +1,21 @@
import {BeforeInsert, Column, Entity, OneToMany, PrimaryColumn} from 'typeorm';
import {ulidMonotonic} from '$lib/server/ulid';
import {AuthMethod} from './AuthMethod';
@Entity()
export class User {
@PrimaryColumn({type: 'varchar', length: 26})
id = ulidMonotonic();
@Column({type: 'varchar', length: 500, nullable: false})
name!: string;
@OneToMany(() => AuthMethod, authMethod => authMethod.user)
authMethods!: AuthMethod[];
@BeforeInsert()
private beforeInsert () {
this.id = ulidMonotonic();
}
}

3
src/lib/server/ulid.ts Normal file
View file

@ -0,0 +1,3 @@
import {monotonicFactory} from 'ulid';
export const ulidMonotonic = monotonicFactory();

View file

@ -0,0 +1,51 @@
import {discordAvatarURL} from '$lib/server/auth/discord';
import {getDataSource} from '$lib/server/db';
import {AuthSession} from '$lib/server/entity/AuthSession';
import type {LayoutServerLoad} from './$types';
async function findSession (sessionID?: string) {
const dataSource = await getDataSource();
const sessionsRepo = dataSource.getRepository(AuthSession);
if (!sessionID) { return null; }
return await sessionsRepo.findOne({where: {id: sessionID}});
}
export const load: LayoutServerLoad = async ({cookies}) => {
const sessionID = cookies.get('sessionid');
let session = await findSession(sessionID);
if (!session) {
return {};
}
console.log('i am happening');
const response = await fetch('https://discordapp.com/api/v6/users/@me', {
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
});
if (response.status !== 200) {
console.log(await response.text());
return {
error: {
code: 'user_info_fetch_failed',
description:
`Discord gave non-200 status when fetching user info: ${response.status}`,
},
};
}
const data = await response.json();
console.log('have data', data.username);
return {
user: {
username: data.username,
discriminator: data.discriminator,
id: data.id,
avatarURL: discordAvatarURL(data),
},
};
};

14
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,14 @@
<script lang="ts">
import type {PageData} from './$types';
export let data: PageData;
</script>
{#if data.user}
<h1>Success!</h1>
<p>You are {data.user.username}#{data.user.discriminator} and your ID is {data.user.id}</p>
<img src={data.user.avatarURL} alt="Discord avatar of {data.user.username}">
{:else}
<h1>Hello</h1>
<p>I do not know you</p>
<p>please <a href="/auth/discord">try again</a></p>
{/if}

View file

@ -0,0 +1,40 @@
import {redirect} from '@sveltejs/kit';
import * as crypto from 'node:crypto';
import {getDataSource} from '$lib/server/db';
import {AuthState} from '$lib/server/entity/AuthState';
import {env} from '$env/dynamic/private';
import {AuthProvider} from '$lib/server/entity/AuthMethod.js';
/**
* The redirect URI for Discord to send the user back to after auth. This must
* match what's configured for the OAuth application in the dev applications
* panel in order to fetch tokens.
*/
const discordRedirectURI = `${env.HOST}/auth/discord/callback`;
/** The base of the URI that starts the OAuth flow. State is attached later. */
const authURIBase = 'https://discordapp.com/api/oauth2/authorize'
+ `?client_id=${env.DISCORD_CLIENT_ID}`
+ '&response_type=code'
+ `&redirect_uri=${encodeURIComponent(discordRedirectURI)}`
+ `&scope=${encodeURIComponent(['identify', 'guilds'].join(' '))}`
+ '&prompt=consent'; // Special Discord-only thing to always show prompt
/** Generates a complete auth URI given a state. */
const buildAuthURI = (state: string) =>
`${authURIBase}&state=${encodeURIComponent(state)}`;
export async function load ({cookies}) {
const dataSource = await getDataSource();
const authStatesRepo = await dataSource.getRepository(AuthState);
const state = authStatesRepo.create({
provider: AuthProvider.DISCORD,
});
await authStatesRepo.save(state);
cookies.set('stateid', state.id, {path: '/auth/discord'});
throw redirect(302, buildAuthURI(state.state));
}

View file

@ -0,0 +1,97 @@
import {
discordAvatarURL,
type DiscordUserInfo,
fetchDiscordTokens,
fetchDiscordUserInfo,
} from '$lib/server/auth/discord';
import {getDataSource} from '$lib/server/db';
import {AuthProvider} from '$lib/server/entity/AuthMethod';
import {AuthSession} from '$lib/server/entity/AuthSession';
import {AuthState} from '$lib/server/entity/AuthState';
import {redirect} from '@sveltejs/kit';
import type {PageServerLoad} from './$types';
export const load: PageServerLoad = async ({cookies, url, fetch}) => {
const errorCode = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// if the user cancelled the login, redirect home gracefully
if (errorCode === 'access_denied') {
throw redirect(302, '/');
}
// if another error was encountered, return the error information only
if (errorCode) {
return {
error: {
code: errorCode,
description: errorDescription ?? '',
},
};
}
// retrieve the state we stored for this session and compare against the
// state we received from the provider
const dataSource = await getDataSource();
const statesRepo = dataSource.getRepository(AuthState);
const stateID = cookies.get('stateid');
let storedState: AuthState | null = null;
if (stateID) {
storedState = await statesRepo.findOne({where: {id: stateID}});
}
const receivedState = url.searchParams.get('state');
if (!storedState || !receivedState || storedState.state !== receivedState) {
return {
error: {
code: 'consumer_state_mismatch',
description:
`Expected state ${storedState?.state}, received ${receivedState}`,
},
};
}
// everything checks out - exchange code for tokens
const code = url.searchParams.get('code');
if (!code) {
return {
error: {
code: 'no_code',
description: 'No code was received',
},
};
}
let tokens;
try {
tokens = await fetchDiscordTokens(code);
} catch (tokenError) {
return {
error: {
code: 'token_exchange_failed',
description:
`Failed to exchange code for tokens: ${tokenError}`,
},
};
}
// Create a new auth session from the tokens and save it
const authSessionRepo = dataSource.getRepository(AuthSession);
const authSession = authSessionRepo.create({
provider: AuthProvider.DISCORD,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
accessTokenExpiresAt: tokens.expiresAt,
});
await authSessionRepo.save(authSession);
cookies.set('sessionid', authSession.id, {path: '/'});
// remove the state we were using now that we're done with it
await statesRepo.remove(storedState);
cookies.delete('stateid');
// Woo we did it, redirect on to wherever we were trying to go before
throw redirect(302, '/');
};

View file

@ -0,0 +1,10 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
{#if data.error}
<h1>Error!</h1>
<pre>{data.error.code}: {data.error.description}</pre>
<p>Please <a href="/auth/discord">try again</a></p>
{/if}

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

18
svelte.config.js Normal file
View file

@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-auto';
import {vitePreprocess} from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(),
},
};
export default config;

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

9
vite.config.ts Normal file
View file

@ -0,0 +1,9 @@
import {sveltekit} from '@sveltejs/kit/vite';
import {defineConfig} from 'vite';
export default defineConfig({
plugins: [sveltekit()],
ssr: {
external: ['reflect-metadata'],
},
});