webduck 1.0.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.
- webduck/__init__.py +1 -0
- webduck/api/__init__.py +1 -0
- webduck/api/admin.py +371 -0
- webduck/api/db.py +357 -0
- webduck/auth/__init__.py +2 -0
- webduck/auth/manager.py +261 -0
- webduck/cli.py +49 -0
- webduck/config.py +192 -0
- webduck/i18n.py +191 -0
- webduck/locales/de/LC_MESSAGES/messages.mo +0 -0
- webduck/locales/de/messages.po +355 -0
- webduck/locales/en/LC_MESSAGES/messages.mo +0 -0
- webduck/locales/en/messages.po +355 -0
- webduck/logging.py +207 -0
- webduck/main.py +580 -0
- webduck/pages/__init__.py +12 -0
- webduck/pages/browse.py +893 -0
- webduck/pages/context.py +90 -0
- webduck/pages/dashboard.py +96 -0
- webduck/pages/import_export.py +366 -0
- webduck/pages/login.py +139 -0
- webduck/pages/projects.py +564 -0
- webduck/pages/query.py +459 -0
- webduck/pages/ui_helpers.py +198 -0
- webduck/pages/user_prefs.py +97 -0
- webduck/static/favicon.ico +0 -0
- webduck/static/footer-logo.png +0 -0
- webduck/static/icon.png +0 -0
- webduck/static/icon.svg +18 -0
- webduck/static/screen_browse.webp +0 -0
- webduck/static/screen_csv.webp +0 -0
- webduck/static/screen_projects.webp +0 -0
- webduck/static/screen_sql.webp +0 -0
- webduck/storage/__init__.py +2 -0
- webduck/storage/engine.py +495 -0
- webduck/ui/__init__.py +2 -0
- webduck-1.0.0.dist-info/METADATA +306 -0
- webduck-1.0.0.dist-info/RECORD +41 -0
- webduck-1.0.0.dist-info/WHEEL +4 -0
- webduck-1.0.0.dist-info/entry_points.txt +2 -0
- webduck-1.0.0.dist-info/licenses/LICENSE +21 -0
webduck/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""WebDuck - A DuckDB server with REST API and Web UI."""
|
webduck/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""WebDuck REST API endpoints."""
|
webduck/api/admin.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 autumo GmbH. All rights reserved.
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the MIT License. See LICENSE file in the project root for
|
|
5
|
+
# full license information.
|
|
6
|
+
#
|
|
7
|
+
# NOTICE: This file is part of WebDuck. The above copyright notice and this
|
|
8
|
+
# permission notice shall be included in all copies or substantial portions
|
|
9
|
+
# of this software.
|
|
10
|
+
# ------------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
# =============================================================================
|
|
13
|
+
# WebDuck — Admin REST API
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# JWT-protected admin endpoints for user, project, and database management.
|
|
16
|
+
#
|
|
17
|
+
# Endpoints:
|
|
18
|
+
# POST /admin/login — Authenticate, get JWT
|
|
19
|
+
# GET /admin/projects — List projects
|
|
20
|
+
# POST /admin/projects — Create project
|
|
21
|
+
# DELETE /admin/projects/{project} — Delete project
|
|
22
|
+
# GET /admin/projects/{project}/databases — List databases
|
|
23
|
+
# POST /admin/projects/{project}/databases — Create database
|
|
24
|
+
# DELETE /admin/projects/{project}/databases/{db} — Delete database
|
|
25
|
+
# PUT /admin/projects/{p}/databases/{db}/password — Set DB password
|
|
26
|
+
# GET /admin/users — List admin users
|
|
27
|
+
# POST /admin/users — Create admin user
|
|
28
|
+
# DELETE /admin/users/{username} — Delete admin user
|
|
29
|
+
#
|
|
30
|
+
# Project: WebDuck
|
|
31
|
+
# Author: autumo GmbH
|
|
32
|
+
# Version: 1.0.0
|
|
33
|
+
# Date: 2026-07-20
|
|
34
|
+
# =============================================================================
|
|
35
|
+
|
|
36
|
+
"""WebDuck REST API - Admin endpoints (JWT protected)."""
|
|
37
|
+
|
|
38
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
39
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
40
|
+
from pydantic import BaseModel
|
|
41
|
+
|
|
42
|
+
from webduck.auth.manager import AuthManager
|
|
43
|
+
from webduck.logging import log_error, log_warning
|
|
44
|
+
from webduck.storage.engine import StorageEngine
|
|
45
|
+
|
|
46
|
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
47
|
+
|
|
48
|
+
# HTTPBearer extracts the "Authorization: Bearer <token>" header on each request.
|
|
49
|
+
# FastAPI's Depends(security) injects the parsed credentials into endpoints.
|
|
50
|
+
security = HTTPBearer()
|
|
51
|
+
|
|
52
|
+
# These module-level globals are injected once at startup via set_dependencies().
|
|
53
|
+
# They act as singletons for the auth and storage layers used by every endpoint.
|
|
54
|
+
auth_manager: AuthManager | None = None
|
|
55
|
+
storage_engine: StorageEngine | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_dependencies(auth: AuthManager, storage: StorageEngine) -> None:
|
|
59
|
+
"""Set dependencies for admin endpoints.
|
|
60
|
+
|
|
61
|
+
Called once during app startup from main.py to wire in the real
|
|
62
|
+
AuthManager and StorageEngine instances. All admin endpoints depend
|
|
63
|
+
on these being set before serving requests.
|
|
64
|
+
"""
|
|
65
|
+
global auth_manager, storage_engine
|
|
66
|
+
auth_manager = auth
|
|
67
|
+
storage_engine = storage
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def verify_admin(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
|
71
|
+
"""Verify admin JWT token.
|
|
72
|
+
|
|
73
|
+
JWT Authentication Flow:
|
|
74
|
+
1. Client sends request with "Authorization: Bearer <jwt>" header.
|
|
75
|
+
2. HTTPBearer (Depends(security)) extracts the raw token string.
|
|
76
|
+
3. verify_jwt_token() decodes and validates the JWT (expiry, signature).
|
|
77
|
+
4. On success the embedded username is returned and injected into the
|
|
78
|
+
endpoint via the 'username' parameter — proving the caller is authenticated.
|
|
79
|
+
5. On failure (None returned) a 401 is raised before any endpoint logic runs.
|
|
80
|
+
|
|
81
|
+
This dependency is attached to every protected endpoint:
|
|
82
|
+
username: str = Depends(verify_admin)
|
|
83
|
+
"""
|
|
84
|
+
if not auth_manager:
|
|
85
|
+
log_error("verify_admin: Auth manager not initialized")
|
|
86
|
+
raise HTTPException(status_code=500, detail="Auth manager not initialized")
|
|
87
|
+
|
|
88
|
+
# Decode JWT and extract username; returns None if expired/invalid.
|
|
89
|
+
username = auth_manager.verify_jwt_token(credentials.credentials)
|
|
90
|
+
if not username:
|
|
91
|
+
log_warning("verify_admin: Invalid or expired token")
|
|
92
|
+
raise HTTPException(
|
|
93
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
94
|
+
detail="Invalid or expired token",
|
|
95
|
+
)
|
|
96
|
+
# Return value is injected as 'username' into the calling endpoint.
|
|
97
|
+
return username
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# --- Request/Response Models ---
|
|
101
|
+
|
|
102
|
+
class LoginRequest(BaseModel):
|
|
103
|
+
username: str
|
|
104
|
+
password: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class LoginResponse(BaseModel):
|
|
108
|
+
token: str
|
|
109
|
+
token_type: str = "bearer"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ProjectRequest(BaseModel):
|
|
113
|
+
name: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class DatabaseRequest(BaseModel):
|
|
117
|
+
name: str
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class PasswordRequest(BaseModel):
|
|
121
|
+
"""Request to set a database password.
|
|
122
|
+
|
|
123
|
+
access_level controls what operations the password holder can perform
|
|
124
|
+
against the specific database: 'read' (SELECT only), 'write' (INSERT,
|
|
125
|
+
UPDATE, DELETE), or 'admin' (full DDL + DML).
|
|
126
|
+
"""
|
|
127
|
+
password: str
|
|
128
|
+
access_level: str = "read"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- Endpoints ---
|
|
132
|
+
|
|
133
|
+
@router.post("/login", response_model=LoginResponse)
|
|
134
|
+
async def login(req: LoginRequest) -> LoginResponse:
|
|
135
|
+
"""Login and get JWT token.
|
|
136
|
+
|
|
137
|
+
This is the only unauthenticated admin endpoint. It accepts username +
|
|
138
|
+
password, verifies credentials against the stored bcrypt hash, and
|
|
139
|
+
returns a signed JWT that must be included in all subsequent requests.
|
|
140
|
+
"""
|
|
141
|
+
if not auth_manager:
|
|
142
|
+
log_error("login: Auth manager not initialized")
|
|
143
|
+
raise HTTPException(status_code=500, detail="Auth manager not initialized")
|
|
144
|
+
|
|
145
|
+
if not auth_manager.verify_user(req.username, req.password):
|
|
146
|
+
log_warning(f"Failed login attempt for user '{req.username}'")
|
|
147
|
+
raise HTTPException(
|
|
148
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
149
|
+
detail="Invalid credentials",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
token = auth_manager.create_jwt_token(req.username)
|
|
153
|
+
return LoginResponse(token=token)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@router.get("/projects")
|
|
157
|
+
async def list_projects(username: str = Depends(verify_admin)) -> list[str]:
|
|
158
|
+
"""List all projects."""
|
|
159
|
+
if not storage_engine:
|
|
160
|
+
log_error("list_projects: Storage engine not initialized")
|
|
161
|
+
raise HTTPException(status_code=500, detail="Storage engine not initialized")
|
|
162
|
+
return storage_engine.list_projects()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@router.post("/projects")
|
|
166
|
+
async def create_project(
|
|
167
|
+
req: ProjectRequest, username: str = Depends(verify_admin)
|
|
168
|
+
) -> dict:
|
|
169
|
+
"""Create a new project."""
|
|
170
|
+
if not storage_engine:
|
|
171
|
+
log_error("create_project: Storage engine not initialized")
|
|
172
|
+
raise HTTPException(
|
|
173
|
+
status_code=500,
|
|
174
|
+
detail="Storage engine not initialized",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if not storage_engine.create_project(req.name):
|
|
178
|
+
log_warning(f"create_project: Project '{req.name}' already exists")
|
|
179
|
+
raise HTTPException(
|
|
180
|
+
status_code=400,
|
|
181
|
+
detail="Project already exists",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
"success": True,
|
|
186
|
+
"message": f"Project '{req.name}' created",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@router.delete("/projects/{project}")
|
|
191
|
+
async def delete_project(project: str, username: str = Depends(verify_admin)) -> dict:
|
|
192
|
+
"""Delete a project and all its databases."""
|
|
193
|
+
if not storage_engine:
|
|
194
|
+
log_error("delete_project: Storage engine not initialized")
|
|
195
|
+
raise HTTPException(status_code=500, detail="Storage engine not initialized")
|
|
196
|
+
|
|
197
|
+
if not storage_engine.delete_project(project):
|
|
198
|
+
raise HTTPException(status_code=404, detail="Project not found")
|
|
199
|
+
|
|
200
|
+
return {"success": True, "message": f"Project '{project}' deleted"}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class ReorderRequest(BaseModel):
|
|
204
|
+
"""Expects the full desired order as a list of project names.
|
|
205
|
+
|
|
206
|
+
The list must contain every existing project exactly once. The storage
|
|
207
|
+
engine persists this ordering so that subsequent list_projects() calls
|
|
208
|
+
return projects in the user-defined sequence (e.g. for a sidebar or
|
|
209
|
+
dashboard layout).
|
|
210
|
+
"""
|
|
211
|
+
projects: list[str]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@router.post("/reorder-projects")
|
|
215
|
+
async def reorder_projects(
|
|
216
|
+
req: ReorderRequest,
|
|
217
|
+
username: str = Depends(verify_admin),
|
|
218
|
+
) -> dict:
|
|
219
|
+
"""Reorder projects.
|
|
220
|
+
|
|
221
|
+
Accepts the complete ordered list of project names and persists the
|
|
222
|
+
new display order. This is used by the UI to let admins drag-and-drop
|
|
223
|
+
projects into a custom sequence.
|
|
224
|
+
"""
|
|
225
|
+
if not storage_engine:
|
|
226
|
+
log_error("reorder_projects: Storage engine not initialized")
|
|
227
|
+
raise HTTPException(
|
|
228
|
+
status_code=500,
|
|
229
|
+
detail="Storage engine not initialized",
|
|
230
|
+
)
|
|
231
|
+
storage_engine.reorder_projects(req.projects)
|
|
232
|
+
return {
|
|
233
|
+
"success": True,
|
|
234
|
+
"message": "Projects reordered",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# --- Project-Scoped Database Endpoints ---
|
|
239
|
+
# All database endpoints are nested under a project: /admin/projects/{project}/databases.
|
|
240
|
+
# This enforces a two-level hierarchy: a project groups related databases, and every
|
|
241
|
+
# database operation requires specifying its parent project. This prevents name
|
|
242
|
+
# collisions across projects and keeps the storage layout organized on disk.
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@router.get(
|
|
246
|
+
"/projects/{project}/databases"
|
|
247
|
+
)
|
|
248
|
+
async def list_databases(project: str, username: str = Depends(verify_admin)) -> list[str]:
|
|
249
|
+
"""List all databases in a project."""
|
|
250
|
+
if not storage_engine:
|
|
251
|
+
log_error("list_databases: Storage engine not initialized")
|
|
252
|
+
raise HTTPException(status_code=500, detail="Storage engine not initialized")
|
|
253
|
+
return storage_engine.list_databases(project)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@router.post("/projects/{project}/databases")
|
|
257
|
+
async def create_database(
|
|
258
|
+
project: str, req: DatabaseRequest, username: str = Depends(verify_admin)
|
|
259
|
+
) -> dict:
|
|
260
|
+
"""Create a new database in a project."""
|
|
261
|
+
if not storage_engine:
|
|
262
|
+
log_error("create_database: Storage engine not initialized")
|
|
263
|
+
raise HTTPException(status_code=500, detail="Storage engine not initialized")
|
|
264
|
+
|
|
265
|
+
if storage_engine.database_exists(project, req.name):
|
|
266
|
+
log_warning(f"create_database: Database '{req.name}' already exists in '{project}'")
|
|
267
|
+
raise HTTPException(status_code=400, detail="Database already exists")
|
|
268
|
+
|
|
269
|
+
if not storage_engine.create_database(project, req.name):
|
|
270
|
+
log_error(f"create_database: Failed to create database '{req.name}' in '{project}'")
|
|
271
|
+
raise HTTPException(status_code=500, detail="Failed to create database")
|
|
272
|
+
|
|
273
|
+
return {"success": True, "message": f"Database '{req.name}' created in project '{project}'"}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@router.delete("/projects/{project}/databases/{database}")
|
|
277
|
+
async def delete_database(
|
|
278
|
+
project: str, database: str, username: str = Depends(verify_admin)
|
|
279
|
+
) -> dict:
|
|
280
|
+
"""Delete a database."""
|
|
281
|
+
if not storage_engine:
|
|
282
|
+
log_error("delete_database: Storage engine not initialized")
|
|
283
|
+
raise HTTPException(status_code=500, detail="Storage engine not initialized")
|
|
284
|
+
|
|
285
|
+
if not storage_engine.delete_database(project, database):
|
|
286
|
+
raise HTTPException(status_code=404, detail="Database not found")
|
|
287
|
+
|
|
288
|
+
return {"success": True, "message": f"Database '{database}' deleted"}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@router.put("/projects/{project}/databases/{database}/password")
|
|
292
|
+
async def set_database_password(
|
|
293
|
+
project: str,
|
|
294
|
+
database: str,
|
|
295
|
+
req: PasswordRequest,
|
|
296
|
+
username: str = Depends(verify_admin),
|
|
297
|
+
) -> dict:
|
|
298
|
+
"""Set password for database access.
|
|
299
|
+
|
|
300
|
+
This endpoint uses ProjectAuth (a separate auth layer from the admin JWT
|
|
301
|
+
system) to manage per-database credentials. The local import avoids a
|
|
302
|
+
circular dependency since ProjectAuth lives in the same auth.manager module
|
|
303
|
+
that admin.py already imports at module level for AuthManager.
|
|
304
|
+
|
|
305
|
+
The access_level ('read', 'write', or 'admin') is stored alongside the
|
|
306
|
+
password and enforced when non-admin clients connect to the database.
|
|
307
|
+
"""
|
|
308
|
+
from webduck.auth.manager import ProjectAuth
|
|
309
|
+
|
|
310
|
+
project_auth = ProjectAuth(storage_engine.data_dir)
|
|
311
|
+
|
|
312
|
+
if not project_auth.set_database_password(project, database, req.password, req.access_level):
|
|
313
|
+
log_error(f"set_database_password: Failed to set password for '{database}' in '{project}'")
|
|
314
|
+
raise HTTPException(status_code=500, detail="Failed to set password")
|
|
315
|
+
|
|
316
|
+
return {"success": True, "message": f"Password set for '{database}' ({req.access_level})"}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@router.get("/users")
|
|
320
|
+
async def list_users(username: str = Depends(verify_admin)) -> list[str]:
|
|
321
|
+
"""List all admin users."""
|
|
322
|
+
if not auth_manager:
|
|
323
|
+
log_error("list_users: Auth manager not initialized")
|
|
324
|
+
raise HTTPException(status_code=500, detail="Auth manager not initialized")
|
|
325
|
+
return auth_manager.list_users()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class CreateUserRequest(BaseModel):
|
|
329
|
+
username: str
|
|
330
|
+
password: str
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@router.post("/users")
|
|
334
|
+
async def create_user(
|
|
335
|
+
req: CreateUserRequest, username: str = Depends(verify_admin)
|
|
336
|
+
) -> dict:
|
|
337
|
+
"""Create a new admin user."""
|
|
338
|
+
if not auth_manager:
|
|
339
|
+
log_error("create_user: Auth manager not initialized")
|
|
340
|
+
raise HTTPException(status_code=500, detail="Auth manager not initialized")
|
|
341
|
+
|
|
342
|
+
if auth_manager.user_exists(req.username):
|
|
343
|
+
log_warning(f"create_user: User '{req.username}' already exists")
|
|
344
|
+
raise HTTPException(status_code=400, detail="User already exists")
|
|
345
|
+
|
|
346
|
+
if not auth_manager.create_user(req.username, req.password):
|
|
347
|
+
log_error(f"create_user: Failed to create user '{req.username}'")
|
|
348
|
+
raise HTTPException(status_code=500, detail="Failed to create user")
|
|
349
|
+
|
|
350
|
+
return {"success": True, "message": f"User '{req.username}' created"}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@router.delete("/users/{target_username}")
|
|
354
|
+
async def delete_user(
|
|
355
|
+
target_username: str, username: str = Depends(verify_admin)
|
|
356
|
+
) -> dict:
|
|
357
|
+
"""Delete an admin user."""
|
|
358
|
+
if not auth_manager:
|
|
359
|
+
log_error("delete_user: Auth manager not initialized")
|
|
360
|
+
raise HTTPException(status_code=500, detail="Auth manager not initialized")
|
|
361
|
+
|
|
362
|
+
if target_username == username:
|
|
363
|
+
# Prevent an admin from deleting their own account. Without this guard
|
|
364
|
+
# an admin could lock themselves out, leaving the system with zero admins.
|
|
365
|
+
log_warning(f"delete_user: User '{username}' attempted to delete themselves")
|
|
366
|
+
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
|
367
|
+
|
|
368
|
+
if not auth_manager.delete_user(target_username):
|
|
369
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
370
|
+
|
|
371
|
+
return {"success": True, "message": f"User '{target_username}' deleted"}
|