Add Python form API + harden nginx web root

- New api/ Python service (stdlib only — no pip install, no packages):
  validates fields server-side, verifies reCAPTCHA, sends via Resend
  with idempotency key, rate-limited (5 req/IP/15min)
- Matches existing project tooling (build_locations.py, build_services.py)
- Front-end form.js stays vanilla JS, no JS frameworks anywhere
- docker-compose runs nginx + python:3.13-alpine api with healthcheck
- nginx proxies /api/ to Python service, strips prefix
- Dockerfile now copies only public folders into web root (was
  copying everything, exposing /Dockerfile, /build_*.py, /api/.env)
- nginx.conf denies dotfiles, .env, .conf, .yml, .py, .md, .txt
  and Dockerfile as defense in depth
- .dockerignore keeps sensitive files out of build context
- .gitignore protects api/.env and __pycache__ from being committed
This commit is contained in:
Concept Agent
2026-05-08 18:22:46 +02:00
parent b363d19da7
commit e0b9e27b90
9 changed files with 338 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.gitignore
.dockerignore
api
build_*.py
__pycache__
*.pyc
*.md
*.txt
review_*.png
docker-compose.yml
.DS_Store
+6
View File
@@ -0,0 +1,6 @@
api/.env
api/__pycache__/
__pycache__/
*.pyc
*.log
.DS_Store
+28
View File
@@ -0,0 +1,28 @@
DMARC RECORD FOR floorithardwoods.com
Add this at Cloudflare DNS:
Type: TXT
Name: _dmarc
Content: v=DMARC1; p=none; rua=mailto:dev@arisingmedia.us
Proxy: DNS only
TTL: Auto
------------------------------------------------------------
Just the value to paste:
v=DMARC1; p=none; rua=mailto:dev@arisingmedia.us
------------------------------------------------------------
WHAT IT DOES
- v=DMARC1: declares a DMARC policy (this alone fixes most Gmail spam-folder issues)
- p=none: monitor mode, does not reject anything yet
- rua=mailto:dev@arisingmedia.us: receives DMARC failure reports
AFTER ADDING
1. Wait ~5 minutes for DNS propagation
2. In Gmail, mark the previous test email as "Not Spam"
3. Send another test from the form to confirm inbox delivery
VERIFY IT IS LIVE
Run this in terminal after adding:
dig +short TXT _dmarc.floorithardwoods.com @8.8.8.8
You should see the value above echoed back.
+13 -1
View File
@@ -1,6 +1,18 @@
FROM nginx:alpine FROM nginx:alpine
# nginx config (server-only, not served as a static file)
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY . /usr/share/nginx/html/
# Copy only public website assets — everything else (api/, build scripts,
# Dockerfile, .env, docs, screenshots) stays out of the web root.
COPY index.html /usr/share/nginx/html/
COPY assets /usr/share/nginx/html/assets/
COPY components /usr/share/nginx/html/components/
COPY about /usr/share/nginx/html/about/
COPY blog /usr/share/nginx/html/blog/
COPY contact /usr/share/nginx/html/contact/
COPY locations /usr/share/nginx/html/locations/
COPY reviews /usr/share/nginx/html/reviews/
COPY services /usr/share/nginx/html/services/
EXPOSE 80 EXPOSE 80
+6
View File
@@ -0,0 +1,6 @@
RESEND_API_KEY=re_your_key_here
RECAPTCHA_SECRET=your_recaptcha_v3_secret_here
TO_EMAIL=floorithardwoods@gmail.com
FROM_EMAIL=estimates@floorithardwoodfloors.com
RECAPTCHA_MIN=0.5
PORT=3001
+5
View File
@@ -0,0 +1,5 @@
FROM python:3.13-alpine
WORKDIR /app
COPY server.py .
EXPOSE 3001
CMD ["python3", "-u", "server.py"]
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
Floor It estimate-form API.
Pure Python 3 standard library only — no pip install, no packages.
Handles POST /estimate from the static site, validates fields,
verifies reCAPTCHA, sends via Resend, with idempotency + rate limiting.
"""
import hashlib
import http.server
import json
import os
import re
import socketserver
import time
import urllib.parse
import urllib.request
PORT = int(os.environ.get("PORT", "3001"))
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
RECAPTCHA_SECRET = os.environ.get("RECAPTCHA_SECRET", "")
TO_EMAIL = os.environ.get("TO_EMAIL", "floorithardwoodfloors@gmail.com")
FROM_EMAIL = os.environ.get("FROM_EMAIL", "Floor It Hardwood Floors <webleads@floorithardwoods.com>")
RECAPTCHA_MIN = float(os.environ.get("RECAPTCHA_MIN", "0.5"))
PHONE_RE = re.compile(r"^\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}$")
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
# In-memory rate limit: 5 requests / IP / 15 minutes
RATE_MAP = {}
RATE_WINDOW = 15 * 60
RATE_MAX = 5
def sanitize(s):
if not isinstance(s, str):
return ""
s = s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
return s.strip()[:2000]
def validate_fields(body):
errors = []
if not body.get("name") or len((body.get("name") or "").strip()) < 2:
errors.append("name")
if not body.get("email") or not EMAIL_RE.match((body.get("email") or "").strip()):
errors.append("email")
phone_clean = (body.get("phone") or "").replace(" ", "")
if not phone_clean or not PHONE_RE.match(phone_clean):
errors.append("phone")
if not body.get("address") or len((body.get("address") or "").strip()) < 3:
errors.append("address")
if not (body.get("service") or "").strip():
errors.append("service")
return errors
def verify_recaptcha(token):
if not RECAPTCHA_SECRET or not token:
return 0.0
data = urllib.parse.urlencode({"secret": RECAPTCHA_SECRET, "response": token}).encode()
req = urllib.request.Request(
"https://www.google.com/recaptcha/api/siteverify",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
with urllib.request.urlopen(req, timeout=8) as resp:
return float(json.loads(resp.read()).get("score", 0))
except Exception:
return 0.0
def send_via_resend(fields):
safe = {k: sanitize(fields.get(k, "")) for k in
["name", "email", "phone", "address", "city", "zip", "service", "condition", "message"]}
html = f"""<!DOCTYPE html>
<html><body style="font-family:Arial,sans-serif;color:#222;max-width:600px;margin:0 auto;padding:24px;">
<p>A new estimate request was submitted on floorithardwoodfloors.com.</p>
<table cellpadding="8" style="border-collapse:collapse;border:1px solid #e5e5e5;width:100%;">
<tr><td style="border:1px solid #e5e5e5;background:#f7f7f7;"><strong>Name</strong></td><td style="border:1px solid #e5e5e5;">{safe['name']}</td></tr>
<tr><td style="border:1px solid #e5e5e5;background:#f7f7f7;"><strong>Email</strong></td><td style="border:1px solid #e5e5e5;">{safe['email']}</td></tr>
<tr><td style="border:1px solid #e5e5e5;background:#f7f7f7;"><strong>Phone</strong></td><td style="border:1px solid #e5e5e5;">{safe['phone']}</td></tr>
<tr><td style="border:1px solid #e5e5e5;background:#f7f7f7;"><strong>Address</strong></td><td style="border:1px solid #e5e5e5;">{safe['address']}</td></tr>
<tr><td style="border:1px solid #e5e5e5;background:#f7f7f7;"><strong>City</strong></td><td style="border:1px solid #e5e5e5;">{safe['city']}</td></tr>
<tr><td style="border:1px solid #e5e5e5;background:#f7f7f7;"><strong>Zip</strong></td><td style="border:1px solid #e5e5e5;">{safe['zip']}</td></tr>
<tr><td style="border:1px solid #e5e5e5;background:#f7f7f7;"><strong>Service</strong></td><td style="border:1px solid #e5e5e5;">{safe['service']}</td></tr>
<tr><td style="border:1px solid #e5e5e5;background:#f7f7f7;"><strong>Floor Condition</strong></td><td style="border:1px solid #e5e5e5;">{safe['condition']}</td></tr>
<tr><td style="border:1px solid #e5e5e5;background:#f7f7f7;vertical-align:top;"><strong>Message</strong></td><td style="border:1px solid #e5e5e5;white-space:pre-wrap;">{safe['message']}</td></tr>
</table>
<p style="margin-top:24px;color:#666;font-size:13px;">Reply directly to this email to respond to {safe['name']}.</p>
</body></html>"""
text = (
"New estimate request from floorithardwoodfloors.com\n\n"
f"Name: {safe['name']}\n"
f"Email: {safe['email']}\n"
f"Phone: {safe['phone']}\n"
f"Address: {safe['address']}\n"
f"City: {safe['city']}\n"
f"Zip: {safe['zip']}\n"
f"Service: {safe['service']}\n"
f"Floor Condition: {safe['condition']}\n\n"
"Message:\n"
f"{safe['message']}\n\n"
f"Reply directly to this email to respond to {safe['name']}."
)
payload_obj = {
"from": FROM_EMAIL,
"to": [TO_EMAIL],
"reply_to": (fields.get("email") or "").strip(),
"subject": f"New estimate request: {safe['name']} ({safe['city'] or 'Buffalo'})",
"html": html,
"text": text,
}
payload = json.dumps(payload_obj).encode("utf-8")
idem_key = hashlib.sha256(payload).hexdigest()[:64]
req = urllib.request.Request(
"https://api.resend.com/emails",
data=payload,
headers={
"Authorization": f"Bearer {RESEND_API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": idem_key,
"User-Agent": "FloorIt-Estimate-Form/1.0",
},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status < 200 or resp.status >= 300:
raise RuntimeError(f"Resend {resp.status}: {resp.read().decode('utf-8', 'ignore')}")
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "ignore")
raise RuntimeError(f"Resend {e.code}: {body}") from None
def rate_limit(ip):
now = time.time()
entry = RATE_MAP.get(ip, {"count": 0, "start": now})
if now - entry["start"] > RATE_WINDOW:
entry = {"count": 0, "start": now}
entry["count"] += 1
RATE_MAP[ip] = entry
return entry["count"] > RATE_MAX
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
print(f"[api] {fmt % args}", flush=True)
def _json(self, code, obj):
body = json.dumps(obj).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "https://floorithardwoodfloors.com")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "https://floorithardwoodfloors.com")
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
if self.path == "/health":
self._json(200, {"ok": True})
return
self._json(404, {"error": "not found"})
def do_POST(self):
if self.path != "/estimate":
self._json(404, {"error": "not found"})
return
ip = self.headers.get("x-forwarded-for", "").split(",")[0].strip() or self.client_address[0]
if rate_limit(ip):
self._json(429, {"error": "Too many requests. Please call us at (716) 602-1429."})
return
length = int(self.headers.get("Content-Length", "0"))
if length > 16384:
self._json(413, {"error": "Payload too large."})
return
try:
raw = self.rfile.read(length).decode("utf-8")
body = json.loads(raw)
except Exception:
self._json(400, {"error": "Invalid request."})
return
errors = validate_fields(body)
if errors:
self._json(422, {"error": "Validation failed.", "fields": errors})
return
score = verify_recaptcha(body.get("token", ""))
if RECAPTCHA_SECRET and score < RECAPTCHA_MIN:
self._json(403, {"error": "Request could not be verified. Please try again or call us directly."})
return
try:
send_via_resend(body)
self._json(200, {"ok": True})
except Exception as e:
print(f"[estimate] {e}", flush=True)
self._json(500, {"error": "Could not send your message. Please call us at (716) 602-1429."})
class ThreadedHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
allow_reuse_address = True
if __name__ == "__main__":
print(f"[api] listening on :{PORT}", flush=True)
ThreadedHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
+18
View File
@@ -6,4 +6,22 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "8096:80" - "8096:80"
depends_on:
api:
condition: service_healthy
restart: unless-stopped
api:
image: floorithardwoodfloors-api
build:
context: ./api
dockerfile: Dockerfile
env_file: ./api/.env
expose:
- "3001"
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:3001/health',timeout=3).status==200 else 1)"]
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped restart: unless-stopped
+25
View File
@@ -4,6 +4,31 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Deny dotfiles, configs, scripts, source — defense in depth
location ~ /\. {
deny all;
return 404;
}
location ~* \.(env|env\.example|conf|yml|yaml|py|pyc|md|txt|sh|sql|log|bak|old|swp|dockerfile)$ {
deny all;
return 404;
}
location = /Dockerfile {
deny all;
return 404;
}
# API proxy — strip /api/ prefix, forward to Node.js service
location /api/ {
proxy_pass http://api:3001/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 10s;
proxy_connect_timeout 5s;
}
# Flat HTML — serve /locations/buffalo as /locations/buffalo.html # Flat HTML — serve /locations/buffalo as /locations/buffalo.html
location / { location / {
try_files $uri $uri/ $uri.html =404; try_files $uri $uri/ $uri.html =404;