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

55
src/actions/admin.ts Normal file
View File

@@ -0,0 +1,55 @@
import { defineAction } from 'astro:actions';
import { db } from '@db/database.ts';
import { z } from 'astro:schema';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
export const admin = {
addAdmin: defineAction({
input: z.object({
username: z.string(),
password: z.string(),
permissions: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
const { id } = await db.addAdmin(input);
return {
id: id
};
}
}),
editAdmin: defineAction({
input: z.object({
id: z.number(),
username: z.string(),
password: z.string().nullable(),
permissions: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
await db.editAdmin(input);
}
}),
deleteAdmin: defineAction({
input: z.object({
id: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
await db.deleteAdmin(input);
}
}),
admins: defineAction({
handler: async (_, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
return {
admins: await db.getAdmins({})
};
}
})
};

49
src/actions/feedback.ts Normal file
View File

@@ -0,0 +1,49 @@
import { defineAction } from 'astro:actions';
import { db } from '@db/database.ts';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { z } from 'astro:schema';
export const feedback = {
addWebsiteFeedback: defineAction({
input: z.object({
content: z.string()
}),
handler: async (input) => {
await db.addFeedback({
event: 'website-feedback',
content: input.content
});
}
}),
addWebsiteContact: defineAction({
input: z.object({
content: z.string(),
email: z.string().email()
}),
handler: async (input) => {
await db.addFeedback({
event: 'website-contact',
content: `${input.content}\n\nEmail: ${input.email}`
});
}
}),
submitFeedback: defineAction({
input: z.object({
urlHash: z.string(),
content: z.string()
}),
handler: async (input) => {
await db.submitFeedback(input);
}
}),
feedbacks: defineAction({
handler: async (_, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Feedback);
return {
feedbacks: await db.getFeedbacks({})
};
}
})
};

19
src/actions/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { session } from './session.ts';
import { signup } from './signup.ts';
import { user } from './user.ts';
import { admin } from './admin.ts';
import { settings } from './settings.ts';
import { feedback } from './feedback.ts';
import { report } from './report.ts';
import { tools } from './tools.ts';
export const server = {
admin,
session,
signup,
user,
report,
feedback,
settings,
tools
};

300
src/actions/report.ts Normal file
View File

@@ -0,0 +1,300 @@
import { ActionError, defineAction } from 'astro:actions';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { db } from '@db/database.ts';
import { z } from 'astro:schema';
import { MAX_UPLOAD_BYTES, UPLOAD_PATH } from 'astro:env/server';
import fs from 'node:fs';
import crypto from 'node:crypto';
import path from 'node:path';
import { sendWebhook, WebhookAction } from '@util/webhook.ts';
import { allowedImageTypes, allowedVideoTypes } from '@util/media.ts';
export const report = {
submitReport: defineAction({
input: z.object({
urlHash: z.string(),
reported: z.string().nullish(),
reason: z.string(),
body: z.string(),
files: z
.array(
z
.instanceof(File)
.refine((f) => [...allowedImageTypes, ...allowedVideoTypes].findIndex((v) => v === f.type) !== -1)
)
.nullable()
}),
handler: async (input) => {
const fileSize = input.files?.reduce((prev, curr) => prev + curr.size, 0);
if (fileSize && fileSize > MAX_UPLOAD_BYTES) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Die Anhänge sind zu groß'
});
}
const report = await db.getReportByUrlHash({ urlHash: input.urlHash });
if (!report) {
throw new ActionError({
code: 'NOT_FOUND'
});
}
let reportedId = report.reported?.id ?? null;
if (input.reported != report.reported?.username) {
if (input.reported == null) reportedId = null;
else {
const reportedUser = await db.getUserByUsername({ username: input.reported });
if (!reportedUser)
throw new ActionError({
code: 'NOT_FOUND'
});
reportedId = reportedUser.id;
}
}
if (!UPLOAD_PATH) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Es dürfen keine Anhänge hochgeladen werden'
});
}
const filePaths = [] as string[];
try {
await db.transaction(async (tx) => {
for (const file of input.files ?? []) {
const uuid = crypto.randomUUID();
const tmpFilePath = path.join(UPLOAD_PATH!, uuid);
const tmpFileStream = fs.createWriteStream(tmpFilePath);
filePaths.push(tmpFilePath);
const md5Hash = crypto.createHash('md5');
for await (const chunk of file.stream()) {
md5Hash.update(chunk);
tmpFileStream.write(chunk);
}
const hash = md5Hash.digest('hex');
const filePath = path.join(UPLOAD_PATH!, hash);
let type: 'image' | 'video';
if (allowedImageTypes.includes(file.type)) {
type = 'image';
} else if (allowedVideoTypes.includes(file.type)) {
type = 'video';
} else {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Invalid file type'
});
}
await tx.addReportAttachment({
type: type,
hash: hash,
reportId: report.id
});
fs.renameSync(tmpFilePath, filePath);
filePaths.pop();
filePaths.push(filePath);
}
await tx.submitReport({
urlHash: input.urlHash,
reportedId: reportedId,
reason: input.reason,
body: input.body
});
});
} catch (e) {
for (const filePath of filePaths) {
fs.rmSync(filePath);
}
throw e;
}
sendWebhook(WebhookAction.Report, {
reporter: report.reporter.username,
reported: report.reported?.username ?? null,
reason: input.reason
});
},
accept: 'form'
}),
addReport: defineAction({
input: z.object({
reason: z.string(),
body: z.string().nullable(),
createdAt: z.string().datetime().nullable(),
reporter: z.number(),
reported: z.number().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
const { id } = await db.addReport({
reason: input.reason,
body: input.body,
createdAt: input.createdAt ? new Date(input.createdAt) : null,
reporterId: input.reporter,
reportedId: input.reported
});
return {
id: id
};
}
}),
editReport: defineAction({
input: z.object({
reportId: z.number(),
reported: z.number().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
await db.editReport({
id: input.reportId,
reportedId: input.reported
});
}
}),
reportStatus: defineAction({
input: z.object({
reportId: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
return {
reportStatus: await db.getReportStatus(input)
};
}
}),
editReportStatus: defineAction({
input: z.object({
reportId: z.number(),
status: z.enum(['open', 'closed']).nullable(),
notice: z.string().nullable(),
statement: z.string().nullable(),
strikeReasonId: z.number().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
let preReportStrike;
if (input.status === 'closed') preReportStrike = await db.getStrikeByReportId({ reportId: input.reportId });
await db.transaction(async (tx) => {
await tx.editReportStatus(input);
if (input.strikeReasonId) {
await db.editStrike({
reportId: input.reportId,
strikeReasonId: input.strikeReasonId
});
} else {
await db.deleteStrike({ reportId: input.reportId });
}
});
if (input.status === 'closed' && preReportStrike?.strikeReason?.id != input.strikeReasonId) {
const report = await db.getReportById({ id: input.reportId });
if (report.reported) {
const user = await db.getUserById({ id: report.reported.id });
// send webhook in background
sendWebhook(WebhookAction.Strike, {
user: user!.uuid!
});
}
}
}
}),
reports: defineAction({
input: z.object({
reporter: z.string().nullish(),
reported: z.string().nullish(),
includeDrafts: z.boolean().nullish()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
return {
reports: await db.getReports(input)
};
}
}),
reportAttachments: defineAction({
input: z.object({
reportId: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
return {
reportAttachments: (await db.getReportAttachments(input)) ?? []
};
}
}),
addStrikeReason: defineAction({
input: z.object({
name: z.string(),
weight: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
return await db.addStrikeReason(input);
}
}),
editStrikeReason: defineAction({
input: z.object({
id: z.number(),
name: z.string(),
weight: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
await db.editStrikeReason(input);
}
}),
deleteStrikeReason: defineAction({
input: z.object({
id: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Admin);
await db.deleteStrikeReason(input);
}
}),
strikeReasons: defineAction({
handler: async (_, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Reports);
return {
strikeReasons: await db.getStrikeReasons({})
};
}
}),
usernames: defineAction({
input: z.object({
username: z.string()
}),
handler: async (input) => {
const users = await db.getUsers({ username: input.username, limit: 5 });
return {
usernames: users.map((u) => u.username)
};
}
})
};

49
src/actions/session.ts Normal file
View File

@@ -0,0 +1,49 @@
import { ActionError, defineAction } from 'astro:actions';
import { z } from 'astro:schema';
import { db } from '@db/database.ts';
import { ADMIN_USER, ADMIN_PASSWORD } from 'astro:env/server';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
export const session = {
login: defineAction({
input: z.object({
username: z.string(),
password: z.string()
}),
handler: async (input, context) => {
let admin;
if (input.username === ADMIN_USER && input.password === ADMIN_PASSWORD) {
admin = {
id: -1,
username: ADMIN_USER,
permissions: new Permissions(Permissions.allPermissions())
};
} else {
admin = await db.existsAdmin(input);
}
if (!admin) {
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'Nutzername und Passwort stimmen nicht überein'
});
}
Session.newSession(admin.id, admin.permissions, context.cookies);
return {
id: admin.id,
username: admin.username,
permissions: admin.permissions.value
};
}
}),
logout: defineAction({
handler: async (_, context) => {
const session = Session.actionSessionFromCookies(context.cookies);
session.invalidate(context.cookies);
}
})
};

23
src/actions/settings.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { db } from '@db/database.ts';
export const settings = {
setSettings: defineAction({
input: z.object({
settings: z.array(
z.object({
name: z.string(),
value: z.string().nullable()
})
)
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Settings);
await db.setSettings(input);
}
})
};

80
src/actions/signup.ts Normal file
View File

@@ -0,0 +1,80 @@
import { ActionError, defineAction } from 'astro:actions';
import { z } from 'astro:schema';
import { db } from '@db/database.ts';
import { getJavaUuid } from '@util/minecraft.ts';
import { getSetting, SettingKey } from '@util/settings.ts';
import { sendWebhook, WebhookAction } from '@util/webhook.ts';
export const signup = {
signup: defineAction({
input: z.object({
firstname: z.string().trim().min(2),
lastname: z.string().trim().min(2),
birthday: z
.string()
.date()
// this will be inaccurate as it is evaluated only once
.max(Date.now() - 1000 * 60 * 60 * 24 * 365 * 6),
phone: z.string().trim().nullable(),
username: z.string().trim(),
edition: z.enum(['java', 'bedrock'])
}),
handler: async (input) => {
// check if signup is allowed
if (!(await getSetting(db, SettingKey.SignupEnabled))) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Die Anmeldung ist derzeit deaktiviert'
});
}
// check if the user were already signed up
if (await db.getUserByUsername({ username: input.username })) {
throw new ActionError({
code: 'CONFLICT',
message: 'Du hast dich bereits registriert'
});
}
let uuid;
try {
uuid = await getJavaUuid(input.username);
} catch (_) {
throw new ActionError({
code: 'NOT_FOUND',
message: `Es wurde kein Minecraft Java Account mit dem Username ${input.username} gefunden`
});
}
// check if user is blocked
if (uuid) {
const blockedUser = await db.getBlockedUserByUuid({ uuid: uuid });
if (blockedUser) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Du bist für die Registrierung gesperrt'
});
}
}
await db.addUser({
firstname: input.firstname,
lastname: input.lastname,
birthday: input.birthday,
telephone: input.phone,
username: input.username,
edition: input.edition,
uuid: uuid
});
sendWebhook(WebhookAction.Signup, {
firstname: input.firstname,
lastname: input.lastname,
birthday: new Date(input.birthday).toISOString().slice(0, 10),
telephone: input.phone,
username: input.username,
edition: input.edition
});
}
})
};

57
src/actions/tools.ts Normal file
View File

@@ -0,0 +1,57 @@
import { ActionError, defineAction } from 'astro:actions';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
import { z } from 'astro:schema';
import { getBedrockUuid, getJavaUuid } from '@util/minecraft.ts';
export const tools = {
uuidFromUsername: defineAction({
input: z.object({
edition: z.enum(['java', 'bedrock']),
username: z.string()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Tools);
let uuid = null;
switch (input.edition) {
case 'java':
try {
uuid = await getJavaUuid(input.username);
} catch (_) {
throw new ActionError({
code: 'NOT_FOUND',
message: `Der Username ${input.username} existiert nicht`
});
}
if (uuid == null) {
throw new ActionError({
code: 'BAD_REQUEST',
message: `Während der Anfrage zur Mojang API ist ein Fehler aufgetreten`
});
}
break;
case 'bedrock':
try {
uuid = await getBedrockUuid(input.username);
} catch (_) {
throw new ActionError({
code: 'NOT_FOUND',
message: `Der Username ${input.username} existiert nicht`
});
}
if (uuid == null) {
throw new ActionError({
code: 'BAD_REQUEST',
message: `Während der Anfrage zum Username Resolver ist ein Fehler aufgetreten`
});
}
break;
}
return {
uuid: uuid
};
}
})
};

148
src/actions/user.ts Normal file
View File

@@ -0,0 +1,148 @@
import { ActionError, defineAction } from 'astro:actions';
import { z } from 'astro:schema';
import { db } from '@db/database.ts';
import { Session } from '@util/session.ts';
import { Permissions } from '@util/permissions.ts';
export const user = {
addUser: defineAction({
input: z.object({
firstname: z.string(),
lastname: z.string(),
birthday: z.string().date(),
telephone: z.string().nullable(),
username: z.string(),
edition: z.enum(['java', 'bedrock']),
uuid: z.string().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
if (await db.existsUser({ username: input.username })) {
throw new ActionError({
code: 'CONFLICT',
message: 'Der Benutzername ist bereits registriert'
});
}
const { id } = await db.addUser({
firstname: input.firstname,
lastname: input.lastname,
birthday: input.birthday,
telephone: input.telephone,
username: input.username,
edition: input.edition,
uuid: input.uuid
});
return {
id: id
};
}
}),
editUser: defineAction({
input: z.object({
id: z.number(),
firstname: z.string(),
lastname: z.string(),
birthday: z.string().date(),
telephone: z.string().nullable(),
username: z.string(),
edition: z.enum(['java', 'bedrock']),
uuid: z.string().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
const user = await db.existsUser({ username: input.username });
if (user && user.id !== input.id) {
throw new ActionError({
code: 'CONFLICT',
message: 'Ein Spieler mit dem Benutzernamen existiert bereits'
});
}
await db.editUser({
id: input.id,
firstname: input.firstname,
lastname: input.lastname,
birthday: input.birthday,
telephone: input.telephone,
username: input.username,
edition: input.edition,
uuid: input.uuid
});
}
}),
deleteUser: defineAction({
input: z.object({
id: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
await db.deleteUser(input);
}
}),
users: defineAction({
input: z.object({
username: z.string().nullish(),
limit: z.number().optional()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
const users = await db.getUsers(input);
return {
users: users
};
}
}),
addBlocked: defineAction({
input: z.object({
uuid: z.string(),
comment: z.string().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
const { id } = await db.addBlockedUser(input);
return {
id: id
};
}
}),
editBlocked: defineAction({
input: z.object({
id: z.number(),
uuid: z.string(),
comment: z.string().nullable()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
await db.editBlockedUser(input);
}
}),
deleteBlocked: defineAction({
input: z.object({
id: z.number()
}),
handler: async (input, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
await db.deleteBlockedUser(input);
}
}),
blocked: defineAction({
handler: async (_, context) => {
Session.actionSessionFromCookies(context.cookies, Permissions.Users);
return {
blocked: await db.getBlockedUsers({})
};
}
})
};