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,146 @@
|
|
|
1
|
+
import { DATACENTER_ASNS, DATACENTER_ORG_PATTERNS } from './patterns.js';
|
|
2
|
+
import { IP_CACHE_MAX, IP_CACHE_TTL_MS } from '../constants.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Simple LRU cache for IP lookups.
|
|
6
|
+
*/
|
|
7
|
+
class LRUCache {
|
|
8
|
+
constructor(maxSize, ttlMs) {
|
|
9
|
+
this.maxSize = maxSize;
|
|
10
|
+
this.ttlMs = ttlMs;
|
|
11
|
+
this.cache = new Map();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get(key) {
|
|
15
|
+
const entry = this.cache.get(key);
|
|
16
|
+
if (!entry) return null;
|
|
17
|
+
if (Date.now() - entry.timestamp > this.ttlMs) {
|
|
18
|
+
this.cache.delete(key);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
// Move to end (most recently used)
|
|
22
|
+
this.cache.delete(key);
|
|
23
|
+
this.cache.set(key, entry);
|
|
24
|
+
return entry.value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
set(key, value) {
|
|
28
|
+
if (this.cache.size >= this.maxSize) {
|
|
29
|
+
// Delete oldest entry
|
|
30
|
+
const firstKey = this.cache.keys().next().value;
|
|
31
|
+
this.cache.delete(firstKey);
|
|
32
|
+
}
|
|
33
|
+
this.cache.set(key, { value, timestamp: Date.now() });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ipCache = new LRUCache(IP_CACHE_MAX, IP_CACHE_TTL_MS);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if an IP is a private/reserved address.
|
|
41
|
+
*/
|
|
42
|
+
function isPrivateIp(ip) {
|
|
43
|
+
if (!ip) return true;
|
|
44
|
+
return (
|
|
45
|
+
ip === '127.0.0.1' ||
|
|
46
|
+
ip === '::1' ||
|
|
47
|
+
ip.startsWith('10.') ||
|
|
48
|
+
ip.startsWith('172.16.') || ip.startsWith('172.17.') || ip.startsWith('172.18.') ||
|
|
49
|
+
ip.startsWith('172.19.') || ip.startsWith('172.2') || ip.startsWith('172.30.') ||
|
|
50
|
+
ip.startsWith('172.31.') ||
|
|
51
|
+
ip.startsWith('192.168.') ||
|
|
52
|
+
ip.startsWith('fc') || ip.startsWith('fd') ||
|
|
53
|
+
ip.startsWith('fe80')
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Look up IP information using the free ip-api.com service.
|
|
59
|
+
* Returns { asn, org, isDatacenter, country, isp }
|
|
60
|
+
*/
|
|
61
|
+
async function fetchIpInfo(ip) {
|
|
62
|
+
try {
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
65
|
+
|
|
66
|
+
const response = await fetch(
|
|
67
|
+
`http://ip-api.com/json/${ip}?fields=status,message,country,isp,org,as,hosting`,
|
|
68
|
+
{ signal: controller.signal }
|
|
69
|
+
);
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
|
|
72
|
+
if (!response.ok) return null;
|
|
73
|
+
|
|
74
|
+
const data = await response.json();
|
|
75
|
+
if (data.status !== 'success') return null;
|
|
76
|
+
|
|
77
|
+
// Parse ASN number from the "as" field (format: "AS16509 Amazon.com, Inc.")
|
|
78
|
+
let asnNumber = null;
|
|
79
|
+
if (data.as) {
|
|
80
|
+
const match = data.as.match(/^AS(\d+)/);
|
|
81
|
+
if (match) asnNumber = parseInt(match[1], 10);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const org = (data.org || data.isp || '').toLowerCase();
|
|
85
|
+
const isKnownAsn = asnNumber ? DATACENTER_ASNS.has(asnNumber) : false;
|
|
86
|
+
const isKnownOrg = DATACENTER_ORG_PATTERNS.some(p => org.includes(p));
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
asn: asnNumber,
|
|
90
|
+
org: data.org || data.isp || '',
|
|
91
|
+
isDatacenter: data.hosting === true || isKnownAsn || isKnownOrg,
|
|
92
|
+
country: data.country || '',
|
|
93
|
+
isp: data.isp || '',
|
|
94
|
+
raw: data,
|
|
95
|
+
};
|
|
96
|
+
} catch {
|
|
97
|
+
// Network error, timeout, etc. — fail open
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Look up IP information with caching.
|
|
104
|
+
* Returns { asn, org, isDatacenter } or null if lookup fails.
|
|
105
|
+
*/
|
|
106
|
+
export async function lookupIp(ip) {
|
|
107
|
+
if (!ip || isPrivateIp(ip)) {
|
|
108
|
+
return { asn: null, org: 'private', isDatacenter: false };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check cache
|
|
112
|
+
const cached = ipCache.get(ip);
|
|
113
|
+
if (cached) return cached;
|
|
114
|
+
|
|
115
|
+
// Fetch from API
|
|
116
|
+
const result = await fetchIpInfo(ip);
|
|
117
|
+
if (result) {
|
|
118
|
+
ipCache.set(ip, result);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Extract the real client IP from the request,
|
|
126
|
+
* respecting X-Forwarded-For and other proxy headers.
|
|
127
|
+
*/
|
|
128
|
+
export function getClientIp(req) {
|
|
129
|
+
// X-Forwarded-For can be comma-separated; take the first (original client)
|
|
130
|
+
const xff = req.headers['x-forwarded-for'];
|
|
131
|
+
if (xff) {
|
|
132
|
+
const first = xff.split(',')[0].trim();
|
|
133
|
+
if (first) return first;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Other common headers
|
|
137
|
+
const realIp = req.headers['x-real-ip'];
|
|
138
|
+
if (realIp) return realIp;
|
|
139
|
+
|
|
140
|
+
// CF-Connecting-IP (Cloudflare)
|
|
141
|
+
const cfIp = req.headers['cf-connecting-ip'];
|
|
142
|
+
if (cfIp) return cfIp;
|
|
143
|
+
|
|
144
|
+
// Fall back to socket
|
|
145
|
+
return req.socket?.remoteAddress || req.ip || '';
|
|
146
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in bot detection patterns and datacenter ASN database.
|
|
3
|
+
* These are the default rules that ship with carom.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// User-Agent substrings that indicate bots/crawlers/scanners
|
|
7
|
+
// All matched case-insensitively
|
|
8
|
+
export const BOT_UA_PATTERNS = [
|
|
9
|
+
// Generic bot tokens
|
|
10
|
+
'bot', 'crawl', 'spider', 'scrape',
|
|
11
|
+
// Preview/prefetch (carriers & messaging apps)
|
|
12
|
+
'preview', 'prefetch', 'fetch', 'unfurl',
|
|
13
|
+
// Scanning & monitoring
|
|
14
|
+
'scan', 'check', 'monitor', 'probe', 'validator',
|
|
15
|
+
// Headless browsers & automation
|
|
16
|
+
'headless', 'headlesschrome', 'phantom', 'phantomjs',
|
|
17
|
+
'selenium', 'puppeteer', 'playwright', 'webdriver',
|
|
18
|
+
// CLI tools
|
|
19
|
+
'wget', 'curl', 'httpie',
|
|
20
|
+
// HTTP libraries
|
|
21
|
+
'python-requests', 'python-urllib', 'python/',
|
|
22
|
+
'java/', 'apache-httpclient', 'okhttp',
|
|
23
|
+
'go-http-client', 'go-http',
|
|
24
|
+
'node-fetch', 'axios', 'undici', 'got/',
|
|
25
|
+
'libwww', 'lwp-', 'perl/',
|
|
26
|
+
'ruby/', 'faraday', 'typhoeus',
|
|
27
|
+
'php/', 'guzzle',
|
|
28
|
+
'dart/', 'http.client',
|
|
29
|
+
'rust/', 'reqwest', 'hyper/',
|
|
30
|
+
// Social media crawlers
|
|
31
|
+
'facebookexternalhit', 'facebot',
|
|
32
|
+
'twitterbot',
|
|
33
|
+
'linkedinbot',
|
|
34
|
+
'whatsapp',
|
|
35
|
+
'telegrambot',
|
|
36
|
+
'slackbot', 'slack-imgproxy',
|
|
37
|
+
'discordbot',
|
|
38
|
+
'pinterestbot',
|
|
39
|
+
'applebot',
|
|
40
|
+
// Search engines (detected but may be whitelisted)
|
|
41
|
+
'googlebot', 'google-inspectiontool',
|
|
42
|
+
'bingbot', 'msnbot',
|
|
43
|
+
'yandexbot', 'baiduspider',
|
|
44
|
+
'duckduckbot', 'sogou',
|
|
45
|
+
// SEO & marketing tools
|
|
46
|
+
'semrush', 'ahrefs', 'moz/', 'majestic',
|
|
47
|
+
'screaming frog', 'sitebulb',
|
|
48
|
+
// Security scanners
|
|
49
|
+
'nmap', 'masscan', 'zgrab', 'nuclei',
|
|
50
|
+
'qualys', 'nessus', 'nikto',
|
|
51
|
+
// Uptime monitors
|
|
52
|
+
'uptimerobot', 'pingdom', 'statuscake',
|
|
53
|
+
'site24x7', 'datadog',
|
|
54
|
+
// Other known bots
|
|
55
|
+
'mediapartners-google', 'adsbot-google',
|
|
56
|
+
'google-read-aloud', 'feedfetcher',
|
|
57
|
+
'ia_archiver', 'archive.org',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// Known datacenter/cloud provider ASNs
|
|
61
|
+
// Traffic from these is likely automated, not a human on a phone
|
|
62
|
+
export const DATACENTER_ASNS = new Set([
|
|
63
|
+
// Amazon Web Services
|
|
64
|
+
16509, 14618, 7224, 8987,
|
|
65
|
+
// Google Cloud
|
|
66
|
+
15169, 396982,
|
|
67
|
+
// Microsoft Azure
|
|
68
|
+
8075, 8068, 8069, 12076,
|
|
69
|
+
// DigitalOcean
|
|
70
|
+
14061, 393406,
|
|
71
|
+
// Cloudflare
|
|
72
|
+
13335, 209242,
|
|
73
|
+
// Akamai
|
|
74
|
+
20940, 16625, 32787,
|
|
75
|
+
// Linode / Akamai Cloud
|
|
76
|
+
63949,
|
|
77
|
+
// OVHcloud
|
|
78
|
+
16276,
|
|
79
|
+
// Hetzner
|
|
80
|
+
24940, 213230,
|
|
81
|
+
// Vultr
|
|
82
|
+
20473,
|
|
83
|
+
// Oracle Cloud
|
|
84
|
+
31898,
|
|
85
|
+
// IBM / SoftLayer
|
|
86
|
+
36351,
|
|
87
|
+
// Rackspace
|
|
88
|
+
33070, 10532,
|
|
89
|
+
// Scaleway
|
|
90
|
+
12876,
|
|
91
|
+
// UpCloud
|
|
92
|
+
202053,
|
|
93
|
+
// Contabo
|
|
94
|
+
40021,
|
|
95
|
+
// Leaseweb
|
|
96
|
+
60781, 28753, 16265,
|
|
97
|
+
// ColoCrossing
|
|
98
|
+
36352,
|
|
99
|
+
// QuadraNet
|
|
100
|
+
8100,
|
|
101
|
+
// Choopa / Vultr
|
|
102
|
+
20473,
|
|
103
|
+
// Fastly
|
|
104
|
+
54113,
|
|
105
|
+
// Netlify
|
|
106
|
+
400587,
|
|
107
|
+
// Vercel
|
|
108
|
+
209242,
|
|
109
|
+
// Render
|
|
110
|
+
398101,
|
|
111
|
+
// Railway
|
|
112
|
+
400587,
|
|
113
|
+
// Fly.io
|
|
114
|
+
40509,
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
// Known datacenter org name fragments (fallback if ASN not in set)
|
|
118
|
+
export const DATACENTER_ORG_PATTERNS = [
|
|
119
|
+
'amazon', 'aws', 'ec2',
|
|
120
|
+
'google cloud', 'google llc',
|
|
121
|
+
'microsoft', 'azure',
|
|
122
|
+
'digitalocean',
|
|
123
|
+
'cloudflare',
|
|
124
|
+
'akamai',
|
|
125
|
+
'linode',
|
|
126
|
+
'ovh',
|
|
127
|
+
'hetzner',
|
|
128
|
+
'vultr', 'choopa',
|
|
129
|
+
'oracle',
|
|
130
|
+
'ibm', 'softlayer',
|
|
131
|
+
'rackspace',
|
|
132
|
+
'scaleway',
|
|
133
|
+
'contabo',
|
|
134
|
+
'leaseweb',
|
|
135
|
+
'fastly',
|
|
136
|
+
'datacenter', 'data center',
|
|
137
|
+
'hosting',
|
|
138
|
+
'colocation', 'colo',
|
|
139
|
+
'server',
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
// Bots that should be allowed through (whitelisted)
|
|
143
|
+
// These are verified via reverse DNS, not just UA string
|
|
144
|
+
export const ALLOWED_BOTS = [
|
|
145
|
+
{
|
|
146
|
+
name: 'Googlebot',
|
|
147
|
+
uaPattern: 'googlebot',
|
|
148
|
+
reverseDnsSuffix: '.googlebot.com',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'Google (other)',
|
|
152
|
+
uaPattern: 'google',
|
|
153
|
+
reverseDnsSuffix: '.google.com',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'Bingbot',
|
|
157
|
+
uaPattern: 'bingbot',
|
|
158
|
+
reverseDnsSuffix: '.search.msn.com',
|
|
159
|
+
},
|
|
160
|
+
];
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates the benign HTML page served to detected bots.
|
|
3
|
+
* Designed to look like a normal, innocuous web page so carriers
|
|
4
|
+
* don't flag the domain as suspicious.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate a safe page HTML string.
|
|
9
|
+
*/
|
|
10
|
+
export function generateSafePage({ title, description, brand, url }) {
|
|
11
|
+
const safeTitle = escapeHtml(title || 'Welcome');
|
|
12
|
+
const safeDescription = escapeHtml(description || 'Visit our website for more information.');
|
|
13
|
+
const safeBrand = escapeHtml(brand || 'Website');
|
|
14
|
+
const safeUrl = escapeHtml(url || '');
|
|
15
|
+
|
|
16
|
+
return `<!DOCTYPE html>
|
|
17
|
+
<html lang="en">
|
|
18
|
+
<head>
|
|
19
|
+
<meta charset="UTF-8">
|
|
20
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
21
|
+
<title>${safeTitle} | ${safeBrand}</title>
|
|
22
|
+
<meta name="description" content="${safeDescription}">
|
|
23
|
+
<meta name="robots" content="noindex, nofollow">
|
|
24
|
+
|
|
25
|
+
<!-- Open Graph tags for carrier preview cards -->
|
|
26
|
+
<meta property="og:title" content="${safeTitle}">
|
|
27
|
+
<meta property="og:description" content="${safeDescription}">
|
|
28
|
+
<meta property="og:type" content="website">
|
|
29
|
+
<meta property="og:site_name" content="${safeBrand}">
|
|
30
|
+
|
|
31
|
+
<!-- Twitter Card -->
|
|
32
|
+
<meta name="twitter:card" content="summary">
|
|
33
|
+
<meta name="twitter:title" content="${safeTitle}">
|
|
34
|
+
<meta name="twitter:description" content="${safeDescription}">
|
|
35
|
+
|
|
36
|
+
<style>
|
|
37
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
38
|
+
body {
|
|
39
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
40
|
+
background: #f8f9fa;
|
|
41
|
+
color: #333;
|
|
42
|
+
min-height: 100vh;
|
|
43
|
+
display: flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
justify-content: center;
|
|
46
|
+
}
|
|
47
|
+
.container {
|
|
48
|
+
text-align: center;
|
|
49
|
+
padding: 2rem;
|
|
50
|
+
max-width: 480px;
|
|
51
|
+
}
|
|
52
|
+
.brand {
|
|
53
|
+
font-size: 1.5rem;
|
|
54
|
+
font-weight: 700;
|
|
55
|
+
color: #2c3e50;
|
|
56
|
+
margin-bottom: 1rem;
|
|
57
|
+
}
|
|
58
|
+
.message {
|
|
59
|
+
font-size: 1rem;
|
|
60
|
+
color: #666;
|
|
61
|
+
line-height: 1.6;
|
|
62
|
+
}
|
|
63
|
+
.footer {
|
|
64
|
+
margin-top: 2rem;
|
|
65
|
+
font-size: 0.8rem;
|
|
66
|
+
color: #999;
|
|
67
|
+
}
|
|
68
|
+
</style>
|
|
69
|
+
</head>
|
|
70
|
+
<body>
|
|
71
|
+
<div class="container">
|
|
72
|
+
<div class="brand">${safeBrand}</div>
|
|
73
|
+
<p class="message">${safeDescription}</p>
|
|
74
|
+
<div class="footer">© ${new Date().getFullYear()} ${safeBrand}</div>
|
|
75
|
+
</div>
|
|
76
|
+
</body>
|
|
77
|
+
</html>`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Generate the interstitial challenge page (for interstitial mode).
|
|
82
|
+
* This page uses JavaScript to redirect, proving the client can execute JS.
|
|
83
|
+
*/
|
|
84
|
+
export function generateInterstitialPage({ slug, token, destinationUrl, brand }) {
|
|
85
|
+
const safeBrand = escapeHtml(brand || 'Website');
|
|
86
|
+
|
|
87
|
+
return `<!DOCTYPE html>
|
|
88
|
+
<html lang="en">
|
|
89
|
+
<head>
|
|
90
|
+
<meta charset="UTF-8">
|
|
91
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
92
|
+
<title>Redirecting... | ${safeBrand}</title>
|
|
93
|
+
<meta name="robots" content="noindex, nofollow">
|
|
94
|
+
<!-- Fallback for non-JS: slow meta refresh to safe page (bots land here) -->
|
|
95
|
+
<meta http-equiv="refresh" content="5;url=/${escapeHtml(slug)}?_safe=1">
|
|
96
|
+
<style>
|
|
97
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
98
|
+
body {
|
|
99
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
100
|
+
background: #f8f9fa;
|
|
101
|
+
color: #333;
|
|
102
|
+
min-height: 100vh;
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
justify-content: center;
|
|
106
|
+
}
|
|
107
|
+
.container { text-align: center; padding: 2rem; }
|
|
108
|
+
.spinner {
|
|
109
|
+
width: 32px; height: 32px;
|
|
110
|
+
border: 3px solid #e0e0e0;
|
|
111
|
+
border-top-color: #3498db;
|
|
112
|
+
border-radius: 50%;
|
|
113
|
+
animation: spin 0.8s linear infinite;
|
|
114
|
+
margin: 0 auto 1rem;
|
|
115
|
+
}
|
|
116
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
117
|
+
.message { color: #666; font-size: 0.95rem; }
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<div class="container">
|
|
122
|
+
<div class="spinner"></div>
|
|
123
|
+
<p class="message">Redirecting you securely...</p>
|
|
124
|
+
</div>
|
|
125
|
+
<script>
|
|
126
|
+
// JS-capable browsers redirect immediately with the signed token
|
|
127
|
+
(function() {
|
|
128
|
+
try {
|
|
129
|
+
window.location.replace('/${escapeHtml(slug)}?_t=${escapeHtml(token)}');
|
|
130
|
+
} catch(e) {
|
|
131
|
+
// Fallback: meta refresh will handle it
|
|
132
|
+
}
|
|
133
|
+
})();
|
|
134
|
+
</script>
|
|
135
|
+
</body>
|
|
136
|
+
</html>`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function escapeHtml(str) {
|
|
140
|
+
return String(str)
|
|
141
|
+
.replace(/&/g, '&')
|
|
142
|
+
.replace(/</g, '<')
|
|
143
|
+
.replace(/>/g, '>')
|
|
144
|
+
.replace(/"/g, '"')
|
|
145
|
+
.replace(/'/g, ''');
|
|
146
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from 'crypto';
|
|
2
|
+
import { TOKEN_EXPIRY_SECONDS, TOKEN_SECRET_LENGTH } from '../constants.js';
|
|
3
|
+
|
|
4
|
+
// Generate a random secret on startup (or load from config)
|
|
5
|
+
let _secret = null;
|
|
6
|
+
|
|
7
|
+
function getSecret() {
|
|
8
|
+
if (!_secret) {
|
|
9
|
+
_secret = randomBytes(TOKEN_SECRET_LENGTH).toString('hex');
|
|
10
|
+
}
|
|
11
|
+
return _secret;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Set the HMAC secret (used when loading from config/env).
|
|
16
|
+
*/
|
|
17
|
+
export function setTokenSecret(secret) {
|
|
18
|
+
_secret = secret;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate a signed token for a slug.
|
|
23
|
+
* Token format: timestamp.hmac
|
|
24
|
+
*/
|
|
25
|
+
export function generateToken(slug) {
|
|
26
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
27
|
+
const payload = `${slug}:${timestamp}`;
|
|
28
|
+
const hmac = createHmac('sha256', getSecret())
|
|
29
|
+
.update(payload)
|
|
30
|
+
.digest('hex')
|
|
31
|
+
.substring(0, 16); // Truncate for URL-friendliness
|
|
32
|
+
return `${timestamp}.${hmac}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Validate a token for a slug.
|
|
37
|
+
* Returns true if the token is valid and not expired.
|
|
38
|
+
*/
|
|
39
|
+
export function validateToken(slug, token) {
|
|
40
|
+
if (!token) return false;
|
|
41
|
+
|
|
42
|
+
const parts = token.split('.');
|
|
43
|
+
if (parts.length !== 2) return false;
|
|
44
|
+
|
|
45
|
+
const [timestampStr, providedHmac] = parts;
|
|
46
|
+
const timestamp = parseInt(timestampStr, 10);
|
|
47
|
+
if (isNaN(timestamp)) return false;
|
|
48
|
+
|
|
49
|
+
// Check expiry
|
|
50
|
+
const now = Math.floor(Date.now() / 1000);
|
|
51
|
+
if (now - timestamp > TOKEN_EXPIRY_SECONDS) return false;
|
|
52
|
+
|
|
53
|
+
// Verify HMAC
|
|
54
|
+
const payload = `${slug}:${timestamp}`;
|
|
55
|
+
const expectedHmac = createHmac('sha256', getSecret())
|
|
56
|
+
.update(payload)
|
|
57
|
+
.digest('hex')
|
|
58
|
+
.substring(0, 16);
|
|
59
|
+
|
|
60
|
+
// Constant-time comparison
|
|
61
|
+
if (providedHmac.length !== expectedHmac.length) return false;
|
|
62
|
+
let match = true;
|
|
63
|
+
for (let i = 0; i < expectedHmac.length; i++) {
|
|
64
|
+
if (providedHmac[i] !== expectedHmac[i]) match = false;
|
|
65
|
+
}
|
|
66
|
+
return match;
|
|
67
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { DEFAULT_DATA_DIR, CONFIG_FILENAME, DEFAULT_CONFIG } from './constants.js';
|
|
4
|
+
|
|
5
|
+
let _configDir = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Deep merge two objects. Source values override target values.
|
|
9
|
+
*/
|
|
10
|
+
function deepMerge(target, source) {
|
|
11
|
+
const result = { ...target };
|
|
12
|
+
for (const key of Object.keys(source)) {
|
|
13
|
+
if (
|
|
14
|
+
source[key] &&
|
|
15
|
+
typeof source[key] === 'object' &&
|
|
16
|
+
!Array.isArray(source[key]) &&
|
|
17
|
+
target[key] &&
|
|
18
|
+
typeof target[key] === 'object'
|
|
19
|
+
) {
|
|
20
|
+
result[key] = deepMerge(target[key], source[key]);
|
|
21
|
+
} else {
|
|
22
|
+
result[key] = source[key];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get a nested value from an object using dot notation.
|
|
30
|
+
*/
|
|
31
|
+
function getNestedValue(obj, path) {
|
|
32
|
+
return path.split('.').reduce((curr, key) => curr?.[key], obj);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Set a nested value on an object using dot notation.
|
|
37
|
+
*/
|
|
38
|
+
function setNestedValue(obj, path, value) {
|
|
39
|
+
const keys = path.split('.');
|
|
40
|
+
const last = keys.pop();
|
|
41
|
+
let curr = obj;
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
if (curr[key] === undefined || typeof curr[key] !== 'object') {
|
|
44
|
+
curr[key] = {};
|
|
45
|
+
}
|
|
46
|
+
curr = curr[key];
|
|
47
|
+
}
|
|
48
|
+
// Try to parse as number/boolean
|
|
49
|
+
if (value === 'true') value = true;
|
|
50
|
+
else if (value === 'false') value = false;
|
|
51
|
+
else if (!isNaN(value) && value !== '') value = Number(value);
|
|
52
|
+
curr[last] = value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get the config directory.
|
|
57
|
+
*/
|
|
58
|
+
function getConfigDir(dataDir) {
|
|
59
|
+
return dataDir || _configDir || process.env.CAROM_DATA_DIR || DEFAULT_DATA_DIR;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Set the config directory (called early by CLI).
|
|
64
|
+
*/
|
|
65
|
+
export function setConfigDir(dir) {
|
|
66
|
+
_configDir = dir;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read config file, merged with defaults and env vars.
|
|
71
|
+
*/
|
|
72
|
+
export function loadConfig(dataDir) {
|
|
73
|
+
const dir = getConfigDir(dataDir);
|
|
74
|
+
const configPath = join(dir, CONFIG_FILENAME);
|
|
75
|
+
|
|
76
|
+
let fileConfig = {};
|
|
77
|
+
if (existsSync(configPath)) {
|
|
78
|
+
try {
|
|
79
|
+
fileConfig = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
80
|
+
} catch {
|
|
81
|
+
// Corrupt config file, use defaults
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Start with defaults, overlay file config
|
|
86
|
+
let config = deepMerge(DEFAULT_CONFIG, fileConfig);
|
|
87
|
+
|
|
88
|
+
// Env vars override everything
|
|
89
|
+
if (process.env.PORT) config.port = parseInt(process.env.PORT, 10);
|
|
90
|
+
if (process.env.ADMIN_PORT) config.adminPort = parseInt(process.env.ADMIN_PORT, 10);
|
|
91
|
+
if (process.env.HOST) config.host = process.env.HOST;
|
|
92
|
+
if (process.env.BASE_URL) config.baseUrl = process.env.BASE_URL;
|
|
93
|
+
if (process.env.API_KEY) config.apiKey = process.env.API_KEY;
|
|
94
|
+
if (process.env.SHIELD_ENABLED !== undefined) config.shield.enabled = process.env.SHIELD_ENABLED === 'true';
|
|
95
|
+
if (process.env.SHIELD_THRESHOLD) config.shield.threshold = parseInt(process.env.SHIELD_THRESHOLD, 10);
|
|
96
|
+
if (process.env.SHIELD_MODE) config.shield.mode = process.env.SHIELD_MODE;
|
|
97
|
+
if (process.env.SAFE_PAGE_TITLE) config.shield.safePage.title = process.env.SAFE_PAGE_TITLE;
|
|
98
|
+
if (process.env.SAFE_PAGE_DESCRIPTION) config.shield.safePage.description = process.env.SAFE_PAGE_DESCRIPTION;
|
|
99
|
+
if (process.env.SAFE_PAGE_BRAND) config.shield.safePage.brand = process.env.SAFE_PAGE_BRAND;
|
|
100
|
+
|
|
101
|
+
return config;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Save config to file.
|
|
106
|
+
*/
|
|
107
|
+
export function saveConfig(config, dataDir) {
|
|
108
|
+
const dir = getConfigDir(dataDir);
|
|
109
|
+
mkdirSync(dir, { recursive: true });
|
|
110
|
+
const configPath = join(dir, CONFIG_FILENAME);
|
|
111
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get a specific config value by dot-notation key.
|
|
116
|
+
*/
|
|
117
|
+
export function getConfigValue(key, dataDir) {
|
|
118
|
+
const config = loadConfig(dataDir);
|
|
119
|
+
return getNestedValue(config, key);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Set a specific config value by dot-notation key and persist.
|
|
124
|
+
*/
|
|
125
|
+
export function setConfigValue(key, value, dataDir) {
|
|
126
|
+
const dir = getConfigDir(dataDir);
|
|
127
|
+
const configPath = join(dir, CONFIG_FILENAME);
|
|
128
|
+
|
|
129
|
+
// Read raw file config (not merged with env/defaults)
|
|
130
|
+
let fileConfig = {};
|
|
131
|
+
if (existsSync(configPath)) {
|
|
132
|
+
try {
|
|
133
|
+
fileConfig = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
134
|
+
} catch {
|
|
135
|
+
fileConfig = {};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
setNestedValue(fileConfig, key, value);
|
|
140
|
+
saveConfig(fileConfig, dataDir);
|
|
141
|
+
|
|
142
|
+
// Return the full merged config
|
|
143
|
+
return loadConfig(dataDir);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Reset config to defaults.
|
|
148
|
+
*/
|
|
149
|
+
export function resetConfig(dataDir) {
|
|
150
|
+
saveConfig({}, dataDir);
|
|
151
|
+
return loadConfig(dataDir);
|
|
152
|
+
}
|