Skip to content

Commit f0f425d

Browse files
committed
Create repair-transcript-images.js
1 parent f7eb8a9 commit f0f425d

1 file changed

Lines changed: 174 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env node
2+
/**
3+
* repair-transcript-images.js — one-off repair for already-stored transcripts.
4+
*
5+
* Background: older transcripts embedded message image/file attachments using the
6+
* raw Discord CDN URL (cdn.discordapp.com/attachments/…). Those URLs are signed
7+
* and expire ~24h after generation, so every such image is now broken. The bot
8+
* already uploaded a permanent copy of each attachment to this server
9+
* (attachments/<uuid>.<ext>), it was just never referenced by the HTML.
10+
*
11+
* This script rewrites the stored transcript.html files: it replaces every dead
12+
* Discord CDN attachment URL with the matching local copy, addressed by a RELATIVE
13+
* path (attachments/<uuid>.<ext>) so it resolves under both a custom domain and
14+
* the main site.
15+
*
16+
* Mapping: old transcripts don't store the Discord attachment id, so files are
17+
* matched by ORIGINAL FILENAME (present both in the Discord URL and in
18+
* ticketbot_attachments.original_name). Unique names → exact. Several files
19+
* sharing a name within one transcript (e.g. multiple "image.png") → matched
20+
* positionally in document order (best effort; logged as ambiguous).
21+
*
22+
* SAFE BY DEFAULT: dry-run unless --apply is passed. The rewrite is idempotent
23+
* (it only touches cdn.discordapp.com/media.discordapp.net attachment URLs that
24+
* map to a stored file), so re-running causes no further changes.
25+
*
26+
* Deployed with the repo at /opt/msk-shop/scripts/. Reads DB_* from the env but
27+
* does NOT load dotenv and requires mysql2 — run it like cleanup.js:
28+
*
29+
* set -a; . /opt/msk-shop/.env.local; set +a; \
30+
* NODE_PATH=/opt/msk-shop/node_modules /usr/bin/node \
31+
* /opt/msk-shop/scripts/repair-transcript-images.js # dry-run
32+
*
33+
* …same… /opt/msk-shop/scripts/repair-transcript-images.js --apply # write
34+
*
35+
* Recommended: snapshot the transcripts dir first, e.g.
36+
* tar czf /root/transcripts-backup-$(date +%F).tgz -C /var/www/html transcripts
37+
*
38+
* Flags:
39+
* --apply actually write the files (default: dry-run, no writes)
40+
* --guild <id> only process transcripts of this guild_id
41+
* --limit <n> process at most n transcripts (useful for a test run)
42+
*/
43+
44+
const { readFile, writeFile } = require('fs/promises');
45+
const path = require('path');
46+
const mysql = require('mysql2/promise');
47+
48+
// Discord attachment URLs as embedded in the transcript HTML (raw, inside a
49+
// src="" / href="" attribute). Both CDN hosts are matched defensively.
50+
const DISCORD_URL_RE =
51+
/(["'])(https:\/\/(?:cdn\.discordapp\.com|media\.discordapp\.net)\/attachments\/\d+\/\d+\/[^"']+)\1/g;
52+
53+
function parseArgs(argv) {
54+
const args = { apply: false, guild: null, limit: null };
55+
for (let i = 0; i < argv.length; i++) {
56+
const a = argv[i];
57+
if (a === '--apply') args.apply = true;
58+
else if (a === '--guild') args.guild = argv[++i] ?? null;
59+
else if (a === '--limit') args.limit = Number(argv[++i] ?? '') || null;
60+
}
61+
return args;
62+
}
63+
64+
/** Filename segment of a Discord attachment URL, query stripped + percent-decoded. */
65+
function filenameFromUrl(url) {
66+
const noQuery = url.split('?')[0];
67+
const last = noQuery.substring(noQuery.lastIndexOf('/') + 1);
68+
try { return decodeURIComponent(last); } catch { return last; }
69+
}
70+
71+
async function main() {
72+
const args = parseArgs(process.argv.slice(2));
73+
const mode = args.apply ? 'APPLY' : 'DRY-RUN';
74+
75+
const pool = mysql.createPool({
76+
host: process.env.DB_HOST ?? 'localhost',
77+
port: Number(process.env.DB_PORT ?? 3306),
78+
user: process.env.DB_USER ?? '',
79+
password: process.env.DB_PASSWORD ?? '',
80+
database: process.env.DB_NAME ?? '',
81+
});
82+
83+
console.log(`[repair] Starting (${mode}) at ${new Date().toISOString()}`);
84+
85+
const where = ['has_attachments = 1'];
86+
const params = [];
87+
if (args.guild) { where.push('guild_id = ?'); params.push(args.guild); }
88+
let sql = `SELECT id, file_path FROM ticketbot_transcripts WHERE ${where.join(' AND ')} ORDER BY created_at ASC`;
89+
if (args.limit) sql += ` LIMIT ${args.limit}`;
90+
91+
const [transcripts] = await pool.execute(sql, params);
92+
console.log(`[repair] ${transcripts.length} transcript(s) with attachments to inspect`);
93+
94+
let filesChanged = 0, urlsReplaced = 0, urlsUnmatched = 0, ambiguous = 0, errors = 0;
95+
96+
for (const t of transcripts) {
97+
try {
98+
const [atts] = await pool.execute(
99+
`SELECT original_name, file_path FROM ticketbot_attachments WHERE transcript_id = ?`,
100+
[t.id],
101+
);
102+
if (atts.length === 0) continue;
103+
104+
// Group local copies by original filename → list of relative URLs.
105+
const groups = new Map(); // name → ["attachments/<uuid>.<ext>", …]
106+
for (const a of atts) {
107+
const rel = `attachments/${path.basename(a.file_path)}`;
108+
if (!groups.has(a.original_name)) groups.set(a.original_name, []);
109+
groups.get(a.original_name).push(rel);
110+
}
111+
112+
let html;
113+
try {
114+
html = await readFile(t.file_path, 'utf-8');
115+
} catch (err) {
116+
console.warn(`[repair] ${t.id}: cannot read ${t.file_path} (${err.code || err.message}) — skipped`);
117+
errors++;
118+
continue;
119+
}
120+
121+
const cursor = new Map(); // name → next index into its group
122+
let replacedHere = 0, unmatchedHere = 0;
123+
124+
const out = html.replace(DISCORD_URL_RE, (full, quote, url) => {
125+
const name = filenameFromUrl(url);
126+
const list = groups.get(name);
127+
const idx = cursor.get(name) ?? 0;
128+
if (list && idx < list.length) {
129+
cursor.set(name, idx + 1);
130+
replacedHere++;
131+
return `${quote}${list[idx]}${quote}`;
132+
}
133+
unmatchedHere++;
134+
return full; // no stored copy for this name → leave the (dead) link
135+
});
136+
137+
// Flag transcripts where a name maps to >1 local file (positional guess).
138+
for (const [, list] of groups) if (list.length > 1) { ambiguous++; break; }
139+
140+
urlsReplaced += replacedHere;
141+
urlsUnmatched += unmatchedHere;
142+
143+
if (out !== html) {
144+
filesChanged++;
145+
if (args.apply) {
146+
await writeFile(t.file_path, out, 'utf-8');
147+
console.log(`[repair] ${t.id}: replaced ${replacedHere} link(s)` + (unmatchedHere ? `, ${unmatchedHere} unmatched` : ''));
148+
} else {
149+
console.log(`[repair] ${t.id}: WOULD replace ${replacedHere} link(s)` + (unmatchedHere ? `, ${unmatchedHere} unmatched` : ''));
150+
}
151+
} else if (unmatchedHere) {
152+
console.log(`[repair] ${t.id}: 0 replaced, ${unmatchedHere} Discord link(s) without a stored copy`);
153+
}
154+
} catch (err) {
155+
console.error(`[repair] ${t.id}: error — ${err.message}`);
156+
errors++;
157+
}
158+
}
159+
160+
await pool.end();
161+
console.log(
162+
`[repair] Done (${mode}). Files ${args.apply ? 'changed' : 'to change'}: ${filesChanged}, ` +
163+
`links replaced: ${urlsReplaced}, unmatched: ${urlsUnmatched}, ` +
164+
`transcripts with duplicate-name guesses: ${ambiguous}, errors: ${errors}`,
165+
);
166+
if (!args.apply && filesChanged > 0) {
167+
console.log('[repair] This was a DRY-RUN — re-run with --apply to write the changes.');
168+
}
169+
}
170+
171+
main().catch(err => {
172+
console.error('[repair] Fatal error:', err);
173+
process.exit(1);
174+
});

0 commit comments

Comments
 (0)