rewrite website
This commit is contained in:
35
src/util/action.ts
Normal file
35
src/util/action.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type ActionError, isInputError } from 'astro:actions';
|
||||
import { popupState } from '@components/popup/Popup.ts';
|
||||
|
||||
export function actionErrorPopup<E extends Record<string, any>>(
|
||||
error: ActionError<E>,
|
||||
values?: { title?: string; message?: string }
|
||||
) {
|
||||
let title = values?.title;
|
||||
if (title == undefined) {
|
||||
if (isInputError(error)) {
|
||||
title = 'Fehler (ungültige Eingabe)';
|
||||
} else {
|
||||
title = `Fehler (${error.status})`;
|
||||
}
|
||||
}
|
||||
|
||||
let message = values?.message;
|
||||
if (message == undefined) {
|
||||
if (isInputError(error)) {
|
||||
const messages = [];
|
||||
for (const [name, msgs] of Object.entries(error.fields)) {
|
||||
messages.push(`${name}: ${(msgs as string[]).join(', ')}`);
|
||||
}
|
||||
message = messages.join('\n');
|
||||
} else {
|
||||
message = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
popupState.set({
|
||||
type: 'error',
|
||||
title: title,
|
||||
message: message
|
||||
});
|
||||
}
|
||||
11
src/util/log.ts
Normal file
11
src/util/log.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
base: null,
|
||||
level: process.env.LOG_LEVEL || (import.meta.env.DEV ? 'debug' : 'warn'),
|
||||
formatters: {
|
||||
level: (label) => {
|
||||
return { label };
|
||||
}
|
||||
}
|
||||
});
|
||||
2
src/util/media.ts
Normal file
2
src/util/media.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const allowedImageTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/avif'];
|
||||
export const allowedVideoTypes = ['video/mp4', 'video/webm'];
|
||||
57
src/util/minecraft.ts
Normal file
57
src/util/minecraft.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export async function getJavaUuid(username: string) {
|
||||
const response = await fetch(`https://api.mojang.com/users/profiles/minecraft/${username}`);
|
||||
if (!response.ok) {
|
||||
// user doesn't exist
|
||||
if (response.status == 400 || response.status == 404) throw new Error();
|
||||
// rate limit
|
||||
else if (response.status == 429) return null;
|
||||
return null;
|
||||
}
|
||||
const json = await response.json();
|
||||
const id: string = json['id'];
|
||||
// prettier-ignore
|
||||
return `${id.substring(0, 8)}-${id.substring(8, 12)}-${id.substring(12, 16)}-${id.substring(16, 20)}-${id.substring(20)}`;
|
||||
}
|
||||
|
||||
// https://github.com/carlop3333/XUIDGrabber/blob/main/grabber.js
|
||||
export async function getBedrockUuid(username: string): Promise<string | null> {
|
||||
const initialPageResponse = await fetch('https://cxkes.me/xbox/xuid');
|
||||
const initialPageContent = await initialPageResponse.text();
|
||||
const token = /name="_token"\svalue="(?<token>\w+)"/.exec(initialPageContent)?.groups?.token;
|
||||
|
||||
const cookies = initialPageResponse.headers.get('set-cookie')?.split(' ');
|
||||
if (token === undefined || cookies === undefined || cookies.length < 11) return null;
|
||||
|
||||
const requestBody = new URLSearchParams();
|
||||
requestBody.set('_token', token);
|
||||
requestBody.set('gamertag', username);
|
||||
|
||||
const resultPageResponse = await fetch('https://cxkes.me/xbox/xuid', {
|
||||
method: 'post',
|
||||
body: requestBody,
|
||||
// prettier-ignore
|
||||
headers: {
|
||||
'Host': 'www.cxkes.me',
|
||||
'Accept-Encoding': 'gzip, deflate,br',
|
||||
'Content-Length': Buffer.byteLength(requestBody.toString()).toString(),
|
||||
'Origin': 'https://www.cxkes.me',
|
||||
'DNT': '1',
|
||||
'Connection': 'keep-alive',
|
||||
'Referer': 'https://www.cxkes.me/xbox/xuid',
|
||||
'Cookie': `${cookies[0]} ${cookies[10].slice(0, cookies[10].length - 1)}`,
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'Sec-Fectch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'same-origin',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,es;q=0.8,en-US;q=0.5,en;q=0.3',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
});
|
||||
const resultPageContent = await resultPageResponse.text();
|
||||
let xuid: string | undefined;
|
||||
if ((xuid = /id="xuidHex">(?<xuid>\w+)</.exec(resultPageContent)?.groups?.xuid) === undefined) throw new Error();
|
||||
return `00000000-0000-0000-${xuid.substring(0, 4)}-${xuid.substring(4)}`;
|
||||
}
|
||||
8
src/util/objects.ts
Normal file
8
src/util/objects.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function getObjectEntryByKey(key: string, data: { [key: string]: any }): any | undefined {
|
||||
let entry = data;
|
||||
for (const part of key.split('.')) {
|
||||
entry = entry[part];
|
||||
if (entry === null || typeof entry !== 'object') return entry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
79
src/util/permissions.ts
Normal file
79
src/util/permissions.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export class Permissions {
|
||||
static readonly Admin = new Permissions(2 << 0);
|
||||
static readonly Users = new Permissions(2 << 1);
|
||||
static readonly Reports = new Permissions(2 << 2);
|
||||
static readonly Feedback = new Permissions(2 << 3);
|
||||
static readonly Settings = new Permissions(2 << 4);
|
||||
static readonly Tools = new Permissions(2 << 5);
|
||||
|
||||
readonly value: number;
|
||||
|
||||
constructor(value: number | number[] | Permissions[] | null) {
|
||||
if (value == null) {
|
||||
this.value = 0;
|
||||
} else if (typeof value == 'number') {
|
||||
this.value = value;
|
||||
} else if (Array.isArray(value)) {
|
||||
let finalValue = 0;
|
||||
for (const v of value) {
|
||||
if (typeof v == 'number') {
|
||||
finalValue |= v;
|
||||
} else {
|
||||
finalValue |= v.value;
|
||||
}
|
||||
}
|
||||
this.value = finalValue;
|
||||
} else {
|
||||
throw 'Invalid arguments';
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
static asOptions() {
|
||||
return {
|
||||
[Permissions.Admin.value]: 'Admin',
|
||||
[Permissions.Users.value]: 'Users',
|
||||
[Permissions.Reports.value]: 'Reports',
|
||||
[Permissions.Feedback.value]: 'Feedback',
|
||||
[Permissions.Settings.value]: 'Settings',
|
||||
[Permissions.Tools.value]: 'Tools'
|
||||
};
|
||||
}
|
||||
|
||||
get admin() {
|
||||
return (this.value & Permissions.Admin.value) != 0;
|
||||
}
|
||||
get users() {
|
||||
return (this.value & Permissions.Users.value) != 0;
|
||||
}
|
||||
get reports() {
|
||||
return (this.value & Permissions.Reports.value) != 0;
|
||||
}
|
||||
get feedback() {
|
||||
return (this.value & Permissions.Feedback.value) != 0;
|
||||
}
|
||||
get settings() {
|
||||
return (this.value & Permissions.Settings.value) != 0;
|
||||
}
|
||||
get tools() {
|
||||
return (this.value & Permissions.Tools.value) != 0;
|
||||
}
|
||||
|
||||
hasPermissions(other: Permissions) {
|
||||
return (other.value & this.value) == other.value;
|
||||
}
|
||||
|
||||
static allPermissions() {
|
||||
return [
|
||||
Permissions.Admin,
|
||||
Permissions.Users,
|
||||
Permissions.Reports,
|
||||
Permissions.Feedback,
|
||||
Permissions.Settings,
|
||||
Permissions.Tools
|
||||
];
|
||||
}
|
||||
}
|
||||
5
src/util/random.ts
Normal file
5
src/util/random.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export function generateRandomString(length: number) {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
}
|
||||
77
src/util/session.ts
Normal file
77
src/util/session.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { AstroCookies, AstroCookieSetOptions } from 'astro';
|
||||
import { ActionError } from 'astro:actions';
|
||||
import crypto from 'node:crypto';
|
||||
import { Permissions } from './permissions.ts';
|
||||
import { ADMIN_COOKIE } from 'astro:env/server';
|
||||
|
||||
export class Session {
|
||||
static readonly #cookieOptions: AstroCookieSetOptions = {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
sameSite: 'lax'
|
||||
};
|
||||
static #sessions: Session[] = [];
|
||||
|
||||
readonly sessionId: string;
|
||||
readonly adminId: number;
|
||||
readonly permissions: Permissions;
|
||||
|
||||
private constructor(sessionId: string, adminId: number, permissions: Permissions) {
|
||||
this.sessionId = sessionId;
|
||||
this.adminId = adminId;
|
||||
this.permissions = permissions;
|
||||
|
||||
Session.#sessions.push(this);
|
||||
}
|
||||
|
||||
invalidate(cookies?: AstroCookies) {
|
||||
for (let i = 0; i < Session.#sessions.length; i++) {
|
||||
if (Session.#sessions[i] == this) {
|
||||
Session.#sessions = Session.#sessions.splice(i, 1);
|
||||
if (cookies) cookies.delete(ADMIN_COOKIE, Session.#cookieOptions);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static newSession(adminId: number, permissions: Permissions, cookies: AstroCookies) {
|
||||
const session = new Session(crypto.randomBytes(16).toString('hex'), adminId, permissions);
|
||||
Session.#sessions.push(session);
|
||||
|
||||
cookies.set(ADMIN_COOKIE, session.sessionId, Session.#cookieOptions);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
static sessionFromCookies(cookies: AstroCookies, neededPermissions?: Permissions) {
|
||||
const sessionId = cookies.get(ADMIN_COOKIE);
|
||||
if (!sessionId) return null;
|
||||
|
||||
for (const session of Session.#sessions) {
|
||||
if (session.sessionId == sessionId.value) {
|
||||
if (neededPermissions && !session.permissions.hasPermissions(neededPermissions)) {
|
||||
break;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static actionSessionFromCookies(cookies: AstroCookies, neededPermissions?: Permissions) {
|
||||
const sessionId = cookies.get(ADMIN_COOKIE);
|
||||
if (!sessionId) throw new ActionError({ code: 'UNAUTHORIZED' });
|
||||
|
||||
for (const session of Session.#sessions) {
|
||||
if (session.sessionId == sessionId.value) {
|
||||
if (neededPermissions && !session.permissions.hasPermissions(neededPermissions)) {
|
||||
throw new ActionError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
throw new ActionError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
}
|
||||
52
src/util/settings.ts
Normal file
52
src/util/settings.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Database } from '@db/database.ts';
|
||||
|
||||
export async function setSettings<K extends SettingKey>(db: Database, settings: { [k in K]: SettingKeyValueType<K> }) {
|
||||
const dbSettings = Object.entries(settings).map(([name, value]) => ({ name, value: JSON.stringify(value) }));
|
||||
|
||||
await db.setSettings({ settings: dbSettings });
|
||||
}
|
||||
|
||||
export async function getSetting<K extends SettingKey>(db: Database, key: K): Promise<SettingKeyValueType<K> | null>;
|
||||
export async function getSetting<K extends SettingKey, D extends SettingKeyValueType<K>>(
|
||||
db: Database,
|
||||
key: K,
|
||||
defaultValue: D
|
||||
): Promise<SettingKeyValueType<K>>;
|
||||
export async function getSetting<K extends SettingKey, D extends SettingKeyValueType<K>>(
|
||||
db: Database,
|
||||
key: K,
|
||||
defaultValue?: D
|
||||
) {
|
||||
const setting = await db.getSetting({ name: key });
|
||||
return setting != null ? JSON.parse(setting) : defaultValue != undefined ? defaultValue : null;
|
||||
}
|
||||
|
||||
export async function getSettings<K extends SettingKey>(
|
||||
db: Database
|
||||
): Promise<{ [k in K]: SettingKeyValueType<K> | null }>;
|
||||
export async function getSettings<K extends SettingKey[]>(
|
||||
db: Database,
|
||||
keys: K
|
||||
): Promise<{ [k in K[number]]: SettingKeyValueType<k> | null }>;
|
||||
export async function getSettings<K extends SettingKey[]>(db: Database, keys?: K) {
|
||||
const settings = await db.getSettings({ names: keys });
|
||||
|
||||
return settings.reduce(
|
||||
(prev, curr) => Object.assign(prev, { [curr.name]: curr.value != null ? JSON.parse(curr.value) : null }),
|
||||
{} as { [k in K[number]]: SettingKeyValueType<k> | null }
|
||||
);
|
||||
}
|
||||
|
||||
export enum SettingKey {
|
||||
SignupEnabled = 'signup.enabled',
|
||||
SignupInfoMessage = 'signup.infoMessage',
|
||||
SignupDisabledMessage = 'signup.disabledMessage',
|
||||
SignupDisabledSubMessage = 'signup.disabledSubMessage'
|
||||
}
|
||||
|
||||
export type SettingKeyValueType<K extends SettingKey> = {
|
||||
[SettingKey.SignupEnabled]: boolean;
|
||||
[SettingKey.SignupInfoMessage]: string;
|
||||
[SettingKey.SignupDisabledMessage]: string;
|
||||
[SettingKey.SignupDisabledSubMessage]: string;
|
||||
}[K];
|
||||
7
src/util/sleep.ts
Normal file
7
src/util/sleep.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export async function sleepMilliseconds(milliseconds: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
export async function sleepSeconds(seconds: number) {
|
||||
await sleepMilliseconds(seconds * 1000);
|
||||
}
|
||||
24
src/util/state.ts
Normal file
24
src/util/state.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export function addToWritableArray<T>(writable: Writable<T[]>, t: T) {
|
||||
writable.update((old) => {
|
||||
old.push(t);
|
||||
return old;
|
||||
});
|
||||
}
|
||||
|
||||
export function updateWritableArray<T>(writable: Writable<T[]>, t: T, cmp: (t: T) => boolean) {
|
||||
writable.update((old) => {
|
||||
const index = old.findIndex(cmp);
|
||||
old[index] = t;
|
||||
return old;
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteFromWritableArray<T>(writable: Writable<T[]>, cmp: (t: T) => boolean) {
|
||||
writable.update((old) => {
|
||||
const index = old.findIndex(cmp);
|
||||
old.splice(index, 1);
|
||||
return old;
|
||||
});
|
||||
}
|
||||
48
src/util/webhook.ts
Normal file
48
src/util/webhook.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { sleepSeconds } from './sleep.ts';
|
||||
import { WEBHOOK_ENDPOINT } from 'astro:env/server';
|
||||
|
||||
export enum WebhookAction {
|
||||
Signup = 'signup',
|
||||
Report = 'report',
|
||||
Strike = 'strike'
|
||||
}
|
||||
|
||||
export type WebhookActionType<T extends WebhookAction> = {
|
||||
[WebhookAction.Signup]: {
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
birthday: string;
|
||||
telephone: string | null;
|
||||
username: string;
|
||||
edition: 'java' | 'bedrock';
|
||||
};
|
||||
[WebhookAction.Report]: {
|
||||
reporter: string | null;
|
||||
reported: string | null;
|
||||
reason: string;
|
||||
};
|
||||
[WebhookAction.Strike]: {
|
||||
user: string;
|
||||
};
|
||||
}[T];
|
||||
|
||||
export async function sendWebhook<T extends WebhookAction>(action: T, data: WebhookActionType<T>) {
|
||||
if (!WEBHOOK_ENDPOINT || !/^https?:\/\/.+$/.test(WEBHOOK_ENDPOINT)) return;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(WEBHOOK_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-webhook-action': action
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.status === 200) return;
|
||||
} catch (_) {}
|
||||
|
||||
await sleepSeconds(60);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user