Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
228240ec2d | ||
|
|
87f0443b31 | ||
|
|
7d9c0014ed | ||
|
|
3caf1e30b0 | ||
|
|
b4ef9fd2e0 | ||
|
|
66f63523e2 | ||
|
|
9a84d4b78c | ||
|
|
f559a28b74 | ||
|
|
4e82497640 | ||
|
|
136c0e61db | ||
|
|
39507d4bd8 | ||
|
|
0fb0877a92 | ||
|
|
6f7efed5e5 | ||
|
|
594aa96dc7 | ||
|
|
d5a2914915 | ||
|
|
9181ab2fed | ||
|
|
31b9e665b5 | ||
|
|
2a56bd1b3a | ||
|
|
5489adf81e | ||
|
|
6975f5aeab | ||
|
|
da75479555 | ||
|
|
261bed6e25 | ||
|
|
0c4d1dd9c2 | ||
|
|
584a4325af | ||
|
|
fec156f6d3 | ||
|
|
fb85b0d834 | ||
|
|
8f86e13dfc |
@@ -1,2 +0,0 @@
|
|||||||
VITE_CONTACT_API_URL=/api/contact
|
|
||||||
VITE_TURNSTILE_SITE_KEY=0x4AAAAAACfQya1R13nGeuOy
|
|
||||||
@@ -7,8 +7,6 @@ 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
|
||||||
@@ -46,19 +44,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Restart Contact API
|
- name: Restart Contact API
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
systemctl restart jody-contact-api
|
||||||
SYSTEMCTL_BIN="/usr/bin/systemctl"
|
systemctl is-active --quiet jody-contact-api
|
||||||
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: |
|
run: curl --fail --silent http://127.0.0.1:8787/health
|
||||||
curl --fail --show-error --silent \
|
|
||||||
--retry 8 \
|
|
||||||
--retry-delay 2 \
|
|
||||||
--retry-all-errors \
|
|
||||||
"$CONTACT_HEALTH_URL"
|
|
||||||
|
|||||||
+21
-28
@@ -1,42 +1,35 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
node_modules/
|
||||||
contact-api/node_modules/
|
|
||||||
|
|
||||||
# Build artifacts
|
|
||||||
dist/
|
|
||||||
build/
|
|
||||||
contact-api/dist/
|
|
||||||
coverage/
|
|
||||||
*.tsbuildinfo
|
|
||||||
.vite/
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
# Environment files (keep examples)
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
contact-api/dist/
|
||||||
|
|
||||||
|
# Vite cache
|
||||||
|
.vite/
|
||||||
|
|
||||||
# Archives and local release bundles
|
# VSCode settings
|
||||||
*.tgz
|
|
||||||
*.tar
|
|
||||||
*.tar.gz
|
|
||||||
|
|
||||||
# Tool caches
|
|
||||||
.eslintcache
|
|
||||||
.nyc_output/
|
|
||||||
|
|
||||||
# Editor / IDE
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
|
||||||
*.swp
|
# Log files
|
||||||
*.swo
|
*.log
|
||||||
*~
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
contact-api/.env
|
||||||
|
|
||||||
# OS generated
|
# OS generated
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Optional: lock files (if you use one package manager)
|
||||||
|
# Uncomment the ones you're not using
|
||||||
|
# yarn.lock
|
||||||
|
# package-lock.json
|
||||||
|
# pnpm-lock.yamlw
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Jody Holt
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -84,11 +84,6 @@ Create `contact-api/.env` from `contact-api/.env.example` and set your real Turn
|
|||||||
- systemd unit template: `ops/jody-contact-api.service`
|
- systemd unit template: `ops/jody-contact-api.service`
|
||||||
- Nginx reverse proxy snippet: `ops/nginx-contact-api.conf`
|
- Nginx reverse proxy snippet: `ops/nginx-contact-api.conf`
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
|
||||||
See `LICENSE` for full text.
|
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
### Jody Holt
|
### Jody Holt
|
||||||
|
|||||||
Binary file not shown.
@@ -1,20 +0,0 @@
|
|||||||
NODE_ENV=production
|
|
||||||
PORT=8787
|
|
||||||
CONTACT_ALLOWED_ORIGIN=https://jodyholt.com,https://www.jodyholt.com
|
|
||||||
TURNSTILE_EXPECTED_HOSTNAME=jodyholt.com,www.jodyholt.com
|
|
||||||
TURNSTILE_SECRET_KEY=0x4AAAAAACfQyRwRzwsEMIfVtCSkjz7__Yc
|
|
||||||
TURNSTILE_EXPECTED_ACTION=contact_form
|
|
||||||
SMTP_HOST=mail.jodyholt.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_SECURE=false
|
|
||||||
SMTP_REQUIRE_TLS=true
|
|
||||||
SMTP_USER=portfolio-smtp
|
|
||||||
SMTP_PASS=portfolio124521!
|
|
||||||
MAIL_FROM_NAME=Portfolio Contact
|
|
||||||
MAIL_FROM_ADDRESS=contact@jodyholt.com
|
|
||||||
MAIL_TO_ADDRESS=you@jodyholt.com
|
|
||||||
MAIL_SUBJECT_PREFIX=[Portfolio Contact]
|
|
||||||
RATE_LIMIT_WINDOW_MS=600000
|
|
||||||
RATE_LIMIT_MAX=5
|
|
||||||
HONEYPOT_FIELD=website
|
|
||||||
MIN_SUBMIT_TIME_MS=3000
|
|
||||||
@@ -10,9 +10,7 @@ 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),
|
||||||
// Comma-separated list of allowed browser origins, e.g.
|
CONTACT_ALLOWED_ORIGIN: z.string().url(),
|
||||||
// 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,25 +16,6 @@ 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",
|
||||||
@@ -49,13 +30,7 @@ app.use(helmet());
|
|||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin(origin, callback) {
|
origin(origin, callback) {
|
||||||
if (!origin) {
|
if (!origin || origin === config.CONTACT_ALLOWED_ORIGIN) {
|
||||||
callback(null, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedOrigin = normalizeOrigin(origin);
|
|
||||||
if (allowedOrigins.includes(normalizedOrigin)) {
|
|
||||||
callback(null, true);
|
callback(null, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,6 @@ 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,
|
||||||
@@ -48,7 +36,7 @@ export async function verifyTurnstileToken(
|
|||||||
return { ok: false, reason: codes };
|
return { ok: false, reason: codes };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.hostname || !expectedHostnames.includes(normalizeHostname(result.hostname))) {
|
if (result.hostname !== config.TURNSTILE_EXPECTED_HOSTNAME) {
|
||||||
return { ok: false, reason: "hostname_mismatch" };
|
return { ok: false, reason: "hostname_mismatch" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -297,7 +297,9 @@ 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