goosebit 0.1.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 (48) hide show
  1. goosebit/__init__.py +62 -0
  2. goosebit/api/__init__.py +1 -0
  3. goosebit/api/devices.py +112 -0
  4. goosebit/api/download.py +20 -0
  5. goosebit/api/firmware.py +64 -0
  6. goosebit/api/routes.py +11 -0
  7. goosebit/auth/__init__.py +123 -0
  8. goosebit/db.py +32 -0
  9. goosebit/models.py +21 -0
  10. goosebit/permissions.py +55 -0
  11. goosebit/realtime/__init__.py +1 -0
  12. goosebit/realtime/logs.py +43 -0
  13. goosebit/realtime/routes.py +13 -0
  14. goosebit/settings.py +55 -0
  15. goosebit/ui/__init__.py +1 -0
  16. goosebit/ui/routes.py +104 -0
  17. goosebit/ui/static/__init__.py +5 -0
  18. goosebit/ui/static/favicon.ico +0 -0
  19. goosebit/ui/static/favicon.svg +1 -0
  20. goosebit/ui/static/js/devices.js +370 -0
  21. goosebit/ui/static/js/firmware.js +131 -0
  22. goosebit/ui/static/js/index.js +161 -0
  23. goosebit/ui/static/js/logs.js +18 -0
  24. goosebit/ui/static/svg/goosebit-logo.svg +1 -0
  25. goosebit/ui/templates/__init__.py +5 -0
  26. goosebit/ui/templates/devices.html +82 -0
  27. goosebit/ui/templates/firmware.html +47 -0
  28. goosebit/ui/templates/index.html +37 -0
  29. goosebit/ui/templates/login.html +34 -0
  30. goosebit/ui/templates/logs.html +21 -0
  31. goosebit/ui/templates/nav.html +64 -0
  32. goosebit/updater/__init__.py +1 -0
  33. goosebit/updater/controller/__init__.py +1 -0
  34. goosebit/updater/controller/routes.py +6 -0
  35. goosebit/updater/controller/v1/__init__.py +1 -0
  36. goosebit/updater/controller/v1/routes.py +92 -0
  37. goosebit/updater/download/__init__.py +1 -0
  38. goosebit/updater/download/routes.py +6 -0
  39. goosebit/updater/download/v1/__init__.py +1 -0
  40. goosebit/updater/download/v1/routes.py +26 -0
  41. goosebit/updater/manager.py +206 -0
  42. goosebit/updater/misc.py +69 -0
  43. goosebit/updater/routes.py +30 -0
  44. goosebit/updater/updates.py +93 -0
  45. goosebit-0.1.0.dist-info/LICENSE +201 -0
  46. goosebit-0.1.0.dist-info/METADATA +37 -0
  47. goosebit-0.1.0.dist-info/RECORD +48 -0
  48. goosebit-0.1.0.dist-info/WHEEL +4 -0
goosebit/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ from contextlib import asynccontextmanager
2
+ from typing import Annotated
3
+
4
+ from fastapi import Depends, FastAPI
5
+ from fastapi.requests import Request
6
+ from fastapi.responses import RedirectResponse
7
+ from fastapi.security import OAuth2PasswordRequestForm
8
+
9
+ from goosebit import api, db, realtime, ui, updater
10
+ from goosebit.auth import (
11
+ authenticate_user,
12
+ auto_redirect,
13
+ create_session,
14
+ get_current_user,
15
+ )
16
+ from goosebit.ui.static import static
17
+ from goosebit.ui.templates import templates
18
+
19
+
20
+ @asynccontextmanager
21
+ async def lifespan(_: FastAPI):
22
+ await db.init()
23
+ yield
24
+ await db.close()
25
+
26
+
27
+ app = FastAPI(lifespan=lifespan)
28
+ app.include_router(updater.router)
29
+ app.include_router(ui.router)
30
+ app.include_router(api.router)
31
+ app.include_router(realtime.router)
32
+ app.mount("/static", static, name="static")
33
+
34
+
35
+ @app.middleware("http")
36
+ async def attach_user(request: Request, call_next):
37
+ request.scope["user"] = get_current_user(request)
38
+ return await call_next(request)
39
+
40
+
41
+ @app.get("/", include_in_schema=False)
42
+ def root_redirect(request: Request):
43
+ return RedirectResponse(request.url_for("ui_root"))
44
+
45
+
46
+ @app.get("/login", dependencies=[Depends(auto_redirect)], include_in_schema=False)
47
+ async def login_ui(request: Request):
48
+ return templates.TemplateResponse("login.html", context={"request": request})
49
+
50
+
51
+ @app.post("/login", include_in_schema=False, dependencies=[Depends(authenticate_user)])
52
+ async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
53
+ resp = RedirectResponse("/ui/home", status_code=302)
54
+ resp.set_cookie(key="session_id", value=create_session(form_data.username))
55
+ return resp
56
+
57
+
58
+ @app.get("/logout", include_in_schema=False)
59
+ async def logout(request: Request):
60
+ resp = RedirectResponse("/login", status_code=302)
61
+ resp.delete_cookie(key="session_id")
62
+ return resp
@@ -0,0 +1 @@
1
+ from .routes import router
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+
6
+ from fastapi import APIRouter, Security
7
+ from fastapi.requests import Request
8
+ from pydantic import BaseModel
9
+
10
+ from goosebit.auth import validate_user_permissions
11
+ from goosebit.models import Device
12
+ from goosebit.permissions import Permissions
13
+ from goosebit.updater.manager import delete_device, get_update_manager
14
+ from goosebit.updater.misc import get_device_by_uuid
15
+
16
+ router = APIRouter(prefix="/devices")
17
+
18
+
19
+ @router.get(
20
+ "/all",
21
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])],
22
+ )
23
+ async def devices_get_all() -> list[dict]:
24
+ devices = await Device.all()
25
+
26
+ async def parse(device: Device) -> dict:
27
+ manager = await get_update_manager(device.uuid)
28
+ last_seen = device.last_seen
29
+ if last_seen is not None:
30
+ last_seen = round(time.time() - device.last_seen)
31
+ return {
32
+ "uuid": device.uuid,
33
+ "web_pwd": device.web_pwd,
34
+ "name": device.name,
35
+ "fw": device.fw_version,
36
+ "fw_file": device.fw_file,
37
+ "state": device.last_state,
38
+ "force_update": manager.force_update,
39
+ "last_ip": device.last_ip,
40
+ "last_seen": last_seen,
41
+ "online": last_seen < 120 if last_seen is not None else None,
42
+ }
43
+
44
+ return list(await asyncio.gather(*[parse(d) for d in devices]))
45
+
46
+
47
+ class UpdateDevicesModel(BaseModel):
48
+ devices: list[str]
49
+ firmware: str | None = None
50
+ name: str | None = None
51
+
52
+
53
+ @router.post(
54
+ "/update",
55
+ dependencies=[
56
+ Security(validate_user_permissions, scopes=[Permissions.DEVICE.WRITE])
57
+ ],
58
+ )
59
+ async def devices_update(request: Request, config: UpdateDevicesModel) -> dict:
60
+ for uuid in config.devices:
61
+ updater = await get_update_manager(uuid)
62
+ device = await updater.get_device()
63
+ if config.firmware is not None:
64
+ device.fw_file = config.firmware
65
+ if config.name is not None:
66
+ device.name = config.name
67
+ await updater.save()
68
+ return {"success": True}
69
+
70
+
71
+ class ForceUpdateModel(BaseModel):
72
+ devices: list[str]
73
+
74
+
75
+ @router.post(
76
+ "/force_update",
77
+ dependencies=[
78
+ Security(validate_user_permissions, scopes=[Permissions.DEVICE.WRITE])
79
+ ],
80
+ )
81
+ async def devices_force_update(request: Request, config: ForceUpdateModel) -> dict:
82
+ for uuid in config.devices:
83
+ updater = await get_update_manager(uuid)
84
+ updater.force_update = True
85
+ return {"success": True}
86
+
87
+
88
+ @router.get(
89
+ "/logs/{dev_id}",
90
+ dependencies=[Security(validate_user_permissions, scopes=[Permissions.HOME.READ])],
91
+ )
92
+ async def device_logs(request: Request, dev_id: str) -> str:
93
+ device = await get_device_by_uuid(dev_id)
94
+ if device.last_log is not None:
95
+ return device.last_log
96
+ return "No logs found."
97
+
98
+
99
+ class DeleteModel(BaseModel):
100
+ devices: list[str]
101
+
102
+
103
+ @router.post(
104
+ "/delete",
105
+ dependencies=[
106
+ Security(validate_user_permissions, scopes=[Permissions.DEVICE.DELETE])
107
+ ],
108
+ )
109
+ async def devices_delete(request: Request, config: DeleteModel) -> dict:
110
+ for uuid in config.devices:
111
+ await delete_device(uuid)
112
+ return {"success": True}
@@ -0,0 +1,20 @@
1
+ from fastapi import APIRouter, Security
2
+ from fastapi.requests import Request
3
+ from fastapi.responses import FileResponse
4
+
5
+ from goosebit.auth import validate_user_permissions
6
+ from goosebit.permissions import Permissions
7
+ from goosebit.settings import UPDATES_DIR
8
+
9
+ router = APIRouter(prefix="/download")
10
+
11
+
12
+ @router.get(
13
+ "/{file}",
14
+ dependencies=[
15
+ Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.READ])
16
+ ],
17
+ )
18
+ async def download_file(request: Request, file: str):
19
+ filename = UPDATES_DIR.joinpath(file)
20
+ return FileResponse(filename, media_type="application/octet-stream")
@@ -0,0 +1,64 @@
1
+ from fastapi import APIRouter, Body, Security
2
+ from fastapi.requests import Request
3
+
4
+ from goosebit.auth import validate_user_permissions
5
+ 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
+
10
+ router = APIRouter(prefix="/firmware")
11
+
12
+
13
+ @router.get(
14
+ "/all",
15
+ dependencies=[
16
+ Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.READ])
17
+ ],
18
+ )
19
+ async def firmware_get_all() -> list[dict]:
20
+ UPDATES_DIR.mkdir(parents=True, exist_ok=True)
21
+
22
+ firmware = []
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
38
+
39
+
40
+ @router.get(
41
+ "/latest",
42
+ dependencies=[
43
+ Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.READ])
44
+ ],
45
+ )
46
+ async def firmware_get_latest() -> dict:
47
+ UPDATES_DIR.mkdir(parents=True, exist_ok=True)
48
+
49
+ file_data = UPDATES_DIR.joinpath(get_newest_fw())
50
+ return {"name": file_data.name, "size": file_data.stat().st_size}
51
+
52
+
53
+ @router.post(
54
+ "/delete",
55
+ dependencies=[
56
+ Security(validate_user_permissions, scopes=[Permissions.FIRMWARE.DELETE])
57
+ ],
58
+ )
59
+ async def firmware_delete(request: Request, file: str = Body()) -> dict:
60
+ file_path = UPDATES_DIR.joinpath(file)
61
+ if file_path.exists():
62
+ file_path.unlink()
63
+ return {"success": True}
64
+ return {"success": False}
goosebit/api/routes.py ADDED
@@ -0,0 +1,11 @@
1
+ from fastapi import APIRouter, Depends
2
+
3
+ from goosebit.api import devices, download, firmware
4
+ from goosebit.auth import authenticate_api_session
5
+
6
+ router = APIRouter(
7
+ prefix="/api", dependencies=[Depends(authenticate_api_session)], tags=["api"]
8
+ )
9
+ router.include_router(firmware.router)
10
+ router.include_router(devices.router)
11
+ router.include_router(download.router)
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import Depends, HTTPException
4
+ from fastapi.requests import Request
5
+ from fastapi.security import SecurityScopes
6
+ from fastapi.websockets import WebSocket
7
+ from jose import jwt
8
+
9
+ from goosebit.settings import PWD_CXT, SECRET, USERS
10
+
11
+
12
+ async def authenticate_user(request: Request):
13
+ form_data = await request.form()
14
+ username = form_data.get("username")
15
+ password = form_data.get("password")
16
+ user = USERS.get(username)
17
+ if user is None:
18
+ raise HTTPException(
19
+ status_code=302,
20
+ headers={"location": str(request.url_for("login"))},
21
+ detail="Invalid credentials",
22
+ )
23
+ if not PWD_CXT.verify(password, user.hashed_pwd):
24
+ raise HTTPException(
25
+ status_code=302,
26
+ headers={"location": str(request.url_for("login"))},
27
+ detail="Invalid credentials",
28
+ )
29
+ return user
30
+
31
+
32
+ sessions = {}
33
+
34
+
35
+ def create_session(email: str) -> str:
36
+ token = jwt.encode({"email": email}, SECRET)
37
+ sessions[token] = email
38
+ return token
39
+
40
+
41
+ def authenticate_session(request: Request):
42
+ session_id = request.cookies.get("session_id")
43
+ if session_id is None or session_id not in sessions:
44
+ raise HTTPException(
45
+ status_code=302,
46
+ headers={"location": str(request.url_for("login"))},
47
+ detail="Invalid session ID",
48
+ )
49
+ user = get_user_from_session(session_id)
50
+ return user
51
+
52
+
53
+ def authenticate_api_session(request: Request):
54
+ 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
+ user = get_user_from_session(session_id)
58
+ return user
59
+
60
+
61
+ def authenticate_ws_session(websocket: WebSocket):
62
+ 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
+ user = get_user_from_session(session_id)
66
+ return user
67
+
68
+
69
+ def auto_redirect(request: Request):
70
+ session_id = request.cookies.get("session_id")
71
+ if session_id is None or session_id not in sessions:
72
+ return request
73
+ raise HTTPException(
74
+ status_code=302,
75
+ headers={"location": str(request.url_for("ui_root"))},
76
+ detail="Already logged in",
77
+ )
78
+
79
+
80
+ def get_user_from_session(session_id: str):
81
+ for username in USERS:
82
+ if username == sessions.get(session_id):
83
+ return username
84
+
85
+
86
+ def get_current_user(request: Request):
87
+ session_id = request.cookies.get("session_id")
88
+ if session_id is None or session_id not in sessions:
89
+ return None
90
+ user = get_user_from_session(session_id)
91
+ return USERS[user]
92
+
93
+
94
+ def validate_user_permissions(
95
+ request: Request,
96
+ security: SecurityScopes,
97
+ username: str = Depends(authenticate_session),
98
+ ) -> Request:
99
+ user = USERS[username]
100
+ if security.scopes is None:
101
+ return request
102
+ for scope in security.scopes:
103
+ if scope not in user.permissions:
104
+ raise HTTPException(
105
+ status_code=403,
106
+ detail="Not enough permissions",
107
+ )
108
+
109
+
110
+ def validate_ws_user_permissions(
111
+ websocket: WebSocket,
112
+ security: SecurityScopes,
113
+ username: str = Depends(authenticate_ws_session),
114
+ ) -> WebSocket:
115
+ user = USERS[username]
116
+ if security.scopes is None:
117
+ return websocket
118
+ for scope in security.scopes:
119
+ if scope not in user.permissions:
120
+ raise HTTPException(
121
+ status_code=403,
122
+ detail="Not enough permissions",
123
+ )
goosebit/db.py ADDED
@@ -0,0 +1,32 @@
1
+ from aerich import Command
2
+ from tortoise import Tortoise, run_async
3
+
4
+ from goosebit.settings import DB_MIGRATIONS_LOC, DB_URI
5
+
6
+ TORTOISE_CONF = {
7
+ "connections": {"default": DB_URI},
8
+ "apps": {
9
+ "models": {
10
+ "models": ["goosebit.models", "aerich.models"],
11
+ },
12
+ },
13
+ }
14
+
15
+
16
+ async def init():
17
+ command = Command(tortoise_config=TORTOISE_CONF, location=DB_MIGRATIONS_LOC)
18
+ await Tortoise.init(config=TORTOISE_CONF)
19
+ if not DB_MIGRATIONS_LOC.exists():
20
+ await command.init_db(safe=True)
21
+ await command.init()
22
+ await command.migrate()
23
+ await command.upgrade(run_in_transaction=True)
24
+ await Tortoise.generate_schemas(safe=True)
25
+
26
+
27
+ async def close():
28
+ await Tortoise.close_connections()
29
+
30
+
31
+ if __name__ == "__main__":
32
+ run_async(init())
goosebit/models.py ADDED
@@ -0,0 +1,21 @@
1
+ from tortoise import Model, fields
2
+
3
+
4
+ class Tag(Model):
5
+ id = fields.IntField(pk=True)
6
+ name = fields.CharField(max_length=255)
7
+
8
+
9
+ class Device(Model):
10
+ uuid = fields.CharField(max_length=255, pk=True)
11
+ name = fields.CharField(max_length=255, null=True)
12
+ fw_file = fields.CharField(max_length=255, default="latest")
13
+ fw_version = fields.CharField(max_length=255, null=True)
14
+ last_state = fields.CharField(max_length=255, null=True)
15
+ last_log = fields.TextField(null=True)
16
+ last_seen = fields.BigIntField(null=True)
17
+ last_ip = fields.CharField(max_length=15, null=True)
18
+ last_ipv6 = fields.CharField(max_length=40, null=True)
19
+ tags = fields.ManyToManyField(
20
+ "models.Tag", related_name="devices", through="device_tags"
21
+ )
@@ -0,0 +1,55 @@
1
+ from enum import Enum
2
+
3
+
4
+ class PermissionsBase(str, Enum):
5
+ @classmethod
6
+ def full(cls) -> list:
7
+ return [i for i in cls]
8
+
9
+ def __str__(self):
10
+ return self.value
11
+
12
+
13
+ class FirmwarePermissions(PermissionsBase):
14
+ READ = "firmware.read"
15
+ WRITE = "firmware.write"
16
+ DELETE = "firmware.delete"
17
+
18
+
19
+ class DevicePermissions(PermissionsBase):
20
+ READ = "devices.read"
21
+ WRITE = "devices.write"
22
+ DELETE = "devices.delete"
23
+
24
+
25
+ class TunnelPermissions(PermissionsBase):
26
+ READ = "tunnels.read"
27
+ WRITE = "tunnels.write"
28
+ DELETE = "tunnels.delete"
29
+
30
+
31
+ class HomePermissions(PermissionsBase):
32
+ READ = "home.read"
33
+
34
+
35
+ class Permissions:
36
+ HOME = HomePermissions
37
+ FIRMWARE = FirmwarePermissions
38
+ DEVICE = DevicePermissions
39
+ TUNNEL = TunnelPermissions
40
+
41
+ @classmethod
42
+ def full(cls):
43
+ all_items = set()
44
+ for item in [cls.HOME, cls.FIRMWARE, cls.DEVICE, cls.TUNNEL]:
45
+ all_items.update(item.full())
46
+ return list(all_items)
47
+
48
+
49
+ ADMIN = Permissions.full()
50
+ MONITORING = [
51
+ *Permissions.HOME.full(),
52
+ *Permissions.FIRMWARE.full(),
53
+ *Permissions.DEVICE.full(),
54
+ ]
55
+ READONLY = [Permissions.HOME.READ]
@@ -0,0 +1 @@
1
+ from .routes import router
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Security
4
+ from fastapi.websockets import WebSocket, WebSocketDisconnect
5
+ from pydantic import BaseModel
6
+ from websockets.exceptions import ConnectionClosed
7
+
8
+ from goosebit.auth import validate_ws_user_permissions
9
+ from goosebit.permissions import Permissions
10
+ from goosebit.updater.manager import get_update_manager
11
+
12
+ router = APIRouter(prefix="/logs")
13
+
14
+
15
+ class RealtimeLogModel(BaseModel):
16
+ log: str | None
17
+ clear: bool = False
18
+
19
+
20
+ @router.websocket(
21
+ "/{dev_id}",
22
+ dependencies=[
23
+ Security(validate_ws_user_permissions, scopes=[Permissions.HOME.READ])
24
+ ],
25
+ )
26
+ async def device_logs(websocket: WebSocket, dev_id: str):
27
+ await websocket.accept()
28
+
29
+ manager = await get_update_manager(dev_id)
30
+
31
+ async def callback(log_update):
32
+ data = RealtimeLogModel(log=log_update)
33
+ if log_update is None:
34
+ data.clear = True
35
+ data.log = ""
36
+ await websocket.send_json(dict(data))
37
+
38
+ async with manager.subscribe_log(callback):
39
+ try:
40
+ while True:
41
+ await websocket.receive()
42
+ except (WebSocketDisconnect, ConnectionClosed, RuntimeError):
43
+ pass
@@ -0,0 +1,13 @@
1
+ from fastapi import APIRouter, Depends
2
+
3
+ from goosebit.auth import authenticate_ws_session
4
+
5
+ from . import logs
6
+
7
+ router = APIRouter(
8
+ prefix="/realtime",
9
+ dependencies=[Depends(authenticate_ws_session)],
10
+ tags=["realtime"],
11
+ )
12
+
13
+ router.include_router(logs.router)
goosebit/settings.py ADDED
@@ -0,0 +1,55 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+ from passlib.context import CryptContext
5
+
6
+ from goosebit import permissions
7
+
8
+ POLL_SECONDS = 0
9
+ POLL_MINUTES = 1
10
+ POLL_HOURS = 0
11
+ POLL_TIME = ":".join(
12
+ [
13
+ str(POLL_HOURS).rjust(2, "0"),
14
+ str(POLL_MINUTES).rjust(2, "0"),
15
+ str(POLL_SECONDS).rjust(2, "0"),
16
+ ]
17
+ )
18
+
19
+ BASE_DIR = Path(__file__).resolve().parent
20
+ TOKEN_SWU_DIR = BASE_DIR.joinpath("swugen")
21
+ SWUPDATE_FILES_DIR = BASE_DIR.joinpath("swupdate")
22
+ UPDATES_DIR = BASE_DIR.joinpath("updates")
23
+ DB_LOC = BASE_DIR.joinpath("db.sqlite3")
24
+ DB_MIGRATIONS_LOC = BASE_DIR.joinpath("migrations")
25
+ DB_URI = f"sqlite:///{DB_LOC}"
26
+
27
+ SECRET = "123456789"
28
+ PWD_CXT = CryptContext(schemes=["bcrypt"], deprecated="auto")
29
+
30
+ users = {}
31
+
32
+
33
+ @dataclass
34
+ class User:
35
+ username: str
36
+ hashed_pwd: str
37
+ permissions: list
38
+
39
+ def get_json_permissions(self):
40
+ return [str(p) for p in self.permissions]
41
+
42
+
43
+ def add_user(user: User):
44
+ users[user.username] = user
45
+
46
+
47
+ # User configuration
48
+ add_user(
49
+ User(
50
+ username="admin@goosebit.local",
51
+ hashed_pwd=PWD_CXT.hash("admin"),
52
+ permissions=permissions.ADMIN,
53
+ )
54
+ )
55
+ USERS = users
@@ -0,0 +1 @@
1
+ from .routes import router