1+ import re
12import uuid
23
34import httpx
4- from fastapi import APIRouter , UploadFile , File , Form
5+ from fastapi import APIRouter , UploadFile , File , Form , HTTPException
56from typing import Optional
67
78from db .connection import SUPABASE_URL , SUPABASE_KEY , table
89
910BUCKET = "application_resumes"
1011
12+ # /apply is intentionally public (anyone can apply), so the upload path must be
13+ # bounded — otherwise it's an unauthenticated, unbounded upload + DB-write sink
14+ # (#199). Cap size and restrict to document types, mirroring storage_service's
15+ # avatar validation (415 unsupported / 413 too large).
16+ MAX_RESUME_SIZE = 5 * 1024 * 1024 # 5 MB
17+ ALLOWED_RESUME_TYPES = {
18+ "application/pdf" ,
19+ "application/msword" ,
20+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ,
21+ }
22+ _EMAIL_RE = re .compile (r"^[^@\s]+@[^@\s]+\.[^@\s]+$" )
23+
1124router = APIRouter ()
1225
1326
14- def _upload_resume (file : UploadFile ) -> str :
15- """Upload PDF to Supabase Storage and return the storage path."""
16- ext = file .filename .rsplit ("." , 1 )[- 1 ] if "." in file .filename else "pdf"
27+ def _validate_resume (file : UploadFile ) -> bytes :
28+ """Enforce content-type + size bounds and return the file bytes.
29+
30+ Reads at most MAX_RESUME_SIZE + 1 bytes so an oversize upload can't be
31+ pulled fully into memory before we reject it.
32+ """
33+ if (file .content_type or "" ) not in ALLOWED_RESUME_TYPES :
34+ raise HTTPException (
35+ status_code = 415 ,
36+ detail = "Unsupported resume type. Allowed: PDF, DOC, DOCX." ,
37+ )
38+ content = file .file .read (MAX_RESUME_SIZE + 1 )
39+ if len (content ) > MAX_RESUME_SIZE :
40+ raise HTTPException (status_code = 413 , detail = "Resume too large. Maximum size is 5 MB." )
41+ if not content :
42+ raise HTTPException (status_code = 400 , detail = "Resume file is empty." )
43+ return content
44+
45+
46+ def _upload_resume (file : UploadFile , content : bytes ) -> str :
47+ """Upload a validated resume to Supabase Storage and return the path."""
48+ ext = file .filename .rsplit ("." , 1 )[- 1 ] if file .filename and "." in file .filename else "pdf"
1749 path = f"{ uuid .uuid4 ()} .{ ext } "
18- content = file .file .read ()
1950 url = f"{ SUPABASE_URL } /storage/v1/object/{ BUCKET } /{ path } "
2051 r = httpx .put (
2152 url ,
@@ -40,14 +71,31 @@ async def apply(
4071 portfolio_link : str = Form ("" ),
4172 resume : Optional [UploadFile ] = File (None ),
4273):
43- resume_path = _upload_resume (resume ) if resume else None
74+ position = position .strip ()
75+ full_name = full_name .strip ()
76+ email = email .strip ()
77+ linkedin_url = linkedin_url .strip ()
78+ if not position or len (position ) > 200 :
79+ raise HTTPException (status_code = 422 , detail = "A valid position is required." )
80+ if not full_name or len (full_name ) > 200 :
81+ raise HTTPException (status_code = 422 , detail = "Full name is required." )
82+ if not _EMAIL_RE .match (email ) or len (email ) > 320 :
83+ raise HTTPException (status_code = 422 , detail = "A valid email is required." )
84+ if not linkedin_url or len (linkedin_url ) > 500 :
85+ raise HTTPException (status_code = 422 , detail = "A LinkedIn URL is required." )
86+
87+ resume_path = None
88+ if resume is not None and resume .filename :
89+ content = _validate_resume (resume )
90+ resume_path = _upload_resume (resume , content )
91+
4492 row = table ("job_applications" ).insert ({
4593 "position" : position ,
4694 "full_name" : full_name ,
4795 "email" : email ,
48- "phone" : phone or None ,
96+ "phone" : ( phone or "" ). strip () or None ,
4997 "linkedin_url" : linkedin_url ,
50- "portfolio_link" : portfolio_link or None ,
98+ "portfolio_link" : ( portfolio_link or "" ). strip () or None ,
5199 "resume" : resume_path ,
52100 })
53101 return {"ok" : True , "id" : row [0 ]["id" ] if row else None }
0 commit comments