diff --git a/backend/app/config.py b/backend/app/config.py index 2445f41..dd3f407 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -38,5 +38,8 @@ class Settings(BaseSettings): UPLOAD_DIR: str = "./uploads" MAX_UPLOAD_SIZE: int = 524288000 # 500MB + TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha + ADMIN_EMAIL: str = "" # Where contact form submissions are emailed + settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py index 0fb041c..da291d3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles from app.config import settings from app.database import engine, Base, SessionLocal -from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach +from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact from app.utils.auth import get_password_hash from app.utils.scheduler import start_scheduler, stop_scheduler @@ -230,6 +230,18 @@ def setup_pgvector(): """)) # Unthrottle flag for users (exempt from AI/TTS rate limits) conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_unthrottled INTEGER DEFAULT 0")) + # Contact form submissions table + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS contact_submissions ( + id SERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + email VARCHAR NOT NULL, + type VARCHAR NOT NULL, + message TEXT NOT NULL, + read INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() + ) + """)) conn.commit() @@ -307,7 +319,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, - allow_origins=["https://quiz.danvics.com"], + allow_origins=["https://quiz.danvics.com", "https://pedshub.com", "https://www.pedshub.com"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -333,6 +345,7 @@ app.include_router(questions.router, prefix="/api/questions", tags=["questions"] app.include_router(question_categories.router, prefix="/api/question-categories", tags=["question-categories"]) app.include_router(favorites.router, prefix="/api/favorites", tags=["favorites"]) app.include_router(teach.router, prefix="/api/teach", tags=["teach"]) +app.include_router(contact.router, prefix="/api/contact", tags=["contact"]) @app.get("/api/health") diff --git a/backend/app/routers/contact.py b/backend/app/routers/contact.py new file mode 100644 index 0000000..c3ad3ff --- /dev/null +++ b/backend/app/routers/contact.py @@ -0,0 +1,140 @@ +"""Contact form — stores submissions and emails admin.""" +from datetime import datetime +import logging + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, EmailStr +from sqlalchemy import Column, Integer, String, Text, DateTime +from sqlalchemy.orm import Session + +from app.database import get_db, Base +from app.config import settings + +router = APIRouter() +log = logging.getLogger(__name__) + + +class ContactSubmission(Base): + __tablename__ = "contact_submissions" + id = Column(Integer, primary_key=True) + name = Column(String, nullable=False) + email = Column(String, nullable=False) + type = Column(String, nullable=False) # "question" | "moderator" + message = Column(Text, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + read = Column(Integer, default=0) + + +class ContactRequest(BaseModel): + name: str + email: EmailStr + type: str # "question" | "moderator" + message: str + turnstile_token: str | None = None + + +async def _verify_turnstile(token: str) -> bool: + """Verify Cloudflare Turnstile token. Returns True if valid or if Turnstile not configured.""" + secret = getattr(settings, "TURNSTILE_SECRET_KEY", None) + if not secret: + return True # Not configured — allow in dev/no-captcha mode + try: + import httpx + resp = httpx.post( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + data={"secret": secret, "response": token}, + timeout=10, + ) + return resp.json().get("success", False) + except Exception as e: + log.warning(f"Turnstile verification failed: {e}") + return False + + +@router.post("") +async def submit_contact(req: ContactRequest, db: Session = Depends(get_db)): + """Public endpoint — no auth required.""" + if req.type not in ("question", "moderator"): + raise HTTPException(status_code=400, detail="Type must be 'question' or 'moderator'") + + name = req.name.strip()[:120] + message = req.message.strip()[:2000] + if not name or not message: + raise HTTPException(status_code=400, detail="Name and message are required") + + # Verify Turnstile if configured + secret = getattr(settings, "TURNSTILE_SECRET_KEY", None) + if secret: + if not req.turnstile_token: + raise HTTPException(status_code=400, detail="Bot verification required") + if not await _verify_turnstile(req.turnstile_token): + raise HTTPException(status_code=400, detail="Bot verification failed — please try again") + + submission = ContactSubmission( + name=name, + email=req.email.lower().strip(), + type=req.type, + message=message, + ) + db.add(submission) + db.commit() + + # Email admin + _email_admin(name, req.email, req.type, message) + + return {"success": True} + + +def _email_admin(name: str, email: str, type_: str, message: str): + try: + from app.services.email_service import send_email + label = "Moderator Application" if type_ == "moderator" else "Contact Form Question" + body = ( + f"

New {label} — PedsHub

" + f"

From: {name} <{email}>

" + f"

Message:

" + f"
" + f"{message.replace(chr(10), '
')}" + f"
" + ) + admin_email = getattr(settings, "ADMIN_EMAIL", None) or getattr(settings, "SMTP_FROM", None) + if admin_email: + send_email(to=admin_email, subject=f"[PedsHub] {label} from {name}", html=body) + except Exception as e: + log.warning(f"Failed to email admin about contact submission: {e}") + + +# ── Admin view of submissions ───────────────────────────────────────────────── + +@router.get("/submissions") +def list_submissions( + db: Session = Depends(get_db), +): + """Admin: list all contact submissions.""" + from app.utils.auth import require_admin + rows = db.query(ContactSubmission).order_by(ContactSubmission.created_at.desc()).all() + return [ + { + "id": r.id, + "name": r.name, + "email": r.email, + "type": r.type, + "message": r.message, + "read": r.read, + "created_at": r.created_at.isoformat(), + } + for r in rows + ] + + +@router.put("/submissions/{submission_id}/read") +def mark_read( + submission_id: int, + db: Session = Depends(get_db), +): + row = db.query(ContactSubmission).filter(ContactSubmission.id == submission_id).first() + if not row: + raise HTTPException(status_code=404, detail="Not found") + row.read = 1 + db.commit() + return {"success": True} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c6877d7..20ca3e3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -21,11 +21,12 @@ import VerifyEmailPage from './pages/VerifyEmailPage' import ForgotPasswordPage from './pages/ForgotPasswordPage' import ResetPasswordPage from './pages/ResetPasswordPage' import NotFoundPage from './pages/NotFoundPage' +import LandingPage from './pages/LandingPage' function ProtectedRoute({ children, requireModerator = false }) { const { user, loading } = useAuth() if (loading) return
- if (!user) return + if (!user) return if (requireModerator && user.role !== 'admin' && user.role !== 'moderator') return return children } @@ -46,11 +47,22 @@ function AppRoutes() { return ( <> + {!user && + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + {user && <>
- : } /> - : } /> + } /> + } /> + } /> } /> } /> } /> @@ -71,6 +83,7 @@ function AppRoutes() {