Rhinox13/chatapi
0
1from __future__ import annotations
2
3from pathlib import Path
4
5from flask import Flask, abort, send_from_directory
6from flask_cors import CORS
7
8from .core import AppDependencies, AuthContext, settings
9from .repositories import ConversationStore, SystemConfigStore, UserStore, utc_now_iso
10from .services import ImageAssetStore, MessageRateLimiter, PendingTurnRegistry
11from .services.csrf import register_csrf_protection
12from .services.realtime import RealtimeBroker
13from .routes import (
14 register_admin_routes,
15 register_auth_routes,
16 register_conversation_routes,
17 register_realtime_routes,
18 register_response_routes,
19 register_statistics_routes,
20 register_upload_routes,
21 register_user_api_key_routes,
22 register_user_config_routes,
23)
24
25
26def create_app() -> Flask:
27 store = ConversationStore(settings.db_path)
28 system_config_store = SystemConfigStore(settings.db_path)
29 user_store = UserStore(settings.db_path)
30
31 # Ensure admin user exists
32 admin = user_store.get_user_by_username(settings.admin_username)
33 if admin is None:
34 user_store.create_user(settings.admin_username, settings.admin_password, role="admin")
35 elif admin.role != "admin":
36 # Promote to admin if somehow not admin
37 with user_store._connection() as conn:
38 conn.execute(
39 "UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?",
40 (utc_now_iso(), admin.id),
41 )
42
43 session_secret = system_config_store.get_or_create_session_secret(settings.session_secret)
44
45 app = Flask(__name__)
46 app.config.update(SECRET_KEY=session_secret)
47 app.config.update(
48 SESSION_COOKIE_HTTPONLY=True,
49 SESSION_COOKIE_SAMESITE="Lax",
50 )
51 CORS(app, supports_credentials=True, origins=settings.cors_origins)
52 register_csrf_protection(app, cors_origins=settings.cors_origins)
53
54 auth = AuthContext(store, user_store)
55 pending_turns = PendingTurnRegistry()
56 message_rate_limiter = MessageRateLimiter()
57 image_store = ImageAssetStore(
58 settings.uploads_img_dir,
59 system_config_store=system_config_store,
60 user_store=user_store,
61 )
62 realtime = RealtimeBroker(store, user_store)
63 deps = AppDependencies(
64 settings=settings,
65 auth=auth,
66 store=store,
67 system_config_store=system_config_store,
68 user_store=user_store,
69 pending_turns=pending_turns,
70 message_rate_limiter=message_rate_limiter,
71 image_store=image_store,
72 )
73 app.extensions["chat_store"] = store
74 app.extensions["chat_system_config_store"] = system_config_store
75 app.extensions["chat_user_store"] = user_store
76 app.extensions["chat_realtime"] = realtime
77 app.extensions["chat_image_store"] = image_store
78
79 messages = store.iter_messages()
80 owner_lookup = {conversation.id: conversation.owner_id for conversation in store.list_conversations_all()}
81 image_store.backfill_owners_from_messages(messages, owner_lookup)
82 image_store.cleanup_orphans(messages)
83
84 @app.after_request
85 def apply_security_headers(response):
86 response.headers.setdefault("X-Frame-Options", "DENY")
87 response.headers.setdefault("X-Content-Type-Options", "nosniff")
88 response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
89 response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
90 return response
91
92 @app.get("/api/health")
93 def health():
94 return {"ok": True, "title": system_config_store.get_effective_title("ChatAPI")}
95
96 register_auth_routes(
97 app,
98 auth=auth,
99 settings=settings,
100 system_config_store=system_config_store,
101 user_store=user_store,
102 )
103 register_admin_routes(app, auth=auth, store=store, user_store=user_store)
104 register_user_config_routes(
105 app,
106 auth=auth,
107 user_store=user_store,
108 system_config_store=system_config_store,
109 )
110 register_user_api_key_routes(app, auth=auth, user_store=user_store)
111 register_conversation_routes(app, deps=deps)
112 register_realtime_routes(app, deps=deps)
113 register_response_routes(app, deps=deps)
114 register_statistics_routes(app, deps=deps)
115 register_upload_routes(app, deps=deps)
116
117 if settings.web_dist_dir:
118 web_dist_dir = settings.web_dist_dir
119 index_file = web_dist_dir / "index.html"
120 if not web_dist_dir.exists():
121 raise FileNotFoundError(f"WEB_DIST_DIR not found: {web_dist_dir}")
122 if not web_dist_dir.is_dir():
123 raise NotADirectoryError(f"WEB_DIST_DIR is not a directory: {web_dist_dir}")
124
125 def _send_dist_file(request_path: str):
126 candidate = (web_dist_dir / request_path).resolve()
127 try:
128 candidate.relative_to(web_dist_dir.resolve())
129 except ValueError as exc:
130 raise FileNotFoundError(request_path) from exc
131 if candidate.is_file():
132 relative_path = candidate.relative_to(web_dist_dir).as_posix()
133 return send_from_directory(web_dist_dir, relative_path)
134 raise FileNotFoundError(request_path)
135
136 @app.get("/", defaults={"request_path": ""})
137 @app.get("/<path:request_path>")
138 def serve_web_dist(request_path: str):
139 if request_path.startswith("api/") or request_path.startswith("v1/"):
140 abort(404)
141 if not request_path:
142 if not index_file.exists():
143 abort(404)
144 return send_from_directory(web_dist_dir, "index.html")
145 try:
146 return _send_dist_file(request_path)
147 except FileNotFoundError:
148 if index_file.exists():
149 return send_from_directory(web_dist_dir, "index.html")
150 abort(404)
151
152 return app
153 