A Deno-powered backend service for Plants vs. Zombies: MODDED. [Read-only GitHub mirror] docs.pvzm.net
express typescript expressjs plant deno jspvz pvzm game online backend plants-vs-zombies zombie javascript plants modded vs plantsvszombies openapi pvz noads
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

0.5.0: overhauled logging system, added bluesky logging

Clay 10954857 1c0425f7

+1084 -184
+16 -5
.env.example
··· 44 44 # get your API key from https://platform.openai.com/account/api-keys 45 45 OPENAI_API_KEY=some-openai-api-key 46 46 47 - # discord webhooks 48 - # send a webhook message for each successful upload 47 + # upload logging - posts to external services when levels are uploaded/updated/deleted 48 + # supports multiple providers (discord, slack, bluesky, etc.) via the logging module 49 49 USE_UPLOAD_LOGGING=true 50 - DISCORD_UPLOAD_WEBHOOK_URL=https://discord.com/api/webhooks/some-upload-webhook-url 51 - # send a webhook message for reports 50 + 51 + # discord logging provider 52 + # create a bot at https://discord.com/developers/applications 53 + # bot needs "Send Messages" and "Manage Messages" permissions in the channels 54 + DISCORD_BOT_TOKEN=your-discord-bot-token 55 + DISCORD_UPLOAD_CHANNEL_ID=your-upload-channel-id 56 + # optional: separate channel for admin notifications (with edit/delete buttons) 57 + DISCORD_ADMIN_UPLOAD_CHANNEL_ID=your-admin-upload-channel-id 58 + 59 + # reporting - posts to discord when levels are reported 52 60 USE_REPORTING=true 53 - DISCORD_REPORT_WEBHOOK_URL=https://discord.com/api/webhooks/some-report-webhook-url 61 + DISCORD_REPORT_CHANNEL_ID=your-report-channel-id 54 62 # user ids to mention in reports (prefix with & for a role id) 55 63 DISCORD_MENTION_USER_IDS=some-user-id1,some-user-id2,&some-role-id 64 + 65 + # audit log - posts to discord when admins make changes (edit/delete/feature) 66 + DISCORD_AUDIT_CHANNEL_ID=your-audit-channel-id
+1
.vscode/settings.json
··· 1 1 { 2 2 "deno.enable": true, 3 + "oxc.enable": false, 3 4 "deno.disablePaths": ["public"], 4 5 "[typescript]": { 5 6 "editor.defaultFormatter": "oxc.oxc-vscode"
+1 -1
README.md
··· 1 - # PVZM Backend ![v0.4.2](https://img.shields.io/badge/version-v0.4.2-darklime) 1 + # PVZM Backend ![v0.5.0](https://img.shields.io/badge/version-v0.4.2-darklime) 2 2 3 3 > 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. 4 4
+2 -1
deno.json
··· 1 1 { 2 - "version": "0.4.2", 2 + "version": "0.5.0", 3 3 "tasks": { 4 4 "dev": "deno run --watch -P=dev --env-file=.env main.ts", 5 5 "start": "deno run -P --env-file=.env main.ts", ··· 28 28 } 29 29 }, 30 30 "imports": { 31 + "@atproto/api": "npm:@atproto/api@^0.18.20", 31 32 "@db/sqlite": "jsr:@db/sqlite@^0.12.0", 32 33 "@mathis/turnstile-verify": "jsr:@mathis/turnstile-verify@^1.2.0", 33 34 "@msgpack/msgpack": "npm:@msgpack/msgpack@^3.1.2",
+85
deno.lock
··· 15 15 "jsr:@std/path@0.217": "0.217.0", 16 16 "jsr:@std/path@1": "1.1.3", 17 17 "jsr:@std/path@^1.1.1": "1.1.3", 18 + "npm:@atproto/api@~0.18.20": "0.18.20", 18 19 "npm:@msgpack/msgpack@^3.1.2": "3.1.3", 19 20 "npm:@types/cors@^2.8.19": "2.8.19", 20 21 "npm:@types/express@^5.0.3": "5.0.6", ··· 91 92 } 92 93 }, 93 94 "npm": { 95 + "@atproto/api@0.18.20": { 96 + "integrity": "sha512-BZYZkh2VJIFCXEnc/vzKwAwWjAQQTgbNJ8FBxpBK+z+KYh99O0uPCsRYKoCQsRrnkgrhzdU9+g2G+7zanTIGbw==", 97 + "dependencies": [ 98 + "@atproto/common-web", 99 + "@atproto/lexicon", 100 + "@atproto/syntax", 101 + "@atproto/xrpc", 102 + "await-lock", 103 + "multiformats", 104 + "tlds", 105 + "zod" 106 + ] 107 + }, 108 + "@atproto/common-web@0.4.15": { 109 + "integrity": "sha512-A4l9gyqUNez8CjZp/Trypz/D3WIQsNj8dN05WR6+RoBbvwc9JhWjKPrm+WoVYc/F16RPdXHLkE3BEJlGIyYIiA==", 110 + "dependencies": [ 111 + "@atproto/lex-data", 112 + "@atproto/lex-json", 113 + "@atproto/syntax", 114 + "zod" 115 + ] 116 + }, 117 + "@atproto/lex-data@0.0.10": { 118 + "integrity": "sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==", 119 + "dependencies": [ 120 + "multiformats", 121 + "tslib", 122 + "uint8arrays", 123 + "unicode-segmenter" 124 + ] 125 + }, 126 + "@atproto/lex-json@0.0.10": { 127 + "integrity": "sha512-L6MyXU17C5ODMeob8myQ2F3xvgCTvJUtM0ew8qSApnN//iDasB/FDGgd7ty4UVNmx4NQ/rtvz8xV94YpG6kneQ==", 128 + "dependencies": [ 129 + "@atproto/lex-data", 130 + "tslib" 131 + ] 132 + }, 133 + "@atproto/lexicon@0.6.1": { 134 + "integrity": "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==", 135 + "dependencies": [ 136 + "@atproto/common-web", 137 + "@atproto/syntax", 138 + "iso-datestring-validator", 139 + "multiformats", 140 + "zod" 141 + ] 142 + }, 143 + "@atproto/syntax@0.4.3": { 144 + "integrity": "sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==", 145 + "dependencies": [ 146 + "tslib" 147 + ] 148 + }, 149 + "@atproto/xrpc@0.7.7": { 150 + "integrity": "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==", 151 + "dependencies": [ 152 + "@atproto/lexicon", 153 + "zod" 154 + ] 155 + }, 94 156 "@discordjs/builders@1.13.1": { 95 157 "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", 96 158 "dependencies": [ ··· 251 313 "negotiator" 252 314 ] 253 315 }, 316 + "await-lock@2.2.2": { 317 + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 318 + }, 254 319 "bad-words@4.0.0": { 255 320 "integrity": "sha512-fLjG/I0N3I7xhurqGnGitSRD10UeEE63a7hyXtutQDpxo4+Eal+i7veWeZxZJPNtsl6X1mUIoWPwt8gQ7NMQUw==", 256 321 "dependencies": [ ··· 522 587 "is-promise@4.0.0": { 523 588 "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" 524 589 }, 590 + "iso-datestring-validator@2.2.2": { 591 + "integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 592 + }, 525 593 "lodash.snakecase@4.1.1": { 526 594 "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==" 527 595 }, ··· 568 636 }, 569 637 "ms@2.1.3": { 570 638 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 639 + }, 640 + "multiformats@9.9.0": { 641 + "integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" 571 642 }, 572 643 "negotiator@1.0.0": { 573 644 "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==" ··· 748 819 }, 749 820 "statuses@2.0.2": { 750 821 "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==" 822 + }, 823 + "tlds@1.261.0": { 824 + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", 825 + "bin": true 751 826 }, 752 827 "toidentifier@1.0.1": { 753 828 "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" ··· 775 850 "uid2@0.0.4": { 776 851 "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" 777 852 }, 853 + "uint8arrays@3.0.0": { 854 + "integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==", 855 + "dependencies": [ 856 + "multiformats" 857 + ] 858 + }, 778 859 "undici-types@7.16.0": { 779 860 "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" 780 861 }, 781 862 "undici@6.21.3": { 782 863 "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==" 864 + }, 865 + "unicode-segmenter@0.14.5": { 866 + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" 783 867 }, 784 868 "unpipe@1.0.0": { 785 869 "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" ··· 809 893 "jsr:@mathis/turnstile-verify@^1.2.0", 810 894 "jsr:@openai/openai@^5.20.1", 811 895 "jsr:@std/fs@^1.0.19", 896 + "npm:@atproto/api@~0.18.20", 812 897 "npm:@msgpack/msgpack@^3.1.2", 813 898 "npm:@types/cors@^2.8.19", 814 899 "npm:@types/express@^5.0.3",
+52 -20
main.ts
··· 1 1 import { createExpressApp, setupBodyParsers, setupCors } from "./modules/app_middleware.ts"; 2 2 import { ensureAuthenticated, ensureAuthenticatedOrConsumeTokenForLevelParam, setupGithubAuth, setupSession } from "./modules/auth.ts"; 3 3 import { loadConfig } from "./modules/config.ts"; 4 - import { createDiscordClients } from "./modules/discord.ts"; 5 4 import { ensureDataFolder, initDatabase } from "./modules/db.ts"; 5 + import { DiscordLoggingProvider, BlueskyLoggingProvider, LoggingManager } from "./modules/logging/index.ts"; 6 6 import { initModeration } from "./modules/moderation.ts"; 7 7 import { setupPublicFolder } from "./modules/public_folder.ts"; 8 8 import { registerAdminRoutes } from "./modules/routes/admin.ts"; ··· 24 24 ensureDataFolder(config); 25 25 26 26 const dbCtx = initDatabase(config); 27 - const { reportWebhookClient, uploadWebhookClient } = createDiscordClients(config); 28 27 const validateTurnstile = initTurnstile(config); 29 28 const moderateContent = initModeration(config); 30 29 31 - registerRootRoute(app, config); 32 - registerConfigRoute(app, config); 33 - registerLevelRoutes(app, config, dbCtx, { 34 - validateTurnstile, 35 - moderateContent, 36 - reportWebhookClient, 37 - uploadWebhookClient, 38 - }); 30 + async function startServer() { 31 + // initialize logging providers 32 + const loggingManager = new LoggingManager(); 39 33 40 - const ensureAuth = ensureAuthenticated(config); 41 - const ensureAuthOrToken = ensureAuthenticatedOrConsumeTokenForLevelParam(config, dbCtx.consumeOneTimeTokenForLevel); 42 - registerAdminRoutes(app, dbCtx, { 43 - ensureAuthenticated: ensureAuth, 44 - ensureAuthenticatedOrConsumeTokenForLevelParam: ensureAuthOrToken, 45 - }); 34 + if (config.useUploadLogging || config.useReporting) { 35 + loggingManager.addProvider( 36 + new DiscordLoggingProvider({ 37 + enabled: config.discordProviderEnabled, 38 + botToken: config.discordBotToken, 39 + channelId: config.discordUploadChannelId, 40 + adminChannelId: config.discordAdminUploadChannelId, 41 + reportChannelId: config.discordReportChannelId, 42 + auditChannelId: config.discordAuditChannelId, 43 + }) 44 + ); 45 + loggingManager.addProvider( 46 + new BlueskyLoggingProvider({ 47 + enabled: config.blueskyProviderEnabled, 48 + pds: config.blueskyPds, 49 + identifier: config.blueskyIdentifier, 50 + password: config.blueskyPassword 51 + }) 52 + ); 53 + // add more providers here in the future: 54 + // loggingManager.addProvider(new SlackLoggingProvider({ ... })); 55 + // loggingManager.addProvider(new BlueskyLoggingProvider({ ... })); 56 + } 46 57 47 - console.log(`UI Status - Test UI: ${config.useTestUI ? "enabled" : "disabled"}, Admin UI: ${config.useAdminUI ? "enabled" : "disabled"}`); 58 + await loggingManager.initAll(); 48 59 49 - app.listen(config.port, () => { 50 - console.log(`HTTP server running on http://localhost:${config.port}`); 51 - }); 60 + registerRootRoute(app, config); 61 + registerConfigRoute(app, config); 62 + registerLevelRoutes(app, config, dbCtx, { 63 + validateTurnstile, 64 + moderateContent, 65 + loggingManager, 66 + }); 67 + 68 + const ensureAuth = ensureAuthenticated(config); 69 + const ensureAuthOrToken = ensureAuthenticatedOrConsumeTokenForLevelParam(config, dbCtx.consumeOneTimeTokenForLevel); 70 + registerAdminRoutes(app, config, dbCtx, { 71 + ensureAuthenticated: ensureAuth, 72 + ensureAuthenticatedOrConsumeTokenForLevelParam: ensureAuthOrToken, 73 + loggingManager, 74 + }); 75 + 76 + console.log(`UI Status - Test UI: ${config.useTestUI ? "enabled" : "disabled"}, Admin UI: ${config.useAdminUI ? "enabled" : "disabled"}`); 77 + 78 + app.listen(config.port, () => { 79 + console.log(`HTTP server running on http://localhost:${config.port}`); 80 + }); 81 + } 82 + 83 + startServer();
+36 -6
modules/config.ts
··· 14 14 usePublicFolder: boolean; 15 15 publicFolderPath: string; 16 16 17 - discordReportWebhookUrl: string; 18 - discordUploadWebhookUrl: string; 19 17 useReporting: boolean; 20 18 useUploadLogging: boolean; 19 + 20 + discordProviderEnabled: boolean; 21 + discordBotToken: string; 22 + discordUploadChannelId: string; 23 + discordAdminUploadChannelId: string; 24 + discordReportChannelId: string; 25 + discordAuditChannelId: string; 21 26 discordMentionUserIds: string[]; 27 + 28 + blueskyProviderEnabled: boolean; 29 + blueskyIdentifier: string; 30 + blueskyPassword: string; 31 + blueskyPds: string; 22 32 23 33 gameUrl: string; 24 34 backendUrl: string; ··· 60 70 const usePublicFolder = (Deno.env.get("USE_PUBLIC_FOLDER") ?? "true") === "true"; 61 71 const publicFolderPath = Deno.env.get("PUBLIC_FOLDER_PATH") || "./public"; 62 72 63 - const discordReportWebhookUrl = Deno.env.get("DISCORD_REPORT_WEBHOOK_URL") || ""; 64 - const discordUploadWebhookUrl = Deno.env.get("DISCORD_UPLOAD_WEBHOOK_URL") || ""; 65 73 const useReporting = Deno.env.get("USE_REPORTING") !== "false"; 66 74 const useUploadLogging = Deno.env.get("USE_UPLOAD_LOGGING") === "true"; 75 + 76 + const discordProviderEnabled = (Deno.env.get("DISCORD_PROVIDER_ENABLED") ?? "true") === "true"; 77 + const discordBotToken = Deno.env.get("DISCORD_BOT_TOKEN") || ""; 78 + const discordUploadChannelId = Deno.env.get("DISCORD_UPLOAD_CHANNEL_ID") || ""; 79 + const discordAdminUploadChannelId = Deno.env.get("DISCORD_ADMIN_UPLOAD_CHANNEL_ID") || ""; 80 + const discordReportChannelId = Deno.env.get("DISCORD_REPORT_CHANNEL_ID") || ""; 81 + const discordAuditChannelId = Deno.env.get("DISCORD_AUDIT_CHANNEL_ID") || ""; 67 82 const discordMentionUserIdsString = Deno.env.get("DISCORD_MENTION_USER_IDS") || ""; 68 83 const discordMentionUserIds = discordMentionUserIdsString ? splitCsv(discordMentionUserIdsString) : []; 84 + 85 + const blueskyProviderEnabled = (Deno.env.get("BLUESKY_PROVIDER_ENABLED") ?? "true") === "true"; 86 + const blueskyIdentifier = Deno.env.get("BLUESKY_IDENTIFIER") || ""; 87 + const blueskyPassword = Deno.env.get("BLUESKY_PASSWORD") || ""; 88 + const blueskyPds = Deno.env.get("BLUESKY_PDS") || "https://bsky.social"; 69 89 70 90 const gameUrl = Deno.env.get("GAME_URL") || "https://pvzm.net"; 71 91 const backendUrl = Deno.env.get("BACKEND_URL") || "https://backend.pvzm.net"; ··· 98 118 usePublicFolder, 99 119 publicFolderPath, 100 120 101 - discordReportWebhookUrl, 102 - discordUploadWebhookUrl, 103 121 useReporting, 104 122 useUploadLogging, 123 + 124 + discordProviderEnabled, 125 + discordBotToken, 126 + discordUploadChannelId, 127 + discordAdminUploadChannelId, 128 + discordReportChannelId, 129 + discordAuditChannelId, 105 130 discordMentionUserIds, 131 + 132 + blueskyProviderEnabled, 133 + blueskyIdentifier, 134 + blueskyPassword, 135 + blueskyPds, 106 136 107 137 gameUrl, 108 138 backendUrl,
+53 -43
modules/db.ts
··· 23 23 version: number; 24 24 featured: number; 25 25 featured_at: number | null; 26 + logging_data: string | null; 27 + admin_logging_data: string | null; 26 28 }; 27 29 28 30 function tableHasColumn(db: Database, tableName: string, columnName: string) { ··· 59 61 60 62 // levels 61 63 db.prepare(` 62 - CREATE TABLE IF NOT EXISTS levels ( 63 - id INTEGER PRIMARY KEY AUTOINCREMENT, 64 - name TEXT NOT NULL, 65 - author TEXT NOT NULL, 66 - created_at INTEGER NOT NULL, 67 - sun INTEGER NOT NULL, 68 - is_water INTEGER NOT NULL, 69 - favorites INTEGER NOT NULL DEFAULT 0, 70 - plays INTEGER NOT NULL DEFAULT 0, 71 - difficulty INTEGER, 72 - author_id INTEGER, 73 - version INTEGER DEFAULT 3 74 - ) 75 - `).run(); 64 + CREATE TABLE IF NOT EXISTS levels ( 65 + id INTEGER PRIMARY KEY AUTOINCREMENT, 66 + name TEXT NOT NULL, 67 + author TEXT NOT NULL, 68 + created_at INTEGER NOT NULL, 69 + sun INTEGER NOT NULL, 70 + is_water INTEGER NOT NULL, 71 + favorites INTEGER NOT NULL DEFAULT 0, 72 + plays INTEGER NOT NULL DEFAULT 0, 73 + difficulty INTEGER, 74 + author_id INTEGER, 75 + version INTEGER DEFAULT 3 76 + ) 77 + `).run(); 76 78 77 79 // authors 78 80 db.prepare(` 79 - CREATE TABLE IF NOT EXISTS authors ( 80 - id INTEGER PRIMARY KEY AUTOINCREMENT, 81 - names TEXT NOT NULL, 82 - first_level_id INTEGER NOT NULL, 83 - first_level_created_at INTEGER NOT NULL, 84 - level_ids TEXT NOT NULL, 85 - origin_ip TEXT NOT NULL 86 - ) 87 - `).run(); 81 + CREATE TABLE IF NOT EXISTS authors ( 82 + id INTEGER PRIMARY KEY AUTOINCREMENT, 83 + names TEXT NOT NULL, 84 + first_level_id INTEGER NOT NULL, 85 + first_level_created_at INTEGER NOT NULL, 86 + level_ids TEXT NOT NULL, 87 + origin_ip TEXT NOT NULL 88 + ) 89 + `).run(); 88 90 89 91 // favorites 90 - db.prepare( 91 - ` 92 - CREATE TABLE IF NOT EXISTS favorites ( 93 - id INTEGER PRIMARY KEY AUTOINCREMENT, 94 - level_id INTEGER NOT NULL, 95 - ip_address TEXT NOT NULL, 96 - created_at INTEGER NOT NULL, 97 - UNIQUE(level_id, ip_address) 98 - ) 99 - ` 100 - ).run(); 92 + db.prepare(` 93 + CREATE TABLE IF NOT EXISTS favorites ( 94 + id INTEGER PRIMARY KEY AUTOINCREMENT, 95 + level_id INTEGER NOT NULL, 96 + ip_address TEXT NOT NULL, 97 + created_at INTEGER NOT NULL, 98 + UNIQUE(level_id, ip_address) 99 + ) 100 + `).run(); 101 101 102 102 // one-time admin tokens 103 - db.prepare( 104 - ` 105 - CREATE TABLE IF NOT EXISTS admin_tokens ( 106 - token TEXT PRIMARY KEY, 107 - level_id INTEGER NOT NULL, 108 - created_at INTEGER NOT NULL 109 - ) 110 - ` 111 - ).run(); 103 + db.prepare(` 104 + CREATE TABLE IF NOT EXISTS admin_tokens ( 105 + token TEXT PRIMARY KEY, 106 + level_id INTEGER NOT NULL, 107 + created_at INTEGER NOT NULL 108 + ) 109 + `).run(); 112 110 113 111 try { 114 112 db.prepare("CREATE INDEX IF NOT EXISTS idx_admin_tokens_level_id ON admin_tokens(level_id)").run(); ··· 142 140 } 143 141 } catch (migrationError) { 144 142 console.error("Featured migration error:", migrationError); 143 + } 144 + 145 + // lightweight runtime migration: logging data (provider message IDs as JSON) 146 + try { 147 + if (!tableHasColumn(db, "levels", "logging_data")) { 148 + db.prepare("ALTER TABLE levels ADD COLUMN logging_data TEXT").run(); 149 + } 150 + if (!tableHasColumn(db, "levels", "admin_logging_data")) { 151 + db.prepare("ALTER TABLE levels ADD COLUMN admin_logging_data TEXT").run(); 152 + } 153 + } catch (migrationError) { 154 + console.error("Logging data migration error:", migrationError); 145 155 } 146 156 147 157 function createOneTimeTokenForLevel(levelId: number): string {
-35
modules/discord.ts
··· 1 - import { WebhookClient } from "discord.js"; 2 - 3 - import type { ServerConfig } from "./config.ts"; 4 - 5 - export type DiscordClients = { 6 - reportWebhookClient?: WebhookClient; 7 - uploadWebhookClient?: WebhookClient; 8 - }; 9 - 10 - export function createDiscordClients(config: ServerConfig): DiscordClients { 11 - let reportWebhookClient: WebhookClient | undefined; 12 - if (config.useReporting && config.discordReportWebhookUrl) { 13 - reportWebhookClient = new WebhookClient({ 14 - url: config.discordReportWebhookUrl, 15 - }); 16 - } 17 - 18 - let uploadWebhookClient: WebhookClient | undefined; 19 - if (config.useUploadLogging && config.discordUploadWebhookUrl) { 20 - uploadWebhookClient = new WebhookClient({ 21 - url: config.discordUploadWebhookUrl, 22 - }); 23 - } 24 - 25 - return { reportWebhookClient, uploadWebhookClient }; 26 - } 27 - 28 - export async function trySendDiscordWebhook(client: WebhookClient | undefined, payload: any) { 29 - if (!client) return; 30 - try { 31 - await client.send(payload); 32 - } catch (error) { 33 - console.error("Error sending Discord webhook:", error); 34 - } 35 - }
+95
modules/logging/bluesky.ts
··· 1 + import { AtpAgent, RichText } from "@atproto/api"; 2 + import type { LevelInfo, LoggingProvider } from "./types.ts"; 3 + 4 + export type BlueskyProviderConfig = { 5 + enabled?: boolean; 6 + pds: string; 7 + identifier: string; 8 + password: string; 9 + }; 10 + 11 + export class BlueskyLoggingProvider implements LoggingProvider { 12 + readonly name = "bluesky"; 13 + 14 + private agent: AtpAgent | null = null; 15 + private config: BlueskyProviderConfig; 16 + 17 + constructor(config: BlueskyProviderConfig) { 18 + this.config = config; 19 + } 20 + 21 + async init(): Promise<boolean> { 22 + if (this.config.enabled === false) { 23 + console.log("Bluesky logging provider: disabled in config, skipping"); 24 + return false; 25 + } 26 + 27 + if (!this.config.pds || !this.config.identifier || !this.config.password) { 28 + console.log("Bluesky logging provider: missing credentials, skipping"); 29 + return false; 30 + } 31 + 32 + try { 33 + this.agent = new AtpAgent({ 34 + service: this.config.pds, 35 + }); 36 + 37 + await this.agent.login({ 38 + identifier: this.config.identifier, 39 + password: this.config.password, 40 + }); 41 + 42 + console.log("Bluesky logging provider: bot logged in"); 43 + return true; 44 + } catch (error) { 45 + console.error("Bluesky logging provider: init failed:", error); 46 + return false; 47 + } 48 + } 49 + 50 + async sendLevelMessage(level: LevelInfo): Promise<string | null> { 51 + if (!this.agent) return null; 52 + 53 + try { 54 + const playUrl = `${level.gameUrl}/?izl_id=${level.id}`; 55 + const message = `New I, Zombie level uploaded!\n\n"${level.name}" by ${level.author}\n\n${playUrl}`; 56 + 57 + const rt = new RichText({ text: message }); 58 + await rt.detectFacets(this.agent); 59 + 60 + const response = await this.agent.post({ 61 + text: rt.text, 62 + facets: rt.facets, 63 + }); 64 + 65 + // return the post URI as the message ID 66 + return response.uri; 67 + } catch (error) { 68 + console.error("Bluesky logging provider: sendLevelMessage failed:", error); 69 + return null; 70 + } 71 + } 72 + 73 + async editLevelMessage(_messageId: string, _level: LevelInfo): Promise<boolean> { 74 + // bluesky doesn't support editing posts 75 + return false; 76 + } 77 + 78 + async deleteLevelMessage(messageId: string): Promise<boolean> { 79 + if (!this.agent || !messageId) return false; 80 + 81 + try { 82 + await this.agent.deletePost(messageId); 83 + return true; 84 + } catch (error) { 85 + console.error("Bluesky logging provider: deleteLevelMessage failed:", error); 86 + return false; 87 + } 88 + } 89 + 90 + async sendReportMessage(): Promise<boolean> { 91 + // reports not supported on Bluesky 92 + return false; 93 + } 94 + } 95 +
+298
modules/logging/discord.ts
··· 1 + import { AttachmentBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, GatewayIntentBits, TextChannel } from "discord.js"; 2 + import { Buffer } from "node:buffer"; 3 + 4 + import type { LevelInfo, AdminLevelInfo, LoggingProvider, ReportInfo, AuditLogEntry } from "./types.ts"; 5 + 6 + export type DiscordProviderConfig = { 7 + enabled?: boolean; 8 + botToken: string; 9 + channelId: string; 10 + adminChannelId?: string; 11 + reportChannelId?: string; 12 + auditChannelId?: string; 13 + }; 14 + 15 + export class DiscordLoggingProvider implements LoggingProvider { 16 + readonly name = "discord"; 17 + 18 + private client: Client | null = null; 19 + private channel: TextChannel | null = null; 20 + private adminChannel: TextChannel | null = null; 21 + private reportChannel: TextChannel | null = null; 22 + private auditChannel: TextChannel | null = null; 23 + private config: DiscordProviderConfig; 24 + 25 + constructor(config: DiscordProviderConfig) { 26 + this.config = config; 27 + } 28 + 29 + async init(): Promise<boolean> { 30 + if (this.config.enabled === false) { 31 + console.log("Discord logging provider: disabled in config, skipping"); 32 + return false; 33 + } 34 + 35 + if (!this.config.botToken) { 36 + console.log("Discord logging provider: missing bot token, skipping"); 37 + return false; 38 + } 39 + 40 + if (!this.config.channelId && !this.config.reportChannelId) { 41 + console.log("Discord logging provider: no channels configured, skipping"); 42 + return false; 43 + } 44 + 45 + try { 46 + this.client = new Client({ 47 + intents: [GatewayIntentBits.Guilds], 48 + }); 49 + 50 + await this.client.login(this.config.botToken); 51 + console.log("Discord logging provider: bot logged in"); 52 + 53 + if (this.config.channelId) { 54 + const channel = await this.client.channels.fetch(this.config.channelId); 55 + if (channel?.isTextBased()) { 56 + this.channel = channel as TextChannel; 57 + console.log(`Discord logging provider: upload channel cached (#${this.channel.name})`); 58 + } else { 59 + console.error("Discord logging provider: upload channel not found or not a text channel"); 60 + } 61 + } 62 + 63 + if (this.config.adminChannelId) { 64 + const adminChannel = await this.client.channels.fetch(this.config.adminChannelId); 65 + if (adminChannel?.isTextBased()) { 66 + this.adminChannel = adminChannel as TextChannel; 67 + console.log(`Discord logging provider: admin upload channel cached (#${this.adminChannel.name})`); 68 + } else { 69 + console.error("Discord logging provider: admin upload channel not found or not a text channel"); 70 + } 71 + } 72 + 73 + if (this.config.reportChannelId) { 74 + const reportChannel = await this.client.channels.fetch(this.config.reportChannelId); 75 + if (reportChannel?.isTextBased()) { 76 + this.reportChannel = reportChannel as TextChannel; 77 + console.log(`Discord logging provider: report channel cached (#${this.reportChannel.name})`); 78 + } else { 79 + console.error("Discord logging provider: report channel not found or not a text channel"); 80 + } 81 + } 82 + 83 + if (this.config.auditChannelId) { 84 + const auditChannel = await this.client.channels.fetch(this.config.auditChannelId); 85 + if (auditChannel?.isTextBased()) { 86 + this.auditChannel = auditChannel as TextChannel; 87 + console.log(`Discord logging provider: audit channel cached (#${this.auditChannel.name})`); 88 + } else { 89 + console.error("Discord logging provider: audit channel not found or not a text channel"); 90 + } 91 + } 92 + 93 + return this.channel !== null || this.adminChannel !== null || this.reportChannel !== null || this.auditChannel !== null; 94 + } catch (error) { 95 + console.error("Discord logging provider: init failed:", error); 96 + return false; 97 + } 98 + } 99 + 100 + private buildEmbed(level: LevelInfo): EmbedBuilder { 101 + return new EmbedBuilder().setTitle(level.name).setDescription(`By **${level.author}**`).setAuthor({ name: "New level uploaded" }); 102 + } 103 + 104 + private buildPublicUploadButtons(level: LevelInfo): ActionRowBuilder<ButtonBuilder> { 105 + const playButton = new ButtonBuilder().setLabel("Play").setStyle(ButtonStyle.Link).setURL(`${level.gameUrl}/?izl_id=${level.id}`); 106 + 107 + const downloadButton = new ButtonBuilder() 108 + .setLabel("Download") 109 + .setStyle(ButtonStyle.Link) 110 + .setURL(`${level.backendUrl}/api/levels/${level.id}/download`); 111 + 112 + return new ActionRowBuilder<ButtonBuilder>().addComponents(playButton, downloadButton); 113 + } 114 + 115 + private buildAdminUploadButtons(level: AdminLevelInfo): ActionRowBuilder<ButtonBuilder> { 116 + const playButton = new ButtonBuilder().setLabel("Play").setStyle(ButtonStyle.Link).setURL(`${level.gameUrl}/?izl_id=${level.id}`); 117 + 118 + const downloadButton = new ButtonBuilder() 119 + .setLabel("Download") 120 + .setStyle(ButtonStyle.Link) 121 + .setURL(`${level.backendUrl}/api/levels/${level.id}/download`); 122 + 123 + const editButton = new ButtonBuilder().setLabel("Edit Metadata").setStyle(ButtonStyle.Link).setURL(level.editUrl); 124 + 125 + const deleteButton = new ButtonBuilder().setLabel("Delete Level").setStyle(ButtonStyle.Link).setURL(level.deleteUrl); 126 + 127 + return new ActionRowBuilder<ButtonBuilder>().addComponents(playButton, downloadButton, editButton, deleteButton); 128 + } 129 + 130 + async sendLevelMessage(level: LevelInfo): Promise<string | null> { 131 + if (!this.channel) return null; 132 + 133 + try { 134 + const message = await this.channel.send({ 135 + content: "", 136 + embeds: [this.buildEmbed(level)], 137 + components: [this.buildPublicUploadButtons(level)], 138 + }); 139 + return message.id; 140 + } catch (error) { 141 + console.error("Discord logging provider: send failed:", error); 142 + return null; 143 + } 144 + } 145 + 146 + async sendAdminLevelMessage(level: AdminLevelInfo): Promise<string | null> { 147 + if (!this.adminChannel) return null; 148 + 149 + try { 150 + const message = await this.adminChannel.send({ 151 + content: "", 152 + embeds: [this.buildEmbed(level)], 153 + components: [this.buildAdminUploadButtons(level)], 154 + }); 155 + 156 + // send a link to the admin message in the audit channel 157 + if (this.auditChannel) { 158 + await this.auditChannel.send(`https://discord.com/channels/${this.adminChannel.guildId}/${this.adminChannel.id}/${message.id}`); 159 + } 160 + 161 + return message.id; 162 + } catch (error) { 163 + console.error("Discord logging provider: send failed:", error); 164 + return null; 165 + } 166 + } 167 + 168 + async editLevelMessage(messageId: string, level: LevelInfo): Promise<boolean> { 169 + if (!this.channel || !messageId) return false; 170 + 171 + try { 172 + const message = await this.channel.messages.fetch(messageId); 173 + await message.edit({ 174 + content: "", 175 + embeds: [this.buildEmbed(level)], 176 + components: [this.buildPublicUploadButtons(level)], 177 + }); 178 + return true; 179 + } catch (error) { 180 + console.error("Discord logging provider: edit failed:", error); 181 + return false; 182 + } 183 + } 184 + 185 + async editAdminLevelMessage(messageId: string, level: AdminLevelInfo): Promise<boolean> { 186 + if (!this.adminChannel || !messageId) return false; 187 + 188 + try { 189 + const message = await this.adminChannel.messages.fetch(messageId); 190 + await message.edit({ 191 + content: "", 192 + embeds: [this.buildEmbed(level)], 193 + components: [this.buildAdminUploadButtons(level)], 194 + }); 195 + return true; 196 + } catch (error) { 197 + console.error("Discord logging provider: edit failed:", error); 198 + return false; 199 + } 200 + } 201 + 202 + async deleteLevelMessage(messageId: string): Promise<boolean> { 203 + if (!this.channel || !messageId) return false; 204 + 205 + try { 206 + const message = await this.channel.messages.fetch(messageId); 207 + await message.delete(); 208 + return true; 209 + } catch (error) { 210 + console.error("Discord logging provider: delete failed:", error); 211 + return false; 212 + } 213 + } 214 + 215 + async deleteAdminLevelMessage(messageId: string): Promise<boolean> { 216 + if (!this.adminChannel || !messageId) return false; 217 + 218 + try { 219 + const message = await this.adminChannel.messages.fetch(messageId); 220 + const existingEmbed = message.embeds[0]; 221 + 222 + const deletedEmbed = new EmbedBuilder() 223 + .setTitle(`~~${existingEmbed?.title ?? "Unknown"}~~`) 224 + .setDescription(`~~${existingEmbed?.description ?? ""}~~`) 225 + .setAuthor({ name: "Level deleted" }) 226 + .setColor(0xff0000); 227 + 228 + await message.edit({ 229 + content: "", 230 + embeds: [deletedEmbed], 231 + components: [], 232 + }); 233 + return true; 234 + } catch (error) { 235 + console.error("Discord logging provider: delete failed:", error); 236 + return false; 237 + } 238 + } 239 + 240 + async sendReportMessage(report: ReportInfo): Promise<boolean> { 241 + if (!this.reportChannel) return false; 242 + 243 + try { 244 + const mentionString = report.mentionUserIds.length > 0 ? report.mentionUserIds.map((id) => `<@${id}>`).join(" ") + " " : ""; 245 + 246 + const content = 247 + `${mentionString}**New level report received:**\n` + 248 + `Level ID: ${report.levelId}\n` + 249 + `Level Name: ${report.levelName}\n` + 250 + `Author: ${report.author}\n` + 251 + `Reason: ${report.reason}\n` + 252 + `Reported from IP: ${report.reporterIp}\n` + 253 + `**[Edit level metadata](<${report.editUrl}>)** | ` + 254 + `**[Delete level](<${report.deleteUrl}>)** | ` + 255 + `**[View level](<${report.viewUrl}>)**` + 256 + (report.fileAttachment ? "" : "\n\n(Attachment missing: level file not found)"); 257 + 258 + const files = report.fileAttachment 259 + ? [new AttachmentBuilder(Buffer.from(report.fileAttachment.content), { name: report.fileAttachment.fileName })] 260 + : []; 261 + 262 + await this.reportChannel.send({ content, files }); 263 + return true; 264 + } catch (error) { 265 + console.error("Discord logging provider: report send failed:", error); 266 + return false; 267 + } 268 + } 269 + 270 + async sendAuditLog(entry: AuditLogEntry): Promise<boolean> { 271 + if (!this.auditChannel) return false; 272 + 273 + try { 274 + const actionLabels: Record<AuditLogEntry["action"], string> = { 275 + edit: "✏️ Level Edited", 276 + delete: "🗑️ Level Deleted", 277 + feature: "⭐ Level Featured", 278 + unfeature: "❌ Level Unfeatured", 279 + }; 280 + 281 + const embed = new EmbedBuilder() 282 + .setTitle(actionLabels[entry.action]) 283 + .setDescription( 284 + `**Level:** ${entry.levelName} (ID: ${entry.levelId})\n` + 285 + `**Author:** ${entry.author}` + 286 + (entry.changes ? `\n\n**Changes:**\n${entry.changes}` : "") 287 + ) 288 + .setTimestamp() 289 + .setColor(entry.action === "delete" ? 0xff0000 : entry.action === "feature" ? 0xffd700 : 0x3498db); 290 + 291 + await this.auditChannel.send({ embeds: [embed] }); 292 + return true; 293 + } catch (error) { 294 + console.error("Discord logging provider: audit log failed:", error); 295 + return false; 296 + } 297 + } 298 + }
+4
modules/logging/index.ts
··· 1 + export type { LevelInfo, LoggingProvider } from "./types.ts"; 2 + export { LoggingManager, type ProviderMessageIds } from "./manager.ts"; 3 + export { DiscordLoggingProvider, type DiscordProviderConfig } from "./discord.ts"; 4 + export { BlueskyLoggingProvider, type BlueskyProviderConfig } from "./bluesky.ts";
+211
modules/logging/manager.ts
··· 1 + import type { LevelInfo, AdminLevelInfo, LoggingProvider, ReportInfo, AuditLogEntry } from "./types.ts"; 2 + 3 + /** 4 + * stores message IDs for each provider as JSON: { "discord": "123", "slack": "456" } 5 + */ 6 + export type ProviderMessageIds = Record<string, string>; 7 + 8 + export class LoggingManager { 9 + private providers: LoggingProvider[] = []; 10 + 11 + addProvider(provider: LoggingProvider): void { 12 + this.providers.push(provider); 13 + } 14 + 15 + async initAll(): Promise<void> { 16 + const results = await Promise.allSettled( 17 + this.providers.map(async (provider) => { 18 + const success = await provider.init(); 19 + return { name: provider.name, success }; 20 + }) 21 + ); 22 + 23 + for (const result of results) { 24 + if (result.status === "rejected") { 25 + console.error("Logging provider init rejected:", result.reason); 26 + } 27 + } 28 + 29 + // filter out providers that failed to initialize 30 + const successfulProviders: LoggingProvider[] = []; 31 + for (let i = 0; i < this.providers.length; i++) { 32 + const result = results[i]; 33 + if (result.status === "fulfilled" && result.value.success) { 34 + successfulProviders.push(this.providers[i]); 35 + } 36 + } 37 + this.providers = successfulProviders; 38 + 39 + console.log(`Logging manager: ${this.providers.length} provider(s) active: ${this.providers.map((p) => p.name).join(", ") || "(none)"}`); 40 + } 41 + 42 + /** 43 + * send a level message to all providers 44 + * returns a JSON string of provider -> messageId mappings 45 + */ 46 + async sendLevelMessage(level: LevelInfo): Promise<string | null> { 47 + if (this.providers.length === 0) return null; 48 + 49 + const messageIds: ProviderMessageIds = {}; 50 + 51 + await Promise.allSettled( 52 + this.providers.map(async (provider) => { 53 + const messageId = await provider.sendLevelMessage(level); 54 + if (messageId) { 55 + messageIds[provider.name] = messageId; 56 + } 57 + }) 58 + ); 59 + 60 + if (Object.keys(messageIds).length === 0) return null; 61 + return JSON.stringify(messageIds); 62 + } 63 + 64 + /** 65 + * send an admin level message to all providers 66 + * returns a JSON string of provider -> messageId mappings 67 + */ 68 + async sendAdminLevelMessage(level: AdminLevelInfo): Promise<string | null> { 69 + if (this.providers.length === 0) return null; 70 + 71 + const messageIds: ProviderMessageIds = {}; 72 + 73 + await Promise.allSettled( 74 + this.providers.map(async (provider) => { 75 + if ("sendAdminLevelMessage" in provider) { 76 + const messageId = await (provider as any).sendAdminLevelMessage(level); 77 + if (messageId) { 78 + messageIds[provider.name] = messageId; 79 + } 80 + } 81 + }) 82 + ); 83 + 84 + if (Object.keys(messageIds).length === 0) return null; 85 + return JSON.stringify(messageIds); 86 + } 87 + 88 + /** 89 + * edit a level message across all providers 90 + * messageIdsJson is the JSON string stored in the database 91 + */ 92 + async editLevelMessage(messageIdsJson: string | null, level: LevelInfo): Promise<void> { 93 + if (!messageIdsJson || this.providers.length === 0) return; 94 + 95 + let messageIds: ProviderMessageIds; 96 + try { 97 + messageIds = JSON.parse(messageIdsJson); 98 + } catch { 99 + return; 100 + } 101 + 102 + await Promise.allSettled( 103 + this.providers.map(async (provider) => { 104 + const messageId = messageIds[provider.name]; 105 + if (messageId) { 106 + await provider.editLevelMessage(messageId, level); 107 + } 108 + }) 109 + ); 110 + } 111 + 112 + /** 113 + * edits an admin level message across all providers 114 + * messageIdsJson is the JSON string stored in the database 115 + */ 116 + async editAdminLevelMessage(messageIdsJson: string | null, level: AdminLevelInfo): Promise<void> { 117 + if (!messageIdsJson || this.providers.length === 0) return; 118 + 119 + let messageIds: ProviderMessageIds; 120 + try { 121 + messageIds = JSON.parse(messageIdsJson); 122 + } catch { 123 + return; 124 + } 125 + 126 + await Promise.allSettled( 127 + this.providers.map(async (provider) => { 128 + const messageId = messageIds[provider.name]; 129 + if (messageId && "editAdminLevelMessage" in provider) { 130 + await (provider as any).editAdminLevelMessage(messageId, level); 131 + } 132 + }) 133 + ); 134 + } 135 + 136 + /** 137 + * delete a level message across all providers 138 + * messageIdsJson is the JSON string stored in the database 139 + */ 140 + async deleteLevelMessage(messageIdsJson: string | null): Promise<void> { 141 + if (!messageIdsJson || this.providers.length === 0) return; 142 + 143 + let messageIds: ProviderMessageIds; 144 + try { 145 + messageIds = JSON.parse(messageIdsJson); 146 + } catch { 147 + return; 148 + } 149 + 150 + await Promise.allSettled( 151 + this.providers.map(async (provider) => { 152 + const messageId = messageIds[provider.name]; 153 + if (messageId) { 154 + await provider.deleteLevelMessage(messageId); 155 + } 156 + }) 157 + ); 158 + } 159 + 160 + /** 161 + * delete an admin level message across all providers 162 + * messageIdsJson is the JSON string stored in the database 163 + */ 164 + async deleteAdminLevelMessage(messageIdsJson: string | null): Promise<void> { 165 + if (!messageIdsJson || this.providers.length === 0) return; 166 + 167 + let messageIds: ProviderMessageIds; 168 + try { 169 + messageIds = JSON.parse(messageIdsJson); 170 + } catch { 171 + return; 172 + } 173 + 174 + await Promise.allSettled( 175 + this.providers.map(async (provider) => { 176 + const messageId = messageIds[provider.name]; 177 + if (messageId && "deleteAdminLevelMessage" in provider) { 178 + await (provider as any).deleteAdminLevelMessage(messageId); 179 + } 180 + }) 181 + ); 182 + } 183 + 184 + /** 185 + * send a report message to all providers 186 + */ 187 + async sendReportMessage(report: ReportInfo): Promise<void> { 188 + if (this.providers.length === 0) return; 189 + 190 + await Promise.allSettled(this.providers.map((provider) => provider.sendReportMessage(report))); 191 + } 192 + 193 + /** 194 + * send an audit log entry to all providers 195 + */ 196 + async sendAuditLog(entry: AuditLogEntry): Promise<void> { 197 + if (this.providers.length === 0) return; 198 + 199 + await Promise.allSettled( 200 + this.providers.map(async (provider) => { 201 + if ("sendAuditLog" in provider) { 202 + await (provider as any).sendAuditLog(entry); 203 + } 204 + }) 205 + ); 206 + } 207 + 208 + get hasProviders(): boolean { 209 + return this.providers.length > 0; 210 + } 211 + }
+70
modules/logging/types.ts
··· 1 + export type LevelInfo = { 2 + id: number; 3 + name: string; 4 + author: string; 5 + gameUrl: string; 6 + backendUrl: string; 7 + }; 8 + 9 + export type AdminLevelInfo = LevelInfo & { 10 + editUrl: string; 11 + deleteUrl: string; 12 + }; 13 + 14 + export type AuditLogEntry = { 15 + action: "edit" | "delete" | "feature" | "unfeature"; 16 + levelId: number; 17 + levelName: string; 18 + author: string; 19 + changes?: string; 20 + }; 21 + 22 + export type ReportInfo = { 23 + levelId: number; 24 + levelName: string; 25 + author: string; 26 + reason: string; 27 + reporterIp: string; 28 + editUrl: string; 29 + deleteUrl: string; 30 + viewUrl: string; 31 + mentionUserIds: string[]; 32 + fileAttachment?: { 33 + content: Uint8Array; 34 + fileName: string; 35 + }; 36 + }; 37 + 38 + export interface LoggingProvider { 39 + readonly name: string; 40 + 41 + /** 42 + * initialize the provider (connect to service, authenticate, etc.) 43 + * returns true if initialization was successful 44 + */ 45 + init(): Promise<boolean>; 46 + 47 + /** 48 + * send a new level upload message 49 + * returns a provider-specific message ID, or null if failed 50 + */ 51 + sendLevelMessage(level: LevelInfo): Promise<string | null>; 52 + 53 + /** 54 + * edit an existing level message (e.g., when name/author changes) 55 + * returns true if successful 56 + */ 57 + editLevelMessage(messageId: string, level: LevelInfo): Promise<boolean>; 58 + 59 + /** 60 + * delete a level message (e.g., when level is deleted) 61 + * returns true if successful 62 + */ 63 + deleteLevelMessage(messageId: string): Promise<boolean>; 64 + 65 + /** 66 + * send a report message (e.g., when a user reports a level) 67 + * returns true if successful 68 + */ 69 + sendReportMessage(report: ReportInfo): Promise<boolean>; 70 + }
+96 -4
modules/routes/admin.ts
··· 1 + import type { ServerConfig } from "../config.ts"; 1 2 import type { DbContext, LevelRecord } from "../db.ts"; 3 + import type { LoggingManager } from "../logging/index.ts"; 2 4 import { decodeLevelFromDisk, encodeIZL3FileToDisk } from "../levels_io.ts"; 3 5 4 6 export function registerAdminRoutes( 5 7 app: any, 8 + config: ServerConfig, 6 9 dbCtx: DbContext, 7 10 deps: { 8 11 ensureAuthenticated: any; 9 12 ensureAuthenticatedOrConsumeTokenForLevelParam: any; 13 + loggingManager: LoggingManager; 10 14 } 11 15 ) { 12 16 // get all levels with pagination and search ··· 161 165 encodeIZL3FileToDisk(dbCtx.dataFolderPath, levelId, levelData.decoded); 162 166 } 163 167 } 168 + 169 + // update logging messages if name or author changed 170 + const typedUpdatedLevel = updatedLevel as LevelRecord; 171 + const changes: string[] = []; 172 + if (name !== undefined && name !== existingLevel.name) { 173 + changes.push(`Name: "${existingLevel.name}" → "${name}"`); 174 + } 175 + if (author !== undefined && author !== existingLevel.author) { 176 + changes.push(`Author: "${existingLevel.author}" → "${author}"`); 177 + } 178 + if (sun !== undefined && sun !== existingLevel.sun) { 179 + changes.push(`Sun: ${existingLevel.sun} → ${sun}`); 180 + } 181 + if (difficulty !== undefined && difficulty !== existingLevel.difficulty) { 182 + changes.push(`Difficulty: ${existingLevel.difficulty} → ${difficulty}`); 183 + } 184 + if (favorites !== undefined && favorites !== existingLevel.favorites) { 185 + changes.push(`Favorites: ${existingLevel.favorites} → ${favorites}`); 186 + } 187 + if (plays !== undefined && plays !== existingLevel.plays) { 188 + changes.push(`Plays: ${existingLevel.plays} → ${plays}`); 189 + } 190 + 191 + if (changes.length > 0) { 192 + const levelInfo = { 193 + id: levelId, 194 + name: typedUpdatedLevel.name, 195 + author: typedUpdatedLevel.author, 196 + gameUrl: config.gameUrl, 197 + backendUrl: config.backendUrl, 198 + }; 199 + 200 + if (typedUpdatedLevel.logging_data) { 201 + await deps.loggingManager.editLevelMessage(typedUpdatedLevel.logging_data, levelInfo); 202 + } 203 + if (typedUpdatedLevel.admin_logging_data) { 204 + const adminLevelInfo = { 205 + ...levelInfo, 206 + editUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 207 + dbCtx.createOneTimeTokenForLevel(levelId) 208 + )}&action=edit&level=${levelId}`, 209 + deleteUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 210 + dbCtx.createOneTimeTokenForLevel(levelId) 211 + )}&action=delete&level=${levelId}`, 212 + }; 213 + await deps.loggingManager.editAdminLevelMessage(typedUpdatedLevel.admin_logging_data, adminLevelInfo); 214 + } 215 + 216 + await deps.loggingManager.sendAuditLog({ 217 + action: "edit", 218 + levelId, 219 + levelName: typedUpdatedLevel.name, 220 + author: typedUpdatedLevel.author, 221 + changes: changes.join("\n"), 222 + }); 223 + } 224 + 164 225 res.json({ 165 226 success: true, 166 227 level: updatedLevel, ··· 175 236 }); 176 237 177 238 // feature a level (admin only) 178 - app.post("/api/admin/levels/:id/feature", deps.ensureAuthenticated, (req: any, res: any) => { 239 + app.post("/api/admin/levels/:id/feature", deps.ensureAuthenticated, async (req: any, res: any) => { 179 240 try { 180 241 const levelId = parseInt(req.params.id); 181 242 ··· 191 252 const now = Math.floor(Date.now() / 1000); 192 253 dbCtx.db.prepare("UPDATE levels SET featured = 1, featured_at = ? WHERE id = ?").run(now, levelId); 193 254 194 - const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 255 + const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId) as LevelRecord; 256 + 257 + await deps.loggingManager.sendAuditLog({ 258 + action: "feature", 259 + levelId, 260 + levelName: updatedLevel.name, 261 + author: updatedLevel.author, 262 + }); 263 + 195 264 res.json({ success: true, level: updatedLevel }); 196 265 } catch (error) { 197 266 console.error("Error featuring level:", error); ··· 203 272 }); 204 273 205 274 // unfeature a level (admin only) 206 - app.delete("/api/admin/levels/:id/feature", deps.ensureAuthenticated, (req: any, res: any) => { 275 + app.delete("/api/admin/levels/:id/feature", deps.ensureAuthenticated, async (req: any, res: any) => { 207 276 try { 208 277 const levelId = parseInt(req.params.id); 209 278 ··· 218 287 219 288 dbCtx.db.prepare("UPDATE levels SET featured = 0, featured_at = NULL WHERE id = ?").run(levelId); 220 289 221 - const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId); 290 + const updatedLevel = dbCtx.db.prepare("SELECT * FROM levels WHERE id = ?").get(levelId) as LevelRecord; 291 + 292 + await deps.loggingManager.sendAuditLog({ 293 + action: "unfeature", 294 + levelId, 295 + levelName: updatedLevel.name, 296 + author: updatedLevel.author, 297 + }); 298 + 222 299 res.json({ success: true, level: updatedLevel }); 223 300 } catch (error) { 224 301 console.error("Error unfeaturing level:", error); ··· 245 322 } 246 323 247 324 const typedLevel = existingLevel as LevelRecord; 325 + 326 + // delete logging messages if they exist 327 + if (typedLevel.logging_data) { 328 + await deps.loggingManager.deleteLevelMessage(typedLevel.logging_data); 329 + } 330 + if (typedLevel.admin_logging_data) { 331 + await deps.loggingManager.deleteAdminLevelMessage(typedLevel.admin_logging_data); 332 + } 333 + 334 + await deps.loggingManager.sendAuditLog({ 335 + action: "delete", 336 + levelId, 337 + levelName: typedLevel.name, 338 + author: typedLevel.author, 339 + }); 248 340 249 341 dbCtx.db.prepare("DELETE FROM favorites WHERE level_id = ?").run(levelId); 250 342 dbCtx.db.prepare("DELETE FROM levels WHERE id = ?").run(levelId);
+60 -69
modules/routes/levels.ts
··· 1 - import { Buffer } from "node:buffer"; 2 - import { EmbedBuilder, type WebhookClient } from "discord.js"; 3 - 4 1 import { allPlantsStringArray, decodeFile, decodeLevelFromDisk, detectFileVersion } from "../levels_io.ts"; 5 2 import { validateClone } from "../validate.ts"; 6 3 7 4 import type { ServerConfig } from "../config.ts"; 8 5 import type { DbContext, LevelRecord } from "../db.ts"; 9 - import { trySendDiscordWebhook } from "../discord.ts"; 6 + import type { LoggingManager } from "../logging/index.ts"; 10 7 import type { ModerationResult } from "../moderation.ts"; 11 8 import type { TurnstileResponse } from "../turnstile.ts"; 12 9 import { getClientIP } from "../request.ts"; ··· 18 15 deps: { 19 16 validateTurnstile: (response: string, remoteip: string) => Promise<TurnstileResponse>; 20 17 moderateContent: (text: string) => Promise<ModerationResult>; 21 - reportWebhookClient?: WebhookClient; 22 - uploadWebhookClient?: WebhookClient; 18 + loggingManager: LoggingManager; 23 19 } 24 20 ) { 25 21 const uploadRateLimitByIp = new Map<string, number>(); ··· 231 227 .run(author, levelId, now, JSON.stringify([levelId]), clientIP); 232 228 } 233 229 234 - if (config.useUploadLogging) { 235 - await trySendDiscordWebhook(deps.uploadWebhookClient, { 236 - username: "Level Uploads", 237 - content: "", 238 - embeds: [ 239 - new EmbedBuilder() 240 - .setTitle(name) 241 - .setDescription( 242 - `By **${author}**\n\n` + 243 - `**[Play](<${config.gameUrl}/?izl_id=${levelId}>)** | ` + 244 - `[Download](<${config.backendUrl}/api/levels/${levelId}/download>)` 245 - ) 246 - .setAuthor({ 247 - name: "New level uploaded", 248 - }), 249 - ], 250 - }); 230 + if (config.useUploadLogging && deps.loggingManager.hasProviders) { 231 + const levelInfo = { 232 + id: levelId, 233 + name, 234 + author, 235 + gameUrl: config.gameUrl, 236 + backendUrl: config.backendUrl, 237 + }; 238 + 239 + const adminLevelInfo = { 240 + ...levelInfo, 241 + editUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 242 + dbCtx.createOneTimeTokenForLevel(levelId) 243 + )}&action=edit&level=${levelId}`, 244 + deleteUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 245 + dbCtx.createOneTimeTokenForLevel(levelId) 246 + )}&action=delete&level=${levelId}`, 247 + }; 248 + 249 + const messageIds = await deps.loggingManager.sendLevelMessage(levelInfo); 250 + const adminMessageIds = await deps.loggingManager.sendAdminLevelMessage(adminLevelInfo); 251 + 252 + if (messageIds || adminMessageIds) { 253 + dbCtx.db 254 + .prepare("UPDATE levels SET logging_data = ?, admin_logging_data = ? WHERE id = ?") 255 + .run(messageIds, adminMessageIds, levelId); 256 + } 251 257 } 252 258 253 259 res.status(201).json({ ··· 608 614 609 615 const typedLevel = level as LevelRecord; 610 616 611 - if (deps.reportWebhookClient) { 612 - const version = typedLevel.version ?? 3; 613 - const fileExtension = `izl${version || 3}`; 614 - const filePath = `${dbCtx.dataFolderPath}/${levelId}.${fileExtension}`; 617 + const version = typedLevel.version ?? 3; 618 + const fileExtension = `izl${version || 3}`; 619 + const filePath = `${dbCtx.dataFolderPath}/${levelId}.${fileExtension}`; 620 + const safeName = (typedLevel.name || `level_${levelId}`).replace(/[^a-zA-Z0-9]/g, "_"); 615 621 616 - const safeName = (typedLevel.name || `level_${levelId}`).replace(/[^a-zA-Z0-9]/g, "_"); 622 + let fileContent: Uint8Array | null = null; 623 + try { 624 + fileContent = Deno.readFileSync(filePath); 625 + } catch (fileError) { 626 + console.error("Error reading level file for report:", fileError); 627 + } 617 628 618 - let fileContent: Uint8Array | null = null; 619 - try { 620 - fileContent = Deno.readFileSync(filePath); 621 - } catch (fileError) { 622 - console.error("Error reading level file for report:", fileError); 623 - } 624 - 625 - const mentionString = (function (): string { 626 - if (config.discordMentionUserIds.length === 0) return ""; 627 - return config.discordMentionUserIds.map((m) => `<@${m}>`).join(" "); 628 - })(); 629 - 630 - await trySendDiscordWebhook(deps.reportWebhookClient, { 631 - username: "Level Reports", 632 - content: 633 - `${mentionString} New level report received:\n` + 634 - `Level ID: ${levelId}\n` + 635 - `Level Name: ${typedLevel.name}\n` + 636 - `Author: ${typedLevel.author}\n` + 637 - `Reason: ${reason}\n` + 638 - `Reported from IP: ${getClientIP(req)}\n` + 639 - `**[Edit level metadata](<${config.backendUrl}/admin.html?token=${encodeURIComponent( 640 - dbCtx.createOneTimeTokenForLevel(levelId) 641 - )}&action=edit&level=${levelId}>)**` + 642 - ` | **[Delete level](<${config.backendUrl}/admin.html?token=${encodeURIComponent( 643 - dbCtx.createOneTimeTokenForLevel(levelId) 644 - )}&action=delete&level=${levelId}>)**` + 645 - ` | **[View level](<${config.gameUrl}/?izl_id=${levelId}>)**` + 646 - (fileContent ? "" : "\n\n(Attachment missing: level file not found)"), 647 - ...(fileContent 648 - ? { 649 - files: [ 650 - { 651 - attachment: Buffer.from(fileContent), 652 - name: `${safeName}.${fileExtension}`, 653 - }, 654 - ], 655 - } 656 - : {}), 657 - }); 658 - } 629 + await deps.loggingManager.sendReportMessage({ 630 + levelId, 631 + levelName: typedLevel.name, 632 + author: typedLevel.author, 633 + reason, 634 + reporterIp: getClientIP(req), 635 + editUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 636 + dbCtx.createOneTimeTokenForLevel(levelId) 637 + )}&action=edit&level=${levelId}`, 638 + deleteUrl: `${config.backendUrl}/admin.html?token=${encodeURIComponent( 639 + dbCtx.createOneTimeTokenForLevel(levelId) 640 + )}&action=delete&level=${levelId}`, 641 + viewUrl: `${config.gameUrl}/?izl_id=${levelId}`, 642 + mentionUserIds: config.discordMentionUserIds, 643 + fileAttachment: fileContent 644 + ? { 645 + content: fileContent, 646 + fileName: `${safeName}.${fileExtension}`, 647 + } 648 + : undefined, 649 + }); 659 650 660 651 res.json({ success: true }); 661 652 } catch (error) {
+4
modules/validate.ts
··· 115 115 } 116 116 117 117 function noDuplicateZombies(clone: Clone): boolean { 118 + if (!clone.selectedZombies) return true; 119 + 118 120 const zombieSet = new Set<string>(); 119 121 120 122 for (const zombie of clone.selectedZombies) { ··· 171 173 } 172 174 173 175 function noInvalidZombies(clone: Clone): boolean { 176 + if (!clone.selectedZombies) return true; 177 + 174 178 const validZombies = new Set(iZombies); 175 179 176 180 for (const zombie of clone.selectedZombies) {