···4444# get your API key from https://platform.openai.com/account/api-keys
4545OPENAI_API_KEY=some-openai-api-key
46464747-# discord webhooks
4848-# send a webhook message for each successful upload
4747+# upload logging - posts to external services when levels are uploaded/updated/deleted
4848+# supports multiple providers (discord, slack, bluesky, etc.) via the logging module
4949USE_UPLOAD_LOGGING=true
5050-DISCORD_UPLOAD_WEBHOOK_URL=https://discord.com/api/webhooks/some-upload-webhook-url
5151-# send a webhook message for reports
5050+5151+# discord logging provider
5252+# create a bot at https://discord.com/developers/applications
5353+# bot needs "Send Messages" and "Manage Messages" permissions in the channels
5454+DISCORD_BOT_TOKEN=your-discord-bot-token
5555+DISCORD_UPLOAD_CHANNEL_ID=your-upload-channel-id
5656+# optional: separate channel for admin notifications (with edit/delete buttons)
5757+DISCORD_ADMIN_UPLOAD_CHANNEL_ID=your-admin-upload-channel-id
5858+5959+# reporting - posts to discord when levels are reported
5260USE_REPORTING=true
5353-DISCORD_REPORT_WEBHOOK_URL=https://discord.com/api/webhooks/some-report-webhook-url
6161+DISCORD_REPORT_CHANNEL_ID=your-report-channel-id
5462# user ids to mention in reports (prefix with & for a role id)
5563DISCORD_MENTION_USER_IDS=some-user-id1,some-user-id2,&some-role-id
6464+6565+# audit log - posts to discord when admins make changes (edit/delete/feature)
6666+DISCORD_AUDIT_CHANNEL_ID=your-audit-channel-id
···11-# PVZM Backend 
11+# PVZM Backend 
2233> A Deno-powered backend service for [Plants vs. Zombies: MODDED](https://github.com/roblnet13/pvz). This service provides APIs for uploading, downloading, listing, favoriting, and reporting user-created _I, Zombie_ levels.
44
···11+export type { LevelInfo, LoggingProvider } from "./types.ts";
22+export { LoggingManager, type ProviderMessageIds } from "./manager.ts";
33+export { DiscordLoggingProvider, type DiscordProviderConfig } from "./discord.ts";
44+export { BlueskyLoggingProvider, type BlueskyProviderConfig } from "./bluesky.ts";
+211
modules/logging/manager.ts
···11+import type { LevelInfo, AdminLevelInfo, LoggingProvider, ReportInfo, AuditLogEntry } from "./types.ts";
22+33+/**
44+ * stores message IDs for each provider as JSON: { "discord": "123", "slack": "456" }
55+ */
66+export type ProviderMessageIds = Record<string, string>;
77+88+export class LoggingManager {
99+ private providers: LoggingProvider[] = [];
1010+1111+ addProvider(provider: LoggingProvider): void {
1212+ this.providers.push(provider);
1313+ }
1414+1515+ async initAll(): Promise<void> {
1616+ const results = await Promise.allSettled(
1717+ this.providers.map(async (provider) => {
1818+ const success = await provider.init();
1919+ return { name: provider.name, success };
2020+ })
2121+ );
2222+2323+ for (const result of results) {
2424+ if (result.status === "rejected") {
2525+ console.error("Logging provider init rejected:", result.reason);
2626+ }
2727+ }
2828+2929+ // filter out providers that failed to initialize
3030+ const successfulProviders: LoggingProvider[] = [];
3131+ for (let i = 0; i < this.providers.length; i++) {
3232+ const result = results[i];
3333+ if (result.status === "fulfilled" && result.value.success) {
3434+ successfulProviders.push(this.providers[i]);
3535+ }
3636+ }
3737+ this.providers = successfulProviders;
3838+3939+ console.log(`Logging manager: ${this.providers.length} provider(s) active: ${this.providers.map((p) => p.name).join(", ") || "(none)"}`);
4040+ }
4141+4242+ /**
4343+ * send a level message to all providers
4444+ * returns a JSON string of provider -> messageId mappings
4545+ */
4646+ async sendLevelMessage(level: LevelInfo): Promise<string | null> {
4747+ if (this.providers.length === 0) return null;
4848+4949+ const messageIds: ProviderMessageIds = {};
5050+5151+ await Promise.allSettled(
5252+ this.providers.map(async (provider) => {
5353+ const messageId = await provider.sendLevelMessage(level);
5454+ if (messageId) {
5555+ messageIds[provider.name] = messageId;
5656+ }
5757+ })
5858+ );
5959+6060+ if (Object.keys(messageIds).length === 0) return null;
6161+ return JSON.stringify(messageIds);
6262+ }
6363+6464+ /**
6565+ * send an admin level message to all providers
6666+ * returns a JSON string of provider -> messageId mappings
6767+ */
6868+ async sendAdminLevelMessage(level: AdminLevelInfo): Promise<string | null> {
6969+ if (this.providers.length === 0) return null;
7070+7171+ const messageIds: ProviderMessageIds = {};
7272+7373+ await Promise.allSettled(
7474+ this.providers.map(async (provider) => {
7575+ if ("sendAdminLevelMessage" in provider) {
7676+ const messageId = await (provider as any).sendAdminLevelMessage(level);
7777+ if (messageId) {
7878+ messageIds[provider.name] = messageId;
7979+ }
8080+ }
8181+ })
8282+ );
8383+8484+ if (Object.keys(messageIds).length === 0) return null;
8585+ return JSON.stringify(messageIds);
8686+ }
8787+8888+ /**
8989+ * edit a level message across all providers
9090+ * messageIdsJson is the JSON string stored in the database
9191+ */
9292+ async editLevelMessage(messageIdsJson: string | null, level: LevelInfo): Promise<void> {
9393+ if (!messageIdsJson || this.providers.length === 0) return;
9494+9595+ let messageIds: ProviderMessageIds;
9696+ try {
9797+ messageIds = JSON.parse(messageIdsJson);
9898+ } catch {
9999+ return;
100100+ }
101101+102102+ await Promise.allSettled(
103103+ this.providers.map(async (provider) => {
104104+ const messageId = messageIds[provider.name];
105105+ if (messageId) {
106106+ await provider.editLevelMessage(messageId, level);
107107+ }
108108+ })
109109+ );
110110+ }
111111+112112+ /**
113113+ * edits an admin level message across all providers
114114+ * messageIdsJson is the JSON string stored in the database
115115+ */
116116+ async editAdminLevelMessage(messageIdsJson: string | null, level: AdminLevelInfo): Promise<void> {
117117+ if (!messageIdsJson || this.providers.length === 0) return;
118118+119119+ let messageIds: ProviderMessageIds;
120120+ try {
121121+ messageIds = JSON.parse(messageIdsJson);
122122+ } catch {
123123+ return;
124124+ }
125125+126126+ await Promise.allSettled(
127127+ this.providers.map(async (provider) => {
128128+ const messageId = messageIds[provider.name];
129129+ if (messageId && "editAdminLevelMessage" in provider) {
130130+ await (provider as any).editAdminLevelMessage(messageId, level);
131131+ }
132132+ })
133133+ );
134134+ }
135135+136136+ /**
137137+ * delete a level message across all providers
138138+ * messageIdsJson is the JSON string stored in the database
139139+ */
140140+ async deleteLevelMessage(messageIdsJson: string | null): Promise<void> {
141141+ if (!messageIdsJson || this.providers.length === 0) return;
142142+143143+ let messageIds: ProviderMessageIds;
144144+ try {
145145+ messageIds = JSON.parse(messageIdsJson);
146146+ } catch {
147147+ return;
148148+ }
149149+150150+ await Promise.allSettled(
151151+ this.providers.map(async (provider) => {
152152+ const messageId = messageIds[provider.name];
153153+ if (messageId) {
154154+ await provider.deleteLevelMessage(messageId);
155155+ }
156156+ })
157157+ );
158158+ }
159159+160160+ /**
161161+ * delete an admin level message across all providers
162162+ * messageIdsJson is the JSON string stored in the database
163163+ */
164164+ async deleteAdminLevelMessage(messageIdsJson: string | null): Promise<void> {
165165+ if (!messageIdsJson || this.providers.length === 0) return;
166166+167167+ let messageIds: ProviderMessageIds;
168168+ try {
169169+ messageIds = JSON.parse(messageIdsJson);
170170+ } catch {
171171+ return;
172172+ }
173173+174174+ await Promise.allSettled(
175175+ this.providers.map(async (provider) => {
176176+ const messageId = messageIds[provider.name];
177177+ if (messageId && "deleteAdminLevelMessage" in provider) {
178178+ await (provider as any).deleteAdminLevelMessage(messageId);
179179+ }
180180+ })
181181+ );
182182+ }
183183+184184+ /**
185185+ * send a report message to all providers
186186+ */
187187+ async sendReportMessage(report: ReportInfo): Promise<void> {
188188+ if (this.providers.length === 0) return;
189189+190190+ await Promise.allSettled(this.providers.map((provider) => provider.sendReportMessage(report)));
191191+ }
192192+193193+ /**
194194+ * send an audit log entry to all providers
195195+ */
196196+ async sendAuditLog(entry: AuditLogEntry): Promise<void> {
197197+ if (this.providers.length === 0) return;
198198+199199+ await Promise.allSettled(
200200+ this.providers.map(async (provider) => {
201201+ if ("sendAuditLog" in provider) {
202202+ await (provider as any).sendAuditLog(entry);
203203+ }
204204+ })
205205+ );
206206+ }
207207+208208+ get hasProviders(): boolean {
209209+ return this.providers.length > 0;
210210+ }
211211+}
+70
modules/logging/types.ts
···11+export type LevelInfo = {
22+ id: number;
33+ name: string;
44+ author: string;
55+ gameUrl: string;
66+ backendUrl: string;
77+};
88+99+export type AdminLevelInfo = LevelInfo & {
1010+ editUrl: string;
1111+ deleteUrl: string;
1212+};
1313+1414+export type AuditLogEntry = {
1515+ action: "edit" | "delete" | "feature" | "unfeature";
1616+ levelId: number;
1717+ levelName: string;
1818+ author: string;
1919+ changes?: string;
2020+};
2121+2222+export type ReportInfo = {
2323+ levelId: number;
2424+ levelName: string;
2525+ author: string;
2626+ reason: string;
2727+ reporterIp: string;
2828+ editUrl: string;
2929+ deleteUrl: string;
3030+ viewUrl: string;
3131+ mentionUserIds: string[];
3232+ fileAttachment?: {
3333+ content: Uint8Array;
3434+ fileName: string;
3535+ };
3636+};
3737+3838+export interface LoggingProvider {
3939+ readonly name: string;
4040+4141+ /**
4242+ * initialize the provider (connect to service, authenticate, etc.)
4343+ * returns true if initialization was successful
4444+ */
4545+ init(): Promise<boolean>;
4646+4747+ /**
4848+ * send a new level upload message
4949+ * returns a provider-specific message ID, or null if failed
5050+ */
5151+ sendLevelMessage(level: LevelInfo): Promise<string | null>;
5252+5353+ /**
5454+ * edit an existing level message (e.g., when name/author changes)
5555+ * returns true if successful
5656+ */
5757+ editLevelMessage(messageId: string, level: LevelInfo): Promise<boolean>;
5858+5959+ /**
6060+ * delete a level message (e.g., when level is deleted)
6161+ * returns true if successful
6262+ */
6363+ deleteLevelMessage(messageId: string): Promise<boolean>;
6464+6565+ /**
6666+ * send a report message (e.g., when a user reports a level)
6767+ * returns true if successful
6868+ */
6969+ sendReportMessage(report: ReportInfo): Promise<boolean>;
7070+}
+96-4
modules/routes/admin.ts
···11+import type { ServerConfig } from "../config.ts";
12import type { DbContext, LevelRecord } from "../db.ts";
33+import type { LoggingManager } from "../logging/index.ts";
24import { decodeLevelFromDisk, encodeIZL3FileToDisk } from "../levels_io.ts";
3546export function registerAdminRoutes(
57 app: any,
88+ config: ServerConfig,
69 dbCtx: DbContext,
710 deps: {
811 ensureAuthenticated: any;
912 ensureAuthenticatedOrConsumeTokenForLevelParam: any;
1313+ loggingManager: LoggingManager;
1014 }
1115) {
1216 // get all levels with pagination and search
···161165 encodeIZL3FileToDisk(dbCtx.dataFolderPath, levelId, levelData.decoded);
162166 }
163167 }
168168+169169+ // update logging messages if name or author changed
170170+ const typedUpdatedLevel = updatedLevel as LevelRecord;
171171+ const changes: string[] = [];
172172+ if (name !== undefined && name !== existingLevel.name) {
173173+ changes.push(`Name: "${existingLevel.name}" → "${name}"`);
174174+ }
175175+ if (author !== undefined && author !== existingLevel.author) {
176176+ changes.push(`Author: "${existingLevel.author}" → "${author}"`);
177177+ }
178178+ if (sun !== undefined && sun !== existingLevel.sun) {
179179+ changes.push(`Sun: ${existingLevel.sun} → ${sun}`);
180180+ }
181181+ if (difficulty !== undefined && difficulty !== existingLevel.difficulty) {
182182+ changes.push(`Difficulty: ${existingLevel.difficulty} → ${difficulty}`);
183183+ }
184184+ if (favorites !== undefined && favorites !== existingLevel.favorites) {
185185+ changes.push(`Favorites: ${existingLevel.favorites} → ${favorites}`);
186186+ }
187187+ if (plays !== undefined && plays !== existingLevel.plays) {
188188+ changes.push(`Plays: ${existingLevel.plays} → ${plays}`);
189189+ }
190190+191191+ if (changes.length > 0) {
192192+ const levelInfo = {
193193+ id: levelId,
194194+ name: typedUpdatedLevel.name,
195195+ author: typedUpdatedLevel.author,
196196+ gameUrl: config.gameUrl,
197197+ backendUrl: config.backendUrl,
198198+ };
199199+200200+ if (typedUpdatedLevel.logging_data) {
201201+ await deps.loggingManager.editLevelMessage(typedUpdatedLevel.logging_data, levelInfo);
202202+ }
203203+ if (typedUpdatedLevel.admin_logging_data) {
204204+ const adminLevelInfo = {
205205+ ...levelInfo,
206206+ editUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent(
207207+ dbCtx.createOneTimeTokenForLevel(levelId)
208208+ )}&action=edit&level=${levelId}`,
209209+ deleteUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent(
210210+ dbCtx.createOneTimeTokenForLevel(levelId)
211211+ )}&action=delete&level=${levelId}`,
212212+ };
213213+ await deps.loggingManager.editAdminLevelMessage(typedUpdatedLevel.admin_logging_data, adminLevelInfo);
214214+ }
215215+216216+ await deps.loggingManager.sendAuditLog({
217217+ action: "edit",
218218+ levelId,
219219+ levelName: typedUpdatedLevel.name,
220220+ author: typedUpdatedLevel.author,
221221+ changes: changes.join("\n"),
222222+ });
223223+ }
224224+164225 res.json({
165226 success: true,
166227 level: updatedLevel,
···175236 });
176237177238 // feature a level (admin only)
178178- app.post("/api/admin/levels/:id/feature", deps.ensureAuthenticated, (req: any, res: any) => {
239239+ app.post("/api/admin/levels/:id/feature", deps.ensureAuthenticated, async (req: any, res: any) => {
179240 try {
180241 const levelId = parseInt(req.params.id);
181242···191252 const now = Math.floor(Date.now() / 1000);
192253 dbCtx.db.prepare("UPDATE levels SET featured = 1, featured_at = ? WHERE id = ?").run(now, levelId);
193254194194- const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId);
255255+ const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId) as LevelRecord;
256256+257257+ await deps.loggingManager.sendAuditLog({
258258+ action: "feature",
259259+ levelId,
260260+ levelName: updatedLevel.name,
261261+ author: updatedLevel.author,
262262+ });
263263+195264 res.json({ success: true, level: updatedLevel });
196265 } catch (error) {
197266 console.error("Error featuring level:", error);
···203272 });
204273205274 // unfeature a level (admin only)
206206- app.delete("/api/admin/levels/:id/feature", deps.ensureAuthenticated, (req: any, res: any) => {
275275+ app.delete("/api/admin/levels/:id/feature", deps.ensureAuthenticated, async (req: any, res: any) => {
207276 try {
208277 const levelId = parseInt(req.params.id);
209278···218287219288 dbCtx.db.prepare("UPDATE levels SET featured = 0, featured_at = NULL WHERE id = ?").run(levelId);
220289221221- const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId);
290290+ const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId) as LevelRecord;
291291+292292+ await deps.loggingManager.sendAuditLog({
293293+ action: "unfeature",
294294+ levelId,
295295+ levelName: updatedLevel.name,
296296+ author: updatedLevel.author,
297297+ });
298298+222299 res.json({ success: true, level: updatedLevel });
223300 } catch (error) {
224301 console.error("Error unfeaturing level:", error);
···245322 }
246323247324 const typedLevel = existingLevel as LevelRecord;
325325+326326+ // delete logging messages if they exist
327327+ if (typedLevel.logging_data) {
328328+ await deps.loggingManager.deleteLevelMessage(typedLevel.logging_data);
329329+ }
330330+ if (typedLevel.admin_logging_data) {
331331+ await deps.loggingManager.deleteAdminLevelMessage(typedLevel.admin_logging_data);
332332+ }
333333+334334+ await deps.loggingManager.sendAuditLog({
335335+ action: "delete",
336336+ levelId,
337337+ levelName: typedLevel.name,
338338+ author: typedLevel.author,
339339+ });
248340249341 dbCtx.db.prepare("DELETE FROM favorites WHERE level_id = ?").run(levelId);
250342 dbCtx.db.prepare("DELETE FROM levels WHERE id = ?").run(levelId);