From ee8f595eccb2bedb472e287f7a52512093d335a2 Mon Sep 17 00:00:00 2001 From: bytedream Date: Sat, 21 Jun 2025 14:45:39 +0200 Subject: [PATCH] add feedback and report things --- .env.example | 2 + astro.config.mjs | 3 + src/actions/feedback.ts | 9 + src/actions/report.ts | 119 +++++++++++- src/app/website/report/AdversarySearch.svelte | 77 ++++++++ src/app/website/report/Dropzone.svelte | 183 ++++++++++++++++++ src/components/admin/search/Search.svelte | 22 ++- src/components/admin/search/TeamSearch.svelte | 17 +- src/components/admin/search/UserSearch.svelte | 17 +- src/components/input/Select.svelte | 4 +- src/components/popup/Popup.svelte | 1 + src/components/popup/Popup.ts | 4 +- src/db/database.sql | 9 + src/db/database.ts | 42 +++- src/db/schema/feedback.ts | 29 ++- src/db/schema/report.ts | 96 ++++++++- src/db/schema/reportAttachment.ts | 39 ++++ src/db/schema/settings.ts | 3 +- src/db/schema/strike.ts | 13 ++ src/db/schema/team.ts | 25 ++- src/db/schema/teamMember.ts | 14 ++ src/pages/feedback/[urlHash].astro | 66 +++++++ src/pages/report/[urlHash].astro | 23 +++ src/pages/report/_draft.astro | 105 ++++++++++ src/pages/report/_submitted.astro | 33 ++++ 25 files changed, 898 insertions(+), 57 deletions(-) create mode 100644 src/app/website/report/AdversarySearch.svelte create mode 100644 src/app/website/report/Dropzone.svelte create mode 100644 src/db/schema/reportAttachment.ts create mode 100644 src/pages/feedback/[urlHash].astro create mode 100644 src/pages/report/[urlHash].astro create mode 100644 src/pages/report/_draft.astro create mode 100644 src/pages/report/_submitted.astro diff --git a/.env.example b/.env.example index 8a842af..2ecc2a7 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ ADMIN_USER=admin ADMIN_PASSWORD=admin ADMIN_COOKIE=muelleel +UPLOAD_PATH=/tmp + YOUTUBE_INTRO_LINK=https://www.youtube-nocookie.com/embed/e78_QbTNb4s TEAMSPEAK_LINK=http://example.com DISCORD_LINK=http://example.com diff --git a/astro.config.mjs b/astro.config.mjs index a7a7d21..8029926 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -38,6 +38,9 @@ export default defineConfig({ ADMIN_PASSWORD: envField.string({ context: 'server', access: 'secret', optional: true }), ADMIN_COOKIE: envField.string({ context: 'server', access: 'secret', default: 'muelleel' }), + UPLOAD_PATH: envField.string({ context: 'server', access: 'secret', optional: true }), + MAX_UPLOAD_BYTES: envField.number({ context: 'server', access: 'secret', default: 20 * 1024 * 1024 }), + START_DATE: envField.string({ context: 'server', access: 'secret', default: '1970-01-01' }), WEBHOOK_ENDPOINT: envField.string({ context: 'server', access: 'secret', optional: true }), diff --git a/src/actions/feedback.ts b/src/actions/feedback.ts index f8df930..7678052 100644 --- a/src/actions/feedback.ts +++ b/src/actions/feedback.ts @@ -28,6 +28,15 @@ export const feedback = { }); } }), + 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); diff --git a/src/actions/report.ts b/src/actions/report.ts index 9f47f87..c0a7f29 100644 --- a/src/actions/report.ts +++ b/src/actions/report.ts @@ -1,10 +1,84 @@ -import { defineAction } from 'astro:actions'; +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 { 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'; export const report = { + submitReport: defineAction({ + input: z.object({ + urlHash: z.string(), + reason: z.string(), + body: z.string(), + files: z.array(z.instanceof(File)).nullable() + }), + handler: async (input) => { + const report = await db.getReportByUrlHash({ urlHash: input.urlHash }); + if (!report) { + throw new ActionError({ + code: 'NOT_FOUND' + }); + } + + 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); + + await tx.addReportAttachment({ + type: 'video', + hash: hash, + reportId: report.id + }); + + fs.renameSync(tmpFilePath, filePath); + filePaths.pop(); + filePaths.push(filePath); + } + + await tx.submitReport({ + urlHash: input.urlHash, + reason: input.reason, + body: input.body + }); + }); + } catch (e) { + for (const filePath of filePaths) { + fs.rmSync(filePath); + } + + throw e; + } + }, + accept: 'form' + }), addReport: defineAction({ input: z.object({ reason: z.string(), @@ -19,7 +93,7 @@ export const report = { const { id } = await db.addReport({ reason: input.reason, body: input.body, - createdAt: input.createdAt, + createdAt: input.createdAt ? new Date(input.createdAt) : null, reporterTeamId: input.reporter, reportedTeamId: input.reported }); @@ -52,6 +126,9 @@ export const report = { 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); @@ -62,6 +139,20 @@ export const report = { }); } }); + + if (input.status === 'closed' && preReportStrike?.strikeReasonId != input.strikeReasonId) { + const report = await db.getReportById({ id: input.reportId }); + if (report.reported) { + const strikes = await db.getStrikesByTeamId({ teamId: report.reported.id }); + const teamMembers = await db.getTeamMembersByTeamId({ teamId: report.reported.id }); + + // send webhook in background + sendWebhook(WebhookAction.Strike, { + users: teamMembers.map((tm) => tm.user.uuid!), + totalWeight: strikes.map((strike) => strike.reason.weight).reduce((a, b) => a + b, 0) + }); + } + } } }), reports: defineAction({ @@ -118,5 +209,29 @@ export const report = { strikeReasons: await db.getStrikeReasons({}) }; } + }), + teamNamesByUsername: defineAction({ + input: z.object({ + username: z.string().nullish() + }), + handler: async (input) => { + const teams = await db.getTeamsByUsername({ username: input.username ?? '', limit: 5 }); + + return { + teamNames: teams.map((team) => ({ name: team.user.username, value: team.team.name })) + }; + } + }), + teamNamesByTeamName: defineAction({ + input: z.object({ + teamName: z.string().nullish() + }), + handler: async (input) => { + const teams = await db.getTeams({ name: input.teamName, limit: 5 }); + + return { + teamNames: teams.map((team) => ({ name: team.name, value: team.name })) + }; + } }) }; diff --git a/src/app/website/report/AdversarySearch.svelte b/src/app/website/report/AdversarySearch.svelte new file mode 100644 index 0000000..81023ab --- /dev/null +++ b/src/app/website/report/AdversarySearch.svelte @@ -0,0 +1,77 @@ + + +
+
+ + + setTimeout(() => (previewUploadFile = null), 300)}> + + + diff --git a/src/components/admin/search/Search.svelte b/src/components/admin/search/Search.svelte index 26ceebc..e21261c 100644 --- a/src/components/admin/search/Search.svelte +++ b/src/components/admin/search/Search.svelte @@ -8,9 +8,9 @@ required?: boolean; mustMatch?: boolean; - requestSuggestions: (query: string, limit: number) => Promise; + requestSuggestions: (query: string, limit: number) => Promise<{ name: string; value: string }[]>; - onSubmit?: (value: string | null) => void; + onSubmit?: (value: { name: string; value: string } | null) => void; } // html bindings @@ -21,7 +21,7 @@ // states let inputValue = $derived(value); - let suggestions = $state([]); + let suggestions = $state<{ name: string; value: string }[]>([]); let matched = $state(false); // callbacks @@ -34,9 +34,9 @@ suggestions = await requestSuggestions(inputValue ?? '', 5); - let suggestion = suggestions.find((s) => s === inputValue); + let suggestion = suggestions.find((s) => s.name === inputValue); if (suggestion != null) { - inputValue = value = suggestion; + inputValue = value = suggestion.name; matched = true; onSubmit?.(suggestion); } else if (!mustMatch) { @@ -49,8 +49,10 @@ } } - function onSuggestionClick(suggestion: string) { - inputValue = value = suggestion; + function onSuggestionClick(name: string) { + const suggestion = suggestions.find((s) => s.name === name)!; + + inputValue = value = suggestion.name; suggestions = []; onSubmit?.(suggestion); } @@ -77,7 +79,7 @@ bind:value={inputValue} oninput={() => onSearchInput()} onfocusin={() => onSearchInput()} - pattern={mustMatch && matched ? `^(${suggestions.join('|')})$` : undefined} + pattern={mustMatch && matched ? `^(${suggestions.map((s) => s.name).join('|')})$` : undefined} /> {#if suggestions.length > 0}