adapt-server 0.2.4__tar.gz → 0.2.6__tar.gz
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.
- {adapt_server-0.2.4 → adapt_server-0.2.6}/PKG-INFO +3 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/README.md +2 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/__init__.py +1 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/cache.py +19 -11
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/cli.py +19 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/addsuperuser.py +19 -4
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/__init__.py +3 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/create_user.py +17 -3
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/check.py +3 -3
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/list_endpoints.py +1 -1
- adapt_server-0.2.6/adapt/commands/passwords.py +116 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/config.py +10 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/locks.py +8 -4
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/mcp.py +23 -5
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/__init__.py +3 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/base.py +31 -3
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/csv_plugin.py +9 -12
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/excel_plugin.py +6 -8
- adapt_server-0.2.6/adapt/plugins/file_plugin.py +108 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/html_plugin.py +7 -16
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/parquet_plugin.py +11 -14
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/search.py +1 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/static/admin/app.js +30 -7
- adapt_server-0.2.6/adapt/static/sortable-tables.css +30 -0
- adapt_server-0.2.6/adapt/static/sortable-tables.js +229 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/admin/index.html +27 -27
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/admin_base.html +3 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/base.html +3 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/landing.html +23 -13
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/profile.html +10 -7
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/utils/__init__.py +5 -5
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/PKG-INFO +3 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/SOURCES.txt +5 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/pyproject.toml +1 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_admin.py +138 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_cache.py +7 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_cli_integration.py +20 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_csv_plugin.py +63 -1
- adapt_server-0.2.6/tests/test_file_plugin.py +109 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_html_plugin.py +8 -2
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_integration.py +61 -1
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_locks.py +17 -8
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_mcp.py +12 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_parquet_plugin.py +49 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_plugin_interface.py +2 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_search.py +9 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/LICENSE +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/__main__.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/__init__.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/api_keys.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/audit_logs.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/cache.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/groups.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/locks.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/models.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/permissions.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/resources.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/ui.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/admin/users.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/api_keys.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/app.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/audit.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/auth/__init__.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/auth/dependencies.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/auth/password.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/auth/routes.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/auth/session.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/__init__.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/add_to_group.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/create_group.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/create_permissions.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/delete_group.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/delete_user.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/list_groups.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/list_resources.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/list_users.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/admin/remove_from_group.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/reindex.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/commands/serve.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/discovery.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/models.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/permissions.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/dataset_plugin.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/markdown_plugin.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/media_plugin.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/plugins/python_plugin.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/routes.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/routes_search.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/security.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/security_urls.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/static/admin/style.css +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/storage.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/datatable.html +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/login.html +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/media_gallery.html +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/media_player.html +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/templates/search_results.html +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt/utils/query.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/dependency_links.txt +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/entry_points.txt +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/requires.txt +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/adapt_server.egg-info/top_level.txt +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/setup.cfg +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_auth.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_config.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_constraints.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_dataset_plugin.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_dependencies.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_discovery.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_markdown_plugin.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_on_delete.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_phase3.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_python_plugin.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_readonly.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_routes.py +0 -0
- {adapt_server-0.2.4 → adapt_server-0.2.6}/tests/test_security_urls.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: adapt-server
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: Adaptive file-backed FastAPI server that turns datasets into CRUD APIs and UIs.
|
|
5
5
|
Author-email: notesofcliff <notesofcliff@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -207,6 +207,8 @@ anonymous path, since MCP has no concept of a browser session. Set
|
|
|
207
207
|
`mcp_enabled: false` in `.adapt/conf.json` (or `ADAPT_MCP_ENABLED=false`) to
|
|
208
208
|
remove `/mcp` entirely. Full walkthrough, troubleshooting, and the tool
|
|
209
209
|
reference table: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md).
|
|
210
|
+
For dataset reads, `sort` is the column name and `order` must be `asc` or
|
|
211
|
+
`desc`.
|
|
210
212
|
|
|
211
213
|
## Dataset Mutation Envelope
|
|
212
214
|
|
|
@@ -161,6 +161,8 @@ anonymous path, since MCP has no concept of a browser session. Set
|
|
|
161
161
|
`mcp_enabled: false` in `.adapt/conf.json` (or `ADAPT_MCP_ENABLED=false`) to
|
|
162
162
|
remove `/mcp` entirely. Full walkthrough, troubleshooting, and the tool
|
|
163
163
|
reference table: [docs/manual/mcp_guide.md](docs/manual/mcp_guide.md).
|
|
164
|
+
For dataset reads, `sort` is the column name and `order` must be `asc` or
|
|
165
|
+
`desc`.
|
|
164
166
|
|
|
165
167
|
## Dataset Mutation Envelope
|
|
166
168
|
|
|
@@ -8,12 +8,22 @@ import threading
|
|
|
8
8
|
import logging
|
|
9
9
|
from datetime import datetime, timedelta, timezone
|
|
10
10
|
import pickle
|
|
11
|
+
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
logger = logging.getLogger(__name__)
|
|
13
14
|
|
|
14
15
|
CACHE_TABLE = "cache"
|
|
15
|
-
_lock = threading.
|
|
16
|
+
_lock = threading.RLock()
|
|
16
17
|
_db_path: str | None = None
|
|
18
|
+
_CREATE_CACHE_TABLE_SQL = f"""
|
|
19
|
+
CREATE TABLE IF NOT EXISTS {CACHE_TABLE} (
|
|
20
|
+
key TEXT PRIMARY KEY,
|
|
21
|
+
value BLOB,
|
|
22
|
+
expires_at DATETIME,
|
|
23
|
+
resource TEXT,
|
|
24
|
+
user TEXT
|
|
25
|
+
)
|
|
26
|
+
"""
|
|
17
27
|
|
|
18
28
|
|
|
19
29
|
def _default_db_path() -> str:
|
|
@@ -35,24 +45,22 @@ def configure(db_path: str | None) -> None:
|
|
|
35
45
|
def _get_conn():
|
|
36
46
|
"""Get a SQLite connection to the cache database."""
|
|
37
47
|
path = _db_path or _default_db_path()
|
|
48
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
38
49
|
conn = sqlite3.connect(path, check_same_thread=False)
|
|
39
50
|
conn.row_factory = sqlite3.Row
|
|
51
|
+
_ensure_cache_table(conn)
|
|
40
52
|
return conn
|
|
41
53
|
|
|
54
|
+
|
|
55
|
+
def _ensure_cache_table(conn: sqlite3.Connection) -> None:
|
|
56
|
+
"""Ensure the cache table exists for the active database connection."""
|
|
57
|
+
conn.execute(_CREATE_CACHE_TABLE_SQL)
|
|
58
|
+
conn.commit()
|
|
59
|
+
|
|
42
60
|
def init_cache_table():
|
|
43
61
|
"""Initialize the cache table in the SQLite database."""
|
|
44
62
|
with _lock:
|
|
45
63
|
conn = _get_conn()
|
|
46
|
-
conn.execute(f"""
|
|
47
|
-
CREATE TABLE IF NOT EXISTS {CACHE_TABLE} (
|
|
48
|
-
key TEXT PRIMARY KEY,
|
|
49
|
-
value BLOB,
|
|
50
|
-
expires_at DATETIME,
|
|
51
|
-
resource TEXT,
|
|
52
|
-
user TEXT
|
|
53
|
-
)
|
|
54
|
-
""")
|
|
55
|
-
conn.commit()
|
|
56
64
|
conn.close()
|
|
57
65
|
logger.debug("Cache table initialized")
|
|
58
66
|
|
|
@@ -36,6 +36,12 @@ def main() -> None:
|
|
|
36
36
|
user_parser.add_argument("root", nargs="?", default=".", help="Document root containing the SQLite store")
|
|
37
37
|
user_parser.add_argument("--username", required=True, help="Username for the superuser")
|
|
38
38
|
user_parser.add_argument("--password", help="Password (will prompt if missing)")
|
|
39
|
+
user_parser.add_argument("--password-confirm", help="Password confirmation for non-interactive use")
|
|
40
|
+
user_parser.add_argument(
|
|
41
|
+
"--allow-weak-password",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Allow a weak password without the safety confirmation prompt",
|
|
44
|
+
)
|
|
39
45
|
|
|
40
46
|
list_parser = subparsers.add_parser("list-endpoints", help="List the auto-generated REST/UI endpoints")
|
|
41
47
|
list_parser.add_argument("root", nargs="?", default=".", help="Document root to inspect")
|
|
@@ -65,6 +71,12 @@ def main() -> None:
|
|
|
65
71
|
admin_create_user_parser.add_argument("root", nargs="?", default=".", help="Document root")
|
|
66
72
|
admin_create_user_parser.add_argument("--username", required=True, help="Username")
|
|
67
73
|
admin_create_user_parser.add_argument("--password", help="Password (will prompt if missing)")
|
|
74
|
+
admin_create_user_parser.add_argument("--password-confirm", help="Password confirmation for non-interactive use")
|
|
75
|
+
admin_create_user_parser.add_argument(
|
|
76
|
+
"--allow-weak-password",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="Allow a weak password without the safety confirmation prompt",
|
|
79
|
+
)
|
|
68
80
|
admin_create_user_parser.add_argument("--superuser", action="store_true", help="Create as superuser")
|
|
69
81
|
|
|
70
82
|
admin_delete_user_parser = admin_subparsers.add_parser("delete-user", help="Delete user")
|
|
@@ -116,7 +128,13 @@ def main() -> None:
|
|
|
116
128
|
check.run_check(Path(args.root).resolve())
|
|
117
129
|
elif args.command == "addsuperuser":
|
|
118
130
|
logger.info("Running addsuperuser command for username=%s", args.username)
|
|
119
|
-
addsuperuser.run_add_superuser(
|
|
131
|
+
addsuperuser.run_add_superuser(
|
|
132
|
+
Path(args.root).resolve(),
|
|
133
|
+
args.username,
|
|
134
|
+
args.password,
|
|
135
|
+
password_confirm=args.password_confirm,
|
|
136
|
+
allow_weak_password=args.allow_weak_password,
|
|
137
|
+
)
|
|
120
138
|
elif args.command == "list-endpoints":
|
|
121
139
|
logger.info("Running list-endpoints command with root=%s", args.root)
|
|
122
140
|
list_endpoints.run_list_endpoints(Path(args.root).resolve())
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import getpass
|
|
2
1
|
from pathlib import Path
|
|
3
2
|
import logging
|
|
4
3
|
|
|
@@ -7,17 +6,26 @@ from sqlmodel import Session, select
|
|
|
7
6
|
from ..config import AdaptConfig
|
|
8
7
|
from ..storage import User, init_database
|
|
9
8
|
from ..auth.password import hash_password
|
|
9
|
+
from .passwords import resolve_password
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def run_add_superuser(
|
|
14
|
+
def run_add_superuser(
|
|
15
|
+
root: Path,
|
|
16
|
+
username: str,
|
|
17
|
+
password: str | None,
|
|
18
|
+
password_confirm: str | None = None,
|
|
19
|
+
allow_weak_password: bool = False,
|
|
20
|
+
) -> None:
|
|
15
21
|
"""Create a new superuser account.
|
|
16
22
|
|
|
17
23
|
Args:
|
|
18
24
|
root: The root directory path for the Adapt configuration.
|
|
19
25
|
username: The username for the new superuser.
|
|
20
26
|
password: The password for the new superuser. If None, prompts for input.
|
|
27
|
+
password_confirm: Confirmation for non-interactive password entry.
|
|
28
|
+
allow_weak_password: Allow weak passwords without an interactive override prompt.
|
|
21
29
|
|
|
22
30
|
Returns:
|
|
23
31
|
None
|
|
@@ -27,8 +35,15 @@ def run_add_superuser(root: Path, username: str, password: str | None) -> None:
|
|
|
27
35
|
"""
|
|
28
36
|
config = AdaptConfig(root=root)
|
|
29
37
|
engine = init_database(config.db_path)
|
|
38
|
+
password = resolve_password(
|
|
39
|
+
username=username,
|
|
40
|
+
password=password,
|
|
41
|
+
password_confirm=password_confirm,
|
|
42
|
+
allow_weak_password=allow_weak_password,
|
|
43
|
+
)
|
|
30
44
|
if password is None:
|
|
31
|
-
|
|
45
|
+
logger.warning("Aborted superuser creation for %s due to password validation", username)
|
|
46
|
+
return
|
|
32
47
|
|
|
33
48
|
hashed = hash_password(password)
|
|
34
49
|
logger.debug("Hashed password for user %s", username)
|
|
@@ -43,4 +58,4 @@ def run_add_superuser(root: Path, username: str, password: str | None) -> None:
|
|
|
43
58
|
session.add(user)
|
|
44
59
|
session.commit()
|
|
45
60
|
logger.info("Created superuser '%s'", username)
|
|
46
|
-
print(f"Created superuser '{username}'")
|
|
61
|
+
print(f"Created superuser '{username}'")
|
|
@@ -51,7 +51,9 @@ def run_admin(args):
|
|
|
51
51
|
root=Path(args.root).resolve(),
|
|
52
52
|
username=args.username,
|
|
53
53
|
password=args.password,
|
|
54
|
+
password_confirm=args.password_confirm,
|
|
54
55
|
is_superuser=args.superuser,
|
|
56
|
+
allow_weak_password=args.allow_weak_password,
|
|
55
57
|
)
|
|
56
58
|
elif args.admin_command == "delete-user":
|
|
57
59
|
logger.debug("Running admin command: delete-user for username=%s", args.username)
|
|
@@ -70,4 +72,4 @@ def run_admin(args):
|
|
|
70
72
|
run_remove_from_group(Path(args.root).resolve(), args.username, args.group)
|
|
71
73
|
else:
|
|
72
74
|
logger.error("Unknown admin command: %s", args.admin_command)
|
|
73
|
-
raise ValueError(f"Unknown admin command: {args.admin_command}")
|
|
75
|
+
raise ValueError(f"Unknown admin command: {args.admin_command}")
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import getpass
|
|
2
1
|
from pathlib import Path
|
|
3
2
|
import logging
|
|
4
3
|
|
|
@@ -7,17 +6,32 @@ from sqlmodel import Session, select
|
|
|
7
6
|
from ...auth.password import hash_password
|
|
8
7
|
from ...config import AdaptConfig
|
|
9
8
|
from ...storage import User, init_database
|
|
9
|
+
from ..passwords import resolve_password
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
def run_create_user(
|
|
14
|
+
def run_create_user(
|
|
15
|
+
root: Path,
|
|
16
|
+
username: str,
|
|
17
|
+
password: str | None,
|
|
18
|
+
password_confirm: str | None = None,
|
|
19
|
+
is_superuser: bool = False,
|
|
20
|
+
allow_weak_password: bool = False,
|
|
21
|
+
) -> None:
|
|
15
22
|
"""Create a user account."""
|
|
16
23
|
config = AdaptConfig(root=root)
|
|
17
24
|
engine = init_database(config.db_path)
|
|
18
25
|
|
|
26
|
+
password = resolve_password(
|
|
27
|
+
username=username,
|
|
28
|
+
password=password,
|
|
29
|
+
password_confirm=password_confirm,
|
|
30
|
+
allow_weak_password=allow_weak_password,
|
|
31
|
+
)
|
|
19
32
|
if password is None:
|
|
20
|
-
|
|
33
|
+
logger.warning("Aborted user creation for %s due to password validation", username)
|
|
34
|
+
return
|
|
21
35
|
|
|
22
36
|
with Session(engine) as db:
|
|
23
37
|
existing = db.exec(select(User).where(User.username == username)).first()
|
|
@@ -38,10 +38,10 @@ def run_check(root: Path) -> None:
|
|
|
38
38
|
print(f"SQLite store: {config.db_path} (engine {engine})")
|
|
39
39
|
print(f"Discovered {count} dataset(s)")
|
|
40
40
|
|
|
41
|
-
# Markdown and
|
|
42
|
-
# with built-in routes; datasets live under /api and /ui and cannot.
|
|
41
|
+
# Markdown, HTML, and file resources mount at /<namespace>, so they can
|
|
42
|
+
# collide with built-in routes; datasets live under /api and /ui and cannot.
|
|
43
43
|
for resource in resources:
|
|
44
|
-
if resource.resource_type not in ("markdown", "html"):
|
|
44
|
+
if resource.resource_type not in ("markdown", "html", "file"):
|
|
45
45
|
continue
|
|
46
46
|
namespace = resource.relative_path.with_suffix("").as_posix()
|
|
47
47
|
if namespace in RESERVED_NAMESPACES:
|
|
@@ -34,7 +34,7 @@ def run_list_endpoints(root: Path) -> None:
|
|
|
34
34
|
logger.debug("Listing endpoints for %d resources", len(resources))
|
|
35
35
|
for resource in resources:
|
|
36
36
|
namespace = resource.relative_path.with_suffix("").as_posix()
|
|
37
|
-
if resource.resource_type not in ("html", "markdown"):
|
|
37
|
+
if resource.resource_type not in ("html", "markdown", "file"):
|
|
38
38
|
print(f"/api/{namespace}")
|
|
39
39
|
print(f"/ui/{namespace}")
|
|
40
40
|
print(f"/schema/{namespace}")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
_COMMON_PASSWORDS = {
|
|
11
|
+
"123456",
|
|
12
|
+
"12345678",
|
|
13
|
+
"123456789",
|
|
14
|
+
"admin",
|
|
15
|
+
"admin123",
|
|
16
|
+
"changeme",
|
|
17
|
+
"letmein",
|
|
18
|
+
"password",
|
|
19
|
+
"password1",
|
|
20
|
+
"password123",
|
|
21
|
+
"qwerty",
|
|
22
|
+
"welcome",
|
|
23
|
+
}
|
|
24
|
+
_COMMON_SEQUENCES = ("12345", "54321", "abcdef", "fedcba", "qwerty", "asdf", "zxcv")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_weak_password(password: str, username: str | None = None) -> bool:
|
|
28
|
+
"""Return True when the password is easily guessed by common heuristics."""
|
|
29
|
+
normalized = password.casefold()
|
|
30
|
+
compact = re.sub(r"[^a-z0-9]", "", normalized)
|
|
31
|
+
|
|
32
|
+
if len(password) < 10:
|
|
33
|
+
return True
|
|
34
|
+
if normalized in _COMMON_PASSWORDS or compact in _COMMON_PASSWORDS:
|
|
35
|
+
return True
|
|
36
|
+
if username and username.casefold() in normalized:
|
|
37
|
+
return True
|
|
38
|
+
if len(set(normalized)) <= 2:
|
|
39
|
+
return True
|
|
40
|
+
if any(sequence in compact for sequence in _COMMON_SEQUENCES):
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
classes = sum(
|
|
44
|
+
(
|
|
45
|
+
any(ch.islower() for ch in password),
|
|
46
|
+
any(ch.isupper() for ch in password),
|
|
47
|
+
any(ch.isdigit() for ch in password),
|
|
48
|
+
any(not ch.isalnum() for ch in password),
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
if len(password) >= 12 and classes >= 3:
|
|
52
|
+
return False
|
|
53
|
+
return not (len(password) >= 10 and classes == 4)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_password(
|
|
57
|
+
*,
|
|
58
|
+
username: str,
|
|
59
|
+
password: str | None,
|
|
60
|
+
password_confirm: str | None = None,
|
|
61
|
+
allow_weak_password: bool = False,
|
|
62
|
+
) -> str | None:
|
|
63
|
+
"""Collect, confirm, and validate a password for CLI user creation."""
|
|
64
|
+
if password is None:
|
|
65
|
+
return _prompt_for_password(username=username)
|
|
66
|
+
|
|
67
|
+
if password_confirm is None:
|
|
68
|
+
if not sys.stdin.isatty():
|
|
69
|
+
print("Password confirmation required. Re-run with --password-confirm for non-interactive use.")
|
|
70
|
+
return None
|
|
71
|
+
password_confirm = getpass.getpass("Confirm password: ")
|
|
72
|
+
|
|
73
|
+
if password != password_confirm:
|
|
74
|
+
logger.warning("Password confirmation mismatch for user %s", username)
|
|
75
|
+
print("Passwords do not match.")
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
if is_weak_password(password, username=username):
|
|
79
|
+
if allow_weak_password:
|
|
80
|
+
return password
|
|
81
|
+
if not sys.stdin.isatty():
|
|
82
|
+
print(
|
|
83
|
+
"This password appears weak and may be easily guessed. "
|
|
84
|
+
"Choose a stronger password or re-run with --allow-weak-password."
|
|
85
|
+
)
|
|
86
|
+
return None
|
|
87
|
+
if not _confirm_weak_password():
|
|
88
|
+
print("Password not accepted.")
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
return password
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _prompt_for_password(*, username: str) -> str:
|
|
95
|
+
while True:
|
|
96
|
+
password = getpass.getpass("Password: ")
|
|
97
|
+
password_confirm = getpass.getpass("Confirm password: ")
|
|
98
|
+
if password != password_confirm:
|
|
99
|
+
logger.warning("Password confirmation mismatch for user %s", username)
|
|
100
|
+
print("Passwords do not match. Please try again.")
|
|
101
|
+
continue
|
|
102
|
+
if is_weak_password(password, username=username) and not _confirm_weak_password():
|
|
103
|
+
print("Please choose a stronger password.")
|
|
104
|
+
continue
|
|
105
|
+
return password
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _confirm_weak_password() -> bool:
|
|
109
|
+
while True:
|
|
110
|
+
answer = input("This password appears weak and may be easily guessed. Use it anyway? [y/N]: ")
|
|
111
|
+
normalized = answer.strip().casefold()
|
|
112
|
+
if normalized in {"y", "yes"}:
|
|
113
|
+
return True
|
|
114
|
+
if normalized in {"", "n", "no"}:
|
|
115
|
+
return False
|
|
116
|
+
print("Please answer 'y' or 'n'.")
|
|
@@ -45,7 +45,16 @@ class AdaptConfig:
|
|
|
45
45
|
".parquet": "adapt.plugins.parquet_plugin.ParquetPlugin",
|
|
46
46
|
".py": "adapt.plugins.python_plugin.PythonHandlerPlugin",
|
|
47
47
|
".html": "adapt.plugins.html_plugin.HtmlPlugin",
|
|
48
|
-
".txt": "adapt.plugins.
|
|
48
|
+
".txt": "adapt.plugins.file_plugin.FilePlugin",
|
|
49
|
+
".pdf": "adapt.plugins.file_plugin.FilePlugin",
|
|
50
|
+
".json": "adapt.plugins.file_plugin.FilePlugin",
|
|
51
|
+
".xml": "adapt.plugins.file_plugin.FilePlugin",
|
|
52
|
+
".svg": "adapt.plugins.file_plugin.FilePlugin",
|
|
53
|
+
".png": "adapt.plugins.file_plugin.FilePlugin",
|
|
54
|
+
".jpg": "adapt.plugins.file_plugin.FilePlugin",
|
|
55
|
+
".jpeg": "adapt.plugins.file_plugin.FilePlugin",
|
|
56
|
+
".gif": "adapt.plugins.file_plugin.FilePlugin",
|
|
57
|
+
".webp": "adapt.plugins.file_plugin.FilePlugin",
|
|
49
58
|
".md": "adapt.plugins.markdown_plugin.MarkdownPlugin",
|
|
50
59
|
".mp4": "adapt.plugins.media_plugin.MediaPlugin",
|
|
51
60
|
".mp3": "adapt.plugins.media_plugin.MediaPlugin",
|
|
@@ -47,18 +47,22 @@ class LockManager:
|
|
|
47
47
|
return lock
|
|
48
48
|
except IntegrityError:
|
|
49
49
|
db.rollback()
|
|
50
|
-
#
|
|
50
|
+
# Another writer won the unique-constraint race. Only retry if we can
|
|
51
|
+
# positively reclaim an expired row; otherwise report a clean conflict.
|
|
51
52
|
existing = db.exec(select(LockRecord).where(LockRecord.resource == resource)).first()
|
|
52
53
|
if existing and (existing.expires_at is None or existing.expires_at.replace(tzinfo=timezone.utc) > now):
|
|
53
54
|
logger.warning(f"Lock already exists for {resource} by {existing.owner}")
|
|
54
55
|
raise RuntimeError(f"Resource '{resource}' is already locked by {existing.owner}")
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
|
|
57
|
+
if existing is not None:
|
|
58
|
+
logger.debug(f"Expired lock found for {resource}, removing stale row")
|
|
57
59
|
db.delete(existing)
|
|
58
60
|
db.commit()
|
|
59
|
-
logger.debug(f"Expired lock removed for {resource}, retrying")
|
|
60
61
|
return self.acquire_lock(resource, owner, reason, ttl_seconds)
|
|
61
62
|
|
|
63
|
+
logger.warning(f"Lock acquisition race detected for {resource} with no remaining row")
|
|
64
|
+
raise RuntimeError(f"Resource '{resource}' is already locked")
|
|
65
|
+
|
|
62
66
|
def release_lock(self, lock_id: int) -> bool:
|
|
63
67
|
"""Release the lock with *lock_id*; returns True if a lock was deleted."""
|
|
64
68
|
logger.info(f"Releasing lock {lock_id}")
|
|
@@ -8,11 +8,12 @@ API surface, no duplicated authorization logic.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
|
-
from typing import Any, Literal
|
|
11
|
+
from typing import Annotated, Any, Literal
|
|
12
12
|
|
|
13
13
|
from fastapi import HTTPException, Request
|
|
14
14
|
from mcp.server.fastmcp import Context, FastMCP
|
|
15
15
|
from mcp.server.fastmcp.exceptions import ToolError
|
|
16
|
+
from pydantic import Field
|
|
16
17
|
from sqlmodel import Session
|
|
17
18
|
|
|
18
19
|
from .auth.dependencies import check_permission, get_current_user
|
|
@@ -26,6 +27,19 @@ from .storage import User
|
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger(__name__)
|
|
28
29
|
|
|
30
|
+
SortParam = Annotated[
|
|
31
|
+
str | None,
|
|
32
|
+
Field(description="Dataset column name to sort by, for example `category` or `price`."),
|
|
33
|
+
]
|
|
34
|
+
OrderParam = Annotated[
|
|
35
|
+
Literal["asc", "desc"],
|
|
36
|
+
Field(description="Sort direction for dataset reads. Use `sort` for the column name and `order` for `asc` or `desc`."),
|
|
37
|
+
]
|
|
38
|
+
FilterParam = Annotated[
|
|
39
|
+
dict[str, Any] | None,
|
|
40
|
+
Field(description="Optional dataset filter object, using the same shape as the REST API."),
|
|
41
|
+
]
|
|
42
|
+
|
|
29
43
|
|
|
30
44
|
async def _authenticated_user(ctx: Context) -> User:
|
|
31
45
|
"""Resolve the calling user from the request's `X-API-Key` header.
|
|
@@ -102,11 +116,15 @@ def build_mcp_server(config: AdaptConfig) -> FastMCP:
|
|
|
102
116
|
ctx: Context,
|
|
103
117
|
limit: int | None = None,
|
|
104
118
|
offset: int = 0,
|
|
105
|
-
sort:
|
|
106
|
-
order:
|
|
107
|
-
filter:
|
|
119
|
+
sort: SortParam = None,
|
|
120
|
+
order: OrderParam = "asc",
|
|
121
|
+
filter: FilterParam = None,
|
|
108
122
|
) -> Any:
|
|
109
|
-
"""Read a resource's content, permission-checked like the REST API.
|
|
123
|
+
"""Read a resource's content, permission-checked like the REST API.
|
|
124
|
+
|
|
125
|
+
For dataset resources, `sort` is the column name and `order` must be
|
|
126
|
+
`asc` or `desc`.
|
|
127
|
+
"""
|
|
110
128
|
request = ctx.request_context.request
|
|
111
129
|
user = await _authenticated_user(ctx)
|
|
112
130
|
entry = _authorized_entry(request, user, resource, "read")
|
|
@@ -5,6 +5,7 @@ logger = logging.getLogger(__name__)
|
|
|
5
5
|
from .base import Plugin, PluginContext, ResourceDescriptor, discover_plugins
|
|
6
6
|
from .csv_plugin import CsvPlugin
|
|
7
7
|
from .excel_plugin import ExcelPlugin
|
|
8
|
+
from .file_plugin import FilePlugin
|
|
8
9
|
from .media_plugin import MediaPlugin
|
|
9
10
|
from .python_plugin import PythonHandlerPlugin
|
|
10
11
|
|
|
@@ -17,6 +18,7 @@ __all__ = [
|
|
|
17
18
|
"discover_plugins",
|
|
18
19
|
"CsvPlugin",
|
|
19
20
|
"ExcelPlugin",
|
|
21
|
+
"FilePlugin",
|
|
20
22
|
"MediaPlugin",
|
|
21
23
|
"PythonHandlerPlugin",
|
|
22
|
-
]
|
|
24
|
+
]
|
|
@@ -3,9 +3,13 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
from abc import ABC, abstractmethod
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
-
|
|
7
|
-
from typing import Any, Iterable, Sequence, TYPE_CHECKING
|
|
6
|
+
import errno
|
|
8
7
|
import logging
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
from typing import Any, Callable, Iterable, Sequence, TYPE_CHECKING
|
|
9
13
|
|
|
10
14
|
from fastapi import Request
|
|
11
15
|
from fastapi.routing import APIRouter
|
|
@@ -159,4 +163,28 @@ def ensure_file(path: Path, content: str) -> None:
|
|
|
159
163
|
return
|
|
160
164
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
165
|
path.write_text(content, encoding="utf-8")
|
|
162
|
-
logger.debug("Created file %s", path)
|
|
166
|
+
logger.debug("Created file %s", path)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def atomic_write(target: Path, suffix: str, write_fn: Callable[[Path], None]) -> None:
|
|
170
|
+
"""Write via a temp file on the target filesystem with EXDEV fallback."""
|
|
171
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
173
|
+
dir=target.parent,
|
|
174
|
+
prefix=f".{target.stem}.",
|
|
175
|
+
suffix=suffix,
|
|
176
|
+
)
|
|
177
|
+
os.close(fd)
|
|
178
|
+
tmp_path = Path(tmp_name)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
write_fn(tmp_path)
|
|
182
|
+
try:
|
|
183
|
+
os.replace(tmp_path, target)
|
|
184
|
+
except OSError as exc:
|
|
185
|
+
if exc.errno != errno.EXDEV:
|
|
186
|
+
raise
|
|
187
|
+
shutil.copyfile(tmp_path, target)
|
|
188
|
+
tmp_path.unlink()
|
|
189
|
+
finally:
|
|
190
|
+
tmp_path.unlink(missing_ok=True)
|
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
import logging
|
|
8
8
|
|
|
9
|
-
from .base import ResourceDescriptor
|
|
9
|
+
from .base import ResourceDescriptor, atomic_write
|
|
10
10
|
from .dataset_plugin import DatasetPlugin
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger(__name__)
|
|
@@ -51,17 +51,14 @@ class CsvPlugin(DatasetPlugin):
|
|
|
51
51
|
|
|
52
52
|
def _write_rows(self, resource: ResourceDescriptor, rows: list[dict[str, Any]], header: list[str]) -> None:
|
|
53
53
|
"""Write rows back to CSV file atomically."""
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
for row in rows:
|
|
61
|
-
writer.writerow([row.get(col, "") for col in header])
|
|
62
|
-
tmp_path = tmp_fh.name
|
|
54
|
+
def write_tmp(tmp_path: Path) -> None:
|
|
55
|
+
with tmp_path.open(mode="w", newline="", encoding="utf-8") as tmp_fh:
|
|
56
|
+
writer = csv.writer(tmp_fh)
|
|
57
|
+
writer.writerow(header)
|
|
58
|
+
for row in rows:
|
|
59
|
+
writer.writerow([row.get(col, "") for col in header])
|
|
63
60
|
|
|
64
|
-
|
|
61
|
+
atomic_write(resource.path, ".csv", write_tmp)
|
|
65
62
|
# Invalidate cache after mutation
|
|
66
63
|
invalidate_cache(str(resource.path))
|
|
67
|
-
logger.debug("Wrote %d rows to %s", len(rows), resource.path)
|
|
64
|
+
logger.debug("Wrote %d rows to %s", len(rows), resource.path)
|
|
@@ -7,7 +7,7 @@ from typing import Any, Sequence
|
|
|
7
7
|
|
|
8
8
|
from openpyxl import load_workbook
|
|
9
9
|
|
|
10
|
-
from .base import ResourceDescriptor
|
|
10
|
+
from .base import ResourceDescriptor, atomic_write
|
|
11
11
|
from .dataset_plugin import DatasetPlugin
|
|
12
12
|
|
|
13
13
|
|
|
@@ -103,9 +103,6 @@ class ExcelPlugin(DatasetPlugin):
|
|
|
103
103
|
"""
|
|
104
104
|
logger.info(f"Writing rows to Excel worksheet: {resource.metadata.get('sub_namespace', '')}")
|
|
105
105
|
sub_namespace = resource.metadata["sub_namespace"]
|
|
106
|
-
# Write back atomically
|
|
107
|
-
import tempfile
|
|
108
|
-
import os
|
|
109
106
|
workbook = load_workbook(resource.path)
|
|
110
107
|
try:
|
|
111
108
|
sheet = workbook[sub_namespace]
|
|
@@ -117,13 +114,14 @@ class ExcelPlugin(DatasetPlugin):
|
|
|
117
114
|
for row_idx, row_data in enumerate(rows, start=2):
|
|
118
115
|
for col_idx, col_name in enumerate(header):
|
|
119
116
|
sheet.cell(row=row_idx, column=col_idx+1).value = row_data.get(col_name, "")
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
tmp_path
|
|
117
|
+
|
|
118
|
+
def write_tmp(tmp_path: Path) -> None:
|
|
119
|
+
workbook.save(tmp_path)
|
|
120
|
+
|
|
121
|
+
atomic_write(resource.path, ".xlsx", write_tmp)
|
|
123
122
|
finally:
|
|
124
123
|
workbook.close()
|
|
125
124
|
|
|
126
|
-
os.replace(tmp_path, resource.path)
|
|
127
125
|
# Invalidate cache after mutation
|
|
128
126
|
invalidate_cache(str(resource.path))
|
|
129
127
|
logger.debug(f"Successfully wrote {len(rows)} rows to Excel file: {resource.path}")
|