Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,9 @@ WORDPRESS_API_URL=https://wp.keploy.io/graphql
# override here only if you need a different telemetry endpoint or key.
# NEXT_PUBLIC_TELEMETRY_URL=https://telemetry.keploy.io
# NEXT_PUBLIC_RECAPTCHA_SITE_KEY=<score-based reCAPTCHA Enterprise key>

# Google Chat notification for newsletter/lead form submissions. Server-only
# secret (NOT NEXT_PUBLIC_*) — never exposed to the browser. Create it in the
# Chat space: Apps & integrations → Webhooks → add → copy the URL here.
# If unset, submissions still succeed but no Chat message is delivered.
# GOOGLE_CHAT_WEBHOOK_URL=https://chat.googleapis.com/v1/spaces/.../messages?key=...&token=...
32 changes: 32 additions & 0 deletions components/subscribe-newsletter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useRef, useState } from "react";
import { useRouter } from "next/router";
import styles from "./subscribe-newsletter.module.css";
import { newsLetterSubscriptionUrl } from '../services/constants'
import { useInvisibleRecaptcha, RecaptchaAttribution } from "../lib/use-invisible-recaptcha";
Expand Down Expand Up @@ -39,11 +40,14 @@ export const subscribeMutation = (formData: { fullName: string, email: string, c


export default function SubscribeNewsletter(props: { isSmallScreen?: boolean }) {
const router = useRouter();
const myComponent = useRef<HTMLDivElement>(null);
const [isVisible, setVisible] = useState<boolean>(true);
const [fullName, setFullName] = useState('');
const [email, setEmail] = useState('');
const [companyName, setCompanyName] = useState('');
// Honeypot — hidden from humans; bots that auto-fill every field trip it.
const [companyWebsite, setCompanyWebsite] = useState('');
const [subscribed, setSubscribed] = useState<boolean>(false);
const [emailError, setEmailError] = useState('');
const message = "NEWSLETTER"
Expand Down Expand Up @@ -108,6 +112,22 @@ export default function SubscribeNewsletter(props: { isSmallScreen?: boolean })
});
});

// Google Chat notification — fire-and-forget, fail-open. Forwards the lead
Comment thread
dhananjay6561 marked this conversation as resolved.
// to a Chat space via the server-side /api/blog-lead-notify handler (which
// holds the webhook secret). Never gates the subscription.
fetch(`${router.basePath || ''}/api/blog-lead-notify`, {
Comment thread
dhananjay6561 marked this conversation as resolved.
Comment thread
dhananjay6561 marked this conversation as resolved.
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify({
fullName: fullName.trim(),
email: email.trim().toLowerCase(),
companyName: companyName.trim(),
company_website: companyWebsite,
page,
}),
}).catch(() => {});

handleSubscribe(payload)
};
const isSubscribeDisabled = ()=>{
Expand All @@ -129,6 +149,18 @@ export default function SubscribeNewsletter(props: { isSmallScreen?: boolean })
onSubmit={submitHandler}
onFocusCapture={() => setCaptchaActive(true)}
>
{/* Honeypot — hidden from humans (off-screen, not tabbable, no
autofill); a filled value marks the submit as a bot. */}
<input
type="text"
name="company_website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
value={companyWebsite}
onChange={(e) => setCompanyWebsite(e.target.value)}
style={{ position: "absolute", left: "-9999px", width: 1, height: 1, opacity: 0 }}
/>
<input
type="text"
className="rounded px-4 py-2 border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
Expand Down
139 changes: 139 additions & 0 deletions pages/api/blog-lead-notify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { NextApiRequest, NextApiResponse } from "next";

// Server-side handler for blog newsletter / lead submissions. It forwards each
// lead to a Google Chat space via an incoming webhook (GOOGLE_CHAT_WEBHOOK_URL).
// Nothing is persisted here — the newsletter subscription (api-server) and the
// MQL lead (telemetry /blog-mql) are still handled by their own paths. This is
// a notification-only side channel, mirroring the landing repo's trial form.
//
// Security model:
// • GOOGLE_CHAT_WEBHOOK_URL is a server-only secret (NOT NEXT_PUBLIC_*), so it
// never reaches the browser bundle. This handler runs server-side only, so
// the webhook URL is never exposed to the client, git, or logs.
// • Create it in Google Chat: open the space → Apps & integrations → Webhooks
// → add one → copy the URL into the env var. Rotate by deleting/recreating.
// • Abuse: a hidden honeypot field + a best-effort per-IP rate limit keep
// casual flooding out. Stronger gating (reCAPTCHA Enterprise verification,
// matching the /blog-mql path) is tracked as a follow-up.
// If the env var is unset the submission still succeeds for the user, but the
// lead is NOT delivered.

const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;

// Best-effort in-memory per-IP rate limit. On serverless this is per-instance,
// not global, so it caps bursts against a hot instance rather than guaranteeing
// a hard ceiling — enough to blunt casual flooding of the chat space without
// pulling in an external store.
const RATE_LIMIT_MAX = 5; // requests
const RATE_LIMIT_WINDOW_MS = 60_000; // per minute, per IP
const rateHits = new Map<string, number[]>();

function isRateLimited(ip: string): boolean {
const now = Date.now();
const cutoff = now - RATE_LIMIT_WINDOW_MS;
const hits = (rateHits.get(ip) || []).filter((t) => t > cutoff);
hits.push(now);
rateHits.set(ip, hits);
return hits.length > RATE_LIMIT_MAX;
}

function clientIp(req: NextApiRequest): string {
const fwd = req.headers["x-forwarded-for"];
const raw = Array.isArray(fwd) ? fwd[0] : fwd;
return (raw?.split(",")[0].trim() || req.socket.remoteAddress || "unknown");
}

// Strip characters that carry meaning in Google Chat `text` messages so user
// input can't inject formatting (*bold* / _italic_), fake clickable links
// (<url|label>), or break the layout with newlines. Also caps length.
function sanitize(value: string, max = 200): string {
return value
.replace(/[<>|*_`\r\n]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, max);
}

// Track the "webhook not configured" warning so it's logged once per instance
// instead of on every submit — the off state is expected until the env is set.
let warnedMissingWebhook = false;

export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({ ok: false, error: "method_not_allowed" });
}

const data = (req.body && typeof req.body === "object" ? req.body : {}) as Record<
string,
unknown
>;

// Honeypot — the form renders a hidden `company_website` input that humans
// never see; a filled value means a bot. Silently accept and drop.
if (data.company_website) {
Comment thread
dhananjay6561 marked this conversation as resolved.
Comment thread
dhananjay6561 marked this conversation as resolved.
return res.status(200).json({ ok: true, delivered: false });
}

if (isRateLimited(clientIp(req))) {
return res.status(429).json({ ok: false, error: "rate_limited" });
}

const name = String(data.fullName ?? "").trim();
const email = String(data.email ?? "").trim();
Comment thread
dhananjay6561 marked this conversation as resolved.
Outdated
// Re-validate server-side: a request could hit this endpoint directly and
// bypass the client-side checks.
if (!name || !EMAIL_RE.test(email)) {
Comment thread
dhananjay6561 marked this conversation as resolved.
Comment thread
dhananjay6561 marked this conversation as resolved.
return res.status(400).json({ ok: false, error: "validation" });
}

const lead = {
Comment thread
dhananjay6561 marked this conversation as resolved.
name: sanitize(name, 120),
email: sanitize(email, 254),
company: sanitize(String(data.companyName ?? ""), 160),
page: sanitize(String(data.page ?? ""), 500),
submittedAt: new Date().toISOString(),
};

const webhook = process.env.GOOGLE_CHAT_WEBHOOK_URL;
if (!webhook) {
// No PII in logs. Set GOOGLE_CHAT_WEBHOOK_URL to deliver leads to the space.
// Expected steady state until the env is set, so warn once, not every submit.
if (!warnedMissingWebhook) {
warnedMissingWebhook = true;
console.warn(
"[blog-lead] GOOGLE_CHAT_WEBHOOK_URL is not configured — leads accepted but NOT delivered. Set the env var to enable delivery.",
);
}
return res.status(200).json({ ok: true, delivered: false });
}

try {
// page is rendered as plain (sanitized) text, not a <url|label> link, so a
// direct POST can't inject a misleading clickable URL.
const text =
`*📨 New Keploy blog subscriber*\n` +
`*Name:* ${lead.name}\n` +
`*Email:* ${lead.email}\n` +
`*Company:* ${lead.company || "—"}\n` +
`*Page:* ${lead.page || "—"}\n` +
`*Submitted:* ${lead.submittedAt}`;

const chatRes = await fetch(webhook, {
Comment thread
dhananjay6561 marked this conversation as resolved.
method: "POST",
headers: { "Content-Type": "application/json; charset=UTF-8" },
body: JSON.stringify({ text }),
});
return res.status(200).json({ ok: true, delivered: chatRes.ok });
} catch (err) {
console.error(
"[blog-lead] delivery to Google Chat failed — verify GOOGLE_CHAT_WEBHOOK_URL is a valid incoming-webhook URL. Lead was NOT delivered.",
err,
);
// Never fail the user — the newsletter subscription path is unaffected.
return res.status(200).json({ ok: true, delivered: false });
}
}
Loading