Compare commits

...

20 commits

Author SHA1 Message Date
TheClashFruit a4173e24ba
fix: fix authorization in user/:id
All checks were successful
Lint Codebase / lint (push) Successful in 1m3s
2024-09-07 16:56:09 +02:00
TheClashFruit d9377a9731
feat: delete sessions 2024-09-07 16:48:06 +02:00
TheClashFruit e4012844a6
feat: sessions endpoint
All checks were successful
Lint Codebase / lint (push) Successful in 1m12s
2024-09-07 16:42:52 +02:00
TheClashFruit a9f943b7e4
fix: auth util fixes 2024-09-07 16:38:00 +02:00
TheClashFruit 89a31da575
feat: authorization stuff 2024-09-07 16:33:25 +02:00
TheClashFruit c6881a16da
feat: some user apis and storage stuff
All checks were successful
Lint Codebase / lint (push) Successful in 1m24s
2024-09-07 16:08:27 +02:00
TheClashFruit 4e7c2a1df2
feat: authentication
All checks were successful
Lint Codebase / lint (push) Successful in 56s
2024-09-01 16:45:58 +02:00
TheClashFruit 0a8b61c472
feat: add /meta apis
All checks were successful
Lint Codebase / lint (push) Successful in 54s
2024-09-01 15:47:31 +02:00
TheClashFruit bd27f0084c
feat: the old api mess be gone 2024-09-01 15:10:54 +02:00
TheClashFruit 36ac57378a
feat: prepare db stuff for the new api
All checks were successful
Lint Codebase / lint (push) Successful in 56s
2024-09-01 14:37:26 +02:00
TheClashFruit 57e5f69bc8
feat: add db structure
All checks were successful
Lint Codebase / lint (push) Successful in 55s
2024-09-01 14:07:55 +02:00
TheClashFruit 6c8793ff68
feat: remove parliamentBulding from Government 2024-09-01 13:52:22 +02:00
TheClashFruit 54cf25a554
feat: add settings type 2024-09-01 13:11:31 +02:00
TheClashFruit 1dde9b1a84
feat: add updated and created dates, add discord id to user 2024-09-01 13:00:21 +02:00
TheClashFruit d6db115c08
fix: use Permission and Badge for permissions and badges
All checks were successful
Lint Codebase / lint (push) Successful in 56s
2024-09-01 12:38:02 +02:00
TheClashFruit dbbefde41e
feat: internal types
All checks were successful
Lint Codebase / lint (push) Successful in 59s
2024-09-01 12:34:33 +02:00
TheClashFruit ada5cdd47c
feat: setup s3 stuff 2024-09-01 12:07:38 +02:00
TheClashFruit a5aae6e9df
feat(ci): require lint to complete before deploying
All checks were successful
Lint Codebase / lint (push) Successful in 1m7s
2024-09-01 11:49:31 +02:00
TheClashFruit 638452eb0b
feat: badges 2024-09-01 11:38:46 +02:00
TheClashFruit 9d6e607ab5
feat: preperations for the api 2024-09-01 11:30:26 +02:00
39 changed files with 2392 additions and 474 deletions

View file

@ -11,5 +11,12 @@ DB_HOST=
DB_PORT=
AUTH_SECRET=
LINK_SECRET=
MC_API=
MC_API=
S3_REGION="fra1"
S3_ENDPOINT="https://fra1.digitaloceanspaces.com"
S3_ACCESS_KEY=
S3_SECRET_KEY=

View file

@ -3,7 +3,8 @@
"rules": {
"indent": [
"error",
2
2,
{ "SwitchCase": 1 }
],
"quotes": [
"error",

View file

@ -8,6 +8,7 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
needs: lint
steps:
- name: Deploy Over SSH
uses: https://github.com/nekiro/ssh-job@main

25
docs/cdn.md Normal file
View file

@ -0,0 +1,25 @@
# CDN Url Structure
Base URL: `https://crss.fra1.cdn.digitaloceanspaces.com/`
## Users
### Avatars
`users/[id]/avatar/[hash].png`
### Banners
`users/[id]/banner/[hash].png`
## Nations
### Flags
`nations/[id]/flag/[hash].svg`
## Gallery
### Images
`gallery/[id]/[hash].png`

161
docs/database.sql Normal file
View file

@ -0,0 +1,161 @@
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
START TRANSACTION;
SET time_zone = "+00:00";
CREATE DATABASE IF NOT EXISTS `crss` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `crss`;
CREATE TABLE IF NOT EXISTS `governments` (
`id` bigint(20) NOT NULL,
`nation_id` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
KEY `nation_id` (`nation_id`)
);
CREATE TABLE IF NOT EXISTS `government_officials` (
`id` bigint(20) NOT NULL,
`government_id` bigint(20) NOT NULL,
`user_id` bigint(20) NOT NULL,
`role_id` bigint(20) NOT NULL,
PRIMARY KEY (`id`),
KEY `government_id` (`government_id`),
KEY `user_id` (`user_id`),
KEY `role_id` (`role_id`)
);
CREATE TABLE IF NOT EXISTS `government_roles` (
`id` bigint(20) NOT NULL,
`government_id` bigint(20) NOT NULL,
`level` int(11) NOT NULL,
`name` varchar(48) NOT NULL,
PRIMARY KEY (`id`),
KEY `government_id` (`government_id`)
);
CREATE TABLE IF NOT EXISTS `images` (
`id` bigint(20) NOT NULL,
`user_id` bigint(20) NOT NULL,
`name` varchar(64) NOT NULL,
`alt` text NOT NULL,
`type` text NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `image_user` (`user_id`)
);
CREATE TABLE IF NOT EXISTS `image_locations` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`image_id` bigint(20) NOT NULL,
`x` int(11) NOT NULL,
`y` int(11) NOT NULL,
`z` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `image_id` (`image_id`)
);
CREATE TABLE IF NOT EXISTS `image_sizes` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`image_id` bigint(20) NOT NULL,
`width` int(11) NOT NULL,
`height` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `image_id` (`image_id`)
);
CREATE TABLE IF NOT EXISTS `mc_links` (
`id` bigint(20) NOT NULL,
`user_id` bigint(20) NOT NULL,
`uuid` text NOT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`)
);
CREATE TABLE IF NOT EXISTS `mc_link_codes` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL,
`code` varchar(8) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `code` (`code`),
KEY `user_id` (`user_id`)
);
CREATE TABLE IF NOT EXISTS `nations` (
`id` bigint(20) NOT NULL,
`code` varchar(3) NOT NULL,
`name` varchar(48) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`)
);
CREATE TABLE IF NOT EXISTS `nation_points` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`nation_id` bigint(20) NOT NULL,
`x` int(11) NOT NULL,
`z` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `nation_id` (`nation_id`)
);
CREATE TABLE IF NOT EXISTS `users` (
`id` bigint(20) NOT NULL,
`username` varchar(32) NOT NULL,
`display_name` varchar(64) NOT NULL,
`email` varchar(320) NOT NULL,
`avatar` text NOT NULL,
`banner` text NOT NULL,
`accent_color` int(11) NOT NULL,
`discord_id` bigint(20) NOT NULL,
`permissions` int(11) NOT NULL,
`badges` int(11) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`),
UNIQUE KEY `email` (`email`),
UNIQUE KEY `discord_id` (`discord_id`)
);
CREATE TABLE IF NOT EXISTS `user_sessions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL,
`token` varchar(1024) NOT NULL,
`dc_access_token` text NOT NULL,
`dc_refresh_token` text NOT NULL,
`dc_id_token` text NOT NULL,
`user_agent` text NOT NULL,
`ip` text NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`expires_at` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `token` (`token`) USING HASH,
KEY `user_id` (`user_id`)
);
CREATE TABLE IF NOT EXISTS `user_settings` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) NOT NULL,
`settings` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL CHECK (json_valid(`settings`)),
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`)
);
--- Foreign keys
ALTER TABLE `governments` ADD CONSTRAINT `nation` FOREIGN KEY (`nation_id`) REFERENCES `nations` (`id`) ON DELETE CASCADE;
ALTER TABLE `government_officials` ADD CONSTRAINT `government_official` FOREIGN KEY (`government_id`) REFERENCES `governments` (`id`) ON DELETE CASCADE, ADD CONSTRAINT `government_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, ADD CONSTRAINT `role` FOREIGN KEY (`role_id`) REFERENCES `government_roles` (`id`) ON DELETE CASCADE;
ALTER TABLE `government_roles` ADD CONSTRAINT `government_role` FOREIGN KEY (`government_id`) REFERENCES `governments` (`id`) ON DELETE CASCADE;
ALTER TABLE `images` ADD CONSTRAINT `image_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
ALTER TABLE `image_locations` ADD CONSTRAINT `image_location_id` FOREIGN KEY (`image_id`) REFERENCES `images` (`id`) ON DELETE CASCADE;
ALTER TABLE `image_sizes` ADD CONSTRAINT `image_size` FOREIGN KEY (`image_id`) REFERENCES `images` (`id`) ON DELETE CASCADE;
ALTER TABLE `mc_links` ADD CONSTRAINT `mc_link_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
ALTER TABLE `mc_link_codes` ADD CONSTRAINT `mc_link_code_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
ALTER TABLE `nation_points` ADD CONSTRAINT `nation_point_id` FOREIGN KEY (`nation_id`) REFERENCES `nations` (`id`) ON DELETE CASCADE;
ALTER TABLE `user_sessions` ADD CONSTRAINT `session_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
ALTER TABLE `user_settings` ADD CONSTRAINT `setting_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE;
COMMIT;

15
interfaces/company.ts Normal file
View file

@ -0,0 +1,15 @@
import { User } from './user';
export interface Company {
id: BigInt;
owners: User[];
name: string;
slogan: string;
logo: string;
createdAt: Date;
updatedAt: Date;
}

24
interfaces/gallery.ts Normal file
View file

@ -0,0 +1,24 @@
import { Point3D } from './spacial_data';
import { User } from './user';
export interface AspectRatio {
w: number;
h: number;
}
export interface Image {
id: BigInt;
by: User;
name: string;
alt: string;
location?: Point3D;
type: 'image/jpeg' | 'image/png';
aspectRatio: AspectRatio;
createdAt: Date;
updatedAt: Date;
}

30
interfaces/government.ts Normal file
View file

@ -0,0 +1,30 @@
import { Point2D } from './spacial_data';
import { User } from './user';
export enum GovernmentLevel {
Highest,
High,
Regular,
Low,
Lowest
}
export interface GovernmentRole {
id: BigInt;
level: GovernmentLevel;
name: string;
}
export interface GovernmentUser extends User {
role: GovernmentRole;
ordering: number;
}
export interface Government {
id: BigInt;
parliament: GovernmentUser[];
// parliamentBulding: Point2D;
}

12
interfaces/index.ts Normal file
View file

@ -0,0 +1,12 @@
export type * from './user';
export type * from './nation';
export type * from './government';
export type * from './company';
export type * from './gallery';
export type * from './spacial_data';
export type * from './minecraft';
export type * from './settings';
export type * from './responses';

21
interfaces/minecraft.ts Normal file
View file

@ -0,0 +1,21 @@
import { Point3D } from './spacial_data';
export enum MinecraftDimension {
Overworld = 'minecraft:overworld',
Nether = 'minecraft:the_nether',
End = 'minecraft:the_end',
}
export interface MinecraftPosition extends Point3D {
yaw: number;
pitch: number;
dimension: MinecraftDimension;
}
export interface MinecraftPlayer {
uuid: string;
username: string;
position: MinecraftPosition;
}

18
interfaces/nation.ts Normal file
View file

@ -0,0 +1,18 @@
import { Company } from './company';
import { Government } from './government';
import { Point2D } from './spacial_data';
export interface Nation {
id: BigInt;
code: string;
name: string;
government: Government;
companies: Company[];
borderPoints: Point2D[];
createdAt: Date;
updatedAt: Date;
}

4
interfaces/responses.ts Normal file
View file

@ -0,0 +1,4 @@
export interface ErrorResponse {
code: number;
message: string;
}

6
interfaces/settings.ts Normal file
View file

@ -0,0 +1,6 @@
export interface Settings {
animations: boolean;
ads: boolean;
rightSidebar: boolean;
}

View file

@ -0,0 +1,8 @@
export interface Point2D {
x: number;
z: number;
}
export interface Point3D extends Point2D {
y: number;
}

30
interfaces/user.ts Normal file
View file

@ -0,0 +1,30 @@
import { BadgeNamed } from '@/utils/badges';
import { PermissionNamed } from '@/utils/permissions';
export interface User {
id: BigInt;
username: string;
displayName: string;
email?: string;
avatar?: string;
banner?: string;
accentColor?: number;
discordId?: BigInt;
subscription: number;
permissions: PermissionNamed[];
badges: BadgeNamed[];
createdAt: Date;
updatedAt: Date;
}
export interface TeamMember extends User {
role: string;
}

9
lib/ApiClient.ts Normal file
View file

@ -0,0 +1,9 @@
class ApiClient {
private sessionToken: string;
constructor(sessionToken: string) {
this.sessionToken = sessionToken;
}
}
export default ApiClient;

View file

@ -1,58 +1,14 @@
import { Role } from '@/utils/permissions';
import { TeamMember, User } from '@/interfaces';
import { getBadges } from '@/utils/badges';
import { getPermissions } from '@/utils/permissions';
import mysql, { Pool, QueryResult } from 'mysql2/promise';
import crypto from 'node:crypto';
interface DiscordTokenData {
token_type: string;
access_token: string;
expires_in: number;
refresh_token: string;
scope: string;
id_token: string;
}
interface UserTable {
id: number;
did: string;
username: string;
global_name: string;
email: string;
avatar?: string;
banner?: string;
accent_color?: number;
permissions: number;
}
interface UserTableWithoutEmail {
id: number;
did: string;
username: string;
global_name: string;
avatar?: string;
banner?: string;
accent_color?: number;
permissions: number;
}
interface TeamMember {
id: number;
uid: number;
did: string;
username: string;
global_name: string;
email: string;
avatar: string;
banner: string;
accent_color: number;
permissions: number;
role: Role;
}
import Discord from './Discord';
import { createSessionToken, DiscordTokenData } from '@/utils/token_util';
import snowflake from '@/utils/snowflake';
class Database {
static instance: (Database | null) = null;
mysqlPool: (Pool | null) = null;
static instance: (Database | null) = null;
private mysqlPool: (Pool | null) = null;
constructor() {
if(Database.instance)
@ -72,153 +28,252 @@ class Database {
Database.instance = this;
}
// Auth Stuff
/**
* Get a user from their id.
*
* @param id User's id
*
* @returns User
*/
async getUser(id: string): Promise<User | undefined> {
const [ rows ] = await this.mysqlPool!.query('SELECT * FROM users WHERE id = ?', [ id ]);
async createUser(user: any): Promise<number> {
const [ result ] = await this.mysqlPool!.execute('SELECT * FROM users WHERE did = ?', [ user.id ]);
if((rows as any[]).length === 0)
return undefined;
if ((result as any).length > 0) {
return (result as any)[0].id;
}
const row = (rows as any[])[0];
const [ res ] = await this.mysqlPool!.execute('INSERT INTO users (did, username, global_name, email, avatar, banner, accent_color) VALUES (?, ?, ?, ?, ?, ?, ?)', [
user.id,
user.username,
(user.global_name || user.username),
user.email,
user.avatar,
user.banner,
user.accent_color
]);
return {
id: row.id as BigInt,
return (res as any).insertId;
username: row.username,
displayName: row.display_name,
email: row.email,
avatar: row.avatar,
banner: row.banner,
accentColor: row.accent_color,
discordId: row.discord_id as BigInt,
subscription: row.subscription,
permissions: getPermissions(row.permissions),
badges: getBadges(row.badges),
createdAt: row.created_at,
updatedAt: row.updated_at
} as User;
}
/**
* Creates a session for the user.
* Get a user from their username.
*
* Should only be called by backend!
* @param username User's username
*
* @param userData The data returned from the `/oauth2/token` endpoint.
* @param userAgent The user agent of the user.
*
* @returns `string` The session token.
* @returns User
*/
async createSession(userData: DiscordTokenData, userAgent?: string): Promise<string | null> {
const res = await fetch(`${process.env.DISCORD_API}/users/@me`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${userData.access_token}`,
},
});
async getUserUsername(username: string): Promise<User | undefined> {
const [ rows ] = await this.mysqlPool!.query('SELECT * FROM users WHERE username = ?', [ username ]);
if (res.ok) {
const user = await res.json();
const uid = await this.createUser(user);
if((rows as any[]).length === 0)
return undefined;
const sum = crypto.createHmac('sha256', process.env.AUTH_SECRET!);
const base = Buffer.from(user.id).toString('base64').replaceAll('=', '');
const date = Buffer.from((Date.now() - 1688940000000).toString()).toString('base64').replaceAll('=', '');
const row = (rows as any[])[0];
sum.update(userData.access_token);
return {
id: row.id as BigInt,
const sid = base + '.' + date + '.' + sum.digest('hex');
username: row.username,
displayName: row.display_name,
const [ result ] = await this.mysqlPool!.execute('INSERT INTO sessions (sid, uid, access_token, refresh_token, id_token, user_agent, expires) VALUES (?, ?, ?, ?, ?, ?, ?)', [
sid,
uid,
userData.access_token,
userData.refresh_token,
userData.id_token,
email: row.email,
avatar: row.avatar,
banner: row.banner,
accentColor: row.accent_color,
discordId: row.discord_id as BigInt,
subscription: row.subscription,
permissions: getPermissions(row.permissions),
badges: getBadges(row.badges),
createdAt: row.created_at,
updatedAt: row.updated_at
} as User;
}
private async getUserDiscord(discordId: BigInt): Promise<User | undefined> {
const [ rows ] = await this.mysqlPool!.query('SELECT * FROM users WHERE discord_id = ?', [ discordId ]);
if((rows as any[]).length === 0)
return undefined;
const row = (rows as any[])[0];
return {
id: row.id as BigInt,
username: row.username,
displayName: row.display_name,
email: row.email,
avatar: row.avatar,
banner: row.banner,
accentColor: row.accent_color,
discordId: row.discord_id as BigInt,
subscription: row.subscription,
permissions: getPermissions(row.permissions),
badges: getBadges(row.badges),
createdAt: row.created_at,
updatedAt: row.updated_at
} as User;
}
async newUser(user: any): Promise<User> {
const userExists = await this.getUserDiscord(user.id);
if(userExists)
return userExists;
const id = snowflake.getUniqueID();
await this.mysqlPool!.query('INSERT INTO users (id, username, display_name, email, avatar, banner, accent_color, discord_id, permissions, badges) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [
id.toString(),
user.username,
user.global_name,
user.email,
user.avatar,
user.banner,
user.accent_color,
user.id
]);
return {
id: id,
username: user.username,
displayName: user.display_name,
email: user.email,
avatar: user.avatar,
banner: user.banner,
accentColor: user.accent_color,
discordId: user.discord as BigInt,
subscription: user.subscription,
permissions: getPermissions(user.permissions),
badges: getBadges(user.badges),
createdAt: new Date(),
updatedAt: new Date()
} as User;
}
// Session -------------
/**
* Create a new session.
*
* @param oauthData The data returned from the `/oauth2/token` endpoint.
* @param userAgent Request's User-Agent
* @param ip Request's IP
*
* @returns
*/
async newSession(oauthData: DiscordTokenData, userAgent: string, ip: string): Promise<string | undefined> {
const discord = new Discord(oauthData.access_token);
const dcUser = await discord.user();
const token = await createSessionToken(oauthData);
if(!dcUser.ok || !token)
return undefined;
const user = await this.newUser(dcUser.data);
if(!user)
return undefined;
try {
await this.mysqlPool!.query('INSERT INTO user_sessions (token, user_id, dc_access_token, dc_refresh_token, dc_id_token, user_agent, ip, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [
token,
user.id,
oauthData.access_token,
oauthData.refresh_token,
oauthData.id_token,
userAgent,
new Date(Date.now() + (userData.expires_in * 1000)),
ip,
new Date(Date.now() + (oauthData.expires_in * 1000))
]);
} catch (e) {
console.error(e);
return sid;
} else {
throw new Error('Error Fetching Discord User Data');
return undefined;
}
return token;
}
async deleteSession(sid: string): Promise<void> {
await this.mysqlPool!.execute('DELETE FROM sessions WHERE sid = ?', [ sid ]);
async deleteSession(id: string): Promise<boolean> {
const [ result ] = await this.mysqlPool!.query('DELETE FROM user_sessions WHERE id = ?', [ id ]);
return (result as any).affectedRows === 1;
}
async getSession(sid: string): Promise<any> {
const [ rows ] = await this.mysqlPool!.execute('SELECT * FROM sessions WHERE sid = ?', [ sid ]);
async getSession(token: string): Promise<any | undefined> {
const [ rows ] = await this.mysqlPool!.query('SELECT * FROM user_sessions WHERE token = ?', [ token ]);
return (rows as any)[0];
if((rows as any[]).length === 0)
return undefined;
const row = (rows as any[])[0];
if(new Date(row.expires_at) < new Date())
return undefined;
return row;
}
// End of Auth Stuff
async getSessions(userId: BigInt): Promise<any[]> {
const [ rows ] = await this.mysqlPool!.query('SELECT * FROM user_sessions WHERE user_id = ?', [ userId ]);
async getUser(id: number): Promise<UserTable> {
const [ rows ] = await this.mysqlPool!.execute('SELECT * FROM users WHERE id = ?', [ id ]);
return (rows as UserTable[])[0];
return rows as any[];
}
async getUsers(): Promise<UserTableWithoutEmail[]> {
const [ rows ] = await this.mysqlPool!.execute('SELECT id, did, username, global_name, avatar, banner, accent_color, permissions FROM users');
// Meta ----------------
return rows as UserTableWithoutEmail[];
}
async getTeam(): Promise<(TeamMember | undefined)[]> {
const [ rows ] = await this.mysqlPool!.query('SELECT * FROM team');
const usersPromises = (rows as any[]).map(row => this.getUser(row.user_id));
const users = await Promise.all(usersPromises);
async getUserUsername(username: string): Promise<UserTable> {
const [ rows ] = await this.mysqlPool!.execute('SELECT * FROM users WHERE username = ?', [ username ]);
return users.map((user, i) => {
if (!user) return undefined;
return (rows as UserTable[])[0];
}
async getUserSessions(uid: number): Promise<any> {
const [ rows ] = await this.mysqlPool!.execute('SELECT * FROM sessions WHERE uid = ?', [ uid ]);
return rows;
}
async getTeam(): Promise<TeamMember[]> {
const [ rows ] = await this.mysqlPool!.execute('SELECT team_members.id AS tid, team_members.uid AS uid, users.did, users.username, users.global_name, users.email, users.avatar, users.banner, users.accent_color, users.permissions, team_members.role FROM team_members JOIN users ON team_members.uid = users.id;');
return (rows as any).map((row: any) => {
return {
id: row.tid,
uid: row.uid,
did: row.did,
username: row.username,
global_name: row.global_name,
email: row.email,
avatar: row.avatar,
banner: row.banner,
accent_color: row.accent_color,
permissions: row.permissions,
role: (row.role as Role),
};
...user,
role: (rows as any[])[i].role
} as TeamMember;
});
}
async getNations(): Promise<any> {
const [ rows ] = await this.mysqlPool!.execute('SELECT * FROM nations');
return rows;
}
async getNationCode(code: string): Promise<any> {
const [ rows ] = await this.mysqlPool!.execute('SELECT * FROM nations WHERE code = ?', [ code ]);
return (rows as any)[0];
}
async getCompaies(nid: number): Promise<any> {
const [ rows ] = await this.mysqlPool!.execute('SELECT * FROM companies WHERE nid = ?', [ nid ]);
return rows;
}
async updateUserPermissions(id: number, permissions: any) {
await this.mysqlPool!.execute('UPDATE users SET permissions = ? WHERE id = ?', [ permissions, id ]);
return;
}
}
export default Database;

22
lib/Discord.ts Normal file
View file

@ -0,0 +1,22 @@
class Discord {
private token: string;
constructor(token: string) {
this.token = token;
}
async user(): Promise<{ ok: boolean, data?: any }> {
const res = await fetch(`${process.env.DISCORD_API}/users/@me`, {
headers: {
Authorization: `Bearer ${this.token}`,
}
});
return {
ok: res.ok,
data: res.ok ? await res.json() : null,
};
}
}
export default Discord;

38
lib/Storage.ts Normal file
View file

@ -0,0 +1,38 @@
import { ObjectCannedACL, PutObjectCommand, PutObjectCommandOutput, S3Client } from '@aws-sdk/client-s3';
import { Readable } from 'node:stream';
class S3Storage {
private client;
// change this if it's different for you :3
private bucket: string = 'crss';
constructor() {
this.client = new S3Client({
forcePathStyle: false,
region: process.env.S3_REGION!,
endpoint: process.env.S3_ENDPOINT!,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!
}
});
}
async uploadFile(key: string, file: (string | Uint8Array | Buffer | Readable), contetnType: string): Promise<PutObjectCommandOutput> {
const res = await this.client.send(new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: file,
ContentType: contetnType,
ACL: ObjectCannedACL.public_read
}));
return res;
}
}
export default S3Storage;

View file

@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.637.0",
"@icons-pack/react-simple-icons": "^10.0.0",
"@svgr/webpack": "^8.1.0",
"bcrypt": "^5.1.1",
@ -18,6 +19,7 @@
"lucide-react": "^0.429.0",
"mysql2": "^3.11.0",
"next": "14.2.6",
"nodejs-snowflake": "^2.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"showdown": "^2.1.0",

View file

@ -7,7 +7,7 @@ import Database from '@/lib/Database';
import Image from 'next/image';
import Link from 'next/link';
import { Role } from '@/utils/permissions';
import { TeamRole } from '@/utils/badges';
import styles from '@/styles/About.module.scss';
import { Globe } from 'lucide-react';
@ -47,8 +47,8 @@ export default function About({ teamMembers }: { teamMembers: any[] }) {
<h3>{member.global_name}</h3>
{member.role === Role.Owner && <label>Owner</label>}
{member.role === Role.Admin && <label>Admin</label>}
{member.role === TeamRole.Owner && <label>Owner</label>}
{member.role === TeamRole.Admin && <label>Admin</label>}
</div>
</Card>
))}

22
pages/api/index.ts Normal file
View file

@ -0,0 +1,22 @@
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(
req: NextApiRequest,
res: NextApiResponse<any>,
) {
res
.status(200)
.json({
latest: 0,
versions: [
{
version: {
name: 'v1.0.0',
code: 1,
},
path: '/v1',
deprecated: false,
}
]
});
}

View file

@ -1,71 +1,78 @@
import Database from '@/lib/Database';
import { serialize } from 'cookie';
import Discord from '@/lib/Discord';
import S3Storage from '@/lib/Storage';
import type { NextApiRequest, NextApiResponse } from 'next';
type Data = {
sid: string | null;
};
type Error = {
error: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data | Error>,
res: NextApiResponse<any>,
) {
const db = new Database();
const s3 = new S3Storage();
const { code, state } = req.query;
console.log(code, state);
const discordApi = process.env.DISCORD_API!;
try {
if (typeof code === 'string') {
const data = await fetch(`${discordApi}/oauth2/token`, {
method: 'POST',
body: new URLSearchParams({
client_id: process.env.DISCORD_CLIENT!,
client_secret: process.env.DISCORD_SECRET!,
grant_type: 'authorization_code',
code,
redirect_uri: process.env.DISCORD_REDIRECT!,
}),
});
const json = await data.json();
const sid = await db.createSession(json, req.headers['user-agent']);
if (sid) {
const cookie = serialize('session', sid, {
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
expires: new Date(Date.now() + json.expires_in * 1000),
});
res.setHeader('Set-Cookie', cookie);
if ((state as string).startsWith('/'))
res.status(302).redirect(state as string);
else
res.status(400).json({ error: 'Invalid redirect uri in state!' });
}
return;
}
} catch (error) {
console.error(error);
res.status(500).json(
{ error: 'Internal Server Error' }
);
if (typeof code !== 'string' || typeof state !== 'string') {
return res.status(400).json({
code: 400,
message: 'Invalid Request'
});
}
res.status(400).json(
{ error: 'Invalid code' }
);
try {
const data = await fetch(`${process.env.DISCORD_API}/oauth2/token`, {
method: 'POST',
body: new URLSearchParams({
client_id: process.env.DISCORD_CLIENT!,
client_secret: process.env.DISCORD_SECRET!,
grant_type: 'authorization_code',
code,
redirect_uri: process.env.DISCORD_REDIRECT!,
}),
});
const json = await data.json();
const token = await db.newSession(json, req.headers['user-agent']!, (req.headers['x-forwarded-for'] as string || req.socket.remoteAddress!));
if (!token) {
return res.status(500).json({
code: 500,
message: 'Internal Server Error'
});
}
res.status(200).json({
token
});
try {
const dc = new Discord(json.access_token);
const dcUser = await dc.user();
const user = await db.getUser((await db.getSession(token)).user_id);
if (dcUser.data.avatar) {
const fetchData = await fetch(`https://cdn.discordapp.com/avatars/${dcUser.data.id}/${dcUser.data.avatar}.png?size=1024`);
const buffer = await fetchData.arrayBuffer();
await s3.uploadFile(`users/${user?.id}/avatar/${dcUser.data.avatar}.png`, Buffer.from(buffer), 'image/png');
}
if (dcUser.data.banner) {
const fetchData = await fetch(`https://cdn.discordapp.com/banners/${dcUser.data.id}/${dcUser.data.banner}.png?size=1024`);
const buffer = await fetchData.arrayBuffer();
await s3.uploadFile(`users/${user?.id}/banner/${dcUser.data.banner}.png`, Buffer.from(buffer), 'image/png');
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
return res.status(500).json({
code: 500,
message: 'Internal Server Error'
});
}
}

View file

@ -0,0 +1,20 @@
import getConfig from 'next/config';
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(
req: NextApiRequest,
res: NextApiResponse<any>,
) {
const { publicRuntimeConfig } = getConfig();
let { git } = publicRuntimeConfig;
git.commit.created = new Date(git.commit.created).getTime();
res
.status(200)
.json({
git,
});
}

36
pages/api/v1/meta/team.ts Normal file
View file

@ -0,0 +1,36 @@
import { ErrorResponse, TeamMember, User } from '@/interfaces';
import Database from '@/lib/Database';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<TeamMember[] | ErrorResponse>,
) {
const db = new Database();
try {
let team: any[] = await db.getTeam();
team = team.map((member: TeamMember) => {
return {
...member,
email: undefined,
discordId: undefined,
createdAt: new Date(member.createdAt).getTime(),
updatedAt: new Date(member.updatedAt).getTime()
};
});
res.status(200).json(team);
} catch (e) {
console.error(e);
res.status(500).json({
code: 500,
message: 'Internal Server Error'
});
}
}

View file

@ -1,61 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next';
type Error = {
message: string;
};
interface ServerInfo {
version: string;
online: number;
worlds: string[];
}
import * as net from 'node:net';
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ServerInfo | Error>,
) {
try {
const mc_api = process.env.MC_API!.split(':');
const socket = net.createConnection({
host: mc_api[0],
port: parseInt(mc_api[1]),
}, async () => {
const reqData = Buffer.alloc(1 + 4);
reqData.writeInt8(0x00, 0);
reqData.writeUint32BE(0, 1);
socket.write(reqData);
socket.on('data', (data) => {
const packetId = data[0];
const length = data.readUInt32BE(1); // unused but in case someone wants to verify the lenght :3
if (packetId !== 0x00) {
socket.end();
return res.status(500).json({ message: 'There was an error with the server.' });
}
const jsonData = data.toString('utf-8', 5);
socket.end();
res.status(200).json(JSON.parse(jsonData));
});
socket.on('error', (err) => {
console.error(err);
socket.end();
res.status(500).json({ message: 'There was an error with the server.' });
});
});
} catch (e) {
res.status(500).json({ message: 'There was an error with the server.' });
}
}

View file

@ -1,48 +0,0 @@
import Database from '@/lib/Database';
import type { NextApiRequest, NextApiResponse } from 'next';
import { serialize } from 'cookie';
type Data = {
success: string;
};
interface Error {
error: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data | Error>,
) {
const db = new Database();
const sid = req.cookies.session;
if (!sid)
return res.status(401).json({ error: 'Unauthorized' });
const session = await db.getSession(sid!);
if (!session)
return res.status(401).json({ error: 'Unauthorized' });
const user = await db.getUser(session.uid);
if (!user)
return res.status(404).json({ error: 'Not Found' });
if (req.method === 'DELETE') {
db.deleteSession(sid!);
res.setHeader('Set-Cookie', serialize('session', '', {
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/',
expires: new Date(0),
}));
return res.status(200).json({ success: 'Delete Session for ' + user.username });
}
res.status(405).json({ error: 'Method Not Allowed' });
}

View file

@ -1,52 +1,46 @@
import { ErrorResponse, User } from '@/interfaces';
import Database from '@/lib/Database';
import { getAuthenticatedUser } from '@/utils/auth_util';
import type { NextApiRequest, NextApiResponse } from 'next';
type Data = {
id: number;
discord_id: string;
names: {
username: string;
global_name: string;
};
email: string;
avatar?: string;
banner?: string;
accent_color?: number;
permissions: number;
};
interface Error {
error: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data | Error>,
res: NextApiResponse<User | ErrorResponse>,
) {
const db = new Database();
const sid = req.cookies.session;
const db = new Database();
const user = await getAuthenticatedUser(req);
if (!sid)
return res.status(401).json({ error: 'Unauthorized' });
if (!user)
return res.status(401).json({
code: 401,
message: 'Unauthorized'
});
const session = await db.getSession(sid!);
if (req.method === 'GET') {
return res.status(200).json(user);
}
if (!session)
return res.status(401).json({ error: 'Unauthorized' });
if (req.method === 'PATCH') {
// TODO: implement
const user = await db.getUser(session.uid);
return res.status(501).json({
code: 501,
message: 'Not Yet Implemented'
});
}
res.status(200).json({
id: user.id,
discord_id: user.did,
names: {
username: user.username,
global_name: user.global_name
},
email: user.email,
avatar: user.avatar,
banner: user.banner,
accent_color: user.accent_color,
permissions: user.permissions
if (req.method === 'DELETE') {
// TODO: implement
return res.status(501).json({
code: 501,
message: 'Not Yet Implemented'
});
}
return res.status(405).json({
code: 405,
message: 'Method Not Allowed'
});
}
}

View file

@ -0,0 +1,38 @@
import { ErrorResponse, User } from '@/interfaces';
import Database from '@/lib/Database';
import { getAuthenticatedUser } from '@/utils/auth_util';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<any>, // User | ErrorResponse
) {
const db = new Database();
const user = await getAuthenticatedUser(req);
const { id } = req.query;
if (!user)
return res.status(401).json({
code: 401,
message: 'Unauthorized'
});
if (req.method === 'DELETE') {
const didDelete = await db.deleteSession(id as string);
if (!didDelete)
return res.status(404).json({
code: 404,
message: 'Session Not Found'
});
return res.status(204).end();
}
return res.status(405).json({
code: 405,
message: 'Method Not Allowed'
});
}

View file

@ -0,0 +1,40 @@
import { ErrorResponse, User } from '@/interfaces';
import Database from '@/lib/Database';
import { getAuthenticatedUser } from '@/utils/auth_util';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<any>, // User | ErrorResponse
) {
const db = new Database();
const user = await getAuthenticatedUser(req);
if (!user)
return res.status(401).json({
code: 401,
message: 'Unauthorized'
});
if (req.method === 'GET') {
const sessions = await db.getSessions(user.id);
return res.status(200).json(
sessions.map((session) => ({
id: session.id,
ip: session.ip,
userAgent: session.user_agent,
createdAt: session.created_at,
expiresAt: session.expires_at,
}))
);
}
return res.status(405).json({
code: 405,
message: 'Method Not Allowed'
});
}

71
pages/api/v1/user/[id].ts Normal file
View file

@ -0,0 +1,71 @@
import { ErrorResponse, User } from '@/interfaces';
import Database from '@/lib/Database';
import { getAuthenticatedUser, reqHasValidToken } from '@/utils/auth_util';
import { getPermission, hasPermission, Permission, PermissionNamed } from '@/utils/permissions';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<User | ErrorResponse>,
) {
const db = new Database();
const { id } = req.query;
let shouldShowSensitive = false;
// tf was I on?
// const valid = await reqHasValidToken(req);
// thats better
const vUser = await getAuthenticatedUser(req);
if (!vUser)
shouldShowSensitive = false;
if (
hasPermission(getPermission(vUser!.permissions), Permission.SuperAdmin) ||
vUser!.id === BigInt(id as string)
)
shouldShowSensitive = true;
if ((/^\d+$/).test(id as string)) {
let user = await db.getUser((id as string));
if (!user)
return res.status(404).json({
code: 404,
message: 'User Not Found'
});
user = {
...user,
email: shouldShowSensitive ? user.email : undefined,
discordId: shouldShowSensitive ? user.discordId : undefined,
};
res.status(200).json(user);
} else {
let user: any = await db.getUserUsername(id as string);
if (!user)
return res.status(404).json({
code: 404,
message: 'User Not Found'
});
// TODO: check if user is admin or itself and show email and discordId
user = {
...user,
email: shouldShowSensitive ? user.email : undefined,
discordId: shouldShowSensitive ? user.discordId : undefined,
};
res.status(200).json(user);
}
}

View file

@ -1,69 +0,0 @@
import Database from '@/lib/Database';
import { isUserAdmin } from '@/utils/auth_util';
import type { NextApiRequest, NextApiResponse } from 'next';
type Data = {
id: number;
discord_id: string;
names: {
username: string;
global_name: string;
};
avatar?: string;
banner?: string;
accent_color?: number;
permissions: number;
};
interface Error {
error: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data | Error>,
) {
const db = new Database();
const user = await db.getUserUsername(req.query.username as string);
if (!user) {
return res.status(404).json({ error: 'Not Found' });
}
// hehe only admins update users :trolley:
// also validation yeah uh... didn't have budget for that
// tech debt for the win
if (req.method === 'PATCH') {
const sid = req.cookies.session;
const isAdmin = isUserAdmin(sid);
if (!isAdmin) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { permissions } = req.body;
if (permissions) {
await db.updateUserPermissions(user.id, permissions);
}
res.status(204).end();
return;
}
res.status(200).json({
id: user.id,
discord_id: user.did,
names: {
username: user.username,
global_name: user.global_name
},
avatar: user.avatar,
banner: user.banner,
accent_color: user.accent_color,
permissions: user.permissions
});
}

View file

@ -1,24 +0,0 @@
import Database from '@/lib/Database';
import type { NextApiRequest, NextApiResponse } from 'next';
interface Response {
id: number;
did: string;
username: string;
global_name: string;
avatar?: string;
banner?: string;
accent_color?: number;
permissions: number;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Response[]>,
) {
const db = new Database();
const users = await db.getUsers();
res.status(200).json(users);
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,8 @@
import Database from '@/lib/Database';
import { hasPermission, Permission } from './permissions';
import { getPermission, hasPermission, Permission } from './permissions';
import { User } from '@/interfaces';
import type { NextApiRequest } from 'next';
const db = new Database();
@ -24,6 +27,49 @@ export async function isUserAdmin(sid?: string): Promise<doas> {
return {
user,
hasPermission: hasPermission(user.permissions, Permission.Admin)
hasPermission: hasPermission(getPermission(user.permissions), Permission.Admin)
};
}
export async function getAuthenticatedUser(req: NextApiRequest): Promise<User | undefined> {
const { authorization } = req.headers;
const token = authorization?.split(' ')!;
if (!token)
return undefined;
if (token[0] !== 'Bearer' || !token[1])
return undefined;
const session = await db.getSession(token[1]);
if (!session)
return undefined;
const user = await db.getUser(session.user_id);
if (!user)
return undefined;
return user;
}
export async function reqHasValidToken(req: NextApiRequest): Promise<boolean> {
const { authorization } = req.headers;
const token = authorization?.split(' ')!;
if (!token)
return false;
if (token[0] !== 'Bearer' || !token[1])
return false;
const session = await db.getSession(token[1]);
if (!session)
return false;
return true;
}

29
utils/badges.ts Normal file
View file

@ -0,0 +1,29 @@
export enum TeamRole {
Owner = 'owner',
Admin = 'admin',
Moderator = 'mod'
}
export enum Badge {
Old = 1 << 0, // CRSS OG
Supporter = 1 << 1, // "Donator"
}
export enum BadgeNamed {
Old = 'og',
Supporter = 'supporter',
}
export function getBadges(badges: number): BadgeNamed[] {
const result: BadgeNamed[] = [];
if ((badges & Badge.Old) === Badge.Old) {
result.push(BadgeNamed.Old);
}
if ((badges & Badge.Supporter) === Badge.Supporter) {
result.push(BadgeNamed.Supporter);
}
return result;
}

View file

@ -1,12 +1,56 @@
export enum Permission {
Admin = 1 << 0
SuperAdmin = 1 << 0,
Admin = 1 << 1,
ServerPlayer = 1 << 2
}
export enum Role {
Owner = 'owner',
export enum PermissionNamed {
SuperAdmin = 'super_admin',
Admin = 'admin',
ServerPlayer = 'server_player'
}
export function getPermission(permissions: PermissionNamed[]): Permission {
let result = 0;
for (const permission of permissions) {
switch (permission) {
case PermissionNamed.SuperAdmin:
result |= Permission.SuperAdmin;
break;
case PermissionNamed.Admin:
result |= Permission.Admin;
break;
case PermissionNamed.ServerPlayer:
result |= Permission.ServerPlayer;
break;
}
}
return result;
}
export function hasPermission(permissions: number, permission: Permission): boolean {
return (permissions & permission) === permission;
}
export function getPermissions(permissions: number): PermissionNamed[] {
const result: PermissionNamed[] = [];
if ((permissions & Permission.SuperAdmin) === Permission.SuperAdmin) {
result.push(PermissionNamed.SuperAdmin);
}
if ((permissions & Permission.Admin) === Permission.Admin) {
result.push(PermissionNamed.Admin);
}
if ((permissions & Permission.ServerPlayer) === Permission.ServerPlayer) {
result.push(PermissionNamed.ServerPlayer);
}
return result;
}

8
utils/snowflake.ts Normal file
View file

@ -0,0 +1,8 @@
import { Snowflake } from 'nodejs-snowflake';
const snowflake = new Snowflake({
custom_epoch: new Date('2023-07-10T00:00:00Z').getTime(),
instance_id: 1
});
export default snowflake;

52
utils/token_util.ts Normal file
View file

@ -0,0 +1,52 @@
import crypto from 'node:crypto';
import snowflake from './snowflake';
import Discord from '@/lib/Discord';
export interface DiscordTokenData {
token_type: string;
access_token: string;
expires_in: number;
refresh_token: string;
scope: string;
id_token: string;
};
/**
* Creates a session token.
*
* @param oauthData The data returned from the `/oauth2/token` endpoint.
*
* @returns The session token.
*/
export async function createSessionToken(oauthData: DiscordTokenData): Promise<string | undefined> {
const discord = new Discord(oauthData.access_token);
const res = await discord.user();
if (!res.ok)
return undefined;
const dcUser = res.data;
const hmac = crypto
.createHmac('sha256', process.env.AUTH_SECRET!)
.update(oauthData.access_token)
.digest('hex');
const tuid = Buffer
.from(dcUser.id)
.toString('base64')
.replaceAll('=', '');
const tdid = Buffer
.from(
snowflake
.getUniqueID()
.toString()
)
.toString('base64')
.replaceAll('=', '');
return `${tuid}.${tdid}.${hmac}`;
}