goosebit 0.2.5__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 (88) hide show
  1. goosebit/__init__.py +41 -7
  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 +68 -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 +49 -13
  17. goosebit/auth/permissions.py +80 -0
  18. goosebit/db/config.py +57 -1
  19. goosebit/db/migrations/models/2_20241121113728_update.py +11 -0
  20. goosebit/db/migrations/models/3_20241121140210_update.py +11 -0
  21. goosebit/db/migrations/models/4_20250324110331_update.py +16 -0
  22. goosebit/db/migrations/models/4_20250402085235_rename_uuid_to_id.py +11 -0
  23. goosebit/db/migrations/models/5_20250619090242_null_feed.py +83 -0
  24. goosebit/db/models.py +19 -8
  25. goosebit/db/pg_ssl_context.py +51 -0
  26. goosebit/device_manager.py +262 -0
  27. goosebit/plugins/__init__.py +32 -0
  28. goosebit/schema/devices.py +8 -5
  29. goosebit/schema/plugins.py +67 -0
  30. goosebit/schema/updates.py +15 -0
  31. goosebit/schema/users.py +9 -0
  32. goosebit/settings/__init__.py +0 -3
  33. goosebit/settings/schema.py +60 -14
  34. goosebit/storage/__init__.py +62 -0
  35. goosebit/storage/base.py +14 -0
  36. goosebit/storage/filesystem.py +111 -0
  37. goosebit/storage/s3.py +104 -0
  38. goosebit/ui/bff/common/columns.py +50 -0
  39. goosebit/ui/bff/common/responses.py +1 -0
  40. goosebit/ui/bff/devices/device/__init__.py +1 -0
  41. goosebit/ui/bff/devices/device/routes.py +17 -0
  42. goosebit/ui/bff/devices/requests.py +1 -0
  43. goosebit/ui/bff/devices/routes.py +49 -46
  44. goosebit/ui/bff/download/routes.py +14 -3
  45. goosebit/ui/bff/rollouts/routes.py +32 -4
  46. goosebit/ui/bff/routes.py +2 -1
  47. goosebit/ui/bff/settings/__init__.py +1 -0
  48. goosebit/ui/bff/settings/routes.py +20 -0
  49. goosebit/ui/bff/settings/users/__init__.py +1 -0
  50. goosebit/ui/bff/settings/users/responses.py +33 -0
  51. goosebit/ui/bff/settings/users/routes.py +80 -0
  52. goosebit/ui/bff/software/routes.py +40 -12
  53. goosebit/ui/nav.py +12 -2
  54. goosebit/ui/routes.py +66 -13
  55. goosebit/ui/static/js/devices.js +32 -24
  56. goosebit/ui/static/js/login.js +21 -5
  57. goosebit/ui/static/js/logs.js +7 -22
  58. goosebit/ui/static/js/rollouts.js +31 -30
  59. goosebit/ui/static/js/settings.js +322 -0
  60. goosebit/ui/static/js/setup.js +28 -0
  61. goosebit/ui/static/js/software.js +127 -121
  62. goosebit/ui/static/js/util.js +25 -4
  63. goosebit/ui/templates/__init__.py +10 -1
  64. goosebit/ui/templates/login.html.jinja +5 -0
  65. goosebit/ui/templates/nav.html.jinja +13 -5
  66. goosebit/ui/templates/rollouts.html.jinja +4 -22
  67. goosebit/ui/templates/settings.html.jinja +88 -0
  68. goosebit/ui/templates/setup.html.jinja +71 -0
  69. goosebit/ui/templates/software.html.jinja +0 -11
  70. goosebit/updater/controller/v1/routes.py +119 -77
  71. goosebit/updater/routes.py +83 -8
  72. goosebit/updates/__init__.py +24 -31
  73. goosebit/updates/swdesc.py +15 -8
  74. goosebit/users/__init__.py +63 -0
  75. goosebit/util/__init__.py +0 -0
  76. goosebit/util/path.py +42 -0
  77. goosebit/util/version.py +92 -0
  78. goosebit-0.2.6.dist-info/METADATA +280 -0
  79. goosebit-0.2.6.dist-info/RECORD +133 -0
  80. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/WHEEL +1 -1
  81. goosebit/realtime/logs.py +0 -42
  82. goosebit/realtime/routes.py +0 -13
  83. goosebit/updater/manager.py +0 -325
  84. goosebit-0.2.5.dist-info/METADATA +0 -189
  85. goosebit-0.2.5.dist-info/RECORD +0 -99
  86. /goosebit/{realtime → api/v1/settings}/__init__.py +0 -0
  87. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/LICENSE +0 -0
  88. {goosebit-0.2.5.dist-info → goosebit-0.2.6.dist-info}/entry_points.txt +0 -0
goosebit/__init__.py CHANGED
@@ -13,13 +13,14 @@ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrum
13
13
  from starlette.exceptions import HTTPException as StarletteHTTPException
14
14
  from tortoise.exceptions import ValidationError
15
15
 
16
- from goosebit import api, db, realtime, ui, updater
17
- from goosebit.api.telemetry import metrics
16
+ from goosebit import api, db, plugins, ui, updater
18
17
  from goosebit.auth import get_user_from_request, login_user, redirect_if_authenticated
19
- from goosebit.settings import config
18
+ from goosebit.device_manager import DeviceManager
19
+ from goosebit.settings import PWD_CXT, config
20
20
  from goosebit.ui.nav import nav
21
21
  from goosebit.ui.static import static
22
22
  from goosebit.ui.templates import templates
23
+ from goosebit.users import create_initial_user
23
24
 
24
25
  logger = getLogger(__name__)
25
26
 
@@ -29,7 +30,9 @@ async def lifespan(_: FastAPI):
29
30
  db_ready = await db.init()
30
31
  if not db_ready:
31
32
  logger.exception("DB does not exist, try running `poetry run aerich upgrade`.")
32
- await metrics.init()
33
+
34
+ logger.debug(f"Initialized storage backend: {config.storage.backend}")
35
+
33
36
  if db_ready:
34
37
  yield
35
38
  await db.close()
@@ -57,10 +60,30 @@ app = FastAPI(
57
60
  app.include_router(updater.router)
58
61
  app.include_router(ui.router)
59
62
  app.include_router(api.router)
60
- app.include_router(realtime.router)
61
63
  app.mount("/static", static, name="static")
62
64
  Instrumentor.instrument_app(app)
63
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
+
64
87
 
65
88
  # Custom exception handler for Tortoise ValidationError
66
89
  @app.exception_handler(ValidationError)
@@ -105,7 +128,18 @@ async def login_get(request: Request):
105
128
 
106
129
  @app.post("/login", tags=["login"])
107
130
  async def login_post(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
108
- 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"}
109
143
 
110
144
 
111
145
  @app.get("/logout", include_in_schema=False)
@@ -115,7 +149,7 @@ async def logout(request: Request):
115
149
  return resp
116
150
 
117
151
 
118
- @app.get("/docs")
152
+ @app.get("/docs", include_in_schema=False)
119
153
  async def swagger_docs(request: Request):
120
154
  return get_swagger_ui_html(
121
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=["device.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=["device.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,19 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ from http.client import HTTPException
4
5
 
5
6
  from fastapi import APIRouter, Security
6
7
  from fastapi.requests import Request
7
8
 
8
9
  from goosebit.api.responses import StatusResponse
9
10
  from goosebit.auth import validate_user_permissions
10
- from goosebit.db.models import Device
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
11
14
  from goosebit.schema.devices import DeviceSchema
12
15
  from goosebit.schema.software import SoftwareSchema
13
- from goosebit.updater.manager import delete_devices, get_update_manager
14
16
 
15
17
  from . import device
16
- from .requests import DevicesDeleteRequest
18
+ from .requests import DevicesDeleteRequest, DevicesPatchRequest, DevicesPutRequest
17
19
  from .responses import DevicesResponse
18
20
 
19
21
  router = APIRouter(prefix="/devices", tags=["devices"])
@@ -21,15 +23,15 @@ router = APIRouter(prefix="/devices", tags=["devices"])
21
23
 
22
24
  @router.get(
23
25
  "",
24
- dependencies=[Security(validate_user_permissions, scopes=["device.read"])],
26
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["read"]()])],
25
27
  )
26
28
  async def devices_get(_: Request) -> DevicesResponse:
27
29
  devices = await Device.all().prefetch_related("hardware", "assigned_software", "assigned_software__compatibility")
28
30
  response = DevicesResponse(devices=devices)
29
31
 
30
32
  async def set_assigned_sw(d: DeviceSchema):
31
- updater = await get_update_manager(d.uuid)
32
- _, target = await updater.get_update()
33
+ device = await get_device(d.id)
34
+ _, target = await DeviceManager.get_update(device)
33
35
  if target is not None:
34
36
  await target.fetch_related("compatibility")
35
37
  d.assigned_software = SoftwareSchema.model_validate(target)
@@ -41,10 +43,68 @@ async def devices_get(_: Request) -> DevicesResponse:
41
43
 
42
44
  @router.delete(
43
45
  "",
44
- dependencies=[Security(validate_user_permissions, scopes=["device.delete"])],
46
+ dependencies=[Security(validate_user_permissions, scopes=[GOOSEBIT_PERMISSIONS["device"]["delete"]()])],
45
47
  )
46
48
  async def devices_delete(_: Request, config: DevicesDeleteRequest) -> StatusResponse:
47
- 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)
48
108
  return StatusResponse(success=True)
49
109
 
50
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: