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.
Files changed (52) hide show
  1. {supernote-0.6.0 → supernote-0.6.2}/PKG-INFO +1 -1
  2. {supernote-0.6.0 → supernote-0.6.2}/pyproject.toml +1 -1
  3. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/app.py +17 -17
  4. supernote-0.6.2/supernote/server/config.py +90 -0
  5. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/services/file.py +3 -3
  6. supernote-0.6.2/supernote/server/services/state.py +107 -0
  7. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/services/storage.py +17 -13
  8. supernote-0.6.2/supernote/server/services/user.py +156 -0
  9. {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/PKG-INFO +1 -1
  10. {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/SOURCES.txt +1 -0
  11. supernote-0.6.0/supernote/server/config.py +0 -159
  12. supernote-0.6.0/supernote/server/services/user.py +0 -225
  13. {supernote-0.6.0 → supernote-0.6.2}/LICENSE +0 -0
  14. {supernote-0.6.0 → supernote-0.6.2}/README.md +0 -0
  15. {supernote-0.6.0 → supernote-0.6.2}/setup.cfg +0 -0
  16. {supernote-0.6.0 → supernote-0.6.2}/supernote/__init__.py +0 -0
  17. {supernote-0.6.0 → supernote-0.6.2}/supernote/cli/__init__.py +0 -0
  18. {supernote-0.6.0 → supernote-0.6.2}/supernote/cli/client.py +0 -0
  19. {supernote-0.6.0 → supernote-0.6.2}/supernote/cli/main.py +0 -0
  20. {supernote-0.6.0 → supernote-0.6.2}/supernote/cli/notebook.py +0 -0
  21. {supernote-0.6.0 → supernote-0.6.2}/supernote/cli/server.py +0 -0
  22. {supernote-0.6.0 → supernote-0.6.2}/supernote/client/__init__.py +0 -0
  23. {supernote-0.6.0 → supernote-0.6.2}/supernote/client/api_model.py +0 -0
  24. {supernote-0.6.0 → supernote-0.6.2}/supernote/client/auth.py +0 -0
  25. {supernote-0.6.0 → supernote-0.6.2}/supernote/client/client.py +0 -0
  26. {supernote-0.6.0 → supernote-0.6.2}/supernote/client/cloud_client.py +0 -0
  27. {supernote-0.6.0 → supernote-0.6.2}/supernote/client/exceptions.py +0 -0
  28. {supernote-0.6.0 → supernote-0.6.2}/supernote/client/login_client.py +0 -0
  29. {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/__init__.py +0 -0
  30. {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/color.py +0 -0
  31. {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/converter.py +0 -0
  32. {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/decoder.py +0 -0
  33. {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/exceptions.py +0 -0
  34. {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/fileformat.py +0 -0
  35. {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/manipulator.py +0 -0
  36. {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/parser.py +0 -0
  37. {supernote-0.6.0 → supernote-0.6.2}/supernote/notebook/utils.py +0 -0
  38. {supernote-0.6.0 → supernote-0.6.2}/supernote/py.typed +0 -0
  39. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/__init__.py +0 -0
  40. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/models/__init__.py +0 -0
  41. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/models/auth.py +0 -0
  42. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/models/base.py +0 -0
  43. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/models/file.py +0 -0
  44. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/routes/auth.py +0 -0
  45. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/routes/decorators.py +0 -0
  46. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/routes/file.py +0 -0
  47. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/routes/system.py +0 -0
  48. {supernote-0.6.0 → supernote-0.6.2}/supernote/server/services/__init__.py +0 -0
  49. {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/dependency_links.txt +0 -0
  50. {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/entry_points.txt +0 -0
  51. {supernote-0.6.0 → supernote-0.6.2}/supernote.egg-info/requires.txt +0 -0
  52. {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.0
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.0"
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 (Supernote device) or Authorization Bearer (tests)
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
- import jwt
97
-
98
- server_config: ServerConfig = request.app["config"]
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
- request["user"] = payload["sub"]
101
+
102
+ request["user"] = user
109
103
  return await handler(request)
110
104
 
111
105
 
112
- def create_app(config: ServerConfig | None = None) -> web.Application:
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
- temp_root = storage_root / "temp"
122
- storage_service = StorageService(storage_root, temp_root)
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["user_service"] = UserService(config.auth)
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, Optional
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) -> Optional[FileEntryVO]:
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.storage_root / user
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, storage_root: Path, temp_root: Path):
14
- self.storage_root = storage_root
15
- self.temp_root = temp_root
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.storage_root.mkdir(parents=True, exist_ok=True)
20
- self.temp_root.mkdir(parents=True, exist_ok=True)
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.storage_root / user)
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.storage_root / user / clean_rel_path
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.temp_root / user / filename
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.storage_root / user).resolve()
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.storage_root / user
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.storage_root / user / ".trash"
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.storage_root / user) / trash_rel_path
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.storage_root / user) / trash_rel_path
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.0
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
@@ -43,5 +43,6 @@ supernote/server/routes/file.py
43
43
  supernote/server/routes/system.py
44
44
  supernote/server/services/__init__.py
45
45
  supernote/server/services/file.py
46
+ supernote/server/services/state.py
46
47
  supernote/server/services/storage.py
47
48
  supernote/server/services/user.py
@@ -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