rewrite website

This commit is contained in:
2025-10-13 17:22:49 +02:00
parent a6d910f56a
commit 32f28e5324
263 changed files with 17904 additions and 14451 deletions

35
src/util/action.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}