aboutsummaryrefslogtreecommitdiffstats
path: root/routes/email.ts
blob: 749fba5e8b40b1aa287e4f83c912f1b7ff5f1fc7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import { bail, fail } from "$lib/utils";
import got from "got";
import config from "$config";
import { l, warn } from "$lib/logging";
import { SESClient } from "@aws-sdk/client-ses";
import { SendEmailCommand } from "@aws-sdk/client-ses";

// reCAPTCHA v3 never challenges: siteverify returns success:true for any
// well-formed token and reports how human the request looked in `score`.
// Checking `success` alone let a headless scanner submit freely.
const MIN_SCORE = 0.5;

const escapeHtml = (s: string) =>
  s
    .replace(/&/g, "&")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");

// Strip CR/LF and other control characters from single-line header values.
const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f]+/g, " ").trim();

export default {
  async send({ body, headers }, res) {
    try {
      const { email, message, username, token: response } = body;
      if (typeof message !== "string" || !message.trim()) fail("Message required");
      if (typeof email !== "string" || !email.trim()) fail("Email required");
      if (username != null && typeof username !== "string") fail("Invalid username");
      if (typeof response !== "string" || !response) fail("failed captcha");

      const Charset = "UTF-8";

      const { recaptcha: secret } = config;
      const remoteip = headers?.["cf-connecting-ip"];
      const { success, score, action } = await got
        .post("https://www.google.com/recaptcha/api/siteverify", {
          form: { secret, response, ...(remoteip && { remoteip }) },
          timeout: { request: 6000 },
        })
        .json<any>();

      if (!success || action !== "submit" || !(score >= MIN_SCORE)) {
        warn("support request rejected", email, "score", score, "action", action);
        fail("failed captcha");
      }

      l("support request from", email, "score", score);
      const name = username ? oneLine(username).slice(0, 100) : "";
      const client = new SESClient({ region: "us-east-2" });
      await client.send(
        new SendEmailCommand({
          Destination: {
            CcAddresses: [],
            ToAddresses: [config.support],
          },
          Message: {
            Body: {
              Html: { Charset, Data: escapeHtml(message).replace(/\n/g, "<br>") },
              Text: { Charset, Data: message },
            },
            Subject: {
              Charset,
              Data: `Support Request${name ? ` From ${name}` : ""}`,
            },
          },
          ReplyToAddresses: [oneLine(email)],
          Source: config.support,
        }),
      );

      res.send({ ok: true });
    } catch (e) {
      bail(res, e.message);
    }
  },
};