Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion apps/frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,11 @@ REACT_APP_ENABLE_SERVICE_WORKER=false
REACT_APP_SIMULATE_TX=false
REACT_APP_ESTIMATOR_URI=https://simulator.sovryn.app

REACT_APP_DATADOG_CLIENT_TOKEN=
REACT_APP_DATADOG_CLIENT_TOKEN=

# Support form
REACT_APP_TURNSTILE_SITE_KEY=

# Netlify Function variables (configure in Netlify environment settings)
TURNSTILE_SECRET_KEY=
DISCORD_SUPPORT_WEBHOOK_URL=
3 changes: 3 additions & 0 deletions apps/frontend/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
base = "apps/frontend/"
publish = "build"
command = "yarn build"

[functions]
directory = "apps/frontend/netlify/functions"
197 changes: 197 additions & 0 deletions apps/frontend/netlify/functions/support-feedback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
const TURNSTILE_VERIFY_URL =
'https://challenges.cloudflare.com/turnstile/v0/siteverify';

const MAX_NAME_LENGTH = 80;
const MAX_EMAIL_LENGTH = 254;
const MAX_MESSAGE_LENGTH = 2000;
const MAX_DISCORD_DESCRIPTION_LENGTH = 3500;

const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
};

const JSON_HEADERS = {
...CORS_HEADERS,
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
};

const EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

const jsonResponse = (statusCode, payload) => ({
statusCode,
headers: JSON_HEADERS,
body: JSON.stringify(payload),
});

const normalizeText = (value, maxLength) =>
typeof value === 'string' ? value.trim().slice(0, maxLength) : '';

const escapeMentions = value => value.replace(/@/g, '@\u200b');

const truncateText = (value, maxLength) =>
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;

const getClientIp = event => {
const headers = event.headers || {};
const header =
headers['x-forwarded-for'] ||
headers['X-Forwarded-For'] ||
headers['x-nf-client-connection-ip'] ||
'';

return header.split(',')[0].trim();
};

const verifyTurnstile = async ({ token, secret, clientIp }) => {
const payload = new URLSearchParams();
payload.append('secret', secret);
payload.append('response', token);
if (clientIp) {
payload.append('remoteip', clientIp);
}

const response = await fetch(TURNSTILE_VERIFY_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: payload.toString(),
});

if (!response.ok) {
return false;
}

const result = await response.json().catch(() => null);
return !!result?.success;
};

const sendToDiscord = async ({ webhookUrl, name, email, message }) => {
const payload = {
allowed_mentions: {
parse: [],
},
embeds: [
{
title: 'New Sovryn Support Request',
color: 0xf7931a,
fields: [
{
name: 'Name',
value: escapeMentions(name),
inline: true,
},
{
name: 'Email',
value: escapeMentions(email),
inline: true,
},
],
description: escapeMentions(
truncateText(message, MAX_DISCORD_DESCRIPTION_LENGTH),
),
timestamp: new Date().toISOString(),
},
],
};

const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});

return response.ok;
};

exports.handler = async event => {
if (event.httpMethod === 'OPTIONS') {
return {
statusCode: 204,
headers: CORS_HEADERS,
body: '',
};
}

if (event.httpMethod !== 'POST') {
return jsonResponse(405, {
error: 'Method not allowed.',
code: 'method_not_allowed',
});
}

const webhookUrl = process.env.DISCORD_SUPPORT_WEBHOOK_URL;
const turnstileSecret = process.env.TURNSTILE_SECRET_KEY;

if (!webhookUrl || !turnstileSecret) {
return jsonResponse(500, {
error: 'Support service is not configured.',
code: 'not_configured',
});
}

let parsedBody = {};

try {
parsedBody = JSON.parse(event.body || '{}');
} catch {
return jsonResponse(400, {
error: 'Invalid request payload.',
code: 'invalid_payload',
});
}

const name = normalizeText(parsedBody.name, MAX_NAME_LENGTH);
const email = normalizeText(parsedBody.email, MAX_EMAIL_LENGTH);
const message = normalizeText(parsedBody.message, MAX_MESSAGE_LENGTH);
const turnstileToken = normalizeText(parsedBody.turnstileToken, 4096);

if (!name || !email || !message || !turnstileToken) {
return jsonResponse(400, {
error: 'Missing required fields.',
code: 'missing_fields',
});
}

if (!EMAIL_REGEX.test(email)) {
return jsonResponse(400, {
error: 'Invalid email address.',
code: 'invalid_email',
});
}

const captchaIsValid = await verifyTurnstile({
token: turnstileToken,
secret: turnstileSecret,
clientIp: getClientIp(event),
}).catch(() => false);

if (!captchaIsValid) {
return jsonResponse(403, {
error: 'Captcha verification failed. Please try again.',
code: 'captcha_failed',
});
}

const delivered = await sendToDiscord({
webhookUrl,
name,
email,
message,
}).catch(() => false);

if (!delivered) {
return jsonResponse(502, {
error: 'Unable to deliver your feedback right now. Please try again.',
code: 'discord_failed',
});
}

return jsonResponse(200, { ok: true });
};
40 changes: 1 addition & 39 deletions apps/frontend/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,9 @@
width: 100%;
height: 100%;
}
@media (max-width: 640px) {
#tiledesk-container.closed #tiledeskdiv {
bottom: -15px !important;
right: -15px !important;
}
}
</style>
<link rel="icon" href="%PUBLIC_URL%/sovryn_favicon.png" />
<link
href="%PUBLIC_URL%/sovryn_favicon_256.png"
rel="apple-touch-icon"
/>
<link href="%PUBLIC_URL%/sovryn_favicon_256.png" rel="apple-touch-icon" />
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet"
Expand All @@ -92,35 +83,6 @@
rel="stylesheet"
/>
<title>Sovryn - DeFi for bitcoin</title>
<% if (process.env.REACT_APP_TILEDESK_ID) { %>
<!-- Tiledesk Code -->
<script type="application/javascript">
window.tiledeskSettings = {
projectid: '%REACT_APP_TILEDESK_ID%',
};
(function (d, s, id) {
var w = window;
var d = document;
var i = function () {
i.c(arguments);
};
i.q = [];
i.c = function (args) {
i.q.push(args);
};
w.Tiledesk = i;
var js,
fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) return;
js = d.createElement(s);
js.id = id;
js.async = true;
js.src = 'https://widget.tiledesk.com/v6/launch.js';
fjs.parentNode.insertBefore(js, fjs);
})(document, 'script', 'tiledesk-jssdk');
</script>
<!-- End Tiledesk Code -->
<% } %>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
Expand Down
76 changes: 40 additions & 36 deletions apps/frontend/src/app/3_organisms/Footer/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { translations } from '../../../locales/i18n';
import { isStaging } from '../../../utils/helpers';
import { getChangelogUrl } from '../../../utils/helpers';
import { SupportFeedbackBadge } from '../SupportFeedbackBadge/SupportFeedbackBadge';

type FooterProps = {
showDashboardLink?: boolean;
Expand Down Expand Up @@ -76,42 +77,45 @@ export const Footer: FC<FooterProps> = ({ showDashboardLink }) => {
);

return (
<UIFooter
leftContent={
<SovrynLogo
image={Logo}
dataAttribute="footer-logo"
className="max-h-4 max-w-fit mr-2"
text="Powered by bitcoin"
link="/"
/>
}
links={
<div className="flex flex-row justify-center flex-wrap gap-x-6 gap-y-5">
{footerLinks.map(link => (
<Link
key={link.id}
href={link.href}
text={link.name}
openNewTab={link.openNewTab}
/>
))}
</div>
}
rightContent={
<div className="flex gap-x-2">
<div className="flex items-center text-xs justify-center">
<Link
href={getChangelogUrl(CURRENT_RELEASE.commit)}
text={`${t(
translations.footer.buildID,
)} ${CURRENT_RELEASE.commit?.substring(0, 7)}`}
openNewTab={true}
/>
<>
<UIFooter
leftContent={
<SovrynLogo
image={Logo}
dataAttribute="footer-logo"
className="max-h-4 max-w-fit mr-2"
text="Powered by bitcoin"
link="/"
/>
}
links={
<div className="flex flex-row justify-center flex-wrap gap-x-6 gap-y-5">
{footerLinks.map(link => (
<Link
key={link.id}
href={link.href}
text={link.name}
openNewTab={link.openNewTab}
/>
))}
</div>
<SocialLinks dataAttribute="footer-social" />
</div>
}
/>
}
rightContent={
<div className="flex gap-x-2">
<div className="flex items-center text-xs justify-center">
<Link
href={getChangelogUrl(CURRENT_RELEASE.commit)}
text={`${t(
translations.footer.buildID,
)} ${CURRENT_RELEASE.commit?.substring(0, 7)}`}
openNewTab={true}
/>
</div>
<SocialLinks dataAttribute="footer-social" />
</div>
}
/>
<SupportFeedbackBadge />
</>
);
};
Loading