From e0b9e27b90a949923cba338efd639bc3c9b232be Mon Sep 17 00:00:00 2001 From: Concept Agent Date: Fri, 8 May 2026 18:22:46 +0200 Subject: [PATCH] Add Python form API + harden nginx web root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .dockerignore | 12 +++ .gitignore | 6 ++ DNS_DMARC_RECORD.txt | 28 ++++++ Dockerfile | 14 ++- api/.env.example | 6 ++ api/Dockerfile | 5 + api/server.py | 225 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 18 ++++ nginx.conf | 25 +++++ 9 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 DNS_DMARC_RECORD.txt create mode 100644 api/.env.example create mode 100644 api/Dockerfile create mode 100644 api/server.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..45b17a7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.gitignore +.dockerignore +api +build_*.py +__pycache__ +*.pyc +*.md +*.txt +review_*.png +docker-compose.yml +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..397cf7f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +api/.env +api/__pycache__/ +__pycache__/ +*.pyc +*.log +.DS_Store diff --git a/DNS_DMARC_RECORD.txt b/DNS_DMARC_RECORD.txt new file mode 100644 index 0000000..762e619 --- /dev/null +++ b/DNS_DMARC_RECORD.txt @@ -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. diff --git a/Dockerfile b/Dockerfile index bb31955..6fc5a4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,18 @@ FROM nginx:alpine +# nginx config (server-only, not served as a static file) 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 diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..de04fee --- /dev/null +++ b/api/.env.example @@ -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 diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..2a83453 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.13-alpine +WORKDIR /app +COPY server.py . +EXPOSE 3001 +CMD ["python3", "-u", "server.py"] diff --git a/api/server.py b/api/server.py new file mode 100644 index 0000000..61850c4 --- /dev/null +++ b/api/server.py @@ -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 ") +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("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + 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""" + +

A new estimate request was submitted on floorithardwoodfloors.com.

+ + + + + + + + + + +
Name{safe['name']}
Email{safe['email']}
Phone{safe['phone']}
Address{safe['address']}
City{safe['city']}
Zip{safe['zip']}
Service{safe['service']}
Floor Condition{safe['condition']}
Message{safe['message']}
+

Reply directly to this email to respond to {safe['name']}.

+""" + + 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() diff --git a/docker-compose.yml b/docker-compose.yml index 6486a73..ca5eacd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,4 +6,22 @@ services: dockerfile: Dockerfile ports: - "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 diff --git a/nginx.conf b/nginx.conf index b421c3f..607a78e 100644 --- a/nginx.conf +++ b/nginx.conf @@ -4,6 +4,31 @@ server { root /usr/share/nginx/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 location / { try_files $uri $uri/ $uri.html =404;