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.uuid, reported: report.reported?.uuid ?? 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, { uuid: 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) }; } }) };