33import sqlite3
44from datetime import datetime , timezone
55from pathlib import Path
6+ from typing import Optional
7+ from urllib .parse import unquote , urlparse
68from sqlalchemy import event , create_engine , Column , String , Text , Boolean , DateTime , Integer , ForeignKey , JSON , Index , func , text
7- from sqlalchemy .engine import Engine
9+ from sqlalchemy .engine import Engine , make_url
810from sqlalchemy .types import TypeDecorator
911from sqlalchemy .ext .declarative import declarative_base , declared_attr
1012from sqlalchemy .orm import relationship , sessionmaker , backref
1113
1214from src .runtime_paths import get_app_root
15+ from core .platform_compat import safe_chmod , IS_WINDOWS
1316
1417logger = logging .getLogger (__name__ )
1518
@@ -42,12 +45,28 @@ def _default_database_url() -> str:
4245
4346
4447def _normalize_sqlite_url (url : str ) -> str :
45- if not url .startswith ("sqlite:///" ):
48+ """Resolve relative ordinary SQLite paths without rewriting URI filenames."""
49+ try :
50+ parsed = make_url (url )
51+ except Exception :
52+ return url
53+
54+ if parsed .get_backend_name () != "sqlite" :
4655 return url
47- db_path = url .replace ("sqlite:///" , "" , 1 )
48- if db_path == ":memory:" or os .path .isabs (db_path ):
56+
57+ db_path = parsed .database
58+ if (
59+ not db_path
60+ or db_path == ":memory:"
61+ or str (db_path ).lower ().startswith ("file:" )
62+ or os .path .isabs (str (db_path ))
63+ ):
4964 return url
50- return f"sqlite:///{ (Path (get_app_root ()) / db_path ).resolve ().as_posix ()} "
65+
66+ absolute_path = (Path (get_app_root ()) / str (db_path )).resolve ().as_posix ()
67+ return parsed .set (database = absolute_path ).render_as_string (
68+ hide_password = False
69+ )
5170
5271
5372# Get database URL from environment, default to SQLite in DATA_DIR
@@ -59,6 +78,59 @@ def _normalize_sqlite_url(url: str) -> str:
5978 connect_args = {"check_same_thread" : False } if "sqlite" in DATABASE_URL else {}
6079)
6180
81+
82+ # Sidecar files SQLite can create next to the main DB. -journal is the default
83+ # rollback journal; -wal/-shm appear once WAL is enabled. Each can hold copies of
84+ # secret-bearing pages, so they get the same 0o600 lockdown as the DB itself.
85+ _SQLITE_SIDECARS = ("-journal" , "-wal" , "-shm" )
86+
87+
88+ def _sqlite_db_path (url ) -> Optional [str ]:
89+ """Return the filesystem path for a file-backed SQLite URL.
90+
91+ SQLite query parameters such as ``mode=memory`` only affect filename
92+ semantics when SQLAlchemy enables URI handling with ``uri=true``. Ordinary
93+ file URLs must therefore remain file-backed even when they contain a query
94+ parameter named ``mode``.
95+
96+ For SQLite ``file:`` URIs, an empty authority or ``localhost`` identifies a
97+ local path. Other authorities are retained as UNC-style paths.
98+ """
99+ if url .get_backend_name () != "sqlite" :
100+ return None
101+
102+ db_path = url .database
103+ if not db_path or db_path == ":memory:" :
104+ return None
105+
106+ db_path = str (db_path )
107+ query = {
108+ str (key ).lower (): str (value ).strip ().lower ()
109+ for key , value in dict (getattr (url , "query" , {}) or {}).items ()
110+ }
111+ uri_enabled = query .get ("uri" ) in {"1" , "true" , "yes" , "on" }
112+ is_file_uri = db_path .lower ().startswith ("file:" )
113+
114+ if not uri_enabled or not is_file_uri :
115+ return db_path
116+
117+ if (
118+ db_path .lower ().startswith ("file::memory:" )
119+ or query .get ("mode" ) == "memory"
120+ ):
121+ return None
122+
123+ parsed = urlparse (db_path )
124+ fs_path = parsed .path or ""
125+ if not fs_path or fs_path == ":memory:" :
126+ return None
127+
128+ authority = parsed .netloc
129+ if authority and authority .lower () != "localhost" :
130+ fs_path = f"//{ authority } { fs_path } "
131+
132+ return unquote (fs_path )
133+
62134# Create session factory
63135SessionLocal = sessionmaker (autocommit = False , autoflush = False , bind = engine )
64136
@@ -1822,6 +1894,41 @@ def init_db():
18221894 """
18231895 _migrate_model_endpoints ()
18241896 Base .metadata .create_all (bind = engine )
1897+ # Lock the DB file (and any SQLite sidecars) to 0o600 — it holds bearer-token
1898+ # + bcrypt hashes and encrypted provider keys. POSIX only; safe_chmod no-ops
1899+ # on Windows (ACL-restricted profile dir) and the path helper returns None for
1900+ # Postgres / in-memory. Must stay AFTER create_all: the file is born here at
1901+ # the umask default, and nothing below resets the mode. The path comes from
1902+ # engine.url (SQLAlchemy's parsed URL), so a driver-qualified or query-tagged
1903+ # DATABASE_URL still resolves to the real file instead of slipping through.
1904+ db_path = _sqlite_db_path (engine .url )
1905+ if db_path is not None :
1906+ # Fail closed-loud on the main file: this is the only access control on
1907+ # it, so if the chmod genuinely fails (read-only FS, foreign owner) an
1908+ # operator should hear about it. safe_chmod also returns False as a
1909+ # Windows no-op, so guard on IS_WINDOWS to avoid a spurious warning there.
1910+ if not safe_chmod (db_path , 0o600 ) and not IS_WINDOWS :
1911+ logger .warning (
1912+ "Could not restrict %s to 0o600; it holds secrets and may be "
1913+ "world-readable. Check filesystem permissions and ownership." ,
1914+ db_path ,
1915+ )
1916+ # Re-lock any sidecars present at startup. New ones inherit the main
1917+ # file's mode (now 0o600, since we set it first), and they're usually
1918+ # absent here, but a stale -wal/-shm/-journal left by an older 0o644
1919+ # install could still expose secret pages. Absent sidecars are the
1920+ # normal case, not an error — only a failed chmod warrants a warning.
1921+ for suffix in _SQLITE_SIDECARS :
1922+ sidecar = db_path + suffix
1923+ if (
1924+ os .path .exists (sidecar )
1925+ and not safe_chmod (sidecar , 0o600 )
1926+ and not IS_WINDOWS
1927+ ):
1928+ logger .warning (
1929+ "Could not restrict %s to 0o600; it may expose DB pages." ,
1930+ sidecar ,
1931+ )
18251932 _migrate_add_hidden_models_column ()
18261933 _migrate_add_cached_models_column ()
18271934 _migrate_add_pinned_models_column ()
@@ -1907,6 +2014,20 @@ def _migrate_chat_messages_fts():
19072014 conn = None
19082015 try :
19092016 conn = sqlite3 .connect (db_path )
2017+ fts_content_expr_new = (
2018+ "CASE WHEN instr(COALESCE(new.content, ''), ';base64,') > 0 "
2019+ "OR instr(COALESCE(new.content, ''), 'data:image/') > 0 "
2020+ "OR instr(COALESCE(new.content, ''), 'data:audio/') > 0 "
2021+ "THEN '[inline media omitted from search index]' "
2022+ "ELSE COALESCE(new.content, '') END"
2023+ )
2024+ fts_content_expr_cm = (
2025+ "CASE WHEN instr(COALESCE(cm.content, ''), ';base64,') > 0 "
2026+ "OR instr(COALESCE(cm.content, ''), 'data:image/') > 0 "
2027+ "OR instr(COALESCE(cm.content, ''), 'data:audio/') > 0 "
2028+ "THEN '[inline media omitted from search index]' "
2029+ "ELSE COALESCE(cm.content, '') END"
2030+ )
19102031 try :
19112032 conn .execute ("CREATE VIRTUAL TABLE IF NOT EXISTS temp._odysseus_fts5_probe USING fts5(content)" )
19122033 conn .execute ("DROP TABLE IF EXISTS temp._odysseus_fts5_probe" )
@@ -1915,18 +2036,22 @@ def _migrate_chat_messages_fts():
19152036 return
19162037
19172038 conn .executescript (
1918- """
2039+ f """
19192040 CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5(
19202041 content,
19212042 message_id UNINDEXED,
19222043 session_id UNINDEXED,
19232044 role UNINDEXED
19242045 );
19252046
2047+ DROP TRIGGER IF EXISTS chat_messages_fts_ai;
2048+ DROP TRIGGER IF EXISTS chat_messages_fts_ad;
2049+ DROP TRIGGER IF EXISTS chat_messages_fts_au;
2050+
19262051 CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ai
19272052 AFTER INSERT ON chat_messages BEGIN
19282053 INSERT INTO chat_messages_fts(content, message_id, session_id, role)
1929- VALUES (COALESCE(new.content, '') , new.id, new.session_id, new.role);
2054+ VALUES ({ fts_content_expr_new } , new.id, new.session_id, new.role);
19302055 END;
19312056
19322057 CREATE TRIGGER IF NOT EXISTS chat_messages_fts_ad
@@ -1938,21 +2063,22 @@ def _migrate_chat_messages_fts():
19382063 AFTER UPDATE ON chat_messages BEGIN
19392064 DELETE FROM chat_messages_fts WHERE message_id = old.id;
19402065 INSERT INTO chat_messages_fts(content, message_id, session_id, role)
1941- VALUES (COALESCE(new.content, '') , new.id, new.session_id, new.role);
2066+ VALUES ({ fts_content_expr_new } , new.id, new.session_id, new.role);
19422067 END;
19432068 """
19442069 )
19452070 conn .execute (
1946- """
2071+ f """
19472072 INSERT INTO chat_messages_fts(content, message_id, session_id, role)
1948- SELECT COALESCE(cm.content, '') , cm.id, cm.session_id, cm.role
2073+ SELECT { fts_content_expr_cm } , cm.id, cm.session_id, cm.role
19492074 FROM chat_messages cm
19502075 WHERE NOT EXISTS (
19512076 SELECT 1 FROM chat_messages_fts fts
19522077 WHERE fts.message_id = cm.id
19532078 )
19542079 """
19552080 )
2081+ _scrub_legacy_chat_message_fts_media (conn )
19562082 conn .commit ()
19572083 except Exception as e :
19582084 logging .getLogger (__name__ ).warning (f"chat_messages FTS migration failed: { e } " )
@@ -1963,6 +2089,37 @@ def _migrate_chat_messages_fts():
19632089 pass
19642090
19652091
2092+ def _scrub_legacy_chat_message_fts_media (conn ) -> None :
2093+ """Replace already-indexed inline media rows with searchable text only."""
2094+ try :
2095+ from src .attachment_refs import search_index_text
2096+ except Exception as e :
2097+ logging .getLogger (__name__ ).warning (f"chat_messages FTS media scrub skipped: { e } " )
2098+ return
2099+
2100+ try :
2101+ rows = conn .execute (
2102+ """
2103+ SELECT id, session_id, role, content
2104+ FROM chat_messages
2105+ WHERE instr(COALESCE(content, ''), ';base64,') > 0
2106+ OR instr(COALESCE(content, ''), 'data:image/') > 0
2107+ OR instr(COALESCE(content, ''), 'data:audio/') > 0
2108+ """
2109+ ).fetchall ()
2110+ for message_id , session_id , role , content in rows :
2111+ conn .execute ("DELETE FROM chat_messages_fts WHERE message_id = ?" , (message_id ,))
2112+ conn .execute (
2113+ """
2114+ INSERT INTO chat_messages_fts(content, message_id, session_id, role)
2115+ VALUES (?, ?, ?, ?)
2116+ """ ,
2117+ (search_index_text (content ), message_id , session_id , role ),
2118+ )
2119+ except Exception as e :
2120+ logging .getLogger (__name__ ).warning (f"chat_messages FTS media scrub failed: { e } " )
2121+
2122+
19662123def _migrate_add_email_smtp_security ():
19672124 """Add explicit SMTP security mode for Proton Bridge/custom local SMTP."""
19682125 import sqlite3
0 commit comments