@uranotools/urano-social 0.2.0 → 0.3.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/.env.example +16 -0
- package/README.md +122 -102
- package/dist/client/createUranoChat.js +4 -7
- package/dist/client/index.d.ts +3 -2
- package/dist/client/index.js +4 -10
- package/dist/client/recaptcha.d.ts +14 -0
- package/dist/client/recaptcha.js +36 -0
- package/dist/client/sse.js +2 -6
- package/dist/client/submitContactForm.d.ts +33 -2
- package/dist/client/submitContactForm.js +74 -6
- package/dist/crypto.js +6 -46
- package/dist/flavors/UranoSocialChatElement.js +4 -9
- package/dist/flavors/UranoSocialContactFormElement.js +17 -12
- package/dist/index.d.ts +5 -3
- package/dist/index.js +10 -26
- package/dist/react/UranoSocialChat.js +25 -62
- package/dist/react/UranoSocialContactForm.d.ts +4 -0
- package/dist/react/UranoSocialContactForm.js +46 -16
- package/dist/react/index.js +2 -8
- package/dist/server/antiSpam.d.ts +76 -0
- package/dist/server/antiSpam.js +204 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +4 -13
- package/dist/server/proxy.js +58 -12
- package/dist/server/visitorCookie.js +8 -13
- package/dist/server/websocket.js +8 -12
- package/dist/types.d.ts +30 -0
- package/dist/types.js +3 -2
- package/package.json +2 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { randomId, signHmacHex, verifyHmacHex } from '../crypto';
|
|
2
|
+
import { DEFAULT_HONEYPOT_FIELD, DEFAULT_RECAPTCHA_ACTION, } from '../types';
|
|
3
|
+
export { DEFAULT_HONEYPOT_FIELD, DEFAULT_RECAPTCHA_ACTION };
|
|
4
|
+
export const DEFAULT_RECAPTCHA_MIN_SCORE = 0.5;
|
|
5
|
+
export const DEFAULT_CONTACT_MIN_AGE_MS = 2500;
|
|
6
|
+
export const DEFAULT_CONTACT_MAX_AGE_MS = 30 * 60 * 1000;
|
|
7
|
+
export const DEFAULT_CONTACT_MAX_PER_HOUR = 5;
|
|
8
|
+
export const DEFAULT_CONTACT_MAX_MESSAGE_CHARS = 4000;
|
|
9
|
+
/** Process-local buckets (good enough for single Node / Pages isolate). */
|
|
10
|
+
const rateBuckets = new Map();
|
|
11
|
+
export function resolveAntiSpamConfig(partial) {
|
|
12
|
+
const enabledEnv = process.env.URANO_SOCIAL_ANTISPAM;
|
|
13
|
+
const enabled = partial?.enabled ?? (enabledEnv == null ? true : !['0', 'false', 'off'].includes(enabledEnv.toLowerCase()));
|
|
14
|
+
const recaptchaSecret = partial?.recaptchaSecret
|
|
15
|
+
|| process.env.URANO_SOCIAL_RECAPTCHA_SECRET
|
|
16
|
+
|| process.env.RECAPTCHA_SECRET_KEY
|
|
17
|
+
|| '';
|
|
18
|
+
const minScoreRaw = partial?.recaptchaMinScore
|
|
19
|
+
?? (process.env.URANO_SOCIAL_RECAPTCHA_MIN_SCORE
|
|
20
|
+
? Number(process.env.URANO_SOCIAL_RECAPTCHA_MIN_SCORE)
|
|
21
|
+
: DEFAULT_RECAPTCHA_MIN_SCORE);
|
|
22
|
+
const maxPerHourRaw = partial?.maxPerHour
|
|
23
|
+
?? (process.env.URANO_SOCIAL_CONTACT_MAX_PER_HOUR
|
|
24
|
+
? Number(process.env.URANO_SOCIAL_CONTACT_MAX_PER_HOUR)
|
|
25
|
+
: DEFAULT_CONTACT_MAX_PER_HOUR);
|
|
26
|
+
return {
|
|
27
|
+
enabled,
|
|
28
|
+
honeypotField: partial?.honeypotField || process.env.URANO_SOCIAL_HONEYPOT_FIELD || DEFAULT_HONEYPOT_FIELD,
|
|
29
|
+
minSubmitAgeMs: partial?.minSubmitAgeMs
|
|
30
|
+
?? (process.env.URANO_SOCIAL_CONTACT_MIN_AGE_MS
|
|
31
|
+
? Number(process.env.URANO_SOCIAL_CONTACT_MIN_AGE_MS)
|
|
32
|
+
: DEFAULT_CONTACT_MIN_AGE_MS),
|
|
33
|
+
maxSubmitAgeMs: partial?.maxSubmitAgeMs
|
|
34
|
+
?? (process.env.URANO_SOCIAL_CONTACT_MAX_AGE_MS
|
|
35
|
+
? Number(process.env.URANO_SOCIAL_CONTACT_MAX_AGE_MS)
|
|
36
|
+
: DEFAULT_CONTACT_MAX_AGE_MS),
|
|
37
|
+
maxPerHour: Number.isFinite(maxPerHourRaw) ? Math.max(1, maxPerHourRaw) : DEFAULT_CONTACT_MAX_PER_HOUR,
|
|
38
|
+
maxMessageChars: partial?.maxMessageChars
|
|
39
|
+
?? (process.env.URANO_SOCIAL_CONTACT_MAX_MESSAGE_CHARS
|
|
40
|
+
? Number(process.env.URANO_SOCIAL_CONTACT_MAX_MESSAGE_CHARS)
|
|
41
|
+
: DEFAULT_CONTACT_MAX_MESSAGE_CHARS),
|
|
42
|
+
recaptchaSecret: recaptchaSecret || undefined,
|
|
43
|
+
recaptchaMinScore: Number.isFinite(minScoreRaw) ? minScoreRaw : DEFAULT_RECAPTCHA_MIN_SCORE,
|
|
44
|
+
recaptchaAction: partial?.recaptchaAction
|
|
45
|
+
|| process.env.URANO_SOCIAL_RECAPTCHA_ACTION
|
|
46
|
+
|| DEFAULT_RECAPTCHA_ACTION,
|
|
47
|
+
requireRecaptcha: partial?.requireRecaptcha
|
|
48
|
+
?? (recaptchaSecret
|
|
49
|
+
? true
|
|
50
|
+
: ['1', 'true', 'on'].includes(String(process.env.URANO_SOCIAL_REQUIRE_RECAPTCHA || '').toLowerCase())),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function issueContactTicket(secret, now = Date.now()) {
|
|
54
|
+
const nonce = randomId('ct');
|
|
55
|
+
const payload = `${now}.${nonce}`;
|
|
56
|
+
return `${payload}.${signHmacHex(payload, secret)}`;
|
|
57
|
+
}
|
|
58
|
+
export function verifyContactTicket(ticket, secret, opts, now = Date.now()) {
|
|
59
|
+
if (!ticket || typeof ticket !== 'string') {
|
|
60
|
+
return { ok: false, error: 'contact_ticket_required' };
|
|
61
|
+
}
|
|
62
|
+
const parts = ticket.split('.');
|
|
63
|
+
if (parts.length < 3)
|
|
64
|
+
return { ok: false, error: 'contact_ticket_invalid' };
|
|
65
|
+
const sig = parts.pop();
|
|
66
|
+
const payload = parts.join('.');
|
|
67
|
+
const [issuedRaw] = parts;
|
|
68
|
+
const issuedAt = Number(issuedRaw);
|
|
69
|
+
if (!Number.isFinite(issuedAt) || !verifyHmacHex(payload, sig, secret)) {
|
|
70
|
+
return { ok: false, error: 'contact_ticket_invalid' };
|
|
71
|
+
}
|
|
72
|
+
const age = now - issuedAt;
|
|
73
|
+
if (age < opts.minAgeMs)
|
|
74
|
+
return { ok: false, error: 'contact_too_fast' };
|
|
75
|
+
if (age > opts.maxAgeMs)
|
|
76
|
+
return { ok: false, error: 'contact_ticket_expired' };
|
|
77
|
+
return { ok: true, issuedAt };
|
|
78
|
+
}
|
|
79
|
+
export function checkContactRateLimit(key, maxPerHour, now = Date.now()) {
|
|
80
|
+
const bucket = rateBuckets.get(key);
|
|
81
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
82
|
+
rateBuckets.set(key, { count: 1, resetAt: now + 60 * 60 * 1000 });
|
|
83
|
+
return { ok: true };
|
|
84
|
+
}
|
|
85
|
+
if (bucket.count >= maxPerHour) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: 'contact_rate_limited',
|
|
89
|
+
retryAfterSec: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
bucket.count += 1;
|
|
93
|
+
return { ok: true };
|
|
94
|
+
}
|
|
95
|
+
/** Test helper — clears in-memory rate buckets. */
|
|
96
|
+
export function resetContactRateLimits() {
|
|
97
|
+
rateBuckets.clear();
|
|
98
|
+
}
|
|
99
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
100
|
+
export function validateContactFields(input) {
|
|
101
|
+
const email = typeof input.email === 'string' ? input.email.trim().toLowerCase() : '';
|
|
102
|
+
const message = typeof input.message === 'string' ? input.message.trim() : '';
|
|
103
|
+
if (!email || !message)
|
|
104
|
+
return { ok: false, error: 'email_and_message_required' };
|
|
105
|
+
if (!EMAIL_RE.test(email) || email.length > 254)
|
|
106
|
+
return { ok: false, error: 'invalid_email' };
|
|
107
|
+
if (message.length > input.maxMessageChars)
|
|
108
|
+
return { ok: false, error: 'message_too_long' };
|
|
109
|
+
return { ok: true, email, message };
|
|
110
|
+
}
|
|
111
|
+
export async function verifyRecaptchaV3(opts) {
|
|
112
|
+
if (!opts.token)
|
|
113
|
+
return { ok: false, error: 'recaptcha_required' };
|
|
114
|
+
const body = new URLSearchParams();
|
|
115
|
+
body.set('secret', opts.secret);
|
|
116
|
+
body.set('response', opts.token);
|
|
117
|
+
if (opts.remoteIp)
|
|
118
|
+
body.set('remoteip', opts.remoteIp);
|
|
119
|
+
let data;
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
124
|
+
body,
|
|
125
|
+
});
|
|
126
|
+
data = await res.json();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return { ok: false, error: 'recaptcha_unreachable' };
|
|
130
|
+
}
|
|
131
|
+
if (!data?.success)
|
|
132
|
+
return { ok: false, error: 'recaptcha_failed' };
|
|
133
|
+
if (opts.expectedAction && data.action && data.action !== opts.expectedAction) {
|
|
134
|
+
return { ok: false, error: 'recaptcha_action_mismatch' };
|
|
135
|
+
}
|
|
136
|
+
const score = typeof data.score === 'number' ? data.score : 0;
|
|
137
|
+
if (score < opts.minScore)
|
|
138
|
+
return { ok: false, error: 'recaptcha_low_score' };
|
|
139
|
+
return { ok: true, score };
|
|
140
|
+
}
|
|
141
|
+
export function clientIpFromRequest(request) {
|
|
142
|
+
const forwarded = request.headers.get('cf-connecting-ip')
|
|
143
|
+
|| request.headers.get('x-real-ip')
|
|
144
|
+
|| request.headers.get('x-forwarded-for');
|
|
145
|
+
if (!forwarded)
|
|
146
|
+
return null;
|
|
147
|
+
return forwarded.split(',')[0]?.trim() || null;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Runs contact anti-spam gates. On honeypot hit returns softOk so the proxy
|
|
151
|
+
* can fake a success without calling the AI webhook.
|
|
152
|
+
*/
|
|
153
|
+
export async function enforceContactAntiSpam(opts) {
|
|
154
|
+
const { body, request, visitorId, ticketSecret, config } = opts;
|
|
155
|
+
if (!config.enabled)
|
|
156
|
+
return { ok: true };
|
|
157
|
+
const honeypotField = config.honeypotField || DEFAULT_HONEYPOT_FIELD;
|
|
158
|
+
const honeypotValue = body[honeypotField];
|
|
159
|
+
if (typeof honeypotValue === 'string' && honeypotValue.trim() !== '') {
|
|
160
|
+
return { ok: false, softOk: true, fakeText: 'Gracias. Te responderemos pronto.' };
|
|
161
|
+
}
|
|
162
|
+
const fields = validateContactFields({
|
|
163
|
+
email: body.email,
|
|
164
|
+
message: body.message ?? body.text,
|
|
165
|
+
maxMessageChars: config.maxMessageChars || DEFAULT_CONTACT_MAX_MESSAGE_CHARS,
|
|
166
|
+
});
|
|
167
|
+
if (!fields.ok)
|
|
168
|
+
return { ok: false, status: 400, error: fields.error };
|
|
169
|
+
const ticket = typeof body.contactTicket === 'string'
|
|
170
|
+
? body.contactTicket
|
|
171
|
+
: typeof body.ticket === 'string'
|
|
172
|
+
? body.ticket
|
|
173
|
+
: undefined;
|
|
174
|
+
const ticketCheck = verifyContactTicket(ticket, ticketSecret, {
|
|
175
|
+
minAgeMs: config.minSubmitAgeMs ?? DEFAULT_CONTACT_MIN_AGE_MS,
|
|
176
|
+
maxAgeMs: config.maxSubmitAgeMs ?? DEFAULT_CONTACT_MAX_AGE_MS,
|
|
177
|
+
});
|
|
178
|
+
if (!ticketCheck.ok)
|
|
179
|
+
return { ok: false, status: 403, error: ticketCheck.error };
|
|
180
|
+
const ip = clientIpFromRequest(request);
|
|
181
|
+
const rateKey = `contact:${visitorId}:${ip || 'noip'}`;
|
|
182
|
+
const rate = checkContactRateLimit(rateKey, config.maxPerHour ?? DEFAULT_CONTACT_MAX_PER_HOUR);
|
|
183
|
+
if (!rate.ok) {
|
|
184
|
+
return { ok: false, status: 429, error: rate.error, retryAfterSec: rate.retryAfterSec };
|
|
185
|
+
}
|
|
186
|
+
const needsCaptcha = Boolean(config.recaptchaSecret) && config.requireRecaptcha !== false;
|
|
187
|
+
if (needsCaptcha) {
|
|
188
|
+
const token = typeof body.captchaToken === 'string'
|
|
189
|
+
? body.captchaToken
|
|
190
|
+
: typeof body.recaptchaToken === 'string'
|
|
191
|
+
? body.recaptchaToken
|
|
192
|
+
: '';
|
|
193
|
+
const captcha = await verifyRecaptchaV3({
|
|
194
|
+
secret: config.recaptchaSecret,
|
|
195
|
+
token,
|
|
196
|
+
minScore: config.recaptchaMinScore ?? DEFAULT_RECAPTCHA_MIN_SCORE,
|
|
197
|
+
expectedAction: config.recaptchaAction || DEFAULT_RECAPTCHA_ACTION,
|
|
198
|
+
remoteIp: ip,
|
|
199
|
+
});
|
|
200
|
+
if (!captcha.ok)
|
|
201
|
+
return { ok: false, status: 403, error: captcha.error };
|
|
202
|
+
}
|
|
203
|
+
return { ok: true };
|
|
204
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { createSocialProxy, socialProxyRoute } from './proxy';
|
|
2
2
|
export { attachSocialWebSocket, buildWsEnvelope } from './websocket';
|
|
3
3
|
export { resolveVisitorId, parseCookies, DEFAULT_COOKIE_NAME } from './visitorCookie';
|
|
4
|
+
export { resolveAntiSpamConfig, issueContactTicket, verifyContactTicket, verifyRecaptchaV3, enforceContactAntiSpam, resetContactRateLimits, DEFAULT_HONEYPOT_FIELD, DEFAULT_RECAPTCHA_ACTION, } from './antiSpam';
|
package/dist/server/index.js
CHANGED
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Object.defineProperty(exports, "createSocialProxy", { enumerable: true, get: function () { return proxy_1.createSocialProxy; } });
|
|
6
|
-
Object.defineProperty(exports, "socialProxyRoute", { enumerable: true, get: function () { return proxy_1.socialProxyRoute; } });
|
|
7
|
-
var websocket_1 = require("./websocket");
|
|
8
|
-
Object.defineProperty(exports, "attachSocialWebSocket", { enumerable: true, get: function () { return websocket_1.attachSocialWebSocket; } });
|
|
9
|
-
Object.defineProperty(exports, "buildWsEnvelope", { enumerable: true, get: function () { return websocket_1.buildWsEnvelope; } });
|
|
10
|
-
var visitorCookie_1 = require("./visitorCookie");
|
|
11
|
-
Object.defineProperty(exports, "resolveVisitorId", { enumerable: true, get: function () { return visitorCookie_1.resolveVisitorId; } });
|
|
12
|
-
Object.defineProperty(exports, "parseCookies", { enumerable: true, get: function () { return visitorCookie_1.parseCookies; } });
|
|
13
|
-
Object.defineProperty(exports, "DEFAULT_COOKIE_NAME", { enumerable: true, get: function () { return visitorCookie_1.DEFAULT_COOKIE_NAME; } });
|
|
1
|
+
export { createSocialProxy, socialProxyRoute } from './proxy';
|
|
2
|
+
export { attachSocialWebSocket, buildWsEnvelope } from './websocket';
|
|
3
|
+
export { resolveVisitorId, parseCookies, DEFAULT_COOKIE_NAME } from './visitorCookie';
|
|
4
|
+
export { resolveAntiSpamConfig, issueContactTicket, verifyContactTicket, verifyRecaptchaV3, enforceContactAntiSpam, resetContactRateLimits, DEFAULT_HONEYPOT_FIELD, DEFAULT_RECAPTCHA_ACTION, } from './antiSpam';
|
package/dist/server/proxy.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
exports.socialProxyRoute = socialProxyRoute;
|
|
5
|
-
const crypto_1 = require("../crypto");
|
|
6
|
-
const visitorCookie_1 = require("./visitorCookie");
|
|
1
|
+
import { signHmacHex } from '../crypto';
|
|
2
|
+
import { resolveVisitorId, DEFAULT_COOKIE_NAME } from './visitorCookie';
|
|
3
|
+
import { enforceContactAntiSpam, issueContactTicket, resolveAntiSpamConfig, } from './antiSpam';
|
|
7
4
|
function envConfig(partial) {
|
|
8
5
|
const webhookUrl = partial?.webhookUrl || process.env.URANO_SOCIAL_WEBHOOK_URL || '';
|
|
9
6
|
const webhookSecret = partial?.webhookSecret || process.env.URANO_SOCIAL_WEBHOOK_SECRET || '';
|
|
@@ -15,9 +12,10 @@ function envConfig(partial) {
|
|
|
15
12
|
webhookUrl: webhookUrl.replace(/\/$/, ''),
|
|
16
13
|
webhookSecret,
|
|
17
14
|
cookieSecret: partial?.cookieSecret || process.env.URANO_SOCIAL_COOKIE_SECRET || webhookSecret,
|
|
18
|
-
cookieName: partial?.cookieName ||
|
|
15
|
+
cookieName: partial?.cookieName || DEFAULT_COOKIE_NAME,
|
|
19
16
|
cookieMaxAgeSec: partial?.cookieMaxAgeSec,
|
|
20
17
|
defaultDisplayName: partial?.defaultDisplayName,
|
|
18
|
+
antiSpam: resolveAntiSpamConfig(partial?.antiSpam),
|
|
21
19
|
};
|
|
22
20
|
}
|
|
23
21
|
function json(status, body, headers) {
|
|
@@ -41,8 +39,9 @@ async function readJson(request) {
|
|
|
41
39
|
* Creates a site-local proxy that signs webhook traffic with the server secret.
|
|
42
40
|
* The browser never receives WEBHOOK_SECRET.
|
|
43
41
|
*/
|
|
44
|
-
function createSocialProxy(partial) {
|
|
42
|
+
export function createSocialProxy(partial) {
|
|
45
43
|
const config = envConfig(partial);
|
|
44
|
+
const antiSpam = resolveAntiSpamConfig(config.antiSpam);
|
|
46
45
|
async function handle(request) {
|
|
47
46
|
const url = new URL(request.url);
|
|
48
47
|
const method = request.method.toUpperCase();
|
|
@@ -57,7 +56,7 @@ function createSocialProxy(partial) {
|
|
|
57
56
|
},
|
|
58
57
|
});
|
|
59
58
|
}
|
|
60
|
-
const visitor =
|
|
59
|
+
const visitor = resolveVisitorId({
|
|
61
60
|
cookieHeader: request.headers.get('cookie'),
|
|
62
61
|
cookieName: config.cookieName,
|
|
63
62
|
cookieSecret: config.cookieSecret,
|
|
@@ -65,7 +64,20 @@ function createSocialProxy(partial) {
|
|
|
65
64
|
const responseHeaders = new Headers();
|
|
66
65
|
if (visitor.setCookie)
|
|
67
66
|
responseHeaders.append('Set-Cookie', visitor.setCookie);
|
|
68
|
-
|
|
67
|
+
const action = url.searchParams.get('action');
|
|
68
|
+
if (method === 'GET' && action === 'contact-ticket') {
|
|
69
|
+
const ticket = issueContactTicket(config.cookieSecret || config.webhookSecret);
|
|
70
|
+
return json(200, {
|
|
71
|
+
ok: true,
|
|
72
|
+
ticket,
|
|
73
|
+
minAgeMs: antiSpam.minSubmitAgeMs,
|
|
74
|
+
maxAgeMs: antiSpam.maxSubmitAgeMs,
|
|
75
|
+
honeypotField: antiSpam.honeypotField,
|
|
76
|
+
recaptchaRequired: Boolean(antiSpam.recaptchaSecret) && antiSpam.requireRecaptcha !== false,
|
|
77
|
+
recaptchaAction: antiSpam.recaptchaAction,
|
|
78
|
+
}, responseHeaders);
|
|
79
|
+
}
|
|
80
|
+
if (method === 'GET' && !url.searchParams.get('mode') && !url.searchParams.get('payload') && !action) {
|
|
69
81
|
try {
|
|
70
82
|
const discovery = await fetch(config.webhookUrl, { method: 'GET' });
|
|
71
83
|
const body = await discovery.json().catch(() => ({ ok: discovery.ok }));
|
|
@@ -74,6 +86,15 @@ function createSocialProxy(partial) {
|
|
|
74
86
|
ok: true,
|
|
75
87
|
proxy: true,
|
|
76
88
|
visitorId: visitor.visitorId,
|
|
89
|
+
antiSpam: {
|
|
90
|
+
enabled: antiSpam.enabled !== false,
|
|
91
|
+
honeypotField: antiSpam.honeypotField,
|
|
92
|
+
ticket: true,
|
|
93
|
+
rateLimitPerHour: antiSpam.maxPerHour,
|
|
94
|
+
recaptcha: Boolean(antiSpam.recaptchaSecret),
|
|
95
|
+
recaptchaRequired: Boolean(antiSpam.recaptchaSecret) && antiSpam.requireRecaptcha !== false,
|
|
96
|
+
recaptchaAction: antiSpam.recaptchaAction,
|
|
97
|
+
},
|
|
77
98
|
upstream: body,
|
|
78
99
|
}), { status: 200, headers: responseHeaders });
|
|
79
100
|
}
|
|
@@ -98,6 +119,31 @@ function createSocialProxy(partial) {
|
|
|
98
119
|
|| clientBody.mode
|
|
99
120
|
|| ((request.headers.get('Accept') || '').includes('text/event-stream') ? 'sse' : 'wait'));
|
|
100
121
|
const isContact = ['contact', 'email', 'form'].includes(String(clientBody.type || clientBody.channel || '').toLowerCase());
|
|
122
|
+
if (isContact) {
|
|
123
|
+
const gate = await enforceContactAntiSpam({
|
|
124
|
+
body: clientBody,
|
|
125
|
+
request,
|
|
126
|
+
visitorId: visitor.visitorId,
|
|
127
|
+
ticketSecret: config.cookieSecret || config.webhookSecret,
|
|
128
|
+
config: antiSpam,
|
|
129
|
+
});
|
|
130
|
+
if (!gate.ok) {
|
|
131
|
+
if ('softOk' in gate && gate.softOk) {
|
|
132
|
+
return json(200, {
|
|
133
|
+
ok: true,
|
|
134
|
+
channel: 'contact',
|
|
135
|
+
delivered: 'honeypot',
|
|
136
|
+
text: gate.fakeText,
|
|
137
|
+
}, responseHeaders);
|
|
138
|
+
}
|
|
139
|
+
if ('status' in gate) {
|
|
140
|
+
if (gate.retryAfterSec)
|
|
141
|
+
responseHeaders.set('Retry-After', String(gate.retryAfterSec));
|
|
142
|
+
return json(gate.status, { error: gate.error, retryAfterSec: gate.retryAfterSec }, responseHeaders);
|
|
143
|
+
}
|
|
144
|
+
return json(403, { error: 'contact_rejected' }, responseHeaders);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
101
147
|
const outbound = isContact
|
|
102
148
|
? { type: 'contact' }
|
|
103
149
|
: {
|
|
@@ -146,7 +192,7 @@ function createSocialProxy(partial) {
|
|
|
146
192
|
delete clientBody.visitorId;
|
|
147
193
|
delete clientBody.from;
|
|
148
194
|
const rawBody = JSON.stringify(outbound);
|
|
149
|
-
const signature =
|
|
195
|
+
const signature = signHmacHex(rawBody, config.webhookSecret);
|
|
150
196
|
const idempotency = request.headers.get('Idempotency-Key')
|
|
151
197
|
|| (typeof outbound.idempotencyKey === 'string' ? outbound.idempotencyKey : undefined);
|
|
152
198
|
const upstreamHeaders = {
|
|
@@ -197,7 +243,7 @@ function createSocialProxy(partial) {
|
|
|
197
243
|
return { handle };
|
|
198
244
|
}
|
|
199
245
|
/** Convenience for Next.js App Router route handlers. */
|
|
200
|
-
function socialProxyRoute(partial) {
|
|
246
|
+
export function socialProxyRoute(partial) {
|
|
201
247
|
const proxy = createSocialProxy(partial);
|
|
202
248
|
return {
|
|
203
249
|
GET: (request) => proxy.handle(request),
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
exports.parseCookies = parseCookies;
|
|
5
|
-
exports.resolveVisitorId = resolveVisitorId;
|
|
6
|
-
const crypto_1 = require("../crypto");
|
|
7
|
-
exports.DEFAULT_COOKIE_NAME = 'urano_social_vid';
|
|
8
|
-
function parseCookies(header) {
|
|
1
|
+
import { randomId, signCookieValue, verifyCookieValue } from '../crypto';
|
|
2
|
+
export const DEFAULT_COOKIE_NAME = 'urano_social_vid';
|
|
3
|
+
export function parseCookies(header) {
|
|
9
4
|
const out = {};
|
|
10
5
|
if (!header)
|
|
11
6
|
return out;
|
|
@@ -20,17 +15,17 @@ function parseCookies(header) {
|
|
|
20
15
|
}
|
|
21
16
|
return out;
|
|
22
17
|
}
|
|
23
|
-
function resolveVisitorId(opts) {
|
|
24
|
-
const name = opts.cookieName ||
|
|
18
|
+
export function resolveVisitorId(opts) {
|
|
19
|
+
const name = opts.cookieName || DEFAULT_COOKIE_NAME;
|
|
25
20
|
const cookies = parseCookies(opts.cookieHeader);
|
|
26
21
|
const existing = cookies[name];
|
|
27
22
|
if (existing) {
|
|
28
|
-
const verified =
|
|
23
|
+
const verified = verifyCookieValue(existing, opts.cookieSecret);
|
|
29
24
|
if (verified)
|
|
30
25
|
return { visitorId: verified, isNew: false };
|
|
31
26
|
}
|
|
32
|
-
const visitorId =
|
|
33
|
-
const signed =
|
|
27
|
+
const visitorId = randomId('vis');
|
|
28
|
+
const signed = signCookieValue(visitorId, opts.cookieSecret);
|
|
34
29
|
const maxAge = 60 * 60 * 24 * 365;
|
|
35
30
|
const setCookie = [
|
|
36
31
|
`${name}=${encodeURIComponent(signed)}`,
|
package/dist/server/websocket.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
exports.attachSocialWebSocket = attachSocialWebSocket;
|
|
4
|
-
exports.buildWsEnvelope = buildWsEnvelope;
|
|
5
|
-
const crypto_1 = require("../crypto");
|
|
6
|
-
const visitorCookie_1 = require("./visitorCookie");
|
|
1
|
+
import { signHmacHex } from '../crypto';
|
|
2
|
+
import { resolveVisitorId, DEFAULT_COOKIE_NAME } from './visitorCookie';
|
|
7
3
|
/**
|
|
8
4
|
* Attaches an optional WebSocket bridge to a Node HTTP server.
|
|
9
5
|
* Next App Router cannot upgrade sockets in a route handler — use SSE there.
|
|
10
6
|
* Astro Node / custom servers can call this after `http.createServer`.
|
|
11
7
|
*/
|
|
12
|
-
function attachSocialWebSocket(server, partial) {
|
|
8
|
+
export function attachSocialWebSocket(server, partial) {
|
|
13
9
|
const webhookUrl = (partial?.webhookUrl || process.env.URANO_SOCIAL_WEBHOOK_URL || '').replace(/\/$/, '');
|
|
14
10
|
const webhookSecret = partial?.webhookSecret || process.env.URANO_SOCIAL_WEBHOOK_SECRET || '';
|
|
15
11
|
const cookieSecret = partial?.cookieSecret || process.env.URANO_SOCIAL_COOKIE_SECRET || webhookSecret;
|
|
16
|
-
const cookieName = partial?.cookieName ||
|
|
12
|
+
const cookieName = partial?.cookieName || DEFAULT_COOKIE_NAME;
|
|
17
13
|
const path = partial?.path || '/api/urano-social/ws';
|
|
18
14
|
if (!webhookUrl || !webhookSecret) {
|
|
19
15
|
throw new Error('attachSocialWebSocket requires URANO_SOCIAL_WEBHOOK_URL and URANO_SOCIAL_WEBHOOK_SECRET');
|
|
@@ -23,7 +19,7 @@ function attachSocialWebSocket(server, partial) {
|
|
|
23
19
|
const url = new URL(req.url || '/', 'http://localhost');
|
|
24
20
|
if (url.pathname !== path)
|
|
25
21
|
return;
|
|
26
|
-
const visitor =
|
|
22
|
+
const visitor = resolveVisitorId({
|
|
27
23
|
cookieHeader: req.headers.cookie,
|
|
28
24
|
cookieName,
|
|
29
25
|
cookieSecret,
|
|
@@ -68,7 +64,7 @@ function attachSocialWebSocket(server, partial) {
|
|
|
68
64
|
turnId: msg.turnId,
|
|
69
65
|
};
|
|
70
66
|
const payloadStr = JSON.stringify(payloadObj);
|
|
71
|
-
const signature =
|
|
67
|
+
const signature = signHmacHex(payloadStr, webhookSecret);
|
|
72
68
|
upstream.send(JSON.stringify({
|
|
73
69
|
payload: payloadStr,
|
|
74
70
|
headers: {
|
|
@@ -117,12 +113,12 @@ function attachSocialWebSocket(server, partial) {
|
|
|
117
113
|
return () => server.off('upgrade', onUpgrade);
|
|
118
114
|
}
|
|
119
115
|
/** Exported for tests — builds the WS envelope the upstream expects. */
|
|
120
|
-
function buildWsEnvelope(payload, secret) {
|
|
116
|
+
export function buildWsEnvelope(payload, secret) {
|
|
121
117
|
const payloadStr = JSON.stringify(payload);
|
|
122
118
|
return {
|
|
123
119
|
payload: payloadStr,
|
|
124
120
|
headers: {
|
|
125
|
-
'x-urano-signature':
|
|
121
|
+
'x-urano-signature': signHmacHex(payloadStr, secret),
|
|
126
122
|
'Content-Type': 'application/json',
|
|
127
123
|
'X-Urano-Protocol-Version': '1',
|
|
128
124
|
},
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
export type SocialTransport = 'sse' | 'wait' | 'ws' | 'async';
|
|
2
2
|
export type WebhookAction = 'status' | 'events' | 'cancel';
|
|
3
|
+
/** Shared honeypot field name (safe to import from browser bundles). */
|
|
4
|
+
export declare const DEFAULT_HONEYPOT_FIELD = "website";
|
|
5
|
+
export declare const DEFAULT_RECAPTCHA_ACTION = "contact";
|
|
6
|
+
export interface ContactAntiSpamConfig {
|
|
7
|
+
/** Master switch for contact gates. Default true. */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
/** Hidden field name bots fill. Default `website`. */
|
|
10
|
+
honeypotField?: string;
|
|
11
|
+
/** Minimum ms between issuing a contact ticket and accepting submit. Default 2500. */
|
|
12
|
+
minSubmitAgeMs?: number;
|
|
13
|
+
/** Maximum ticket lifetime. Default 30 minutes. */
|
|
14
|
+
maxSubmitAgeMs?: number;
|
|
15
|
+
/** Max contact submits per visitor+IP per hour. Default 5. */
|
|
16
|
+
maxPerHour?: number;
|
|
17
|
+
/** Max message length. Default 4000. */
|
|
18
|
+
maxMessageChars?: number;
|
|
19
|
+
/** Google reCAPTCHA v3 secret (server-only). Env: URANO_SOCIAL_RECAPTCHA_SECRET */
|
|
20
|
+
recaptchaSecret?: string;
|
|
21
|
+
/** Minimum v3 score. Default 0.5. */
|
|
22
|
+
recaptchaMinScore?: number;
|
|
23
|
+
/** Expected grecaptcha action. Default `contact`. */
|
|
24
|
+
recaptchaAction?: string;
|
|
25
|
+
/**
|
|
26
|
+
* When a recaptcha secret is set, require a token (default true).
|
|
27
|
+
* Set false to verify only when the client sends a token.
|
|
28
|
+
*/
|
|
29
|
+
requireRecaptcha?: boolean;
|
|
30
|
+
}
|
|
3
31
|
export interface SocialProxyConfig {
|
|
4
32
|
webhookUrl: string;
|
|
5
33
|
webhookSecret: string;
|
|
@@ -9,6 +37,8 @@ export interface SocialProxyConfig {
|
|
|
9
37
|
cookieMaxAgeSec?: number;
|
|
10
38
|
/** Default display name forwarded as displayName when the client omits one. */
|
|
11
39
|
defaultDisplayName?: string;
|
|
40
|
+
/** Contact-form anti-spam (honeypot, ticket, rate limit, optional reCAPTCHA v3). */
|
|
41
|
+
antiSpam?: ContactAntiSpamConfig;
|
|
12
42
|
}
|
|
13
43
|
export interface SocialClientMessage {
|
|
14
44
|
id: string;
|
package/dist/types.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/** Shared honeypot field name (safe to import from browser bundles). */
|
|
2
|
+
export const DEFAULT_HONEYPOT_FIELD = 'website';
|
|
3
|
+
export const DEFAULT_RECAPTCHA_ACTION = 'contact';
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uranotools/urano-social",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Customer-support chat widget for SalesAgentConnector MCP webhooks (SSE / WebSocket / wait)",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"main": "dist/index.js",
|
|
6
7
|
"types": "dist/index.d.ts",
|
|
7
8
|
"scripts": {
|