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.
@@ -0,0 +1,141 @@
1
+ import { Router } from 'express';
2
+ import { createHash } from 'crypto';
3
+ import { findBySlug, logClick } from '../../db.js';
4
+ import { scoreRequest } from '../../cloak/detector.js';
5
+ import { generateToken } from '../../cloak/tokens.js';
6
+ import { generateSafePage, generateInterstitialPage } from '../../cloak/safePage.js';
7
+ import { getClientIp } from '../../cloak/ipLookup.js';
8
+
9
+ /**
10
+ * Create the redirect router.
11
+ */
12
+ export function createRedirectRouter(config, db) {
13
+ const router = Router();
14
+
15
+ router.get('/:slug', async (req, res) => {
16
+ try {
17
+ const { slug } = req.params;
18
+
19
+ // Skip favicon and other common paths
20
+ if (slug === 'favicon.ico' || slug === 'robots.txt' || slug === 'sitemap.xml') {
21
+ return res.status(404).end();
22
+ }
23
+
24
+ // Look up the link
25
+ const link = findBySlug(db, slug);
26
+ if (!link) {
27
+ return res.status(404).json({ error: 'Link not found' });
28
+ }
29
+
30
+ // Check expiration
31
+ if (link.expires_at && new Date(link.expires_at) < new Date()) {
32
+ return res.status(410).json({ error: 'Link has expired' });
33
+ }
34
+
35
+ const ip = getClientIp(req);
36
+ const ipHash = createHash('sha256').update(ip + 'carom-salt').digest('hex').substring(0, 16);
37
+ const userAgent = req.headers['user-agent'] || '';
38
+ const referer = req.headers['referer'] || '';
39
+
40
+ // ── Safe page request (from interstitial fallback) ──
41
+ if (req.query._safe === '1') {
42
+ logClick(db, {
43
+ linkId: link.id,
44
+ userAgent,
45
+ ipHash,
46
+ referer,
47
+ isBot: true,
48
+ botScore: 100,
49
+ botSignals: ['interstitial_fallback'],
50
+ classification: 'bot',
51
+ });
52
+
53
+ const html = generateSafePage({
54
+ title: config.shield?.safePage?.title,
55
+ description: config.shield?.safePage?.description,
56
+ brand: config.shield?.safePage?.brand,
57
+ url: `/${slug}`,
58
+ });
59
+ return res.status(200).type('html').send(html);
60
+ }
61
+
62
+ // ── Bot protection disabled — straight redirect ──
63
+ if (!config.shield?.enabled) {
64
+ logClick(db, {
65
+ linkId: link.id,
66
+ userAgent,
67
+ ipHash,
68
+ referer,
69
+ isBot: false,
70
+ botScore: 0,
71
+ botSignals: [],
72
+ classification: 'human',
73
+ });
74
+ return res.redirect(301, link.destination_url);
75
+ }
76
+
77
+ // ── Run the bot detection engine ──
78
+ const result = await scoreRequest(req, {
79
+ linkCreatedAt: link.created_at,
80
+ slug,
81
+ config,
82
+ db,
83
+ });
84
+
85
+ // Log the click
86
+ logClick(db, {
87
+ linkId: link.id,
88
+ userAgent,
89
+ ipHash,
90
+ referer,
91
+ isBot: result.isBot,
92
+ botScore: result.score,
93
+ botSignals: result.signals.map(s => s.name),
94
+ classification: result.classification,
95
+ });
96
+
97
+ // ── Bot detected — serve safe page ──
98
+ if (result.isBot) {
99
+ if (config.verbose) {
100
+ console.log(`[carom] 🛡 BOT GET /${slug} score=${result.score} ua="${userAgent.substring(0, 60)}" → safe page`);
101
+ }
102
+
103
+ const html = generateSafePage({
104
+ title: config.shield?.safePage?.title,
105
+ description: config.shield?.safePage?.description,
106
+ brand: config.shield?.safePage?.brand,
107
+ url: `/${slug}`,
108
+ });
109
+ return res.status(200).type('html').send(html);
110
+ }
111
+
112
+ // ── Interstitial mode — serve JS challenge ──
113
+ if (config.shield?.mode === 'interstitial' && !req.query._t) {
114
+ if (config.verbose) {
115
+ console.log(`[carom] 🔒 CHALLENGE GET /${slug} score=${result.score} → interstitial`);
116
+ }
117
+
118
+ const token = generateToken(slug);
119
+ const html = generateInterstitialPage({
120
+ slug,
121
+ token,
122
+ destinationUrl: link.destination_url,
123
+ brand: config.shield?.safePage?.brand,
124
+ });
125
+ return res.status(200).type('html').send(html);
126
+ }
127
+
128
+ // ── Human — redirect ──
129
+ if (config.verbose) {
130
+ console.log(`[carom] 👤 HUMAN GET /${slug} score=${result.score} → 301 redirect`);
131
+ }
132
+
133
+ return res.redirect(301, link.destination_url);
134
+ } catch (err) {
135
+ console.error('[carom] Redirect error:', err);
136
+ res.status(500).json({ error: 'Internal server error' });
137
+ }
138
+ });
139
+
140
+ return router;
141
+ }
@@ -0,0 +1,117 @@
1
+ import { createRedirectApp, createAdminApp } from './app.js';
2
+ import { getDb, closeDb } from '../db.js';
3
+ import { writeFileSync, unlinkSync, mkdirSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { DEFAULT_DATA_DIR, PID_FILENAME, LOG_DIR } from '../constants.js';
6
+
7
+ /**
8
+ * Start the carom servers (redirect + admin).
9
+ *
10
+ * @param {object} config - Full config object
11
+ * @param {object} options - Additional options
12
+ * @param {string} options.dataDir - Data directory
13
+ * @param {boolean} options.verbose - Verbose logging
14
+ * @returns {Promise<{ redirectApp, adminApp, redirectServer, adminServer, db }>}
15
+ */
16
+ export async function startServer(config, options = {}) {
17
+ const dataDir = options.dataDir || process.env.CAROM_DATA_DIR || DEFAULT_DATA_DIR;
18
+ const port = config.port || 3000;
19
+ const adminPort = config.adminPort || 3001;
20
+ const host = config.host || 'localhost';
21
+
22
+ // Ensure data & log directories exist
23
+ mkdirSync(dataDir, { recursive: true });
24
+ mkdirSync(join(dataDir, LOG_DIR), { recursive: true });
25
+
26
+ // Initialize database
27
+ const db = getDb(dataDir);
28
+
29
+ // Build full config with runtime fields
30
+ const fullConfig = { ...config, verbose: options.verbose || false, _dataDir: dataDir };
31
+
32
+ // Create both Express apps
33
+ const redirectApp = createRedirectApp(fullConfig, db);
34
+ const adminApp = createAdminApp(fullConfig, db);
35
+
36
+ // Start both servers
37
+ return new Promise((resolve, reject) => {
38
+ let redirectReady = false;
39
+ let adminReady = false;
40
+
41
+ const checkReady = () => {
42
+ if (redirectReady && adminReady) {
43
+ // Write PID file
44
+ const pidPath = join(dataDir, PID_FILENAME);
45
+ try {
46
+ writeFileSync(pidPath, String(process.pid));
47
+ } catch {
48
+ // Non-fatal
49
+ }
50
+ resolve({ redirectApp, adminApp, redirectServer, adminServer, db });
51
+ }
52
+ };
53
+
54
+ // ── Redirect server (public) ──
55
+ const redirectServer = redirectApp.listen(port, host, () => {
56
+ redirectReady = true;
57
+ checkReady();
58
+ });
59
+
60
+ redirectServer.on('error', (err) => {
61
+ if (err.code === 'EADDRINUSE') {
62
+ reject(new Error(`Redirect port ${port} is already in use. Use --port to specify a different port.`));
63
+ } else {
64
+ reject(err);
65
+ }
66
+ });
67
+
68
+ // ── Admin server (dashboard + API) ──
69
+ const adminServer = adminApp.listen(adminPort, host, () => {
70
+ adminReady = true;
71
+ checkReady();
72
+ });
73
+
74
+ adminServer.on('error', (err) => {
75
+ if (err.code === 'EADDRINUSE') {
76
+ reject(new Error(`Admin port ${adminPort} is already in use. Use --admin-port to specify a different port.`));
77
+ } else {
78
+ reject(err);
79
+ }
80
+ });
81
+
82
+ // Graceful shutdown — close both servers
83
+ const shutdown = (signal) => {
84
+ console.log(`\n[carom] Received ${signal}, shutting down...`);
85
+
86
+ let closed = 0;
87
+ const onClose = () => {
88
+ closed++;
89
+ if (closed >= 2) {
90
+ closeDb();
91
+
92
+ // Remove PID file
93
+ try {
94
+ unlinkSync(join(dataDir, PID_FILENAME));
95
+ } catch {
96
+ // Non-fatal
97
+ }
98
+
99
+ console.log('[carom] Servers stopped.');
100
+ process.exit(0);
101
+ }
102
+ };
103
+
104
+ redirectServer.close(onClose);
105
+ adminServer.close(onClose);
106
+
107
+ // Force exit after 5 seconds
108
+ setTimeout(() => {
109
+ console.error('[carom] Forced shutdown after timeout.');
110
+ process.exit(1);
111
+ }, 5000);
112
+ };
113
+
114
+ process.on('SIGINT', () => shutdown('SIGINT'));
115
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
116
+ });
117
+ }
@@ -0,0 +1,166 @@
1
+ import { writeFileSync, unlinkSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { execSync } from 'child_process';
5
+ import { DEFAULT_DATA_DIR } from '../constants.js';
6
+
7
+ const LABEL = 'com.carom';
8
+ const PLIST_DIR = join(homedir(), 'Library', 'LaunchAgents');
9
+ const PLIST_PATH = join(PLIST_DIR, `${LABEL}.plist`);
10
+
11
+ /**
12
+ * Generate the launchd plist XML.
13
+ */
14
+ function generatePlist(config) {
15
+ const dataDir = config.dataDir || DEFAULT_DATA_DIR;
16
+ const nodePath = process.execPath;
17
+ const binPath = join(process.argv[1] || '', '..', '..', 'bin', 'carom.js');
18
+
19
+ // Try to find the actual bin path
20
+ let actualBinPath;
21
+ try {
22
+ actualBinPath = execSync('which carom', { encoding: 'utf8' }).trim();
23
+ } catch {
24
+ // Fallback: resolve from the current execution
25
+ actualBinPath = binPath;
26
+ }
27
+
28
+ const args = [nodePath, actualBinPath, 'start'];
29
+ if (config.port) args.push('--port', String(config.port));
30
+ if (config.adminPort) args.push('--admin-port', String(config.adminPort));
31
+ if (config.host && config.host !== 'localhost') args.push('--host', config.host);
32
+ if (config.apiKey) args.push('--api-key', config.apiKey);
33
+ if (config.dataDir) args.push('--data-dir', config.dataDir);
34
+
35
+ const logDir = join(dataDir, 'logs');
36
+ const stdoutLog = join(logDir, 'stdout.log');
37
+ const stderrLog = join(logDir, 'stderr.log');
38
+
39
+ return `<?xml version="1.0" encoding="UTF-8"?>
40
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
41
+ <plist version="1.0">
42
+ <dict>
43
+ <key>Label</key>
44
+ <string>${LABEL}</string>
45
+
46
+ <key>ProgramArguments</key>
47
+ <array>
48
+ ${args.map(a => ` <string>${escapeXml(a)}</string>`).join('\n')}
49
+ </array>
50
+
51
+ <key>RunAtLoad</key>
52
+ <true/>
53
+
54
+ <key>KeepAlive</key>
55
+ <true/>
56
+
57
+ <key>StandardOutPath</key>
58
+ <string>${escapeXml(stdoutLog)}</string>
59
+
60
+ <key>StandardErrorPath</key>
61
+ <string>${escapeXml(stderrLog)}</string>
62
+
63
+ <key>EnvironmentVariables</key>
64
+ <dict>
65
+ <key>CAROM_DATA_DIR</key>
66
+ <string>${escapeXml(dataDir)}</string>
67
+ <key>PATH</key>
68
+ <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
69
+ </dict>
70
+
71
+ <key>ThrottleInterval</key>
72
+ <integer>5</integer>
73
+ </dict>
74
+ </plist>`;
75
+ }
76
+
77
+ function escapeXml(str) {
78
+ return String(str)
79
+ .replace(/&/g, '&amp;')
80
+ .replace(/</g, '&lt;')
81
+ .replace(/>/g, '&gt;')
82
+ .replace(/"/g, '&quot;')
83
+ .replace(/'/g, '&apos;');
84
+ }
85
+
86
+ /**
87
+ * Install the launchd service.
88
+ */
89
+ export async function installLaunchd(config) {
90
+ // Unload existing if present
91
+ if (existsSync(PLIST_PATH)) {
92
+ try {
93
+ execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, { stdio: 'ignore' });
94
+ } catch {
95
+ // Ignore
96
+ }
97
+ }
98
+
99
+ const plist = generatePlist(config);
100
+ writeFileSync(PLIST_PATH, plist, 'utf8');
101
+
102
+ // Load the service
103
+ execSync(`launchctl load "${PLIST_PATH}"`);
104
+
105
+ return {
106
+ path: PLIST_PATH,
107
+ label: LABEL,
108
+ message: `Service installed at ${PLIST_PATH}`,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Uninstall the launchd service.
114
+ */
115
+ export async function uninstallLaunchd() {
116
+ if (!existsSync(PLIST_PATH)) {
117
+ throw new Error('Service is not installed.');
118
+ }
119
+
120
+ try {
121
+ execSync(`launchctl unload "${PLIST_PATH}"`, { stdio: 'ignore' });
122
+ } catch {
123
+ // May already be unloaded
124
+ }
125
+
126
+ unlinkSync(PLIST_PATH);
127
+
128
+ return { message: 'Service uninstalled successfully.' };
129
+ }
130
+
131
+ /**
132
+ * Check if the launchd service is installed.
133
+ */
134
+ export function isLaunchdInstalled() {
135
+ return existsSync(PLIST_PATH);
136
+ }
137
+
138
+ /**
139
+ * Get the launchd service status.
140
+ */
141
+ export async function getLaunchdStatus() {
142
+ const installed = isLaunchdInstalled();
143
+ let running = false;
144
+ let pid = null;
145
+
146
+ if (installed) {
147
+ try {
148
+ const output = execSync(`launchctl list | grep ${LABEL}`, { encoding: 'utf8' });
149
+ const parts = output.trim().split(/\s+/);
150
+ if (parts[0] && parts[0] !== '-') {
151
+ pid = parseInt(parts[0], 10);
152
+ running = !isNaN(pid);
153
+ }
154
+ } catch {
155
+ // Not running
156
+ }
157
+ }
158
+
159
+ return {
160
+ installed,
161
+ running,
162
+ pid,
163
+ type: 'launchd',
164
+ path: PLIST_PATH,
165
+ };
166
+ }
@@ -0,0 +1,79 @@
1
+ import { platform } from 'os';
2
+ import { installLaunchd, uninstallLaunchd, isLaunchdInstalled, getLaunchdStatus } from './launchd.js';
3
+ import { installSystemd, uninstallSystemd, isSystemdInstalled, getSystemdStatus } from './systemd.js';
4
+
5
+ /**
6
+ * Detect the current platform and return the appropriate service manager.
7
+ */
8
+ function getServiceImpl() {
9
+ const os = platform();
10
+ if (os === 'darwin') {
11
+ return {
12
+ install: installLaunchd,
13
+ uninstall: uninstallLaunchd,
14
+ isInstalled: isLaunchdInstalled,
15
+ getStatus: getLaunchdStatus,
16
+ type: 'launchd',
17
+ };
18
+ } else if (os === 'linux') {
19
+ return {
20
+ install: installSystemd,
21
+ uninstall: uninstallSystemd,
22
+ isInstalled: isSystemdInstalled,
23
+ getStatus: getSystemdStatus,
24
+ type: 'systemd',
25
+ };
26
+ } else {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Install the carom service for auto-start on boot.
33
+ */
34
+ export async function installService(config) {
35
+ const impl = getServiceImpl();
36
+ if (!impl) {
37
+ throw new Error(`Service installation is not supported on ${platform()}. Use "carom start" to run manually.`);
38
+ }
39
+ return impl.install(config);
40
+ }
41
+
42
+ /**
43
+ * Uninstall the carom service.
44
+ */
45
+ export async function uninstallService() {
46
+ const impl = getServiceImpl();
47
+ if (!impl) {
48
+ throw new Error(`Service management is not supported on ${platform()}.`);
49
+ }
50
+ return impl.uninstall();
51
+ }
52
+
53
+ /**
54
+ * Check if the service is installed.
55
+ */
56
+ export function isServiceInstalled() {
57
+ const impl = getServiceImpl();
58
+ if (!impl) return false;
59
+ return impl.isInstalled();
60
+ }
61
+
62
+ /**
63
+ * Get the service status.
64
+ */
65
+ export async function getServiceStatus() {
66
+ const impl = getServiceImpl();
67
+ if (!impl) {
68
+ return { installed: false, running: false, type: 'unsupported' };
69
+ }
70
+ return impl.getStatus();
71
+ }
72
+
73
+ /**
74
+ * Get the service type for the current platform.
75
+ */
76
+ export function getServiceType() {
77
+ const impl = getServiceImpl();
78
+ return impl?.type || 'unsupported';
79
+ }
@@ -0,0 +1,147 @@
1
+ import { writeFileSync, unlinkSync, existsSync } from 'fs';
2
+ import { execSync } from 'child_process';
3
+ import { DEFAULT_DATA_DIR } from '../constants.js';
4
+
5
+ const SERVICE_NAME = 'carom';
6
+ const UNIT_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
7
+
8
+ /**
9
+ * Generate the systemd unit file.
10
+ */
11
+ function generateUnit(config) {
12
+ const dataDir = config.dataDir || DEFAULT_DATA_DIR;
13
+ const nodePath = process.execPath;
14
+
15
+ let execStart;
16
+ try {
17
+ const binPath = execSync('which carom', { encoding: 'utf8' }).trim();
18
+ execStart = `${nodePath} ${binPath} start`;
19
+ } catch {
20
+ execStart = `${nodePath} ${process.argv[1]} start`;
21
+ }
22
+
23
+ if (config.port) execStart += ` --port ${config.port}`;
24
+ if (config.adminPort) execStart += ` --admin-port ${config.adminPort}`;
25
+ if (config.host && config.host !== 'localhost') execStart += ` --host ${config.host}`;
26
+ if (config.apiKey) execStart += ` --api-key ${config.apiKey}`;
27
+ if (config.dataDir) execStart += ` --data-dir ${config.dataDir}`;
28
+
29
+ const user = process.env.USER || process.env.LOGNAME || 'root';
30
+
31
+ return `[Unit]
32
+ Description=carom - Link redirect server with bot protection
33
+ After=network.target
34
+
35
+ [Service]
36
+ Type=simple
37
+ User=${user}
38
+ ExecStart=${execStart}
39
+ Restart=always
40
+ RestartSec=5
41
+ Environment=CAROM_DATA_DIR=${dataDir}
42
+ Environment=NODE_ENV=production
43
+ StandardOutput=append:${dataDir}/logs/stdout.log
44
+ StandardError=append:${dataDir}/logs/stderr.log
45
+
46
+ [Install]
47
+ WantedBy=multi-user.target
48
+ `;
49
+ }
50
+
51
+ /**
52
+ * Install the systemd service.
53
+ */
54
+ export async function installSystemd(config) {
55
+ const unit = generateUnit(config);
56
+
57
+ try {
58
+ writeFileSync(UNIT_PATH, unit, 'utf8');
59
+ } catch (err) {
60
+ if (err.code === 'EACCES') {
61
+ throw new Error('Permission denied. Run with sudo: sudo carom install');
62
+ }
63
+ throw err;
64
+ }
65
+
66
+ execSync('systemctl daemon-reload');
67
+ execSync(`systemctl enable ${SERVICE_NAME}`);
68
+ execSync(`systemctl start ${SERVICE_NAME}`);
69
+
70
+ return {
71
+ path: UNIT_PATH,
72
+ name: SERVICE_NAME,
73
+ message: `Service installed at ${UNIT_PATH}`,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Uninstall the systemd service.
79
+ */
80
+ export async function uninstallSystemd() {
81
+ if (!existsSync(UNIT_PATH)) {
82
+ throw new Error('Service is not installed.');
83
+ }
84
+
85
+ try {
86
+ execSync(`systemctl stop ${SERVICE_NAME}`, { stdio: 'ignore' });
87
+ execSync(`systemctl disable ${SERVICE_NAME}`, { stdio: 'ignore' });
88
+ } catch {
89
+ // May already be stopped
90
+ }
91
+
92
+ try {
93
+ unlinkSync(UNIT_PATH);
94
+ } catch (err) {
95
+ if (err.code === 'EACCES') {
96
+ throw new Error('Permission denied. Run with sudo: sudo carom uninstall');
97
+ }
98
+ throw err;
99
+ }
100
+
101
+ execSync('systemctl daemon-reload');
102
+
103
+ return { message: 'Service uninstalled successfully.' };
104
+ }
105
+
106
+ /**
107
+ * Check if the systemd service is installed.
108
+ */
109
+ export function isSystemdInstalled() {
110
+ return existsSync(UNIT_PATH);
111
+ }
112
+
113
+ /**
114
+ * Get the systemd service status.
115
+ */
116
+ export async function getSystemdStatus() {
117
+ const installed = isSystemdInstalled();
118
+ let running = false;
119
+ let pid = null;
120
+
121
+ if (installed) {
122
+ try {
123
+ const output = execSync(`systemctl is-active ${SERVICE_NAME}`, { encoding: 'utf8' }).trim();
124
+ running = output === 'active';
125
+ } catch {
126
+ running = false;
127
+ }
128
+
129
+ if (running) {
130
+ try {
131
+ const output = execSync(`systemctl show ${SERVICE_NAME} --property=MainPID`, { encoding: 'utf8' }).trim();
132
+ const match = output.match(/MainPID=(\d+)/);
133
+ if (match) pid = parseInt(match[1], 10);
134
+ } catch {
135
+ // Ignore
136
+ }
137
+ }
138
+ }
139
+
140
+ return {
141
+ installed,
142
+ running,
143
+ pid,
144
+ type: 'systemd',
145
+ path: UNIT_PATH,
146
+ };
147
+ }