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.
- fathom_server/__init__.py +8 -0
- fathom_server/app.py +254 -0
- fathom_server/auth.py +139 -0
- fathom_server/config.py +148 -0
- fathom_server/paths.py +30 -0
- fathom_server/routes/__init__.py +0 -0
- fathom_server/routes/activation.py +311 -0
- fathom_server/routes/room.py +573 -0
- fathom_server/routes/settings.py +514 -0
- fathom_server/routes/telegram.py +236 -0
- fathom_server/routes/terminal.py +157 -0
- fathom_server/routes/vault.py +744 -0
- fathom_server/service.py +126 -0
- fathom_server/services/__init__.py +0 -0
- fathom_server/services/access.py +209 -0
- fathom_server/services/crystal_scheduler.py +51 -0
- fathom_server/services/crystallization.py +225 -0
- fathom_server/services/indexer.py +90 -0
- fathom_server/services/links.py +106 -0
- fathom_server/services/memento.py +146 -0
- fathom_server/services/persistent_session.py +411 -0
- fathom_server/services/ping_scheduler.py +375 -0
- fathom_server/services/schema.py +33 -0
- fathom_server/services/settings.py +554 -0
- fathom_server/services/telegram_bridge.py +555 -0
- fathom_server/services/vault.py +187 -0
- fathom_server/static/.gitkeep +0 -0
- fathom_server/static/assets/index-CoS7M8QX.js +93 -0
- fathom_server/static/assets/index-DOcm59Zq.css +1 -0
- fathom_server/static/assets/index-LeWKhG39.js +93 -0
- fathom_server/static/index.html +22 -0
- fathom_server-0.1.0.dist-info/METADATA +351 -0
- fathom_server-0.1.0.dist-info/RECORD +36 -0
- fathom_server-0.1.0.dist-info/WHEEL +5 -0
- fathom_server-0.1.0.dist-info/entry_points.txt +2 -0
- fathom_server-0.1.0.dist-info/top_level.txt +1 -0
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)
|
fathom_server/config.py
ADDED
|
@@ -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
|