goosebit 0.1.0__py3-none-any.whl → 0.1.2__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.
- goosebit/__init__.py +8 -5
- goosebit/api/__init__.py +1 -1
- goosebit/api/devices.py +60 -36
- goosebit/api/download.py +28 -14
- goosebit/api/firmware.py +37 -44
- goosebit/api/helper.py +30 -0
- goosebit/api/rollouts.py +87 -0
- goosebit/api/routes.py +15 -7
- goosebit/auth/__init__.py +37 -21
- goosebit/db.py +5 -0
- goosebit/models.py +125 -6
- goosebit/permissions.py +33 -13
- goosebit/realtime/__init__.py +1 -1
- goosebit/realtime/logs.py +4 -6
- goosebit/settings.py +38 -29
- goosebit/telemetry/__init__.py +28 -0
- goosebit/telemetry/prometheus.py +10 -0
- goosebit/ui/__init__.py +1 -1
- goosebit/ui/routes.py +36 -39
- goosebit/ui/static/js/devices.js +191 -239
- goosebit/ui/static/js/firmware.js +234 -88
- goosebit/ui/static/js/index.js +83 -84
- goosebit/ui/static/js/logs.js +17 -10
- goosebit/ui/static/js/rollouts.js +198 -0
- goosebit/ui/static/js/util.js +66 -0
- goosebit/ui/templates/devices.html +75 -42
- goosebit/ui/templates/firmware.html +150 -34
- goosebit/ui/templates/index.html +9 -23
- goosebit/ui/templates/login.html +58 -27
- goosebit/ui/templates/logs.html +18 -3
- goosebit/ui/templates/nav.html +78 -25
- goosebit/ui/templates/rollouts.html +76 -0
- goosebit/updater/__init__.py +1 -1
- goosebit/updater/controller/__init__.py +1 -1
- goosebit/updater/controller/v1/__init__.py +1 -1
- goosebit/updater/controller/v1/routes.py +112 -24
- goosebit/updater/manager.py +237 -94
- goosebit/updater/routes.py +7 -8
- goosebit/updates/__init__.py +70 -0
- goosebit/updates/swdesc.py +83 -0
- goosebit-0.1.2.dist-info/METADATA +123 -0
- goosebit-0.1.2.dist-info/RECORD +51 -0
- goosebit/updater/download/__init__.py +0 -1
- goosebit/updater/download/routes.py +0 -6
- goosebit/updater/download/v1/__init__.py +0 -1
- goosebit/updater/download/v1/routes.py +0 -26
- goosebit/updater/misc.py +0 -69
- goosebit/updater/updates.py +0 -93
- goosebit-0.1.0.dist-info/METADATA +0 -37
- goosebit-0.1.0.dist-info/RECORD +0 -48
- {goosebit-0.1.0.dist-info → goosebit-0.1.2.dist-info}/LICENSE +0 -0
- {goosebit-0.1.0.dist-info → goosebit-0.1.2.dist-info}/WHEEL +0 -0
goosebit/__init__.py
CHANGED
@@ -5,8 +5,9 @@ from fastapi import Depends, FastAPI
|
|
5
5
|
from fastapi.requests import Request
|
6
6
|
from fastapi.responses import RedirectResponse
|
7
7
|
from fastapi.security import OAuth2PasswordRequestForm
|
8
|
+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as Instrumentor
|
8
9
|
|
9
|
-
from goosebit import api, db, realtime, ui, updater
|
10
|
+
from goosebit import api, db, realtime, telemetry, ui, updater
|
10
11
|
from goosebit.auth import (
|
11
12
|
authenticate_user,
|
12
13
|
auto_redirect,
|
@@ -20,6 +21,7 @@ from goosebit.ui.templates import templates
|
|
20
21
|
@asynccontextmanager
|
21
22
|
async def lifespan(_: FastAPI):
|
22
23
|
await db.init()
|
24
|
+
await telemetry.init()
|
23
25
|
yield
|
24
26
|
await db.close()
|
25
27
|
|
@@ -30,6 +32,7 @@ app.include_router(ui.router)
|
|
30
32
|
app.include_router(api.router)
|
31
33
|
app.include_router(realtime.router)
|
32
34
|
app.mount("/static", static, name="static")
|
35
|
+
Instrumentor.instrument_app(app)
|
33
36
|
|
34
37
|
|
35
38
|
@app.middleware("http")
|
@@ -45,18 +48,18 @@ def root_redirect(request: Request):
|
|
45
48
|
|
46
49
|
@app.get("/login", dependencies=[Depends(auto_redirect)], include_in_schema=False)
|
47
50
|
async def login_ui(request: Request):
|
48
|
-
return templates.TemplateResponse("login.html"
|
51
|
+
return templates.TemplateResponse(request, "login.html")
|
49
52
|
|
50
53
|
|
51
54
|
@app.post("/login", include_in_schema=False, dependencies=[Depends(authenticate_user)])
|
52
|
-
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
|
53
|
-
resp = RedirectResponse("
|
55
|
+
async def login(request: Request, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
|
56
|
+
resp = RedirectResponse(request.url_for("ui_root"), status_code=302)
|
54
57
|
resp.set_cookie(key="session_id", value=create_session(form_data.username))
|
55
58
|
return resp
|
56
59
|
|
57
60
|
|
58
61
|
@app.get("/logout", include_in_schema=False)
|
59
62
|
async def logout(request: Request):
|
60
|
-
resp = RedirectResponse("
|
63
|
+
resp = RedirectResponse(request.url_for("login_ui"), status_code=302)
|
61
64
|
resp.delete_cookie(key="session_id")
|
62
65
|
return resp
|
goosebit/api/__init__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
from .routes import router
|
1
|
+
from .routes import router # noqa: F401
|
goosebit/api/devices.py
CHANGED
@@ -1,17 +1,16 @@
|
|
1
|
-
from __future__ import annotations
|
2
|
-
|
3
|
-
import asyncio
|
4
1
|
import time
|
2
|
+
from typing import Any
|
5
3
|
|
6
4
|
from fastapi import APIRouter, Security
|
7
5
|
from fastapi.requests import Request
|
8
6
|
from pydantic import BaseModel
|
7
|
+
from tortoise.expressions import Q
|
9
8
|
|
9
|
+
from goosebit.api.helper import filter_data
|
10
10
|
from goosebit.auth import validate_user_permissions
|
11
|
-
from goosebit.models import Device
|
11
|
+
from goosebit.models import Device, Firmware, UpdateModeEnum, UpdateStateEnum
|
12
12
|
from goosebit.permissions import Permissions
|
13
|
-
from goosebit.updater.manager import
|
14
|
-
from goosebit.updater.misc import get_device_by_uuid
|
13
|
+
from goosebit.updater.manager import delete_devices, get_update_manager
|
15
14
|
|
16
15
|
router = APIRouter(prefix="/devices")
|
17
16
|
|
@@ -20,51 +19,80 @@ router = APIRouter(prefix="/devices")
|
|
20
19
|
"/all",
|
21
20
|
dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])],
|
22
21
|
)
|
23
|
-
async def devices_get_all() -> list[
|
24
|
-
|
22
|
+
async def devices_get_all(request: Request) -> dict[str, int | list[Any] | Any]:
|
23
|
+
query = Device.all().prefetch_related("assigned_firmware", "hardware")
|
24
|
+
|
25
|
+
def search_filter(search_value):
|
26
|
+
return (
|
27
|
+
Q(uuid__icontains=search_value)
|
28
|
+
| Q(name__icontains=search_value)
|
29
|
+
| Q(feed__icontains=search_value)
|
30
|
+
| Q(flavor__icontains=search_value)
|
31
|
+
| Q(update_mode__icontains=UpdateModeEnum.from_str(search_value))
|
32
|
+
| Q(last_state__icontains=UpdateStateEnum.from_str(search_value))
|
33
|
+
)
|
25
34
|
|
26
35
|
async def parse(device: Device) -> dict:
|
27
36
|
manager = await get_update_manager(device.uuid)
|
37
|
+
_, target_firmware = await manager.get_update()
|
28
38
|
last_seen = device.last_seen
|
29
39
|
if last_seen is not None:
|
30
40
|
last_seen = round(time.time() - device.last_seen)
|
31
41
|
return {
|
32
42
|
"uuid": device.uuid,
|
33
|
-
"web_pwd": device.web_pwd,
|
34
43
|
"name": device.name,
|
35
|
-
"
|
36
|
-
"
|
37
|
-
"
|
38
|
-
"
|
44
|
+
"fw_installed_version": device.fw_version,
|
45
|
+
"fw_target_version": (target_firmware.version if target_firmware is not None else None),
|
46
|
+
"fw_assigned": (device.assigned_firmware.id if device.assigned_firmware is not None else None),
|
47
|
+
"hw_model": device.hardware.model,
|
48
|
+
"hw_revision": device.hardware.revision,
|
49
|
+
"feed": device.feed,
|
50
|
+
"flavor": device.flavor,
|
51
|
+
"progress": device.progress,
|
52
|
+
"state": str(device.last_state),
|
53
|
+
"update_mode": str(device.update_mode),
|
54
|
+
"force_update": device.force_update,
|
39
55
|
"last_ip": device.last_ip,
|
40
56
|
"last_seen": last_seen,
|
41
|
-
"online": last_seen <
|
57
|
+
"online": (last_seen < manager.poll_seconds if last_seen is not None else None),
|
42
58
|
}
|
43
59
|
|
44
|
-
|
60
|
+
total_records = await Device.all().count()
|
61
|
+
return await filter_data(request, query, search_filter, parse, total_records)
|
45
62
|
|
46
63
|
|
47
64
|
class UpdateDevicesModel(BaseModel):
|
48
65
|
devices: list[str]
|
49
66
|
firmware: str | None = None
|
50
67
|
name: str | None = None
|
68
|
+
pinned: bool | None = None
|
69
|
+
feed: str | None = None
|
70
|
+
flavor: str | None = None
|
51
71
|
|
52
72
|
|
53
73
|
@router.post(
|
54
74
|
"/update",
|
55
|
-
dependencies=[
|
56
|
-
Security(validate_user_permissions, scopes=[Permissions.DEVICE.WRITE])
|
57
|
-
],
|
75
|
+
dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.WRITE])],
|
58
76
|
)
|
59
|
-
async def devices_update(
|
77
|
+
async def devices_update(_: Request, config: UpdateDevicesModel) -> dict:
|
60
78
|
for uuid in config.devices:
|
61
79
|
updater = await get_update_manager(uuid)
|
62
|
-
device = await updater.get_device()
|
63
80
|
if config.firmware is not None:
|
64
|
-
|
81
|
+
if config.firmware == "rollout":
|
82
|
+
await updater.update_update(UpdateModeEnum.ROLLOUT, None)
|
83
|
+
elif config.firmware == "latest":
|
84
|
+
await updater.update_update(UpdateModeEnum.LATEST, None)
|
85
|
+
else:
|
86
|
+
firmware = await Firmware.get_or_none(id=config.firmware)
|
87
|
+
await updater.update_update(UpdateModeEnum.ASSIGNED, firmware)
|
88
|
+
if config.pinned is not None:
|
89
|
+
await updater.update_update(UpdateModeEnum.PINNED, None)
|
65
90
|
if config.name is not None:
|
66
|
-
|
67
|
-
|
91
|
+
await updater.update_name(config.name)
|
92
|
+
if config.feed is not None:
|
93
|
+
await updater.update_feed(config.feed)
|
94
|
+
if config.flavor is not None:
|
95
|
+
await updater.update_flavor(config.flavor)
|
68
96
|
return {"success": True}
|
69
97
|
|
70
98
|
|
@@ -74,14 +102,12 @@ class ForceUpdateModel(BaseModel):
|
|
74
102
|
|
75
103
|
@router.post(
|
76
104
|
"/force_update",
|
77
|
-
dependencies=[
|
78
|
-
Security(validate_user_permissions, scopes=[Permissions.DEVICE.WRITE])
|
79
|
-
],
|
105
|
+
dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.WRITE])],
|
80
106
|
)
|
81
|
-
async def devices_force_update(
|
107
|
+
async def devices_force_update(_: Request, config: ForceUpdateModel) -> dict:
|
82
108
|
for uuid in config.devices:
|
83
109
|
updater = await get_update_manager(uuid)
|
84
|
-
updater.
|
110
|
+
await updater.update_force_update(True)
|
85
111
|
return {"success": True}
|
86
112
|
|
87
113
|
|
@@ -89,8 +115,9 @@ async def devices_force_update(request: Request, config: ForceUpdateModel) -> di
|
|
89
115
|
"/logs/{dev_id}",
|
90
116
|
dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])],
|
91
117
|
)
|
92
|
-
async def device_logs(
|
93
|
-
|
118
|
+
async def device_logs(_: Request, dev_id: str) -> str:
|
119
|
+
updater = await get_update_manager(dev_id)
|
120
|
+
device = await updater.get_device()
|
94
121
|
if device.last_log is not None:
|
95
122
|
return device.last_log
|
96
123
|
return "No logs found."
|
@@ -102,11 +129,8 @@ class DeleteModel(BaseModel):
|
|
102
129
|
|
103
130
|
@router.post(
|
104
131
|
"/delete",
|
105
|
-
dependencies=[
|
106
|
-
Security(validate_user_permissions, scopes=[Permissions.DEVICE.DELETE])
|
107
|
-
],
|
132
|
+
dependencies=[Security(validate_user_permissions, scopes=[Permissions.DEVICE.DELETE])],
|
108
133
|
)
|
109
|
-
async def devices_delete(
|
110
|
-
|
111
|
-
await delete_device(uuid)
|
134
|
+
async def devices_delete(_: Request, config: DeleteModel) -> dict:
|
135
|
+
await delete_devices(config.devices)
|
112
136
|
return {"success": True}
|
goosebit/api/download.py
CHANGED
@@ -1,20 +1,34 @@
|
|
1
|
-
from fastapi import APIRouter,
|
1
|
+
from fastapi import APIRouter, HTTPException
|
2
2
|
from fastapi.requests import Request
|
3
|
-
from fastapi.responses import FileResponse
|
3
|
+
from fastapi.responses import FileResponse, RedirectResponse
|
4
|
+
from starlette.responses import Response
|
4
5
|
|
5
|
-
from goosebit.
|
6
|
-
from goosebit.permissions import Permissions
|
7
|
-
from goosebit.settings import UPDATES_DIR
|
6
|
+
from goosebit.models import Firmware
|
8
7
|
|
9
8
|
router = APIRouter(prefix="/download")
|
10
9
|
|
11
10
|
|
12
|
-
@router.
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
return
|
11
|
+
@router.head("/{file_id}")
|
12
|
+
async def download_file_head(_: Request, file_id: int):
|
13
|
+
firmware = await Firmware.get_or_none(id=file_id)
|
14
|
+
if firmware is None:
|
15
|
+
raise HTTPException(404)
|
16
|
+
|
17
|
+
response = Response()
|
18
|
+
response.headers["Content-Length"] = str(firmware.size)
|
19
|
+
return response
|
20
|
+
|
21
|
+
|
22
|
+
@router.get("/{file_id}")
|
23
|
+
async def download_file(_: Request, file_id: int):
|
24
|
+
firmware = await Firmware.get_or_none(id=file_id)
|
25
|
+
if firmware is None:
|
26
|
+
raise HTTPException(404)
|
27
|
+
if firmware.local:
|
28
|
+
return FileResponse(
|
29
|
+
firmware.path,
|
30
|
+
media_type="application/octet-stream",
|
31
|
+
filename=firmware.path.name,
|
32
|
+
)
|
33
|
+
else:
|
34
|
+
return RedirectResponse(url=firmware.uri)
|
goosebit/api/firmware.py
CHANGED
@@ -1,64 +1,57 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
1
3
|
from fastapi import APIRouter, Body, Security
|
2
4
|
from fastapi.requests import Request
|
5
|
+
from tortoise.expressions import Q
|
3
6
|
|
7
|
+
from goosebit.api.helper import filter_data
|
4
8
|
from goosebit.auth import validate_user_permissions
|
9
|
+
from goosebit.models import Firmware
|
5
10
|
from goosebit.permissions import Permissions
|
6
|
-
from goosebit.settings import UPDATES_DIR
|
7
|
-
from goosebit.updater.misc import fw_sort_key, get_newest_fw
|
8
|
-
from goosebit.updater.updates import FirmwareArtifact
|
9
11
|
|
10
12
|
router = APIRouter(prefix="/firmware")
|
11
13
|
|
12
14
|
|
13
15
|
@router.get(
|
14
16
|
"/all",
|
15
|
-
dependencies=[
|
16
|
-
Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.READ])
|
17
|
-
],
|
17
|
+
dependencies=[Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.READ])],
|
18
18
|
)
|
19
|
-
async def firmware_get_all(
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
for file in sorted(
|
24
|
-
[f for f in UPDATES_DIR.iterdir() if f.suffix == ".swu"],
|
25
|
-
key=lambda x: fw_sort_key(x),
|
26
|
-
reverse=True,
|
27
|
-
):
|
28
|
-
artifact = FirmwareArtifact(file.name)
|
29
|
-
firmware.append(
|
30
|
-
{
|
31
|
-
"name": file.name,
|
32
|
-
"size": artifact.path.stat().st_size,
|
33
|
-
"version": artifact.version,
|
34
|
-
}
|
35
|
-
)
|
36
|
-
|
37
|
-
return firmware
|
19
|
+
async def firmware_get_all(
|
20
|
+
request: Request,
|
21
|
+
) -> dict[str, int | list[dict[str, list[Any] | Any]]]:
|
22
|
+
query = Firmware.all()
|
38
23
|
|
24
|
+
def search_filter(search_value):
|
25
|
+
return Q(uri__icontains=search_value) | Q(version__icontains=search_value)
|
39
26
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
27
|
+
async def parse(f):
|
28
|
+
return {
|
29
|
+
"id": f.id,
|
30
|
+
"name": f.path.name,
|
31
|
+
"size": f.size,
|
32
|
+
"hash": f.hash,
|
33
|
+
"version": f.version,
|
34
|
+
"compatibility": list(await f.compatibility.all().values()),
|
35
|
+
}
|
48
36
|
|
49
|
-
|
50
|
-
return
|
37
|
+
total_records = await Firmware.all().count()
|
38
|
+
return await filter_data(request, query, search_filter, parse, total_records)
|
51
39
|
|
52
40
|
|
53
41
|
@router.post(
|
54
42
|
"/delete",
|
55
|
-
dependencies=[
|
56
|
-
Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.DELETE])
|
57
|
-
],
|
43
|
+
dependencies=[Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.DELETE])],
|
58
44
|
)
|
59
|
-
async def firmware_delete(
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
45
|
+
async def firmware_delete(_: Request, files: list[int] = Body()) -> dict:
|
46
|
+
success = False
|
47
|
+
for f_id in files:
|
48
|
+
firmware = await Firmware.get_or_none(id=f_id)
|
49
|
+
if firmware is None:
|
50
|
+
continue
|
51
|
+
if firmware.local:
|
52
|
+
path = firmware.path
|
53
|
+
if path.exists():
|
54
|
+
path.unlink()
|
55
|
+
await firmware.delete()
|
56
|
+
success = True
|
57
|
+
return {"success": success}
|
goosebit/api/helper.py
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
import asyncio
|
2
|
+
|
3
|
+
|
4
|
+
async def filter_data(request, query, search_filter, parse, total_records):
|
5
|
+
params = request.query_params
|
6
|
+
|
7
|
+
draw = int(params.get("draw", 1))
|
8
|
+
start = int(params.get("start", 0))
|
9
|
+
length = int(params.get("length", 10))
|
10
|
+
search_value = params.get("search[value]", None)
|
11
|
+
order_column_index = params.get("order[0][column]", None)
|
12
|
+
order_column = params.get(f"columns[{order_column_index}][data]", None)
|
13
|
+
order_dir = params.get("order[0][dir]", None)
|
14
|
+
|
15
|
+
if search_value:
|
16
|
+
query = query.filter(search_filter(search_value))
|
17
|
+
|
18
|
+
if order_column:
|
19
|
+
query = query.order_by(f"{"-" if order_dir == "desc" else ""}{order_column}")
|
20
|
+
|
21
|
+
filtered_records = await query.count()
|
22
|
+
rollouts = await query.offset(start).limit(length).all()
|
23
|
+
data = list(await asyncio.gather(*[parse(r) for r in rollouts]))
|
24
|
+
|
25
|
+
return {
|
26
|
+
"draw": draw,
|
27
|
+
"recordsTotal": total_records,
|
28
|
+
"recordsFiltered": filtered_records,
|
29
|
+
"data": data,
|
30
|
+
}
|
goosebit/api/rollouts.py
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
from fastapi import APIRouter, Security
|
2
|
+
from fastapi.requests import Request
|
3
|
+
from pydantic import BaseModel
|
4
|
+
from tortoise.expressions import Q
|
5
|
+
|
6
|
+
from goosebit.api.helper import filter_data
|
7
|
+
from goosebit.auth import validate_user_permissions
|
8
|
+
from goosebit.models import Rollout
|
9
|
+
from goosebit.permissions import Permissions
|
10
|
+
|
11
|
+
router = APIRouter(prefix="/rollouts")
|
12
|
+
|
13
|
+
|
14
|
+
@router.get(
|
15
|
+
"/all",
|
16
|
+
dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.READ])],
|
17
|
+
)
|
18
|
+
async def rollouts_get_all(request: Request) -> dict[str, int | list[dict]]:
|
19
|
+
query = Rollout.all().prefetch_related("firmware")
|
20
|
+
|
21
|
+
def search_filter(search_value):
|
22
|
+
return Q(name__icontains=search_value) | Q(feed__icontains=search_value) | Q(flavor__icontains=search_value)
|
23
|
+
|
24
|
+
async def parse(rollout: Rollout) -> dict:
|
25
|
+
return {
|
26
|
+
"id": rollout.id,
|
27
|
+
"created_at": int(rollout.created_at.timestamp() * 1000),
|
28
|
+
"name": rollout.name,
|
29
|
+
"feed": rollout.feed,
|
30
|
+
"flavor": rollout.flavor,
|
31
|
+
"fw_file": rollout.firmware.path.name,
|
32
|
+
"fw_version": rollout.firmware.version,
|
33
|
+
"paused": rollout.paused,
|
34
|
+
"success_count": rollout.success_count,
|
35
|
+
"failure_count": rollout.failure_count,
|
36
|
+
}
|
37
|
+
|
38
|
+
total_records = await Rollout.all().count()
|
39
|
+
return await filter_data(request, query, search_filter, parse, total_records)
|
40
|
+
|
41
|
+
|
42
|
+
class CreateRolloutsModel(BaseModel):
|
43
|
+
name: str
|
44
|
+
feed: str
|
45
|
+
flavor: str
|
46
|
+
firmware_id: int
|
47
|
+
|
48
|
+
|
49
|
+
@router.post(
|
50
|
+
"/",
|
51
|
+
dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.WRITE])],
|
52
|
+
)
|
53
|
+
async def rollouts_create(_: Request, rollout: CreateRolloutsModel) -> dict:
|
54
|
+
rollout = await Rollout.create(
|
55
|
+
name=rollout.name,
|
56
|
+
feed=rollout.feed,
|
57
|
+
flavor=rollout.flavor,
|
58
|
+
firmware_id=rollout.firmware_id,
|
59
|
+
)
|
60
|
+
return {"success": True, "id": rollout.id}
|
61
|
+
|
62
|
+
|
63
|
+
class UpdateRolloutsModel(BaseModel):
|
64
|
+
ids: list[int]
|
65
|
+
paused: bool
|
66
|
+
|
67
|
+
|
68
|
+
@router.post(
|
69
|
+
"/update",
|
70
|
+
dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.WRITE])],
|
71
|
+
)
|
72
|
+
async def rollouts_update(_: Request, rollouts: UpdateRolloutsModel) -> dict:
|
73
|
+
await Rollout.filter(id__in=rollouts.ids).update(paused=rollouts.paused)
|
74
|
+
return {"success": True}
|
75
|
+
|
76
|
+
|
77
|
+
class DeleteRolloutsModel(BaseModel):
|
78
|
+
ids: list[int]
|
79
|
+
|
80
|
+
|
81
|
+
@router.post(
|
82
|
+
"/delete",
|
83
|
+
dependencies=[Security(validate_user_permissions, scopes=[Permissions.ROLLOUT.DELETE])],
|
84
|
+
)
|
85
|
+
async def rollouts_delete(_: Request, rollouts: DeleteRolloutsModel) -> dict:
|
86
|
+
await Rollout.filter(id__in=rollouts.ids).delete()
|
87
|
+
return {"success": True}
|
goosebit/api/routes.py
CHANGED
@@ -1,11 +1,19 @@
|
|
1
1
|
from fastapi import APIRouter, Depends
|
2
2
|
|
3
|
-
from goosebit.api import devices, download, firmware
|
3
|
+
from goosebit.api import devices, download, firmware, rollouts
|
4
4
|
from goosebit.auth import authenticate_api_session
|
5
5
|
|
6
|
-
router
|
7
|
-
|
8
|
-
)
|
9
|
-
|
10
|
-
|
11
|
-
|
6
|
+
# main router that requires authentication
|
7
|
+
main_router = APIRouter(prefix="/api", dependencies=[Depends(authenticate_api_session)], tags=["api"])
|
8
|
+
main_router.include_router(firmware.router)
|
9
|
+
main_router.include_router(devices.router)
|
10
|
+
main_router.include_router(rollouts.router)
|
11
|
+
|
12
|
+
# download router without authentication
|
13
|
+
download_router = APIRouter(prefix="/api", tags=["api"])
|
14
|
+
download_router.include_router(download.router)
|
15
|
+
|
16
|
+
# include both routers
|
17
|
+
router = APIRouter()
|
18
|
+
router.include_router(main_router)
|
19
|
+
router.include_router(download_router)
|
goosebit/auth/__init__.py
CHANGED
@@ -1,13 +1,16 @@
|
|
1
|
-
|
1
|
+
import logging
|
2
2
|
|
3
|
+
from argon2.exceptions import VerifyMismatchError
|
3
4
|
from fastapi import Depends, HTTPException
|
4
5
|
from fastapi.requests import Request
|
5
6
|
from fastapi.security import SecurityScopes
|
6
7
|
from fastapi.websockets import WebSocket
|
7
|
-
from
|
8
|
+
from joserfc import jwt
|
9
|
+
from joserfc.errors import BadSignatureError
|
8
10
|
|
9
11
|
from goosebit.settings import PWD_CXT, SECRET, USERS
|
10
12
|
|
13
|
+
logger = logging.getLogger(__name__)
|
11
14
|
|
12
15
|
async def authenticate_user(request: Request):
|
13
16
|
form_data = await request.form()
|
@@ -20,7 +23,14 @@ async def authenticate_user(request: Request):
|
|
20
23
|
headers={"location": str(request.url_for("login"))},
|
21
24
|
detail="Invalid credentials",
|
22
25
|
)
|
23
|
-
|
26
|
+
try:
|
27
|
+
if not PWD_CXT.verify(user.hashed_pwd, password):
|
28
|
+
raise HTTPException(
|
29
|
+
status_code=302,
|
30
|
+
headers={"location": str(request.url_for("login"))},
|
31
|
+
detail="Invalid credentials",
|
32
|
+
)
|
33
|
+
except VerifyMismatchError:
|
24
34
|
raise HTTPException(
|
25
35
|
status_code=302,
|
26
36
|
headers={"location": str(request.url_for("login"))},
|
@@ -29,46 +39,47 @@ async def authenticate_user(request: Request):
|
|
29
39
|
return user
|
30
40
|
|
31
41
|
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
def create_session(email: str) -> str:
|
36
|
-
token = jwt.encode({"email": email}, SECRET)
|
37
|
-
sessions[token] = email
|
38
|
-
return token
|
42
|
+
def create_session(username: str) -> str:
|
43
|
+
return jwt.encode(header={"alg": "HS256"}, claims={"username": username}, key=SECRET)
|
39
44
|
|
40
45
|
|
41
46
|
def authenticate_session(request: Request):
|
42
47
|
session_id = request.cookies.get("session_id")
|
43
|
-
if session_id is None
|
48
|
+
if session_id is None:
|
44
49
|
raise HTTPException(
|
45
50
|
status_code=302,
|
46
51
|
headers={"location": str(request.url_for("login"))},
|
47
52
|
detail="Invalid session ID",
|
48
53
|
)
|
49
54
|
user = get_user_from_session(session_id)
|
55
|
+
if user is None:
|
56
|
+
raise HTTPException(
|
57
|
+
status_code=302,
|
58
|
+
headers={"location": str(request.url_for("login"))},
|
59
|
+
detail="Invalid username",
|
60
|
+
)
|
50
61
|
return user
|
51
62
|
|
52
63
|
|
53
64
|
def authenticate_api_session(request: Request):
|
54
65
|
session_id = request.cookies.get("session_id")
|
55
|
-
if session_id is None or session_id not in sessions:
|
56
|
-
raise HTTPException(status_code=401, detail="Not logged in")
|
57
66
|
user = get_user_from_session(session_id)
|
67
|
+
if user is None:
|
68
|
+
raise HTTPException(status_code=401, detail="Not logged in")
|
58
69
|
return user
|
59
70
|
|
60
71
|
|
61
72
|
def authenticate_ws_session(websocket: WebSocket):
|
62
73
|
session_id = websocket.cookies.get("session_id")
|
63
|
-
if session_id is None or session_id not in sessions:
|
64
|
-
raise HTTPException(status_code=401, detail="Not logged in")
|
65
74
|
user = get_user_from_session(session_id)
|
75
|
+
if user is None:
|
76
|
+
raise HTTPException(status_code=401, detail="Not logged in")
|
66
77
|
return user
|
67
78
|
|
68
79
|
|
69
80
|
def auto_redirect(request: Request):
|
70
81
|
session_id = request.cookies.get("session_id")
|
71
|
-
if session_id is None
|
82
|
+
if get_user_from_session(session_id) is None:
|
72
83
|
return request
|
73
84
|
raise HTTPException(
|
74
85
|
status_code=302,
|
@@ -78,16 +89,20 @@ def auto_redirect(request: Request):
|
|
78
89
|
|
79
90
|
|
80
91
|
def get_user_from_session(session_id: str):
|
81
|
-
|
82
|
-
|
83
|
-
|
92
|
+
if session_id is None:
|
93
|
+
return
|
94
|
+
try:
|
95
|
+
session_data = jwt.decode(session_id, SECRET)
|
96
|
+
return session_data.claims["username"]
|
97
|
+
except (BadSignatureError, LookupError, ValueError):
|
98
|
+
pass
|
84
99
|
|
85
100
|
|
86
101
|
def get_current_user(request: Request):
|
87
102
|
session_id = request.cookies.get("session_id")
|
88
|
-
if session_id is None or session_id not in sessions:
|
89
|
-
return None
|
90
103
|
user = get_user_from_session(session_id)
|
104
|
+
if user is None:
|
105
|
+
return None
|
91
106
|
return USERS[user]
|
92
107
|
|
93
108
|
|
@@ -101,6 +116,7 @@ def validate_user_permissions(
|
|
101
116
|
return request
|
102
117
|
for scope in security.scopes:
|
103
118
|
if scope not in user.permissions:
|
119
|
+
logger.warning(f"User {username} does not have permission {scope}")
|
104
120
|
raise HTTPException(
|
105
121
|
status_code=403,
|
106
122
|
detail="Not enough permissions",
|
goosebit/db.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
from aerich import Command
|
2
2
|
from tortoise import Tortoise, run_async
|
3
3
|
|
4
|
+
from goosebit.models import Firmware
|
4
5
|
from goosebit.settings import DB_MIGRATIONS_LOC, DB_URI
|
5
6
|
|
6
7
|
TORTOISE_CONF = {
|
@@ -22,6 +23,10 @@ async def init():
|
|
22
23
|
await command.migrate()
|
23
24
|
await command.upgrade(run_in_transaction=True)
|
24
25
|
await Tortoise.generate_schemas(safe=True)
|
26
|
+
for firmware in await Firmware.all():
|
27
|
+
if firmware.local and not firmware.path.exists():
|
28
|
+
# delete it
|
29
|
+
await firmware.delete()
|
25
30
|
|
26
31
|
|
27
32
|
async def close():
|