Compare commits
12 Commits
feat/conta
...
4d5e05061c
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d5e05061c | |||
| a7b80c8318 | |||
| 0ae4affe20 | |||
| 30e6c23bac | |||
| 5f0d87ce2a | |||
| beca15d9bf | |||
| 0b99bcb800 | |||
| 14333f90d1 | |||
| cfc564ce11 | |||
| 0c7d9e3bd2 | |||
| 28452a288e | |||
| 975254e933 |
@@ -7,6 +7,8 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: vps-host
|
runs-on: vps-host
|
||||||
|
env:
|
||||||
|
CONTACT_HEALTH_URL: https://jodyholt.com/api/health
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -44,8 +46,19 @@ jobs:
|
|||||||
|
|
||||||
- name: Restart Contact API
|
- name: Restart Contact API
|
||||||
run: |
|
run: |
|
||||||
systemctl restart jody-contact-api
|
set -euo pipefail
|
||||||
systemctl is-active --quiet jody-contact-api
|
SYSTEMCTL_BIN="/usr/bin/systemctl"
|
||||||
|
if [ ! -x "$SYSTEMCTL_BIN" ]; then
|
||||||
|
SYSTEMCTL_BIN="/bin/systemctl"
|
||||||
|
fi
|
||||||
|
sudo -n "$SYSTEMCTL_BIN" restart jody-contact-api
|
||||||
|
sudo -n "$SYSTEMCTL_BIN" is-active --quiet jody-contact-api
|
||||||
|
echo "jody-contact-api service is active"
|
||||||
|
|
||||||
- name: Health Check Contact API
|
- name: Health Check Contact API
|
||||||
run: curl --fail --silent http://127.0.0.1:8787/health
|
run: |
|
||||||
|
curl --fail --show-error --silent \
|
||||||
|
--retry 8 \
|
||||||
|
--retry-delay 2 \
|
||||||
|
--retry-all-errors \
|
||||||
|
"$CONTACT_HEALTH_URL"
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -19,9 +19,7 @@ contact-api/dist/
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
|
||||||
.env.*.local
|
|
||||||
contact-api/.env
|
|
||||||
|
|
||||||
# OS generated
|
# OS generated
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const boolFromEnv = z
|
|||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
PORT: z.coerce.number().int().positive().default(8787),
|
PORT: z.coerce.number().int().positive().default(8787),
|
||||||
CONTACT_ALLOWED_ORIGIN: z.string().url(),
|
// Comma-separated list of allowed browser origins, e.g.
|
||||||
|
// https://jodyholt.com,https://www.jodyholt.com
|
||||||
|
CONTACT_ALLOWED_ORIGIN: z.string().min(1),
|
||||||
TURNSTILE_SECRET_KEY: z.string().min(1),
|
TURNSTILE_SECRET_KEY: z.string().min(1),
|
||||||
TURNSTILE_EXPECTED_HOSTNAME: z.string().min(1),
|
TURNSTILE_EXPECTED_HOSTNAME: z.string().min(1),
|
||||||
TURNSTILE_EXPECTED_ACTION: z.string().min(1).default("contact_form"),
|
TURNSTILE_EXPECTED_ACTION: z.string().min(1).default("contact_form"),
|
||||||
|
|||||||
@@ -16,6 +16,25 @@ type ApiErrorResponse = {
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.set("trust proxy", 1);
|
app.set("trust proxy", 1);
|
||||||
|
|
||||||
|
const normalizeOrigin = (value: string): string => {
|
||||||
|
const cleaned = value
|
||||||
|
.trim()
|
||||||
|
.replace(/^['"]|['"]$/g, "")
|
||||||
|
.replace(/\/+$/g, "");
|
||||||
|
|
||||||
|
// Some clients can emit explicit default ports. URL.origin normalizes them.
|
||||||
|
try {
|
||||||
|
return new URL(cleaned).origin.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return cleaned.toLowerCase();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedOrigins = config.CONTACT_ALLOWED_ORIGIN
|
||||||
|
.split(",")
|
||||||
|
.map(normalizeOrigin)
|
||||||
|
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
pinoHttp({
|
pinoHttp({
|
||||||
level: config.NODE_ENV === "production" ? "info" : "debug",
|
level: config.NODE_ENV === "production" ? "info" : "debug",
|
||||||
@@ -30,7 +49,13 @@ app.use(helmet());
|
|||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin(origin, callback) {
|
origin(origin, callback) {
|
||||||
if (!origin || origin === config.CONTACT_ALLOWED_ORIGIN) {
|
if (!origin) {
|
||||||
|
callback(null, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedOrigin = normalizeOrigin(origin);
|
||||||
|
if (allowedOrigins.includes(normalizedOrigin)) {
|
||||||
callback(null, true);
|
callback(null, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ type TurnstileVerifyResponse = {
|
|||||||
"error-codes"?: string[];
|
"error-codes"?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeHostname = (value: string): string =>
|
||||||
|
value
|
||||||
|
.trim()
|
||||||
|
.replace(/^['"]|['"]$/g, "")
|
||||||
|
.replace(/\.+$/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
const expectedHostnames = config.TURNSTILE_EXPECTED_HOSTNAME
|
||||||
|
.split(",")
|
||||||
|
.map(normalizeHostname)
|
||||||
|
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||||
|
|
||||||
export async function verifyTurnstileToken(
|
export async function verifyTurnstileToken(
|
||||||
token: string,
|
token: string,
|
||||||
remoteIp?: string,
|
remoteIp?: string,
|
||||||
@@ -36,7 +48,7 @@ export async function verifyTurnstileToken(
|
|||||||
return { ok: false, reason: codes };
|
return { ok: false, reason: codes };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.hostname !== config.TURNSTILE_EXPECTED_HOSTNAME) {
|
if (!result.hostname || !expectedHostnames.includes(normalizeHostname(result.hostname))) {
|
||||||
return { ok: false, reason: "hostname_mismatch" };
|
return { ok: false, reason: "hostname_mismatch" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -297,9 +297,7 @@ export function Contact() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div ref={turnstileContainerRef} className="min-h-[66px]" />
|
<div ref={turnstileContainerRef} className="min-h-[66px]" />
|
||||||
{!TURNSTILE_SITE_KEY && (
|
|
||||||
<p className="text-xs text-contrast">Set `VITE_TURNSTILE_SITE_KEY` to enable submissions.</p>
|
|
||||||
)}
|
|
||||||
{TURNSTILE_SITE_KEY && !turnstileReady && (
|
{TURNSTILE_SITE_KEY && !turnstileReady && (
|
||||||
<p className="text-xs text-text/65">Loading human verification...</p>
|
<p className="text-xs text-text/65">Loading human verification...</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user