goosebit 0.2.4__py3-none-any.whl → 0.2.6__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 (96) hide show
  1. goosebit/__init__.py +56 -6
  2. goosebit/api/telemetry/metrics.py +1 -5
  3. goosebit/api/v1/devices/device/responses.py +1 -0
  4. goosebit/api/v1/devices/device/routes.py +8 -8
  5. goosebit/api/v1/devices/requests.py +20 -0
  6. goosebit/api/v1/devices/routes.py +83 -8
  7. goosebit/api/v1/download/routes.py +14 -3
  8. goosebit/api/v1/rollouts/routes.py +5 -4
  9. goosebit/api/v1/routes.py +2 -1
  10. goosebit/api/v1/settings/routes.py +14 -0
  11. goosebit/api/v1/settings/users/__init__.py +1 -0
  12. goosebit/api/v1/settings/users/requests.py +16 -0
  13. goosebit/api/v1/settings/users/responses.py +7 -0
  14. goosebit/api/v1/settings/users/routes.py +56 -0
  15. goosebit/api/v1/software/routes.py +18 -14
  16. goosebit/auth/__init__.py +54 -14
  17. goosebit/auth/permissions.py +80 -0
  18. goosebit/db/config.py +57 -1
  19. goosebit/db/migrations/models/1_20241109151811_update.py +11 -0
  20. goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
  21. goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
  22. goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
  23. goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
  24. goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
  25. goosebit/db/models.py +22 -7
  26. goosebit/db/pg_ssl_context.py +51 -0
  27. goosebit/device_manager.py +262 -0
  28. goosebit/plugins/__init__.py +32 -0
  29. goosebit/schema/devices.py +9 -6
  30. goosebit/schema/plugins.py +67 -0
  31. goosebit/schema/updates.py +15 -0
  32. goosebit/schema/users.py +9 -0
  33. goosebit/settings/__init__.py +0 -3
  34. goosebit/settings/schema.py +62 -14
  35. goosebit/storage/__init__.py +62 -0
  36. goosebit/storage/base.py +14 -0
  37. goosebit/storage/filesystem.py +111 -0
  38. goosebit/storage/s3.py +104 -0
  39. goosebit/ui/bff/common/columns.py +50 -0
  40. goosebit/ui/bff/common/requests.py +3 -15
  41. goosebit/ui/bff/common/responses.py +17 -0
  42. goosebit/ui/bff/devices/device/__init__.py +1 -0
  43. goosebit/ui/bff/devices/device/routes.py +17 -0
  44. goosebit/ui/bff/devices/requests.py +1 -0
  45. goosebit/ui/bff/devices/responses.py +6 -2
  46. goosebit/ui/bff/devices/routes.py +71 -17
  47. goosebit/ui/bff/download/routes.py +14 -3
  48. goosebit/ui/bff/rollouts/responses.py +6 -2
  49. goosebit/ui/bff/rollouts/routes.py +32 -4
  50. goosebit/ui/bff/routes.py +6 -3
  51. goosebit/ui/bff/settings/__init__.py +1 -0
  52. goosebit/ui/bff/settings/routes.py +20 -0
  53. goosebit/ui/bff/settings/users/__init__.py +1 -0
  54. goosebit/ui/bff/settings/users/responses.py +33 -0
  55. goosebit/ui/bff/settings/users/routes.py +80 -0
  56. goosebit/ui/bff/software/responses.py +19 -9
  57. goosebit/ui/bff/software/routes.py +40 -12
  58. goosebit/ui/nav.py +12 -2
  59. goosebit/ui/routes.py +70 -26
  60. goosebit/ui/static/js/devices.js +72 -80
  61. goosebit/ui/static/js/login.js +21 -5
  62. goosebit/ui/static/js/logs.js +7 -22
  63. goosebit/ui/static/js/rollouts.js +39 -35
  64. goosebit/ui/static/js/settings.js +322 -0
  65. goosebit/ui/static/js/setup.js +28 -0
  66. goosebit/ui/static/js/software.js +127 -127
  67. goosebit/ui/static/js/util.js +45 -4
  68. goosebit/ui/templates/__init__.py +10 -1
  69. goosebit/ui/templates/devices.html.jinja +0 -20
  70. goosebit/ui/templates/login.html.jinja +5 -0
  71. goosebit/ui/templates/nav.html.jinja +26 -7
  72. goosebit/ui/templates/rollouts.html.jinja +4 -22
  73. goosebit/ui/templates/settings.html.jinja +88 -0
  74. goosebit/ui/templates/setup.html.jinja +71 -0
  75. goosebit/ui/templates/software.html.jinja +0 -11
  76. goosebit/updater/controller/v1/routes.py +120 -72
  77. goosebit/updater/routes.py +86 -7
  78. goosebit/updates/__init__.py +24 -31
  79. goosebit/updates/swdesc.py +15 -8
  80. goosebit/users/__init__.py +63 -0
  81. goosebit/util/__init__.py +0 -0
  82. goosebit/util/path.py +42 -0
  83. goosebit/util/version.py +92 -0
  84. goosebit-0.2.6.dist-info/METADATA +280 -0
  85. goosebit-0.2.6.dist-info/RECORD +133 -0
  86. {goosebit-0.2.4.dist-info → goosebit-0.2.6.dist-info}/WHEEL +1 -1
  87. goosebit-0.2.6.dist-info/entry_points.txt +3 -0
  88. goosebit/realtime/logs.py +0 -42
  89. goosebit/realtime/routes.py +0 -13
  90. goosebit/ui/static/js/index.js +0 -155
  91. goosebit/ui/templates/index.html.jinja +0 -25
  92. goosebit/updater/manager.py +0 -357
  93. goosebit-0.2.4.dist-info/METADATA +0 -181
  94. goosebit-0.2.4.dist-info/RECORD +0 -98
  95. /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
  96. {goosebit-0.2.4.dist-info → goosebit-0.2.6.dist-info}/LICENSE +0 -0
goosebit/__init__.py CHANGED
@@ -4,19 +4,23 @@ from logging import getLogger
4
4
  from typing import Annotated
5
5
 
6
6
  from fastapi import Depends, FastAPI, HTTPException
7
+ from fastapi.exception_handlers import http_exception_handler
7
8
  from fastapi.openapi.docs import get_swagger_ui_html
8
9
  from fastapi.requests import Request
9
10
  from fastapi.responses import RedirectResponse
10
11
  from fastapi.security import OAuth2PasswordRequestForm
11
12
  from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
13
+ from starlette.exceptions import HTTPException as StarletteHTTPException
12
14
  from tortoise.exceptions import ValidationError
13
15
 
14
- from goosebit import api, db, realtime, ui, updater
15
- from goosebit.api.telemetry import metrics
16
+ from goosebit import api, db, plugins, ui, updater
16
17
  from goosebit.auth import get_user_from_request, login_user, redirect_if_authenticated
18
+ from goosebit.device_manager import DeviceManager
19
+ from goosebit.settings import PWD_CXT, config
17
20
  from goosebit.ui.nav import nav
18
21
  from goosebit.ui.static import static
19
22
  from goosebit.ui.templates import templates
23
+ from goosebit.users import create_initial_user
20
24
 
21
25
  logger = getLogger(__name__)
22
26
 
@@ -26,7 +30,9 @@ async def lifespan(_: FastAPI):
26
30
  db_ready = await db.init()
27
31
  if not db_ready:
28
32
  logger.exception("DB does not exist, try running `poetry run aerich upgrade`.")
29
- await metrics.init()
33
+
34
+ logger.debug(f"Initialized storage backend: {config.storage.backend}")
35
+
30
36
  if db_ready:
31
37
  yield
32
38
  await db.close()
@@ -54,10 +60,30 @@ app = FastAPI(
54
60
  app.include_router(updater.router)
55
61
  app.include_router(ui.router)
56
62
  app.include_router(api.router)
57
- app.include_router(realtime.router)
58
63
  app.mount("/static", static, name="static")
59
64
  Instrumentor.instrument_app(app)
60
65
 
66
+ for plugin in plugins.load():
67
+ if plugin.middleware is not None:
68
+ logger.info(f"Adding middleware for plugin: {plugin.name}")
69
+ app.add_middleware(plugin.middleware)
70
+ if plugin.router is not None:
71
+ logger.info(f"Adding routing handler for plugin: {plugin.name}")
72
+ app.include_router(router=plugin.router, prefix=plugin.url_prefix)
73
+ if plugin.db_model_path is not None:
74
+ logger.info(f"Adding db handler for plugin: {plugin.name}")
75
+ db.config.add_models(plugin.db_model_path)
76
+ if plugin.static_files is not None:
77
+ logger.info(f"Adding static files handler for plugin: {plugin.name}")
78
+ app.mount(f"{plugin.url_prefix}/static", plugin.static_files, name=plugin.static_files_name)
79
+ if plugin.templates is not None:
80
+ logger.info(f"Adding template handler for plugin: {plugin.name}")
81
+ templates.add_template_handler(plugin.templates)
82
+ if plugin.update_source_hook is not None:
83
+ DeviceManager.add_update_source(plugin.update_source_hook)
84
+ if plugin.config_data_hook is not None:
85
+ DeviceManager.add_config_callback(plugin.config_data_hook)
86
+
61
87
 
62
88
  # Custom exception handler for Tortoise ValidationError
63
89
  @app.exception_handler(ValidationError)
@@ -65,6 +91,13 @@ async def tortoise_validation_exception_handler(request: Request, exc: Validatio
65
91
  raise HTTPException(422, str(exc))
66
92
 
67
93
 
94
+ # Extend default handler to do logging
95
+ @app.exception_handler(StarletteHTTPException)
96
+ async def custom_http_exception_handler(request, exc):
97
+ logger.warning(f"HTTPException, request={request.url}, status={exc.status_code}, detail={exc.detail}")
98
+ return await http_exception_handler(request, exc)
99
+
100
+
68
101
  @app.middleware("http")
69
102
  async def attach_user(request: Request, call_next):
70
103
  request.scope["user"] = await get_user_from_request(request)
@@ -77,6 +110,12 @@ async def attach_nav(request: Request, call_next):
77
110
  return await call_next(request)
78
111
 
79
112
 
113
+ @app.middleware("http")
114
+ async def attach_config(request: Request, call_next):
115
+ request.scope["config"] = config
116
+ return await call_next(request)
117
+
118
+
80
119
  @app.get("/", include_in_schema=False)
81
120
  def root_redirect(request: Request):
82
121
  return RedirectResponse(request.url_for("ui_root"))
@@ -89,7 +128,18 @@ async def login_get(request: Request):
89
128
 
90
129
  @app.post("/login", tags=["login"])
91
130
  async def login_post(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
92
- return {"access_token": login_user(form_data.username, form_data.password), "token_type": "bearer"}
131
+ return {"access_token": await login_user(form_data.username, form_data.password), "token_type": "bearer"}
132
+
133
+
134
+ @app.get("/setup", include_in_schema=False)
135
+ async def setup_get(request: Request):
136
+ return templates.TemplateResponse(request, "setup.html.jinja")
137
+
138
+
139
+ @app.post("/setup", include_in_schema=False)
140
+ async def setup_post(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
141
+ await create_initial_user(form_data.username, PWD_CXT.hash(form_data.password))
142
+ return {"access_token": await login_user(form_data.username, form_data.password), "token_type": "bearer"}
93
143
 
94
144
 
95
145
  @app.get("/logout", include_in_schema=False)
@@ -99,7 +149,7 @@ async def logout(request: Request):
99
149
  return resp
100
150
 
101
151
 
102
- @app.get("/docs")
152
+ @app.get("/docs", include_in_schema=False)
103
153
  async def swagger_docs(request: Request):
104
154
  return get_swagger_ui_html(
105
155
  title="gooseBit docs",
@@ -2,7 +2,7 @@ from opentelemetry import metrics
2
2
  from opentelemetry.sdk.metrics import MeterProvider
3
3
  from opentelemetry.sdk.resources import SERVICE_NAME, Resource
4
4
 
5
- from goosebit.settings import USERS, config
5
+ from goosebit.settings import config
6
6
 
7
7
  from . import prometheus
8
8
 
@@ -28,7 +28,3 @@ users_count = meter.create_gauge(
28
28
  "users.count",
29
29
  description="The number of registered users",
30
30
  )
31
-
32
-
33
- async def init():
34
- users_count.set(len(USERS))
@@ -7,6 +7,7 @@ from goosebit.schema.devices import DeviceSchema
7
7
 
8
8
  class DeviceLogResponse(BaseModel):
9
9
  log: str | None
10
+ progress: int | None
10
11
 
11
12
 
12
13
  class DeviceResponse(DeviceSchema):
@@ -5,17 +5,18 @@ from fastapi.requests import Request
5
5
 
6
6
  from goosebit.api.v1.devices.device.responses import DeviceLogResponse, DeviceResponse
7
7
  from goosebit.auth import validate_user_permissions
8
- from goosebit.updater.manager import UpdateManager, get_update_manager
8
+ from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
9
+ from goosebit.db import Device
10
+ from goosebit.device_manager import get_device
9
11
 
10
12
  router = APIRouter(prefix="/{dev_id}")
11
13
 
12
14
 
13
15
  @router.get(
14
16
  "",
15
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
17
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])],
16
18
  )
17
- async def device_get(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceResponse:
18
- device = await updater.get_device()
19
+ async def device_get(_: Request, device: Device = Depends(get_device)) -> DeviceResponse:
19
20
  if device is None:
20
21
  raise HTTPException(404)
21
22
  await device.fetch_related("assigned_software", "hardware")
@@ -24,10 +25,9 @@ async def device_get(_: Request, updater: UpdateManager = Depends(get_update_man
24
25
 
25
26
  @router.get(
26
27
  "/log",
27
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
28
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])],
28
29
  )
29
- async def device_logs(_: Request, updater: UpdateManager = Depends(get_update_manager)) -> DeviceLogResponse:
30
- device = await updater.get_device()
30
+ async def device_logs(_: Request, device: Device = Depends(get_device)) -> DeviceLogResponse:
31
31
  if device is None:
32
32
  raise HTTPException(404)
33
- return DeviceLogResponse(log=device.last_log)
33
+ return DeviceLogResponse(log=device.last_log, progress=device.progress)
@@ -5,3 +5,23 @@ from pydantic import BaseModel
5
5
 
6
6
  class DevicesDeleteRequest(BaseModel):
7
7
  devices: list[str]
8
+
9
+
10
+ class DevicesPatchRequest(BaseModel):
11
+ devices: list[str]
12
+ software: str | None = None
13
+ name: str | None = None
14
+ pinned: bool | None = None
15
+ feed: str | None = None
16
+ force_update: bool | None = None
17
+ auth_token: str | None = None
18
+
19
+
20
+ class DevicesPutRequest(BaseModel):
21
+ devices: list[str]
22
+ software: str | None = None
23
+ name: str | None = None
24
+ pinned: bool | None = None
25
+ feed: str | None = None
26
+ force_update: bool | None = None
27
+ auth_token: str | None = None
@@ -1,15 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
4
+ from http.client import HTTPException
5
+
3
6
  from fastapi import APIRouter, Security
4
7
  from fastapi.requests import Request
5
8
 
6
9
  from goosebit.api.responses import StatusResponse
7
10
  from goosebit.auth import validate_user_permissions
8
- from goosebit.db.models import Device
9
- from goosebit.updater.manager import delete_devices
11
+ from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
12
+ from goosebit.db.models import Device, Software, UpdateModeEnum
13
+ from goosebit.device_manager import DeviceManager, get_device
14
+ from goosebit.schema.devices import DeviceSchema
15
+ from goosebit.schema.software import SoftwareSchema
10
16
 
11
17
  from . import device
12
- from .requests import DevicesDeleteRequest
18
+ from .requests import DevicesDeleteRequest, DevicesPatchRequest, DevicesPutRequest
13
19
  from .responses import DevicesResponse
14
20
 
15
21
  router = APIRouter(prefix="/devices", tags=["devices"])
@@ -17,19 +23,88 @@ router = APIRouter(prefix="/devices", tags=["devices"])
17
23
 
18
24
  @router.get(
19
25
  "",
20
- dependencies=[Security(validate_user_permissions, scopes=["home.read"])],
26
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])],
21
27
  )
22
28
  async def devices_get(_: Request) -> DevicesResponse:
23
- devices = await Device.all().prefetch_related("assigned_software", "hardware")
24
- return DevicesResponse(devices=devices)
29
+ devices = await Device.all().prefetch_related("hardware", "assigned_software", "assigned_software__compatibility")
30
+ response = DevicesResponse(devices=devices)
31
+
32
+ async def set_assigned_sw(d: DeviceSchema):
33
+ device = await get_device(d.id)
34
+ _, target = await DeviceManager.get_update(device)
35
+ if target is not None:
36
+ await target.fetch_related("compatibility")
37
+ d.assigned_software = SoftwareSchema.model_validate(target)
38
+ return d
39
+
40
+ response.devices = await asyncio.gather(*[set_assigned_sw(d) for d in response.devices])
41
+ return response
25
42
 
26
43
 
27
44
  @router.delete(
28
45
  "",
29
- dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
46
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["delete"]()])],
30
47
  )
31
48
  async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResponse:
32
- await delete_devices(config.devices)
49
+ await DeviceManager.delete_devices(config.devices)
50
+ return StatusResponse(success=True)
51
+
52
+
53
+ @router.patch(
54
+ "",
55
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["write"]()])],
56
+ )
57
+ async def devices_patch(_: Request, config: DevicesPatchRequest) -> StatusResponse:
58
+ for dev_id in config.devices:
59
+ if await Device.get_or_none(id=dev_id) is None:
60
+ raise HTTPException(404, f"Device with ID {dev_id} not found")
61
+ device = await DeviceManager.get_device(dev_id)
62
+ if config.feed is not None:
63
+ await DeviceManager.update_feed(device, config.feed)
64
+ if config.software is not None:
65
+ if config.software == "rollout":
66
+ await DeviceManager.update_update(device, UpdateModeEnum.ROLLOUT, None)
67
+ elif config.software == "latest":
68
+ await DeviceManager.update_update(device, UpdateModeEnum.LATEST, None)
69
+ else:
70
+ software = await Software.get_or_none(id=config.software)
71
+ await DeviceManager.update_update(device, UpdateModeEnum.ASSIGNED, software)
72
+ if config.pinned is not None:
73
+ await DeviceManager.update_update(device, UpdateModeEnum.PINNED, None)
74
+ if config.name is not None:
75
+ await DeviceManager.update_name(device, config.name)
76
+ if config.force_update is not None:
77
+ await DeviceManager.update_force_update(device, config.force_update)
78
+ if config.auth_token is not None:
79
+ await DeviceManager.update_auth_token(device, config.auth_token)
80
+ return StatusResponse(success=True)
81
+
82
+
83
+ @router.put(
84
+ "",
85
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["write"]()])],
86
+ )
87
+ async def devices_put(_: Request, config: DevicesPutRequest) -> StatusResponse:
88
+ for dev_id in config.devices:
89
+ device = await DeviceManager.get_device(dev_id)
90
+ if config.feed is not None:
91
+ await DeviceManager.update_feed(device, config.feed)
92
+ if config.software is not None:
93
+ if config.software == "rollout":
94
+ await DeviceManager.update_update(device, UpdateModeEnum.ROLLOUT, None)
95
+ elif config.software == "latest":
96
+ await DeviceManager.update_update(device, UpdateModeEnum.LATEST, None)
97
+ else:
98
+ software = await Software.get_or_none(id=config.software)
99
+ await DeviceManager.update_update(device, UpdateModeEnum.ASSIGNED, software)
100
+ if config.pinned is not None:
101
+ await DeviceManager.update_update(device, UpdateModeEnum.PINNED, None)
102
+ if config.name is not None:
103
+ await DeviceManager.update_name(device, config.name)
104
+ if config.force_update is not None:
105
+ await DeviceManager.update_force_update(device, config.force_update)
106
+ if config.auth_token is not None:
107
+ await DeviceManager.update_auth_token(device, config.auth_token)
33
108
  return StatusResponse(success=True)
34
109
 
35
110
 
@@ -1,8 +1,9 @@
1
1
  from fastapi import APIRouter, HTTPException
2
2
  from fastapi.requests import Request
3
- from fastapi.responses import FileResponse, RedirectResponse
3
+ from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
4
4
 
5
5
  from goosebit.db.models import Software
6
+ from goosebit.storage import storage
6
7
 
7
8
  router = APIRouter(prefix="/download", tags=["download"])
8
9
 
@@ -18,5 +19,15 @@ async def download_file(_: Request, file_id: int):
18
19
  media_type="application/octet-stream",
19
20
  filename=software.path.name,
20
21
  )
21
- else:
22
- return RedirectResponse(url=software.uri)
22
+
23
+ try:
24
+ url = await storage.get_download_url(software.uri)
25
+ return RedirectResponse(url=url)
26
+ except Exception:
27
+ # Fallback to streaming if redirect fails.
28
+ file_stream = storage.get_file_stream(software.uri)
29
+ return StreamingResponse(
30
+ file_stream,
31
+ media_type="application/octet-stream",
32
+ headers={"Content-Disposition": f"attachment; filename={software.path.name}"},
33
+ )
@@ -3,6 +3,7 @@ from fastapi.requests import Request
3
3
 
4
4
  from goosebit.api.responses import StatusResponse
5
5
  from goosebit.auth import validate_user_permissions
6
+ from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
6
7
  from goosebit.db.models import Rollout, Software
7
8
 
8
9
  from .requests import RolloutsDeleteRequest, RolloutsPatchRequest, RolloutsPutRequest
@@ -13,7 +14,7 @@ router = APIRouter(prefix="/rollouts", tags=["rollouts"])
13
14
 
14
15
  @router.get(
15
16
  "",
16
- dependencies=[Security(validate_user_permissions, scopes=["rollout.read"])],
17
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["read"]()])],
17
18
  )
18
19
  async def rollouts_get(_: Request) -> RolloutsResponse:
19
20
  rollouts = await Rollout.all().prefetch_related("software", "software__compatibility")
@@ -22,7 +23,7 @@ async def rollouts_get(_: Request) -> RolloutsResponse:
22
23
 
23
24
  @router.post(
24
25
  "",
25
- dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])],
26
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])],
26
27
  )
27
28
  async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutResponse:
28
29
  software = await Software.filter(id=rollout.software_id)
@@ -38,7 +39,7 @@ async def rollouts_put(_: Request, rollout: RolloutsPutRequest) -> RolloutsPutRe
38
39
 
39
40
  @router.patch(
40
41
  "",
41
- dependencies=[Security(validate_user_permissions, scopes=["rollout.write"])],
42
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["write"]()])],
42
43
  )
43
44
  async def rollouts_patch(_: Request, rollouts: RolloutsPatchRequest) -> StatusResponse:
44
45
  await Rollout.filter(id__in=rollouts.ids).update(paused=rollouts.paused)
@@ -47,7 +48,7 @@ async def rollouts_patch(_: Request, rollouts: RolloutsPatchRequest) -> StatusRe
47
48
 
48
49
  @router.delete(
49
50
  "",
50
- dependencies=[Security(validate_user_permissions, scopes=["rollout.delete"])],
51
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["rollout"]["delete"]()])],
51
52
  )
52
53
  async def rollouts_delete(_: Request, rollouts: RolloutsDeleteRequest) -> StatusResponse:
53
54
  await Rollout.filter(id__in=rollouts.ids).delete()
goosebit/api/v1/routes.py CHANGED
@@ -1,9 +1,10 @@
1
1
  from fastapi import APIRouter
2
2
 
3
- from . import devices, download, rollouts, software
3
+ from . import devices, download, rollouts, settings, software
4
4
 
5
5
  router = APIRouter(prefix="/v1")
6
6
  router.include_router(software.router)
7
7
  router.include_router(devices.router)
8
8
  router.include_router(rollouts.router)
9
9
  router.include_router(download.router)
10
+ router.include_router(settings.router)
@@ -0,0 +1,14 @@
1
+ from fastapi import APIRouter
2
+
3
+ from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS, Permission
4
+
5
+ from . import users
6
+
7
+ router = APIRouter(prefix="/settings", tags=["settings"])
8
+
9
+ router.include_router(users.router)
10
+
11
+
12
+ @router.get("/permissions", response_model_exclude_none=True)
13
+ async def settings_permissions_get() -> Permission:
14
+ return GOOSEBIT_PERMISSIONS
@@ -0,0 +1 @@
1
+ from .routes import router # noqa: F401
@@ -0,0 +1,16 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class UsersPutRequest(BaseModel):
5
+ username: str
6
+ password: str
7
+ permissions: list[str]
8
+
9
+
10
+ class UsersPatchRequest(BaseModel):
11
+ usernames: list[str]
12
+ enabled: bool
13
+
14
+
15
+ class UsersDeleteRequest(BaseModel):
16
+ usernames: list[str]
@@ -0,0 +1,7 @@
1
+ from pydantic import BaseModel
2
+
3
+ from goosebit.schema.users import UserSchema
4
+
5
+
6
+ class SettingsUsersResponse(BaseModel):
7
+ users: list[UserSchema]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, HTTPException, Security
4
+ from fastapi.requests import Request
5
+
6
+ from goosebit.api.responses import StatusResponse
7
+ from goosebit.auth import validate_user_permissions
8
+ from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
9
+ from goosebit.db.models import User
10
+ from goosebit.users import UserManager, create_user
11
+
12
+ from .requests import UsersDeleteRequest, UsersPatchRequest, UsersPutRequest
13
+ from .responses import SettingsUsersResponse
14
+
15
+ router = APIRouter(prefix="/users")
16
+
17
+
18
+ @router.get(
19
+ "",
20
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["read"]()])],
21
+ )
22
+ async def settings_users_get(_: Request) -> SettingsUsersResponse:
23
+ users = await User.all()
24
+ return SettingsUsersResponse(users=users)
25
+
26
+
27
+ @router.post(
28
+ "",
29
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["write"]()])],
30
+ )
31
+ async def settings_users_put(_: Request, user: UsersPutRequest) -> StatusResponse:
32
+ await create_user(username=user.username, password=user.password, permissions=user.permissions)
33
+ return StatusResponse(success=True)
34
+
35
+
36
+ @router.delete(
37
+ "",
38
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["delete"]()])],
39
+ )
40
+ async def settings_users_delete(_: Request, config: UsersDeleteRequest) -> StatusResponse:
41
+ await UserManager.delete_users(config.usernames)
42
+ return StatusResponse(success=True)
43
+
44
+
45
+ @router.patch(
46
+ "",
47
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["settings"]["users"]["delete"]()])],
48
+ )
49
+ async def settings_users_patch(_: Request, config: UsersPatchRequest) -> StatusResponse:
50
+ for username in config.usernames:
51
+ if await User.get_or_none(username=username) is None:
52
+ raise HTTPException(404, f"User with username {username} not found")
53
+
54
+ user = await UserManager.get_user(username)
55
+ await UserManager.update_enabled(user, config.enabled)
56
+ return StatusResponse(success=True)
@@ -9,9 +9,11 @@ from fastapi.requests import Request
9
9
 
10
10
  from goosebit.api.responses import StatusResponse
11
11
  from goosebit.auth import validate_user_permissions
12
+ from goosebit.auth.permissions import GOOSEBIT_PERMISSIONS
12
13
  from goosebit.db.models import Rollout, Software
13
- from goosebit.settings import config
14
+ from goosebit.storage import storage
14
15
  from goosebit.updates import create_software_update
16
+ from goosebit.util.path import validate_filename
15
17
 
16
18
  from .requests import SoftwareDeleteRequest
17
19
  from .responses import SoftwareResponse
@@ -21,7 +23,7 @@ router = APIRouter(prefix="/software", tags=["software"])
21
23
 
22
24
  @router.get(
23
25
  "",
24
- dependencies=[Security(validate_user_permissions, scopes=["software.read"])],
26
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["read"]()])],
25
27
  )
26
28
  async def software_get(_: Request) -> SoftwareResponse:
27
29
  software = await Software.all().prefetch_related("compatibility")
@@ -30,7 +32,7 @@ async def software_get(_: Request) -> SoftwareResponse:
30
32
 
31
33
  @router.delete(
32
34
  "",
33
- dependencies=[Security(validate_user_permissions, scopes=["software.delete"])],
35
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["delete"]()])],
34
36
  )
35
37
  async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> StatusResponse:
36
38
  success = False
@@ -44,10 +46,11 @@ async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> Stat
44
46
  if rollout_count > 0:
45
47
  raise HTTPException(409, "Software is referenced by rollout")
46
48
 
47
- if software.local:
48
- path = software.path
49
- if await path.exists():
50
- await path.unlink()
49
+ if software.uri:
50
+ try:
51
+ await storage.delete_file(software.uri)
52
+ except ValueError:
53
+ pass
51
54
 
52
55
  await software.delete()
53
56
  success = True
@@ -56,7 +59,7 @@ async def software_delete(_: Request, delete_req: SoftwareDeleteRequest) -> Stat
56
59
 
57
60
  @router.post(
58
61
  "",
59
- dependencies=[Security(validate_user_permissions, scopes=["software.write"])],
62
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["software"]["write"]()])],
60
63
  )
61
64
  async def post_update(_: Request, file: UploadFile | None = File(None), url: str | None = Form(None)):
62
65
  if url is not None:
@@ -72,16 +75,17 @@ async def post_update(_: Request, file: UploadFile | None = File(None), url: str
72
75
  software = await create_software_update(url, None)
73
76
  elif file is not None:
74
77
  # local file
75
- artifacts_dir = Path(config.artifacts_dir)
76
- file_path = artifacts_dir.joinpath(file.filename)
77
- tmp_file_path = artifacts_dir.joinpath("tmp", ("".join(random.choices(string.ascii_lowercase, k=12)) + ".tmp"))
78
- await tmp_file_path.parent.mkdir(parents=True, exist_ok=True)
78
+ temp_dir = Path(storage.get_temp_dir())
79
+ try:
80
+ file_path = await validate_filename(file.filename, temp_dir)
81
+ except ValueError as e:
82
+ raise HTTPException(400, f"Invalid filename: {e}")
83
+ tmp_file_path = temp_dir.joinpath("".join(random.choices(string.ascii_lowercase, k=12)) + ".tmp")
79
84
  file_absolute_path = await file_path.absolute()
80
- tmp_file_absolute_path = await tmp_file_path.absolute()
81
85
  try:
82
86
  async with await open_file(tmp_file_path, "w+b") as f:
83
87
  await f.write(await file.read())
84
- software = await create_software_update(file_absolute_path.as_uri(), tmp_file_absolute_path)
88
+ software = await create_software_update(file_absolute_path.as_uri(), tmp_file_path)
85
89
  finally:
86
90
  await tmp_file_path.unlink(missing_ok=True)
87
91
  else: