webgate 0.3.0__tar.gz → 0.3.2__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.
- {webgate-0.3.0 → webgate-0.3.2}/CHANGELOG.md +31 -0
- {webgate-0.3.0 → webgate-0.3.2}/PKG-INFO +19 -5
- {webgate-0.3.0 → webgate-0.3.2}/README.md +16 -2
- {webgate-0.3.0 → webgate-0.3.2}/pyproject.toml +3 -4
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/app.py +2 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/auth/routes.py +10 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/db/engine.py +11 -7
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/files/routes.py +8 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/routes.py +7 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/static/index.html +78 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/terminal/routes.py +5 -0
- webgate-0.3.2/src/webgate/webhooks/dispatcher.py +74 -0
- webgate-0.3.2/src/webgate/webhooks/models.py +61 -0
- webgate-0.3.2/src/webgate/webhooks/routes.py +129 -0
- webgate-0.3.2/tests/__init__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/uv.lock +53 -1
- {webgate-0.3.0 → webgate-0.3.2}/.dockerignore +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/.github/workflows/docs.yml +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/.gitignore +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/Dockerfile +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/Dockerfile.demo +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/Dockerfile.ssh-demo +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/LICENSE +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/ROADMAP.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/VERSION +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/compose.dev.yml +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/compose.yml +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/api/auth.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/api/files.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/api/servers.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/api/terminal.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/changelog.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/getting-started/installation.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/getting-started/quickstart.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/guide/files.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/guide/servers.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/guide/split.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/guide/terminal.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/guide/users.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/index.md +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/access-control.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/audit.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/edit-access-control.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/editor.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/light-theme.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/login.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/new-server-form.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/sftp-restricted.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/sftp.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/site-manager.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/split-view.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/ssh-disabled.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/terminal.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/docs/screenshots/users.png +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/fly.toml +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/mkdocs.yml +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/__init__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/__main__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/audit/__init__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/audit/models.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/audit/service.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/auth/__init__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/auth/models.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/auth/service.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/config.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/db/__init__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/demo.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/files/__init__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/files/models.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/files/pool.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/files/sftp_service.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/__init__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/crypto.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/models.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/monitor.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/servers/service.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/snippets/__init__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/snippets/models.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/snippets/routes.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/terminal/__init__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/terminal/ssh_session.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/src/webgate/terminal/ws_handler.py +0 -0
- {webgate-0.3.0/tests → webgate-0.3.2/src/webgate/webhooks}/__init__.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/tests/conftest.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/tests/test_auth.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/tests/test_files.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/tests/test_servers.py +0 -0
- {webgate-0.3.0 → webgate-0.3.2}/tests/test_terminal.py +0 -0
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.3.2 (2026-04-15)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **Webhook notifications** -- admin can register HTTPS endpoints that receive a JSON POST when significant events fire. Supported events: `user_login`, `user_login_failed`, `ssh_connect`, `sftp_upload`, `sftp_delete`, `server_added`, `server_deleted`. Each webhook can subscribe to all events (`*`) or a specific subset.
|
|
8
|
+
- **HMAC-SHA256 signing** -- optional shared secret per webhook; the dispatcher signs the body and sends it as `X-Webgate-Signature: sha256=<hex>` so the receiver can verify authenticity.
|
|
9
|
+
- **Test button + delivery telemetry** -- one-click test fire from the admin UI; each webhook row shows the last HTTP status and timestamp.
|
|
10
|
+
|
|
11
|
+
### Details
|
|
12
|
+
|
|
13
|
+
- New `Webhook` model + REST CRUD at `/api/webhooks` (admin-only)
|
|
14
|
+
- New `webgate.webhooks.dispatcher.fire(event, data)` -- fire-and-forget, schedules HTTP delivery via `httpx`, never blocks the caller
|
|
15
|
+
- Frontend: "Webhooks" button in the top toolbar (admin only) with full management modal
|
|
16
|
+
- New base dependency: `httpx>=0.28.0` (used by the dispatcher)
|
|
17
|
+
|
|
18
|
+
Verified end-to-end against a real HTTP echo receiver: `user_login`, `server_added` and a manual test all delivered with HTTP 200 and signature header.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## v0.3.1 (2026-04-15)
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **PostgreSQL actually works in Docker now.** v0.3.0 advertised PostgreSQL support but two bugs prevented it from working:
|
|
27
|
+
1. `asyncpg` was an optional extra (`webgate[postgres]`), so the official Docker image (and `pip install webgate`) didn't have the driver. Now bundled by default.
|
|
28
|
+
2. Lightweight migrations ran inside the same transaction as `create_all`. PostgreSQL aborts the entire transaction on any error (even when caught), so the first "column already exists" error rolled back table creation on subsequent runs. Each migration now uses its own transaction.
|
|
29
|
+
|
|
30
|
+
Verified end-to-end against a real `postgres:16-alpine` container: tables created, login works, server creation and persistence across restarts confirmed.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
3
34
|
## v0.3.0 (2026-04-15)
|
|
4
35
|
|
|
5
36
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webgate
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Self-hosted web application for remote server management via SSH terminal and SFTP file browser
|
|
5
5
|
Project-URL: Homepage, https://github.com/kalexnolasco/webgate
|
|
6
6
|
Project-URL: Documentation, https://kalexnolasco.github.io/webgate/
|
|
@@ -27,10 +27,12 @@ Classifier: Typing :: Typed
|
|
|
27
27
|
Requires-Python: >=3.11
|
|
28
28
|
Requires-Dist: aiofiles>=24.1.0
|
|
29
29
|
Requires-Dist: aiosqlite>=0.21.0
|
|
30
|
+
Requires-Dist: asyncpg>=0.30.0
|
|
30
31
|
Requires-Dist: asyncssh>=2.18.0
|
|
31
32
|
Requires-Dist: bcrypt>=4.2.0
|
|
32
33
|
Requires-Dist: cryptography>=44.0.0
|
|
33
34
|
Requires-Dist: fastapi>=0.115.0
|
|
35
|
+
Requires-Dist: httpx>=0.28.0
|
|
34
36
|
Requires-Dist: passlib[bcrypt]>=1.7.4
|
|
35
37
|
Requires-Dist: pydantic-settings>=2.7.0
|
|
36
38
|
Requires-Dist: pydantic>=2.10.0
|
|
@@ -42,8 +44,6 @@ Requires-Dist: slowapi>=0.1.9
|
|
|
42
44
|
Requires-Dist: sqlalchemy>=2.0.36
|
|
43
45
|
Requires-Dist: uvicorn[standard]>=0.34.0
|
|
44
46
|
Requires-Dist: websockets>=14.0
|
|
45
|
-
Provides-Extra: postgres
|
|
46
|
-
Requires-Dist: asyncpg>=0.30.0; extra == 'postgres'
|
|
47
47
|
Description-Content-Type: text/markdown
|
|
48
48
|
|
|
49
49
|
# webgate
|
|
@@ -457,10 +457,9 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
|
|
|
457
457
|
|
|
458
458
|
### PostgreSQL
|
|
459
459
|
|
|
460
|
-
SQLite is the default. To use PostgreSQL
|
|
460
|
+
SQLite is the default. To use PostgreSQL, just point `WEBGATE_DB_URL` at it (the `asyncpg` driver is bundled as of v0.3.1):
|
|
461
461
|
|
|
462
462
|
```bash
|
|
463
|
-
pip install 'webgate[postgres]' # adds asyncpg
|
|
464
463
|
export WEBGATE_DB_URL='postgresql+asyncpg://user:pass@host:5432/webgate'
|
|
465
464
|
```
|
|
466
465
|
|
|
@@ -473,6 +472,21 @@ When adding a server, pick another server from the **Jump Via** dropdown to tunn
|
|
|
473
472
|
### SSH command snippets
|
|
474
473
|
|
|
475
474
|
Each user has a personal library of named commands. They appear as buttons in the terminal toolbar -- click to send `command + Enter`. Right-click to delete, `+` to create. Stored in the `snippets` table.
|
|
475
|
+
|
|
476
|
+
### Webhooks
|
|
477
|
+
|
|
478
|
+
Admins can register HTTPS endpoints that receive a JSON POST when events fire (`user_login`, `ssh_connect`, `server_added`, etc). Each webhook can subscribe to all events (`*`) or a specific subset.
|
|
479
|
+
|
|
480
|
+
```json
|
|
481
|
+
POST https://your-receiver.example.com/wh
|
|
482
|
+
Content-Type: application/json
|
|
483
|
+
X-Webgate-Signature: sha256=<hmac of body using your webhook's secret>
|
|
484
|
+
|
|
485
|
+
{"event": "ssh_connect", "timestamp": "2026-04-15T18:03:18Z",
|
|
486
|
+
"data": {"user": "alice", "server": "prod-web-01", "host": "10.0.1.50:22"}}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Manage from the **Webhooks** button in the top toolbar (admin only). Last delivery status is shown next to each row.
|
|
476
490
|
| `WEBGATE_LOG_LEVEL` | `info` | Log level |
|
|
477
491
|
| `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
|
|
478
492
|
| `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
|
|
@@ -409,10 +409,9 @@ All settings are configurable via environment variables with the `WEBGATE_` pref
|
|
|
409
409
|
|
|
410
410
|
### PostgreSQL
|
|
411
411
|
|
|
412
|
-
SQLite is the default. To use PostgreSQL
|
|
412
|
+
SQLite is the default. To use PostgreSQL, just point `WEBGATE_DB_URL` at it (the `asyncpg` driver is bundled as of v0.3.1):
|
|
413
413
|
|
|
414
414
|
```bash
|
|
415
|
-
pip install 'webgate[postgres]' # adds asyncpg
|
|
416
415
|
export WEBGATE_DB_URL='postgresql+asyncpg://user:pass@host:5432/webgate'
|
|
417
416
|
```
|
|
418
417
|
|
|
@@ -425,6 +424,21 @@ When adding a server, pick another server from the **Jump Via** dropdown to tunn
|
|
|
425
424
|
### SSH command snippets
|
|
426
425
|
|
|
427
426
|
Each user has a personal library of named commands. They appear as buttons in the terminal toolbar -- click to send `command + Enter`. Right-click to delete, `+` to create. Stored in the `snippets` table.
|
|
427
|
+
|
|
428
|
+
### Webhooks
|
|
429
|
+
|
|
430
|
+
Admins can register HTTPS endpoints that receive a JSON POST when events fire (`user_login`, `ssh_connect`, `server_added`, etc). Each webhook can subscribe to all events (`*`) or a specific subset.
|
|
431
|
+
|
|
432
|
+
```json
|
|
433
|
+
POST https://your-receiver.example.com/wh
|
|
434
|
+
Content-Type: application/json
|
|
435
|
+
X-Webgate-Signature: sha256=<hmac of body using your webhook's secret>
|
|
436
|
+
|
|
437
|
+
{"event": "ssh_connect", "timestamp": "2026-04-15T18:03:18Z",
|
|
438
|
+
"data": {"user": "alice", "server": "prod-web-01", "host": "10.0.1.50:22"}}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
Manage from the **Webhooks** button in the top toolbar (admin only). Last delivery status is shown next to each row.
|
|
428
442
|
| `WEBGATE_LOG_LEVEL` | `info` | Log level |
|
|
429
443
|
| `WEBGATE_SESSION_TIMEOUT` | `3600` | SSH session timeout (seconds) |
|
|
430
444
|
| `WEBGATE_MAX_UPLOAD_SIZE` | `104857600` | Max upload size (100 MB) |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "webgate"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.2"
|
|
4
4
|
description = "Self-hosted web application for remote server management via SSH terminal and SFTP file browser"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -40,11 +40,10 @@ dependencies = [
|
|
|
40
40
|
"slowapi>=0.1.9",
|
|
41
41
|
"pyotp>=2.9.0",
|
|
42
42
|
"qrcode[pil]>=8.2",
|
|
43
|
+
"asyncpg>=0.30.0",
|
|
44
|
+
"httpx>=0.28.0",
|
|
43
45
|
]
|
|
44
46
|
|
|
45
|
-
[project.optional-dependencies]
|
|
46
|
-
postgres = ["asyncpg>=0.30.0"]
|
|
47
|
-
|
|
48
47
|
[project.urls]
|
|
49
48
|
Homepage = "https://github.com/kalexnolasco/webgate"
|
|
50
49
|
Documentation = "https://kalexnolasco.github.io/webgate/"
|
|
@@ -20,6 +20,7 @@ from webgate.servers.monitor import server_monitor
|
|
|
20
20
|
from webgate.servers.routes import router as servers_router
|
|
21
21
|
from webgate.snippets.routes import router as snippets_router
|
|
22
22
|
from webgate.terminal.routes import router as terminal_router
|
|
23
|
+
from webgate.webhooks.routes import router as webhooks_router
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
@asynccontextmanager
|
|
@@ -91,6 +92,7 @@ def create_app() -> FastAPI:
|
|
|
91
92
|
app.include_router(terminal_router)
|
|
92
93
|
app.include_router(files_router)
|
|
93
94
|
app.include_router(snippets_router)
|
|
95
|
+
app.include_router(webhooks_router)
|
|
94
96
|
|
|
95
97
|
app.mount("/", StaticFiles(directory=str(settings.static_dir), html=True), name="static")
|
|
96
98
|
|
|
@@ -10,6 +10,7 @@ limiter = Limiter(key_func=get_remote_address)
|
|
|
10
10
|
|
|
11
11
|
from webgate.audit.models import AuditOut
|
|
12
12
|
from webgate.audit.service import get_audit_log, log_action
|
|
13
|
+
from webgate.webhooks.dispatcher import fire as fire_webhook
|
|
13
14
|
from webgate.auth.models import (
|
|
14
15
|
ApiKeyCreate,
|
|
15
16
|
ApiKeyCreated,
|
|
@@ -88,6 +89,10 @@ def _require_admin(user: UserOut) -> None:
|
|
|
88
89
|
async def login(request: Request, body: UserLogin, session: SessionDep) -> LoginOut:
|
|
89
90
|
user = await get_user_by_username(session, body.username)
|
|
90
91
|
if not user or not verify_password(body.password, user.hashed_password):
|
|
92
|
+
await fire_webhook("user_login_failed", {
|
|
93
|
+
"username": body.username,
|
|
94
|
+
"ip": request.client.host if request.client else "",
|
|
95
|
+
})
|
|
91
96
|
raise HTTPException(
|
|
92
97
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
|
93
98
|
)
|
|
@@ -111,6 +116,11 @@ async def login(request: Request, body: UserLogin, session: SessionDep) -> Login
|
|
|
111
116
|
"login",
|
|
112
117
|
ip_address=request.client.host if request.client else "",
|
|
113
118
|
)
|
|
119
|
+
await fire_webhook("user_login", {
|
|
120
|
+
"username": user.username,
|
|
121
|
+
"user_id": user.id,
|
|
122
|
+
"ip": request.client.host if request.client else "",
|
|
123
|
+
})
|
|
114
124
|
return LoginOut(access_token=token)
|
|
115
125
|
|
|
116
126
|
|
|
@@ -48,15 +48,19 @@ async def init_db() -> None:
|
|
|
48
48
|
dialect = engine.dialect.name # "sqlite", "postgresql", ...
|
|
49
49
|
async with engine.begin() as conn:
|
|
50
50
|
await conn.run_sync(Base.metadata.create_all)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
# Lightweight migrations: each in its OWN transaction. PostgreSQL aborts
|
|
52
|
+
# the whole transaction on any error (even one we catch), so running
|
|
53
|
+
# multiple ALTERs in a single txn would poison create_all on a fresh DB
|
|
54
|
+
# the moment the first "column already exists" fires.
|
|
55
|
+
for table, column, sqlite_def, pg_def in _MIGRATIONS:
|
|
56
|
+
col_def = pg_def if dialect == "postgresql" else sqlite_def
|
|
57
|
+
sql = f"ALTER TABLE {table} ADD COLUMN {column} {col_def}"
|
|
58
|
+
try:
|
|
59
|
+
async with engine.begin() as conn:
|
|
56
60
|
await conn.execute(text(sql))
|
|
57
61
|
logger.info("Migration applied: %s.%s", table, column)
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
except Exception:
|
|
63
|
+
pass # Column already exists, or table not yet present on fresh DB
|
|
60
64
|
|
|
61
65
|
|
|
62
66
|
async def close_db() -> None:
|
|
@@ -26,6 +26,7 @@ from webgate.files.pool import sftp_pool
|
|
|
26
26
|
from webgate.files.sftp_service import SFTPClient, validate_path
|
|
27
27
|
from webgate.servers.models import Server
|
|
28
28
|
from webgate.servers.service import get_server, get_server_credentials, resolve_jump_creds
|
|
29
|
+
from webgate.webhooks.dispatcher import fire as fire_webhook
|
|
29
30
|
|
|
30
31
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
|
31
32
|
|
|
@@ -199,6 +200,10 @@ async def upload_files(
|
|
|
199
200
|
data = await f.read()
|
|
200
201
|
await client.upload(dest, data)
|
|
201
202
|
uploaded.append(dest)
|
|
203
|
+
await fire_webhook("sftp_upload", {
|
|
204
|
+
"user": current_user.username, "server_id": server_id,
|
|
205
|
+
"paths": uploaded, "count": len(uploaded),
|
|
206
|
+
})
|
|
202
207
|
return {"uploaded": uploaded, "count": len(uploaded)}
|
|
203
208
|
except ValueError as e:
|
|
204
209
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
|
@@ -256,6 +261,9 @@ async def delete_item(
|
|
|
256
261
|
check_read_only(read_only)
|
|
257
262
|
check_path_allowed(path, allowed_paths)
|
|
258
263
|
await client.delete(path)
|
|
264
|
+
await fire_webhook("sftp_delete", {
|
|
265
|
+
"user": current_user.username, "server_id": server_id, "path": path,
|
|
266
|
+
})
|
|
259
267
|
return {"path": validate_path(path), "status": "deleted"}
|
|
260
268
|
except ValueError as e:
|
|
261
269
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
|
@@ -7,6 +7,7 @@ from webgate.auth.models import UserOut
|
|
|
7
7
|
from webgate.auth.routes import get_current_user
|
|
8
8
|
from webgate.db.engine import get_session
|
|
9
9
|
from webgate.servers.models import ServerCreate, ServerImport, ServerOut, ServerUpdate
|
|
10
|
+
from webgate.webhooks.dispatcher import fire as fire_webhook
|
|
10
11
|
from webgate.servers.service import (
|
|
11
12
|
create_server,
|
|
12
13
|
delete_server,
|
|
@@ -57,6 +58,10 @@ async def create(
|
|
|
57
58
|
) -> ServerOut:
|
|
58
59
|
_require_admin(current_user)
|
|
59
60
|
server = await create_server(session, body, current_user.id)
|
|
61
|
+
await fire_webhook("server_added", {
|
|
62
|
+
"id": server.id, "name": server.name, "hostname": server.hostname,
|
|
63
|
+
"by": current_user.username,
|
|
64
|
+
})
|
|
60
65
|
return server_to_out(server)
|
|
61
66
|
|
|
62
67
|
|
|
@@ -144,7 +149,9 @@ async def delete(
|
|
|
144
149
|
server = await get_server(session, server_id, current_user.id, is_admin=True)
|
|
145
150
|
if not server:
|
|
146
151
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Server not found")
|
|
152
|
+
server_info = {"id": server.id, "name": server.name, "hostname": server.hostname}
|
|
147
153
|
await delete_server(session, server)
|
|
154
|
+
await fire_webhook("server_deleted", {**server_info, "by": current_user.username})
|
|
148
155
|
|
|
149
156
|
|
|
150
157
|
@router.post("/{server_id}/test")
|
|
@@ -232,6 +232,7 @@ window._showToastImpl = function(message, type) {
|
|
|
232
232
|
<button x-show="currentUser?.is_admin" @click="showServerModal=true;resetServerForm()" class="fz-btn" style="font-size:11px" title="New Server (Ctrl+N)">New Server</button>
|
|
233
233
|
<button x-show="currentUser?.is_admin" @click="openUsersModal()" class="fz-btn" style="font-size:11px">Users</button>
|
|
234
234
|
<button x-show="currentUser?.is_admin" @click="openAuditLog()" class="fz-btn" style="font-size:11px">Audit</button>
|
|
235
|
+
<button x-show="currentUser?.is_admin" @click="openWebhooksModal()" class="fz-btn" style="font-size:11px">Webhooks</button>
|
|
235
236
|
<button @click="openApiKeysModal()" class="fz-btn" style="font-size:11px">Keys</button>
|
|
236
237
|
</div>
|
|
237
238
|
<div style="display:flex;align-items:center;gap:8px">
|
|
@@ -741,6 +742,59 @@ window._showToastImpl = function(message, type) {
|
|
|
741
742
|
</div>
|
|
742
743
|
</div>
|
|
743
744
|
|
|
745
|
+
<!-- ======== WEBHOOKS MODAL ======== -->
|
|
746
|
+
<div x-show="showWebhooksModal" class="fz-modal-bg" @click.self="showWebhooksModal=false">
|
|
747
|
+
<div class="fz-modal" style="width:720px" @click.stop>
|
|
748
|
+
<div class="fz-modal-title">Webhooks</div>
|
|
749
|
+
<div class="fz-modal-body" style="padding:0;max-height:50vh;overflow-y:auto">
|
|
750
|
+
<table style="width:100%;font-size:12px;border-collapse:collapse">
|
|
751
|
+
<thead style="position:sticky;top:0;background:var(--btn-bg)">
|
|
752
|
+
<tr>
|
|
753
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">Name</th>
|
|
754
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">URL</th>
|
|
755
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">Events</th>
|
|
756
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;text-align:left">Last</th>
|
|
757
|
+
<th style="padding:8px 12px;border-bottom:1px solid var(--border);color:var(--text-secondary);font-size:10px;text-transform:uppercase;width:120px"></th>
|
|
758
|
+
</tr>
|
|
759
|
+
</thead>
|
|
760
|
+
<tbody>
|
|
761
|
+
<template x-for="w in webhooks" :key="w.id">
|
|
762
|
+
<tr style="border-bottom:1px solid var(--btn-bg)">
|
|
763
|
+
<td style="padding:6px 12px;color:var(--text-primary);font-weight:500" x-text="w.name"></td>
|
|
764
|
+
<td style="padding:6px 12px;font-family:'JetBrains Mono',monospace;color:var(--text-secondary);font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis" :title="w.url" x-text="w.url"></td>
|
|
765
|
+
<td style="padding:6px 12px;color:var(--text-secondary);font-size:11px" x-text="w.events.join(', ')"></td>
|
|
766
|
+
<td style="padding:6px 12px;color:var(--text-secondary);font-size:11px">
|
|
767
|
+
<span x-show="w.last_status" :style="(w.last_status >= 200 && w.last_status < 300) ? 'color:var(--success)' : 'color:var(--danger)'" x-text="w.last_status + ' · ' + (w.last_fired_at ? new Date(w.last_fired_at).toLocaleString() : '')"></span>
|
|
768
|
+
<span x-show="!w.last_status" style="color:var(--text-muted)">never fired</span>
|
|
769
|
+
</td>
|
|
770
|
+
<td style="padding:6px 12px;display:flex;gap:4px">
|
|
771
|
+
<button @click="testWebhook(w)" class="fz-btn" style="font-size:10px;padding:1px 6px">Test</button>
|
|
772
|
+
<button @click="confirmDeleteWebhook(w)" class="fz-btn fz-btn-danger" style="font-size:10px;padding:1px 6px">Del</button>
|
|
773
|
+
</td>
|
|
774
|
+
</tr>
|
|
775
|
+
</template>
|
|
776
|
+
<template x-if="webhooks.length === 0">
|
|
777
|
+
<tr><td colspan="5" style="padding:16px;text-align:center;color:var(--text-muted);font-size:12px">No webhooks. Add one below.</td></tr>
|
|
778
|
+
</template>
|
|
779
|
+
</tbody>
|
|
780
|
+
</table>
|
|
781
|
+
</div>
|
|
782
|
+
<div style="border-top:1px solid var(--border);padding:12px 16px;background:var(--bg-tertiary)">
|
|
783
|
+
<div style="font-size:12px;font-weight:600;margin-bottom:8px;color:var(--text-primary)">Add Webhook</div>
|
|
784
|
+
<form @submit.prevent="createWebhook()" style="display:grid;grid-template-columns:1fr 2fr 1fr;gap:6px;align-items:end">
|
|
785
|
+
<div><label style="font-size:10px;color:var(--text-secondary);font-weight:500">Name</label><input type="text" x-model="newWebhook.name" required placeholder="Slack" style="width:100%;font-size:12px;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)"></div>
|
|
786
|
+
<div><label style="font-size:10px;color:var(--text-secondary);font-weight:500">URL</label><input type="url" x-model="newWebhook.url" required placeholder="https://hooks.slack.com/..." style="width:100%;font-size:12px;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)"></div>
|
|
787
|
+
<div><label style="font-size:10px;color:var(--text-secondary);font-weight:500">Events</label><input type="text" x-model="newWebhook.events" placeholder="* (or ssh_connect,user_login)" style="width:100%;font-size:12px;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)"></div>
|
|
788
|
+
<div style="grid-column:span 2"><label style="font-size:10px;color:var(--text-secondary);font-weight:500">Secret (optional, for HMAC signing)</label><input type="text" x-model="newWebhook.secret" placeholder="leave empty to skip" style="width:100%;font-size:12px;padding:5px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary)"></div>
|
|
789
|
+
<button type="submit" class="fz-btn fz-btn-primary" style="font-size:12px">Add</button>
|
|
790
|
+
</form>
|
|
791
|
+
</div>
|
|
792
|
+
<div class="fz-modal-footer">
|
|
793
|
+
<button @click="showWebhooksModal=false" class="fz-btn">Close</button>
|
|
794
|
+
</div>
|
|
795
|
+
</div>
|
|
796
|
+
</div>
|
|
797
|
+
|
|
744
798
|
<!-- ======== EDIT GROUPS MODAL ======== -->
|
|
745
799
|
<div x-show="showGroupsModal" class="fz-modal-bg" @click.self="showGroupsModal=false">
|
|
746
800
|
<div class="fz-modal" style="width:400px" @click.stop>
|
|
@@ -846,6 +900,7 @@ function app() {
|
|
|
846
900
|
editingUserObj: null, editingGroupsStr: '',
|
|
847
901
|
showAuditModal: false, auditEntries: [],
|
|
848
902
|
showApiKeysModal: false, apiKeys: [], newApiKeyName: '', createdApiKey: null,
|
|
903
|
+
showWebhooksModal: false, webhooks: [], newWebhook: { name: '', url: '', events: '*', secret: '' },
|
|
849
904
|
view: 'dashboard',
|
|
850
905
|
ssh: { host: '', port: 22, username: '', password: '' },
|
|
851
906
|
servers: [], groups: [], selectedGroup: '', searchQuery: '',
|
|
@@ -1429,6 +1484,29 @@ function app() {
|
|
|
1429
1484
|
if (!confirm(`Revoke API key "${k.name}"?`)) return;
|
|
1430
1485
|
try { await this.apiCall(`/api/auth/api-keys/${k.id}`, { method: 'DELETE' }); this.showToast('API key revoked'); this.apiKeys = await this.apiCall('/api/auth/api-keys'); } catch (e) { this.showToast(e.message, 'error'); }
|
|
1431
1486
|
},
|
|
1487
|
+
|
|
1488
|
+
// Webhooks
|
|
1489
|
+
async openWebhooksModal() {
|
|
1490
|
+
try { this.webhooks = await this.apiCall('/api/webhooks'); } catch (e) { this.showToast(e.message, 'error'); return; }
|
|
1491
|
+
this.showWebhooksModal = true;
|
|
1492
|
+
},
|
|
1493
|
+
async createWebhook() {
|
|
1494
|
+
const events = (this.newWebhook.events || '*').split(',').map(e => e.trim()).filter(Boolean);
|
|
1495
|
+
const body = { name: this.newWebhook.name.trim(), url: this.newWebhook.url.trim(), events, secret: this.newWebhook.secret || '' };
|
|
1496
|
+
try {
|
|
1497
|
+
await this.apiCall('/api/webhooks', { method: 'POST', body: JSON.stringify(body) });
|
|
1498
|
+
this.newWebhook = { name: '', url: '', events: '*', secret: '' };
|
|
1499
|
+
this.webhooks = await this.apiCall('/api/webhooks');
|
|
1500
|
+
this.showToast('Webhook added');
|
|
1501
|
+
} catch (e) { this.showToast(e.message, 'error'); }
|
|
1502
|
+
},
|
|
1503
|
+
async confirmDeleteWebhook(w) {
|
|
1504
|
+
if (!confirm(`Delete webhook "${w.name}"?`)) return;
|
|
1505
|
+
try { await this.apiCall(`/api/webhooks/${w.id}`, { method: 'DELETE' }); this.webhooks = await this.apiCall('/api/webhooks'); this.showToast('Webhook deleted'); } catch (e) { this.showToast(e.message, 'error'); }
|
|
1506
|
+
},
|
|
1507
|
+
async testWebhook(w) {
|
|
1508
|
+
try { await this.apiCall(`/api/webhooks/${w.id}/test`, { method: 'POST' }); this.showToast(`Test queued for "${w.name}"`); setTimeout(async () => { this.webhooks = await this.apiCall('/api/webhooks'); }, 1500); } catch (e) { this.showToast(e.message, 'error'); }
|
|
1509
|
+
},
|
|
1432
1510
|
};
|
|
1433
1511
|
}
|
|
1434
1512
|
</script>
|
|
@@ -13,6 +13,7 @@ from webgate.servers.service import (
|
|
|
13
13
|
update_last_connected,
|
|
14
14
|
)
|
|
15
15
|
from webgate.terminal.ws_handler import authenticate_websocket, handle_terminal_ws
|
|
16
|
+
from webgate.webhooks.dispatcher import fire as fire_webhook
|
|
16
17
|
|
|
17
18
|
router = APIRouter(tags=["terminal"])
|
|
18
19
|
|
|
@@ -107,6 +108,10 @@ async def ws_terminal_server(ws: WebSocket, server_id: int) -> None:
|
|
|
107
108
|
|
|
108
109
|
await update_last_connected(session, server)
|
|
109
110
|
await log_action(user_out.id, user_out.username, "ssh_connect", f"{server.hostname}:{server.port}")
|
|
111
|
+
await fire_webhook("ssh_connect", {
|
|
112
|
+
"user": user_out.username, "server_id": server.id, "server": server.name,
|
|
113
|
+
"host": f"{server.hostname}:{server.port}", "via_jump": server.jump_via_id is not None,
|
|
114
|
+
})
|
|
110
115
|
|
|
111
116
|
await handle_terminal_ws(
|
|
112
117
|
ws,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Fire-and-forget webhook dispatcher.
|
|
2
|
+
|
|
3
|
+
Webhooks are sent asynchronously without blocking the request that triggered
|
|
4
|
+
them. Failures are logged and stored on the webhook row (last_status) but
|
|
5
|
+
never propagate back to the caller.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from datetime import UTC, datetime
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from sqlalchemy import select
|
|
20
|
+
|
|
21
|
+
from webgate.db.engine import async_session_factory
|
|
22
|
+
from webgate.webhooks.models import Webhook
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_TIMEOUT = 5.0 # seconds
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sign(secret: str, body: bytes) -> str:
|
|
30
|
+
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _deliver(webhook_id: int, url: str, secret: str, payload: dict[str, Any]) -> None:
|
|
34
|
+
body = json.dumps(payload).encode()
|
|
35
|
+
headers = {"Content-Type": "application/json", "User-Agent": "webgate-webhook/1.0"}
|
|
36
|
+
if secret:
|
|
37
|
+
headers["X-Webgate-Signature"] = "sha256=" + _sign(secret, body)
|
|
38
|
+
status: int | None = None
|
|
39
|
+
try:
|
|
40
|
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
41
|
+
resp = await client.post(url, content=body, headers=headers)
|
|
42
|
+
status = resp.status_code
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.warning("Webhook %s -> %s failed: %s", webhook_id, url, e)
|
|
45
|
+
finally:
|
|
46
|
+
async with async_session_factory() as session:
|
|
47
|
+
wh = (
|
|
48
|
+
await session.execute(select(Webhook).where(Webhook.id == webhook_id))
|
|
49
|
+
).scalar_one_or_none()
|
|
50
|
+
if wh is not None:
|
|
51
|
+
wh.last_fired_at = datetime.now(UTC)
|
|
52
|
+
wh.last_status = status
|
|
53
|
+
await session.commit()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def fire(event: str, data: dict[str, Any]) -> None:
|
|
57
|
+
"""Fire-and-forget: schedule webhook deliveries for this event."""
|
|
58
|
+
payload = {
|
|
59
|
+
"event": event,
|
|
60
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
61
|
+
"data": data,
|
|
62
|
+
}
|
|
63
|
+
async with async_session_factory() as session:
|
|
64
|
+
result = await session.execute(select(Webhook).where(Webhook.enabled.is_(True)))
|
|
65
|
+
hooks = list(result.scalars().all())
|
|
66
|
+
for wh in hooks:
|
|
67
|
+
try:
|
|
68
|
+
events = json.loads(wh.events)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
events = ["*"]
|
|
71
|
+
if "*" not in events and event not in events:
|
|
72
|
+
continue
|
|
73
|
+
# Detached background task; never block caller.
|
|
74
|
+
asyncio.create_task(_deliver(wh.id, wh.url, wh.secret, payload))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
|
5
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
6
|
+
|
|
7
|
+
from webgate.db.engine import Base
|
|
8
|
+
|
|
9
|
+
# Known event names. Stored in `events` as a JSON array of strings; "*" means all.
|
|
10
|
+
EVENT_NAMES = [
|
|
11
|
+
"user_login",
|
|
12
|
+
"user_login_failed",
|
|
13
|
+
"ssh_connect",
|
|
14
|
+
"sftp_upload",
|
|
15
|
+
"sftp_delete",
|
|
16
|
+
"server_added",
|
|
17
|
+
"server_deleted",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Webhook(Base):
|
|
22
|
+
__tablename__ = "webhooks"
|
|
23
|
+
|
|
24
|
+
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
|
25
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
26
|
+
url: Mapped[str] = mapped_column(Text)
|
|
27
|
+
events: Mapped[str] = mapped_column(Text, default='["*"]') # JSON array
|
|
28
|
+
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
29
|
+
secret: Mapped[str] = mapped_column(String(255), default="") # HMAC signing key
|
|
30
|
+
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
31
|
+
last_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
32
|
+
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"))
|
|
33
|
+
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class WebhookCreate(BaseModel):
|
|
37
|
+
name: str
|
|
38
|
+
url: str
|
|
39
|
+
events: list[str] = Field(default_factory=lambda: ["*"])
|
|
40
|
+
enabled: bool = True
|
|
41
|
+
secret: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WebhookUpdate(BaseModel):
|
|
45
|
+
name: str | None = None
|
|
46
|
+
url: str | None = None
|
|
47
|
+
events: list[str] | None = None
|
|
48
|
+
enabled: bool | None = None
|
|
49
|
+
secret: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class WebhookOut(BaseModel):
|
|
53
|
+
id: int
|
|
54
|
+
name: str
|
|
55
|
+
url: str
|
|
56
|
+
events: list[str]
|
|
57
|
+
enabled: bool
|
|
58
|
+
has_secret: bool
|
|
59
|
+
last_fired_at: datetime | None
|
|
60
|
+
last_status: int | None
|
|
61
|
+
created_at: datetime
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Annotated
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
5
|
+
from sqlalchemy import select
|
|
6
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
7
|
+
|
|
8
|
+
from webgate.auth.models import UserOut
|
|
9
|
+
from webgate.auth.routes import get_current_user
|
|
10
|
+
from webgate.db.engine import get_session
|
|
11
|
+
from webgate.webhooks.dispatcher import fire
|
|
12
|
+
from webgate.webhooks.models import (
|
|
13
|
+
EVENT_NAMES,
|
|
14
|
+
Webhook,
|
|
15
|
+
WebhookCreate,
|
|
16
|
+
WebhookOut,
|
|
17
|
+
WebhookUpdate,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
|
|
21
|
+
|
|
22
|
+
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
|
23
|
+
CurrentUserDep = Annotated[UserOut, Depends(get_current_user)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _to_out(wh: Webhook) -> WebhookOut:
|
|
27
|
+
try:
|
|
28
|
+
events = json.loads(wh.events)
|
|
29
|
+
except json.JSONDecodeError:
|
|
30
|
+
events = ["*"]
|
|
31
|
+
return WebhookOut(
|
|
32
|
+
id=wh.id,
|
|
33
|
+
name=wh.name,
|
|
34
|
+
url=wh.url,
|
|
35
|
+
events=events,
|
|
36
|
+
enabled=wh.enabled,
|
|
37
|
+
has_secret=bool(wh.secret),
|
|
38
|
+
last_fired_at=wh.last_fired_at,
|
|
39
|
+
last_status=wh.last_status,
|
|
40
|
+
created_at=wh.created_at,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _require_admin(user: UserOut) -> None:
|
|
45
|
+
if not user.is_admin:
|
|
46
|
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("", response_model=list[WebhookOut])
|
|
50
|
+
async def list_webhooks(session: SessionDep, user: CurrentUserDep) -> list[WebhookOut]:
|
|
51
|
+
_require_admin(user)
|
|
52
|
+
result = await session.execute(select(Webhook).order_by(Webhook.name))
|
|
53
|
+
return [_to_out(w) for w in result.scalars().all()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.get("/events", response_model=list[str])
|
|
57
|
+
async def list_events(user: CurrentUserDep) -> list[str]:
|
|
58
|
+
_require_admin(user)
|
|
59
|
+
return EVENT_NAMES
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.post("", response_model=WebhookOut, status_code=status.HTTP_201_CREATED)
|
|
63
|
+
async def create_webhook(
|
|
64
|
+
body: WebhookCreate, session: SessionDep, user: CurrentUserDep
|
|
65
|
+
) -> WebhookOut:
|
|
66
|
+
_require_admin(user)
|
|
67
|
+
wh = Webhook(
|
|
68
|
+
name=body.name,
|
|
69
|
+
url=body.url,
|
|
70
|
+
events=json.dumps(body.events or ["*"]),
|
|
71
|
+
enabled=body.enabled,
|
|
72
|
+
secret=body.secret,
|
|
73
|
+
user_id=user.id,
|
|
74
|
+
)
|
|
75
|
+
session.add(wh)
|
|
76
|
+
await session.commit()
|
|
77
|
+
await session.refresh(wh)
|
|
78
|
+
return _to_out(wh)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@router.put("/{webhook_id}", response_model=WebhookOut)
|
|
82
|
+
async def update_webhook(
|
|
83
|
+
webhook_id: int, body: WebhookUpdate, session: SessionDep, user: CurrentUserDep
|
|
84
|
+
) -> WebhookOut:
|
|
85
|
+
_require_admin(user)
|
|
86
|
+
wh = (
|
|
87
|
+
await session.execute(select(Webhook).where(Webhook.id == webhook_id))
|
|
88
|
+
).scalar_one_or_none()
|
|
89
|
+
if wh is None:
|
|
90
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
|
91
|
+
if body.name is not None:
|
|
92
|
+
wh.name = body.name
|
|
93
|
+
if body.url is not None:
|
|
94
|
+
wh.url = body.url
|
|
95
|
+
if body.events is not None:
|
|
96
|
+
wh.events = json.dumps(body.events)
|
|
97
|
+
if body.enabled is not None:
|
|
98
|
+
wh.enabled = body.enabled
|
|
99
|
+
if body.secret is not None:
|
|
100
|
+
wh.secret = body.secret
|
|
101
|
+
await session.commit()
|
|
102
|
+
await session.refresh(wh)
|
|
103
|
+
return _to_out(wh)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.delete("/{webhook_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
107
|
+
async def delete_webhook(webhook_id: int, session: SessionDep, user: CurrentUserDep) -> None:
|
|
108
|
+
_require_admin(user)
|
|
109
|
+
wh = (
|
|
110
|
+
await session.execute(select(Webhook).where(Webhook.id == webhook_id))
|
|
111
|
+
).scalar_one_or_none()
|
|
112
|
+
if wh is None:
|
|
113
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
|
114
|
+
await session.delete(wh)
|
|
115
|
+
await session.commit()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.post("/{webhook_id}/test")
|
|
119
|
+
async def test_webhook(
|
|
120
|
+
webhook_id: int, session: SessionDep, user: CurrentUserDep
|
|
121
|
+
) -> dict[str, object]:
|
|
122
|
+
_require_admin(user)
|
|
123
|
+
wh = (
|
|
124
|
+
await session.execute(select(Webhook).where(Webhook.id == webhook_id))
|
|
125
|
+
).scalar_one_or_none()
|
|
126
|
+
if wh is None:
|
|
127
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Webhook not found")
|
|
128
|
+
await fire("test", {"webhook_id": wh.id, "name": wh.name, "triggered_by": user.username})
|
|
129
|
+
return {"queued": True}
|
|
File without changes
|
|
@@ -51,6 +51,54 @@ wheels = [
|
|
|
51
51
|
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
|
52
52
|
]
|
|
53
53
|
|
|
54
|
+
[[package]]
|
|
55
|
+
name = "asyncpg"
|
|
56
|
+
version = "0.31.0"
|
|
57
|
+
source = { registry = "https://pypi.org/simple" }
|
|
58
|
+
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
|
|
59
|
+
wheels = [
|
|
60
|
+
{ url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
|
|
61
|
+
{ url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
|
|
62
|
+
{ url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
|
|
63
|
+
{ url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
|
|
64
|
+
{ url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
|
|
65
|
+
{ url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
|
|
66
|
+
{ url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
|
|
67
|
+
{ url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
|
|
68
|
+
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
|
|
69
|
+
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
|
|
70
|
+
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
|
|
71
|
+
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
|
|
72
|
+
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
|
|
73
|
+
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
|
|
74
|
+
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
|
|
75
|
+
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
|
|
76
|
+
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
|
|
77
|
+
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
|
|
78
|
+
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
|
|
79
|
+
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
|
|
80
|
+
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
|
|
81
|
+
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
|
|
82
|
+
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
|
|
83
|
+
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
|
|
84
|
+
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
|
|
85
|
+
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
|
|
86
|
+
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
|
|
87
|
+
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
|
|
88
|
+
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
|
|
89
|
+
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
|
|
90
|
+
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
|
|
91
|
+
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
|
|
92
|
+
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
|
|
93
|
+
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
|
|
94
|
+
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
|
|
95
|
+
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
|
|
96
|
+
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
|
|
97
|
+
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
|
|
98
|
+
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
|
|
99
|
+
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
|
100
|
+
]
|
|
101
|
+
|
|
54
102
|
[[package]]
|
|
55
103
|
name = "asyncssh"
|
|
56
104
|
version = "2.22.0"
|
|
@@ -1787,15 +1835,17 @@ wheels = [
|
|
|
1787
1835
|
|
|
1788
1836
|
[[package]]
|
|
1789
1837
|
name = "webgate"
|
|
1790
|
-
version = "0.
|
|
1838
|
+
version = "0.3.1"
|
|
1791
1839
|
source = { editable = "." }
|
|
1792
1840
|
dependencies = [
|
|
1793
1841
|
{ name = "aiofiles" },
|
|
1794
1842
|
{ name = "aiosqlite" },
|
|
1843
|
+
{ name = "asyncpg" },
|
|
1795
1844
|
{ name = "asyncssh" },
|
|
1796
1845
|
{ name = "bcrypt" },
|
|
1797
1846
|
{ name = "cryptography" },
|
|
1798
1847
|
{ name = "fastapi" },
|
|
1848
|
+
{ name = "httpx" },
|
|
1799
1849
|
{ name = "passlib", extra = ["bcrypt"] },
|
|
1800
1850
|
{ name = "pydantic" },
|
|
1801
1851
|
{ name = "pydantic-settings" },
|
|
@@ -1826,10 +1876,12 @@ dev = [
|
|
|
1826
1876
|
requires-dist = [
|
|
1827
1877
|
{ name = "aiofiles", specifier = ">=24.1.0" },
|
|
1828
1878
|
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
|
1879
|
+
{ name = "asyncpg", specifier = ">=0.30.0" },
|
|
1829
1880
|
{ name = "asyncssh", specifier = ">=2.18.0" },
|
|
1830
1881
|
{ name = "bcrypt", specifier = ">=4.2.0" },
|
|
1831
1882
|
{ name = "cryptography", specifier = ">=44.0.0" },
|
|
1832
1883
|
{ name = "fastapi", specifier = ">=0.115.0" },
|
|
1884
|
+
{ name = "httpx", specifier = ">=0.28.0" },
|
|
1833
1885
|
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
|
1834
1886
|
{ name = "pydantic", specifier = ">=2.10.0" },
|
|
1835
1887
|
{ name = "pydantic-settings", specifier = ">=2.7.0" },
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|