Skip to content

Commit 2db1090

Browse files
committed
feat: implement phishtank as a client instead of util
1 parent 150ceea commit 2db1090

8 files changed

Lines changed: 315 additions & 67 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ PROJECT_AUTO_RESTART=false
66
PROJECT_THRESHOLD_MEMORY=1024
77
PROJECT_MAX_MEMORY=2048 # 2GB
88

9+
PHISHTANK_ENABLE=true
10+
PHISHTANK_UPDATE_HOUR=3 # default run at 03:00 UTC
11+
PHISHTANK_AUTO_UPDATE=true
12+
913
# whether to enable debug mode
1014
# in debug mode, additional logging is enabled
1115
DEBUG=true

package-lock.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"redis": "^5.6.0",
4444
"semver": "^7.7.2",
4545
"systeminformation": "^5.27.7",
46+
"unbzip2-stream": "^1.4.3",
4647
"undici": "^7.16.0",
4748
"whatsapp-web.js": "^1.31.0",
4849
"youtubei.js": "^15.1.1"
@@ -53,6 +54,7 @@
5354
"@types/node": "^24.0.14",
5455
"@types/npmlog": "^7.0.0",
5556
"@types/semver": "^7.7.1",
57+
"@types/unbzip2-stream": "^1.4.3",
5658
"prisma": "^6.12.0",
5759
"ts-node": "^10.9.2",
5860
"typescript": "^5.8.3"

src/components/events/message.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ import { errors } from "../utils/data";
1616
import emojiRegex from "emoji-regex";
1717
import { funD, happyEE, sadEE, loveEE } from "../../data/reaction";
1818
import { containsAny } from "../utils/string";
19-
import { checkMessage } from "../utils/phishtank";
19+
import { phishingSet } from "../../index";
2020
import { getSetting } from "../services/settings";
21+
import { normalize } from "../utils/url";
2122

2223
const regex = emojiRegex();
2324
const commandPrefix = process.env.COMMAND_PREFIX || "!";
2425
const commandPrefixLess = process.env.COMMAND_PREFIX_LESS === "true";
2526
const debug = process.env.DEBUG === "true";
27+
const isPhishtankEnable = process.env.PHISHTANK_ENABLE === "true";
2628
const mentionResponses = [
2729
"👀 Did someone just say my name?",
2830
"Bruh, why me again? 😂",
@@ -60,21 +62,25 @@ export default async function (msg: Message, type: string) {
6062
*
6163
* Check for scam urls
6264
*/
63-
64-
Promise.resolve().then(async () => {
65-
const spamUrls = checkMessage(msg.body);
66-
67-
if (spamUrls.length == 0) return;
68-
69-
const text = `
65+
if (isPhishtankEnable) {
66+
Promise.resolve().then(async () => {
67+
const extractUrls = msg.body.match(/(https?:\/\/[^\s]+)/g) || [];
68+
const urls = extractUrls
69+
.map((url) => normalize(url))
70+
.filter((u): u is string => Boolean(u));
71+
const spamUrls = urls.filter((url) => phishingSet.has(url));
72+
if (spamUrls.length == 0) return;
73+
74+
const text = `
7075
\`Phishing Alert\`
7176
7277
We've found that this url(s): \`${spamUrls.join(", ")}\`
7378
to be phishing site/page.
7479
Proceed with caution.
7580
`;
76-
await msg.reply(text);
77-
});
81+
await msg.reply(text);
82+
});
83+
}
7884

7985
if (msg.isForwarded) return;
8086

src/components/phishtank.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import fs from "fs";
2+
import fsp from "fs/promises";
3+
import path from "path";
4+
import { pipeline } from "stream/promises";
5+
import bz2 from "unbzip2-stream";
6+
import log from "./utils/log";
7+
8+
interface PhishEntry {
9+
phish_id?: string;
10+
phish_detail_url?: string;
11+
url?: string;
12+
submission_time?: string;
13+
online?: string;
14+
target?: string;
15+
[k: string]: any;
16+
}
17+
18+
export default class PhishTankClient {
19+
private dataDir = path.join(__dirname, "../../.phishtank");
20+
private rawFilePath = path.join(this.dataDir, "verified_online.json");
21+
private etagPath = path.join(this.dataDir, "verified_online.etag");
22+
private updateHourUTC = parseInt(process.env.PHISHTANK_UPDATE_HOUR ?? "3");
23+
private autoUpdateDaily = process.env.PHISHTANK_AUTO_UPDATE === "true";
24+
private isPhishtankEnable = process.env.PHISHTANK_ENABLE === "true";
25+
private dataUrl = "http://data.phishtank.com/data/online-valid.json.bz2";
26+
27+
private phishingSet: Set<string> = new Set();
28+
private autoUpdateTimer: NodeJS.Timeout | null = null;
29+
30+
constructor() {
31+
if (this.autoUpdateDaily && this.isPhishtankEnable)
32+
this.startAutoUpdateLoop();
33+
}
34+
35+
async init(): Promise<void> {
36+
if (!this.isPhishtankEnable) return;
37+
await fsp.mkdir(this.dataDir, { recursive: true });
38+
await this.loadLocalFile();
39+
}
40+
41+
private async loadLocalFile(): Promise<void> {
42+
try {
43+
const raw = await fsp.readFile(this.rawFilePath, "utf8");
44+
const parsed = JSON.parse(raw);
45+
this.phishingSet = new Set(
46+
parsed.map((e: PhishEntry) => this.normalizeUrl(e.url)).filter(Boolean),
47+
);
48+
log.info(
49+
"PhishTankClient",
50+
`Loaded ${this.phishingSet.size} phish URLs from disk.`,
51+
);
52+
} catch (err: any) {
53+
log.warn(
54+
"PhishTankClient",
55+
"No local phishtank file found or failed to parse:",
56+
err.message || err,
57+
);
58+
this.updateNow();
59+
}
60+
}
61+
62+
getPhishingSet(): Set<string> {
63+
return this.phishingSet;
64+
}
65+
66+
private normalizeUrl(raw?: string | null): string | null {
67+
if (!raw) return null;
68+
try {
69+
const u = new URL(raw.trim());
70+
let normalized = `${u.protocol}//${u.hostname}${u.pathname}`;
71+
if (!normalized.endsWith("/")) normalized += "/";
72+
return normalized.toLowerCase();
73+
} catch {
74+
return null;
75+
}
76+
}
77+
78+
private async fetchRemoteEtag(): Promise<string | null> {
79+
try {
80+
const res = await fetch(this.dataUrl);
81+
if (!res.ok) {
82+
log.warn(
83+
"PhishTankClient",
84+
`HEAD returned ${res.status} ${res.statusText}`,
85+
);
86+
return null;
87+
}
88+
const etag = res.headers.get("etag") || res.headers.get("ETag");
89+
return etag;
90+
} catch (err: any) {
91+
log.error(
92+
"PhishTankClient",
93+
"Failed to HEAD remote feed:",
94+
err.message || err,
95+
);
96+
return null;
97+
}
98+
}
99+
100+
private async readStoredEtag(): Promise<string | null> {
101+
try {
102+
const s = await fsp.readFile(this.etagPath, "utf8");
103+
return s.trim();
104+
} catch {
105+
return null;
106+
}
107+
}
108+
109+
private async writeStoredEtag(etag: string | null): Promise<void> {
110+
if (!etag) return;
111+
try {
112+
await fsp.writeFile(this.etagPath, etag, "utf8");
113+
} catch (err: any) {
114+
log.warn(
115+
"PhishTankClient",
116+
"Unable to write etag file:",
117+
err.message || err,
118+
);
119+
}
120+
}
121+
122+
async updateNow(): Promise<boolean> {
123+
log.info("PhishTankClient", "Checking PhishTank for updates...");
124+
const remoteEtag = await this.fetchRemoteEtag();
125+
const localEtag = await this.readStoredEtag();
126+
127+
if (remoteEtag && localEtag && remoteEtag === localEtag) {
128+
log.info(
129+
"PhishTankClient",
130+
"PhishTank dataset not changed (ETag match).",
131+
);
132+
return false;
133+
}
134+
135+
try {
136+
const resp = await fetch(this.dataUrl);
137+
if (!resp.ok) {
138+
throw new Error(`Download failed: ${resp.status} ${resp.statusText}`);
139+
}
140+
141+
const tempPath = this.rawFilePath + ".tmp";
142+
const destStream = fs.createWriteStream(tempPath, { encoding: "utf8" });
143+
144+
if (!resp.body) throw new Error("No response body from PhishTank");
145+
146+
await pipeline(resp.body as any, bz2(), destStream);
147+
148+
const raw = await fsp.readFile(tempPath, "utf8");
149+
const parsed = JSON.parse(raw);
150+
if (!Array.isArray(parsed)) {
151+
log.warn(
152+
"PhishTankClient",
153+
"Downloaded phishtank JSON is not an array; continuing but be cautious.",
154+
);
155+
}
156+
157+
await fsp.rename(tempPath, this.rawFilePath);
158+
this.phishingSet = new Set(
159+
parsed.map((e: PhishEntry) => this.normalizeUrl(e.url)).filter(Boolean),
160+
);
161+
log.info(
162+
"PhishTankClient",
163+
`Updated local phishtank file; ${this.phishingSet.size} URLs loaded.`,
164+
);
165+
if (remoteEtag) await this.writeStoredEtag(remoteEtag);
166+
return true;
167+
} catch (err: any) {
168+
log.error("PhishTankClient", "Update failed:", err.message || err);
169+
return false;
170+
}
171+
}
172+
173+
startAutoUpdateLoop() {
174+
if (this.autoUpdateTimer) return;
175+
176+
const scheduleNext = async () => {
177+
try {
178+
const now = new Date();
179+
const next = new Date(
180+
Date.UTC(
181+
now.getUTCFullYear(),
182+
now.getUTCMonth(),
183+
now.getUTCDate(),
184+
this.updateHourUTC,
185+
0,
186+
0,
187+
0,
188+
),
189+
);
190+
if (next <= now) next.setUTCDate(next.getUTCDate() + 1);
191+
const ms = next.getTime() - now.getTime();
192+
log.info(
193+
"PhishTankClient",
194+
`Next PhishTank update scheduled at ${next.toISOString()}`,
195+
);
196+
this.autoUpdateTimer = setTimeout(async () => {
197+
try {
198+
await this.updateNow();
199+
} catch (err: any) {
200+
log.error(
201+
"PhishTankClient",
202+
"Scheduled update failed:",
203+
err.message || err,
204+
);
205+
} finally {
206+
// schedule again
207+
scheduleNext();
208+
}
209+
}, ms);
210+
} catch (err: any) {
211+
log.error(
212+
"PhishTankClient",
213+
"Failed to schedule next update:",
214+
err.message || err,
215+
);
216+
this.autoUpdateTimer = setTimeout(scheduleNext, 60 * 60 * 1000);
217+
}
218+
};
219+
220+
this.init()
221+
.then(() => {
222+
this.fetchRemoteEtag()
223+
.then(async (remoteEtag) => {
224+
const localEtag = await this.readStoredEtag();
225+
if (!remoteEtag || !localEtag || remoteEtag !== localEtag) {
226+
await this.updateNow();
227+
} else {
228+
log.info(
229+
"PhishTankClient",
230+
"Local data already up-to-date at startup.",
231+
);
232+
}
233+
scheduleNext();
234+
})
235+
.catch((err) => {
236+
log.warn("PhishTankClient", "HEAD check failed at startup:", err);
237+
scheduleNext();
238+
});
239+
})
240+
.catch((err) => {
241+
log.error("PhishTankClient", "Init failed:", err);
242+
scheduleNext();
243+
});
244+
}
245+
246+
stopAutoUpdateLoop() {
247+
if (this.autoUpdateTimer) {
248+
clearTimeout(this.autoUpdateTimer);
249+
this.autoUpdateTimer = null;
250+
log.info("PhishTankClient", "Auto-update loop stopped.");
251+
}
252+
}
253+
}

0 commit comments

Comments
 (0)