Fafnirk commited on
Commit
0986c85
·
1 Parent(s): 733733f

Added security checks, updated README.md to reflect that

Browse files
Files changed (6) hide show
  1. Dockerfile +7 -2
  2. README.md +16 -0
  3. backend/app.py +56 -6
  4. sec_test.py +139 -0
  5. static/script.js +40 -15
  6. 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 json
6
  import sqlite3
7
  import subprocess
8
- from typing import List, Dict, 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
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=4012, n_threads=4, n_batch=512, flash_attn=True)
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
- // ---------- NEW: Project History Loading ----------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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]); // Load history for the initial project
90
  }
91
  }
92
 
93
  addProjectBtn.addEventListener("click", async () => {
94
  const name = prompt("Project Name:");
95
  if (!name) return;
96
- await fetch(`/add_project/${name}`, { method: "POST" });
 
 
 
 
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/${p}`, { method: "DELETE" });
 
 
 
 
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: { "Content-Type": "application/json" },
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: { "Content-Type": "application/json" },
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
- const res = await fetch(`/upload_file/${project}`, { method: "POST", body: formData });
 
 
 
 
 
 
 
 
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>