fathom-server 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. fathom_server/__init__.py +8 -0
  2. fathom_server/app.py +254 -0
  3. fathom_server/auth.py +139 -0
  4. fathom_server/config.py +148 -0
  5. fathom_server/paths.py +30 -0
  6. fathom_server/routes/__init__.py +0 -0
  7. fathom_server/routes/activation.py +311 -0
  8. fathom_server/routes/room.py +573 -0
  9. fathom_server/routes/settings.py +514 -0
  10. fathom_server/routes/telegram.py +236 -0
  11. fathom_server/routes/terminal.py +157 -0
  12. fathom_server/routes/vault.py +744 -0
  13. fathom_server/service.py +126 -0
  14. fathom_server/services/__init__.py +0 -0
  15. fathom_server/services/access.py +209 -0
  16. fathom_server/services/crystal_scheduler.py +51 -0
  17. fathom_server/services/crystallization.py +225 -0
  18. fathom_server/services/indexer.py +90 -0
  19. fathom_server/services/links.py +106 -0
  20. fathom_server/services/memento.py +146 -0
  21. fathom_server/services/persistent_session.py +411 -0
  22. fathom_server/services/ping_scheduler.py +375 -0
  23. fathom_server/services/schema.py +33 -0
  24. fathom_server/services/settings.py +554 -0
  25. fathom_server/services/telegram_bridge.py +555 -0
  26. fathom_server/services/vault.py +187 -0
  27. fathom_server/static/.gitkeep +0 -0
  28. fathom_server/static/assets/index-CoS7M8QX.js +93 -0
  29. fathom_server/static/assets/index-DOcm59Zq.css +1 -0
  30. fathom_server/static/assets/index-LeWKhG39.js +93 -0
  31. fathom_server/static/index.html +22 -0
  32. fathom_server-0.1.0.dist-info/METADATA +351 -0
  33. fathom_server-0.1.0.dist-info/RECORD +36 -0
  34. fathom_server-0.1.0.dist-info/WHEEL +5 -0
  35. fathom_server-0.1.0.dist-info/entry_points.txt +2 -0
  36. fathom_server-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,8 @@
1
+ """Fathom Server — dashboard + REST API + background services for the vault layer."""
2
+
3
+ try:
4
+ from importlib.metadata import version
5
+
6
+ __version__ = version("fathom-server")
7
+ except Exception:
8
+ __version__ = "0.0.0-dev"
fathom_server/app.py ADDED
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env python3
2
+ """Fathom Server — dashboard + REST API + background services for the vault layer."""
3
+
4
+ import argparse
5
+
6
+ from flask import Flask, send_from_directory
7
+
8
+ from fathom_server.config import FRONTEND_DIR, HOST, PORT
9
+
10
+ app = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path="")
11
+
12
+ from fathom_server.routes.activation import bp as activation_bp # noqa: E402
13
+ from fathom_server.routes.room import bp as room_bp # noqa: E402
14
+ from fathom_server.routes.settings import bp as settings_bp # noqa: E402
15
+ from fathom_server.routes.telegram import bp as telegram_bp # noqa: E402
16
+ from fathom_server.routes.terminal import sock as terminal_sock # noqa: E402
17
+ from fathom_server.routes.vault import bp as vault_bp # noqa: E402
18
+
19
+ terminal_sock.init_app(app)
20
+ app.register_blueprint(activation_bp)
21
+ app.register_blueprint(vault_bp)
22
+ app.register_blueprint(settings_bp)
23
+ app.register_blueprint(room_bp)
24
+ app.register_blueprint(telegram_bp)
25
+
26
+ # Bootstrap all workspaces on startup
27
+ import threading as _threading # noqa: E402
28
+
29
+ from fathom_server.services.crystal_scheduler import crystal_scheduler # noqa: E402
30
+ from fathom_server.services.indexer import indexer # noqa: E402
31
+ from fathom_server.services.persistent_session import ( # noqa: E402
32
+ ensure_running as session_ensure_running,
33
+ )
34
+ from fathom_server.services.ping_scheduler import ping_scheduler # noqa: E402
35
+ from fathom_server.services.settings import ( # noqa: E402
36
+ load_global_settings,
37
+ load_workspace_settings,
38
+ )
39
+ from fathom_server.services.telegram_bridge import telegram_bridge # noqa: E402
40
+
41
+ _global_settings = load_global_settings()
42
+ _workspaces = _global_settings["workspaces"]
43
+ _default_ws = _global_settings["default_workspace"]
44
+
45
+ # Ensure persistent sessions for local-execution workspaces only (non-blocking)
46
+ for _ws_name, _ws_entry in _workspaces.items():
47
+ if _ws_entry.get("execution", "local") == "local" and _ws_entry.get("type") != "human":
48
+ _threading.Thread(target=session_ensure_running, args=(_ws_name,), daemon=True).start()
49
+
50
+ # Collect all ping routines across workspaces (tagged with workspace)
51
+ _all_routines = []
52
+ for _ws_name in _workspaces:
53
+ _ws_settings = load_workspace_settings(_ws_name)
54
+ for _r in _ws_settings["ping"]["routines"]:
55
+ _r["workspace"] = _ws_name
56
+ _all_routines.extend(_ws_settings["ping"]["routines"])
57
+
58
+ ping_scheduler.configure_all(_all_routines)
59
+
60
+ # Configure indexer from default workspace settings (indexes all workspaces)
61
+ _default_settings = load_workspace_settings(_default_ws)
62
+ indexer.configure(
63
+ _default_settings["background_index"]["enabled"],
64
+ _default_settings["background_index"]["interval_minutes"],
65
+ _default_settings["background_index"]["excluded_dirs"],
66
+ )
67
+
68
+ # Configure crystal scheduler from default workspace
69
+ crystal_scheduler.configure(
70
+ _default_settings["crystal_regen"]["enabled"],
71
+ _default_settings["crystal_regen"]["interval_days"],
72
+ workspace=_default_ws,
73
+ )
74
+
75
+ # Configure and start Telegram bridge if enabled
76
+ _telegram_cfg = _global_settings.get("telegram", {})
77
+ telegram_bridge.configure(
78
+ api_id=_telegram_cfg.get("api_id", 0),
79
+ api_hash=_telegram_cfg.get("api_hash", ""),
80
+ session_string=_telegram_cfg.get("session_string", ""),
81
+ primary_agent=_default_ws,
82
+ enabled=_telegram_cfg.get("enabled", False),
83
+ )
84
+ telegram_bridge.start()
85
+
86
+
87
+ @app.route("/")
88
+ def index():
89
+ return send_from_directory(FRONTEND_DIR, "index.html")
90
+
91
+
92
+ @app.route("/<path:path>")
93
+ def spa(path): # noqa: ARG001
94
+ """Catch-all for SPA client-side routing."""
95
+ return send_from_directory(FRONTEND_DIR, "index.html")
96
+
97
+
98
+ def _cmd_serve(args):
99
+ """Run the server."""
100
+ from fathom_server.auth import load_server_config
101
+
102
+ server_config = load_server_config()
103
+ api_key = server_config["api_key"]
104
+ auth_enabled = server_config.get("auth_enabled", False)
105
+ key_is_new = server_config.get("_key_newly_created", False)
106
+
107
+ from fathom_server import __version__
108
+
109
+ print(f"\n Fathom Server v{__version__}")
110
+ print(f" Bind: {args.host}:{args.port}")
111
+ print(f" Auth: {'enabled' if auth_enabled else 'disabled (enable via dashboard)'}")
112
+ if key_is_new:
113
+ print(f" API Key: {api_key} \u2190 SAVE THIS (shown once)")
114
+ else:
115
+ print(f" API Key: {api_key[:7]}...{api_key[-4:]}")
116
+ print(f" Dashboard: http://localhost:{args.port}\n")
117
+
118
+ app.run(host=args.host, port=args.port, threaded=True)
119
+
120
+
121
+ def _cmd_setup(args):
122
+ """First-time setup — create data dirs and generate API key."""
123
+ from fathom_server import __version__
124
+ from fathom_server.auth import load_server_config
125
+ from fathom_server.paths import DATA_DIR
126
+
127
+ if not args.yes:
128
+ print(f"\n Fathom Server v{__version__}")
129
+ print(f" Data directory: {DATA_DIR}")
130
+ print()
131
+ confirm = input(" Proceed with setup? [Y/n] ").strip().lower()
132
+ if confirm and confirm != "y":
133
+ print(" Aborted.")
134
+ return
135
+
136
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
137
+ server_config = load_server_config()
138
+ api_key = server_config["api_key"]
139
+ key_is_new = server_config.get("_key_newly_created", False)
140
+
141
+ print(f"\n Fathom Server v{__version__} — setup complete")
142
+ print(f" Data directory: {DATA_DIR}")
143
+ if key_is_new:
144
+ print(f" API Key: {api_key} \u2190 SAVE THIS (shown once)")
145
+ else:
146
+ print(f" API Key: {api_key[:7]}...{api_key[-4:]} (already existed)")
147
+ print()
148
+ print(" Next steps:")
149
+ print(" fathom-server # run the server")
150
+ print(" fathom-server install # register as a boot service")
151
+ print()
152
+
153
+
154
+ def _cmd_install(args):
155
+ """Install fathom-server as a systemd user service."""
156
+ from fathom_server.service import install_service
157
+
158
+ install_service(
159
+ data_dir=args.data_dir,
160
+ host=args.host,
161
+ port=args.port,
162
+ working_dir=args.working_dir,
163
+ no_start=args.no_start,
164
+ )
165
+
166
+
167
+ def _cmd_uninstall(_args):
168
+ """Remove the systemd user service."""
169
+ from fathom_server.service import uninstall_service
170
+
171
+ uninstall_service()
172
+
173
+
174
+ def main():
175
+ """Entry point for `fathom-server` CLI command."""
176
+ parser = argparse.ArgumentParser(description="Fathom Server")
177
+ sub = parser.add_subparsers(dest="command")
178
+
179
+ # -- serve (default when no subcommand given) --
180
+ serve_p = sub.add_parser("serve", help="Run the server (default)")
181
+ serve_p.add_argument(
182
+ "--host", type=str, default=HOST, help=f"Address to bind to (default: {HOST})"
183
+ )
184
+ serve_p.add_argument(
185
+ "--port", type=int, default=PORT, help=f"Port to listen on (default: {PORT})"
186
+ )
187
+
188
+ # -- setup (non-interactive first-time setup, no service) --
189
+ setup_p = sub.add_parser("setup", help="First-time setup (create data dirs, generate API key)")
190
+ setup_p.add_argument(
191
+ "-y",
192
+ "--yes",
193
+ action="store_true",
194
+ help="Skip confirmation prompt",
195
+ )
196
+
197
+ # -- install --
198
+ install_p = sub.add_parser("install", help="Install as a systemd user service")
199
+ install_p.add_argument(
200
+ "--data-dir",
201
+ type=str,
202
+ default=None,
203
+ help="FATHOM_DATA_DIR override (default: ~/.local/share/fathom-server)",
204
+ )
205
+ install_p.add_argument("--host", type=str, default=HOST, help=f"Bind address (default: {HOST})")
206
+ install_p.add_argument("--port", type=int, default=PORT, help=f"Port (default: {PORT})")
207
+ install_p.add_argument(
208
+ "--working-dir",
209
+ type=str,
210
+ default=None,
211
+ help="WorkingDirectory for the service (default: home directory)",
212
+ )
213
+ install_p.add_argument(
214
+ "--no-start",
215
+ action="store_true",
216
+ help="Install and enable without starting immediately",
217
+ )
218
+
219
+ # -- uninstall --
220
+ sub.add_parser("uninstall", help="Remove the systemd user service")
221
+
222
+ # Also accept --host/--port at top level for backward compat (no subcommand = serve)
223
+ parser.add_argument(
224
+ "--host",
225
+ type=str,
226
+ default=None,
227
+ help=argparse.SUPPRESS,
228
+ )
229
+ parser.add_argument(
230
+ "--port",
231
+ type=int,
232
+ default=None,
233
+ help=argparse.SUPPRESS,
234
+ )
235
+
236
+ cli_args = parser.parse_args()
237
+
238
+ if cli_args.command == "setup":
239
+ _cmd_setup(cli_args)
240
+ elif cli_args.command == "install":
241
+ _cmd_install(cli_args)
242
+ elif cli_args.command == "uninstall":
243
+ _cmd_uninstall(cli_args)
244
+ elif cli_args.command == "serve":
245
+ _cmd_serve(cli_args)
246
+ else:
247
+ # No subcommand — run serve with top-level flags
248
+ cli_args.host = cli_args.host or HOST
249
+ cli_args.port = cli_args.port or PORT
250
+ _cmd_serve(cli_args)
251
+
252
+
253
+ if __name__ == "__main__":
254
+ main()
fathom_server/auth.py ADDED
@@ -0,0 +1,139 @@
1
+ """API key authentication for fathom-server.
2
+
3
+ Generates a server API key on first run (stored in data/server.json).
4
+ Provides a Flask middleware decorator to require Bearer token auth on API routes.
5
+ Dashboard routes (serving static files) are exempt.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import secrets
11
+ from functools import wraps
12
+
13
+ from flask import jsonify, request
14
+
15
+ from fathom_server.paths import DATA_DIR, SERVER_CONFIG_PATH
16
+
17
+ _DATA_DIR = str(DATA_DIR)
18
+ _SERVER_CONFIG_PATH = str(SERVER_CONFIG_PATH)
19
+
20
+ # Prefix makes keys visually identifiable and greppable
21
+ _KEY_PREFIX = "fv_"
22
+ _KEY_BYTES = 24 # 24 bytes = 32 base64 chars
23
+
24
+
25
+ def _generate_api_key() -> str:
26
+ """Generate a new API key with the fv_ prefix."""
27
+ return _KEY_PREFIX + secrets.token_urlsafe(_KEY_BYTES)
28
+
29
+
30
+ def load_server_config() -> dict:
31
+ """Load server config from data/server.json, creating with defaults if missing.
32
+
33
+ Returns the config dict with a transient ``_key_newly_created`` flag
34
+ (True when the API key was just generated — not persisted to disk).
35
+ """
36
+ os.makedirs(_DATA_DIR, exist_ok=True)
37
+
38
+ try:
39
+ with open(_SERVER_CONFIG_PATH) as f:
40
+ config = json.load(f)
41
+ except (FileNotFoundError, json.JSONDecodeError):
42
+ config = {}
43
+
44
+ changed = False
45
+ key_newly_created = False
46
+
47
+ if "api_key" not in config:
48
+ config["api_key"] = _generate_api_key()
49
+ changed = True
50
+ key_newly_created = True
51
+
52
+ if "auth_enabled" not in config:
53
+ config["auth_enabled"] = False # Off by default for backward compat
54
+ changed = True
55
+
56
+ if changed:
57
+ save_server_config(config)
58
+
59
+ config["_key_newly_created"] = key_newly_created
60
+ return config
61
+
62
+
63
+ def save_server_config(config: dict) -> None:
64
+ """Persist server config to data/server.json."""
65
+ os.makedirs(_DATA_DIR, exist_ok=True)
66
+ with open(_SERVER_CONFIG_PATH, "w") as f:
67
+ json.dump(config, f, indent=2)
68
+
69
+
70
+ def get_api_key() -> str:
71
+ """Return the current server API key."""
72
+ return load_server_config()["api_key"]
73
+
74
+
75
+ def regenerate_api_key() -> str:
76
+ """Generate a new API key, invalidating the old one."""
77
+ config = load_server_config()
78
+ config["api_key"] = _generate_api_key()
79
+ save_server_config(config)
80
+ return config["api_key"]
81
+
82
+
83
+ def is_auth_enabled() -> bool:
84
+ """Check whether API key auth is currently enforced."""
85
+ return load_server_config().get("auth_enabled", False)
86
+
87
+
88
+ def set_auth_enabled(enabled: bool) -> None:
89
+ """Enable or disable API key auth enforcement."""
90
+ config = load_server_config()
91
+ config["auth_enabled"] = enabled
92
+ save_server_config(config)
93
+
94
+
95
+ def require_api_key(f):
96
+ """Flask route decorator — requires valid Bearer token when auth is enabled.
97
+
98
+ When auth is disabled (default), all requests pass through.
99
+ When auth is enabled, validates Authorization: Bearer <key> header.
100
+ """
101
+
102
+ @wraps(f)
103
+ def decorated(*args, **kwargs):
104
+ if not is_auth_enabled():
105
+ return f(*args, **kwargs)
106
+
107
+ auth_header = request.headers.get("Authorization", "")
108
+ if not auth_header.startswith("Bearer "):
109
+ return jsonify({"error": "Missing or malformed Authorization header"}), 401
110
+
111
+ token = auth_header[7:] # Strip "Bearer "
112
+ expected = get_api_key()
113
+
114
+ if not secrets.compare_digest(token, expected):
115
+ return jsonify({"error": "Invalid API key"}), 401
116
+
117
+ return f(*args, **kwargs)
118
+
119
+ return decorated
120
+
121
+
122
+ def validate_token_param(query_string: str) -> bool:
123
+ """Validate a token query parameter for WebSocket/SSE connections.
124
+
125
+ These APIs don't support custom headers, so the token is passed as ?token=KEY.
126
+ Returns True if auth is disabled or token is valid.
127
+ """
128
+ if not is_auth_enabled():
129
+ return True
130
+
131
+ from urllib.parse import parse_qs
132
+
133
+ params = parse_qs(query_string)
134
+ tokens = params.get("token", [])
135
+ if not tokens:
136
+ return False
137
+
138
+ expected = get_api_key()
139
+ return secrets.compare_digest(tokens[0], expected)
@@ -0,0 +1,148 @@
1
+ """Shared constants and path configuration for Fathom Server."""
2
+
3
+ import json
4
+ import os
5
+
6
+ _SETTINGS_FILE = os.path.expanduser("~/.config/fathom-vault/settings.json")
7
+ _DEFAULT_VAULT_DIR = os.environ.get("FATHOM_VAULT_DIR", "")
8
+
9
+
10
+ def _read_setting(*keys, default=None):
11
+ """Read a nested setting from the global settings file."""
12
+ try:
13
+ with open(_SETTINGS_FILE) as f:
14
+ data = json.load(f)
15
+ for k in keys:
16
+ data = data[k]
17
+ return data
18
+ except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError):
19
+ return default
20
+
21
+
22
+ def get_workspace_path(workspace=None):
23
+ """Resolve project root directory for a workspace name.
24
+
25
+ Returns (path, None) on success, (None, error_dict) on failure.
26
+ If workspace is None, returns the default workspace path.
27
+ Empty or missing path returns (None, error_dict) — not a crash.
28
+ """
29
+ workspaces = _read_setting("workspaces", default={})
30
+ default_ws = _read_setting("default_workspace", default=None)
31
+
32
+ if not workspace:
33
+ if default_ws and workspaces.get(default_ws):
34
+ entry = workspaces[default_ws]
35
+ ws_path = entry["path"] if isinstance(entry, dict) else entry
36
+ if not ws_path:
37
+ return None, {"error": f'Workspace "{default_ws}" has no local path configured'}
38
+ return ws_path, None
39
+ # Legacy fallback — derive from terminal.vault_dir or default
40
+ vault_dir = _read_setting("terminal", "vault_dir", default=_DEFAULT_VAULT_DIR)
41
+ return os.path.dirname(vault_dir), None
42
+
43
+ ws_entry = workspaces.get(workspace)
44
+ if not ws_entry:
45
+ available = list(workspaces.keys()) if workspaces else []
46
+ return None, {
47
+ "error": f'Unknown workspace: "{workspace}"',
48
+ "available_workspaces": available,
49
+ }
50
+ # Entry can be a string (legacy) or dict (current)
51
+ ws_path = ws_entry["path"] if isinstance(ws_entry, dict) else ws_entry
52
+ if not ws_path:
53
+ return None, {"error": f'Workspace "{workspace}" has no local path configured'}
54
+ return ws_path, None
55
+
56
+
57
+ def get_vault_path(workspace=None):
58
+ """Resolve vault directory for a workspace name.
59
+
60
+ Vault path = workspace project root + vault subdir (from settings, default "vault").
61
+ Returns (path, None) on success, (None, error_dict) on failure.
62
+ """
63
+ ws_path, err = get_workspace_path(workspace)
64
+ if err:
65
+ return None, err
66
+
67
+ # Read vault subdir from workspace entry
68
+ workspaces = _read_setting("workspaces", default={})
69
+ default_ws = _read_setting("default_workspace", default=None)
70
+ ws_name = workspace or default_ws
71
+ ws_entry = workspaces.get(ws_name, {})
72
+ vault_subdir = ws_entry.get("vault", "vault") if isinstance(ws_entry, dict) else "vault"
73
+
74
+ vault_dir = os.path.join(ws_path, vault_subdir) if vault_subdir != "." else ws_path
75
+ return vault_dir, None
76
+
77
+
78
+ def get_workspace_settings_path(workspace=None):
79
+ """Resolve per-workspace settings file path.
80
+
81
+ Returns (<project_root>/.fathom/settings.json, None) on success.
82
+ """
83
+ ws_path, err = get_workspace_path(workspace)
84
+ if err:
85
+ return None, err
86
+ return os.path.join(ws_path, ".fathom", "settings.json"), None
87
+
88
+
89
+ def get_workspaces():
90
+ """Return dict of all configured workspaces {name: project_root_path}."""
91
+ return _read_setting("workspaces", default={})
92
+
93
+
94
+ def get_default_workspace():
95
+ """Return the default workspace name."""
96
+ return _read_setting("default_workspace", default=None)
97
+
98
+
99
+ # Legacy constant — pre-migration reads terminal.vault_dir, post-migration falls back to default.
100
+ # Used by services/indexer.py (until Phase 3 makes it workspace-aware).
101
+ VAULT_DIR = _read_setting("terminal", "vault_dir", default=_DEFAULT_VAULT_DIR)
102
+ FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "static")
103
+ IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp")
104
+ HOST = os.environ.get("FATHOM_HOST", "0.0.0.0")
105
+ PORT = int(os.environ.get("FATHOM_PORT", 4243))
106
+
107
+ # Server-side vault storage — hosted vault files live here, keyed by workspace.
108
+ from fathom_server.paths import VAULT_STORAGE_DIR as _VAULT_STORAGE_PATH # noqa: E402
109
+
110
+ VAULT_STORAGE_DIR = str(_VAULT_STORAGE_PATH)
111
+
112
+
113
+ def get_vault_storage_path(workspace: str) -> str:
114
+ """Return the server-side vault storage directory for a workspace."""
115
+ return os.path.join(VAULT_STORAGE_DIR, workspace)
116
+
117
+
118
+ def get_vault_dir_for_dashboard(workspace=None):
119
+ """Resolve vault directory for dashboard endpoints, respecting vault mode.
120
+
121
+ Vault mode is stored as 'type' on the workspace entry:
122
+ - "local" → local filesystem vault (get_vault_path)
123
+ - "synced" / "hosted" → server storage (data/vaults/{workspace}/)
124
+ - "none" → vault not configured
125
+
126
+ Returns (path, None) on success, (None, error_dict) on failure.
127
+ """
128
+ workspaces = _read_setting("workspaces", default={})
129
+ default_ws = _read_setting("default_workspace", default=None)
130
+ ws_name = workspace or default_ws
131
+
132
+ # Unknown workspace — fall through to get_vault_path for standard error handling
133
+ ws_entry = workspaces.get(ws_name)
134
+ if not ws_entry:
135
+ return get_vault_path(workspace)
136
+
137
+ vault_mode = ws_entry.get("type", "local") if isinstance(ws_entry, dict) else "local"
138
+
139
+ if vault_mode == "none":
140
+ return None, {"error": "Vault not configured", "vault_mode": "none"}
141
+
142
+ if vault_mode in ("synced", "hosted"):
143
+ storage_path = get_vault_storage_path(ws_name)
144
+ os.makedirs(storage_path, exist_ok=True)
145
+ return storage_path, None
146
+
147
+ # Default: local filesystem
148
+ return get_vault_path(workspace)
fathom_server/paths.py ADDED
@@ -0,0 +1,30 @@
1
+ """Centralized runtime data directory resolution.
2
+
3
+ After pip install, source files live in read-only site-packages. All mutable data
4
+ (SQLite databases, server config, telegram assets, hosted vaults) must go to a
5
+ runtime data directory instead.
6
+
7
+ Resolution order:
8
+ 1. FATHOM_DATA_DIR env var (explicit override)
9
+ 2. XDG_DATA_HOME/fathom-server (default: ~/.local/share/fathom-server)
10
+ """
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ _ENV_OVERRIDE = os.environ.get("FATHOM_DATA_DIR")
16
+
17
+ if _ENV_OVERRIDE:
18
+ DATA_DIR = Path(_ENV_OVERRIDE)
19
+ else:
20
+ xdg_data = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
21
+ DATA_DIR = Path(xdg_data) / "fathom-server"
22
+
23
+ # Ensure it exists on import
24
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
25
+
26
+ # Convenience paths used across the codebase
27
+ DB_PATH = DATA_DIR / "access.db"
28
+ SERVER_CONFIG_PATH = DATA_DIR / "server.json"
29
+ VAULT_STORAGE_DIR = DATA_DIR / "vaults"
30
+ TELEGRAM_ASSETS_DIR = DATA_DIR / "telegram-assets"
File without changes