paskia 0.9.1__py3-none-any.whl → 0.10.2__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.
- paskia/_version.py +2 -2
- paskia/bootstrap.py +8 -7
- paskia/db/__init__.py +2 -0
- paskia/db/background.py +5 -8
- paskia/db/jsonl.py +2 -2
- paskia/db/logging.py +130 -45
- paskia/db/operations.py +25 -4
- paskia/db/structs.py +3 -2
- paskia/fastapi/__main__.py +33 -19
- paskia/fastapi/admin.py +2 -2
- paskia/fastapi/api.py +7 -3
- paskia/fastapi/authz.py +11 -9
- paskia/fastapi/logging.py +64 -21
- paskia/fastapi/mainapp.py +8 -5
- paskia/fastapi/remote.py +11 -37
- paskia/fastapi/user.py +22 -0
- paskia/fastapi/ws.py +12 -35
- paskia/fastapi/wschat.py +55 -2
- paskia/fastapi/wsutil.py +2 -7
- paskia/frontend-build/auth/admin/index.html +7 -6
- paskia/frontend-build/auth/assets/{AccessDenied-DPkUS8LZ.css → AccessDenied-CVQZxSIL.css} +1 -1
- paskia/frontend-build/auth/assets/AccessDenied-Licr0tqA.js +8 -0
- paskia/frontend-build/auth/assets/{RestrictedAuth-CvR33_Z0.css → RestrictedAuth-0MFeNWS2.css} +1 -1
- paskia/frontend-build/auth/assets/{RestrictedAuth-DsJXicIw.js → RestrictedAuth-DWKMTEV3.js} +1 -1
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js +33 -0
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css +1 -0
- paskia/frontend-build/auth/assets/{admin-DzzjSg72.css → admin-B1H4YqM_.css} +1 -1
- paskia/frontend-build/auth/assets/admin-CZKsX1OI.js +1 -0
- paskia/frontend-build/auth/assets/{auth-C7k64Wad.css → auth-B4EpDxom.css} +1 -1
- paskia/frontend-build/auth/assets/auth-Pe-PKe8b.js +1 -0
- paskia/frontend-build/auth/assets/forward-BC0p23CH.js +1 -0
- paskia/frontend-build/auth/assets/{pow-2N9bxgAo.js → pow-DUr-T9XX.js} +1 -1
- paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
- paskia/frontend-build/auth/assets/reset-CkY9h28U.js +1 -0
- paskia/frontend-build/auth/assets/restricted-C9cJlHkd.js +1 -0
- paskia/frontend-build/auth/assets/theme-C2WysaSw.js +1 -0
- paskia/frontend-build/auth/index.html +8 -7
- paskia/frontend-build/auth/restricted/index.html +7 -6
- paskia/frontend-build/int/forward/index.html +6 -6
- paskia/frontend-build/int/reset/index.html +4 -4
- paskia/frontend-build/paskia.webp +0 -0
- paskia/util/__init__.py +0 -0
- paskia/util/apistructs.py +110 -0
- paskia/util/frontend.py +75 -0
- paskia/util/hostutil.py +75 -0
- paskia/util/htmlutil.py +47 -0
- paskia/util/passphrase.py +20 -0
- paskia/util/permutil.py +43 -0
- paskia/util/pow.py +45 -0
- paskia/util/querysafe.py +11 -0
- paskia/util/sessionutil.py +38 -0
- paskia/util/startupbox.py +103 -0
- paskia/util/timeutil.py +47 -0
- paskia/util/useragent.py +10 -0
- paskia/util/userinfo.py +63 -0
- paskia/util/vitedev.py +71 -0
- paskia/util/wordlist.py +54 -0
- {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/METADATA +14 -11
- paskia-0.10.2.dist-info/RECORD +78 -0
- paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
- paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
- paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
- paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
- paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js +0 -1
- paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
- paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
- paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js +0 -1
- paskia-0.9.1.dist-info/RECORD +0 -60
- {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/WHEEL +0 -0
- {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/entry_points.txt +0 -0
paskia/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.10.2'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 10, 2)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
paskia/bootstrap.py
CHANGED
|
@@ -15,18 +15,18 @@ from paskia.util import hostutil, passphrase
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
17
17
|
# Shared log message template for admin reset links
|
|
18
|
-
ADMIN_RESET_MESSAGE = """
|
|
19
|
-
%s
|
|
20
|
-
|
|
18
|
+
ADMIN_RESET_MESSAGE = """
|
|
21
19
|
👤 Admin %s
|
|
22
20
|
- Use this link to register a Passkey for the admin user!
|
|
23
21
|
"""
|
|
24
22
|
|
|
25
23
|
|
|
26
|
-
def _log_reset_link(
|
|
24
|
+
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
|
|
27
25
|
"""Log a reset link message and return the URL."""
|
|
28
26
|
reset_link = hostutil.reset_link_url(passphrase)
|
|
29
|
-
|
|
27
|
+
if message:
|
|
28
|
+
logger.info(message)
|
|
29
|
+
logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
|
30
30
|
return reset_link
|
|
31
31
|
|
|
32
32
|
|
|
@@ -41,7 +41,7 @@ async def bootstrap_system() -> None:
|
|
|
41
41
|
reset_passphrase = db.bootstrap()
|
|
42
42
|
|
|
43
43
|
# Log the reset link (this is separate from the transaction log)
|
|
44
|
-
_log_reset_link("✅ Bootstrap completed!"
|
|
44
|
+
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
async def check_admin_credentials() -> bool:
|
|
@@ -72,6 +72,7 @@ async def check_admin_credentials() -> bool:
|
|
|
72
72
|
|
|
73
73
|
if not db.get_user_credential_ids(admin_user.uuid):
|
|
74
74
|
# Admin exists but has no credentials, create reset link
|
|
75
|
+
logger.info("⚠️ Admin user has no credentials!")
|
|
75
76
|
|
|
76
77
|
token = passphrase.generate()
|
|
77
78
|
expiry = authsession.reset_expires()
|
|
@@ -81,7 +82,7 @@ async def check_admin_credentials() -> bool:
|
|
|
81
82
|
expiry=expiry,
|
|
82
83
|
token_type="admin registration",
|
|
83
84
|
)
|
|
84
|
-
_log_reset_link(
|
|
85
|
+
_log_reset_link(token)
|
|
85
86
|
return True
|
|
86
87
|
|
|
87
88
|
return False
|
paskia/db/__init__.py
CHANGED
|
@@ -64,6 +64,7 @@ from paskia.db.operations import (
|
|
|
64
64
|
update_user_display_name,
|
|
65
65
|
update_user_role,
|
|
66
66
|
update_user_role_in_organization,
|
|
67
|
+
update_user_theme,
|
|
67
68
|
)
|
|
68
69
|
from paskia.db.structs import (
|
|
69
70
|
DB,
|
|
@@ -147,4 +148,5 @@ __all__ = [
|
|
|
147
148
|
"update_user_display_name",
|
|
148
149
|
"update_user_role",
|
|
149
150
|
"update_user_role_in_organization",
|
|
151
|
+
"update_user_theme",
|
|
150
152
|
]
|
paskia/db/background.py
CHANGED
|
@@ -74,21 +74,18 @@ async def start_background():
|
|
|
74
74
|
_logger.debug("Background task in different event loop, restarting")
|
|
75
75
|
_background_task = None
|
|
76
76
|
else:
|
|
77
|
-
# Task is running in
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"
|
|
77
|
+
# Task is already running in same loop - idempotent, just return
|
|
78
|
+
# This happens with dual IPv4+IPv6 endpoints sharing the same process
|
|
79
|
+
_logger.debug(
|
|
80
|
+
"Background task already running in same loop, skipping"
|
|
81
81
|
)
|
|
82
|
-
|
|
83
|
-
raise # Re-raise RuntimeError from above
|
|
82
|
+
return
|
|
84
83
|
except Exception as e:
|
|
85
84
|
_logger.debug("Error checking background task loop: %s, restarting", e)
|
|
86
85
|
_background_task = None
|
|
87
86
|
|
|
88
87
|
if _background_task is None:
|
|
89
88
|
_background_task = asyncio.create_task(_background_loop())
|
|
90
|
-
else:
|
|
91
|
-
_logger.debug("Background task already running: %s", _background_task)
|
|
92
89
|
|
|
93
90
|
|
|
94
91
|
async def stop_background():
|
paskia/db/jsonl.py
CHANGED
|
@@ -198,7 +198,6 @@ class JsonlStore:
|
|
|
198
198
|
if not diff:
|
|
199
199
|
return
|
|
200
200
|
self._pending_changes.append(create_change_record(action, version, diff, user))
|
|
201
|
-
self._previous_builtins = copy.deepcopy(current)
|
|
202
201
|
|
|
203
202
|
# Log the change with user display name if available
|
|
204
203
|
user_display = None
|
|
@@ -210,7 +209,8 @@ class JsonlStore:
|
|
|
210
209
|
except (ValueError, KeyError):
|
|
211
210
|
user_display = user
|
|
212
211
|
|
|
213
|
-
log_change(action, diff, user_display)
|
|
212
|
+
log_change(action, diff, user_display, self._previous_builtins)
|
|
213
|
+
self._previous_builtins = copy.deepcopy(current)
|
|
214
214
|
|
|
215
215
|
@contextmanager
|
|
216
216
|
def transaction(
|
paskia/db/logging.py
CHANGED
|
@@ -26,8 +26,7 @@ _RESET = "\033[0m"
|
|
|
26
26
|
_DIM = "\033[2m"
|
|
27
27
|
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
|
|
28
28
|
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
|
|
29
|
-
|
|
30
|
-
_DELETE = "\033[0;31m" # Red for deletions
|
|
29
|
+
_DELETE = "\033[1;31m" # Red for deletions
|
|
31
30
|
_ADD = "\033[0;32m" # Green for additions
|
|
32
31
|
_ACTION = "\033[1;34m" # Bold blue for action name
|
|
33
32
|
_USER = "\033[0;34m" # Blue for user display
|
|
@@ -93,18 +92,34 @@ def _format_path(path: list[str], use_color: bool) -> str:
|
|
|
93
92
|
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
|
|
94
93
|
|
|
95
94
|
|
|
95
|
+
def _get_nested(data: dict | None, path: list[str]) -> Any:
|
|
96
|
+
"""Get a nested value from a dict by path, or None if not found."""
|
|
97
|
+
if data is None:
|
|
98
|
+
return None
|
|
99
|
+
current = data
|
|
100
|
+
for key in path:
|
|
101
|
+
if not isinstance(current, dict) or key not in current:
|
|
102
|
+
return None
|
|
103
|
+
current = current[key]
|
|
104
|
+
return current
|
|
105
|
+
|
|
106
|
+
|
|
96
107
|
def _collect_changes(
|
|
97
|
-
diff: dict,
|
|
108
|
+
diff: dict,
|
|
109
|
+
path: list[str],
|
|
110
|
+
changes: list[tuple[str, list[str], Any]],
|
|
111
|
+
previous: dict | None,
|
|
98
112
|
) -> None:
|
|
99
113
|
"""
|
|
100
114
|
Recursively collect changes from a diff into a flat list.
|
|
101
115
|
|
|
102
|
-
Each change is a tuple of (change_type, path, new_value
|
|
103
|
-
change_type is one of: '
|
|
116
|
+
Each change is a tuple of (change_type, path, new_value).
|
|
117
|
+
change_type is one of: 'add', 'update', 'delete'
|
|
104
118
|
"""
|
|
105
119
|
if not isinstance(diff, dict):
|
|
106
|
-
# Leaf value -
|
|
107
|
-
|
|
120
|
+
# Leaf value - check if it existed before
|
|
121
|
+
existed = _get_nested(previous, path) is not None
|
|
122
|
+
changes.append(("update" if existed else "add", path, diff))
|
|
108
123
|
return
|
|
109
124
|
|
|
110
125
|
for key, value in diff.items():
|
|
@@ -112,72 +127,136 @@ def _collect_changes(
|
|
|
112
127
|
# $delete contains a list of keys to delete
|
|
113
128
|
if isinstance(value, list):
|
|
114
129
|
for deleted_key in value:
|
|
115
|
-
changes.append(("delete", path + [str(deleted_key)], None
|
|
130
|
+
changes.append(("delete", path + [str(deleted_key)], None))
|
|
116
131
|
else:
|
|
117
|
-
changes.append(("delete", path + [str(value)], None
|
|
132
|
+
changes.append(("delete", path + [str(value)], None))
|
|
118
133
|
|
|
119
134
|
elif key == "$replace":
|
|
120
|
-
# $replace
|
|
135
|
+
# $replace replaces the entire collection at this path
|
|
136
|
+
# We need to track what was added and what was deleted
|
|
137
|
+
old_collection = _get_nested(previous, path)
|
|
138
|
+
old_keys = (
|
|
139
|
+
set(old_collection.keys())
|
|
140
|
+
if isinstance(old_collection, dict)
|
|
141
|
+
else set()
|
|
142
|
+
)
|
|
143
|
+
new_keys = set(value.keys()) if isinstance(value, dict) else set()
|
|
144
|
+
|
|
145
|
+
# Items that existed before but not in new = deleted
|
|
146
|
+
for deleted_key in old_keys - new_keys:
|
|
147
|
+
changes.append(("delete", path + [str(deleted_key)], None))
|
|
148
|
+
|
|
149
|
+
# Items in new collection
|
|
121
150
|
if isinstance(value, dict):
|
|
122
|
-
# Replacing with a dict - show each key as a replacement
|
|
123
151
|
for rkey, rval in value.items():
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
152
|
+
existed = rkey in old_keys
|
|
153
|
+
changes.append(
|
|
154
|
+
("update" if existed else "add", path + [str(rkey)], rval)
|
|
155
|
+
)
|
|
156
|
+
elif value or not old_keys:
|
|
157
|
+
# Non-dict replacement or empty replacement with nothing before
|
|
158
|
+
changes.append(
|
|
159
|
+
("update" if old_collection is not None else "add", path, value)
|
|
160
|
+
)
|
|
130
161
|
|
|
131
162
|
elif key.startswith("$"):
|
|
132
163
|
# Other special operations (future-proofing)
|
|
133
|
-
changes.append(("
|
|
164
|
+
changes.append(("add", path, {key: value}))
|
|
134
165
|
|
|
135
166
|
else:
|
|
136
|
-
# Regular nested key
|
|
137
|
-
|
|
167
|
+
# Regular nested key - check if this item existed before
|
|
168
|
+
new_path = path + [str(key)]
|
|
169
|
+
existed = _get_nested(previous, new_path) is not None
|
|
170
|
+
if existed:
|
|
171
|
+
# Item exists - recurse to show specific field changes
|
|
172
|
+
_collect_changes(value, new_path, changes, previous)
|
|
173
|
+
else:
|
|
174
|
+
# New item - record as add with full value, don't recurse
|
|
175
|
+
changes.append(("add", new_path, value))
|
|
138
176
|
|
|
139
177
|
|
|
140
|
-
def
|
|
178
|
+
def _format_change_lines(
|
|
141
179
|
change_type: str, path: list[str], value: Any, use_color: bool
|
|
142
|
-
) -> str:
|
|
143
|
-
"""Format a single change as
|
|
144
|
-
path_str = _format_path(path, use_color)
|
|
145
|
-
value_str = _format_value(value, use_color)
|
|
146
|
-
|
|
180
|
+
) -> list[str]:
|
|
181
|
+
"""Format a single change as one or more lines."""
|
|
147
182
|
if change_type == "delete":
|
|
148
|
-
if use_color:
|
|
149
|
-
return f"
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
183
|
+
if not use_color:
|
|
184
|
+
return [f" {'.'.join(path)} ✗"]
|
|
185
|
+
if len(path) == 1:
|
|
186
|
+
return [f" {_DELETE}{path[0]} ✗{_RESET}"]
|
|
187
|
+
prefix = ".".join(path[:-1])
|
|
188
|
+
final = path[-1]
|
|
189
|
+
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
|
190
|
+
|
|
191
|
+
if change_type == "add":
|
|
192
|
+
# New item being created - only final element in green
|
|
193
|
+
# For dict values, show children on separate indented lines
|
|
194
|
+
if isinstance(value, dict) and value:
|
|
195
|
+
lines = []
|
|
196
|
+
# First line: path with green final element and grey =
|
|
197
|
+
if not use_color:
|
|
198
|
+
lines.append(f" {'.'.join(path)} =")
|
|
199
|
+
elif len(path) == 1:
|
|
200
|
+
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
|
|
201
|
+
else:
|
|
202
|
+
prefix = ".".join(path[:-1])
|
|
203
|
+
final = path[-1]
|
|
204
|
+
lines.append(
|
|
205
|
+
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
|
|
206
|
+
)
|
|
207
|
+
# Child lines: indented key: value, with aligned values
|
|
208
|
+
max_key_len = max(len(k) for k in value.keys())
|
|
209
|
+
field_width = max(max_key_len, 12) # minimum 12 chars
|
|
210
|
+
for k, v in value.items():
|
|
211
|
+
v_str = _format_value(v, use_color)
|
|
212
|
+
padding = " " * (field_width - len(k))
|
|
213
|
+
if use_color:
|
|
214
|
+
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
|
|
215
|
+
else:
|
|
216
|
+
lines.append(f" {k}:{padding} {v_str}")
|
|
217
|
+
return lines
|
|
218
|
+
else:
|
|
219
|
+
value_str = _format_value(value, use_color)
|
|
220
|
+
if not use_color:
|
|
221
|
+
return [f" {'.'.join(path)} = {value_str}"]
|
|
222
|
+
if len(path) == 1:
|
|
223
|
+
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
|
|
224
|
+
prefix = ".".join(path[:-1])
|
|
225
|
+
final = path[-1]
|
|
226
|
+
return [
|
|
227
|
+
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
# update: Existing item being updated - normal path colors
|
|
231
|
+
value_str = _format_value(value, use_color)
|
|
232
|
+
path_str = _format_path(path, use_color)
|
|
158
233
|
if use_color:
|
|
159
|
-
return f" {
|
|
160
|
-
return f"
|
|
234
|
+
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
|
|
235
|
+
return [f" {path_str} = {value_str}"]
|
|
161
236
|
|
|
162
237
|
|
|
163
|
-
def format_diff(diff: dict) -> list[str]:
|
|
238
|
+
def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
|
|
164
239
|
"""
|
|
165
240
|
Format a JSON diff as human-readable lines.
|
|
166
241
|
|
|
242
|
+
Args:
|
|
243
|
+
diff: The JSON diff dict
|
|
244
|
+
previous: The previous state dict (for determining add vs update)
|
|
245
|
+
|
|
167
246
|
Returns a list of formatted lines (without newlines).
|
|
168
247
|
Single changes return one line, multiple changes return multiple lines.
|
|
169
248
|
"""
|
|
170
249
|
use_color = _use_color()
|
|
171
|
-
changes: list[tuple[str, list[str], Any
|
|
172
|
-
_collect_changes(diff, [], changes)
|
|
250
|
+
changes: list[tuple[str, list[str], Any]] = []
|
|
251
|
+
_collect_changes(diff, [], changes, previous)
|
|
173
252
|
|
|
174
253
|
if not changes:
|
|
175
254
|
return []
|
|
176
255
|
|
|
177
256
|
# Format each change
|
|
178
257
|
lines = []
|
|
179
|
-
for change_type, path, value
|
|
180
|
-
lines.
|
|
258
|
+
for change_type, path, value in changes:
|
|
259
|
+
lines.extend(_format_change_lines(change_type, path, value, use_color))
|
|
181
260
|
|
|
182
261
|
return lines
|
|
183
262
|
|
|
@@ -198,7 +277,12 @@ def format_action_header(action: str, user_display: str | None = None) -> str:
|
|
|
198
277
|
return action
|
|
199
278
|
|
|
200
279
|
|
|
201
|
-
def log_change(
|
|
280
|
+
def log_change(
|
|
281
|
+
action: str,
|
|
282
|
+
diff: dict,
|
|
283
|
+
user_display: str | None = None,
|
|
284
|
+
previous: dict | None = None,
|
|
285
|
+
) -> None:
|
|
202
286
|
"""
|
|
203
287
|
Log a database change with pretty-printed diff.
|
|
204
288
|
|
|
@@ -206,9 +290,10 @@ def log_change(action: str, diff: dict, user_display: str | None = None) -> None
|
|
|
206
290
|
action: The action name (e.g., "login", "admin:delete_user")
|
|
207
291
|
diff: The JSON diff dict
|
|
208
292
|
user_display: Optional display name of the user who performed the action
|
|
293
|
+
previous: The previous state dict (for determining add vs update)
|
|
209
294
|
"""
|
|
210
295
|
header = format_action_header(action, user_display)
|
|
211
|
-
diff_lines = format_diff(diff)
|
|
296
|
+
diff_lines = format_diff(diff, previous)
|
|
212
297
|
|
|
213
298
|
if not diff_lines:
|
|
214
299
|
logger.info(header)
|
paskia/db/operations.py
CHANGED
|
@@ -352,6 +352,23 @@ def update_user_display_name(
|
|
|
352
352
|
_db.users[uuid].display_name = display_name
|
|
353
353
|
|
|
354
354
|
|
|
355
|
+
def update_user_theme(
|
|
356
|
+
uuid: UUID,
|
|
357
|
+
theme: str,
|
|
358
|
+
*,
|
|
359
|
+
ctx: SessionContext | None = None,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Update user theme preference ('' for auto, 'light', 'dark')."""
|
|
362
|
+
if isinstance(uuid, str):
|
|
363
|
+
uuid = UUID(uuid)
|
|
364
|
+
if uuid not in _db.users:
|
|
365
|
+
raise ValueError(f"User {uuid} not found")
|
|
366
|
+
if theme not in ("", "light", "dark"):
|
|
367
|
+
raise ValueError(f"Invalid theme: {theme}")
|
|
368
|
+
with _db.transaction("update_user_theme", ctx):
|
|
369
|
+
_db.users[uuid].theme = theme
|
|
370
|
+
|
|
371
|
+
|
|
355
372
|
def update_user_role(
|
|
356
373
|
uuid: UUID,
|
|
357
374
|
role_uuid: UUID,
|
|
@@ -517,16 +534,18 @@ def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None)
|
|
|
517
534
|
update_session(key, host=host, ctx=ctx)
|
|
518
535
|
|
|
519
536
|
|
|
520
|
-
def delete_session(
|
|
537
|
+
def delete_session(
|
|
538
|
+
key: str, *, ctx: SessionContext | None = None, action: str = "delete_session"
|
|
539
|
+
) -> None:
|
|
521
540
|
"""Delete a session.
|
|
522
541
|
|
|
523
542
|
The acting user should be logged via ctx.
|
|
524
|
-
For user logout, pass ctx of the user's session.
|
|
543
|
+
For user logout, pass ctx of the user's session and action="logout".
|
|
525
544
|
For admin terminating a session, pass admin's ctx.
|
|
526
545
|
"""
|
|
527
546
|
if key not in _db.sessions:
|
|
528
547
|
raise ValueError("Session not found")
|
|
529
|
-
with _db.transaction(
|
|
548
|
+
with _db.transaction(action, ctx):
|
|
530
549
|
del _db.sessions[key]
|
|
531
550
|
|
|
532
551
|
|
|
@@ -554,6 +573,7 @@ def create_reset_token(
|
|
|
554
573
|
token_type: str,
|
|
555
574
|
*,
|
|
556
575
|
ctx: SessionContext | None = None,
|
|
576
|
+
user: str | None = None,
|
|
557
577
|
) -> None:
|
|
558
578
|
"""Create a reset token from a passphrase.
|
|
559
579
|
|
|
@@ -561,13 +581,14 @@ def create_reset_token(
|
|
|
561
581
|
For self-service (user creating own recovery link), pass user's ctx.
|
|
562
582
|
For admin operations, pass admin's ctx.
|
|
563
583
|
For system operations (bootstrap), pass neither to log no user.
|
|
584
|
+
For API operations where ctx is not available but user is known, pass user.
|
|
564
585
|
"""
|
|
565
586
|
key = _reset_key(passphrase)
|
|
566
587
|
if key in _db.reset_tokens:
|
|
567
588
|
raise ValueError("Reset token already exists")
|
|
568
589
|
if user_uuid not in _db.users:
|
|
569
590
|
raise ValueError(f"User {user_uuid} not found")
|
|
570
|
-
with _db.transaction("create_reset_token", ctx):
|
|
591
|
+
with _db.transaction("create_reset_token", ctx, user=user):
|
|
571
592
|
_db.reset_tokens[key] = ResetToken(
|
|
572
593
|
user_uuid=user_uuid, expiry=expiry, token_type=token_type
|
|
573
594
|
)
|
paskia/db/structs.py
CHANGED
|
@@ -147,10 +147,10 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
|
|
147
147
|
return role
|
|
148
148
|
|
|
149
149
|
|
|
150
|
-
class User(msgspec.Struct, dict=True):
|
|
150
|
+
class User(msgspec.Struct, dict=True, omit_defaults=True):
|
|
151
151
|
"""User data structure.
|
|
152
152
|
|
|
153
|
-
Mutable fields: display_name, role_uuid, last_seen, visits
|
|
153
|
+
Mutable fields: display_name, role_uuid, last_seen, visits, theme
|
|
154
154
|
Immutable fields: created_at (set at creation, never modified)
|
|
155
155
|
uuid is derived from created_at using uuid7.
|
|
156
156
|
"""
|
|
@@ -160,6 +160,7 @@ class User(msgspec.Struct, dict=True):
|
|
|
160
160
|
created_at: datetime
|
|
161
161
|
last_seen: datetime | None = None
|
|
162
162
|
visits: int = 0
|
|
163
|
+
theme: str = "" # "" or "auto" = OS default, "light", "dark"
|
|
163
164
|
|
|
164
165
|
def __post_init__(self):
|
|
165
166
|
if not hasattr(self, "uuid"):
|
paskia/fastapi/__main__.py
CHANGED
|
@@ -7,12 +7,12 @@ from urllib.parse import urlparse
|
|
|
7
7
|
|
|
8
8
|
from fastapi_vue.hostutil import parse_endpoint
|
|
9
9
|
from uvicorn import Config, Server
|
|
10
|
+
from uvicorn import run as uvicorn_run
|
|
10
11
|
|
|
11
12
|
from paskia import globals as _globals
|
|
12
13
|
from paskia.bootstrap import bootstrap_if_needed
|
|
13
14
|
from paskia.config import PaskiaConfig
|
|
14
15
|
from paskia.db.background import flush
|
|
15
|
-
from paskia.fastapi import app as fastapi_app
|
|
16
16
|
from paskia.fastapi import reset as reset_cmd
|
|
17
17
|
from paskia.util import startupbox
|
|
18
18
|
from paskia.util.hostutil import normalize_origin
|
|
@@ -21,10 +21,10 @@ DEFAULT_PORT = 4401
|
|
|
21
21
|
|
|
22
22
|
EPILOG = """\
|
|
23
23
|
Examples:
|
|
24
|
-
paskia
|
|
25
|
-
paskia :8080
|
|
26
|
-
paskia
|
|
27
|
-
paskia reset [user]
|
|
24
|
+
paskia # localhost:4401
|
|
25
|
+
paskia -l :8080 # All interfaces, port 8080
|
|
26
|
+
paskia -l /tmp/paskia.sock # Unix socket
|
|
27
|
+
paskia reset [user] # Generate passkey reset link
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
30
|
|
|
@@ -81,32 +81,40 @@ def main():
|
|
|
81
81
|
epilog=EPILOG,
|
|
82
82
|
)
|
|
83
83
|
|
|
84
|
-
#
|
|
84
|
+
# Subcommand for reset
|
|
85
85
|
parser.add_argument(
|
|
86
|
-
"
|
|
86
|
+
"command",
|
|
87
87
|
nargs="?",
|
|
88
|
-
help=
|
|
89
|
-
"Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
|
|
90
|
-
"[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
|
|
91
|
-
),
|
|
88
|
+
help="Command: 'reset' for credential reset, or omit to run server",
|
|
92
89
|
)
|
|
93
90
|
parser.add_argument(
|
|
94
91
|
"reset_query",
|
|
95
92
|
nargs="?",
|
|
96
93
|
help="For 'reset' command: user UUID or substring of display name",
|
|
97
94
|
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"-l",
|
|
97
|
+
"--listen",
|
|
98
|
+
metavar="LISTEN",
|
|
99
|
+
help=(
|
|
100
|
+
"Endpoint to listen on (default: localhost:4401). "
|
|
101
|
+
"Forms: host:port port :port [ipv6]:port unix:path /path.sock"
|
|
102
|
+
),
|
|
103
|
+
)
|
|
98
104
|
add_common_options(parser)
|
|
99
105
|
|
|
100
106
|
args = parser.parse_args()
|
|
101
107
|
|
|
102
|
-
# Detect "reset" subcommand
|
|
103
|
-
is_reset = args.
|
|
108
|
+
# Detect "reset" subcommand
|
|
109
|
+
is_reset = args.command == "reset"
|
|
104
110
|
|
|
105
111
|
if is_reset:
|
|
106
112
|
endpoints = []
|
|
107
113
|
else:
|
|
114
|
+
if args.command is not None:
|
|
115
|
+
raise SystemExit(f"Unknown command: {args.command}")
|
|
108
116
|
# Parse endpoint using fastapi_vue.hostutil
|
|
109
|
-
endpoints = parse_endpoint(args.
|
|
117
|
+
endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
|
|
110
118
|
|
|
111
119
|
# Extract host/port/uds from first endpoint for config display and site_url
|
|
112
120
|
ep = endpoints[0] if endpoints else {}
|
|
@@ -188,7 +196,7 @@ def main():
|
|
|
188
196
|
devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
|
|
189
197
|
|
|
190
198
|
run_kwargs: dict = {
|
|
191
|
-
"log_level": "
|
|
199
|
+
"log_level": "warning", # Suppress startup messages; we use custom logging
|
|
192
200
|
"access_log": False, # We use custom AccessLogMiddleware instead
|
|
193
201
|
}
|
|
194
202
|
|
|
@@ -199,8 +207,6 @@ def main():
|
|
|
199
207
|
raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
|
|
200
208
|
run_kwargs["reload"] = True
|
|
201
209
|
run_kwargs["reload_dirs"] = ["paskia"]
|
|
202
|
-
# Suppress uvicorn startup messages in dev mode
|
|
203
|
-
run_kwargs["log_level"] = "warning"
|
|
204
210
|
|
|
205
211
|
async def async_main():
|
|
206
212
|
await _globals.init(
|
|
@@ -220,10 +226,18 @@ def main():
|
|
|
220
226
|
async with asyncio.TaskGroup() as tg:
|
|
221
227
|
for ep in endpoints:
|
|
222
228
|
tg.create_task(
|
|
223
|
-
Server(
|
|
229
|
+
Server(
|
|
230
|
+
Config(app="paskia.fastapi:app", **run_kwargs, **ep)
|
|
231
|
+
).serve()
|
|
224
232
|
)
|
|
233
|
+
elif devmode:
|
|
234
|
+
# Use uvicorn.run for proper reload support (it handles subprocess spawning)
|
|
235
|
+
ep = endpoints[0]
|
|
236
|
+
uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
|
|
225
237
|
else:
|
|
226
|
-
server = Server(
|
|
238
|
+
server = Server(
|
|
239
|
+
Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
|
|
240
|
+
)
|
|
227
241
|
await server.serve()
|
|
228
242
|
|
|
229
243
|
try:
|
paskia/fastapi/admin.py
CHANGED
|
@@ -127,7 +127,7 @@ async def admin_create_org(
|
|
|
127
127
|
db.create_org(org, ctx=ctx)
|
|
128
128
|
# Grant requested permissions to the new org
|
|
129
129
|
for perm in permissions:
|
|
130
|
-
db.add_permission_to_org(str(org.uuid), perm)
|
|
130
|
+
db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
|
|
131
131
|
|
|
132
132
|
return {"uuid": str(org.uuid)}
|
|
133
133
|
|
|
@@ -706,7 +706,7 @@ async def admin_delete_user_session(
|
|
|
706
706
|
if not target_session or target_session.user_uuid != user_uuid:
|
|
707
707
|
raise HTTPException(status_code=404, detail="Session not found")
|
|
708
708
|
|
|
709
|
-
db.delete_session(session_id, ctx=ctx)
|
|
709
|
+
db.delete_session(session_id, ctx=ctx, action="admin:delete_session")
|
|
710
710
|
|
|
711
711
|
# Check if admin terminated their own session
|
|
712
712
|
current_terminated = session_id == auth
|
paskia/fastapi/api.py
CHANGED
|
@@ -78,7 +78,7 @@ async def validate_token(
|
|
|
78
78
|
try:
|
|
79
79
|
ctx = await authz.verify(
|
|
80
80
|
auth,
|
|
81
|
-
perm,
|
|
81
|
+
" ".join(perm).split(),
|
|
82
82
|
host=request.headers.get("host"),
|
|
83
83
|
max_age=max_age,
|
|
84
84
|
)
|
|
@@ -94,6 +94,7 @@ async def validate_token(
|
|
|
94
94
|
ip=request.client.host if request.client else "",
|
|
95
95
|
user_agent=request.headers.get("user-agent") or "",
|
|
96
96
|
expiry=expires(),
|
|
97
|
+
ctx=ctx,
|
|
97
98
|
)
|
|
98
99
|
session.set_session_cookie(response, auth)
|
|
99
100
|
renewed = True
|
|
@@ -130,7 +131,10 @@ async def forward_authentication(
|
|
|
130
131
|
"""
|
|
131
132
|
try:
|
|
132
133
|
ctx = await authz.verify(
|
|
133
|
-
auth,
|
|
134
|
+
auth,
|
|
135
|
+
" ".join(perm).split(),
|
|
136
|
+
host=request.headers.get("host"),
|
|
137
|
+
max_age=max_age,
|
|
134
138
|
)
|
|
135
139
|
# Build permission scopes for Remote-Groups header
|
|
136
140
|
role_permissions = (
|
|
@@ -248,7 +252,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
|
|
|
248
252
|
if not ctx:
|
|
249
253
|
return {"message": "Already logged out"}
|
|
250
254
|
with suppress(Exception):
|
|
251
|
-
db.delete_session(auth, ctx=ctx)
|
|
255
|
+
db.delete_session(auth, ctx=ctx, action="logout")
|
|
252
256
|
session.clear_session_cookie(response)
|
|
253
257
|
return {"message": "Logged out successfully"}
|
|
254
258
|
|