supython 0.5.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.
Files changed (188) hide show
  1. supython/__init__.py +8 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +149 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. supython-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,92 @@
1
+ import ipaddress
2
+ from typing import Annotated
3
+ from uuid import UUID
4
+
5
+ from fastapi import APIRouter, Body, Depends, Request
6
+
7
+ from ... import db
8
+ from .. import audit
9
+ from ..deps import require_admin
10
+ from ..errors import AdminError, to_http
11
+ from ..schemas import (
12
+ FunctionInvokeRequest,
13
+ FunctionInvokeResponse,
14
+ FunctionRoute,
15
+ FunctionSourceResponse,
16
+ )
17
+ from . import service_functions
18
+
19
+ router = APIRouter(prefix="/admin/api/v1/functions", tags=["admin.functions"])
20
+
21
+
22
+ def _client_ip(request: Request) -> str | None:
23
+ if request.client is None:
24
+ return None
25
+ try:
26
+ ipaddress.ip_address(request.client.host)
27
+ except ValueError:
28
+ return None
29
+ return request.client.host
30
+
31
+
32
+ @router.get("/routes", response_model=list[FunctionRoute])
33
+ async def list_routes(
34
+ _: Annotated[UUID, Depends(require_admin)],
35
+ ) -> list[FunctionRoute]:
36
+ return service_functions.list_routes()
37
+
38
+
39
+ @router.get("/{name:path}/source", response_model=FunctionSourceResponse)
40
+ async def read_source(
41
+ name: str,
42
+ _: Annotated[UUID, Depends(require_admin)],
43
+ ) -> FunctionSourceResponse:
44
+ try:
45
+ return service_functions.read_source(name)
46
+ except AdminError as exc:
47
+ raise to_http(exc) from exc
48
+
49
+
50
+ @router.post("/{name:path}/invoke", response_model=FunctionInvokeResponse)
51
+ async def invoke(
52
+ name: str,
53
+ request: Request,
54
+ admin_id: Annotated[UUID, Depends(require_admin)],
55
+ payload: Annotated[FunctionInvokeRequest | None, Body()] = None,
56
+ ) -> FunctionInvokeResponse:
57
+ body = payload or FunctionInvokeRequest()
58
+ ip = _client_ip(request)
59
+ ua = request.headers.get("user-agent")
60
+ try:
61
+ result = await service_functions.invoke_function(name, body)
62
+ except AdminError as exc:
63
+ async with db.as_service_role() as conn:
64
+ await audit.write(
65
+ conn,
66
+ admin_id=admin_id,
67
+ action="functions.invoke.failed",
68
+ target=name,
69
+ payload={
70
+ "method": body.method.upper(),
71
+ "error_code": exc.code,
72
+ },
73
+ ip=ip,
74
+ ua=ua,
75
+ )
76
+ raise to_http(exc) from exc
77
+
78
+ async with db.as_service_role() as conn:
79
+ await audit.write(
80
+ conn,
81
+ admin_id=admin_id,
82
+ action="functions.invoke",
83
+ target=name,
84
+ payload={
85
+ "method": body.method.upper(),
86
+ "status": result.status,
87
+ "elapsed_ms": result.elapsed_ms,
88
+ },
89
+ ip=ip,
90
+ ua=ua,
91
+ )
92
+ return result
@@ -0,0 +1,192 @@
1
+ """Admin jobs control plane.
2
+
3
+ GET /admin/api/v1/jobs/queue?status=&queue=&limit=&offset=
4
+ Paginated job queue listing with per-status counts.
5
+
6
+ POST /admin/api/v1/jobs/{id}/retry
7
+ Retry a failed or cancelled job. Returns 409 unless status is
8
+ retryable.
9
+
10
+ POST /admin/api/v1/jobs/{id}/cancel
11
+ Cancel a queued job. Returns 404/409 for non-cancellable states.
12
+
13
+ GET /admin/api/v1/jobs/crons
14
+ List cron schedules from ``jobs.cron_schedules`` with pg_cron
15
+ health data.
16
+
17
+ GET /admin/api/v1/jobs/crons/health
18
+ pg_cron extension health banner data.
19
+
20
+ POST /admin/api/v1/jobs/crons/{name}/run-now
21
+ Enqueue the underlying job for a cron schedule once.
22
+ """
23
+
24
+ import ipaddress
25
+ import logging
26
+ from typing import Annotated
27
+ from uuid import UUID
28
+
29
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
30
+
31
+ from ... import db
32
+ from .. import audit
33
+ from ..deps import require_admin
34
+ from ..errors import AdminError, to_http
35
+ from ..schemas import AdminCronRow, AdminJobRow, AdminJobsPage, PgCronHealth
36
+ from . import service_jobs
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ router = APIRouter(prefix="/admin/api/v1/jobs", tags=["admin.jobs"])
41
+
42
+
43
+ def _client_ip(request: Request) -> str | None:
44
+ if request.client is None:
45
+ return None
46
+ try:
47
+ ipaddress.ip_address(request.client.host)
48
+ except ValueError:
49
+ return None
50
+ return request.client.host
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # GET /queue — paginated job queue
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ @router.get("/queue", response_model=AdminJobsPage)
59
+ async def list_queue(
60
+ _: Annotated[UUID, Depends(require_admin)],
61
+ status: Annotated[str | None, Query(description="Filter by status")] = None,
62
+ queue: Annotated[str | None, Query(description="Filter by queue name")] = None,
63
+ limit: Annotated[int, Query(ge=1, le=500)] = 50,
64
+ offset: Annotated[int, Query(ge=0)] = 0,
65
+ ) -> AdminJobsPage:
66
+ async with db.as_service_role() as conn:
67
+ return await service_jobs.list_queue(
68
+ conn,
69
+ status=status,
70
+ queue=queue,
71
+ limit=limit,
72
+ offset=offset,
73
+ )
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # POST /{id}/retry — retry a terminal job
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ @router.post("/{job_id}/retry", response_model=AdminJobRow)
82
+ async def retry_job(
83
+ request: Request,
84
+ admin_id: Annotated[UUID, Depends(require_admin)],
85
+ job_id: UUID,
86
+ ) -> AdminJobRow:
87
+ async with db.as_service_role() as conn:
88
+ try:
89
+ job = await service_jobs.retry_job(conn, job_id)
90
+ except AdminError as exc:
91
+ raise to_http(exc) from exc
92
+
93
+ ip = _client_ip(request)
94
+ ua = request.headers.get("user-agent")
95
+ await audit.write(
96
+ conn,
97
+ admin_id=admin_id,
98
+ action="jobs.retry",
99
+ target=str(job_id),
100
+ payload={"name": job.name, "status": job.status},
101
+ ip=ip,
102
+ ua=ua,
103
+ )
104
+ return job
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # POST /{id}/cancel — cancel a queued job
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ @router.post("/{job_id}/cancel", response_model=AdminJobRow)
113
+ async def cancel_job(
114
+ request: Request,
115
+ admin_id: Annotated[UUID, Depends(require_admin)],
116
+ job_id: UUID,
117
+ ) -> AdminJobRow:
118
+ async with db.as_service_role() as conn:
119
+ try:
120
+ job = await service_jobs.cancel_job(conn, job_id)
121
+ except AdminError as exc:
122
+ raise to_http(exc) from exc
123
+
124
+ ip = _client_ip(request)
125
+ ua = request.headers.get("user-agent")
126
+ await audit.write(
127
+ conn,
128
+ admin_id=admin_id,
129
+ action="jobs.cancel",
130
+ target=str(job_id),
131
+ payload={"name": job.name},
132
+ ip=ip,
133
+ ua=ua,
134
+ )
135
+ return job
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # GET /crons — list cron schedules
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ @router.get("/crons", response_model=list[AdminCronRow])
144
+ async def list_crons(
145
+ _: Annotated[UUID, Depends(require_admin)],
146
+ ) -> list[AdminCronRow]:
147
+ async with db.as_service_role() as conn:
148
+ return await service_jobs.list_crons(conn)
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # GET /crons/health — pg_cron health banner
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ @router.get("/crons/health", response_model=PgCronHealth)
157
+ async def cron_health(
158
+ _: Annotated[UUID, Depends(require_admin)],
159
+ ) -> PgCronHealth:
160
+ async with db.as_service_role() as conn:
161
+ return await service_jobs.pg_cron_health(conn)
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # POST /crons/{name}/run-now — enqueue a cron's underlying job once
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ @router.post("/crons/{cron_name}/run-now", response_model=AdminJobRow)
170
+ async def run_cron_now(
171
+ request: Request,
172
+ admin_id: Annotated[UUID, Depends(require_admin)],
173
+ cron_name: str,
174
+ ) -> AdminJobRow:
175
+ async with db.as_service_role() as conn:
176
+ try:
177
+ job = await service_jobs.run_cron_now(conn, cron_name)
178
+ except AdminError as exc:
179
+ raise to_http(exc) from exc
180
+
181
+ ip = _client_ip(request)
182
+ ua = request.headers.get("user-agent")
183
+ await audit.write(
184
+ conn,
185
+ admin_id=admin_id,
186
+ action="jobs.run_cron_now",
187
+ target=cron_name,
188
+ payload={"job_id": str(job.id), "job_name": job.name},
189
+ ip=ip,
190
+ ua=ua,
191
+ )
192
+ return job
@@ -0,0 +1,224 @@
1
+ """Admin ops control plane.
2
+
3
+ GET /admin/api/v1/ops/backups
4
+ List backups (id, kind, size, started_at, finished_at, status).
5
+
6
+ POST /admin/api/v1/ops/backups
7
+ Start a new backup (full or schema-only). Returns the record immediately;
8
+ the backup runs in the background.
9
+
10
+ GET /admin/api/v1/ops/backups/{id}/download
11
+ Returns a signed download URL with 10 min TTL.
12
+
13
+ GET /admin/api/v1/ops/downloads/{token}
14
+ Streams the backup file after verifying the signed token.
15
+ Does NOT require admin session — the token is the authority.
16
+
17
+ GET /admin/api/v1/ops/logs/tail
18
+ SSE endpoint. Streams live log entries from the in-memory ring buffer.
19
+ Supports optional query filters: level, substring, request_id.
20
+ """
21
+
22
+ import ipaddress
23
+ import logging
24
+ from pathlib import Path
25
+ from typing import Annotated
26
+ from uuid import UUID
27
+
28
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
29
+ from fastapi.responses import FileResponse, StreamingResponse
30
+
31
+ from ... import backups, db
32
+ from ...settings import get_settings
33
+ from .. import audit
34
+ from ..deps import require_admin
35
+ from ..errors import AdminError, to_http
36
+ from ..schemas import (
37
+ AdminBackupRow,
38
+ AdminBackupsPage,
39
+ BackupDownloadResponse,
40
+ StartBackupRequest,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ router = APIRouter(prefix="/admin/api/v1/ops", tags=["admin.ops"])
46
+
47
+
48
+ def _client_ip(request: Request) -> str | None:
49
+ if request.client is None:
50
+ return None
51
+ try:
52
+ ipaddress.ip_address(request.client.host)
53
+ except ValueError:
54
+ return None
55
+ return request.client.host
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # GET /backups — list backups
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ @router.get("/backups", response_model=AdminBackupsPage)
64
+ async def list_backups(
65
+ _: Annotated[UUID, Depends(require_admin)],
66
+ limit: Annotated[int, Query(ge=1, le=500)] = 50,
67
+ offset: Annotated[int, Query(ge=0)] = 0,
68
+ ) -> AdminBackupsPage:
69
+ async with db.as_service_role() as conn:
70
+ from . import service_ops
71
+
72
+ return await service_ops.list_backups(conn, limit=limit, offset=offset)
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # POST /backups — start a new backup
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ @router.post("/backups", response_model=AdminBackupRow, status_code=status.HTTP_202_ACCEPTED)
81
+ async def start_backup(
82
+ request: Request,
83
+ admin_id: Annotated[UUID, Depends(require_admin)],
84
+ body: StartBackupRequest,
85
+ ) -> AdminBackupRow:
86
+ async with db.as_service_role() as conn:
87
+ from . import service_ops
88
+
89
+ try:
90
+ backup = await service_ops.start_backup(conn, kind=body.kind)
91
+ except AdminError as exc:
92
+ raise to_http(exc) from exc
93
+
94
+ ip = _client_ip(request)
95
+ ua = request.headers.get("user-agent")
96
+ await audit.write(
97
+ conn,
98
+ admin_id=admin_id,
99
+ action="backup.start",
100
+ target=str(backup.id),
101
+ payload={"kind": body.kind, "status": backup.status},
102
+ ip=ip,
103
+ ua=ua,
104
+ )
105
+ return backup
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # GET /backups/{id}/download — signed download URL
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ @router.get("/backups/{backup_id}/download", response_model=BackupDownloadResponse)
114
+ async def get_download_url(
115
+ _: Annotated[UUID, Depends(require_admin)],
116
+ backup_id: UUID,
117
+ ) -> BackupDownloadResponse:
118
+ async with db.as_service_role() as conn:
119
+ from . import service_ops
120
+
121
+ try:
122
+ return await service_ops.get_backup_download_response(conn, backup_id)
123
+ except AdminError as exc:
124
+ raise to_http(exc) from exc
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # GET /downloads/{token} — stream backup file (token-authenticated)
129
+ # ---------------------------------------------------------------------------
130
+
131
+
132
+ @router.get("/downloads/{token}")
133
+ async def download_backup(request: Request, token: str) -> FileResponse:
134
+ backup_id = backups.verify_download_token(token)
135
+ if backup_id is None:
136
+ raise HTTPException(status_code=403, detail="Invalid or expired download token")
137
+
138
+ async with db.as_service_role() as conn:
139
+ record = await backups.get_backup(conn, backup_id)
140
+
141
+ if record is None:
142
+ raise HTTPException(status_code=404, detail="Backup not found")
143
+ if record.status != "completed" or not record.file_path:
144
+ raise HTTPException(status_code=409, detail="Backup not ready for download")
145
+
146
+ # Defense in depth: refuse to serve any file outside the configured
147
+ # backups directory, even if a stray file_path somehow ends up in the
148
+ # row. The token only authenticates the backup_id, not the path.
149
+ backups_dir = Path(get_settings().backups_dir).resolve()
150
+ target = Path(record.file_path).resolve()
151
+ try:
152
+ target.relative_to(backups_dir)
153
+ except ValueError:
154
+ logger.error(
155
+ "backup %s file_path %s is outside backups_dir %s",
156
+ backup_id,
157
+ target,
158
+ backups_dir,
159
+ )
160
+ raise HTTPException(
161
+ status_code=500, detail="Backup file outside backups directory"
162
+ ) from None
163
+
164
+ if not target.is_file():
165
+ logger.error("backup file missing on disk: %s", target)
166
+ raise HTTPException(status_code=500, detail="Backup file missing on disk")
167
+
168
+ kind_label = "schema" if record.kind == "schema-only" else "full"
169
+ filename = f"backup_{backup_id}_{kind_label}.sql"
170
+
171
+ async with db.as_service_role() as conn:
172
+ await audit.write(
173
+ conn,
174
+ admin_id=None,
175
+ action="backup.download",
176
+ target=str(backup_id),
177
+ payload={"kind": record.kind, "size": record.size},
178
+ ip=_client_ip(request),
179
+ ua=request.headers.get("user-agent"),
180
+ )
181
+
182
+ return FileResponse(
183
+ path=str(target),
184
+ media_type="application/sql",
185
+ filename=filename,
186
+ )
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # GET /logs/tail — live log tail via SSE
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ @router.get("/logs/tail")
195
+ async def tail_logs(
196
+ _: Annotated[UUID, Depends(require_admin)],
197
+ level: Annotated[
198
+ str | None,
199
+ Query(description="Minimum log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"),
200
+ ] = None,
201
+ substring: Annotated[
202
+ str | None,
203
+ Query(description="Case-insensitive substring match on message"),
204
+ ] = None,
205
+ request_id: Annotated[
206
+ str | None,
207
+ Query(description="Exact match on request_id field"),
208
+ ] = None,
209
+ ) -> StreamingResponse:
210
+ from . import service_ops
211
+
212
+ return StreamingResponse(
213
+ service_ops.tail_logs(
214
+ level=level,
215
+ substring=substring,
216
+ request_id=request_id,
217
+ ),
218
+ media_type="text/event-stream",
219
+ headers={
220
+ "Cache-Control": "no-cache, no-store, must-revalidate",
221
+ "Connection": "keep-alive",
222
+ "X-Accel-Buffering": "no", # Disable nginx buffering for SSE
223
+ },
224
+ )