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
package/src/constants.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname } from 'path';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
|
|
10
|
+
// Package version from package.json
|
|
11
|
+
let PKG_VERSION = '1.0.0';
|
|
12
|
+
try {
|
|
13
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
|
|
14
|
+
PKG_VERSION = pkg.version;
|
|
15
|
+
} catch {
|
|
16
|
+
// fallback
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const VERSION = PKG_VERSION;
|
|
20
|
+
export const PACKAGE_NAME = 'carom';
|
|
21
|
+
|
|
22
|
+
// Data directory
|
|
23
|
+
export const DEFAULT_DATA_DIR = join(homedir(), '.carom');
|
|
24
|
+
export const DB_FILENAME = 'data.db';
|
|
25
|
+
export const CONFIG_FILENAME = 'config.json';
|
|
26
|
+
export const PID_FILENAME = 'carom.pid';
|
|
27
|
+
export const LOG_DIR = 'logs';
|
|
28
|
+
export const LOG_FILENAME = 'server.log';
|
|
29
|
+
|
|
30
|
+
// Slug generation
|
|
31
|
+
export const SLUG_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
32
|
+
export const SLUG_LENGTH = 7;
|
|
33
|
+
|
|
34
|
+
// Server defaults
|
|
35
|
+
export const DEFAULT_PORT = 3000;
|
|
36
|
+
export const DEFAULT_ADMIN_PORT = 3001;
|
|
37
|
+
export const DEFAULT_HOST = 'localhost';
|
|
38
|
+
|
|
39
|
+
// Rate limiting
|
|
40
|
+
export const API_RATE_LIMIT = { windowMs: 60_000, max: 100 };
|
|
41
|
+
export const REDIRECT_RATE_LIMIT = { windowMs: 60_000, max: 1000 };
|
|
42
|
+
|
|
43
|
+
// Bot protection defaults
|
|
44
|
+
export const DEFAULT_SHIELD_CONFIG = {
|
|
45
|
+
enabled: true,
|
|
46
|
+
threshold: 40,
|
|
47
|
+
mode: 'direct', // 'direct' or 'interstitial'
|
|
48
|
+
safePage: {
|
|
49
|
+
title: 'Welcome',
|
|
50
|
+
description: 'Visit our website for more information.',
|
|
51
|
+
brand: 'YourBrand',
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Default full config
|
|
56
|
+
export const DEFAULT_CONFIG = {
|
|
57
|
+
port: DEFAULT_PORT,
|
|
58
|
+
adminPort: DEFAULT_ADMIN_PORT,
|
|
59
|
+
host: DEFAULT_HOST,
|
|
60
|
+
baseUrl: '',
|
|
61
|
+
apiKey: '',
|
|
62
|
+
shield: { ...DEFAULT_SHIELD_CONFIG },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Token settings
|
|
66
|
+
export const TOKEN_SECRET_LENGTH = 32;
|
|
67
|
+
export const TOKEN_EXPIRY_SECONDS = 30;
|
|
68
|
+
|
|
69
|
+
// IP lookup cache
|
|
70
|
+
export const IP_CACHE_MAX = 10_000;
|
|
71
|
+
export const IP_CACHE_TTL_MS = 3_600_000; // 1 hour
|
|
72
|
+
|
|
73
|
+
// Velocity tracking
|
|
74
|
+
export const VELOCITY_WINDOW_MS = 10_000;
|
|
75
|
+
export const VELOCITY_THRESHOLD = 5;
|
|
76
|
+
|
|
77
|
+
// Timing detection
|
|
78
|
+
export const TIMING_FAST_THRESHOLD_MS = 3_000;
|
package/src/db.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { mkdirSync } from 'fs';
|
|
4
|
+
import { DEFAULT_DATA_DIR, DB_FILENAME } from './constants.js';
|
|
5
|
+
|
|
6
|
+
let _db = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get or initialize the SQLite database connection.
|
|
10
|
+
*/
|
|
11
|
+
export function getDb(dataDir) {
|
|
12
|
+
if (_db) return _db;
|
|
13
|
+
|
|
14
|
+
const dir = dataDir || process.env.CAROM_DATA_DIR || DEFAULT_DATA_DIR;
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
|
|
17
|
+
const dbPath = join(dir, DB_FILENAME);
|
|
18
|
+
_db = new Database(dbPath);
|
|
19
|
+
|
|
20
|
+
// Performance settings
|
|
21
|
+
_db.pragma('journal_mode = WAL');
|
|
22
|
+
_db.pragma('synchronous = NORMAL');
|
|
23
|
+
_db.pragma('foreign_keys = ON');
|
|
24
|
+
|
|
25
|
+
initTables(_db);
|
|
26
|
+
return _db;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Close the database connection.
|
|
31
|
+
*/
|
|
32
|
+
export function closeDb() {
|
|
33
|
+
if (_db) {
|
|
34
|
+
_db.close();
|
|
35
|
+
_db = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create tables if they don't exist.
|
|
41
|
+
*/
|
|
42
|
+
function initTables(db) {
|
|
43
|
+
db.exec(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS links (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
slug TEXT UNIQUE NOT NULL,
|
|
47
|
+
destination_url TEXT NOT NULL,
|
|
48
|
+
created_at DATETIME DEFAULT (datetime('now')),
|
|
49
|
+
expires_at DATETIME,
|
|
50
|
+
active BOOLEAN DEFAULT 1
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_links_slug ON links(slug);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS clicks (
|
|
57
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
58
|
+
link_id INTEGER NOT NULL REFERENCES links(id),
|
|
59
|
+
clicked_at DATETIME DEFAULT (datetime('now')),
|
|
60
|
+
user_agent TEXT,
|
|
61
|
+
ip_hash TEXT,
|
|
62
|
+
referer TEXT,
|
|
63
|
+
is_bot BOOLEAN DEFAULT 0,
|
|
64
|
+
bot_score INTEGER DEFAULT 0,
|
|
65
|
+
bot_signals TEXT,
|
|
66
|
+
classification TEXT DEFAULT 'human'
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_clicks_link_id ON clicks(link_id);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_clicks_classification ON clicks(classification);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS rules (
|
|
74
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
75
|
+
type TEXT NOT NULL,
|
|
76
|
+
pattern TEXT NOT NULL,
|
|
77
|
+
weight INTEGER DEFAULT 30,
|
|
78
|
+
active BOOLEAN DEFAULT 1,
|
|
79
|
+
created_at DATETIME DEFAULT (datetime('now')),
|
|
80
|
+
note TEXT
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_rules_type ON rules(type);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_rules_active ON rules(active);
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Link Operations ──
|
|
89
|
+
|
|
90
|
+
export function createLink(db, { slug, destinationUrl, expiresAt }) {
|
|
91
|
+
const stmt = db.prepare(`
|
|
92
|
+
INSERT INTO links (slug, destination_url, expires_at)
|
|
93
|
+
VALUES (?, ?, ?)
|
|
94
|
+
`);
|
|
95
|
+
const result = stmt.run(slug, destinationUrl, expiresAt || null);
|
|
96
|
+
return findLinkById(db, result.lastInsertRowid);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function findBySlug(db, slug) {
|
|
100
|
+
return db.prepare(`
|
|
101
|
+
SELECT * FROM links WHERE slug = ? AND active = 1
|
|
102
|
+
`).get(slug);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function findLinkById(db, id) {
|
|
106
|
+
return db.prepare('SELECT * FROM links WHERE id = ?').get(id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function findLinkByIdOrSlug(db, idOrSlug) {
|
|
110
|
+
const asInt = parseInt(idOrSlug, 10);
|
|
111
|
+
if (!isNaN(asInt) && String(asInt) === String(idOrSlug)) {
|
|
112
|
+
return findLinkById(db, asInt);
|
|
113
|
+
}
|
|
114
|
+
return db.prepare('SELECT * FROM links WHERE slug = ?').get(idOrSlug);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getLinks(db, { includeInactive = false, limit = 100, offset = 0 } = {}) {
|
|
118
|
+
const where = includeInactive ? '' : 'WHERE l.active = 1';
|
|
119
|
+
return db.prepare(`
|
|
120
|
+
SELECT
|
|
121
|
+
l.*,
|
|
122
|
+
COUNT(c.id) AS total_clicks,
|
|
123
|
+
SUM(CASE WHEN c.classification = 'human' THEN 1 ELSE 0 END) AS human_clicks,
|
|
124
|
+
SUM(CASE WHEN c.classification = 'bot' THEN 1 ELSE 0 END) AS bot_clicks
|
|
125
|
+
FROM links l
|
|
126
|
+
LEFT JOIN clicks c ON c.link_id = l.id
|
|
127
|
+
${where}
|
|
128
|
+
GROUP BY l.id
|
|
129
|
+
ORDER BY l.created_at DESC
|
|
130
|
+
LIMIT ? OFFSET ?
|
|
131
|
+
`).all(limit, offset);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function getLinkCount(db) {
|
|
135
|
+
return db.prepare('SELECT COUNT(*) as count FROM links WHERE active = 1').get().count;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function deactivateLink(db, id) {
|
|
139
|
+
return db.prepare('UPDATE links SET active = 0 WHERE id = ?').run(id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Click Operations ──
|
|
143
|
+
|
|
144
|
+
export function logClick(db, { linkId, userAgent, ipHash, referer, isBot, botScore, botSignals, classification }) {
|
|
145
|
+
return db.prepare(`
|
|
146
|
+
INSERT INTO clicks (link_id, user_agent, ip_hash, referer, is_bot, bot_score, bot_signals, classification)
|
|
147
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
148
|
+
`).run(
|
|
149
|
+
linkId,
|
|
150
|
+
userAgent || null,
|
|
151
|
+
ipHash || null,
|
|
152
|
+
referer || null,
|
|
153
|
+
isBot ? 1 : 0,
|
|
154
|
+
botScore || 0,
|
|
155
|
+
botSignals ? JSON.stringify(botSignals) : null,
|
|
156
|
+
classification || 'human'
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function getClickStats(db, linkId) {
|
|
161
|
+
const totals = db.prepare(`
|
|
162
|
+
SELECT
|
|
163
|
+
COUNT(*) AS total,
|
|
164
|
+
SUM(CASE WHEN classification = 'human' THEN 1 ELSE 0 END) AS human,
|
|
165
|
+
SUM(CASE WHEN classification = 'bot' THEN 1 ELSE 0 END) AS bot,
|
|
166
|
+
SUM(CASE WHEN classification = 'suspicious' THEN 1 ELSE 0 END) AS suspicious
|
|
167
|
+
FROM clicks WHERE link_id = ?
|
|
168
|
+
`).get(linkId);
|
|
169
|
+
|
|
170
|
+
const recentClicks = db.prepare(`
|
|
171
|
+
SELECT * FROM clicks
|
|
172
|
+
WHERE link_id = ?
|
|
173
|
+
ORDER BY clicked_at DESC
|
|
174
|
+
LIMIT 50
|
|
175
|
+
`).all(linkId);
|
|
176
|
+
|
|
177
|
+
const topSignals = db.prepare(`
|
|
178
|
+
SELECT bot_signals, COUNT(*) as count
|
|
179
|
+
FROM clicks
|
|
180
|
+
WHERE link_id = ? AND bot_signals IS NOT NULL
|
|
181
|
+
GROUP BY bot_signals
|
|
182
|
+
ORDER BY count DESC
|
|
183
|
+
LIMIT 10
|
|
184
|
+
`).all(linkId);
|
|
185
|
+
|
|
186
|
+
const topUserAgents = db.prepare(`
|
|
187
|
+
SELECT user_agent, classification, COUNT(*) as count
|
|
188
|
+
FROM clicks
|
|
189
|
+
WHERE link_id = ?
|
|
190
|
+
GROUP BY user_agent, classification
|
|
191
|
+
ORDER BY count DESC
|
|
192
|
+
LIMIT 10
|
|
193
|
+
`).all(linkId);
|
|
194
|
+
|
|
195
|
+
const timeline = {
|
|
196
|
+
last24h: db.prepare(`
|
|
197
|
+
SELECT COUNT(*) as count FROM clicks
|
|
198
|
+
WHERE link_id = ? AND clicked_at >= datetime('now', '-1 day')
|
|
199
|
+
`).get(linkId).count,
|
|
200
|
+
last7d: db.prepare(`
|
|
201
|
+
SELECT COUNT(*) as count FROM clicks
|
|
202
|
+
WHERE link_id = ? AND clicked_at >= datetime('now', '-7 days')
|
|
203
|
+
`).get(linkId).count,
|
|
204
|
+
last30d: db.prepare(`
|
|
205
|
+
SELECT COUNT(*) as count FROM clicks
|
|
206
|
+
WHERE link_id = ? AND clicked_at >= datetime('now', '-30 days')
|
|
207
|
+
`).get(linkId).count,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return { totals, recentClicks, topSignals, topUserAgents, timeline };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Velocity tracking (in-memory, not DB) ──
|
|
214
|
+
|
|
215
|
+
const velocityMap = new Map();
|
|
216
|
+
|
|
217
|
+
export function trackVelocity(slug) {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
if (!velocityMap.has(slug)) {
|
|
220
|
+
velocityMap.set(slug, []);
|
|
221
|
+
}
|
|
222
|
+
const timestamps = velocityMap.get(slug);
|
|
223
|
+
timestamps.push(now);
|
|
224
|
+
|
|
225
|
+
// Clean old entries (older than 10s)
|
|
226
|
+
const cutoff = now - 10_000;
|
|
227
|
+
while (timestamps.length > 0 && timestamps[0] < cutoff) {
|
|
228
|
+
timestamps.shift();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return timestamps.length;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Rule Operations ──
|
|
235
|
+
|
|
236
|
+
export function addRule(db, { type, pattern, weight, note }) {
|
|
237
|
+
const stmt = db.prepare(`
|
|
238
|
+
INSERT INTO rules (type, pattern, weight, note)
|
|
239
|
+
VALUES (?, ?, ?, ?)
|
|
240
|
+
`);
|
|
241
|
+
const result = stmt.run(type, pattern, weight || 30, note || null);
|
|
242
|
+
return db.prepare('SELECT * FROM rules WHERE id = ?').get(result.lastInsertRowid);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function getRules(db, { includeInactive = false } = {}) {
|
|
246
|
+
const where = includeInactive ? '' : 'WHERE active = 1';
|
|
247
|
+
return db.prepare(`SELECT * FROM rules ${where} ORDER BY created_at DESC`).all();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function removeRule(db, id) {
|
|
251
|
+
return db.prepare('UPDATE rules SET active = 0 WHERE id = ?').run(id);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function deleteRule(db, id) {
|
|
255
|
+
return db.prepare('DELETE FROM rules WHERE id = ?').run(id);
|
|
256
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import helmet from 'helmet';
|
|
3
|
+
import cors from 'cors';
|
|
4
|
+
import rateLimit from 'express-rate-limit';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname, join } from 'path';
|
|
7
|
+
import { API_RATE_LIMIT, REDIRECT_RATE_LIMIT } from '../constants.js';
|
|
8
|
+
import { createApiRouter } from './routes/api.js';
|
|
9
|
+
import { createRedirectRouter } from './routes/redirect.js';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = dirname(__filename);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create the public redirect server.
|
|
16
|
+
* This is the lightweight, public-facing app that only handles redirects.
|
|
17
|
+
*/
|
|
18
|
+
export function createRedirectApp(config, db) {
|
|
19
|
+
const app = express();
|
|
20
|
+
|
|
21
|
+
app.set('trust proxy', 1);
|
|
22
|
+
|
|
23
|
+
app.use(helmet());
|
|
24
|
+
|
|
25
|
+
// ── Health check ──
|
|
26
|
+
app.get('/health', (req, res) => {
|
|
27
|
+
res.json({ status: 'ok', version: config.version || '1.0.0', role: 'redirect' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── Redirect handler (catch-all) ──
|
|
31
|
+
const redirectLimiter = rateLimit({
|
|
32
|
+
windowMs: REDIRECT_RATE_LIMIT.windowMs,
|
|
33
|
+
max: REDIRECT_RATE_LIMIT.max,
|
|
34
|
+
standardHeaders: true,
|
|
35
|
+
legacyHeaders: false,
|
|
36
|
+
message: 'Too many requests.',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
app.use('/', redirectLimiter, createRedirectRouter(config, db));
|
|
40
|
+
|
|
41
|
+
// ── 404 handler ──
|
|
42
|
+
app.use((req, res) => {
|
|
43
|
+
res.status(404).json({ error: 'Not found' });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── Error handler ──
|
|
47
|
+
app.use((err, req, res, _next) => {
|
|
48
|
+
console.error('[carom] Redirect error:', err.message);
|
|
49
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return app;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create the admin server.
|
|
57
|
+
* Serves the dashboard UI and REST API on a separate port.
|
|
58
|
+
*/
|
|
59
|
+
export function createAdminApp(config, db) {
|
|
60
|
+
const app = express();
|
|
61
|
+
|
|
62
|
+
app.set('trust proxy', 1);
|
|
63
|
+
|
|
64
|
+
app.use(helmet({
|
|
65
|
+
contentSecurityPolicy: false, // Allow inline styles/scripts in dashboard
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
app.use(cors());
|
|
69
|
+
|
|
70
|
+
app.use(express.json());
|
|
71
|
+
app.use(express.urlencoded({ extended: false }));
|
|
72
|
+
|
|
73
|
+
// ── Health check ──
|
|
74
|
+
app.get('/health', (req, res) => {
|
|
75
|
+
res.json({ status: 'ok', version: config.version || '1.0.0', role: 'admin' });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── API routes (rate limited) ──
|
|
79
|
+
const apiLimiter = rateLimit({
|
|
80
|
+
windowMs: API_RATE_LIMIT.windowMs,
|
|
81
|
+
max: API_RATE_LIMIT.max,
|
|
82
|
+
standardHeaders: true,
|
|
83
|
+
legacyHeaders: false,
|
|
84
|
+
message: { error: 'Too many API requests, please slow down.' },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.use('/api', apiLimiter, createApiRouter(config, db));
|
|
88
|
+
|
|
89
|
+
// ── Admin dashboard (serve static files) ──
|
|
90
|
+
const publicDir = join(__dirname, '..', '..', 'public');
|
|
91
|
+
|
|
92
|
+
// Serve dashboard at root of admin server (no /dashboard prefix needed)
|
|
93
|
+
app.use('/', express.static(publicDir));
|
|
94
|
+
app.get('/', (req, res) => {
|
|
95
|
+
res.sendFile(join(publicDir, 'index.html'));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── 404 handler ──
|
|
99
|
+
app.use((req, res) => {
|
|
100
|
+
res.status(404).json({ error: 'Not found' });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── Error handler ──
|
|
104
|
+
app.use((err, req, res, _next) => {
|
|
105
|
+
console.error('[carom] Admin error:', err.message);
|
|
106
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return app;
|
|
110
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { saveConfig } from '../../config.js';
|
|
3
|
+
import {
|
|
4
|
+
createLink, getLinks, getLinkCount, findLinkByIdOrSlug,
|
|
5
|
+
deactivateLink, getClickStats,
|
|
6
|
+
addRule, getRules, removeRule,
|
|
7
|
+
} from '../../db.js';
|
|
8
|
+
import { testUserAgent } from '../../cloak/detector.js';
|
|
9
|
+
import { customAlphabet } from 'nanoid';
|
|
10
|
+
import { SLUG_ALPHABET, SLUG_LENGTH } from '../../constants.js';
|
|
11
|
+
|
|
12
|
+
const generateSlug = customAlphabet(SLUG_ALPHABET, SLUG_LENGTH);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* API key authentication middleware.
|
|
16
|
+
*/
|
|
17
|
+
function authMiddleware(config) {
|
|
18
|
+
return (req, res, next) => {
|
|
19
|
+
// If no API key configured, allow all
|
|
20
|
+
if (!config.apiKey) return next();
|
|
21
|
+
|
|
22
|
+
const provided = req.headers['x-api-key'] || req.query.apiKey;
|
|
23
|
+
if (provided !== config.apiKey) {
|
|
24
|
+
return res.status(401).json({ error: 'Invalid or missing API key. Set x-api-key header.' });
|
|
25
|
+
}
|
|
26
|
+
next();
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create the API router.
|
|
32
|
+
*/
|
|
33
|
+
export function createApiRouter(config, db) {
|
|
34
|
+
const router = Router();
|
|
35
|
+
|
|
36
|
+
// All API routes require auth
|
|
37
|
+
router.use(authMiddleware(config));
|
|
38
|
+
|
|
39
|
+
// ── Links ──
|
|
40
|
+
|
|
41
|
+
// Create a new short link
|
|
42
|
+
router.post('/links', (req, res) => {
|
|
43
|
+
try {
|
|
44
|
+
const { url, slug: customSlug, expiresAt } = req.body;
|
|
45
|
+
|
|
46
|
+
if (!url) {
|
|
47
|
+
return res.status(400).json({ error: 'Missing required field: url' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Validate URL
|
|
51
|
+
try {
|
|
52
|
+
new URL(url);
|
|
53
|
+
} catch {
|
|
54
|
+
return res.status(400).json({ error: 'Invalid URL format' });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Generate or validate slug
|
|
58
|
+
let slug = customSlug;
|
|
59
|
+
if (slug) {
|
|
60
|
+
// Validate custom slug
|
|
61
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(slug)) {
|
|
62
|
+
return res.status(400).json({ error: 'Slug can only contain letters, numbers, hyphens, and underscores' });
|
|
63
|
+
}
|
|
64
|
+
// Check for reserved paths
|
|
65
|
+
const reserved = ['api', 'dashboard', 'health', '_t', '_safe'];
|
|
66
|
+
if (reserved.includes(slug.toLowerCase())) {
|
|
67
|
+
return res.status(400).json({ error: `Slug "${slug}" is reserved` });
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
// Generate unique slug with collision retry
|
|
71
|
+
let attempts = 0;
|
|
72
|
+
do {
|
|
73
|
+
slug = generateSlug();
|
|
74
|
+
attempts++;
|
|
75
|
+
} while (attempts < 10 && db.prepare('SELECT 1 FROM links WHERE slug = ?').get(slug));
|
|
76
|
+
|
|
77
|
+
if (attempts >= 10) {
|
|
78
|
+
return res.status(500).json({ error: 'Failed to generate unique slug' });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const link = createLink(db, {
|
|
83
|
+
slug,
|
|
84
|
+
destinationUrl: url,
|
|
85
|
+
expiresAt: expiresAt || null,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const baseUrl = config.baseUrl || `http://localhost:${config.port}`;
|
|
89
|
+
res.status(201).json({
|
|
90
|
+
...link,
|
|
91
|
+
shortUrl: `${baseUrl}/${link.slug}`,
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err.message?.includes('UNIQUE constraint')) {
|
|
95
|
+
return res.status(409).json({ error: 'Slug already exists' });
|
|
96
|
+
}
|
|
97
|
+
console.error('[carom] Error creating link:', err);
|
|
98
|
+
res.status(500).json({ error: 'Failed to create link' });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// List all links
|
|
103
|
+
router.get('/links', (req, res) => {
|
|
104
|
+
try {
|
|
105
|
+
const includeInactive = req.query.all === 'true';
|
|
106
|
+
const limit = parseInt(req.query.limit, 10) || 100;
|
|
107
|
+
const offset = parseInt(req.query.offset, 10) || 0;
|
|
108
|
+
const links = getLinks(db, { includeInactive, limit, offset });
|
|
109
|
+
const total = getLinkCount(db);
|
|
110
|
+
const baseUrl = config.baseUrl || `http://localhost:${config.port}`;
|
|
111
|
+
|
|
112
|
+
res.json({
|
|
113
|
+
links: links.map(l => ({
|
|
114
|
+
...l,
|
|
115
|
+
shortUrl: `${baseUrl}/${l.slug}`,
|
|
116
|
+
})),
|
|
117
|
+
total,
|
|
118
|
+
limit,
|
|
119
|
+
offset,
|
|
120
|
+
});
|
|
121
|
+
} catch (err) {
|
|
122
|
+
console.error('[carom] Error listing links:', err);
|
|
123
|
+
res.status(500).json({ error: 'Failed to list links' });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Get link stats
|
|
128
|
+
router.get('/links/:id/stats', (req, res) => {
|
|
129
|
+
try {
|
|
130
|
+
const link = findLinkByIdOrSlug(db, req.params.id);
|
|
131
|
+
if (!link) {
|
|
132
|
+
return res.status(404).json({ error: 'Link not found' });
|
|
133
|
+
}
|
|
134
|
+
const stats = getClickStats(db, link.id);
|
|
135
|
+
const baseUrl = config.baseUrl || `http://localhost:${config.port}`;
|
|
136
|
+
res.json({
|
|
137
|
+
link: { ...link, shortUrl: `${baseUrl}/${link.slug}` },
|
|
138
|
+
stats,
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error('[carom] Error getting stats:', err);
|
|
142
|
+
res.status(500).json({ error: 'Failed to get stats' });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Deactivate a link
|
|
147
|
+
router.delete('/links/:id', (req, res) => {
|
|
148
|
+
try {
|
|
149
|
+
const link = findLinkByIdOrSlug(db, req.params.id);
|
|
150
|
+
if (!link) {
|
|
151
|
+
return res.status(404).json({ error: 'Link not found' });
|
|
152
|
+
}
|
|
153
|
+
deactivateLink(db, link.id);
|
|
154
|
+
res.json({ success: true, message: `Link "${link.slug}" deactivated` });
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.error('[carom] Error deactivating link:', err);
|
|
157
|
+
res.status(500).json({ error: 'Failed to deactivate link' });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ── Rules ──
|
|
162
|
+
|
|
163
|
+
// List detection rules
|
|
164
|
+
router.get('/rules', (req, res) => {
|
|
165
|
+
try {
|
|
166
|
+
const rules = getRules(db);
|
|
167
|
+
res.json({ rules });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.error('[carom] Error listing rules:', err);
|
|
170
|
+
res.status(500).json({ error: 'Failed to list rules' });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Add a custom rule
|
|
175
|
+
router.post('/rules', (req, res) => {
|
|
176
|
+
try {
|
|
177
|
+
const { type, pattern, weight, note } = req.body;
|
|
178
|
+
if (!type || !pattern) {
|
|
179
|
+
return res.status(400).json({ error: 'Missing required fields: type, pattern' });
|
|
180
|
+
}
|
|
181
|
+
const validTypes = ['ua_pattern', 'ip_range', 'asn'];
|
|
182
|
+
if (!validTypes.includes(type)) {
|
|
183
|
+
return res.status(400).json({ error: `Invalid type. Must be one of: ${validTypes.join(', ')}` });
|
|
184
|
+
}
|
|
185
|
+
const rule = addRule(db, { type, pattern, weight, note });
|
|
186
|
+
res.status(201).json(rule);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error('[carom] Error adding rule:', err);
|
|
189
|
+
res.status(500).json({ error: 'Failed to add rule' });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Remove a rule
|
|
194
|
+
router.delete('/rules/:id', (req, res) => {
|
|
195
|
+
try {
|
|
196
|
+
const id = parseInt(req.params.id, 10);
|
|
197
|
+
removeRule(db, id);
|
|
198
|
+
res.json({ success: true, message: `Rule ${id} removed` });
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.error('[carom] Error removing rule:', err);
|
|
201
|
+
res.status(500).json({ error: 'Failed to remove rule' });
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Test a user-agent string
|
|
206
|
+
router.post('/rules/test', (req, res) => {
|
|
207
|
+
try {
|
|
208
|
+
const { userAgent } = req.body;
|
|
209
|
+
if (!userAgent) {
|
|
210
|
+
return res.status(400).json({ error: 'Missing required field: userAgent' });
|
|
211
|
+
}
|
|
212
|
+
const result = testUserAgent(userAgent, db);
|
|
213
|
+
res.json(result);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error('[carom] Error testing UA:', err);
|
|
216
|
+
res.status(500).json({ error: 'Failed to test user agent' });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── Bot Page Configuration ──
|
|
221
|
+
|
|
222
|
+
// Get current safe page settings
|
|
223
|
+
router.get('/config/safe-page', (req, res) => {
|
|
224
|
+
try {
|
|
225
|
+
const safePage = config.shield?.safePage || {};
|
|
226
|
+
res.json({
|
|
227
|
+
title: safePage.title || 'Welcome',
|
|
228
|
+
description: safePage.description || 'Visit our website for more information.',
|
|
229
|
+
brand: safePage.brand || 'Website',
|
|
230
|
+
});
|
|
231
|
+
} catch (err) {
|
|
232
|
+
console.error('[carom] Error getting safe page config:', err);
|
|
233
|
+
res.status(500).json({ error: 'Failed to get safe page config' });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Update safe page settings (persisted to disk + applied in-memory)
|
|
238
|
+
router.put('/config/safe-page', (req, res) => {
|
|
239
|
+
try {
|
|
240
|
+
const { title, description, brand } = req.body;
|
|
241
|
+
|
|
242
|
+
// Ensure shield.safePage exists
|
|
243
|
+
if (!config.shield) config.shield = {};
|
|
244
|
+
if (!config.shield.safePage) config.shield.safePage = {};
|
|
245
|
+
|
|
246
|
+
// Apply changes in-memory (live, no restart needed)
|
|
247
|
+
if (title !== undefined) config.shield.safePage.title = title;
|
|
248
|
+
if (description !== undefined) config.shield.safePage.description = description;
|
|
249
|
+
if (brand !== undefined) config.shield.safePage.brand = brand;
|
|
250
|
+
|
|
251
|
+
// Persist to config file
|
|
252
|
+
const dataDir = process.env.CAROM_DATA_DIR || config._dataDir;
|
|
253
|
+
const configToSave = { shield: { safePage: { ...config.shield.safePage } } };
|
|
254
|
+
saveConfig(configToSave, dataDir);
|
|
255
|
+
|
|
256
|
+
res.json({
|
|
257
|
+
success: true,
|
|
258
|
+
safePage: config.shield.safePage,
|
|
259
|
+
message: 'Bot page settings updated. Changes are live immediately.',
|
|
260
|
+
});
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error('[carom] Error updating safe page config:', err);
|
|
263
|
+
res.status(500).json({ error: 'Failed to update safe page config' });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return router;
|
|
268
|
+
}
|