import hashlib import os import re import secrets import time from collections import defaultdict from contextlib import asynccontextmanager import httpx from fastapi import Depends, FastAPI, Form, Request, UploadFile from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader from pydantic import ValidationError from app.config import settings from app.form_engine import US_STATES, form_engine from app.healthie import healthie from app.schemas import FormAnswer, SectionSubmit _BASE = os.path.dirname(os.path.abspath(__file__)) TEMPLATE_DIR = os.path.join(_BASE, "templates") STATIC_DIR = os.path.join(_BASE, "static") TEMP_UPLOAD_DIR = os.path.join(_BASE, "..", "..", "temp_uploads") os.makedirs(TEMP_UPLOAD_DIR, exist_ok=True) jinja_env = Environment( loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True, ) def error_response(error_type: str, error_message: str, show_start_button: bool = True, status_code: int = 400): html = jinja_env.get_template("error.html").render( error_type=error_type, error_message=error_message, show_start_button=show_start_button, ) return HTMLResponse(html, status_code=status_code) class RateLimiter: def __init__(self, max_requests: int = 5, window: int = 3600): self.max = max_requests self.window = window self.requests: dict[str, list[float]] = defaultdict(list) def is_allowed(self, key: str) -> bool: now = time.time() self.requests[key] = [t for t in self.requests[key] if now - t < self.window] if len(self.requests[key]) >= self.max: return False self.requests[key].append(now) return True rate_limiter = RateLimiter( max_requests=settings().rate_limit_max, window=settings().rate_limit_window, ) submission_limiter = RateLimiter(max_requests=2, window=3600) sessions: dict[str, dict] = {} def create_session() -> str: token = secrets.token_hex(16) sessions[token] = { "created_at": time.time(), "answers": {}, "uploads": [], } return token def get_session(token: str) -> dict | None: session = sessions.get(token) if not session: return None if time.time() - session["created_at"] > settings().form_timeout: del sessions[token] return None return session @asynccontextmanager async def lifespan(app: FastAPI): form_engine.get_sections(refresh_interval=settings().refresh_interval) yield app = FastAPI(title="ACCESS Intake", lifespan=lifespan) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") def get_client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" @app.get("/") async def landing(request: Request): html = jinja_env.get_template("index.html").render(request=request) return HTMLResponse(html) @app.get("/start") async def start_intake(request: Request): token = create_session() sections = form_engine.get_sections(refresh_interval=settings().refresh_interval) html = jinja_env.get_template("section.html").render( request=request, token=token, section=sections[0] if sections else None, section_num=1, total_sections=len(sections), answers={}, us_states=US_STATES, recaptcha_site_key=settings().recaptcha_site_key or None, ) return HTMLResponse(html) @app.get("/section/{num}") async def get_section(request: Request, num: int, token: str = ""): session = get_session(token) if not session: return error_response("Session Expired", "Your session has expired. Please start a new intake.") sections = form_engine.get_sections(refresh_interval=settings().refresh_interval) if num < 1 or num > len(sections): return error_response("Invalid Section", "The requested section does not exist.", show_start_button=False) html = jinja_env.get_template("section.html").render( request=request, token=token, section=sections[num - 1], section_num=num, total_sections=len(sections), answers=session["answers"], us_states=US_STATES, recaptcha_site_key=settings().recaptcha_site_key or None, ) return HTMLResponse(html) async def parse_form_answers(request: Request) -> dict[str, str]: """Extract all form fields (checkbox multi-values joined, files skipped). The honeypot field ("website") is passed through on purpose — SectionSubmit rejects submissions where it was filled in. """ form = await request.form() result: dict[str, str] = {} for k, v in form.multi_items(): if hasattr(v, "filename"): v.close() continue if k in result: result[k] = result[k] + "|" + v else: result[k] = v return result def _missing_required_fields(section, answers: dict[str, str]) -> list[str]: """Server-side required check for one section (HTML `required` is trivially bypassable).""" missing = [] for mod in section.modules: if mod.mod_type == "read_only" or (mod.metadata and mod.metadata.get("file_upload")): continue # static text / uploads validated by their own paths if mod.sub_fields: for sf in mod.sub_fields: if sf.required and not (answers.get(f"{mod.id}_{sf.key}") or "").strip(): missing.append(sf.label) elif mod.required and not (answers.get(mod.id) or "").strip(): missing.append(mod.label) return missing def verify_recaptcha(token: str) -> bool: """Verify a reCAPTCHA v3 token. Disabled (always True) when no secret key is configured; fails closed when configured.""" secret = settings().recaptcha_secret_key if not secret: return True if not token: return False try: resp = httpx.post( "https://www.google.com/recaptcha/api/siteverify", data={"secret": secret, "response": token}, timeout=10.0, ) return bool(resp.json().get("success")) except Exception: return False @app.post("/section/{num}") async def submit_section( request: Request, num: int, answers: dict[str, str] = Depends(parse_form_answers), ): token = answers.pop("token", "") session = get_session(token) if not session: return error_response("Session Expired", "Your session has expired. Please start a new intake.") # Wire the Pydantic schema for real: honeypot rejection + typed validation. try: SectionSubmit(answers=answers) except ValidationError: return error_response("Submission Blocked", "That submission could not be processed.", show_start_button=True) answers.pop("website", None) # validated empty above; keep it out of stored PHI sections = form_engine.get_sections(refresh_interval=settings().refresh_interval) if num < 1 or num > len(sections): return error_response("Invalid Section", "The requested section does not exist.", show_start_button=False) # Server-side required check for the section being submitted. missing = _missing_required_fields(sections[num - 1], answers) if missing: session["answers"].update(answers) html = jinja_env.get_template("section.html").render( request=request, token=token, section=sections[num - 1], section_num=num, total_sections=len(sections), answers=session["answers"], us_states=US_STATES, recaptcha_site_key=settings().recaptcha_site_key or None, error=f"Please complete the required fields: {', '.join(missing)}", ) return HTMLResponse(html, status_code=400) session["answers"].update(answers) next_num = num + 1 if next_num > len(sections): return await finalize_submission(request, token) html = jinja_env.get_template("section.html").render( request=request, token=token, section=sections[next_num - 1], section_num=next_num, total_sections=len(sections), answers=session["answers"], us_states=US_STATES, recaptcha_site_key=settings().recaptcha_site_key or None, ) return HTMLResponse(html) async def finalize_submission(request: Request, token: str): if not submission_limiter.is_allowed(get_client_ip(request)): return error_response("Rate Limit Exceeded", "Too many submissions. Please try again later.", show_start_button=True) session = get_session(token) if not session: return error_response("Session Expired", "Your session has expired. Please start a new intake.") answers = session["answers"] if not verify_recaptcha(answers.get("recaptcha_token", "")): return error_response("Security Check Failed", "The reCAPTCHA check did not pass. Please try again.", show_start_button=True) # Build form answers using module definitions for correct mod_type form_answers = [] for section in form_engine.get_sections(): for mod in section.modules: if mod.sub_fields: for sf in mod.sub_fields: field_name = f"{mod.id}_{sf.key}" val = answers.get(field_name, "") if val: form_answers.append( FormAnswer( custom_module_id=mod.id, mod_type=sf.key, answer=val, ).model_dump() ) if mod.mod_type == "location": form_answers.append( FormAnswer( custom_module_id=mod.id, mod_type="country", answer="United States", ).model_dump() ) elif mod.metadata and mod.metadata.get("file_upload"): uploaded = any(u["module_id"] == mod.id for u in session.get("uploads", [])) if uploaded: form_answers.append( FormAnswer( custom_module_id=mod.id, mod_type="text", answer=f"File uploaded: {session['uploads'][-1]['filename']}", ).model_dump() ) elif mod.mod_type == "agree_to_above": form_answers.append( FormAnswer( custom_module_id=mod.id, mod_type="agree_to_above", answer=answers.get(mod.id, "false"), ).model_dump() ) elif mod.mod_type == "checkbox": val = answers.get(mod.id, "") if val: form_answers.append( FormAnswer( custom_module_id=mod.id, mod_type="checkbox", answer=val, ).model_dump() ) elif mod.mod_type == "radio": val = answers.get(mod.id, "") if val: form_answers.append( FormAnswer( custom_module_id=mod.id, mod_type="radio", answer=val, ).model_dump() ) else: val = answers.get(mod.id, "") if val: form_answers.append( FormAnswer( custom_module_id=mod.id, mod_type=mod.mod_type, answer=val, ).model_dump() ) # Extract patient fields from name sub-fields first_name = answers.get("20244727_first", "") last_name = answers.get("20244727_last", "") email = answers.get("20244730", "") dob = answers.get("20244728", "") gender = answers.get("20244729", "") phone = answers.get("20244731", "") try: client_id = healthie.create_client({ "first_name": first_name, "last_name": last_name, "email": email, "dob": dob, "gender": gender, "phone_number": phone, "dont_send_welcome": True, }) # The returned group id is not needed downstream; the call itself has # the side effect of submitting the answers to Healthie. if form_answers: healthie.submit_form_answers(client_id, form_answers) uploads = session.get("uploads", []) for upload in uploads: try: file_path = upload.get("file_path", "") if not os.path.exists(file_path): continue checksum = hashlib.sha256(upload["filename"].encode()).hexdigest() presign = healthie.get_presigned_url( form_answer_id=upload["module_id"], filename=upload["filename"], byte_size=upload["byte_size"], checksum=checksum, content_type=upload["content_type"], ) with open(file_path, "rb") as f: file_data = f.read() resp = httpx.put(presign["presignedUrl"], data=file_data, headers={ "Content-Type": upload["content_type"] }) resp.raise_for_status() healthie.confirm_file_attachment(upload["module_id"], presign["uploadId"]) os.remove(file_path) except Exception as e: error_msg = str(e) if "only image types" in error_msg: print(f" FILE_SKIPPED {upload['module_id']}: Healthie only accepts images, file saved locally: {upload['filename']}") else: print(f" FILE_UPLOAD_ERROR for {upload['module_id']}: {e}") del sessions[token] html = jinja_env.get_template("complete.html").render( request=request, client_id=client_id ) return HTMLResponse(html) except Exception as e: import traceback traceback.print_exc() return error_response("Submission Failed", f"{e!s}. Please try again.", show_start_button=True) _MODULE_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") @app.post("/upload/file") async def upload_temp_file(file: UploadFile = Form(...), module_id: str = Form(...), token: str = Form(...)): """Store uploaded file temporarily on disk.""" session = get_session(token) if not session: return {"error": "Session expired"} # module_id is client-controlled and must be a known-safe token, never path material. if not _MODULE_ID_RE.match(module_id or ""): return {"error": "Invalid module id"} limit = settings().upload_max_bytes chunks: list[bytes] = [] size = 0 while True: chunk = await file.read(1024 * 1024) if not chunk: break size += len(chunk) if size > limit: return {"error": f"File exceeds the {limit // (1024 * 1024)} MB upload limit"} chunks.append(chunk) # Storage name is server-generated; the client filename never touches a path. file_path = os.path.join(TEMP_UPLOAD_DIR, f"{token}_{module_id}_{secrets.token_hex(8)}") if len(session["uploads"]) >= settings().max_uploads_per_session: return {"error": "Too many files uploaded for this form"} with open(file_path, "wb") as f: for chunk in chunks: f.write(chunk) session["uploads"].append({ "module_id": module_id, # Original name is metadata only (sent to Healthie), never used on disk. "filename": file.filename or os.path.basename(file_path), "byte_size": size, "content_type": file.content_type or "application/octet-stream", "file_path": file_path, }) return {"ok": True} @app.post("/upload/confirm") async def confirm_upload(): """Acknowledge client-side upload completion.""" return {"ok": True}