carom-link 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +221 -0
- package/bin/carom.js +2 -0
- package/package.json +46 -0
- package/public/app.js +519 -0
- package/public/index.html +233 -0
- package/public/style.css +756 -0
- package/src/cli/commands/add.js +106 -0
- package/src/cli/commands/config.js +95 -0
- package/src/cli/commands/install.js +50 -0
- package/src/cli/commands/list.js +62 -0
- package/src/cli/commands/logs.js +70 -0
- package/src/cli/commands/remove.js +36 -0
- package/src/cli/commands/rules.js +168 -0
- package/src/cli/commands/start.js +43 -0
- package/src/cli/commands/stats.js +86 -0
- package/src/cli/commands/status.js +89 -0
- package/src/cli/commands/uninstall.js +28 -0
- package/src/cli/formatters.js +132 -0
- package/src/cli/index.js +45 -0
- package/src/cloak/detector.js +243 -0
- package/src/cloak/ipLookup.js +146 -0
- package/src/cloak/patterns.js +160 -0
- package/src/cloak/safePage.js +146 -0
- package/src/cloak/tokens.js +67 -0
- package/src/config.js +152 -0
- package/src/constants.js +78 -0
- package/src/db.js +256 -0
- package/src/server/app.js +110 -0
- package/src/server/routes/api.js +268 -0
- package/src/server/routes/redirect.js +141 -0
- package/src/server/server.js +117 -0
- package/src/service/launchd.js +166 -0
- package/src/service/manager.js +79 -0
- package/src/service/systemd.js +147 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getDb, findLinkByIdOrSlug, getClickStats } from '../../db.js';
|
|
3
|
+
import { formatTable, formatClassification, formatDate, printError, printInfo, truncate } from '../formatters.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Register the `stats` command.
|
|
7
|
+
*/
|
|
8
|
+
export function registerStatsCommand(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('stats <idOrSlug>')
|
|
11
|
+
.description('Show detailed click analytics for a link')
|
|
12
|
+
.option('--json', 'Output as JSON')
|
|
13
|
+
.action(async (idOrSlug, options) => {
|
|
14
|
+
try {
|
|
15
|
+
const dataDir = program.opts().dataDir;
|
|
16
|
+
const db = getDb(dataDir);
|
|
17
|
+
|
|
18
|
+
const link = findLinkByIdOrSlug(db, idOrSlug);
|
|
19
|
+
if (!link) {
|
|
20
|
+
printError(`Link "${idOrSlug}" not found.`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const stats = getClickStats(db, link.id);
|
|
25
|
+
|
|
26
|
+
if (options.json) {
|
|
27
|
+
console.log(JSON.stringify({ link, stats }, null, 2));
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Header
|
|
32
|
+
console.log('');
|
|
33
|
+
console.log(chalk.bold.cyan(` Link: /${link.slug}`));
|
|
34
|
+
console.log(chalk.dim(` Target: ${link.destination_url}`));
|
|
35
|
+
console.log(chalk.dim(` Created: ${formatDate(link.created_at)}`));
|
|
36
|
+
console.log('');
|
|
37
|
+
|
|
38
|
+
// Click summary
|
|
39
|
+
console.log(chalk.bold(' Click Summary'));
|
|
40
|
+
console.log(chalk.dim(' ─────────────'));
|
|
41
|
+
console.log(` Total: ${chalk.bold(stats.totals.total)}`);
|
|
42
|
+
console.log(` Human: ${chalk.green(stats.totals.human || 0)}`);
|
|
43
|
+
console.log(` Bot: ${chalk.red(stats.totals.bot || 0)}`);
|
|
44
|
+
console.log(` Suspicious: ${chalk.yellow(stats.totals.suspicious || 0)}`);
|
|
45
|
+
console.log('');
|
|
46
|
+
|
|
47
|
+
// Timeline
|
|
48
|
+
console.log(chalk.bold(' Timeline'));
|
|
49
|
+
console.log(chalk.dim(' ────────'));
|
|
50
|
+
console.log(` Last 24h: ${stats.timeline.last24h}`);
|
|
51
|
+
console.log(` Last 7d: ${stats.timeline.last7d}`);
|
|
52
|
+
console.log(` Last 30d: ${stats.timeline.last30d}`);
|
|
53
|
+
console.log('');
|
|
54
|
+
|
|
55
|
+
// Top user agents
|
|
56
|
+
if (stats.topUserAgents.length > 0) {
|
|
57
|
+
console.log(chalk.bold(' Top User Agents'));
|
|
58
|
+
const uaHeaders = ['USER AGENT', 'TYPE', 'COUNT'];
|
|
59
|
+
const uaRows = stats.topUserAgents.map(r => [
|
|
60
|
+
truncate(r.user_agent || '(empty)', 50),
|
|
61
|
+
formatClassification(r.classification),
|
|
62
|
+
r.count,
|
|
63
|
+
]);
|
|
64
|
+
console.log(formatTable(uaHeaders, uaRows));
|
|
65
|
+
console.log('');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Top bot signals
|
|
69
|
+
if (stats.topSignals.length > 0) {
|
|
70
|
+
console.log(chalk.bold(' Top Bot Signals'));
|
|
71
|
+
const sigHeaders = ['SIGNALS', 'COUNT'];
|
|
72
|
+
const sigRows = stats.topSignals.map(r => [
|
|
73
|
+
r.bot_signals || '—',
|
|
74
|
+
r.count,
|
|
75
|
+
]);
|
|
76
|
+
console.log(formatTable(sigHeaders, sigRows));
|
|
77
|
+
console.log('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
process.exit(0);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
printError(err.message);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { loadConfig } from '../../config.js';
|
|
5
|
+
import { getDb, getLinkCount } from '../../db.js';
|
|
6
|
+
import { getServiceStatus, isServiceInstalled, getServiceType } from '../../service/manager.js';
|
|
7
|
+
import { DEFAULT_DATA_DIR, PID_FILENAME } from '../../constants.js';
|
|
8
|
+
import { printInfo, printError } from '../formatters.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register the `status` command.
|
|
12
|
+
*/
|
|
13
|
+
export function registerStatusCommand(program) {
|
|
14
|
+
program
|
|
15
|
+
.command('status')
|
|
16
|
+
.description('Show carom server status')
|
|
17
|
+
.action(async () => {
|
|
18
|
+
try {
|
|
19
|
+
const dataDir = program.opts().dataDir || DEFAULT_DATA_DIR;
|
|
20
|
+
const config = loadConfig(dataDir);
|
|
21
|
+
|
|
22
|
+
console.log('');
|
|
23
|
+
console.log(chalk.bold.cyan(' carom Status'));
|
|
24
|
+
console.log('');
|
|
25
|
+
|
|
26
|
+
// Check PID file
|
|
27
|
+
const pidPath = join(dataDir, PID_FILENAME);
|
|
28
|
+
let pidRunning = false;
|
|
29
|
+
let pid = null;
|
|
30
|
+
|
|
31
|
+
if (existsSync(pidPath)) {
|
|
32
|
+
pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0); // Test if process exists
|
|
35
|
+
pidRunning = true;
|
|
36
|
+
} catch {
|
|
37
|
+
pidRunning = false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check service status
|
|
42
|
+
const serviceStatus = await getServiceStatus();
|
|
43
|
+
|
|
44
|
+
const running = pidRunning || serviceStatus.running;
|
|
45
|
+
const displayPid = serviceStatus.pid || pid;
|
|
46
|
+
|
|
47
|
+
if (running) {
|
|
48
|
+
console.log(` ${chalk.green('●')} ${chalk.bold('carom is running')} ${displayPid ? chalk.dim(`(PID ${displayPid})`) : ''}`);
|
|
49
|
+
} else {
|
|
50
|
+
console.log(` ${chalk.red('●')} ${chalk.bold('carom is not running')}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
console.log(` ${chalk.dim('Port:')} ${config.port}`);
|
|
54
|
+
console.log(` ${chalk.dim('Host:')} ${config.host}`);
|
|
55
|
+
console.log(` ${chalk.dim('Protection:')} ${config.shield?.enabled ? chalk.green('ON') : chalk.red('OFF')}`);
|
|
56
|
+
console.log(` ${chalk.dim('API Key:')} ${config.apiKey ? chalk.green('SET') : chalk.yellow('NOT SET')}`);
|
|
57
|
+
|
|
58
|
+
// Link count
|
|
59
|
+
try {
|
|
60
|
+
const db = getDb(dataDir);
|
|
61
|
+
const count = getLinkCount(db);
|
|
62
|
+
console.log(` ${chalk.dim('Links:')} ${count}`);
|
|
63
|
+
} catch {
|
|
64
|
+
console.log(` ${chalk.dim('Links:')} ${chalk.dim('(DB not accessible)')}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Service info
|
|
68
|
+
console.log('');
|
|
69
|
+
const serviceType = getServiceType();
|
|
70
|
+
if (serviceStatus.installed) {
|
|
71
|
+
console.log(` ${chalk.dim('Service:')} ${chalk.green('Installed')} (${serviceType})`);
|
|
72
|
+
console.log(` ${chalk.dim('Auto-start:')} ${chalk.green('Yes (survives reboots)')}`);
|
|
73
|
+
if (serviceStatus.path) {
|
|
74
|
+
console.log(` ${chalk.dim('Config:')} ${serviceStatus.path}`);
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
77
|
+
console.log(` ${chalk.dim('Service:')} ${chalk.dim('Not installed')}`);
|
|
78
|
+
console.log(` ${chalk.dim('Auto-start:')} ${chalk.dim('No')}`);
|
|
79
|
+
printInfo(`Run ${chalk.bold('carom install')} to auto-start on boot.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log('');
|
|
83
|
+
process.exit(0);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
printError(err.message);
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { uninstallService, isServiceInstalled } from '../../service/manager.js';
|
|
2
|
+
import { printSuccess, printError, printInfo } from '../formatters.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Register the `uninstall` command.
|
|
6
|
+
*/
|
|
7
|
+
export function registerUninstallCommand(program) {
|
|
8
|
+
program
|
|
9
|
+
.command('uninstall')
|
|
10
|
+
.description('Remove the carom system service')
|
|
11
|
+
.action(async () => {
|
|
12
|
+
try {
|
|
13
|
+
if (!isServiceInstalled()) {
|
|
14
|
+
printError('carom service is not installed.');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const result = await uninstallService();
|
|
19
|
+
printSuccess(result.message);
|
|
20
|
+
printInfo('Your data (database, config, logs) has been preserved in ~/.carom/');
|
|
21
|
+
|
|
22
|
+
process.exit(0);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
printError(err.message);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Format a table for terminal output.
|
|
5
|
+
*/
|
|
6
|
+
export function formatTable(headers, rows) {
|
|
7
|
+
if (rows.length === 0) {
|
|
8
|
+
return chalk.dim(' No results.');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Calculate column widths
|
|
12
|
+
const widths = headers.map((h, i) => {
|
|
13
|
+
const maxData = rows.reduce((max, row) => Math.max(max, String(row[i] ?? '').length), 0);
|
|
14
|
+
return Math.max(h.length, maxData);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Header row
|
|
18
|
+
const headerLine = headers.map((h, i) => chalk.bold.cyan(h.padEnd(widths[i]))).join(' ');
|
|
19
|
+
const separator = widths.map(w => chalk.dim('─'.repeat(w))).join('──');
|
|
20
|
+
|
|
21
|
+
// Data rows
|
|
22
|
+
const dataLines = rows.map(row =>
|
|
23
|
+
row.map((cell, i) => {
|
|
24
|
+
const str = String(cell ?? '');
|
|
25
|
+
return str.padEnd(widths[i]);
|
|
26
|
+
}).join(' ')
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
return [headerLine, separator, ...dataLines].join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Truncate a string to a max length.
|
|
34
|
+
*/
|
|
35
|
+
export function truncate(str, maxLen = 40) {
|
|
36
|
+
if (!str) return '';
|
|
37
|
+
if (str.length <= maxLen) return str;
|
|
38
|
+
return str.substring(0, maxLen - 1) + '…';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format a click count with human/bot breakdown.
|
|
43
|
+
*/
|
|
44
|
+
export function formatClicks(human, bot) {
|
|
45
|
+
const h = chalk.green(String(human || 0));
|
|
46
|
+
const b = chalk.red(String(bot || 0));
|
|
47
|
+
return `${h}/${b}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format an active/inactive status badge.
|
|
52
|
+
*/
|
|
53
|
+
export function formatStatus(active) {
|
|
54
|
+
return active ? chalk.green('✓ active') : chalk.dim('✗ inactive');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Format a classification badge.
|
|
59
|
+
*/
|
|
60
|
+
export function formatClassification(classification) {
|
|
61
|
+
switch (classification) {
|
|
62
|
+
case 'human': return chalk.green('👤 human');
|
|
63
|
+
case 'bot': return chalk.red('🛡 bot');
|
|
64
|
+
case 'suspicious': return chalk.yellow('⚠ suspicious');
|
|
65
|
+
default: return chalk.dim(classification || 'unknown');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Print a banner for server startup.
|
|
71
|
+
*/
|
|
72
|
+
export function printBanner(config, linkCount) {
|
|
73
|
+
const adminPort = config.adminPort || 3001;
|
|
74
|
+
const lines = [
|
|
75
|
+
'',
|
|
76
|
+
chalk.bold.cyan(' ╔═══════════════════════════════════╗'),
|
|
77
|
+
chalk.bold.cyan(' ║') + chalk.bold.white(' carom v1.0.0 ') + chalk.bold.cyan('║'),
|
|
78
|
+
chalk.bold.cyan(' ╚═══════════════════════════════════╝'),
|
|
79
|
+
'',
|
|
80
|
+
` ${chalk.dim('Redirects:')} ${chalk.white(`http://${config.host}:${config.port}`)}`,
|
|
81
|
+
` ${chalk.dim('Admin:')} ${chalk.white(`http://${config.host}:${adminPort}`)}`,
|
|
82
|
+
` ${chalk.dim('API:')} ${chalk.white(`http://${config.host}:${adminPort}/api`)}`,
|
|
83
|
+
` ${chalk.dim('Links:')} ${chalk.white(linkCount)}`,
|
|
84
|
+
` ${chalk.dim('Protection:')} ${config.shield?.enabled ? chalk.green('ON') + chalk.dim(` (threshold: ${config.shield.threshold}, mode: ${config.shield.mode})`) : chalk.red('OFF')}`,
|
|
85
|
+
` ${chalk.dim('API Key:')} ${config.apiKey ? chalk.green('SET') : chalk.yellow('NOT SET (open access)')}`,
|
|
86
|
+
'',
|
|
87
|
+
chalk.dim(' Press Ctrl+C to stop.'),
|
|
88
|
+
'',
|
|
89
|
+
];
|
|
90
|
+
console.log(lines.join('\n'));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Print a success message.
|
|
95
|
+
*/
|
|
96
|
+
export function printSuccess(message) {
|
|
97
|
+
console.log(chalk.green('✓') + ' ' + message);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Print an error message.
|
|
102
|
+
*/
|
|
103
|
+
export function printError(message) {
|
|
104
|
+
console.error(chalk.red('✗') + ' ' + message);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Print a warning message.
|
|
109
|
+
*/
|
|
110
|
+
export function printWarning(message) {
|
|
111
|
+
console.log(chalk.yellow('⚠') + ' ' + message);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Print an info message.
|
|
116
|
+
*/
|
|
117
|
+
export function printInfo(message) {
|
|
118
|
+
console.log(chalk.blue('ℹ') + ' ' + message);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Format a date string for display.
|
|
123
|
+
*/
|
|
124
|
+
export function formatDate(dateStr) {
|
|
125
|
+
if (!dateStr) return chalk.dim('—');
|
|
126
|
+
try {
|
|
127
|
+
const d = new Date(dateStr);
|
|
128
|
+
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
129
|
+
} catch {
|
|
130
|
+
return dateStr;
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { VERSION, PACKAGE_NAME, DEFAULT_DATA_DIR } from '../constants.js';
|
|
3
|
+
import { setConfigDir } from '../config.js';
|
|
4
|
+
import { registerStartCommand } from './commands/start.js';
|
|
5
|
+
import { registerAddCommand } from './commands/add.js';
|
|
6
|
+
import { registerListCommand } from './commands/list.js';
|
|
7
|
+
import { registerRemoveCommand } from './commands/remove.js';
|
|
8
|
+
import { registerStatsCommand } from './commands/stats.js';
|
|
9
|
+
import { registerRulesCommand } from './commands/rules.js';
|
|
10
|
+
import { registerConfigCommand } from './commands/config.js';
|
|
11
|
+
import { registerInstallCommand } from './commands/install.js';
|
|
12
|
+
import { registerUninstallCommand } from './commands/uninstall.js';
|
|
13
|
+
import { registerStatusCommand } from './commands/status.js';
|
|
14
|
+
import { registerLogsCommand } from './commands/logs.js';
|
|
15
|
+
|
|
16
|
+
const program = new Command();
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.name(PACKAGE_NAME)
|
|
20
|
+
.description('Link redirect server with bot and automated traffic protection')
|
|
21
|
+
.version(VERSION)
|
|
22
|
+
.option('--data-dir <path>', `Data directory (default: ${DEFAULT_DATA_DIR})`, DEFAULT_DATA_DIR)
|
|
23
|
+
.option('-v, --verbose', 'Verbose logging')
|
|
24
|
+
.hook('preAction', (thisCommand) => {
|
|
25
|
+
const opts = thisCommand.opts();
|
|
26
|
+
if (opts.dataDir) {
|
|
27
|
+
setConfigDir(opts.dataDir);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Register all commands
|
|
32
|
+
registerStartCommand(program);
|
|
33
|
+
registerAddCommand(program);
|
|
34
|
+
registerListCommand(program);
|
|
35
|
+
registerRemoveCommand(program);
|
|
36
|
+
registerStatsCommand(program);
|
|
37
|
+
registerRulesCommand(program);
|
|
38
|
+
registerConfigCommand(program);
|
|
39
|
+
registerInstallCommand(program);
|
|
40
|
+
registerUninstallCommand(program);
|
|
41
|
+
registerStatusCommand(program);
|
|
42
|
+
registerLogsCommand(program);
|
|
43
|
+
|
|
44
|
+
// Parse CLI arguments
|
|
45
|
+
program.parse();
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { BOT_UA_PATTERNS } from './patterns.js';
|
|
2
|
+
import { lookupIp, getClientIp } from './ipLookup.js';
|
|
3
|
+
import { validateToken } from './tokens.js';
|
|
4
|
+
import { trackVelocity, getRules } from '../db.js';
|
|
5
|
+
import { VELOCITY_THRESHOLD, TIMING_FAST_THRESHOLD_MS } from '../constants.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Score a request for bot likelihood.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} req - Express request object
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {Date|string} options.linkCreatedAt - When the link was created
|
|
13
|
+
* @param {string} options.slug - The link slug
|
|
14
|
+
* @param {object} options.config - Shield config
|
|
15
|
+
* @param {object} options.db - Database connection (for custom rules)
|
|
16
|
+
* @returns {Promise<{ score, signals, isBot, classification }>}
|
|
17
|
+
*/
|
|
18
|
+
export async function scoreRequest(req, { linkCreatedAt, slug, config, db }) {
|
|
19
|
+
const signals = [];
|
|
20
|
+
let score = 0;
|
|
21
|
+
const threshold = config?.shield?.threshold || 40;
|
|
22
|
+
|
|
23
|
+
const ua = (req.headers['user-agent'] || '').toLowerCase();
|
|
24
|
+
const ip = getClientIp(req);
|
|
25
|
+
|
|
26
|
+
// ── 1. Check for valid JS challenge token (bypass all checks) ──
|
|
27
|
+
const token = req.query?._t;
|
|
28
|
+
if (token && validateToken(slug, token)) {
|
|
29
|
+
return {
|
|
30
|
+
score: 0,
|
|
31
|
+
signals: [{ name: 'valid_token', weight: 0, detail: 'JS challenge passed' }],
|
|
32
|
+
isBot: false,
|
|
33
|
+
classification: 'human',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── 2. User-Agent pattern matching ──
|
|
38
|
+
if (!ua || ua.length === 0) {
|
|
39
|
+
score += 15;
|
|
40
|
+
signals.push({ name: 'ua_missing', weight: 15, detail: 'No User-Agent header' });
|
|
41
|
+
} else {
|
|
42
|
+
// Check built-in patterns
|
|
43
|
+
for (const pattern of BOT_UA_PATTERNS) {
|
|
44
|
+
if (ua.includes(pattern)) {
|
|
45
|
+
score += 30;
|
|
46
|
+
signals.push({ name: 'ua_pattern', weight: 30, detail: `Matched: "${pattern}"` });
|
|
47
|
+
break; // Only count once for built-in patterns
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check custom rules from DB
|
|
52
|
+
if (db) {
|
|
53
|
+
try {
|
|
54
|
+
const customRules = getRules(db);
|
|
55
|
+
for (const rule of customRules) {
|
|
56
|
+
if (rule.type === 'ua_pattern' && ua.includes(rule.pattern.toLowerCase())) {
|
|
57
|
+
const w = rule.weight || 30;
|
|
58
|
+
score += w;
|
|
59
|
+
signals.push({ name: 'custom_ua_pattern', weight: w, detail: `Custom rule: "${rule.pattern}"` });
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// DB not available, skip custom rules
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── 3. Header analysis ──
|
|
70
|
+
const suspiciousHeaders = analyzeHeaders(req);
|
|
71
|
+
if (suspiciousHeaders.length > 0) {
|
|
72
|
+
score += 20;
|
|
73
|
+
signals.push({
|
|
74
|
+
name: 'headers_suspicious',
|
|
75
|
+
weight: 20,
|
|
76
|
+
detail: suspiciousHeaders.join('; '),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── 4. IP / Datacenter check (async) ──
|
|
81
|
+
try {
|
|
82
|
+
const ipInfo = await lookupIp(ip);
|
|
83
|
+
if (ipInfo?.isDatacenter) {
|
|
84
|
+
score += 25;
|
|
85
|
+
signals.push({
|
|
86
|
+
name: 'datacenter_ip',
|
|
87
|
+
weight: 25,
|
|
88
|
+
detail: `ASN: ${ipInfo.asn || 'unknown'}, Org: ${ipInfo.org || 'unknown'}`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check custom ASN rules
|
|
93
|
+
if (db && ipInfo?.asn) {
|
|
94
|
+
try {
|
|
95
|
+
const customRules = getRules(db);
|
|
96
|
+
for (const rule of customRules) {
|
|
97
|
+
if (rule.type === 'asn' && parseInt(rule.pattern, 10) === ipInfo.asn) {
|
|
98
|
+
const w = rule.weight || 25;
|
|
99
|
+
score += w;
|
|
100
|
+
signals.push({ name: 'custom_asn', weight: w, detail: `Custom ASN rule: ${rule.pattern}` });
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Skip
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// IP lookup failed — fail open, don't penalize
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── 5. Timing analysis ──
|
|
113
|
+
if (linkCreatedAt) {
|
|
114
|
+
const createdMs = new Date(linkCreatedAt).getTime();
|
|
115
|
+
const elapsed = Date.now() - createdMs;
|
|
116
|
+
if (elapsed < TIMING_FAST_THRESHOLD_MS) {
|
|
117
|
+
score += 15;
|
|
118
|
+
signals.push({
|
|
119
|
+
name: 'timing_fast',
|
|
120
|
+
weight: 15,
|
|
121
|
+
detail: `Click ${elapsed}ms after link creation (threshold: ${TIMING_FAST_THRESHOLD_MS}ms)`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── 6. Velocity check ──
|
|
127
|
+
if (slug) {
|
|
128
|
+
const hitCount = trackVelocity(slug);
|
|
129
|
+
if (hitCount >= VELOCITY_THRESHOLD) {
|
|
130
|
+
score += 15;
|
|
131
|
+
signals.push({
|
|
132
|
+
name: 'velocity_high',
|
|
133
|
+
weight: 15,
|
|
134
|
+
detail: `${hitCount} hits in 10s window (threshold: ${VELOCITY_THRESHOLD})`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Classification ──
|
|
140
|
+
let classification;
|
|
141
|
+
if (score >= threshold) {
|
|
142
|
+
classification = 'bot';
|
|
143
|
+
} else if (score >= 20) {
|
|
144
|
+
classification = 'suspicious';
|
|
145
|
+
} else {
|
|
146
|
+
classification = 'human';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
score,
|
|
151
|
+
signals,
|
|
152
|
+
isBot: classification === 'bot',
|
|
153
|
+
classification,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Analyze request headers for bot-like characteristics.
|
|
159
|
+
*/
|
|
160
|
+
function analyzeHeaders(req) {
|
|
161
|
+
const issues = [];
|
|
162
|
+
const headers = req.headers;
|
|
163
|
+
|
|
164
|
+
// Most real browsers send Accept-Language
|
|
165
|
+
if (!headers['accept-language']) {
|
|
166
|
+
issues.push('Missing Accept-Language');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Real browsers typically send a complex Accept header
|
|
170
|
+
const accept = headers['accept'] || '';
|
|
171
|
+
if (accept && !accept.includes('text/html') && !accept.includes('*/*')) {
|
|
172
|
+
issues.push('Unusual Accept header');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Real browsers send Accept-Encoding
|
|
176
|
+
if (!headers['accept-encoding']) {
|
|
177
|
+
issues.push('Missing Accept-Encoding');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check for very short or very generic UA
|
|
181
|
+
const ua = headers['user-agent'] || '';
|
|
182
|
+
if (ua && ua.length < 20) {
|
|
183
|
+
issues.push(`Very short User-Agent (${ua.length} chars)`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check for connection: close (many bots do this)
|
|
187
|
+
if (headers['connection'] === 'close' && !headers['accept-language']) {
|
|
188
|
+
issues.push('Connection: close without Accept-Language');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return issues;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Test a user-agent string against the detection engine.
|
|
196
|
+
* Used by the CLI `rules test` command.
|
|
197
|
+
* Returns a simulated score result.
|
|
198
|
+
*/
|
|
199
|
+
export function testUserAgent(userAgent, db) {
|
|
200
|
+
const signals = [];
|
|
201
|
+
let score = 0;
|
|
202
|
+
const ua = (userAgent || '').toLowerCase();
|
|
203
|
+
|
|
204
|
+
if (!ua || ua.length === 0) {
|
|
205
|
+
score += 15;
|
|
206
|
+
signals.push({ name: 'ua_missing', weight: 15, detail: 'No User-Agent provided' });
|
|
207
|
+
} else {
|
|
208
|
+
for (const pattern of BOT_UA_PATTERNS) {
|
|
209
|
+
if (ua.includes(pattern)) {
|
|
210
|
+
score += 30;
|
|
211
|
+
signals.push({ name: 'ua_pattern', weight: 30, detail: `Matched built-in: "${pattern}"` });
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Custom rules
|
|
217
|
+
if (db) {
|
|
218
|
+
try {
|
|
219
|
+
const customRules = getRules(db);
|
|
220
|
+
for (const rule of customRules) {
|
|
221
|
+
if (rule.type === 'ua_pattern' && ua.includes(rule.pattern.toLowerCase())) {
|
|
222
|
+
const w = rule.weight || 30;
|
|
223
|
+
score += w;
|
|
224
|
+
signals.push({ name: 'custom_ua_pattern', weight: w, detail: `Custom rule: "${rule.pattern}"` });
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Skip
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Note: can't test headers, IP, timing, or velocity in offline mode
|
|
235
|
+
signals.push({ name: 'note', weight: 0, detail: 'Header, IP, timing, and velocity checks only run on live requests' });
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
score,
|
|
239
|
+
signals: signals.filter(s => s.weight > 0 || s.name === 'note'),
|
|
240
|
+
isBot: score >= 40,
|
|
241
|
+
classification: score >= 40 ? 'bot' : score >= 20 ? 'suspicious' : 'human',
|
|
242
|
+
};
|
|
243
|
+
}
|