Added security checks, updated README.md to reflect that
Browse files- Dockerfile +7 -2
- README.md +16 -0
- backend/app.py +56 -6
- sec_test.py +139 -0
- static/script.js +40 -15
- templates/index.html +3 -1
Dockerfile
CHANGED
|
@@ -7,12 +7,17 @@ WORKDIR /app
|
|
| 7 |
|
| 8 |
# Install dependencies
|
| 9 |
COPY requirements.txt .
|
| 10 |
-
RUN pip install --no-cache-dir huggingface_hub llama-cpp-python gunicorn flask requests
|
| 11 |
-
|
| 12 |
COPY . .
|
| 13 |
|
| 14 |
# Railway uses the PORT env var
|
| 15 |
ENV PORT=8080
|
| 16 |
EXPOSE 8080
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "backend.app:app"]
|
|
|
|
| 7 |
|
| 8 |
# Install dependencies
|
| 9 |
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir huggingface_hub llama-cpp-python gunicorn flask requests python-dotenv
|
|
|
|
| 11 |
COPY . .
|
| 12 |
|
| 13 |
# Railway uses the PORT env var
|
| 14 |
ENV PORT=8080
|
| 15 |
EXPOSE 8080
|
| 16 |
|
| 17 |
+
#creates user appuser and sets ownership of /app to that user
|
| 18 |
+
RUN useradd -m appuser
|
| 19 |
+
RUN chown -R appuser:appuser /app
|
| 20 |
+
|
| 21 |
+
USER appuser
|
| 22 |
+
|
| 23 |
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "backend.app:app"]
|
README.md
CHANGED
|
@@ -33,6 +33,21 @@ This assistant provides high-quality coding intelligence within a constrained 2G
|
|
| 33 |
├── memory.db # SQLite chat history database
|
| 34 |
└── README.md
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
---
|
| 37 |
|
| 38 |
## Deployment (Railway)
|
|
@@ -40,6 +55,7 @@ This assistant provides high-quality coding intelligence within a constrained 2G
|
|
| 40 |
1. Mount Volumes: Create a volume and mount it to /app/data and /app/models.
|
| 41 |
|
| 42 |
2. Environment Variables:
|
|
|
|
| 43 |
- `PORT`: 8080
|
| 44 |
- `PYTHONUNBUFFERED`: 1
|
| 45 |
|
|
|
|
| 33 |
├── memory.db # SQLite chat history database
|
| 34 |
└── README.md
|
| 35 |
|
| 36 |
+
---
|
| 37 |
+
## 🔒 Security & Hardening
|
| 38 |
+
|
| 39 |
+
This project incorporates robust security controls tailored for public cloud deployments:
|
| 40 |
+
|
| 41 |
+
1. Authentication: All sensitive mutation and inference routes require a valid X-API-Key header matched against environment variables.
|
| 42 |
+
|
| 43 |
+
2. Rate Limiting: Custom IP-based rate tracking returns HTTP 429 (Too Many Requests) status codes if thresholds are exceeded.
|
| 44 |
+
|
| 45 |
+
3. Verification: to verify the security implementation run sec_test.py:
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
python sec_test.py
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
---
|
| 52 |
|
| 53 |
## Deployment (Railway)
|
|
|
|
| 55 |
1. Mount Volumes: Create a volume and mount it to /app/data and /app/models.
|
| 56 |
|
| 57 |
2. Environment Variables:
|
| 58 |
+
- `API_KEY`: Your secret key for API verification.
|
| 59 |
- `PORT`: 8080
|
| 60 |
- `PYTHONUNBUFFERED`: 1
|
| 61 |
|
backend/app.py
CHANGED
|
@@ -2,16 +2,48 @@ import os
|
|
| 2 |
import re
|
| 3 |
import glob
|
| 4 |
import time
|
| 5 |
-
import
|
| 6 |
import sqlite3
|
| 7 |
import subprocess
|
| 8 |
-
from typing import List,
|
| 9 |
from flask import Flask, request, jsonify, render_template, Response, abort
|
| 10 |
import requests
|
| 11 |
from werkzeug.utils import secure_filename
|
| 12 |
from llama_cpp import Llama
|
| 13 |
from huggingface_hub import hf_hub_download
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
# --- Cloud Config ---
|
| 16 |
# We use a 1.5B model so it doesn't crash Railway's free RAM (approx 2GB)
|
| 17 |
REPO_ID = "Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF"
|
|
@@ -28,9 +60,8 @@ if not os.path.exists(model_path):
|
|
| 28 |
hf_hub_download(repo_id=REPO_ID, filename=FILENAME, local_dir="/app/models")
|
| 29 |
|
| 30 |
# Initialize LLM
|
| 31 |
-
llm = Llama(model_path=model_path, n_ctx=
|
| 32 |
|
| 33 |
-
# Update your DB_PATH to use the persistent volume
|
| 34 |
DB_PATH = "/app/data/memory.db"
|
| 35 |
PROJECTS_DIR = os.getenv("PROJECTS_DIR", "./projects")
|
| 36 |
|
|
@@ -346,8 +377,14 @@ IMPORTANT:
|
|
| 346 |
|
| 347 |
# -------------- Routes --------------
|
| 348 |
@app.route("/")
|
|
|
|
| 349 |
def index():
|
| 350 |
-
return render_template("index.html")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
@app.route("/history/<project>", methods=["GET"])
|
| 353 |
def get_history(project):
|
|
@@ -376,6 +413,7 @@ def get_projects():
|
|
| 376 |
return jsonify([r[0] for r in rows])
|
| 377 |
|
| 378 |
@app.route("/settings", methods=["GET", "POST"])
|
|
|
|
| 379 |
def settings():
|
| 380 |
"""Get or update runtime settings without redeploy."""
|
| 381 |
global MODEL, TEMPERATURE, TOP_P, NUM_CTX, SEED
|
|
@@ -405,6 +443,7 @@ def add_project():
|
|
| 405 |
return jsonify({"status": "ok", "project": project})
|
| 406 |
|
| 407 |
@app.route("/chat", methods=["POST"])
|
|
|
|
| 408 |
def chat():
|
| 409 |
data = request.json or {}
|
| 410 |
project = data.get("project", "default")
|
|
@@ -435,6 +474,8 @@ def chat():
|
|
| 435 |
return jsonify({"response": assistant_text, "saved_files": saved_files})
|
| 436 |
|
| 437 |
@app.route("/stream", methods=["POST"])
|
|
|
|
|
|
|
| 438 |
def stream():
|
| 439 |
data = request.json or {}
|
| 440 |
project = data.get("project", "default")
|
|
@@ -477,6 +518,8 @@ def stream():
|
|
| 477 |
return resp
|
| 478 |
|
| 479 |
@app.route("/search_web", methods=["POST"])
|
|
|
|
|
|
|
| 480 |
def search_web():
|
| 481 |
data = request.json or {}
|
| 482 |
query = (data.get("query") or "").strip()
|
|
@@ -504,6 +547,8 @@ def allowed_file(filename):
|
|
| 504 |
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
| 505 |
|
| 506 |
@app.route("/upload_file/<project>", methods=["POST"])
|
|
|
|
|
|
|
| 507 |
def upload_file(project):
|
| 508 |
if "file" not in request.files:
|
| 509 |
return jsonify({"error": "no file part"}), 400
|
|
@@ -521,6 +566,7 @@ def upload_file(project):
|
|
| 521 |
return jsonify({"error": "file type not allowed"}), 400
|
| 522 |
|
| 523 |
@app.route("/delete_project", methods=["POST"])
|
|
|
|
| 524 |
def delete_project():
|
| 525 |
data = request.json or {}
|
| 526 |
project = (data.get("project") or "").strip()
|
|
@@ -554,6 +600,8 @@ def _run_cmd(cmd: List[str], cwd: Optional[str] = None, timeout: int = 20) -> Tu
|
|
| 554 |
return 1, "", str(e)
|
| 555 |
|
| 556 |
@app.route("/run/<project>", methods=["POST"])
|
|
|
|
|
|
|
| 557 |
def run_project(project):
|
| 558 |
if not RUNNER_ENABLED:
|
| 559 |
return jsonify({"error": "runner disabled; set RUNNER_ENABLED=1"}), 400
|
|
@@ -569,6 +617,8 @@ def run_project(project):
|
|
| 569 |
return jsonify({"code": code, "stdout": out, "stderr": err})
|
| 570 |
|
| 571 |
@app.route("/lint/<project>", methods=["POST"])
|
|
|
|
|
|
|
| 572 |
def lint(project):
|
| 573 |
if not LINTER_ENABLED:
|
| 574 |
return jsonify({"error": "linter disabled; set LINTER_ENABLED=1"}), 400
|
|
|
|
| 2 |
import re
|
| 3 |
import glob
|
| 4 |
import time
|
| 5 |
+
import functools
|
| 6 |
import sqlite3
|
| 7 |
import subprocess
|
| 8 |
+
from typing import List, Tuple, Optional
|
| 9 |
from flask import Flask, request, jsonify, render_template, Response, abort
|
| 10 |
import requests
|
| 11 |
from werkzeug.utils import secure_filename
|
| 12 |
from llama_cpp import Llama
|
| 13 |
from huggingface_hub import hf_hub_download
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
|
| 16 |
+
load_dotenv()
|
| 17 |
+
|
| 18 |
+
API_KEYS = os.getenv("API_KEY")
|
| 19 |
+
# Security:
|
| 20 |
+
RATE_LIMIT = 10 # 10 requests per minute
|
| 21 |
+
REQUEST_COUNTS = {}
|
| 22 |
+
# rate limiting decorator
|
| 23 |
+
def rate_limit(func):
|
| 24 |
+
@functools.wraps(func)
|
| 25 |
+
def wrapper(*args, **kwargs):
|
| 26 |
+
client_ip = request.remote_addr # Get the client's IP address
|
| 27 |
+
now = time.time()
|
| 28 |
+
if client_ip not in REQUEST_COUNTS:
|
| 29 |
+
REQUEST_COUNTS[client_ip] = {"count": 0, "timestamp": now}
|
| 30 |
+
if now - REQUEST_COUNTS[client_ip]["timestamp"] > 60: # Reset count after 1 minute
|
| 31 |
+
REQUEST_COUNTS[client_ip] = {"count": 0, "timestamp": now}
|
| 32 |
+
if REQUEST_COUNTS[client_ip]["count"] >= RATE_LIMIT:
|
| 33 |
+
abort(429) # Too Many Requests
|
| 34 |
+
REQUEST_COUNTS[client_ip]["count"] += 1
|
| 35 |
+
return func(*args, **kwargs)
|
| 36 |
+
return wrapper
|
| 37 |
+
#api key decorator
|
| 38 |
+
def require_api_key(func):
|
| 39 |
+
@functools.wraps(func)
|
| 40 |
+
def wrapper(*args, **kwargs):
|
| 41 |
+
api_key = request.headers.get('X-API-Key') # Assuming key is sent in header
|
| 42 |
+
if api_key and api_key == API_KEYS:
|
| 43 |
+
return func(*args, **kwargs)
|
| 44 |
+
else:
|
| 45 |
+
abort(401) # Unauthorized
|
| 46 |
+
return wrapper
|
| 47 |
# --- Cloud Config ---
|
| 48 |
# We use a 1.5B model so it doesn't crash Railway's free RAM (approx 2GB)
|
| 49 |
REPO_ID = "Qwen/Qwen2.5-Coder-0.5B-Instruct-GGUF"
|
|
|
|
| 60 |
hf_hub_download(repo_id=REPO_ID, filename=FILENAME, local_dir="/app/models")
|
| 61 |
|
| 62 |
# Initialize LLM
|
| 63 |
+
llm = Llama(model_path=model_path, n_ctx=8192, n_threads=4, n_batch=512, flash_attn=True)
|
| 64 |
|
|
|
|
| 65 |
DB_PATH = "/app/data/memory.db"
|
| 66 |
PROJECTS_DIR = os.getenv("PROJECTS_DIR", "./projects")
|
| 67 |
|
|
|
|
| 377 |
|
| 378 |
# -------------- Routes --------------
|
| 379 |
@app.route("/")
|
| 380 |
+
@rate_limit
|
| 381 |
def index():
|
| 382 |
+
return render_template("index.html", api_key=API_KEYS)
|
| 383 |
+
|
| 384 |
+
@app.route("/protected")
|
| 385 |
+
@require_api_key
|
| 386 |
+
def protected_resource():
|
| 387 |
+
return "This is a protected resource!"
|
| 388 |
|
| 389 |
@app.route("/history/<project>", methods=["GET"])
|
| 390 |
def get_history(project):
|
|
|
|
| 413 |
return jsonify([r[0] for r in rows])
|
| 414 |
|
| 415 |
@app.route("/settings", methods=["GET", "POST"])
|
| 416 |
+
@require_api_key
|
| 417 |
def settings():
|
| 418 |
"""Get or update runtime settings without redeploy."""
|
| 419 |
global MODEL, TEMPERATURE, TOP_P, NUM_CTX, SEED
|
|
|
|
| 443 |
return jsonify({"status": "ok", "project": project})
|
| 444 |
|
| 445 |
@app.route("/chat", methods=["POST"])
|
| 446 |
+
@require_api_key
|
| 447 |
def chat():
|
| 448 |
data = request.json or {}
|
| 449 |
project = data.get("project", "default")
|
|
|
|
| 474 |
return jsonify({"response": assistant_text, "saved_files": saved_files})
|
| 475 |
|
| 476 |
@app.route("/stream", methods=["POST"])
|
| 477 |
+
@require_api_key
|
| 478 |
+
@rate_limit
|
| 479 |
def stream():
|
| 480 |
data = request.json or {}
|
| 481 |
project = data.get("project", "default")
|
|
|
|
| 518 |
return resp
|
| 519 |
|
| 520 |
@app.route("/search_web", methods=["POST"])
|
| 521 |
+
@require_api_key
|
| 522 |
+
@rate_limit
|
| 523 |
def search_web():
|
| 524 |
data = request.json or {}
|
| 525 |
query = (data.get("query") or "").strip()
|
|
|
|
| 547 |
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
| 548 |
|
| 549 |
@app.route("/upload_file/<project>", methods=["POST"])
|
| 550 |
+
@require_api_key
|
| 551 |
+
@rate_limit
|
| 552 |
def upload_file(project):
|
| 553 |
if "file" not in request.files:
|
| 554 |
return jsonify({"error": "no file part"}), 400
|
|
|
|
| 566 |
return jsonify({"error": "file type not allowed"}), 400
|
| 567 |
|
| 568 |
@app.route("/delete_project", methods=["POST"])
|
| 569 |
+
@require_api_key
|
| 570 |
def delete_project():
|
| 571 |
data = request.json or {}
|
| 572 |
project = (data.get("project") or "").strip()
|
|
|
|
| 600 |
return 1, "", str(e)
|
| 601 |
|
| 602 |
@app.route("/run/<project>", methods=["POST"])
|
| 603 |
+
@require_api_key
|
| 604 |
+
@rate_limit
|
| 605 |
def run_project(project):
|
| 606 |
if not RUNNER_ENABLED:
|
| 607 |
return jsonify({"error": "runner disabled; set RUNNER_ENABLED=1"}), 400
|
|
|
|
| 617 |
return jsonify({"code": code, "stdout": out, "stderr": err})
|
| 618 |
|
| 619 |
@app.route("/lint/<project>", methods=["POST"])
|
| 620 |
+
@require_api_key
|
| 621 |
+
@rate_limit
|
| 622 |
def lint(project):
|
| 623 |
if not LINTER_ENABLED:
|
| 624 |
return jsonify({"error": "linter disabled; set LINTER_ENABLED=1"}), 400
|
sec_test.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Qwen Coder API - Security Test Script
|
| 3 |
+
|
| 4 |
+
Tests:
|
| 5 |
+
1. Protected routes reject requests with no API key (401)
|
| 6 |
+
2. Protected routes reject requests with a WRONG key (401) - this also
|
| 7 |
+
re-confirms the substring-bypass bug is actually fixed (see test 3)
|
| 8 |
+
3. A key that's a SUBSTRING of the real one is rejected (401) - this is
|
| 9 |
+
the specific bug we found and fixed (the old `in` comparison would
|
| 10 |
+
have let this one through)
|
| 11 |
+
4. Protected routes accept requests with the CORRECT key (200)
|
| 12 |
+
5. Rate limiting kicks in after RATE_LIMIT requests within 60s (429)
|
| 13 |
+
6. Flags routes that are currently unprotected (/add_project, /cancel)
|
| 14 |
+
so you don't forget they're still open
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python test_security.py --url http://localhost:8080 --key YOUR_REAL_API_KEY
|
| 18 |
+
|
| 19 |
+
Or, to read the key from your .env file automatically:
|
| 20 |
+
pip install python-dotenv
|
| 21 |
+
python test_security.py --url http://localhost:8080
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import requests
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
from dotenv import load_dotenv
|
| 29 |
+
import os
|
| 30 |
+
load_dotenv()
|
| 31 |
+
except ImportError:
|
| 32 |
+
pass
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
PASS = "\033[92mPASS\033[0m"
|
| 36 |
+
FAIL = "\033[91mFAIL\033[0m"
|
| 37 |
+
WARN = "\033[93mWARN\033[0m"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def check(label: str, condition: bool, detail: str = ""):
|
| 41 |
+
status = PASS if condition else FAIL
|
| 42 |
+
print(f" [{status}] {label}" + (f" — {detail}" if detail else ""))
|
| 43 |
+
return condition
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_no_key(base_url: str):
|
| 47 |
+
print("\n1. Requests with NO API key should be rejected (401)")
|
| 48 |
+
r = requests.post(f"{base_url}/add_project", json={"project": "test_no_key"})
|
| 49 |
+
if r.status_code == 401:
|
| 50 |
+
check("/add_project rejects missing key", True)
|
| 51 |
+
else:
|
| 52 |
+
print(f" [{WARN}] /add_project has no auth protection yet (got {r.status_code}, not 401) — known open item")
|
| 53 |
+
|
| 54 |
+
r = requests.post(f"{base_url}/chat", json={"project": "default", "message": "hi"})
|
| 55 |
+
check("/chat rejects missing key", r.status_code == 401, f"got {r.status_code}")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_wrong_key(base_url: str, real_key: str):
|
| 59 |
+
print("\n2. Requests with a WRONG key should be rejected (401)")
|
| 60 |
+
r = requests.post(
|
| 61 |
+
f"{base_url}/chat",
|
| 62 |
+
json={"project": "default", "message": "hi"},
|
| 63 |
+
headers={"X-API-Key": "definitely-not-the-real-key"},
|
| 64 |
+
)
|
| 65 |
+
check("/chat rejects wrong key", r.status_code == 401, f"got {r.status_code}")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_substring_bug(base_url: str, real_key: str):
|
| 69 |
+
print("\n3. A SUBSTRING of the real key should be rejected (the original bug)")
|
| 70 |
+
if len(real_key) < 4:
|
| 71 |
+
print(f" [{WARN}] Real key too short to meaningfully test substring bypass, skipping")
|
| 72 |
+
return
|
| 73 |
+
substring_key = real_key[: max(3, len(real_key) // 3)]
|
| 74 |
+
r = requests.post(
|
| 75 |
+
f"{base_url}/chat",
|
| 76 |
+
json={"project": "default", "message": "hi"},
|
| 77 |
+
headers={"X-API-Key": substring_key},
|
| 78 |
+
)
|
| 79 |
+
check(
|
| 80 |
+
"/chat rejects a substring of the real key",
|
| 81 |
+
r.status_code == 401,
|
| 82 |
+
f"sent '{substring_key}...', got {r.status_code} (200 here would mean the old bug is back)",
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_correct_key(base_url: str, real_key: str):
|
| 87 |
+
print("\n4. Requests WITH the correct key should succeed")
|
| 88 |
+
r = requests.post(f"{base_url}/add_project", json={"project": "test_correct_key"})
|
| 89 |
+
r2 = requests.post(
|
| 90 |
+
f"{base_url}/chat",
|
| 91 |
+
json={"project": "test_correct_key", "message": "Say hello in one word."},
|
| 92 |
+
headers={"X-API-Key": real_key},
|
| 93 |
+
)
|
| 94 |
+
check("/chat accepts the correct key", r2.status_code == 200, f"got {r2.status_code}: {r2.text[:200]}")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_rate_limit(base_url: str, real_key: str, rate_limit: int):
|
| 98 |
+
print(f"\n5. Rate limiting should trigger after {rate_limit} requests/minute")
|
| 99 |
+
hit_429 = False
|
| 100 |
+
for i in range(rate_limit + 3):
|
| 101 |
+
r = requests.post(
|
| 102 |
+
f"{base_url}/search_web",
|
| 103 |
+
json={"query": "test"},
|
| 104 |
+
headers={"X-API-Key": real_key},
|
| 105 |
+
)
|
| 106 |
+
if r.status_code == 429:
|
| 107 |
+
hit_429 = True
|
| 108 |
+
check("Rate limit triggered", True, f"hit 429 on request #{i + 1}")
|
| 109 |
+
break
|
| 110 |
+
if not hit_429:
|
| 111 |
+
check("Rate limit triggered", False, f"never got a 429 after {rate_limit + 3} requests")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_open_routes(base_url: str):
|
| 115 |
+
print("\n6. Checking currently-unprotected routes (informational, not pass/fail)")
|
| 116 |
+
r = requests.post(f"{base_url}/cancel")
|
| 117 |
+
if r.status_code != 401:
|
| 118 |
+
print(f" [{WARN}] /cancel has no auth protection (got {r.status_code}) — decide if this needs one")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
if __name__ == "__main__":
|
| 122 |
+
parser = argparse.ArgumentParser()
|
| 123 |
+
parser.add_argument("--url", default="http://localhost:8080", help="Base URL of the running app")
|
| 124 |
+
parser.add_argument("--key", default=None, help="Your real API_KEY (or set it in .env)")
|
| 125 |
+
parser.add_argument("--rate-limit", type=int, default=10, help="Expected RATE_LIMIT value from app.py")
|
| 126 |
+
args = parser.parse_args()
|
| 127 |
+
|
| 128 |
+
real_key = args.key or os.getenv("API_KEY")
|
| 129 |
+
if not real_key:
|
| 130 |
+
raise SystemExit("No API key provided. Use --key YOUR_KEY or set API_KEY in a .env file.")
|
| 131 |
+
|
| 132 |
+
print(f"=== Testing {args.url} ===")
|
| 133 |
+
test_no_key(args.url)
|
| 134 |
+
test_wrong_key(args.url, real_key)
|
| 135 |
+
test_substring_bug(args.url, real_key)
|
| 136 |
+
test_correct_key(args.url, real_key)
|
| 137 |
+
test_rate_limit(args.url, real_key, args.rate_limit)
|
| 138 |
+
test_open_routes(args.url)
|
| 139 |
+
print("\nDone.")
|
static/script.js
CHANGED
|
@@ -9,7 +9,22 @@ const deleteProjectBtn = document.getElementById("deleteProject");
|
|
| 9 |
|
| 10 |
let currentAbortController = null;
|
| 11 |
|
| 12 |
-
// ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
async function loadHistory(project) {
|
| 15 |
chatDiv.innerHTML = '<div class="message assistant"><em>Loading history...</em></div>';
|
|
@@ -25,13 +40,11 @@ async function loadHistory(project) {
|
|
| 25 |
if (msg.role === "user") {
|
| 26 |
appendAndScroll(makeUserNode(msg.content));
|
| 27 |
} else {
|
| 28 |
-
// Assistant messages need markdown parsing
|
| 29 |
const node = makeAssistantNode();
|
| 30 |
node.innerHTML = `<strong>Assistant:</strong><br>${marked.parse(msg.content)}`;
|
| 31 |
appendAndScroll(node);
|
| 32 |
}
|
| 33 |
});
|
| 34 |
-
// Re-highlight all code blocks after loading
|
| 35 |
if (typeof hljs !== 'undefined') hljs.highlightAll();
|
| 36 |
} else {
|
| 37 |
chatDiv.innerHTML = '<div class="message assistant"><em>New project started. No history found.</em></div>';
|
|
@@ -42,7 +55,6 @@ async function loadHistory(project) {
|
|
| 42 |
}
|
| 43 |
}
|
| 44 |
|
| 45 |
-
// Listen for dropdown changes
|
| 46 |
projectSelect.addEventListener("change", () => {
|
| 47 |
loadHistory(projectSelect.value);
|
| 48 |
});
|
|
@@ -81,26 +93,33 @@ async function refreshProjects() {
|
|
| 81 |
projectSelect.appendChild(opt);
|
| 82 |
});
|
| 83 |
|
| 84 |
-
// Keep selection if it still exists, otherwise load the first project
|
| 85 |
if (data.includes(currentVal)) {
|
| 86 |
projectSelect.value = currentVal;
|
| 87 |
} else if (data.length > 0) {
|
| 88 |
projectSelect.value = data[0];
|
| 89 |
-
loadHistory(data[0]);
|
| 90 |
}
|
| 91 |
}
|
| 92 |
|
| 93 |
addProjectBtn.addEventListener("click", async () => {
|
| 94 |
const name = prompt("Project Name:");
|
| 95 |
if (!name) return;
|
| 96 |
-
await fetch(`/add_project
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
await refreshProjects();
|
| 98 |
});
|
| 99 |
|
| 100 |
deleteProjectBtn.addEventListener("click", async () => {
|
| 101 |
const p = projectSelect.value;
|
| 102 |
if (!p || !confirm(`Delete project ${p}?`)) return;
|
| 103 |
-
await fetch(`/delete_project
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
await refreshProjects();
|
| 105 |
});
|
| 106 |
|
|
@@ -126,8 +145,9 @@ sendBtn.addEventListener("click", async () => {
|
|
| 126 |
assistantNode.innerHTML = `<strong>Assistant:</strong><br><em>Searching web...</em>`;
|
| 127 |
const sResp = await fetch("/search_web", {
|
| 128 |
method: "POST",
|
| 129 |
-
headers:
|
| 130 |
-
body: JSON.stringify({ query: text })
|
|
|
|
| 131 |
});
|
| 132 |
const sData = await sResp.json();
|
| 133 |
search_results = sData.results || [];
|
|
@@ -135,7 +155,7 @@ sendBtn.addEventListener("click", async () => {
|
|
| 135 |
|
| 136 |
const res = await fetch("/chat", {
|
| 137 |
method: "POST",
|
| 138 |
-
headers:
|
| 139 |
body: JSON.stringify({ project, message: text, search_results }),
|
| 140 |
signal: currentAbortController.signal
|
| 141 |
});
|
|
@@ -159,7 +179,6 @@ sendBtn.addEventListener("click", async () => {
|
|
| 159 |
}
|
| 160 |
});
|
| 161 |
|
| 162 |
-
// Clear UI only (doesn't delete database)
|
| 163 |
clearBtn.addEventListener("click", () => {
|
| 164 |
chatDiv.innerHTML = "";
|
| 165 |
});
|
|
@@ -174,7 +193,15 @@ async function uploadFile(project) {
|
|
| 174 |
formData.append("file", fileInput.files[0]);
|
| 175 |
|
| 176 |
try {
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
const data = await res.json();
|
| 179 |
if (data.status === "ok") {
|
| 180 |
alert(`Uploaded: ${data.filename}`);
|
|
@@ -187,10 +214,8 @@ async function uploadFile(project) {
|
|
| 187 |
}
|
| 188 |
}
|
| 189 |
|
| 190 |
-
// Ctrl+Enter support
|
| 191 |
promptInput.addEventListener("keydown", (e) => {
|
| 192 |
if (e.ctrlKey && e.key === "Enter") sendBtn.click();
|
| 193 |
});
|
| 194 |
|
| 195 |
-
// Initial Init
|
| 196 |
refreshProjects();
|
|
|
|
| 9 |
|
| 10 |
let currentAbortController = null;
|
| 11 |
|
| 12 |
+
// ---------- API Key Management ----------
|
| 13 |
+
function getApiKey() {
|
| 14 |
+
return window.APP_API_KEY || "";
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function getHeaders(includeContentType = true) {
|
| 18 |
+
const headers = {
|
| 19 |
+
"X-API-Key": getApiKey()
|
| 20 |
+
};
|
| 21 |
+
if (includeContentType) {
|
| 22 |
+
headers["Content-Type"] = "application/json";
|
| 23 |
+
}
|
| 24 |
+
return headers;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// ---------- Project History Loading ----------
|
| 28 |
|
| 29 |
async function loadHistory(project) {
|
| 30 |
chatDiv.innerHTML = '<div class="message assistant"><em>Loading history...</em></div>';
|
|
|
|
| 40 |
if (msg.role === "user") {
|
| 41 |
appendAndScroll(makeUserNode(msg.content));
|
| 42 |
} else {
|
|
|
|
| 43 |
const node = makeAssistantNode();
|
| 44 |
node.innerHTML = `<strong>Assistant:</strong><br>${marked.parse(msg.content)}`;
|
| 45 |
appendAndScroll(node);
|
| 46 |
}
|
| 47 |
});
|
|
|
|
| 48 |
if (typeof hljs !== 'undefined') hljs.highlightAll();
|
| 49 |
} else {
|
| 50 |
chatDiv.innerHTML = '<div class="message assistant"><em>New project started. No history found.</em></div>';
|
|
|
|
| 55 |
}
|
| 56 |
}
|
| 57 |
|
|
|
|
| 58 |
projectSelect.addEventListener("change", () => {
|
| 59 |
loadHistory(projectSelect.value);
|
| 60 |
});
|
|
|
|
| 93 |
projectSelect.appendChild(opt);
|
| 94 |
});
|
| 95 |
|
|
|
|
| 96 |
if (data.includes(currentVal)) {
|
| 97 |
projectSelect.value = currentVal;
|
| 98 |
} else if (data.length > 0) {
|
| 99 |
projectSelect.value = data[0];
|
| 100 |
+
loadHistory(data[0]);
|
| 101 |
}
|
| 102 |
}
|
| 103 |
|
| 104 |
addProjectBtn.addEventListener("click", async () => {
|
| 105 |
const name = prompt("Project Name:");
|
| 106 |
if (!name) return;
|
| 107 |
+
await fetch(`/add_project`, {
|
| 108 |
+
method: "POST",
|
| 109 |
+
headers: getHeaders(),
|
| 110 |
+
body: JSON.stringify({ project: name })
|
| 111 |
+
});
|
| 112 |
await refreshProjects();
|
| 113 |
});
|
| 114 |
|
| 115 |
deleteProjectBtn.addEventListener("click", async () => {
|
| 116 |
const p = projectSelect.value;
|
| 117 |
if (!p || !confirm(`Delete project ${p}?`)) return;
|
| 118 |
+
await fetch(`/delete_project`, {
|
| 119 |
+
method: "POST",
|
| 120 |
+
headers: getHeaders(),
|
| 121 |
+
body: JSON.stringify({ project: p })
|
| 122 |
+
});
|
| 123 |
await refreshProjects();
|
| 124 |
});
|
| 125 |
|
|
|
|
| 145 |
assistantNode.innerHTML = `<strong>Assistant:</strong><br><em>Searching web...</em>`;
|
| 146 |
const sResp = await fetch("/search_web", {
|
| 147 |
method: "POST",
|
| 148 |
+
headers: getHeaders(),
|
| 149 |
+
body: JSON.stringify({ query: text }),
|
| 150 |
+
signal: currentAbortController.signal
|
| 151 |
});
|
| 152 |
const sData = await sResp.json();
|
| 153 |
search_results = sData.results || [];
|
|
|
|
| 155 |
|
| 156 |
const res = await fetch("/chat", {
|
| 157 |
method: "POST",
|
| 158 |
+
headers: getHeaders(),
|
| 159 |
body: JSON.stringify({ project, message: text, search_results }),
|
| 160 |
signal: currentAbortController.signal
|
| 161 |
});
|
|
|
|
| 179 |
}
|
| 180 |
});
|
| 181 |
|
|
|
|
| 182 |
clearBtn.addEventListener("click", () => {
|
| 183 |
chatDiv.innerHTML = "";
|
| 184 |
});
|
|
|
|
| 193 |
formData.append("file", fileInput.files[0]);
|
| 194 |
|
| 195 |
try {
|
| 196 |
+
// Do not set Content-Type header when uploading FormData so the browser automatically handles the boundary
|
| 197 |
+
const headers = {
|
| 198 |
+
"X-API-Key": getApiKey()
|
| 199 |
+
};
|
| 200 |
+
const res = await fetch(`/upload_file/${project}`, {
|
| 201 |
+
method: "POST",
|
| 202 |
+
headers: headers,
|
| 203 |
+
body: formData
|
| 204 |
+
});
|
| 205 |
const data = await res.json();
|
| 206 |
if (data.status === "ok") {
|
| 207 |
alert(`Uploaded: ${data.filename}`);
|
|
|
|
| 214 |
}
|
| 215 |
}
|
| 216 |
|
|
|
|
| 217 |
promptInput.addEventListener("keydown", (e) => {
|
| 218 |
if (e.ctrlKey && e.key === "Enter") sendBtn.click();
|
| 219 |
});
|
| 220 |
|
|
|
|
| 221 |
refreshProjects();
|
templates/index.html
CHANGED
|
@@ -39,7 +39,9 @@
|
|
| 39 |
// Initialize Markdown and Code Highlighting
|
| 40 |
marked.setOptions({ gfm: true, breaks: true });
|
| 41 |
</script>
|
| 42 |
-
|
|
|
|
|
|
|
| 43 |
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
| 44 |
</body>
|
| 45 |
</html>
|
|
|
|
| 39 |
// Initialize Markdown and Code Highlighting
|
| 40 |
marked.setOptions({ gfm: true, breaks: true });
|
| 41 |
</script>
|
| 42 |
+
<script>
|
| 43 |
+
window.APP_API_KEY = "{{ api_key }}";
|
| 44 |
+
</script>
|
| 45 |
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
| 46 |
</body>
|
| 47 |
</html>
|