@uranotools/urano-social 0.0.0-stage → 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 +22 -0
- package/LICENSE +21 -0
- package/README.md +212 -2
- package/dist/client/createUranoChat.d.ts +16 -0
- package/dist/client/createUranoChat.js +223 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +4 -0
- package/dist/client/recaptcha.d.ts +14 -0
- package/dist/client/recaptcha.js +36 -0
- package/dist/client/sse.d.ts +3 -0
- package/dist/client/sse.js +104 -0
- package/dist/client/submitContactForm.d.ts +61 -0
- package/dist/client/submitContactForm.js +133 -0
- package/dist/crypto.d.ts +5 -0
- package/dist/crypto.js +35 -0
- package/dist/flavors/UranoSocialChatElement.d.ts +13 -0
- package/dist/flavors/UranoSocialChatElement.js +173 -0
- package/dist/flavors/UranoSocialContactFormElement.d.ts +9 -0
- package/dist/flavors/UranoSocialContactFormElement.js +103 -0
- package/dist/flavors/styles.css +257 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +10 -0
- package/dist/react/UranoSocialChat.d.ts +38 -0
- package/dist/react/UranoSocialChat.js +81 -0
- package/dist/react/UranoSocialContactForm.d.ts +19 -0
- package/dist/react/UranoSocialContactForm.js +78 -0
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.js +2 -0
- package/dist/server/antiSpam.d.ts +76 -0
- package/dist/server/antiSpam.js +204 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/proxy.d.ts +16 -0
- package/dist/server/proxy.js +253 -0
- package/dist/server/visitorCookie.d.ts +11 -0
- package/dist/server/visitorCookie.js +38 -0
- package/dist/server/websocket.d.ts +21 -0
- package/dist/server/websocket.js +126 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.js +3 -0
- package/package.json +92 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
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';
|
|
@@ -0,0 +1,4 @@
|
|
|
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';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SocialProxyConfig } from '../types';
|
|
2
|
+
export interface SocialProxyHandlers {
|
|
3
|
+
/** Fetch/Web-standard handler for App Router / Astro / edge-compatible hosts. */
|
|
4
|
+
handle: (request: Request) => Promise<Response>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Creates a site-local proxy that signs webhook traffic with the server secret.
|
|
8
|
+
* The browser never receives WEBHOOK_SECRET.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createSocialProxy(partial?: Partial<SocialProxyConfig>): SocialProxyHandlers;
|
|
11
|
+
/** Convenience for Next.js App Router route handlers. */
|
|
12
|
+
export declare function socialProxyRoute(partial?: Partial<SocialProxyConfig>): {
|
|
13
|
+
GET: (request: Request) => Promise<Response>;
|
|
14
|
+
POST: (request: Request) => Promise<Response>;
|
|
15
|
+
OPTIONS: (request: Request) => Promise<Response>;
|
|
16
|
+
};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { signHmacHex } from '../crypto';
|
|
2
|
+
import { resolveVisitorId, DEFAULT_COOKIE_NAME } from './visitorCookie';
|
|
3
|
+
import { enforceContactAntiSpam, issueContactTicket, resolveAntiSpamConfig, } from './antiSpam';
|
|
4
|
+
function envConfig(partial) {
|
|
5
|
+
const webhookUrl = partial?.webhookUrl || process.env.URANO_SOCIAL_WEBHOOK_URL || '';
|
|
6
|
+
const webhookSecret = partial?.webhookSecret || process.env.URANO_SOCIAL_WEBHOOK_SECRET || '';
|
|
7
|
+
if (!webhookUrl)
|
|
8
|
+
throw new Error('URANO_SOCIAL_WEBHOOK_URL is required');
|
|
9
|
+
if (!webhookSecret)
|
|
10
|
+
throw new Error('URANO_SOCIAL_WEBHOOK_SECRET is required');
|
|
11
|
+
return {
|
|
12
|
+
webhookUrl: webhookUrl.replace(/\/$/, ''),
|
|
13
|
+
webhookSecret,
|
|
14
|
+
cookieSecret: partial?.cookieSecret || process.env.URANO_SOCIAL_COOKIE_SECRET || webhookSecret,
|
|
15
|
+
cookieName: partial?.cookieName || DEFAULT_COOKIE_NAME,
|
|
16
|
+
cookieMaxAgeSec: partial?.cookieMaxAgeSec,
|
|
17
|
+
defaultDisplayName: partial?.defaultDisplayName,
|
|
18
|
+
antiSpam: resolveAntiSpamConfig(partial?.antiSpam),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function json(status, body, headers) {
|
|
22
|
+
return new Response(JSON.stringify(body), {
|
|
23
|
+
status,
|
|
24
|
+
headers: { 'Content-Type': 'application/json', ...(headers || {}) },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
async function readJson(request) {
|
|
28
|
+
const text = await request.text();
|
|
29
|
+
if (!text)
|
|
30
|
+
return {};
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error('invalid_json');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Creates a site-local proxy that signs webhook traffic with the server secret.
|
|
40
|
+
* The browser never receives WEBHOOK_SECRET.
|
|
41
|
+
*/
|
|
42
|
+
export function createSocialProxy(partial) {
|
|
43
|
+
const config = envConfig(partial);
|
|
44
|
+
const antiSpam = resolveAntiSpamConfig(config.antiSpam);
|
|
45
|
+
async function handle(request) {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
const method = request.method.toUpperCase();
|
|
48
|
+
if (method === 'OPTIONS') {
|
|
49
|
+
return new Response(null, {
|
|
50
|
+
status: 204,
|
|
51
|
+
headers: {
|
|
52
|
+
'Access-Control-Allow-Origin': request.headers.get('Origin') || '*',
|
|
53
|
+
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
|
54
|
+
'Access-Control-Allow-Headers': 'Content-Type, Idempotency-Key, Last-Event-ID, Accept',
|
|
55
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const visitor = resolveVisitorId({
|
|
60
|
+
cookieHeader: request.headers.get('cookie'),
|
|
61
|
+
cookieName: config.cookieName,
|
|
62
|
+
cookieSecret: config.cookieSecret,
|
|
63
|
+
});
|
|
64
|
+
const responseHeaders = new Headers();
|
|
65
|
+
if (visitor.setCookie)
|
|
66
|
+
responseHeaders.append('Set-Cookie', visitor.setCookie);
|
|
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) {
|
|
81
|
+
try {
|
|
82
|
+
const discovery = await fetch(config.webhookUrl, { method: 'GET' });
|
|
83
|
+
const body = await discovery.json().catch(() => ({ ok: discovery.ok }));
|
|
84
|
+
responseHeaders.set('Content-Type', 'application/json');
|
|
85
|
+
return new Response(JSON.stringify({
|
|
86
|
+
ok: true,
|
|
87
|
+
proxy: true,
|
|
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
|
+
},
|
|
98
|
+
upstream: body,
|
|
99
|
+
}), { status: 200, headers: responseHeaders });
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return json(502, { error: 'upstream_unreachable', message: error?.message }, responseHeaders);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let clientBody = {};
|
|
106
|
+
try {
|
|
107
|
+
if (method === 'GET') {
|
|
108
|
+
const payloadParam = url.searchParams.get('payload');
|
|
109
|
+
clientBody = payloadParam ? JSON.parse(payloadParam) : {};
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
clientBody = await readJson(request);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return json(400, { error: 'invalid_json' }, responseHeaders);
|
|
117
|
+
}
|
|
118
|
+
const mode = String(url.searchParams.get('mode')
|
|
119
|
+
|| clientBody.mode
|
|
120
|
+
|| ((request.headers.get('Accept') || '').includes('text/event-stream') ? 'sse' : 'wait'));
|
|
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
|
+
}
|
|
147
|
+
const outbound = isContact
|
|
148
|
+
? { type: 'contact' }
|
|
149
|
+
: {
|
|
150
|
+
visitorId: visitor.visitorId,
|
|
151
|
+
from: visitor.visitorId,
|
|
152
|
+
};
|
|
153
|
+
if (isContact) {
|
|
154
|
+
if (typeof clientBody.email === 'string')
|
|
155
|
+
outbound.email = clientBody.email.trim().toLowerCase();
|
|
156
|
+
if (typeof clientBody.name === 'string')
|
|
157
|
+
outbound.name = clientBody.name;
|
|
158
|
+
if (typeof clientBody.subject === 'string')
|
|
159
|
+
outbound.subject = clientBody.subject;
|
|
160
|
+
if (typeof clientBody.message === 'string')
|
|
161
|
+
outbound.message = clientBody.message;
|
|
162
|
+
else if (typeof clientBody.text === 'string')
|
|
163
|
+
outbound.message = clientBody.text;
|
|
164
|
+
if (typeof clientBody.phone === 'string')
|
|
165
|
+
outbound.phone = clientBody.phone;
|
|
166
|
+
if (typeof clientBody.company === 'string')
|
|
167
|
+
outbound.company = clientBody.company;
|
|
168
|
+
// Correlate without trusting client visitor identity for CRM email channel.
|
|
169
|
+
outbound.visitorId = visitor.visitorId;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
if (typeof clientBody.text === 'string')
|
|
173
|
+
outbound.text = clientBody.text;
|
|
174
|
+
if (typeof clientBody.sessionId === 'string')
|
|
175
|
+
outbound.sessionId = clientBody.sessionId;
|
|
176
|
+
if (typeof clientBody.SessionId === 'string')
|
|
177
|
+
outbound.sessionId = clientBody.SessionId;
|
|
178
|
+
if (typeof clientBody.displayName === 'string')
|
|
179
|
+
outbound.displayName = clientBody.displayName;
|
|
180
|
+
else if (config.defaultDisplayName)
|
|
181
|
+
outbound.displayName = config.defaultDisplayName;
|
|
182
|
+
if (clientBody.webhookAction)
|
|
183
|
+
outbound.webhookAction = clientBody.webhookAction;
|
|
184
|
+
if (clientBody.turnId)
|
|
185
|
+
outbound.turnId = clientBody.turnId;
|
|
186
|
+
if (clientBody.after)
|
|
187
|
+
outbound.after = clientBody.after;
|
|
188
|
+
if (clientBody.idempotencyKey)
|
|
189
|
+
outbound.idempotencyKey = clientBody.idempotencyKey;
|
|
190
|
+
}
|
|
191
|
+
// Never trust a visitorId / from supplied by the browser for chat identity.
|
|
192
|
+
delete clientBody.visitorId;
|
|
193
|
+
delete clientBody.from;
|
|
194
|
+
const rawBody = JSON.stringify(outbound);
|
|
195
|
+
const signature = signHmacHex(rawBody, config.webhookSecret);
|
|
196
|
+
const idempotency = request.headers.get('Idempotency-Key')
|
|
197
|
+
|| (typeof outbound.idempotencyKey === 'string' ? outbound.idempotencyKey : undefined);
|
|
198
|
+
const upstreamHeaders = {
|
|
199
|
+
'Content-Type': 'application/json',
|
|
200
|
+
'x-urano-signature': signature,
|
|
201
|
+
'X-Urano-Protocol-Version': '1',
|
|
202
|
+
};
|
|
203
|
+
if (idempotency)
|
|
204
|
+
upstreamHeaders['Idempotency-Key'] = idempotency;
|
|
205
|
+
const lastEventId = request.headers.get('Last-Event-ID');
|
|
206
|
+
if (lastEventId)
|
|
207
|
+
upstreamHeaders['Last-Event-ID'] = lastEventId;
|
|
208
|
+
let upstreamUrl = `${config.webhookUrl}?mode=${encodeURIComponent(mode === 'auto' ? 'sse' : mode)}`;
|
|
209
|
+
let upstreamInit;
|
|
210
|
+
if (method === 'GET') {
|
|
211
|
+
upstreamUrl += `&payload=${encodeURIComponent(rawBody)}`;
|
|
212
|
+
upstreamInit = { method: 'GET', headers: upstreamHeaders };
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
upstreamInit = { method: 'POST', headers: upstreamHeaders, body: rawBody };
|
|
216
|
+
}
|
|
217
|
+
if (mode === 'sse' || request.headers.get('Accept')?.includes('text/event-stream')) {
|
|
218
|
+
upstreamHeaders['Accept'] = 'text/event-stream';
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const upstream = await fetch(upstreamUrl, upstreamInit);
|
|
222
|
+
upstream.headers.forEach((value, key) => {
|
|
223
|
+
const lower = key.toLowerCase();
|
|
224
|
+
if (['transfer-encoding', 'content-encoding', 'content-length', 'set-cookie'].includes(lower))
|
|
225
|
+
return;
|
|
226
|
+
responseHeaders.set(key, value);
|
|
227
|
+
});
|
|
228
|
+
responseHeaders.set('X-Urano-Social-Visitor', visitor.visitorId);
|
|
229
|
+
responseHeaders.set('Cache-Control', 'no-cache, no-transform');
|
|
230
|
+
if (mode === 'sse') {
|
|
231
|
+
responseHeaders.set('Content-Type', 'text/event-stream; charset=utf-8');
|
|
232
|
+
responseHeaders.set('X-Accel-Buffering', 'no');
|
|
233
|
+
}
|
|
234
|
+
return new Response(upstream.body, {
|
|
235
|
+
status: upstream.status,
|
|
236
|
+
headers: responseHeaders,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
return json(502, { error: 'upstream_unreachable', message: error?.message }, responseHeaders);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { handle };
|
|
244
|
+
}
|
|
245
|
+
/** Convenience for Next.js App Router route handlers. */
|
|
246
|
+
export function socialProxyRoute(partial) {
|
|
247
|
+
const proxy = createSocialProxy(partial);
|
|
248
|
+
return {
|
|
249
|
+
GET: (request) => proxy.handle(request),
|
|
250
|
+
POST: (request) => proxy.handle(request),
|
|
251
|
+
OPTIONS: (request) => proxy.handle(request),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const DEFAULT_COOKIE_NAME = "urano_social_vid";
|
|
2
|
+
export declare function parseCookies(header: string | null | undefined): Record<string, string>;
|
|
3
|
+
export declare function resolveVisitorId(opts: {
|
|
4
|
+
cookieHeader?: string | null;
|
|
5
|
+
cookieName?: string;
|
|
6
|
+
cookieSecret: string;
|
|
7
|
+
}): {
|
|
8
|
+
visitorId: string;
|
|
9
|
+
isNew: boolean;
|
|
10
|
+
setCookie?: string;
|
|
11
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { randomId, signCookieValue, verifyCookieValue } from '../crypto';
|
|
2
|
+
export const DEFAULT_COOKIE_NAME = 'urano_social_vid';
|
|
3
|
+
export function parseCookies(header) {
|
|
4
|
+
const out = {};
|
|
5
|
+
if (!header)
|
|
6
|
+
return out;
|
|
7
|
+
for (const part of header.split(';')) {
|
|
8
|
+
const idx = part.indexOf('=');
|
|
9
|
+
if (idx < 0)
|
|
10
|
+
continue;
|
|
11
|
+
const key = part.slice(0, idx).trim();
|
|
12
|
+
const value = part.slice(idx + 1).trim();
|
|
13
|
+
if (key)
|
|
14
|
+
out[key] = decodeURIComponent(value);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
export function resolveVisitorId(opts) {
|
|
19
|
+
const name = opts.cookieName || DEFAULT_COOKIE_NAME;
|
|
20
|
+
const cookies = parseCookies(opts.cookieHeader);
|
|
21
|
+
const existing = cookies[name];
|
|
22
|
+
if (existing) {
|
|
23
|
+
const verified = verifyCookieValue(existing, opts.cookieSecret);
|
|
24
|
+
if (verified)
|
|
25
|
+
return { visitorId: verified, isNew: false };
|
|
26
|
+
}
|
|
27
|
+
const visitorId = randomId('vis');
|
|
28
|
+
const signed = signCookieValue(visitorId, opts.cookieSecret);
|
|
29
|
+
const maxAge = 60 * 60 * 24 * 365;
|
|
30
|
+
const setCookie = [
|
|
31
|
+
`${name}=${encodeURIComponent(signed)}`,
|
|
32
|
+
'Path=/',
|
|
33
|
+
`Max-Age=${maxAge}`,
|
|
34
|
+
'HttpOnly',
|
|
35
|
+
'SameSite=Lax',
|
|
36
|
+
].join('; ');
|
|
37
|
+
return { visitorId, isNew: true, setCookie };
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import type { SocialProxyConfig } from '../types';
|
|
3
|
+
export interface AttachSocialWebSocketOptions extends Partial<SocialProxyConfig> {
|
|
4
|
+
/** Path on the site server that accepts WS upgrades, default `/api/urano-social/ws`. */
|
|
5
|
+
path?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Attaches an optional WebSocket bridge to a Node HTTP server.
|
|
9
|
+
* Next App Router cannot upgrade sockets in a route handler — use SSE there.
|
|
10
|
+
* Astro Node / custom servers can call this after `http.createServer`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function attachSocialWebSocket(server: http.Server, partial?: AttachSocialWebSocketOptions): () => void;
|
|
13
|
+
/** Exported for tests — builds the WS envelope the upstream expects. */
|
|
14
|
+
export declare function buildWsEnvelope(payload: Record<string, unknown>, secret: string): {
|
|
15
|
+
payload: string;
|
|
16
|
+
headers: {
|
|
17
|
+
'x-urano-signature': string;
|
|
18
|
+
'Content-Type': string;
|
|
19
|
+
'X-Urano-Protocol-Version': string;
|
|
20
|
+
};
|
|
21
|
+
};
|