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,106 @@
|
|
|
1
|
+
import { loadConfig } from '../../config.js';
|
|
2
|
+
import { getDb, createLink, findBySlug } from '../../db.js';
|
|
3
|
+
import { customAlphabet } from 'nanoid';
|
|
4
|
+
import { SLUG_ALPHABET, SLUG_LENGTH } from '../../constants.js';
|
|
5
|
+
import { printSuccess, printError, printInfo } from '../formatters.js';
|
|
6
|
+
|
|
7
|
+
const generateSlug = customAlphabet(SLUG_ALPHABET, SLUG_LENGTH);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Register the `add` command.
|
|
11
|
+
*/
|
|
12
|
+
export function registerAddCommand(program) {
|
|
13
|
+
program
|
|
14
|
+
.command('add <url> [slug]')
|
|
15
|
+
.description('Create a new short link')
|
|
16
|
+
.option('--expires <duration>', 'Expiration time (e.g., 7d, 24h, 30m)')
|
|
17
|
+
.action(async (url, customSlug, options) => {
|
|
18
|
+
try {
|
|
19
|
+
const dataDir = program.opts().dataDir;
|
|
20
|
+
const config = loadConfig(dataDir);
|
|
21
|
+
const db = getDb(dataDir);
|
|
22
|
+
|
|
23
|
+
// Validate URL
|
|
24
|
+
try {
|
|
25
|
+
new URL(url);
|
|
26
|
+
} catch {
|
|
27
|
+
printError('Invalid URL format. Provide a full URL like https://example.com');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Generate or validate slug
|
|
32
|
+
let slug = customSlug;
|
|
33
|
+
if (slug) {
|
|
34
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(slug)) {
|
|
35
|
+
printError('Slug can only contain letters, numbers, hyphens, and underscores.');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const reserved = ['api', 'dashboard', 'health'];
|
|
39
|
+
if (reserved.includes(slug.toLowerCase())) {
|
|
40
|
+
printError(`Slug "${slug}" is reserved.`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
if (findBySlug(db, slug)) {
|
|
44
|
+
printError(`Slug "${slug}" already exists.`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
let attempts = 0;
|
|
49
|
+
do {
|
|
50
|
+
slug = generateSlug();
|
|
51
|
+
attempts++;
|
|
52
|
+
} while (attempts < 10 && findBySlug(db, slug));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Parse expiration
|
|
56
|
+
let expiresAt = null;
|
|
57
|
+
if (options.expires) {
|
|
58
|
+
expiresAt = parseExpiration(options.expires);
|
|
59
|
+
if (!expiresAt) {
|
|
60
|
+
printError('Invalid expiration format. Use: 7d, 24h, 30m');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const link = createLink(db, {
|
|
66
|
+
slug,
|
|
67
|
+
destinationUrl: url,
|
|
68
|
+
expiresAt,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const baseUrl = config.baseUrl || `http://localhost:${config.port || 3000}`;
|
|
72
|
+
const shortUrl = `${baseUrl}/${link.slug}`;
|
|
73
|
+
|
|
74
|
+
printSuccess(`Link created!`);
|
|
75
|
+
printInfo(`Short URL: ${shortUrl}`);
|
|
76
|
+
printInfo(`Slug: ${link.slug}`);
|
|
77
|
+
printInfo(`Target: ${url}`);
|
|
78
|
+
if (expiresAt) {
|
|
79
|
+
printInfo(`Expires: ${expiresAt}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
process.exit(0);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
printError(err.message);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseExpiration(duration) {
|
|
91
|
+
const match = duration.match(/^(\d+)(m|h|d)$/i);
|
|
92
|
+
if (!match) return null;
|
|
93
|
+
|
|
94
|
+
const amount = parseInt(match[1], 10);
|
|
95
|
+
const unit = match[2].toLowerCase();
|
|
96
|
+
const now = new Date();
|
|
97
|
+
|
|
98
|
+
switch (unit) {
|
|
99
|
+
case 'm': now.setMinutes(now.getMinutes() + amount); break;
|
|
100
|
+
case 'h': now.setHours(now.getHours() + amount); break;
|
|
101
|
+
case 'd': now.setDate(now.getDate() + amount); break;
|
|
102
|
+
default: return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return now.toISOString();
|
|
106
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadConfig, setConfigValue, resetConfig } from '../../config.js';
|
|
3
|
+
import { printSuccess, printError, printInfo } from '../formatters.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Register the `config` command with subcommands.
|
|
7
|
+
*/
|
|
8
|
+
export function registerConfigCommand(program) {
|
|
9
|
+
const config = program
|
|
10
|
+
.command('config')
|
|
11
|
+
.description('Manage carom configuration');
|
|
12
|
+
|
|
13
|
+
// config ls
|
|
14
|
+
config
|
|
15
|
+
.command('ls')
|
|
16
|
+
.description('Show current configuration')
|
|
17
|
+
.option('--json', 'Output as JSON')
|
|
18
|
+
.action(async (options) => {
|
|
19
|
+
try {
|
|
20
|
+
const dataDir = program.opts().dataDir;
|
|
21
|
+
const currentConfig = loadConfig(dataDir);
|
|
22
|
+
|
|
23
|
+
if (options.json) {
|
|
24
|
+
console.log(JSON.stringify(currentConfig, null, 2));
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.log('');
|
|
29
|
+
console.log(chalk.bold.cyan(' carom Configuration'));
|
|
30
|
+
console.log('');
|
|
31
|
+
printConfigObject(currentConfig, ' ');
|
|
32
|
+
console.log('');
|
|
33
|
+
printInfo(`Config file: ~/.carom/config.json`);
|
|
34
|
+
printInfo(`Use "carom config set <key> <value>" to change settings.`);
|
|
35
|
+
console.log('');
|
|
36
|
+
|
|
37
|
+
process.exit(0);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
printError(err.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// config set
|
|
45
|
+
config
|
|
46
|
+
.command('set <key> <value>')
|
|
47
|
+
.description('Set a configuration value (dot-notation keys)')
|
|
48
|
+
.action(async (key, value) => {
|
|
49
|
+
try {
|
|
50
|
+
const dataDir = program.opts().dataDir;
|
|
51
|
+
setConfigValue(key, value, dataDir);
|
|
52
|
+
printSuccess(`Set ${chalk.bold(key)} = ${chalk.bold(value)}`);
|
|
53
|
+
|
|
54
|
+
process.exit(0);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
printError(err.message);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// config reset
|
|
62
|
+
config
|
|
63
|
+
.command('reset')
|
|
64
|
+
.description('Reset configuration to defaults')
|
|
65
|
+
.action(async () => {
|
|
66
|
+
try {
|
|
67
|
+
const dataDir = program.opts().dataDir;
|
|
68
|
+
resetConfig(dataDir);
|
|
69
|
+
printSuccess('Configuration reset to defaults.');
|
|
70
|
+
|
|
71
|
+
process.exit(0);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
printError(err.message);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Pretty-print a config object with indentation.
|
|
81
|
+
*/
|
|
82
|
+
function printConfigObject(obj, indent = '', path = '') {
|
|
83
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
84
|
+
const fullPath = path ? `${path}.${key}` : key;
|
|
85
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
86
|
+
console.log(`${indent}${chalk.dim(key + ':')}`);
|
|
87
|
+
printConfigObject(value, indent + ' ', fullPath);
|
|
88
|
+
} else {
|
|
89
|
+
const displayValue = typeof value === 'boolean'
|
|
90
|
+
? (value ? chalk.green('true') : chalk.red('false'))
|
|
91
|
+
: chalk.white(String(value || '(empty)'));
|
|
92
|
+
console.log(`${indent}${chalk.dim(key + ':')} ${displayValue} ${chalk.dim(`[${fullPath}]`)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { loadConfig } from '../../config.js';
|
|
3
|
+
import { installService, getServiceType } from '../../service/manager.js';
|
|
4
|
+
import { printSuccess, printError, printInfo, printWarning } from '../formatters.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register the `install` command.
|
|
8
|
+
*/
|
|
9
|
+
export function registerInstallCommand(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('install')
|
|
12
|
+
.description('Install carom as a system service (auto-start on boot)')
|
|
13
|
+
.option('-p, --port <number>', 'Redirect server port', parseInt)
|
|
14
|
+
.option('--admin-port <number>', 'Admin dashboard & API port', parseInt)
|
|
15
|
+
.option('--host <address>', 'Bind address')
|
|
16
|
+
.option('--api-key <key>', 'API key')
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
try {
|
|
19
|
+
const dataDir = program.opts().dataDir;
|
|
20
|
+
const config = loadConfig(dataDir);
|
|
21
|
+
|
|
22
|
+
// CLI options override config
|
|
23
|
+
if (options.port) config.port = options.port;
|
|
24
|
+
if (options.adminPort) config.adminPort = options.adminPort;
|
|
25
|
+
if (options.host) config.host = options.host;
|
|
26
|
+
if (options.apiKey) config.apiKey = options.apiKey;
|
|
27
|
+
config.dataDir = dataDir;
|
|
28
|
+
|
|
29
|
+
const serviceType = getServiceType();
|
|
30
|
+
printInfo(`Detected platform: ${serviceType}`);
|
|
31
|
+
printInfo(`Installing carom service...`);
|
|
32
|
+
|
|
33
|
+
const result = await installService(config);
|
|
34
|
+
|
|
35
|
+
printSuccess(result.message);
|
|
36
|
+
printInfo(`carom will now auto-start on boot.`);
|
|
37
|
+
printInfo(`Use ${chalk.bold('carom status')} to check the service.`);
|
|
38
|
+
printInfo(`Use ${chalk.bold('carom uninstall')} to remove.`);
|
|
39
|
+
|
|
40
|
+
if (!config.apiKey) {
|
|
41
|
+
printWarning('No API key set. The API is open to anyone. Use --api-key to secure it.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
process.exit(0);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
printError(err.message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { loadConfig } from '../../config.js';
|
|
2
|
+
import { getDb, getLinks } from '../../db.js';
|
|
3
|
+
import { formatTable, truncate, formatClicks, formatStatus, formatDate, printInfo } from '../formatters.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Register the `ls` command.
|
|
7
|
+
*/
|
|
8
|
+
export function registerListCommand(program) {
|
|
9
|
+
program
|
|
10
|
+
.command('ls')
|
|
11
|
+
.description('List all links with click stats')
|
|
12
|
+
.option('--all', 'Include inactive links')
|
|
13
|
+
.option('--json', 'Output as JSON')
|
|
14
|
+
.option('--limit <number>', 'Max links to show', parseInt)
|
|
15
|
+
.action(async (options) => {
|
|
16
|
+
try {
|
|
17
|
+
const dataDir = program.opts().dataDir;
|
|
18
|
+
const config = loadConfig(dataDir);
|
|
19
|
+
const db = getDb(dataDir);
|
|
20
|
+
|
|
21
|
+
const links = getLinks(db, {
|
|
22
|
+
includeInactive: options.all || false,
|
|
23
|
+
limit: options.limit || 100,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (options.json) {
|
|
27
|
+
const baseUrl = config.baseUrl || `http://localhost:${config.port || 3000}`;
|
|
28
|
+
const output = links.map(l => ({
|
|
29
|
+
...l,
|
|
30
|
+
shortUrl: `${baseUrl}/${l.slug}`,
|
|
31
|
+
}));
|
|
32
|
+
console.log(JSON.stringify(output, null, 2));
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (links.length === 0) {
|
|
37
|
+
printInfo('No links found. Create one with: carom add <url>');
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const headers = ['ID', 'SLUG', 'DESTINATION', 'CLICKS (H/B)', 'CREATED', 'STATUS'];
|
|
42
|
+
const rows = links.map(l => [
|
|
43
|
+
l.id,
|
|
44
|
+
l.slug,
|
|
45
|
+
truncate(l.destination_url, 45),
|
|
46
|
+
formatClicks(l.human_clicks, l.bot_clicks),
|
|
47
|
+
formatDate(l.created_at),
|
|
48
|
+
formatStatus(l.active),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
console.log('');
|
|
52
|
+
console.log(formatTable(headers, rows));
|
|
53
|
+
console.log('');
|
|
54
|
+
printInfo(`${links.length} link${links.length === 1 ? '' : 's'} total`);
|
|
55
|
+
|
|
56
|
+
process.exit(0);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error('Error:', err.message);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { DEFAULT_DATA_DIR, LOG_DIR } from '../../constants.js';
|
|
5
|
+
import { printError, printInfo } from '../formatters.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Register the `logs` command.
|
|
9
|
+
*/
|
|
10
|
+
export function registerLogsCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('logs')
|
|
13
|
+
.description('Tail the carom server logs')
|
|
14
|
+
.option('-n, --lines <number>', 'Number of lines to show', '50')
|
|
15
|
+
.option('--no-follow', 'Don\'t follow (just print last N lines)')
|
|
16
|
+
.action(async (options) => {
|
|
17
|
+
try {
|
|
18
|
+
const dataDir = program.opts().dataDir || DEFAULT_DATA_DIR;
|
|
19
|
+
const logDir = join(dataDir, LOG_DIR);
|
|
20
|
+
const stdoutLog = join(logDir, 'stdout.log');
|
|
21
|
+
const stderrLog = join(logDir, 'stderr.log');
|
|
22
|
+
|
|
23
|
+
// Find a log file that exists
|
|
24
|
+
let logFile = null;
|
|
25
|
+
if (existsSync(stdoutLog)) {
|
|
26
|
+
logFile = stdoutLog;
|
|
27
|
+
} else if (existsSync(stderrLog)) {
|
|
28
|
+
logFile = stderrLog;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!logFile) {
|
|
32
|
+
printError('No log files found.');
|
|
33
|
+
printInfo(`Log directory: ${logDir}`);
|
|
34
|
+
printInfo('Start the server first with: carom start');
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
printInfo(`Tailing: ${logFile}`);
|
|
39
|
+
console.log('');
|
|
40
|
+
|
|
41
|
+
const args = ['-n', options.lines];
|
|
42
|
+
if (options.follow !== false) {
|
|
43
|
+
args.push('-f');
|
|
44
|
+
}
|
|
45
|
+
args.push(logFile);
|
|
46
|
+
|
|
47
|
+
const tail = spawn('tail', args, {
|
|
48
|
+
stdio: 'inherit',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
tail.on('error', (err) => {
|
|
52
|
+
printError(`Failed to tail logs: ${err.message}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
tail.on('exit', (code) => {
|
|
57
|
+
process.exit(code || 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Handle Ctrl+C
|
|
61
|
+
process.on('SIGINT', () => {
|
|
62
|
+
tail.kill('SIGINT');
|
|
63
|
+
process.exit(0);
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
printError(err.message);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getDb, findLinkByIdOrSlug, deactivateLink } from '../../db.js';
|
|
2
|
+
import { printSuccess, printError } from '../formatters.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Register the `rm` command.
|
|
6
|
+
*/
|
|
7
|
+
export function registerRemoveCommand(program) {
|
|
8
|
+
program
|
|
9
|
+
.command('rm <idOrSlug>')
|
|
10
|
+
.description('Deactivate a link')
|
|
11
|
+
.action(async (idOrSlug) => {
|
|
12
|
+
try {
|
|
13
|
+
const dataDir = program.opts().dataDir;
|
|
14
|
+
const db = getDb(dataDir);
|
|
15
|
+
|
|
16
|
+
const link = findLinkByIdOrSlug(db, idOrSlug);
|
|
17
|
+
if (!link) {
|
|
18
|
+
printError(`Link "${idOrSlug}" not found.`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!link.active) {
|
|
23
|
+
printError(`Link "${link.slug}" is already inactive.`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
deactivateLink(db, link.id);
|
|
28
|
+
printSuccess(`Link "${link.slug}" (→ ${link.destination_url}) deactivated.`);
|
|
29
|
+
|
|
30
|
+
process.exit(0);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
printError(err.message);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { getDb, addRule, getRules, removeRule } from '../../db.js';
|
|
3
|
+
import { BOT_UA_PATTERNS } from '../../cloak/patterns.js';
|
|
4
|
+
import { testUserAgent } from '../../cloak/detector.js';
|
|
5
|
+
import { formatTable, printSuccess, printError, printInfo, formatClassification } from '../formatters.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Register the `rules` command with subcommands.
|
|
9
|
+
*/
|
|
10
|
+
export function registerRulesCommand(program) {
|
|
11
|
+
const rules = program
|
|
12
|
+
.command('rules')
|
|
13
|
+
.description('Manage bot detection rules');
|
|
14
|
+
|
|
15
|
+
// rules ls
|
|
16
|
+
rules
|
|
17
|
+
.command('ls')
|
|
18
|
+
.description('List all bot detection rules')
|
|
19
|
+
.option('--builtin', 'Show built-in patterns')
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
try {
|
|
22
|
+
const dataDir = program.opts().dataDir;
|
|
23
|
+
const db = getDb(dataDir);
|
|
24
|
+
const customRules = getRules(db);
|
|
25
|
+
|
|
26
|
+
if (options.builtin) {
|
|
27
|
+
console.log('');
|
|
28
|
+
console.log(chalk.bold.cyan(' Built-in Bot UA Patterns'));
|
|
29
|
+
console.log(chalk.dim(` ${BOT_UA_PATTERNS.length} patterns loaded`));
|
|
30
|
+
console.log('');
|
|
31
|
+
|
|
32
|
+
// Group into columns
|
|
33
|
+
const cols = 4;
|
|
34
|
+
const rows = [];
|
|
35
|
+
for (let i = 0; i < BOT_UA_PATTERNS.length; i += cols) {
|
|
36
|
+
rows.push(
|
|
37
|
+
BOT_UA_PATTERNS.slice(i, i + cols)
|
|
38
|
+
.map(p => chalk.dim(p.padEnd(22)))
|
|
39
|
+
.join('')
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
console.log(rows.join('\n'));
|
|
43
|
+
console.log('');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log('');
|
|
47
|
+
console.log(chalk.bold.cyan(' Custom Rules'));
|
|
48
|
+
console.log('');
|
|
49
|
+
|
|
50
|
+
if (customRules.length === 0) {
|
|
51
|
+
printInfo('No custom rules. Add one with: carom rules add <pattern>');
|
|
52
|
+
} else {
|
|
53
|
+
const headers = ['ID', 'TYPE', 'PATTERN', 'WEIGHT', 'NOTE'];
|
|
54
|
+
const tableRows = customRules.map(r => [
|
|
55
|
+
r.id,
|
|
56
|
+
r.type,
|
|
57
|
+
r.pattern,
|
|
58
|
+
`+${r.weight}`,
|
|
59
|
+
r.note || '—',
|
|
60
|
+
]);
|
|
61
|
+
console.log(formatTable(headers, tableRows));
|
|
62
|
+
}
|
|
63
|
+
console.log('');
|
|
64
|
+
|
|
65
|
+
process.exit(0);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
printError(err.message);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// rules add
|
|
73
|
+
rules
|
|
74
|
+
.command('add <pattern>')
|
|
75
|
+
.description('Add a custom bot detection pattern')
|
|
76
|
+
.option('--type <type>', 'Rule type: ua_pattern, ip_range, asn', 'ua_pattern')
|
|
77
|
+
.option('--weight <number>', 'Score weight', parseInt)
|
|
78
|
+
.option('--note <text>', 'Description/note for the rule')
|
|
79
|
+
.action(async (pattern, options) => {
|
|
80
|
+
try {
|
|
81
|
+
const dataDir = program.opts().dataDir;
|
|
82
|
+
const db = getDb(dataDir);
|
|
83
|
+
|
|
84
|
+
const validTypes = ['ua_pattern', 'ip_range', 'asn'];
|
|
85
|
+
if (!validTypes.includes(options.type)) {
|
|
86
|
+
printError(`Invalid type "${options.type}". Must be one of: ${validTypes.join(', ')}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const rule = addRule(db, {
|
|
91
|
+
type: options.type,
|
|
92
|
+
pattern,
|
|
93
|
+
weight: options.weight || 30,
|
|
94
|
+
note: options.note || null,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
printSuccess(`Rule added (ID: ${rule.id})`);
|
|
98
|
+
printInfo(`Type: ${rule.type}, Pattern: "${rule.pattern}", Weight: +${rule.weight}`);
|
|
99
|
+
|
|
100
|
+
process.exit(0);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
printError(err.message);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// rules rm
|
|
108
|
+
rules
|
|
109
|
+
.command('rm <id>')
|
|
110
|
+
.description('Remove a custom rule')
|
|
111
|
+
.action(async (id) => {
|
|
112
|
+
try {
|
|
113
|
+
const dataDir = program.opts().dataDir;
|
|
114
|
+
const db = getDb(dataDir);
|
|
115
|
+
|
|
116
|
+
const ruleId = parseInt(id, 10);
|
|
117
|
+
if (isNaN(ruleId)) {
|
|
118
|
+
printError('Rule ID must be a number.');
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
removeRule(db, ruleId);
|
|
123
|
+
printSuccess(`Rule ${ruleId} removed.`);
|
|
124
|
+
|
|
125
|
+
process.exit(0);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
printError(err.message);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// rules test
|
|
133
|
+
rules
|
|
134
|
+
.command('test <userAgent>')
|
|
135
|
+
.description('Test a user-agent string against detection rules')
|
|
136
|
+
.action(async (userAgent) => {
|
|
137
|
+
try {
|
|
138
|
+
const dataDir = program.opts().dataDir;
|
|
139
|
+
const db = getDb(dataDir);
|
|
140
|
+
|
|
141
|
+
const result = testUserAgent(userAgent, db);
|
|
142
|
+
|
|
143
|
+
console.log('');
|
|
144
|
+
console.log(chalk.bold(` Testing: "${userAgent}"`));
|
|
145
|
+
console.log('');
|
|
146
|
+
console.log(` Result: ${formatClassification(result.classification)}`);
|
|
147
|
+
console.log(` Score: ${chalk.bold(result.score)} / 40 (threshold)`);
|
|
148
|
+
console.log('');
|
|
149
|
+
|
|
150
|
+
if (result.signals.length > 0) {
|
|
151
|
+
console.log(chalk.bold(' Signals:'));
|
|
152
|
+
for (const signal of result.signals) {
|
|
153
|
+
if (signal.name === 'note') {
|
|
154
|
+
console.log(chalk.dim(` ℹ ${signal.detail}`));
|
|
155
|
+
} else {
|
|
156
|
+
console.log(` ${chalk.yellow(`+${signal.weight}`)} ${signal.name}: ${signal.detail}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
console.log('');
|
|
161
|
+
|
|
162
|
+
process.exit(0);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
printError(err.message);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { loadConfig } from '../../config.js';
|
|
2
|
+
import { getDb, getLinkCount } from '../../db.js';
|
|
3
|
+
import { startServer } from '../../server/server.js';
|
|
4
|
+
import { printBanner, printError } from '../formatters.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register the `start` command.
|
|
8
|
+
*/
|
|
9
|
+
export function registerStartCommand(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('start')
|
|
12
|
+
.description('Start the carom redirect server')
|
|
13
|
+
.option('-p, --port <number>', 'Redirect server port', parseInt)
|
|
14
|
+
.option('--admin-port <number>', 'Admin dashboard & API port', parseInt)
|
|
15
|
+
.option('--host <address>', 'Bind address')
|
|
16
|
+
.option('--api-key <key>', 'API key for the REST API')
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
try {
|
|
19
|
+
const dataDir = program.opts().dataDir;
|
|
20
|
+
const config = loadConfig(dataDir);
|
|
21
|
+
|
|
22
|
+
// CLI options override config
|
|
23
|
+
if (options.port) config.port = options.port;
|
|
24
|
+
if (options.adminPort) config.adminPort = options.adminPort;
|
|
25
|
+
if (options.host) config.host = options.host;
|
|
26
|
+
if (options.apiKey) config.apiKey = options.apiKey;
|
|
27
|
+
|
|
28
|
+
// Init DB to get link count for banner
|
|
29
|
+
const db = getDb(dataDir);
|
|
30
|
+
const linkCount = getLinkCount(db);
|
|
31
|
+
|
|
32
|
+
printBanner(config, linkCount);
|
|
33
|
+
|
|
34
|
+
await startServer(config, {
|
|
35
|
+
dataDir,
|
|
36
|
+
verbose: program.opts().verbose || false,
|
|
37
|
+
});
|
|
38
|
+
} catch (err) {
|
|
39
|
+
printError(err.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|