supernote 0.6.0__tar.gz → 0.6.2__tar.gz
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.
- {supernote-0.6.0 → supernote-0.6.2}/PKG-INFO +1 -1
- {supernote-0.6.0 → supernote-0.6.2}/pyproject.toml +1 -1
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/app.py +17 -17
- supernote-0.6.2/supernote/server/config.py +90 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/services/file.py +3 -3
- supernote-0.6.2/supernote/server/services/state.py +107 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/services/storage.py +17 -13
- supernote-0.6.2/supernote/server/services/user.py +156 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/PKG-INFO +1 -1
- {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/SOURCES.txt +1 -0
- supernote-0.6.0/supernote/server/config.py +0 -159
- supernote-0.6.0/supernote/server/services/user.py +0 -225
- {supernote-0.6.0 → supernote-0.6.2}/LICENSE +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/README.md +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/setup.cfg +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/__init__.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/cli/__init__.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/cli/client.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/cli/main.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/cli/notebook.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/cli/server.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/client/__init__.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/client/api_model.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/client/auth.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/client/client.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/client/cloud_client.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/client/exceptions.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/client/login_client.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/__init__.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/color.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/converter.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/decoder.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/exceptions.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/fileformat.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/manipulator.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/parser.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/utils.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/py.typed +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/__init__.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/models/__init__.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/models/auth.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/models/base.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/models/file.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/routes/auth.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/routes/decorators.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/routes/file.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/routes/system.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote/server/services/__init__.py +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/dependency_links.txt +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/entry_points.txt +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/requires.txt +0 -0
- {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: supernote
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: All-in-one toolkit for Supernote devices: parse notebooks, self-host services, access services
|
|
5
5
|
Author-email: jya <jya@wizmy.net>, Allen Porter <allen.porter@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -4,7 +4,7 @@ requires = ["setuptools>=77.0"]
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "supernote"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.2"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
license-files = ["LICENSE"]
|
|
10
10
|
description = "All-in-one toolkit for Supernote devices: parse notebooks, self-host services, access services"
|
|
@@ -6,12 +6,11 @@ from typing import Any, Awaitable, Callable
|
|
|
6
6
|
|
|
7
7
|
from aiohttp import web
|
|
8
8
|
|
|
9
|
-
from supernote.server.services.user import JWT_ALGORITHM
|
|
10
|
-
|
|
11
9
|
from .config import ServerConfig
|
|
12
10
|
from .models.base import create_error_response
|
|
13
11
|
from .routes import auth, file, system
|
|
14
12
|
from .services.file import FileService
|
|
13
|
+
from .services.state import StateService
|
|
15
14
|
from .services.storage import StorageService
|
|
16
15
|
from .services.user import UserService
|
|
17
16
|
|
|
@@ -87,29 +86,27 @@ async def jwt_auth_middleware(
|
|
|
87
86
|
if handler_func and getattr(handler_func, "is_public", False):
|
|
88
87
|
return await handler(request)
|
|
89
88
|
|
|
90
|
-
# Check for x-access-token header
|
|
89
|
+
# Check for x-access-token header
|
|
91
90
|
if not (token := request.headers.get("x-access-token")):
|
|
92
91
|
return web.json_response(
|
|
93
92
|
create_error_response("Unauthorized").to_dict(), status=401
|
|
94
93
|
)
|
|
95
94
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
try:
|
|
101
|
-
payload = jwt.decode(
|
|
102
|
-
token, server_config.auth.secret_key, algorithms=[JWT_ALGORITHM]
|
|
103
|
-
)
|
|
104
|
-
except jwt.InvalidTokenError:
|
|
95
|
+
user_service: UserService = request.app["user_service"]
|
|
96
|
+
user = user_service.verify_token(token)
|
|
97
|
+
if not user:
|
|
105
98
|
return web.json_response(
|
|
106
99
|
create_error_response("Invalid token").to_dict(), status=401
|
|
107
100
|
)
|
|
108
|
-
|
|
101
|
+
|
|
102
|
+
request["user"] = user
|
|
109
103
|
return await handler(request)
|
|
110
104
|
|
|
111
105
|
|
|
112
|
-
def create_app(
|
|
106
|
+
def create_app(
|
|
107
|
+
config: ServerConfig | None = None,
|
|
108
|
+
state_service: StateService | None = None,
|
|
109
|
+
) -> web.Application:
|
|
113
110
|
if config is None:
|
|
114
111
|
config = ServerConfig.load()
|
|
115
112
|
|
|
@@ -118,10 +115,13 @@ def create_app(config: ServerConfig | None = None) -> web.Application:
|
|
|
118
115
|
|
|
119
116
|
# Initialize services
|
|
120
117
|
storage_root = Path(config.storage_dir)
|
|
121
|
-
|
|
122
|
-
|
|
118
|
+
storage_service = StorageService(storage_root)
|
|
119
|
+
if state_service is None:
|
|
120
|
+
state_service = StateService(storage_service.system_dir / "state.json")
|
|
121
|
+
|
|
123
122
|
app["storage_service"] = storage_service
|
|
124
|
-
app["
|
|
123
|
+
app["state_service"] = state_service
|
|
124
|
+
app["user_service"] = UserService(config.auth, state_service)
|
|
125
125
|
app["file_service"] = FileService(storage_service)
|
|
126
126
|
app["sync_locks"] = {} # user -> (equipment_no, expiry_time)
|
|
127
127
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from mashumaro.mixins.dict import DataClassDictMixin
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class AuthConfig(DataClassDictMixin):
|
|
16
|
+
secret_key: str = ""
|
|
17
|
+
expiration_hours: int = 24
|
|
18
|
+
users: list["UserEntry"] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class UserEntry(DataClassDictMixin):
|
|
23
|
+
username: str
|
|
24
|
+
password_md5: str
|
|
25
|
+
is_active: bool = True
|
|
26
|
+
display_name: str | None = None
|
|
27
|
+
email: str | None = None
|
|
28
|
+
phone: str | None = None
|
|
29
|
+
avatar: str | None = None
|
|
30
|
+
total_capacity: str = "25485312"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ServerConfig(DataClassDictMixin):
|
|
35
|
+
host: str = "0.0.0.0"
|
|
36
|
+
port: int = 8080
|
|
37
|
+
trace_log_file: str = "data/server_trace.log"
|
|
38
|
+
storage_dir: str = "storage"
|
|
39
|
+
auth: AuthConfig = field(default_factory=AuthConfig)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def load(cls, config_dir: str | Path | None = None) -> "ServerConfig":
|
|
43
|
+
"""Load configuration from directory. READ-ONLY."""
|
|
44
|
+
if config_dir is None:
|
|
45
|
+
config_dir = os.getenv("SUPERNOTE_CONFIG_DIR", "config")
|
|
46
|
+
|
|
47
|
+
config_dir_path = Path(config_dir)
|
|
48
|
+
config_file = config_dir_path / "config.yaml"
|
|
49
|
+
|
|
50
|
+
file_data: dict[str, Any] = {}
|
|
51
|
+
if config_file.exists():
|
|
52
|
+
try:
|
|
53
|
+
with open(config_file, "r") as f:
|
|
54
|
+
file_data = yaml.safe_load(f) or {}
|
|
55
|
+
except Exception as e:
|
|
56
|
+
logger.warning(f"Failed to load config file {config_file}: {e}")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
config = cls.from_dict(file_data)
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.warning(
|
|
62
|
+
f"Failed to parse config file {config_file}, using defaults: {e}"
|
|
63
|
+
)
|
|
64
|
+
config = cls()
|
|
65
|
+
|
|
66
|
+
# 4. JWT Secret priority: Env > Config > Random(in-memory only)
|
|
67
|
+
env_secret = os.getenv("SUPERNOTE_JWT_SECRET")
|
|
68
|
+
if env_secret:
|
|
69
|
+
config.auth.secret_key = env_secret
|
|
70
|
+
|
|
71
|
+
if not config.auth.secret_key:
|
|
72
|
+
logger.warning(
|
|
73
|
+
"No JWT secret key configured. Using a temporary in-memory key."
|
|
74
|
+
)
|
|
75
|
+
config.auth.secret_key = secrets.token_hex(32)
|
|
76
|
+
|
|
77
|
+
# Apply other env var overrides
|
|
78
|
+
if os.getenv("SUPERNOTE_HOST"):
|
|
79
|
+
config.host = os.getenv("SUPERNOTE_HOST", config.host)
|
|
80
|
+
|
|
81
|
+
if os.getenv("SUPERNOTE_PORT"):
|
|
82
|
+
try:
|
|
83
|
+
config.port = int(os.getenv("SUPERNOTE_PORT", str(config.port)))
|
|
84
|
+
except ValueError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
if os.getenv("SUPERNOTE_STORAGE_DIR"):
|
|
88
|
+
config.storage_dir = os.getenv("SUPERNOTE_STORAGE_DIR", config.storage_dir)
|
|
89
|
+
|
|
90
|
+
return config
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import urllib.parse
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import List
|
|
4
|
+
from typing import List
|
|
5
5
|
|
|
6
6
|
from ..models.base import BaseResponse
|
|
7
7
|
from ..models.file import (
|
|
@@ -60,7 +60,7 @@ class FileService:
|
|
|
60
60
|
|
|
61
61
|
return entries
|
|
62
62
|
|
|
63
|
-
def get_file_info(self, user: str, path_str: str) ->
|
|
63
|
+
def get_file_info(self, user: str, path_str: str) -> FileEntryVO | None:
|
|
64
64
|
"""Get file info by path for a specific user."""
|
|
65
65
|
rel_path = path_str.lstrip("/")
|
|
66
66
|
target_path = self.storage_service.resolve_path(user, rel_path)
|
|
@@ -302,7 +302,7 @@ class FileService:
|
|
|
302
302
|
"""Search for files matching the keyword in user's storage."""
|
|
303
303
|
results = []
|
|
304
304
|
keyword_lower = keyword.lower()
|
|
305
|
-
user_root = self.storage_service.
|
|
305
|
+
user_root = self.storage_service.users_dir / user
|
|
306
306
|
|
|
307
307
|
if not user_root.exists():
|
|
308
308
|
return []
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional, cast
|
|
6
|
+
|
|
7
|
+
from mashumaro.mixins.json import DataClassJSONMixin
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SessionState(DataClassJSONMixin):
|
|
14
|
+
token: str
|
|
15
|
+
username: str
|
|
16
|
+
equipment_no: Optional[str] = None
|
|
17
|
+
created_at: float = field(default_factory=time.time)
|
|
18
|
+
last_active_at: float = field(default_factory=time.time)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class UserState(DataClassJSONMixin):
|
|
23
|
+
username: str
|
|
24
|
+
devices: List[str] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SystemState(DataClassJSONMixin):
|
|
29
|
+
users: Dict[str, UserState] = field(default_factory=dict)
|
|
30
|
+
sessions: Dict[str, SessionState] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StateService:
|
|
34
|
+
def __init__(self, state_file: Path) -> None:
|
|
35
|
+
self.state_file = state_file
|
|
36
|
+
self._state = self._load()
|
|
37
|
+
|
|
38
|
+
def _load(self) -> SystemState:
|
|
39
|
+
if not self.state_file.exists():
|
|
40
|
+
return SystemState()
|
|
41
|
+
try:
|
|
42
|
+
with open(self.state_file, "r") as f:
|
|
43
|
+
return SystemState.from_json(f.read())
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.error(f"Failed to load state file {self.state_file}: {e}")
|
|
46
|
+
return SystemState()
|
|
47
|
+
|
|
48
|
+
def save(self) -> None:
|
|
49
|
+
try:
|
|
50
|
+
self.state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
with open(self.state_file, "w") as f:
|
|
52
|
+
f.write(cast(str, self._state.to_json()))
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"Failed to save state file {self.state_file}: {e}")
|
|
55
|
+
|
|
56
|
+
# User State Methods
|
|
57
|
+
def get_user_state(self, username: str) -> UserState:
|
|
58
|
+
if username not in self._state.users:
|
|
59
|
+
self._state.users[username] = UserState(username=username)
|
|
60
|
+
return self._state.users[username]
|
|
61
|
+
|
|
62
|
+
def add_device(self, username: str, equipment_no: str) -> None:
|
|
63
|
+
user_state = self.get_user_state(username)
|
|
64
|
+
if equipment_no not in user_state.devices:
|
|
65
|
+
user_state.devices.append(equipment_no)
|
|
66
|
+
self.save()
|
|
67
|
+
|
|
68
|
+
def remove_device(self, equipment_no: str) -> None:
|
|
69
|
+
found = False
|
|
70
|
+
for user_state in self._state.users.values():
|
|
71
|
+
if equipment_no in user_state.devices:
|
|
72
|
+
user_state.devices.remove(equipment_no)
|
|
73
|
+
found = True
|
|
74
|
+
if found:
|
|
75
|
+
self.save()
|
|
76
|
+
|
|
77
|
+
# Session Management
|
|
78
|
+
def create_session(
|
|
79
|
+
self, token: str, username: str, equipment_no: str | None = None
|
|
80
|
+
) -> None:
|
|
81
|
+
session = SessionState(
|
|
82
|
+
token=token, username=username, equipment_no=equipment_no
|
|
83
|
+
)
|
|
84
|
+
self._state.sessions[token] = session
|
|
85
|
+
self.save()
|
|
86
|
+
|
|
87
|
+
def get_session(self, token: str) -> SessionState | None:
|
|
88
|
+
session = self._state.sessions.get(token)
|
|
89
|
+
if session:
|
|
90
|
+
session.last_active_at = time.time()
|
|
91
|
+
# We don't necessarily save on every activity to avoid disk thrashing,
|
|
92
|
+
# but maybe we should if persistence is critical.
|
|
93
|
+
# For now, let's just update memory.
|
|
94
|
+
return session
|
|
95
|
+
|
|
96
|
+
def prune_sessions(self, max_idle_seconds: int) -> None:
|
|
97
|
+
now = time.time()
|
|
98
|
+
to_remove = [
|
|
99
|
+
token
|
|
100
|
+
for token, session in self._state.sessions.items()
|
|
101
|
+
if now - session.last_active_at > max_idle_seconds
|
|
102
|
+
]
|
|
103
|
+
if to_remove:
|
|
104
|
+
for token in to_remove:
|
|
105
|
+
del self._state.sessions[token]
|
|
106
|
+
self.save()
|
|
107
|
+
logger.info(f"Pruned {len(to_remove)} idle sessions")
|
|
@@ -10,14 +10,18 @@ logger = logging.getLogger(__name__)
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class StorageService:
|
|
13
|
-
def __init__(self,
|
|
14
|
-
self.
|
|
15
|
-
self.
|
|
13
|
+
def __init__(self, root_dir: Path):
|
|
14
|
+
self.root_dir = root_dir
|
|
15
|
+
self.users_dir = root_dir / "users"
|
|
16
|
+
self.temp_dir = root_dir / "temp"
|
|
17
|
+
self.system_dir = root_dir / "system"
|
|
16
18
|
self._ensure_directories()
|
|
17
19
|
|
|
18
20
|
def _ensure_directories(self) -> None:
|
|
19
|
-
self.
|
|
20
|
-
self.
|
|
21
|
+
self.root_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
self.users_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
self.system_dir.mkdir(parents=True, exist_ok=True)
|
|
21
25
|
|
|
22
26
|
def get_file_md5(self, path: Path) -> str:
|
|
23
27
|
"""Calculate MD5 of a file."""
|
|
@@ -39,23 +43,23 @@ class StorageService:
|
|
|
39
43
|
|
|
40
44
|
def get_storage_usage(self, user: str) -> int:
|
|
41
45
|
"""Get total storage usage for a specific user."""
|
|
42
|
-
return self.get_dir_size(self.
|
|
46
|
+
return self.get_dir_size(self.users_dir / user)
|
|
43
47
|
|
|
44
48
|
def resolve_path(self, user: str, rel_path: str) -> Path:
|
|
45
49
|
"""Resolve a relative path to an absolute path in user's storage."""
|
|
46
50
|
# Remove leading slash to make it relative
|
|
47
51
|
clean_rel_path = rel_path.lstrip("/")
|
|
48
|
-
return self.
|
|
52
|
+
return self.users_dir / user / clean_rel_path
|
|
49
53
|
|
|
50
54
|
def resolve_temp_path(self, user: str, filename: str) -> Path:
|
|
51
55
|
"""Resolve a filename to an absolute path in user's temp storage."""
|
|
52
|
-
return self.
|
|
56
|
+
return self.temp_dir / user / filename
|
|
53
57
|
|
|
54
58
|
def is_safe_path(self, user: str, path: Path) -> bool:
|
|
55
59
|
"""Check if path is within user's storage root to prevent traversal."""
|
|
56
60
|
try:
|
|
57
61
|
resolved_path = path.resolve()
|
|
58
|
-
user_root_abs = (self.
|
|
62
|
+
user_root_abs = (self.users_dir / user).resolve()
|
|
59
63
|
return str(resolved_path).startswith(str(user_root_abs))
|
|
60
64
|
except Exception:
|
|
61
65
|
return False
|
|
@@ -153,7 +157,7 @@ class StorageService:
|
|
|
153
157
|
|
|
154
158
|
def get_path_from_id(self, user: str, file_id: int) -> str | None:
|
|
155
159
|
"""Find relative path from ID by scanning user's storage."""
|
|
156
|
-
user_root = self.
|
|
160
|
+
user_root = self.users_dir / user
|
|
157
161
|
if not user_root.exists():
|
|
158
162
|
return None
|
|
159
163
|
|
|
@@ -235,7 +239,7 @@ class StorageService:
|
|
|
235
239
|
|
|
236
240
|
def get_trash_dir(self, user: str) -> Path:
|
|
237
241
|
"""Get the trash directory path for a user."""
|
|
238
|
-
return self.
|
|
242
|
+
return self.users_dir / user / ".trash"
|
|
239
243
|
|
|
240
244
|
def ensure_trash_dir(self, user: str) -> None:
|
|
241
245
|
"""Ensure trash directory exists for a user."""
|
|
@@ -284,7 +288,7 @@ class StorageService:
|
|
|
284
288
|
self, user: str, trash_rel_path: str, original_rel_path: str
|
|
285
289
|
) -> None:
|
|
286
290
|
"""Restore file/folder from user's trash."""
|
|
287
|
-
trash_path = (self.
|
|
291
|
+
trash_path = (self.users_dir / user) / trash_rel_path
|
|
288
292
|
if not trash_path.exists():
|
|
289
293
|
raise FileNotFoundError(f"Trash item {trash_rel_path} not found")
|
|
290
294
|
|
|
@@ -297,7 +301,7 @@ class StorageService:
|
|
|
297
301
|
|
|
298
302
|
def delete_from_trash(self, user: str, trash_rel_path: str) -> None:
|
|
299
303
|
"""Permanently delete item from user's trash."""
|
|
300
|
-
trash_path = (self.
|
|
304
|
+
trash_path = (self.users_dir / user) / trash_rel_path
|
|
301
305
|
if not trash_path.exists():
|
|
302
306
|
return
|
|
303
307
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import logging
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional, cast
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
|
|
9
|
+
from ..config import AuthConfig, UserEntry
|
|
10
|
+
from ..models.auth import LoginResult, UserVO
|
|
11
|
+
from .state import StateService
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
JWT_ALGORITHM = "HS256"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UserService:
|
|
19
|
+
def __init__(self, config: AuthConfig, state_service: StateService):
|
|
20
|
+
self._config = config
|
|
21
|
+
self._state_service = state_service
|
|
22
|
+
self._random_codes: dict[
|
|
23
|
+
str, tuple[str, str]
|
|
24
|
+
] = {} # account -> (code, timestamp)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def _users(self) -> list[UserEntry]:
|
|
28
|
+
return self._config.users
|
|
29
|
+
|
|
30
|
+
def list_users(self) -> list[UserEntry]:
|
|
31
|
+
return list(self._users)
|
|
32
|
+
|
|
33
|
+
def check_user_exists(self, account: str) -> bool:
|
|
34
|
+
return any(u.username == account for u in self._users)
|
|
35
|
+
|
|
36
|
+
def generate_random_code(self, account: str) -> tuple[str, str]:
|
|
37
|
+
"""Generate a random code for login challenge."""
|
|
38
|
+
random_code = secrets.token_hex(4) # 8 chars
|
|
39
|
+
timestamp = str(int(time.time() * 1000))
|
|
40
|
+
self._random_codes[account] = (random_code, timestamp)
|
|
41
|
+
return random_code, timestamp
|
|
42
|
+
|
|
43
|
+
def _get_user(self, account: str) -> UserEntry | None:
|
|
44
|
+
for user in self._users:
|
|
45
|
+
if user.username == account:
|
|
46
|
+
return user
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def verify_password(self, account: str, password: str) -> bool:
|
|
50
|
+
user = self._get_user(account)
|
|
51
|
+
if not user or not user.is_active:
|
|
52
|
+
return False
|
|
53
|
+
hash_hex = hashlib.md5(password.encode()).hexdigest()
|
|
54
|
+
return bool(hash_hex == user.password_md5)
|
|
55
|
+
|
|
56
|
+
def verify_login_hash(self, account: str, client_hash: str, timestamp: str) -> bool:
|
|
57
|
+
user = self._get_user(account)
|
|
58
|
+
if not user or not user.is_active:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
code_tuple = self._random_codes.get(account)
|
|
62
|
+
if not code_tuple or code_tuple[1] != timestamp:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
random_code = code_tuple[0]
|
|
66
|
+
concat = user.password_md5 + random_code
|
|
67
|
+
expected_hash = hashlib.sha256(concat.encode()).hexdigest()
|
|
68
|
+
|
|
69
|
+
return expected_hash == client_hash
|
|
70
|
+
|
|
71
|
+
def login(
|
|
72
|
+
self,
|
|
73
|
+
account: str,
|
|
74
|
+
password_hash: str,
|
|
75
|
+
timestamp: str,
|
|
76
|
+
equipment_no: Optional[str] = None,
|
|
77
|
+
) -> LoginResult | None:
|
|
78
|
+
user = self._get_user(account)
|
|
79
|
+
if not user or not user.is_active:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
if not self.verify_login_hash(account, password_hash, timestamp):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
# Check binding status from StateService
|
|
86
|
+
user_state = self._state_service.get_user_state(account)
|
|
87
|
+
bound_devices = user_state.devices
|
|
88
|
+
is_bind = "Y" if bound_devices else "N"
|
|
89
|
+
is_bind_equipment = (
|
|
90
|
+
"Y" if equipment_no and equipment_no in bound_devices else "N"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
payload = {
|
|
94
|
+
"sub": account,
|
|
95
|
+
"equipment_no": equipment_no or "",
|
|
96
|
+
"iat": int(time.time()),
|
|
97
|
+
"exp": int(time.time()) + (self._config.expiration_hours * 3600),
|
|
98
|
+
}
|
|
99
|
+
token = jwt.encode(payload, self._config.secret_key, algorithm=JWT_ALGORITHM)
|
|
100
|
+
|
|
101
|
+
# Persist session in StateService
|
|
102
|
+
self._state_service.create_session(token, account, equipment_no)
|
|
103
|
+
|
|
104
|
+
return LoginResult(
|
|
105
|
+
token=token,
|
|
106
|
+
is_bind=is_bind,
|
|
107
|
+
is_bind_equipment=is_bind_equipment,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def verify_token(self, token: str) -> str | None:
|
|
111
|
+
"""Verify token against persisted sessions and JWT signature."""
|
|
112
|
+
try:
|
|
113
|
+
# 1. Check if session exists in memory/state
|
|
114
|
+
session = self._state_service.get_session(token)
|
|
115
|
+
if not session:
|
|
116
|
+
logger.warning("Session not found in state: %s", token[:10])
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
# 2. Decode and verify JWT
|
|
120
|
+
payload = jwt.decode(
|
|
121
|
+
token, self._config.secret_key, algorithms=[JWT_ALGORITHM]
|
|
122
|
+
)
|
|
123
|
+
return cast(str, payload.get("sub"))
|
|
124
|
+
except jwt.PyJWTError as e:
|
|
125
|
+
logger.warning("Token verification failed: %s", e)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def get_user_profile(self, account: str) -> UserVO | None:
|
|
129
|
+
"""Get user profile from static config."""
|
|
130
|
+
user = self._get_user(account)
|
|
131
|
+
if not user:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
return UserVO(
|
|
135
|
+
user_name=user.display_name or account,
|
|
136
|
+
email=user.email or account,
|
|
137
|
+
phone=user.phone or "",
|
|
138
|
+
country_code="1",
|
|
139
|
+
total_capacity=user.total_capacity,
|
|
140
|
+
file_server="0",
|
|
141
|
+
avatars_url=user.avatar or "",
|
|
142
|
+
birthday="",
|
|
143
|
+
sex="",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def bind_equipment(self, account: str, equipment_no: str) -> bool:
|
|
147
|
+
"""Bind a device to the user account using StateService."""
|
|
148
|
+
if not self.check_user_exists(account):
|
|
149
|
+
return False
|
|
150
|
+
self._state_service.add_device(account, equipment_no)
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
def unlink_equipment(self, equipment_no: str) -> bool:
|
|
154
|
+
"""Unlink a device from all users using StateService."""
|
|
155
|
+
self._state_service.remove_device(equipment_no)
|
|
156
|
+
return True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: supernote
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: All-in-one toolkit for Supernote devices: parse notebooks, self-host services, access services
|
|
5
5
|
Author-email: jya <jya@wizmy.net>, Allen Porter <allen.porter@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
import os
|
|
3
|
-
import secrets
|
|
4
|
-
from dataclasses import dataclass, field
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
import yaml
|
|
9
|
-
from mashumaro.mixins.dict import DataClassDictMixin
|
|
10
|
-
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class AuthConfig(DataClassDictMixin):
|
|
16
|
-
secret_key: str = ""
|
|
17
|
-
expiration_hours: int = 24
|
|
18
|
-
users_file: str = "users.yaml"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass
|
|
22
|
-
class UserEntry(DataClassDictMixin):
|
|
23
|
-
username: str
|
|
24
|
-
password_md5: str
|
|
25
|
-
is_active: bool = True
|
|
26
|
-
devices: list[str] = field(default_factory=list)
|
|
27
|
-
profile: dict[str, Any] = field(default_factory=dict)
|
|
28
|
-
mobile: str | None = None
|
|
29
|
-
email: str | None = None
|
|
30
|
-
avatar: str | None = None
|
|
31
|
-
signature: str | None = None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
@dataclass
|
|
35
|
-
class UsersConfig(DataClassDictMixin):
|
|
36
|
-
users: list[UserEntry] = field(default_factory=list)
|
|
37
|
-
|
|
38
|
-
@classmethod
|
|
39
|
-
def load(cls, path: str | Path) -> "UsersConfig":
|
|
40
|
-
path = Path(path)
|
|
41
|
-
if not path.exists():
|
|
42
|
-
return cls()
|
|
43
|
-
try:
|
|
44
|
-
with open(path, "r") as f:
|
|
45
|
-
data = yaml.safe_load(f) or {}
|
|
46
|
-
return cls.from_dict(data)
|
|
47
|
-
except Exception as e:
|
|
48
|
-
logger.warning(f"Failed to load users file {path}: {e}")
|
|
49
|
-
return cls()
|
|
50
|
-
|
|
51
|
-
def save(self, path: str | Path) -> None:
|
|
52
|
-
path = Path(path)
|
|
53
|
-
if not path.parent.exists():
|
|
54
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
-
with open(path, "w") as f:
|
|
56
|
-
yaml.safe_dump(self.to_dict(), f, default_flow_style=False)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@dataclass
|
|
60
|
-
class ServerConfig(DataClassDictMixin):
|
|
61
|
-
host: str = "0.0.0.0"
|
|
62
|
-
port: int = 8080
|
|
63
|
-
trace_log_file: str = "data/server_trace.log"
|
|
64
|
-
storage_dir: str = "storage"
|
|
65
|
-
auth: AuthConfig = field(default_factory=AuthConfig)
|
|
66
|
-
|
|
67
|
-
@classmethod
|
|
68
|
-
def load(cls, config_dir: str | Path | None = None) -> "ServerConfig":
|
|
69
|
-
"""Load configuration from directory."""
|
|
70
|
-
# 1. Determine config directory
|
|
71
|
-
if config_dir is None:
|
|
72
|
-
config_dir = os.getenv("SUPERNOTE_CONFIG_DIR", "config")
|
|
73
|
-
|
|
74
|
-
config_dir_path = Path(config_dir)
|
|
75
|
-
config_file = config_dir_path / "config.yaml"
|
|
76
|
-
|
|
77
|
-
# 2. Load from YAML if exists
|
|
78
|
-
file_data: dict[str, Any] = {}
|
|
79
|
-
if file_exists := config_file.exists():
|
|
80
|
-
try:
|
|
81
|
-
with open(config_file, "r") as f:
|
|
82
|
-
file_data = yaml.safe_load(f) or {}
|
|
83
|
-
except Exception as e:
|
|
84
|
-
logger.warning(f"Failed to load config file {config_file}: {e}")
|
|
85
|
-
|
|
86
|
-
# 3. Create config object using mashumaro
|
|
87
|
-
try:
|
|
88
|
-
config = cls.from_dict(file_data)
|
|
89
|
-
except Exception as e:
|
|
90
|
-
logger.warning(
|
|
91
|
-
f"Failed to parse config file {config_file}, using defaults: {e}"
|
|
92
|
-
)
|
|
93
|
-
config = cls()
|
|
94
|
-
|
|
95
|
-
# Resolve users_file relative to config_dir if it's just a filename (not already a path)
|
|
96
|
-
users_path = Path(config.auth.users_file)
|
|
97
|
-
if not users_path.is_absolute() and len(users_path.parts) == 1:
|
|
98
|
-
config.auth.users_file = str(config_dir_path / users_path)
|
|
99
|
-
|
|
100
|
-
# 4. Generate secret if missing AND not provided by env var
|
|
101
|
-
env_secret = os.getenv("SUPERNOTE_JWT_SECRET")
|
|
102
|
-
secret_generated = False
|
|
103
|
-
|
|
104
|
-
if not config.auth.secret_key and not env_secret:
|
|
105
|
-
logger.warning("No JWT secret key configured. Generating a new random key.")
|
|
106
|
-
config.auth.secret_key = secrets.token_hex(32)
|
|
107
|
-
secret_generated = True
|
|
108
|
-
|
|
109
|
-
# 5. Save config if it was missing or if we generated a secret
|
|
110
|
-
if not file_exists or secret_generated:
|
|
111
|
-
try:
|
|
112
|
-
# Ensure directory exists
|
|
113
|
-
if not config_file.parent.exists():
|
|
114
|
-
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
-
|
|
116
|
-
# Prepare config for saving (restore relative paths if possible)
|
|
117
|
-
config_to_save = cls.from_dict(config.to_dict())
|
|
118
|
-
try:
|
|
119
|
-
users_path_abs = Path(config.auth.users_file)
|
|
120
|
-
if users_path_abs.is_absolute() and users_path_abs.is_relative_to(
|
|
121
|
-
config_dir_path
|
|
122
|
-
):
|
|
123
|
-
config_to_save.auth.users_file = str(
|
|
124
|
-
users_path_abs.relative_to(config_dir_path)
|
|
125
|
-
)
|
|
126
|
-
except ValueError:
|
|
127
|
-
pass
|
|
128
|
-
|
|
129
|
-
with open(config_file, "w") as f:
|
|
130
|
-
yaml.safe_dump(
|
|
131
|
-
config_to_save.to_dict(), f, default_flow_style=False
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
if secret_generated:
|
|
135
|
-
logger.info(
|
|
136
|
-
f"Saved new configuration with generated secret to {config_file}"
|
|
137
|
-
)
|
|
138
|
-
else:
|
|
139
|
-
logger.info(f"Created default configuration file at {config_file}")
|
|
140
|
-
except Exception as e:
|
|
141
|
-
logger.warning(f"Failed to save configuration to {config_file}: {e}")
|
|
142
|
-
|
|
143
|
-
# 6. Apply env var override (runtime only, not saved)
|
|
144
|
-
if env_secret:
|
|
145
|
-
config.auth.secret_key = env_secret
|
|
146
|
-
|
|
147
|
-
if os.getenv("SUPERNOTE_HOST"):
|
|
148
|
-
config.host = os.getenv("SUPERNOTE_HOST", config.host)
|
|
149
|
-
|
|
150
|
-
if os.getenv("SUPERNOTE_PORT"):
|
|
151
|
-
try:
|
|
152
|
-
config.port = int(os.getenv("SUPERNOTE_PORT", str(config.port)))
|
|
153
|
-
except ValueError:
|
|
154
|
-
pass
|
|
155
|
-
|
|
156
|
-
if os.getenv("SUPERNOTE_STORAGE_DIR"):
|
|
157
|
-
config.storage_dir = os.getenv("SUPERNOTE_STORAGE_DIR", config.storage_dir)
|
|
158
|
-
|
|
159
|
-
return config
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import hashlib
|
|
2
|
-
import logging
|
|
3
|
-
import secrets
|
|
4
|
-
import time
|
|
5
|
-
from typing import Optional
|
|
6
|
-
|
|
7
|
-
import jwt
|
|
8
|
-
|
|
9
|
-
from ..config import AuthConfig, UserEntry, UsersConfig
|
|
10
|
-
from ..models.auth import LoginResult, UserVO
|
|
11
|
-
|
|
12
|
-
logger = logging.getLogger(__name__)
|
|
13
|
-
|
|
14
|
-
JWT_ALGORITHM = "HS256"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class UserService:
|
|
18
|
-
def __init__(self, config: AuthConfig):
|
|
19
|
-
self._config = config
|
|
20
|
-
self._users_config = UsersConfig.load(config.users_file)
|
|
21
|
-
self._random_codes: dict[
|
|
22
|
-
str, tuple[str, str]
|
|
23
|
-
] = {} # account -> (code, timestamp)
|
|
24
|
-
|
|
25
|
-
@property
|
|
26
|
-
def _users(self) -> list[UserEntry]:
|
|
27
|
-
return self._users_config.users
|
|
28
|
-
|
|
29
|
-
def list_users(self) -> list[UserEntry]:
|
|
30
|
-
return list(self._users)
|
|
31
|
-
|
|
32
|
-
@staticmethod
|
|
33
|
-
def create_user_entry(username: str, password: str) -> UserEntry:
|
|
34
|
-
"""Create a new UserEntry with hashed password."""
|
|
35
|
-
password_md5 = hashlib.md5(password.encode()).hexdigest()
|
|
36
|
-
return UserEntry(
|
|
37
|
-
username=username,
|
|
38
|
-
password_md5=password_md5,
|
|
39
|
-
is_active=True,
|
|
40
|
-
devices=[],
|
|
41
|
-
profile={},
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
def add_user(self, username: str, password: str) -> bool:
|
|
45
|
-
"""Add a new user to the in-memory config. Does NOT save to disk."""
|
|
46
|
-
if any(u.username == username for u in self._users):
|
|
47
|
-
return False
|
|
48
|
-
new_user = self.create_user_entry(username, password)
|
|
49
|
-
self._users.append(new_user)
|
|
50
|
-
return True
|
|
51
|
-
|
|
52
|
-
def save(self) -> None:
|
|
53
|
-
"""Save the current users configuration to disk."""
|
|
54
|
-
self._users_config.save(self._config.users_file)
|
|
55
|
-
|
|
56
|
-
def deactivate_user(self, username: str) -> bool:
|
|
57
|
-
"""Deactivate a user in-memory. Does NOT save to disk."""
|
|
58
|
-
for user in self._users:
|
|
59
|
-
if user.username == username:
|
|
60
|
-
user.is_active = False
|
|
61
|
-
return True
|
|
62
|
-
return False
|
|
63
|
-
|
|
64
|
-
def check_user_exists(self, account: str) -> bool:
|
|
65
|
-
return any(u.username == account for u in self._users)
|
|
66
|
-
|
|
67
|
-
def generate_random_code(self, account: str) -> tuple[str, str]:
|
|
68
|
-
"""Generate a random code for login challenge."""
|
|
69
|
-
random_code = secrets.token_hex(4) # 8 chars
|
|
70
|
-
timestamp = str(int(time.time() * 1000))
|
|
71
|
-
# Only allow one active code per account at a time
|
|
72
|
-
self._random_codes[account] = (random_code, timestamp)
|
|
73
|
-
return random_code, timestamp
|
|
74
|
-
|
|
75
|
-
def _get_user(self, account: str) -> UserEntry | None:
|
|
76
|
-
for user in self._users:
|
|
77
|
-
if user.username == account:
|
|
78
|
-
return user
|
|
79
|
-
return None
|
|
80
|
-
|
|
81
|
-
def verify_password(self, account: str, password: str) -> bool:
|
|
82
|
-
user = self._get_user(account)
|
|
83
|
-
if not user or not user.is_active:
|
|
84
|
-
logger.info("User not found or inactive: %s", account)
|
|
85
|
-
return False
|
|
86
|
-
if not user.password_md5:
|
|
87
|
-
logger.info("MD5 password hash not found for user: %s", account)
|
|
88
|
-
return False
|
|
89
|
-
# Compute md5(password) and compare
|
|
90
|
-
password_bytes = password.encode()
|
|
91
|
-
hash_hex = hashlib.md5(password_bytes).hexdigest()
|
|
92
|
-
return bool(hash_hex == user.password_md5)
|
|
93
|
-
|
|
94
|
-
def verify_login_hash(self, account: str, client_hash: str, timestamp: str) -> bool:
|
|
95
|
-
user = self._get_user(account)
|
|
96
|
-
if not user or not user.is_active:
|
|
97
|
-
logger.info("User not found or inactive: %s", account)
|
|
98
|
-
return False
|
|
99
|
-
|
|
100
|
-
code_tuple = self._random_codes.get(account)
|
|
101
|
-
if not code_tuple or code_tuple[1] != timestamp:
|
|
102
|
-
logger.warning(
|
|
103
|
-
"Random code not found or timestamp mismatch for %s", account
|
|
104
|
-
)
|
|
105
|
-
return False
|
|
106
|
-
random_code = code_tuple[0]
|
|
107
|
-
|
|
108
|
-
if not user.password_md5:
|
|
109
|
-
logger.info("MD5 password hash not found for user: %s", account)
|
|
110
|
-
return False
|
|
111
|
-
|
|
112
|
-
# Compute expected hash: sha256(password_md5 + random_code)
|
|
113
|
-
concat = user.password_md5 + random_code
|
|
114
|
-
expected_hash = hashlib.sha256(concat.encode()).hexdigest()
|
|
115
|
-
|
|
116
|
-
if expected_hash == client_hash:
|
|
117
|
-
return True
|
|
118
|
-
logger.info("Login hash mismatch for user: %s", account)
|
|
119
|
-
return False
|
|
120
|
-
|
|
121
|
-
def login(
|
|
122
|
-
self,
|
|
123
|
-
account: str,
|
|
124
|
-
password_hash: str,
|
|
125
|
-
timestamp: str,
|
|
126
|
-
equipment_no: Optional[str] = None,
|
|
127
|
-
) -> LoginResult | None:
|
|
128
|
-
"""Login user and return token and status info.
|
|
129
|
-
|
|
130
|
-
Args:
|
|
131
|
-
account: User account (email/phone)
|
|
132
|
-
password_hash: Hashed password provided by client
|
|
133
|
-
timestamp: Timestamp used in hash
|
|
134
|
-
equipment_no: Equipment number (optional)
|
|
135
|
-
|
|
136
|
-
Returns:
|
|
137
|
-
LoginResult if login is successful, None otherwise.
|
|
138
|
-
"""
|
|
139
|
-
user = self._get_user(account)
|
|
140
|
-
if not user or not user.is_active:
|
|
141
|
-
# TODO: Raise exceptions so we can return a useful error message
|
|
142
|
-
# to the web APIs.
|
|
143
|
-
logger.warning("Login failed: user not found or inactive: %s", account)
|
|
144
|
-
return None
|
|
145
|
-
code_tuple = self._random_codes.get(account)
|
|
146
|
-
if not code_tuple or code_tuple[1] != timestamp:
|
|
147
|
-
logger.warning(
|
|
148
|
-
"Login failed: random code missing or timestamp mismatch for %s",
|
|
149
|
-
account,
|
|
150
|
-
)
|
|
151
|
-
return None
|
|
152
|
-
if not self.verify_login_hash(account, password_hash, timestamp):
|
|
153
|
-
logger.warning("Login failed: invalid password hash for %s", account)
|
|
154
|
-
return None
|
|
155
|
-
|
|
156
|
-
# Check binding status
|
|
157
|
-
bound_devices = user.devices
|
|
158
|
-
is_bind = "Y" if bound_devices else "N"
|
|
159
|
-
is_bind_equipment = "N"
|
|
160
|
-
if equipment_no and equipment_no in bound_devices:
|
|
161
|
-
is_bind_equipment = "Y"
|
|
162
|
-
|
|
163
|
-
payload = {
|
|
164
|
-
"sub": account,
|
|
165
|
-
"equipment_no": equipment_no or "",
|
|
166
|
-
"iat": int(time.time()),
|
|
167
|
-
"exp": int(time.time()) + (self._config.expiration_hours * 3600),
|
|
168
|
-
}
|
|
169
|
-
token = jwt.encode(payload, self._config.secret_key, algorithm=JWT_ALGORITHM)
|
|
170
|
-
|
|
171
|
-
return LoginResult(
|
|
172
|
-
token=token,
|
|
173
|
-
is_bind=is_bind,
|
|
174
|
-
is_bind_equipment=is_bind_equipment,
|
|
175
|
-
)
|
|
176
|
-
|
|
177
|
-
def get_user_profile(self, account: str) -> UserVO | None:
|
|
178
|
-
"""Get user profile."""
|
|
179
|
-
user = self._get_user(account)
|
|
180
|
-
if not user:
|
|
181
|
-
return None
|
|
182
|
-
|
|
183
|
-
# Default profile values
|
|
184
|
-
username = user.username
|
|
185
|
-
profile = user.profile
|
|
186
|
-
|
|
187
|
-
return UserVO(
|
|
188
|
-
user_name=profile.get("user_name", username),
|
|
189
|
-
email=user.email or profile.get("email", username),
|
|
190
|
-
phone=user.mobile or profile.get("phone", ""),
|
|
191
|
-
country_code=profile.get("country_code", "1"),
|
|
192
|
-
total_capacity=profile.get("total_capacity", "25485312"),
|
|
193
|
-
file_server=profile.get("file_server", "0"),
|
|
194
|
-
avatars_url=user.avatar or profile.get("avatars_url", ""),
|
|
195
|
-
birthday=profile.get("birthday", ""),
|
|
196
|
-
sex=profile.get("sex", ""),
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
def bind_equipment(self, account: str, equipment_no: str) -> bool:
|
|
200
|
-
"""Bind a device to the user account."""
|
|
201
|
-
logger.info("Binding equipment %s to user %s", equipment_no, account)
|
|
202
|
-
user = self._get_user(account)
|
|
203
|
-
if not user:
|
|
204
|
-
logger.warning("User not found for binding: %s", account)
|
|
205
|
-
return False
|
|
206
|
-
|
|
207
|
-
if equipment_no not in user.devices:
|
|
208
|
-
user.devices.append(equipment_no)
|
|
209
|
-
self._users_config.save(self._config.users_file)
|
|
210
|
-
|
|
211
|
-
return True
|
|
212
|
-
|
|
213
|
-
def unlink_equipment(self, equipment_no: str) -> bool:
|
|
214
|
-
"""Unlink a device from all users (or specifically one if we knew context)."""
|
|
215
|
-
logger.info("Unlinking equipment %s", equipment_no)
|
|
216
|
-
found = False
|
|
217
|
-
for user in self._users:
|
|
218
|
-
if equipment_no in user.devices:
|
|
219
|
-
user.devices.remove(equipment_no)
|
|
220
|
-
found = True
|
|
221
|
-
|
|
222
|
-
if found:
|
|
223
|
-
self._users_config.save(self._config.users_file)
|
|
224
|
-
|
|
225
|
-
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|