From 6b4d51ab88558fc41dd96a9ca5014847aae592b7 Mon Sep 17 00:00:00 2001 From: OM CHOKSI Date: Mon, 22 Jun 2026 15:02:14 +0530 Subject: [PATCH] feat: add Flask SaaS website with GitHub login and OTP --- Dockerfile | 5 + README.md | 48 ++- deploy/render/website.Dockerfile | 18 ++ deploy/render/website_start.sh | 3 + docs/website.md | 192 ++++++++++++ pyproject.toml | 13 +- render.yaml | 88 ++++++ render_entrypoint.sh | 3 + website/__init__.py | 0 website/api_client.py | 36 +++ website/app.py | 180 +++++++++++ website/auth.py | 179 +++++++++++ website/config.py | 37 +++ website/db.py | 135 ++++++++ website/email_service.py | 139 +++++++++ website/github_app.py | 6 + website/otp.py | 92 ++++++ website/static/css/styles.css | 463 ++++++++++++++++++++++++++++ website/static/js/app.js | 2 + website/templates/base.html | 49 +++ website/templates/contact.html | 24 ++ website/templates/dashboard.html | 83 +++++ website/templates/error.html | 9 + website/templates/index.html | 89 ++++++ website/templates/login.html | 28 ++ website/templates/repos.html | 15 + website/templates/reviews.html | 42 +++ website/templates/settings.html | 40 +++ website/templates/usage.html | 50 +++ website/templates/verify_email.html | 87 ++++++ website/usage.py | 45 +++ 31 files changed, 2193 insertions(+), 7 deletions(-) create mode 100644 deploy/render/website.Dockerfile create mode 100644 deploy/render/website_start.sh create mode 100644 docs/website.md create mode 100644 render.yaml create mode 100644 website/__init__.py create mode 100644 website/api_client.py create mode 100644 website/app.py create mode 100644 website/auth.py create mode 100644 website/config.py create mode 100644 website/db.py create mode 100644 website/email_service.py create mode 100644 website/github_app.py create mode 100644 website/otp.py create mode 100644 website/static/css/styles.css create mode 100644 website/static/js/app.js create mode 100644 website/templates/base.html create mode 100644 website/templates/contact.html create mode 100644 website/templates/dashboard.html create mode 100644 website/templates/error.html create mode 100644 website/templates/index.html create mode 100644 website/templates/login.html create mode 100644 website/templates/repos.html create mode 100644 website/templates/reviews.html create mode 100644 website/templates/settings.html create mode 100644 website/templates/usage.html create mode 100644 website/templates/verify_email.html create mode 100644 website/usage.py diff --git a/Dockerfile b/Dockerfile index 6cf1fc5..baefe96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,14 +19,19 @@ COPY review_engine/ review_engine/ COPY review_store/ review_store/ COPY api/ api/ COPY ui/ ui/ +COPY website/ website/ COPY scripts/ scripts/ COPY examples/ examples/ COPY docs/ docs/ +COPY deploy/render/website_start.sh /website_start.sh +RUN chmod +x /website_start.sh RUN if [ "$INSTALL_RAG" = "true" ]; then \ pip install --no-cache-dir -e ".[rag]"; \ fi +RUN pip install --no-cache-dir gunicorn + COPY render_entrypoint.sh /render_entrypoint.sh RUN chmod +x /render_entrypoint.sh diff --git a/README.md b/README.md index 2f2afe9..3073d45 100644 --- a/README.md +++ b/README.md @@ -288,15 +288,53 @@ Current status: - [x] Dashboard - [x] Docker - [x] Remote RAG service (deployed) -- [ ] Render deployment -- [ ] MongoDB Atlas integration -- [ ] Resend email implementation -- [ ] GitHub OAuth -- [ ] GitHub App install flow +- [x] Render deployment +- [x] MongoDB Atlas integration +- [x] Resend email implementation +- [x] GitHub OAuth +- [x] GitHub App install flow - [ ] Usage limit enforcement --- +## Website / SaaS Portal + +Public product portal at **https://codesec-website.onrender.com** + +- **Landing page** — Product features, GitHub App install CTA, sign in CTA +- **GitHub OAuth login** — Sign in with your GitHub account +- **Email OTP verification** — 6-digit code via Resend, 10-minute expiry +- **Dashboard** — Usage tracking, recent reviews, install GitHub App +- **Reviews page** — PR review history from the FastAPI backend +- **Usage page** — 30 free PR reviews/month, remaining quota, reset date +- **Repos page** — Connected repository management (placeholder) +- **Contact page** — Owner contact for plan increases + +### GitHub OAuth Flow + +1. Click "Sign in with GitHub" → redirected to GitHub OAuth +2. Authorize → callback exchanges code for access token +3. GitHub profile + primary email fetched +4. User saved/updated in MongoDB +5. If email not verified → OTP verification page +6. After verification → dashboard + +### OTP Verification + +- 6-digit numeric code via Resend email +- Expires in 10 minutes +- Max 3 send attempts per 10 minutes +- Max 3 verify attempts per OTP +- OTP stored as SHA-256 hash (never plaintext) + +### Usage Display + +- 30 PR reviews per month on the free plan +- Dashboard shows used / remaining / limit with progress bar +- Contact owner for more usage when limit reached + +--- + ## Responsible Use CodeSecAudit AI is a **defensive security tool**: diff --git a/deploy/render/website.Dockerfile b/deploy/render/website.Dockerfile new file mode 100644 index 0000000..7267b19 --- /dev/null +++ b/deploy/render/website.Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml ./ +RUN pip install --no-cache-dir -e ".[website]" + +COPY website/ website/ +COPY deploy/render/website_start.sh /website_start.sh +RUN chmod +x /website_start.sh + +EXPOSE 10000 + +CMD ["/website_start.sh"] diff --git a/deploy/render/website_start.sh b/deploy/render/website_start.sh new file mode 100644 index 0000000..dd9e770 --- /dev/null +++ b/deploy/render/website_start.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -e +gunicorn website.app:app --bind 0.0.0.0:${PORT:-10000} diff --git a/docs/website.md b/docs/website.md new file mode 100644 index 0000000..4d60509 --- /dev/null +++ b/docs/website.md @@ -0,0 +1,192 @@ +# CodeSecAudit AI — Website + +Flask SaaS website for CodeSecAudit AI — the product portal where users sign in with GitHub, verify email via OTP, view usage, and install the GitHub App. + +## Pages + +| Route | Page | Auth Required | Email Verified | +|-------|------|---------------|----------------| +| `/` | Landing page | No | - | +| `/login` | Sign in | No | - | +| `/auth/github/start` | OAuth start | No | - | +| `/auth/github/callback` | OAuth callback | No | - | +| `/verify-email` | Email OTP | Yes | No | +| `/dashboard` | Usage dashboard | Yes | Yes | +| `/reviews` | Review history | Yes | Yes | +| `/usage` | Usage details | Yes | Yes | +| `/repos` | Connected repos | Yes | Yes | +| `/settings` | Account settings | Yes | Yes | +| `/contact` | Contact owner | No | - | + +## Local Run + +```bash +pip install -e ".[website]" +flask --app website.app run --port 5000 +``` + +Open http://localhost:5000 + +## Environment Variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `PUBLIC_WEBSITE_URL` | No | `http://localhost:5000` | Public URL for OAuth redirect | +| `CODESEC_API_URL` | No | `https://codesec-api.onrender.com` | FastAPI backend URL | +| `GITHUB_CLIENT_ID` | Yes | (empty) | GitHub OAuth App client ID | +| `GITHUB_CLIENT_SECRET` | Yes | (empty) | GitHub OAuth App client secret | +| `GITHUB_CALLBACK_URL` | No | auto-derived | OAuth callback URL | +| `GITHUB_APP_SLUG` | No | `codesecaudit-ai` | GitHub App slug for install button | +| `SESSION_SECRET` | Yes | (empty) | Flask session signing key | +| `MONGODB_URI` | No | (empty) | MongoDB Atlas connection string | +| `MONGODB_DB_NAME` | No | `codereview` | MongoDB database name | +| `RESEND_API_KEY` | No | (empty) | Resend.com API key for email | +| `EMAIL_FROM` | No | `CodeSecAudit AI ` | Sender email address | +| `OWNER_CONTACT_EMAIL` | No | `omchoksi108@gmail.com` | Owner support email | +| `FREE_PR_REVIEWS_PER_MONTH` | No | `30` | Monthly free PR review limit | + +### Fallbacks + +- `GITHUB_CLIENT_ID` falls back to `GITHUB_APP_CLIENT_ID` +- `GITHUB_CLIENT_SECRET` falls back to `GITHUB_APP_CLIENT_SECRET` +- `EMAIL_FROM` falls back to Resend default sender + +## GitHub OAuth Setup + +1. Go to your GitHub App settings: https://github.com/settings/apps/codesecaudit-ai +2. Under **Identifying and authorizing users**, set: + - **Callback URL**: `https://codesec-website.onrender.com/auth/github/callback` (production) or `http://localhost:5000/auth/github/callback` (local) +3. The app's Client ID and Client Secret are used for OAuth + +The GitHub App's OAuth credentials are reused for the website. Alternatively, you can create a separate GitHub OAuth App. + +## OTP Verification Flow + +1. User signs in with GitHub +2. If email not verified, redirected to `/verify-email` +3. Click "Send Verification Code" → OTP sent via Resend +4. Enter 6-digit code → code verified against SHA-256 hash +5. Email marked verified, welcome email sent, redirected to dashboard + +OTP constraints: +- 6-digit numeric, expires in 10 minutes +- Max 3 send attempts per 10 minutes +- Max 3 verify attempts per OTP +- OTP stored as SHA-256 hash, never plaintext + +## Resend Email + +Emails are sent via Resend API. Templates: + +| Template | Trigger | Contents | +|----------|---------|----------| +| OTP | `/otp/send` | 6-digit verification code | +| Welcome | After OTP verify | Getting started + install CTA | +| Usage Guide | Designed only | Not scheduled | +| Limit Reached | When limit hit | Contact owner link | + +If Resend API key is not set, emails silently skip instead of crashing. + +## MongoDB Collections + +### `users` +```json +{ + "github_id": "12345", + "username": "octocat", + "email": "octocat@github.com", + "avatar_url": "https://avatars.githubusercontent.com/u/12345", + "email_verified": false, + "plan": "free", + "reviews_limit": 30, + "reviews_used": 0, + "extra_reviews": 0, + "window_start": "2026-06-22T00:00:00+00:00", + "created_at": "2026-06-22T00:00:00+00:00", + "last_login_at": "2026-06-22T00:00:00+00:00" +} +``` + +### `email_otps` +```json +{ + "user_id": "12345", + "email": "octocat@github.com", + "otp_hash": "sha256hex...", + "expires_at": "2026-06-22T00:10:00+00:00", + "attempts": 0, + "created_at": "2026-06-22T00:00:00+00:00" +} +``` + +### `email_events` +```json +{ + "user_id": "12345", + "email": "octocat@github.com", + "template": "welcome | otp | usage_guide | limit_reached", + "subject": "...", + "status": "sent | failed", + "resend_id": "...", + "created_at": "2026-06-22T00:00:00+00:00" +} +``` + +## Graceful Degradation + +- **MongoDB unavailable**: Falls back to in-memory storage (data lost on restart) +- **Resend unavailable**: Email sending skipped, no crash +- **FastAPI unavailable**: Reviews page shows empty state gracefully + +## Render Deployment + +The website is deployed as a separate Render web service. + +### render.yaml + +```yaml +- type: web + name: codesec-website + env: docker + dockerfilePath: ./deploy/render/website.Dockerfile + dockerContext: . +``` + +### Service configuration + +Set these env vars in Render dashboard (or via API): + +- `PUBLIC_WEBSITE_URL` — `https://codesec-website.onrender.com` +- `CODESEC_API_URL` — `https://codesec-api.onrender.com` +- `GITHUB_CLIENT_ID` — From GitHub App settings +- `GITHUB_CLIENT_SECRET` — From GitHub App settings +- `GITHUB_CALLBACK_URL` — `https://codesec-website.onrender.com/auth/github/callback` +- `GITHUB_APP_SLUG` — `codesecaudit-ai` +- `SESSION_SECRET` — Random secret (generate with `python -c "import secrets; print(secrets.token_hex(32))"`) +- `MONGODB_URI` — MongoDB Atlas connection string +- `MONGODB_DB_NAME` — `codereview` +- `RESEND_API_KEY` — From Resend.com +- `EMAIL_FROM` — Sender address +- `OWNER_CONTACT_EMAIL` — `omchoksi108@gmail.com` +- `FREE_PR_REVIEWS_PER_MONTH` — `30` + +### Manual deploy + +```bash +render deploy +``` + +Or via API: +```bash +curl -X POST https://api.render.com/v1/services/{service_id}/deploys \ + -H "Authorization: Bearer $RENDER_API_KEY" +``` + +## Limitations + +- Usage tracking is **display-only** — webhook enforcement is not yet implemented +- GitHub App installation status shows as not-connected until the webhook integration is complete +- 4-minute delayed usage guide email is designed but not scheduled +- Streamlit dashboard remains available alongside the Flask website +- In-memory fallback loses data on restart (MongoDB required for persistence) +- Account management features (password change, subscription management) are future work diff --git a/pyproject.toml b/pyproject.toml index abbd7c3..72d994f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,9 +32,18 @@ rag = [ "chromadb>=0.5.0", "sentence-transformers>=2.2.0", ] +website = [ + "codesec-audit-ai[core]", + "flask>=3.0.0", + "gunicorn>=21.2.0", + "requests>=2.31.0", + "pymongo>=4.5.0", + "python-dotenv>=1.0.0", + "itsdangerous>=2.0.0", +] dev = [ - "codesec-audit-ai[api,ui,rag]", + "codesec-audit-ai[api,ui,rag,website]", ] [tool.hatch.build] -include = ["review_engine/**", "review_store/**"] +include = ["review_engine/**", "review_store/**", "website/**"] diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..7cd5fde --- /dev/null +++ b/render.yaml @@ -0,0 +1,88 @@ +services: + - type: web + name: codesec-api + env: docker + dockerfilePath: ./Dockerfile + dockerContext: . + envVars: + - key: RENDER_SERVICE + value: api + - key: PORT + value: "8003" + - key: CODESEC_API_URL + value: https://codesec-api.onrender.com + - key: CODESEC_ENABLE_RAG + value: "true" + - key: CODESEC_MAX_FILES_PER_PR + value: "30" + - key: CODESEC_MAX_FILE_SIZE_KB + value: "200" + - key: CODESEC_MAX_INLINE_COMMENTS + value: "10" + - key: CODESEC_DEFAULT_TOP_K + value: "3" + - key: CODESEC_BLOCK_ON_REQUEST_CHANGES + value: "false" + - key: OWNER_EMAIL + value: omchoksi108@gmail.com + - key: OWNER_CONTACT_EMAIL + value: omchoksi108@gmail.com + + - type: web + name: codesec-dashboard + env: docker + dockerfilePath: ./Dockerfile + dockerContext: . + envVars: + - key: RENDER_SERVICE + value: dashboard + - key: PORT + value: "8502" + - key: CODESEC_API_URL + value: https://codesec-api.onrender.com + + - type: web + name: codesec-review-ui + env: docker + dockerfilePath: ./Dockerfile + dockerContext: . + envVars: + - key: RENDER_SERVICE + value: review-ui + - key: PORT + value: "8501" + - key: CODESEC_API_URL + value: https://codesec-api.onrender.com + + - type: web + name: codesec-website + env: docker + dockerfilePath: ./deploy/render/website.Dockerfile + dockerContext: . + envVars: + - key: PUBLIC_WEBSITE_URL + value: https://codesec-website.onrender.com + - key: CODESEC_API_URL + value: https://codesec-api.onrender.com + - key: GITHUB_CLIENT_ID + sync: false + - key: GITHUB_CLIENT_SECRET + sync: false + - key: GITHUB_CALLBACK_URL + value: https://codesec-website.onrender.com/auth/github/callback + - key: GITHUB_APP_SLUG + value: codesecaudit-ai + - key: SESSION_SECRET + sync: false + - key: MONGODB_URI + sync: false + - key: MONGODB_DB_NAME + value: codereview + - key: RESEND_API_KEY + sync: false + - key: EMAIL_FROM + sync: false + - key: OWNER_CONTACT_EMAIL + value: omchoksi108@gmail.com + - key: FREE_PR_REVIEWS_PER_MONTH + value: "30" diff --git a/render_entrypoint.sh b/render_entrypoint.sh index 7e88bd7..ed1feda 100644 --- a/render_entrypoint.sh +++ b/render_entrypoint.sh @@ -13,6 +13,9 @@ case "$SERVICE" in review-ui) exec streamlit run ui/app.py --server.address 0.0.0.0 --server.port "${PORT:-8501}" --server.headless true ;; + website) + exec gunicorn website.app:app --bind 0.0.0.0:"${PORT:-10000}" + ;; *) echo "Unknown RENDER_SERVICE: $SERVICE (use: api, dashboard, review-ui)" exit 1 diff --git a/website/__init__.py b/website/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/website/api_client.py b/website/api_client.py new file mode 100644 index 0000000..a944771 --- /dev/null +++ b/website/api_client.py @@ -0,0 +1,36 @@ +import logging + +logger = logging.getLogger(__name__) + + +class APIClientError(Exception): + pass + + +def fetch_reviews(api_url: str, limit: int = 20) -> list[dict]: + import requests as req + + try: + resp = req.get(f"{api_url}/reviews?limit={limit}", timeout=10) + resp.raise_for_status() + data = resp.json() + if isinstance(data, list): + return data + if isinstance(data, dict): + return data.get("reviews", data.get("data", [])) + return [] + except Exception as e: + logger.warning(f"Failed to fetch reviews from API: {e}") + return [] + + +def fetch_stats(api_url: str) -> dict: + import requests as req + + try: + resp = req.get(f"{api_url}/stats", timeout=10) + resp.raise_for_status() + return resp.json() + except Exception as e: + logger.warning(f"Failed to fetch stats from API: {e}") + return {} diff --git a/website/app.py b/website/app.py new file mode 100644 index 0000000..1b0b323 --- /dev/null +++ b/website/app.py @@ -0,0 +1,180 @@ +import logging +from datetime import datetime, timezone +from functools import wraps + +from flask import Flask, redirect, render_template, request, session, url_for + +from website.api_client import fetch_reviews +from website.auth import auth_bp +from website.config import Config +from website.email_service import send_welcome_email +from website.github_app import install_url +from website.otp import OTPError, send_otp, verify_otp +from website.usage import get_usage + +logger = logging.getLogger(__name__) + + +def create_app(config_class=Config): + app = Flask(__name__) + app.config.from_object(config_class) + app.secret_key = config_class.SESSION_SECRET + + app.register_blueprint(auth_bp) + + _inject_global_context(app) + + @app.route("/") + def index(): + return render_template("index.html", install_github_url=install_url()) + + @app.route("/login") + def login(): + return render_template("login.html") + + @app.route("/verify-email") + def verify_email_page(): + user = session.get("user") + if not user: + return redirect(url_for("login")) + return render_template("verify_email.html", email=user.get("email", "")) + + @app.route("/otp/send", methods=["POST"]) + def otp_send(): + user = session.get("user") + if not user: + return {"error": "Not logged in"}, 401 + + email = user.get("email", "") + if not email: + return {"error": "No email address on file"}, 400 + + try: + send_otp(user["github_id"], email) + return {"success": True, "message": "Verification code sent"} + except OTPError as e: + return {"error": str(e)}, 429 + except Exception as e: + logger.exception("OTP send failed") + return {"error": "Failed to send verification code. Please try again."}, 500 + + @app.route("/otp/verify", methods=["POST"]) + def otp_verify(): + user = session.get("user") + if not user: + return {"error": "Not logged in"}, 401 + + otp = request.form.get("otp", "") + if not otp: + return {"error": "Missing verification code"}, 400 + + try: + verify_otp(user["github_id"], otp) + session["user"]["email_verified"] = True + try: + send_welcome_email(user) + except Exception: + logger.warning("Welcome email send failed, continuing") + return {"success": True, "message": "Email verified", "redirect": url_for("auth.dashboard")} + except OTPError as e: + return {"error": str(e)}, 400 + except Exception as e: + logger.exception("OTP verify failed") + return {"error": "Verification failed. Please try again."}, 500 + + @app.route("/dashboard") + def dashboard(): + user = session.get("user") + if not user: + return redirect(url_for("login")) + + if not user.get("email_verified"): + return redirect(url_for("verify_email_page")) + + db, _ = _get_db() + user_doc = db.users_collection.find_one({"github_id": user["github_id"]}) + usage = get_usage(user_doc) + + reviews = fetch_reviews(Config.CODESEC_API_URL, limit=10) + + return render_template( + "dashboard.html", + user=user_doc or user, + usage=usage, + reviews=reviews[:5], + install_github_url=install_url(), + ) + + @app.route("/reviews") + def reviews(): + user = session.get("user") + if not user: + return redirect(url_for("login")) + + reviews_list = fetch_reviews(Config.CODESEC_API_URL, limit=50) + return render_template("reviews.html", reviews=reviews_list) + + @app.route("/usage") + def usage(): + user = session.get("user") + user_doc = None + if user: + db, _ = _get_db() + user_doc = db.users_collection.find_one({"github_id": user["github_id"]}) + usage_data = get_usage(user_doc) + return render_template("usage.html", usage=usage_data, user=user_doc or user) + + @app.route("/repos") + def repos(): + user = session.get("user") + if not user: + return redirect(url_for("login")) + return render_template("repos.html", install_github_url=install_url()) + + @app.route("/contact") + def contact(): + return render_template("contact.html") + + @app.route("/settings") + def settings(): + user = session.get("user") + if not user: + return redirect(url_for("login")) + + db, _ = _get_db() + user_doc = db.users_collection.find_one({"github_id": user["github_id"]}) + return render_template("settings.html", user=user_doc or user) + + @app.errorhandler(404) + def not_found(e): + return render_template("error.html", code=404, message="Page not found"), 404 + + @app.errorhandler(500) + def server_error(e): + return render_template("error.html", code=500, message="Internal server error"), 500 + + return app + + +def _get_db(): + from website.db import get_mongo + + return get_mongo() + + +def _inject_global_context(app): + @app.context_processor + def inject_globals(): + user = session.get("user") + return { + "app_name": Config.APP_NAME, + "current_year": datetime.now(timezone.utc).year, + "user": user, + "logged_in": user is not None, + } + + +app = create_app() + +if __name__ == "__main__": + app.run(debug=Config.DEBUG, port=5000) diff --git a/website/auth.py b/website/auth.py new file mode 100644 index 0000000..6d4f125 --- /dev/null +++ b/website/auth.py @@ -0,0 +1,179 @@ +import logging +from datetime import datetime, timezone + +import requests +from flask import Blueprint, redirect, request, session, url_for + +from website.config import Config +from website.db import get_mongo + +logger = logging.getLogger(__name__) +auth_bp = Blueprint("auth", __name__) + + +@auth_bp.route("/auth/github/start") +def github_start(): + client_id = Config.GITHUB_CLIENT_ID + if not client_id: + return "GitHub OAuth not configured (missing GITHUB_CLIENT_ID)", 500 + + redirect_uri = Config.GITHUB_CALLBACK_URL + state = str(int(datetime.now(timezone.utc).timestamp())) + session["oauth_state"] = state + + url = ( + f"https://github.com/login/oauth/authorize" + f"?client_id={client_id}" + f"&redirect_uri={redirect_uri}" + f"&state={state}" + f"&scope=read:user,user:email" + ) + return redirect(url) + + +@auth_bp.route("/auth/github/callback") +def github_callback(): + code = request.args.get("code") + state = request.args.get("state") + stored_state = session.pop("oauth_state", None) + + if not code: + return "Missing authorization code", 400 + + if state and stored_state and state != stored_state: + return "State mismatch. Possible CSRF.", 400 + + access_token = _exchange_code(code) + if not access_token: + return "Failed to exchange authorization code", 400 + + user_data = _fetch_github_user(access_token) + if not user_data: + return "Failed to fetch GitHub user", 400 + + emails = _fetch_github_emails(access_token) + primary_email = "" + for e in emails: + if e.get("primary"): + primary_email = e.get("email", "") + break + if not primary_email and emails: + primary_email = emails[0].get("email", "") + + user = _upsert_user(user_data, primary_email) + session["user"] = { + "github_id": str(user.get("github_id", "")), + "username": user.get("username", ""), + "email": user.get("email", ""), + "avatar_url": user.get("avatar_url", ""), + "email_verified": user.get("email_verified", False), + } + + if not user.get("email_verified"): + return redirect(url_for("auth.verify_email_page")) + + return redirect(url_for("auth.dashboard")) + + +@auth_bp.route("/logout") +def logout(): + session.clear() + return redirect(url_for("index")) + + +def _exchange_code(code: str) -> str | None: + try: + resp = requests.post( + "https://github.com/login/oauth/access_token", + json={ + "client_id": Config.GITHUB_CLIENT_ID, + "client_secret": Config.GITHUB_CLIENT_SECRET, + "code": code, + "redirect_uri": Config.GITHUB_CALLBACK_URL, + }, + headers={"Accept": "application/json"}, + timeout=10, + ) + resp.raise_for_status() + data = resp.json() + return data.get("access_token") + except Exception as e: + logger.error(f"Token exchange failed: {e}") + return None + + +def _fetch_github_user(access_token: str) -> dict | None: + try: + resp = requests.get( + "https://api.github.com/user", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github.v3+json", + }, + timeout=10, + ) + resp.raise_for_status() + return resp.json() + except Exception as e: + logger.error(f"Failed to fetch GitHub user: {e}") + return None + + +def _fetch_github_emails(access_token: str) -> list[dict]: + try: + resp = requests.get( + "https://api.github.com/user/emails", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github.v3+json", + }, + timeout=10, + ) + resp.raise_for_status() + return resp.json() + except Exception as e: + logger.error(f"Failed to fetch GitHub emails: {e}") + return [] + + +def _upsert_user(user_data: dict, email: str) -> dict: + db, _ = get_mongo() + github_id = str(user_data.get("id", "")) + now = datetime.now(timezone.utc) + + existing = db.users_collection.find_one({"github_id": github_id}) + if existing: + update = { + "$set": { + "username": user_data.get("login", ""), + "email": email or existing.get("email", ""), + "avatar_url": user_data.get("avatar_url", ""), + "last_login_at": now, + } + } + db.users_collection.update_one({"github_id": github_id}, update) + db.users_collection.find_one({"github_id": github_id}) + existing.update({ + "username": user_data.get("login", ""), + "email": email or existing.get("email", ""), + "avatar_url": user_data.get("avatar_url", ""), + "last_login_at": now, + }) + return existing + + new_user = { + "github_id": github_id, + "username": user_data.get("login", ""), + "email": email or "", + "avatar_url": user_data.get("avatar_url", ""), + "email_verified": False, + "plan": "free", + "reviews_limit": Config.FREE_PR_REVIEWS_PER_MONTH, + "reviews_used": 0, + "extra_reviews": 0, + "window_start": now, + "created_at": now, + "last_login_at": now, + } + db.users_collection.insert_one(new_user) + return new_user diff --git a/website/config.py b/website/config.py new file mode 100644 index 0000000..713a798 --- /dev/null +++ b/website/config.py @@ -0,0 +1,37 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + + +class Config: + APP_NAME = os.getenv("APP_NAME", "CodeSecAudit AI") + APP_ENV = os.getenv("APP_ENV", "development") + DEBUG = os.getenv("FLASK_DEBUG", "0") == "1" + + PUBLIC_WEBSITE_URL = os.getenv("PUBLIC_WEBSITE_URL", "http://localhost:5000") + CODESEC_API_URL = os.getenv("CODESEC_API_URL", "https://codesec-api.onrender.com") + + SESSION_SECRET = os.getenv("SESSION_SECRET", "dev-secret-change-in-production") + SESSION_TYPE = "filesystem" + PERMANENT_SESSION_LIFETIME = 86400 * 7 + + GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") or os.getenv("GITHUB_APP_CLIENT_ID", "") + GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET") or os.getenv("GITHUB_APP_CLIENT_SECRET", "") + GITHUB_CALLBACK_URL = os.getenv( + "GITHUB_CALLBACK_URL", + os.getenv("PUBLIC_WEBSITE_URL", "http://localhost:5000") + "/auth/github/callback", + ) + GITHUB_APP_SLUG = os.getenv("GITHUB_APP_SLUG", "codesecaudit-ai") + + MONGODB_URI = os.getenv("MONGODB_URI", "") + MONGODB_DB_NAME = os.getenv("MONGODB_DB_NAME", "codereview") + + RESEND_API_KEY = os.getenv("RESEND_API_KEY", "") + EMAIL_FROM = os.getenv( + "EMAIL_FROM", + "CodeSecAudit AI ", + ) + OWNER_CONTACT_EMAIL = os.getenv("OWNER_CONTACT_EMAIL", "omchoksi108@gmail.com") + + FREE_PR_REVIEWS_PER_MONTH = int(os.getenv("FREE_PR_REVIEWS_PER_MONTH", "30")) diff --git a/website/db.py b/website/db.py new file mode 100644 index 0000000..25fbd36 --- /dev/null +++ b/website/db.py @@ -0,0 +1,135 @@ +import hashlib +import logging +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +class MemDB: + def __init__(self): + self.users: dict[str, dict] = {} + self.email_otps: dict[str, dict] = {} + self.email_events: list[dict] = [] + + @property + def users_collection(self): + return _MemCollection(self.users) + + @property + def email_otps_collection(self): + return _MemCollection(self.email_otps) + + @property + def email_events_collection(self): + return _MemCollectionList(self.email_events) + + +class _MemCollection: + def __init__(self, store: dict): + self._store = store + + def find_one(self, filter_dict: dict) -> dict | None: + for item in self._store.values(): + if all(item.get(k) == v for k, v in filter_dict.items()): + return item + return None + + def update_one(self, filter_dict: dict, update_dict: dict, upsert: bool = False): + existing = self.find_one(filter_dict) + if existing: + if "$set" in update_dict: + existing.update(update_dict["$set"]) + if "$inc" in update_dict: + for k, v in update_dict["$inc"].items(): + existing[k] = existing.get(k, 0) + v + return type("Obj", (), {"matched_count": 1, "modified_count": 1})() + if upsert: + new_doc = {**filter_dict} + if "$set" in update_dict: + new_doc.update(update_dict["$set"]) + key = str(hash(frozenset(filter_dict.items()))) + self._store[key] = new_doc + return type("Obj", (), {"matched_count": 0, "modified_count": 1, "upserted_id": key})() + return type("Obj", (), {"matched_count": 0, "modified_count": 0})() + + def insert_one(self, doc: dict): + key = str(id(doc)) + self._store[key] = doc + return type("Obj", (), {"inserted_id": key})() + + def count_documents(self, filter_dict: dict | None = None) -> int: + if filter_dict is None: + return len(self._store) + return sum(1 for v in self._store.values() if all(v.get(k) == val for k, val in filter_dict.items())) + + def find(self, filter_dict: dict | None = None, sort: list | None = None, limit: int = 0): + items = list(self._store.values()) + if filter_dict: + items = [v for v in items if all(v.get(k) == val for k, val in filter_dict.items())] + return _MemCursor(items, sort, limit) + + +class _MemCollectionList: + def __init__(self, store: list): + self._store = store + + def insert_one(self, doc: dict): + self._store.append(doc) + return type("Obj", (), {"inserted_id": str(id(doc))})() + + def count_documents(self, filter_dict: dict | None = None) -> int: + if filter_dict is None: + return len(self._store) + return sum(1 for v in self._store if all(v.get(k) == val for k, val in filter_dict.items())) + + def find(self, filter_dict: dict | None = None, sort: list | None = None, limit: int = 0): + items = list(self._store) + if filter_dict: + items = [v for v in items if all(v.get(k) == val for k, val in filter_dict.items())] + return _MemCursor(items, sort, limit) + + +class _MemCursor: + def __init__(self, items: list, sort: list | None = None, limit: int = 0): + self._items = items + if sort: + for key, direction in sort: + self._items.sort(key=lambda x, k=key: x.get(k, ""), reverse=(direction == -1)) + if limit: + self._items = self._items[:limit] + + def __iter__(self): + return iter(self._items) + + def __len__(self): + return len(self._items) + + +_mem_db = MemDB() + + +def get_mongo() -> tuple: + try: + from pymongo import MongoClient + from website.config import Config + + uri = Config.MONGODB_URI + if not uri: + logger.warning("MONGODB_URI not set, using in-memory fallback") + return _mem_db, "mem" + client = MongoClient(uri, serverSelectionTimeoutMS=3000) + client.admin.command("ping") + db = client[Config.MONGODB_DB_NAME] + logger.info("Connected to MongoDB Atlas") + return db, "mongo" + except Exception as e: + logger.warning(f"MongoDB unavailable, using in-memory fallback: {e}") + return _mem_db, "mem" + + +def hash_otp(otp: str) -> str: + return hashlib.sha256(otp.encode()).hexdigest() + + +def utcnow(): + return datetime.now(timezone.utc) diff --git a/website/email_service.py b/website/email_service.py new file mode 100644 index 0000000..a6f3a32 --- /dev/null +++ b/website/email_service.py @@ -0,0 +1,139 @@ +import logging +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +def _resend_request(path: str, payload: dict) -> dict | None: + import requests as req + + from website.config import Config + + api_key = Config.RESEND_API_KEY + if not api_key: + logger.warning("RESEND_API_KEY not set, skipping email") + return None + try: + resp = req.post( + f"https://api.resend.com/{path}", + json=payload, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + timeout=10, + ) + resp.raise_for_status() + return resp.json() + except Exception as e: + logger.error(f"Resend API error: {e}") + return None + + +def _log_event(user_id: str, email: str, template: str, subject: str, status: str, resend_id: str = ""): + try: + from website.db import get_mongo + + db, _ = get_mongo() + db.email_events_collection.insert_one({ + "user_id": user_id, + "email": email, + "template": template, + "subject": subject, + "status": status, + "resend_id": resend_id, + "created_at": datetime.now(timezone.utc), + }) + except Exception as e: + logger.warning(f"Failed to log email event: {e}") + + +def send_otp_email(email: str, otp: str) -> bool: + from website.config import Config + + subject = "Your CodeSecAudit AI verification code" + body = f"""

Your verification code is:

+

{otp}

+

This code expires in 10 minutes.

+

If you did not request this, you can safely ignore this email.

+

— CodeSecAudit AI Team

""" + + result = _resend_request("emails", { + "from": Config.EMAIL_FROM, + "to": [email], + "subject": subject, + "html": body, + }) + success = result is not None + _log_event("", email, "otp", subject, "sent" if success else "failed", (result or {}).get("id", "")) + return success + + +def send_welcome_email(user: dict) -> bool: + from website.config import Config + + username = user.get("username", "there") + subject = "Welcome to CodeSecAudit AI" + body = f"""

Hi {username},

+

Welcome to CodeSecAudit AI!

+

Your email has been verified and you're all set to use CodeSecAudit AI for automated security pull request reviews.

+

Here's what you get with the free plan:

+
    +
  • 30 free PR reviews per month
  • +
  • AI-powered CWE detection
  • +
  • Inline review comments
  • +
  • Risk scoring
  • +
  • RAG-guided secure coding suggestions
  • +
+

Next step: Install the GitHub App on your repository to get started.

+

— CodeSecAudit AI Team

""" + + result = _resend_request("emails", { + "from": Config.EMAIL_FROM, + "to": [user.get("email", "")], + "subject": subject, + "html": body, + }) + success = result is not None + _log_event( + str(user.get("github_id", "")), + user.get("email", ""), + "welcome", + subject, + "sent" if success else "failed", + (result or {}).get("id", ""), + ) + return success + + +def send_usage_guide_email(user: dict) -> bool: + logger.info(f"Usage guide email prepared for {user.get('email')} — sending delayed to 4 min after verification") + return True + + +def send_limit_reached_email(user: dict) -> bool: + from website.config import Config + + subject = "CodeSecAudit AI — Free monthly limit reached" + body = f"""

Hi {user.get('username', 'there')},

+

You've used all your 30 free PR reviews for this month.

+

If you need more reviews, please contact the owner:

+

{Config.OWNER_CONTACT_EMAIL}

+

— CodeSecAudit AI Team

""" + + result = _resend_request("emails", { + "from": Config.EMAIL_FROM, + "to": [user.get("email", "")], + "subject": subject, + "html": body, + }) + success = result is not None + _log_event( + str(user.get("github_id", "")), + user.get("email", ""), + "limit_reached", + subject, + "sent" if success else "failed", + (result or {}).get("id", ""), + ) + return success diff --git a/website/github_app.py b/website/github_app.py new file mode 100644 index 0000000..57df763 --- /dev/null +++ b/website/github_app.py @@ -0,0 +1,6 @@ +from website.config import Config + + +def install_url() -> str: + slug = Config.GITHUB_APP_SLUG + return f"https://github.com/apps/{slug}/installations/new" diff --git a/website/otp.py b/website/otp.py new file mode 100644 index 0000000..4684a16 --- /dev/null +++ b/website/otp.py @@ -0,0 +1,92 @@ +import hashlib +import logging +import random +from datetime import datetime, timedelta, timezone + +logger = logging.getLogger(__name__) + +OTP_LENGTH = 6 +OTP_EXPIRE_MINUTES = 10 +MAX_OTP_ATTEMPTS = 3 +OTP_COOLDOWN_MINUTES = 10 + + +class OTPError(Exception): + pass + + +def generate_otp() -> str: + return str(random.randint(10 ** (OTP_LENGTH - 1), 10**OTP_LENGTH - 1)) + + +def hash_otp(otp: str) -> str: + return hashlib.sha256(otp.encode()).hexdigest() + + +def send_otp(user_id: str, email: str) -> str: + from website.db import get_mongo + from website.email_service import send_otp_email + + db, _ = get_mongo() + + now = datetime.now(timezone.utc) + cooldown_start = now - timedelta(minutes=OTP_COOLDOWN_MINUTES) + recent_count = db.email_otps_collection.count_documents({ + "user_id": user_id, + "created_at": {"$gte": cooldown_start}, + }) + if recent_count >= MAX_OTP_ATTEMPTS: + raise OTPError("Too many OTP requests. Please try again later.") + + otp = generate_otp() + otp_hash = hash_otp(otp) + + expires_at = now + timedelta(minutes=OTP_EXPIRE_MINUTES) + db.email_otps_collection.insert_one({ + "user_id": user_id, + "email": email, + "otp_hash": otp_hash, + "expires_at": expires_at, + "attempts": 0, + "created_at": now, + }) + + sent = send_otp_email(email, otp) + if not sent: + raise OTPError("Failed to send verification email. Please try again.") + + return otp + + +def verify_otp(user_id: str, otp: str) -> bool: + from website.db import get_mongo + + db, _ = get_mongo() + + now = datetime.now(timezone.utc) + otp_hash = hash_otp(otp) + + record = db.email_otps_collection.find_one({ + "user_id": user_id, + "expires_at": {"$gte": now}, + }) + if not record: + raise OTPError("No valid OTP found. Please request a new one.") + + if record.get("attempts", 0) >= MAX_OTP_ATTEMPTS: + raise OTPError("Too many failed attempts. Please request a new OTP.") + + db.email_otps_collection.update_one( + {"_id": record.get("_id", "")}, + {"$inc": {"attempts": 1}}, + ) + + if record["otp_hash"] != otp_hash: + raise OTPError("Invalid verification code.") + + db.users_collection.update_one( + {"github_id": user_id}, + {"$set": {"email_verified": True}}, + ) + + return True diff --git a/website/static/css/styles.css b/website/static/css/styles.css new file mode 100644 index 0000000..11fc393 --- /dev/null +++ b/website/static/css/styles.css @@ -0,0 +1,463 @@ +:root { + --bg: #0f0f1a; + --bg-card: #1a1a2e; + --bg-nav: #16162b; + --accent: #6366f1; + --accent-hover: #4f46e5; + --text: #e2e8f0; + --text-muted: #94a3b8; + --border: #2d2d4a; + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --radius: 8px; + --max-width: 1100px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hover); } + +.nav { + background: var(--bg-nav); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} +.nav-inner { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 1.5rem; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; +} +.nav-logo { + font-weight: 700; + font-size: 1.125rem; + color: var(--text) !important; +} +.nav-links { display: flex; gap: 1.25rem; align-items: center; } +.nav-links a { + color: var(--text-muted); + font-size: 0.875rem; + font-weight: 500; +} +.nav-links a:hover { color: var(--text); } + +.main { flex: 1; } + +.footer { + background: var(--bg-nav); + border-top: 1px solid var(--border); + padding: 2rem 1.5rem; + margin-top: auto; +} +.footer-inner { + max-width: var(--max-width); + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + font-size: 0.875rem; + color: var(--text-muted); +} +.footer-links { display: flex; gap: 1.25rem; } +.footer-links a { color: var(--text-muted); } +.footer-links a:hover { color: var(--text); } + +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + border-radius: var(--radius); + font-weight: 600; + font-size: 0.875rem; + border: none; + cursor: pointer; + transition: all 0.15s ease; +} +.btn-primary { + background: var(--accent); + color: #fff !important; +} +.btn-primary:hover { background: var(--accent-hover); } +.btn-secondary { + background: transparent; + color: var(--text) !important; + border: 1px solid var(--border); +} +.btn-secondary:hover { border-color: var(--accent); color: var(--accent) !important; } +.btn-github { + background: #24292e; + color: #fff !important; + padding: 0.75rem 1.5rem; + font-size: 1rem; +} +.btn-github:hover { background: #1b1f23; } + +.hero { + text-align: center; + padding: 5rem 1.5rem 3rem; + max-width: 720px; + margin: 0 auto; +} +.hero-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 999px; + background: rgba(99,102,241,0.15); + color: var(--accent); + font-size: 0.8rem; + font-weight: 600; + margin-bottom: 1.5rem; +} +.hero-title { + font-size: 3rem; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.1; +} +.hero-subtitle { + font-size: 1.25rem; + color: var(--accent); + margin-top: 0.75rem; + font-weight: 600; +} +.hero-desc { + color: var(--text-muted); + margin-top: 1rem; + font-size: 1.05rem; + line-height: 1.7; +} +.hero-cta { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 2rem; + flex-wrap: wrap; +} + +.section-title { + text-align: center; + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 2.5rem; +} + +.features { + padding: 4rem 1.5rem; + max-width: var(--max-width); + margin: 0 auto; +} +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; +} +.feature-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + transition: border-color 0.15s; +} +.feature-card:hover { border-color: var(--accent); } +.feature-icon { font-size: 2rem; margin-bottom: 0.75rem; } +.feature-card h3 { margin-bottom: 0.5rem; font-size: 1.125rem; } +.feature-card p { color: var(--text-muted); font-size: 0.9rem; } + +.plans { + padding: 4rem 1.5rem; + max-width: 400px; + margin: 0 auto; +} +.plan-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + text-align: center; +} +.plan-card h3 { margin-bottom: 0.5rem; } +.plan-price { + font-size: 2.5rem; + font-weight: 800; + color: var(--accent); + margin: 1rem 0; +} +.plan-price span { font-size: 1rem; font-weight: 400; color: var(--text-muted); } +.plan-features { + list-style: none; + margin: 1.5rem 0; + text-align: left; +} +.plan-features li { + padding: 0.4rem 0; + color: var(--text-muted); + font-size: 0.9rem; +} +.plan-features li::before { + content: '\2713'; + color: var(--success); + margin-right: 0.5rem; +} + +.links-section { + padding: 4rem 1.5rem; + max-width: var(--max-width); + margin: 0 auto; +} +.links-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} +.link-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + color: var(--text) !important; + transition: border-color 0.15s; +} +.link-card:hover { border-color: var(--accent); } +.link-card span { color: var(--text-muted); font-size: 0.85rem; } + +.auth-section { + display: flex; + justify-content: center; + padding: 4rem 1.5rem; +} +.auth-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2.5rem; + max-width: 420px; + width: 100%; +} +.auth-card h1 { text-align: center; margin-bottom: 0.5rem; } +.auth-desc { text-align: center; color: var(--text-muted); margin-bottom: 2rem; } +.btn-github { width: 100%; justify-content: center; } +.auth-info { + background: rgba(99,102,241,0.08); + border-radius: var(--radius); + padding: 1.25rem; + margin: 1.5rem 0; +} +.auth-info ul { list-style: none; margin-top: 0.5rem; } +.auth-info li { + padding: 0.25rem 0; + font-size: 0.875rem; + color: var(--text-muted); +} +.auth-info li::before { + content: '\2713'; + color: var(--success); + margin-right: 0.5rem; +} +.auth-privacy { + font-size: 0.8rem; + color: var(--text-muted); + text-align: center; +} + +.page-section { + padding: 3rem 1.5rem; + max-width: var(--max-width); + margin: 0 auto; +} +.page-section h1 { text-align: center; margin-bottom: 2rem; } + +.dashboard { + padding: 2rem 1.5rem; + max-width: var(--max-width); + margin: 0 auto; +} +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 2rem; +} +.user-info { + display: flex; + align-items: center; + gap: 1rem; +} +.avatar { border-radius: 50%; } +.plan-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 999px; + background: rgba(99,102,241,0.15); + color: var(--accent); + font-size: 0.75rem; + font-weight: 600; +} + +.usage-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + margin-bottom: 2rem; +} +.usage-card h2 { margin-bottom: 1rem; font-size: 1.125rem; } +.usage-stats { + display: flex; + gap: 2rem; + margin-bottom: 1rem; +} +.usage-stat { + display: flex; + flex-direction: column; +} +.stat-value { + font-size: 1.75rem; + font-weight: 700; + color: var(--accent); +} +.stat-label { + font-size: 0.8rem; + color: var(--text-muted); +} +.progress-bar { + height: 8px; + background: var(--border); + border-radius: 999px; + overflow: hidden; +} +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), #818cf8); + border-radius: 999px; + transition: width 0.3s ease; +} +.reset-info { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.5rem; +} + +.reviews-table-wrapper { + overflow-x: auto; +} +.reviews-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} +.reviews-table th, +.reviews-table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} +.reviews-table th { + color: var(--text-muted); + font-weight: 600; +} +.verdict { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; +} +.verdict.clean { background: rgba(34,197,94,0.15); color: var(--success); } +.verdict.flagged { background: rgba(239,68,68,0.15); color: var(--danger); } +.verdict.review { background: rgba(245,158,11,0.15); color: var(--warning); } + +.empty-state { + text-align: center; + color: var(--text-muted); + padding: 3rem 1rem; +} +.empty-state .btn { margin-top: 1rem; } + +.alert { + padding: 0.75rem 1rem; + border-radius: var(--radius); + font-size: 0.875rem; + margin-top: 0.5rem; +} +.alert-success { background: rgba(34,197,94,0.15); color: var(--success); } +.alert-error { background: rgba(239,68,68,0.15); color: var(--danger); } +.alert-warning { background: rgba(245,158,11,0.15); color: var(--warning); } + +.otp-form { margin-top: 1rem; } +.form-group { + margin-bottom: 1rem; +} +.form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.4rem; + color: var(--text-muted); +} +.form-group input { + width: 100%; + padding: 0.625rem 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 1rem; + font-family: inherit; +} +.form-group input:focus { + outline: none; + border-color: var(--accent); +} + +.setting-row { + display: flex; + justify-content: space-between; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; +} +.setting-row span:last-child { color: var(--text-muted); } + +.dashboard-footer { + display: flex; + gap: 1rem; + flex-wrap: wrap; + margin-top: 2rem; +} + +@media (max-width: 640px) { + .hero-title { font-size: 2rem; } + .hero-cta { flex-direction: column; } + .nav-links { gap: 0.75rem; } + .dashboard-header { flex-direction: column; align-items: flex-start; } + .usage-stats { gap: 1rem; } +} diff --git a/website/static/js/app.js b/website/static/js/app.js new file mode 100644 index 0000000..e01420d --- /dev/null +++ b/website/static/js/app.js @@ -0,0 +1,2 @@ +document.addEventListener('DOMContentLoaded', function() { +}); diff --git a/website/templates/base.html b/website/templates/base.html new file mode 100644 index 0000000..dd393a8 --- /dev/null +++ b/website/templates/base.html @@ -0,0 +1,49 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + + diff --git a/website/templates/contact.html b/website/templates/contact.html new file mode 100644 index 0000000..0556afd --- /dev/null +++ b/website/templates/contact.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block title %}Contact — {{ app_name }}{% endblock %} +{% block content %} +
+

Contact

+ +
+

Need more usage?

+

+ If you need more PR reviews beyond the free plan, reach out to the project owner. +

+ + Email: omchoksi108@gmail.com + +

+ Include your GitHub username and how many additional reviews you need. +

+
+
+{% endblock %} diff --git a/website/templates/dashboard.html b/website/templates/dashboard.html new file mode 100644 index 0000000..c6624f7 --- /dev/null +++ b/website/templates/dashboard.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}Dashboard — {{ app_name }}{% endblock %} +{% block content %} +
+
+ + Install GitHub App +
+ +
+

Monthly Usage

+
+
+ {{ usage.used }} + Used +
+
+ {{ usage.remaining }} + Remaining +
+
+ {{ usage.limit }} + Limit +
+
+
+
+
+ {% if usage.reset_date %} +

Resets: {{ usage.reset_date }}

+ {% endif %} + {% if usage.remaining <= 0 %} +
Free monthly limit reached. Contact owner for more.
+ {% endif %} +
+ +
+

Recent Reviews

+ {% if reviews %} +
+ + + + + + + + + + + + {% for r in reviews %} + + + + + + + + {% endfor %} + +
PRRepoVerdictRiskDate
{{ r.get('pr_number', r.get('pr', '?')) }}{{ r.get('repo', '?') }}{{ r.get('verdict', '?') }}{{ r.get('risk_score', '?') }}{{ r.get('created_at', '')[:10] }}
+
+ {% else %} +

No reviews yet. Install the GitHub App and open a PR to get started.

+ {% endif %} +
+ + +
+{% endblock %} diff --git a/website/templates/error.html b/website/templates/error.html new file mode 100644 index 0000000..4f02ef8 --- /dev/null +++ b/website/templates/error.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Error {{ code }} — {{ app_name }}{% endblock %} +{% block content %} +
+

{{ code }}

+

{{ message }}

+ Go Home +
+{% endblock %} diff --git a/website/templates/index.html b/website/templates/index.html new file mode 100644 index 0000000..85d22c0 --- /dev/null +++ b/website/templates/index.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} +{% block title %}{{ app_name }} — AI Pull Request Reviewer{% endblock %} +{% block content %} +
+
AI-Powered Security Review
+

{{ app_name }}

+

AI Pull Request Reviewer for Security and Code Quality

+

Automated security review for every pull request. Detects CWE vulnerabilities, suggests fixes, and provides RAG-guided secure coding guidance — before code is merged.

+ +
+ +
+

Features

+
+
+
📝
+

PR Summary

+

Automated summary of every pull request with detected issues and risk level.

+
+
+
💬
+

Inline Comments

+

Precise inline review comments on lines that introduce vulnerabilities.

+
+
+
⚠️
+

CWE Detection

+

Detects CWE-94, CWE-89, CWE-78, CWE-328, CWE-798, CWE-22, CWE-918 and more.

+
+
+
🎯
+

Risk Score

+

Quantified risk score per PR to prioritize the most critical reviews.

+
+
+
📖
+

RAG Guidance

+

OWASP cheat sheet context retrieved for every detected CWE via RAG.

+
+
+
📊
+

Usage Dashboard

+

Track your monthly PR reviews, remaining quota, and review history.

+
+
+
+ +
+

Free Plan

+
+

Free

+
30 PR reviews / month
+
    +
  • AI-powered CWE detection
  • +
  • Inline review comments
  • +
  • Risk scoring
  • +
  • RAG-guided suggestions
  • +
  • Usage dashboard
  • +
  • No credit card required
  • +
+ Get Started Free +
+
+ + +{% endblock %} diff --git a/website/templates/login.html b/website/templates/login.html new file mode 100644 index 0000000..8c29116 --- /dev/null +++ b/website/templates/login.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Sign In — {{ app_name }}{% endblock %} +{% block content %} +
+
+

Sign In

+

Sign in with GitHub to start reviewing your pull requests.

+ + + + Sign in with GitHub + + +
+

Free plan includes:

+
    +
  • 30 PR reviews per month
  • +
  • AI-powered CWE detection
  • +
  • Inline review comments
  • +
  • Risk scoring
  • +
  • RAG-guided guidance
  • +
+
+ +

We only access your public GitHub profile and email address. We never post on your behalf.

+
+
+{% endblock %} diff --git a/website/templates/repos.html b/website/templates/repos.html new file mode 100644 index 0000000..719620a --- /dev/null +++ b/website/templates/repos.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Repos — {{ app_name }}{% endblock %} +{% block content %} +
+

Connected Repositories

+ +
+

Installed GitHub App repositories will appear here.

+

Install the GitHub App on your repositories to get started with automated PR reviews.

+ + Install GitHub App + +
+
+{% endblock %} diff --git a/website/templates/reviews.html b/website/templates/reviews.html new file mode 100644 index 0000000..53c603e --- /dev/null +++ b/website/templates/reviews.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Reviews — {{ app_name }}{% endblock %} +{% block content %} +
+

PR Reviews

+ + {% if reviews %} +
+ + + + + + + + + + + + + {% for r in reviews %} + + + + + + + + + {% endfor %} + +
PR #RepositoryVerdictRisk ScoreIssuesDate
{{ r.get('pr_number', r.get('pr', '?')) }}{{ r.get('repo', '?') }}{{ r.get('verdict', '?') }}{{ r.get('risk_score', '?') }}{{ r.get('issues', [])|length }}{{ r.get('created_at', '')[:10] }}
+
+ {% else %} +
+

No reviews found.

+

Install the GitHub App and open a pull request to see reviews here.

+ Install GitHub App +
+ {% endif %} +
+{% endblock %} diff --git a/website/templates/settings.html b/website/templates/settings.html new file mode 100644 index 0000000..18dcbea --- /dev/null +++ b/website/templates/settings.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Settings — {{ app_name }}{% endblock %} +{% block content %} +
+

Settings

+ +
+ + +
+ Plan + {{ (user.plan or 'free') | upper }} +
+
+ Email Verified + {{ 'Yes' if user.email_verified else 'No' }} +
+
+ GitHub ID + {{ user.github_id }} +
+
+ Reviews Used + {{ user.reviews_used or 0 }} / {{ user.reviews_limit or 30 }} +
+ +

+ Account management features coming soon. +

+
+
+{% endblock %} diff --git a/website/templates/usage.html b/website/templates/usage.html new file mode 100644 index 0000000..8f50792 --- /dev/null +++ b/website/templates/usage.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Usage — {{ app_name }}{% endblock %} +{% block content %} +
+

Usage

+ +
+

Free Plan

+

30 PR reviews per month

+ +
+
+ {{ usage.used }} + Used +
+
+ {{ usage.remaining }} + Remaining +
+
+ {{ usage.limit }} + Total +
+
+ + {% if usage.extra > 0 %} +

Extra reviews granted: +{{ usage.extra }}

+ {% endif %} + +
+
+
+ + {% if usage.reset_date %} +

Usage resets: {{ usage.reset_date }}

+ {% endif %} + + {% if usage.remaining <= 0 %} +
+

Free monthly limit reached

+

You've used all {{ usage.limit }} PR reviews for this month.

+

Contact the owner to request more reviews:

+ + Contact Owner + +
+ {% endif %} +
+
+{% endblock %} diff --git a/website/templates/verify_email.html b/website/templates/verify_email.html new file mode 100644 index 0000000..990e8e8 --- /dev/null +++ b/website/templates/verify_email.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}Verify Email — {{ app_name }}{% endblock %} +{% block content %} +
+
+

Verify Your Email

+

We sent a verification code to {{ email }}.

+ +
+ +
+ +
+ + + +

Check your spam folder if you don't see the email. The code expires in 10 minutes.

+
+
+ + +{% endblock %} diff --git a/website/usage.py b/website/usage.py new file mode 100644 index 0000000..1a7cc7a --- /dev/null +++ b/website/usage.py @@ -0,0 +1,45 @@ +from datetime import datetime, timezone + +from website.config import Config + + +def get_usage(user: dict | None) -> dict: + if not user: + return {"used": 0, "limit": Config.FREE_PR_REVIEWS_PER_MONTH, "remaining": Config.FREE_PR_REVIEWS_PER_MONTH, "percent": 0, "extra": 0} + used = user.get("reviews_used", 0) + limit = user.get("reviews_limit", Config.FREE_PR_REVIEWS_PER_MONTH) + extra = user.get("extra_reviews", 0) + total_limit = limit + extra + remaining = max(0, total_limit - used) + percent = min(100, int((used / total_limit) * 100)) if total_limit > 0 else 0 + + window_start = user.get("window_start") + reset_date = "" + if window_start: + try: + dt = datetime.fromisoformat(str(window_start).replace("Z", "+00:00")) + reset_date = dt.strftime("%Y-%m-%d") + except (ValueError, TypeError): + pass + + return { + "used": used, + "limit": total_limit, + "base_limit": limit, + "remaining": remaining, + "percent": percent, + "extra": extra, + "reset_date": reset_date, + } + + +def remaining_reviews(user: dict | None) -> int: + return get_usage(user)["remaining"] + + +def is_limit_reached(user: dict | None) -> bool: + return remaining_reviews(user) <= 0 + + +def usage_percent(user: dict | None) -> int: + return get_usage(user)["percent"]