Capture ThinkStorm project: codebase state, workflows, and access control policies
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
ThinkStorm Main Application Entrypoint
|
||||
Initializes FastAPI, mounts routes, static files, Jinja2 templates, and starts the background queue worker.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request, Depends, HTTPException, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .config import config, BASE_DIR
|
||||
from .database import init_db, get_db
|
||||
from .models import User, UserRole
|
||||
from .auth import get_current_user
|
||||
from .queue.worker import job_queue
|
||||
from .api.ideas import router as ideas_router
|
||||
from .api.admin import router as admin_router
|
||||
from .api.auth_routes import router as auth_router
|
||||
from .prompts.catalog import get_all_prompts, get_all_profiles
|
||||
|
||||
TEMPLATES_DIR = BASE_DIR / "thinkstorm" / "templates"
|
||||
STATIC_DIR = BASE_DIR / "thinkstorm" / "static"
|
||||
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup
|
||||
print("[ThinkStorm] Initializing operational SQLite schema and seed defaults...")
|
||||
init_db()
|
||||
print("[ThinkStorm] Starting background job queue worker...")
|
||||
await job_queue.start()
|
||||
yield
|
||||
# Shutdown
|
||||
print("[ThinkStorm] Shutting down...")
|
||||
|
||||
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
|
||||
app = FastAPI(
|
||||
title="ThinkStorm Orchestrator",
|
||||
description="AI-Assisted Self-Hosted Idea Collection and Incubation Platform",
|
||||
version=config.version,
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_no_cache_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if request.url.path.startswith("/static/") or request.url.path.endswith(".js"):
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Mount API routers
|
||||
app.include_router(ideas_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
# Trash root aliases
|
||||
from .api.ideas import empty_trash
|
||||
app.add_api_route("/api/trash/empty", empty_trash, methods=["POST"])
|
||||
app.add_api_route("/api/trash", empty_trash, methods=["DELETE"])
|
||||
|
||||
# Gitea OAuth callback root alias
|
||||
from .api.auth_routes import gitea_oauth_callback
|
||||
app.add_api_route("/auth/gitea/callback", gitea_oauth_callback, methods=["GET"])
|
||||
|
||||
# ----------------- Frontend HTML Routes -----------------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def home_view(request: Request, user: User = Depends(get_current_user)):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="index.html",
|
||||
context={"user": user, "active_page": "home"}
|
||||
)
|
||||
|
||||
@app.get("/ideas", response_class=HTMLResponse)
|
||||
async def ideas_view(
|
||||
request: Request,
|
||||
state: str = None,
|
||||
category: str = None,
|
||||
tag: str = None,
|
||||
q: str = None,
|
||||
user: User = Depends(get_current_user)
|
||||
):
|
||||
if user.role == UserRole.ANONYMOUS:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
query = """
|
||||
SELECT i.*,
|
||||
GROUP_CONCAT(DISTINCT c.name) AS category_names,
|
||||
GROUP_CONCAT(DISTINCT t.name) AS tag_names
|
||||
FROM ideas i
|
||||
LEFT JOIN idea_categories ic ON i.id = ic.idea_id
|
||||
LEFT JOIN categories c ON ic.category_id = c.id
|
||||
LEFT JOIN idea_tags it ON i.id = it.idea_id
|
||||
LEFT JOIN tags t ON it.tag_id = t.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
if state and state.upper() != "ALL":
|
||||
query += " AND i.lifecycle_state = ?"
|
||||
params.append(state.upper())
|
||||
elif state and state.upper() == "ALL":
|
||||
query += " AND i.lifecycle_state NOT IN ('TRASHED')"
|
||||
elif not state:
|
||||
query += " AND i.lifecycle_state NOT IN ('QUARANTINED', 'REJECTED', 'TRASHED')"
|
||||
|
||||
if category:
|
||||
query += " AND c.name = ?"
|
||||
params.append(category)
|
||||
if tag:
|
||||
query += " AND t.name = ?"
|
||||
params.append(tag.lstrip("#").lower())
|
||||
if q:
|
||||
query += " AND (i.title LIKE ? OR i.summary LIKE ? OR i.original_text LIKE ?)"
|
||||
term = f"%{q}%"
|
||||
params.extend([term, term, term])
|
||||
|
||||
query += " GROUP BY i.id ORDER BY i.submitted_at DESC LIMIT 50"
|
||||
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
trashed_count_row = conn.execute("SELECT COUNT(*) FROM ideas WHERE lifecycle_state = 'TRASHED'").fetchone()
|
||||
trashed_count = trashed_count_row[0] if trashed_count_row else 0
|
||||
ideas = []
|
||||
for r in rows:
|
||||
ideas.append({
|
||||
"id": r["id"],
|
||||
"title": r["title"] or "Untitled Idea",
|
||||
"summary": r["summary"],
|
||||
"lifecycle_state": r["lifecycle_state"],
|
||||
"processing_state": r["processing_state"],
|
||||
"enrichment_level": r["enrichment_level"],
|
||||
"claimed_by": r["claimed_by"],
|
||||
"submitted_at": r["submitted_at"],
|
||||
"trashed_at": r["trashed_at"] if "trashed_at" in r.keys() else None,
|
||||
"categories": [c.strip() for c in r["category_names"].split(",")] if r["category_names"] else [],
|
||||
"tags": [t.strip() for t in r["tag_names"].split(",")] if r["tag_names"] else []
|
||||
})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="ideas.html",
|
||||
context={
|
||||
"user": user,
|
||||
"ideas": ideas,
|
||||
"current_state": state,
|
||||
"search_query": q,
|
||||
"trashed_count": trashed_count,
|
||||
"active_page": "ideas"
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/ideas/{idea_id}", response_class=HTMLResponse)
|
||||
async def idea_detail_view(idea_id: str, request: Request, user: User = Depends(get_current_user)):
|
||||
if user.role == UserRole.ANONYMOUS:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
from .api.ideas import get_idea_detail
|
||||
try:
|
||||
idea_data = await get_idea_detail(idea_id, current_user=user)
|
||||
except HTTPException as e:
|
||||
if e.status_code == 404:
|
||||
return HTMLResponse("<h1>Idea Not Found</h1>", status_code=404)
|
||||
elif e.status_code == 401:
|
||||
return RedirectResponse("/login")
|
||||
elif e.status_code == 403:
|
||||
return HTMLResponse(f"<h1>Access Denied: {e.detail}</h1>", status_code=403)
|
||||
raise
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="idea_detail.html",
|
||||
context={"user": user, "idea": idea_data, "active_page": "ideas"}
|
||||
)
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse)
|
||||
async def admin_view(request: Request, user: User = Depends(get_current_user)):
|
||||
if user.role != UserRole.ADMIN:
|
||||
return RedirectResponse("/login")
|
||||
|
||||
from .api.admin import list_services, list_jobs, list_quarantined_ideas, get_token_metrics, get_audit_logs
|
||||
prompts = get_all_prompts(is_admin=True)
|
||||
profiles = get_all_profiles()
|
||||
services = await list_services()
|
||||
jobs_data = await list_jobs()
|
||||
quarantined = await list_quarantined_ideas()
|
||||
metrics = await get_token_metrics()
|
||||
audit_logs = await get_audit_logs()
|
||||
|
||||
with get_db() as conn:
|
||||
trashed_rows = conn.execute("SELECT * FROM ideas WHERE lifecycle_state = 'TRASHED' ORDER BY trashed_at DESC, submitted_at DESC").fetchall()
|
||||
trashed_ideas = [dict(r) for r in trashed_rows]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="admin.html",
|
||||
context={
|
||||
"user": user,
|
||||
"prompts": prompts,
|
||||
"profiles": profiles,
|
||||
"services": services,
|
||||
"jobs_data": jobs_data,
|
||||
"quarantined": quarantined,
|
||||
"trashed_ideas": trashed_ideas,
|
||||
"trashed_count": len(trashed_ideas),
|
||||
"metrics": metrics,
|
||||
"audit_logs": audit_logs,
|
||||
"active_page": "admin"
|
||||
}
|
||||
)
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_view(request: Request, user: User = Depends(get_current_user)):
|
||||
if user.role != UserRole.ANONYMOUS:
|
||||
return RedirectResponse("/")
|
||||
return templates.TemplateResponse(request=request, name="login.html", context={"user": user, "active_page": "login"})
|
||||
|
||||
@app.get("/auth/gitea/callback")
|
||||
async def gitea_callback_redirect(code: str, request: Request):
|
||||
# Handled via auth API router
|
||||
from .api.auth_routes import gitea_oauth_callback
|
||||
from fastapi.responses import Response
|
||||
res = Response()
|
||||
await gitea_oauth_callback(code=code, request=request, response=res)
|
||||
redirect = RedirectResponse("/", status_code=302)
|
||||
# Forward set-cookie
|
||||
for k, v in res.headers.items():
|
||||
if k.lower() == "set-cookie":
|
||||
redirect.headers[k] = v
|
||||
return redirect
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("thinkstorm.main:app", host=config.host, port=config.port, reload=True)
|
||||
Reference in New Issue
Block a user