initial commit, lots of random shit
This commit is contained in:
commit
7bb18f0a71
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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-*
|
13
LICENSE.txt
Normal file
13
LICENSE.txt
Normal 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.
|
35
dprint.json
Normal file
35
dprint.json
Normal 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
2655
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
package.json
Normal file
31
package.json
Normal 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
17
src/app.d.ts
vendored
Normal 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
12
src/app.html
Normal 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>
|
142
src/lib/server/auth/discord.ts
Normal file
142
src/lib/server/auth/discord.ts
Normal 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
33
src/lib/server/db.ts
Normal 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;
|
||||
}
|
28
src/lib/server/entity/AuthMethod.ts
Normal file
28
src/lib/server/entity/AuthMethod.ts
Normal 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();
|
||||
}
|
||||
}
|
54
src/lib/server/entity/AuthSession.ts
Normal file
54
src/lib/server/entity/AuthSession.ts
Normal 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();
|
||||
}
|
||||
}
|
20
src/lib/server/entity/AuthState.ts
Normal file
20
src/lib/server/entity/AuthState.ts
Normal 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');
|
||||
}
|
29
src/lib/server/entity/Edge.ts
Normal file
29
src/lib/server/entity/Edge.ts
Normal 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();
|
||||
}
|
||||
}
|
30
src/lib/server/entity/Node.ts
Normal file
30
src/lib/server/entity/Node.ts
Normal 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();
|
||||
}
|
||||
}
|
21
src/lib/server/entity/User.ts
Normal file
21
src/lib/server/entity/User.ts
Normal 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
3
src/lib/server/ulid.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import {monotonicFactory} from 'ulid';
|
||||
|
||||
export const ulidMonotonic = monotonicFactory();
|
51
src/routes/+layout.server.ts
Normal file
51
src/routes/+layout.server.ts
Normal 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
14
src/routes/+page.svelte
Normal 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}
|
40
src/routes/auth/discord/+page.server.ts
Normal file
40
src/routes/auth/discord/+page.server.ts
Normal 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));
|
||||
}
|
97
src/routes/auth/discord/callback/+page.server.ts
Normal file
97
src/routes/auth/discord/callback/+page.server.ts
Normal 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, '/');
|
||||
};
|
10
src/routes/auth/discord/callback/+page.svelte
Normal file
10
src/routes/auth/discord/callback/+page.svelte
Normal 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
BIN
static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
18
svelte.config.js
Normal file
18
svelte.config.js
Normal 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
20
tsconfig.json
Normal 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
9
vite.config.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import {sveltekit} from '@sveltejs/kit/vite';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
ssr: {
|
||||
external: ['reflect-metadata'],
|
||||
},
|
||||
});
|
Loading…
Reference in a new issue