supernote 0.4.0__tar.gz → 0.5.0__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.4.0 → supernote-0.5.0}/PKG-INFO +1 -1
- {supernote-0.4.0 → supernote-0.5.0}/pyproject.toml +1 -1
- {supernote-0.4.0 → supernote-0.5.0}/supernote/cli/server.py +28 -37
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/__init__.py +2 -2
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/app.py +22 -9
- supernote-0.5.0/supernote/server/config.py +159 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/services/user.py +50 -64
- {supernote-0.4.0 → supernote-0.5.0}/supernote.egg-info/PKG-INFO +1 -1
- supernote-0.4.0/supernote/server/config.py +0 -16
- {supernote-0.4.0 → supernote-0.5.0}/LICENSE +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/README.md +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/setup.cfg +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/__init__.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/cli/__init__.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/cli/client.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/cli/main.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/cli/notebook.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/client/__init__.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/client/api_model.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/client/auth.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/client/client.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/client/cloud_client.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/client/exceptions.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/client/login_client.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/notebook/__init__.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/notebook/color.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/notebook/converter.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/notebook/decoder.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/notebook/exceptions.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/notebook/fileformat.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/notebook/manipulator.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/notebook/parser.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/notebook/utils.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/py.typed +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/models/__init__.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/models/auth.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/models/base.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/models/file.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/routes/auth.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/routes/decorators.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/routes/file.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/routes/system.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/services/__init__.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/services/file.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote/server/services/storage.py +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote.egg-info/SOURCES.txt +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote.egg-info/dependency_links.txt +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote.egg-info/entry_points.txt +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/supernote.egg-info/requires.txt +0 -0
- {supernote-0.4.0 → supernote-0.5.0}/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.
|
|
3
|
+
Version: 0.5.0
|
|
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.
|
|
7
|
+
version = "0.5.0"
|
|
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"
|
|
@@ -2,35 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import getpass
|
|
5
|
-
import os
|
|
6
|
-
|
|
7
|
-
import yaml
|
|
8
5
|
|
|
9
6
|
from supernote.server import app as server_app
|
|
7
|
+
from supernote.server.config import AuthConfig, ServerConfig
|
|
10
8
|
from supernote.server.services.user import UserService
|
|
11
9
|
|
|
12
10
|
|
|
13
|
-
def
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return yaml.safe_load(f) or {"users": []}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def save_users(data, users_file: str):
|
|
21
|
-
with open(users_file, "w") as f:
|
|
22
|
-
yaml.safe_dump(data, f, default_flow_style=False)
|
|
11
|
+
def get_auth_config(config_dir: str | None) -> AuthConfig:
|
|
12
|
+
# Load base config
|
|
13
|
+
server_config = ServerConfig.load(config_dir)
|
|
14
|
+
return server_config.auth
|
|
23
15
|
|
|
24
16
|
|
|
25
|
-
def list_users(
|
|
26
|
-
|
|
17
|
+
def list_users(config_dir: str | None):
|
|
18
|
+
config = get_auth_config(config_dir)
|
|
19
|
+
service = UserService(config)
|
|
27
20
|
for user in service.list_users():
|
|
28
|
-
status = "active" if user.
|
|
29
|
-
print(f"{user
|
|
21
|
+
status = "active" if user.is_active else "inactive"
|
|
22
|
+
print(f"{user.username} ({status})")
|
|
30
23
|
|
|
31
24
|
|
|
32
|
-
def add_user(
|
|
33
|
-
|
|
25
|
+
def add_user(config_dir: str | None, username: str, password: str | None = None):
|
|
26
|
+
config = get_auth_config(config_dir)
|
|
27
|
+
service = UserService(config)
|
|
34
28
|
if password is None:
|
|
35
29
|
password = getpass.getpass(f"Password for {username}: ")
|
|
36
30
|
if service.add_user(username, password):
|
|
@@ -39,8 +33,9 @@ def add_user(users_file: str, username: str, password: str | None = None):
|
|
|
39
33
|
print(f"User '{username}' already exists.")
|
|
40
34
|
|
|
41
35
|
|
|
42
|
-
def deactivate_user(
|
|
43
|
-
|
|
36
|
+
def deactivate_user(config_dir: str | None, username: str):
|
|
37
|
+
config = get_auth_config(config_dir)
|
|
38
|
+
service = UserService(config)
|
|
44
39
|
if service.deactivate_user(username):
|
|
45
40
|
print(f"User '{username}' deactivated.")
|
|
46
41
|
else:
|
|
@@ -56,46 +51,42 @@ def add_parser(subparsers):
|
|
|
56
51
|
|
|
57
52
|
# User management
|
|
58
53
|
parser_user = subparsers.add_parser("user", help="User management commands")
|
|
59
|
-
parser_user.add_argument(
|
|
60
|
-
"--users-file",
|
|
61
|
-
type=str,
|
|
62
|
-
default="./config/users.yaml",
|
|
63
|
-
help="Path to users.yaml file",
|
|
64
|
-
)
|
|
65
54
|
user_subparsers = parser_user.add_subparsers(dest="user_command")
|
|
66
55
|
|
|
67
56
|
# user list
|
|
68
|
-
parser_user_list = user_subparsers.add_parser(
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
parser_user_list.set_defaults(func=lambda args: list_users(args.users_file))
|
|
57
|
+
parser_user_list = user_subparsers.add_parser("list", help="List all users")
|
|
58
|
+
parser_user_list.set_defaults(func=lambda args: list_users(args.config_dir))
|
|
72
59
|
|
|
73
60
|
# user add
|
|
74
|
-
parser_user_add = user_subparsers.add_parser(
|
|
75
|
-
"add", help="Add a new user to users.yaml"
|
|
76
|
-
)
|
|
61
|
+
parser_user_add = user_subparsers.add_parser("add", help="Add a new user")
|
|
77
62
|
parser_user_add.add_argument("username", type=str, help="Username to add")
|
|
78
63
|
parser_user_add.add_argument(
|
|
79
64
|
"--password", type=str, help="Password (if omitted, prompt interactively)"
|
|
80
65
|
)
|
|
81
66
|
parser_user_add.set_defaults(
|
|
82
|
-
func=lambda args: add_user(args.
|
|
67
|
+
func=lambda args: add_user(args.config_dir, args.username, args.password)
|
|
83
68
|
)
|
|
84
69
|
|
|
85
70
|
# user deactivate
|
|
86
71
|
parser_user_deactivate = user_subparsers.add_parser(
|
|
87
|
-
"deactivate", help="Deactivate a user
|
|
72
|
+
"deactivate", help="Deactivate a user"
|
|
88
73
|
)
|
|
89
74
|
parser_user_deactivate.add_argument(
|
|
90
75
|
"username", type=str, help="Username to deactivate"
|
|
91
76
|
)
|
|
92
77
|
parser_user_deactivate.set_defaults(
|
|
93
|
-
func=lambda args: deactivate_user(args.
|
|
78
|
+
func=lambda args: deactivate_user(args.config_dir, args.username)
|
|
94
79
|
)
|
|
95
80
|
|
|
96
81
|
|
|
97
82
|
def main():
|
|
98
83
|
parser = argparse.ArgumentParser(description="Supernote Server CLI")
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--config-dir",
|
|
86
|
+
type=str,
|
|
87
|
+
default=None,
|
|
88
|
+
help="Path to configuration directory (default: config/)",
|
|
89
|
+
)
|
|
99
90
|
subparsers = parser.add_subparsers(dest="command")
|
|
100
91
|
add_parser(subparsers)
|
|
101
92
|
args = parser.parse_args()
|
|
@@ -6,9 +6,9 @@ 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
|
|
9
|
+
from supernote.server.services.user import JWT_ALGORITHM
|
|
10
10
|
|
|
11
|
-
from . import
|
|
11
|
+
from .config import ServerConfig
|
|
12
12
|
from .models.base import create_error_response
|
|
13
13
|
from .routes import auth, file, system
|
|
14
14
|
from .services.file import FileService
|
|
@@ -56,8 +56,11 @@ async def trace_middleware(
|
|
|
56
56
|
"body": body_str,
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
# Get config from app
|
|
60
|
+
server_config: ServerConfig = request.app["config"]
|
|
61
|
+
|
|
59
62
|
try:
|
|
60
|
-
with open(
|
|
63
|
+
with open(server_config.trace_log_file, "a") as f:
|
|
61
64
|
f.write(json.dumps(log_entry) + "\n")
|
|
62
65
|
f.flush()
|
|
63
66
|
except Exception as e:
|
|
@@ -92,8 +95,12 @@ async def jwt_auth_middleware(
|
|
|
92
95
|
|
|
93
96
|
import jwt
|
|
94
97
|
|
|
98
|
+
server_config: ServerConfig = request.app["config"]
|
|
99
|
+
|
|
95
100
|
try:
|
|
96
|
-
payload = jwt.decode(
|
|
101
|
+
payload = jwt.decode(
|
|
102
|
+
token, server_config.auth.secret_key, algorithms=[JWT_ALGORITHM]
|
|
103
|
+
)
|
|
97
104
|
except jwt.InvalidTokenError:
|
|
98
105
|
return web.json_response(
|
|
99
106
|
create_error_response("Invalid token").to_dict(), status=401
|
|
@@ -102,15 +109,19 @@ async def jwt_auth_middleware(
|
|
|
102
109
|
return await handler(request)
|
|
103
110
|
|
|
104
111
|
|
|
105
|
-
def create_app() -> web.Application:
|
|
112
|
+
def create_app(config: ServerConfig | None = None) -> web.Application:
|
|
113
|
+
if config is None:
|
|
114
|
+
config = ServerConfig.load()
|
|
115
|
+
|
|
106
116
|
app = web.Application(middlewares=[trace_middleware, jwt_auth_middleware])
|
|
117
|
+
app["config"] = config
|
|
107
118
|
|
|
108
119
|
# Initialize services
|
|
109
|
-
storage_root = Path(config.
|
|
120
|
+
storage_root = Path(config.storage_dir)
|
|
110
121
|
temp_root = storage_root / "temp"
|
|
111
122
|
storage_service = StorageService(storage_root, temp_root)
|
|
112
123
|
app["storage_service"] = storage_service
|
|
113
|
-
app["user_service"] = UserService(config.
|
|
124
|
+
app["user_service"] = UserService(config.auth)
|
|
114
125
|
app["file_service"] = FileService(storage_service)
|
|
115
126
|
|
|
116
127
|
# Register routes
|
|
@@ -125,5 +136,7 @@ def create_app() -> web.Application:
|
|
|
125
136
|
|
|
126
137
|
def run(args: Any) -> None:
|
|
127
138
|
logging.basicConfig(level=logging.DEBUG)
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
config_dir = getattr(args, "config_dir", None)
|
|
140
|
+
config = ServerConfig.load(config_dir)
|
|
141
|
+
app = create_app(config)
|
|
142
|
+
web.run_app(app, host=config.host, port=config.port)
|
|
@@ -0,0 +1,159 @@
|
|
|
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 not absolute
|
|
96
|
+
users_path = Path(config.auth.users_file)
|
|
97
|
+
if not users_path.is_absolute():
|
|
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,73 +1,59 @@
|
|
|
1
1
|
import hashlib
|
|
2
2
|
import logging
|
|
3
|
-
import os
|
|
4
3
|
import secrets
|
|
5
4
|
import time
|
|
6
5
|
from typing import Optional
|
|
7
6
|
|
|
8
7
|
import jwt
|
|
9
|
-
import yaml
|
|
10
8
|
|
|
9
|
+
from ..config import AuthConfig, UserEntry, UsersConfig
|
|
11
10
|
from ..models.auth import LoginResult, UserVO
|
|
12
11
|
|
|
13
12
|
logger = logging.getLogger(__name__)
|
|
14
13
|
|
|
15
|
-
# TODO: This should be generated on first startup and stored somewhere secure
|
|
16
|
-
JWT_SECRET = os.environ.get("SUPERNOTE_JWT_SECRET", "supernote-secret-key")
|
|
17
14
|
JWT_ALGORITHM = "HS256"
|
|
18
|
-
JWT_EXPIRATION_HOURS = int(os.environ.get("SUPERNOTE_JWT_EXPIRATION_HOURS", "24"))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def _load_users(users_file: str) -> list[dict]:
|
|
22
|
-
if not os.path.exists(users_file):
|
|
23
|
-
return []
|
|
24
|
-
with open(users_file, "r") as f:
|
|
25
|
-
data = yaml.safe_load(f) or {"users": []}
|
|
26
|
-
return data.get("users") or []
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _save_users(users_file: str, users: list[dict]) -> None:
|
|
30
|
-
with open(users_file, "w") as f:
|
|
31
|
-
yaml.safe_dump({"users": users}, f, default_flow_style=False)
|
|
32
15
|
|
|
33
16
|
|
|
34
17
|
class UserService:
|
|
35
|
-
def __init__(self,
|
|
36
|
-
self.
|
|
37
|
-
self.
|
|
18
|
+
def __init__(self, config: AuthConfig):
|
|
19
|
+
self._config = config
|
|
20
|
+
self._users_config = UsersConfig.load(config.users_file)
|
|
38
21
|
self._random_codes: dict[
|
|
39
22
|
str, tuple[str, str]
|
|
40
23
|
] = {} # account -> (code, timestamp)
|
|
41
24
|
|
|
42
|
-
|
|
25
|
+
@property
|
|
26
|
+
def _users(self) -> list[UserEntry]:
|
|
27
|
+
return self._users_config.users
|
|
28
|
+
|
|
29
|
+
def list_users(self) -> list[UserEntry]:
|
|
43
30
|
return list(self._users)
|
|
44
31
|
|
|
45
32
|
def add_user(self, username: str, password: str) -> bool:
|
|
46
|
-
if any(u
|
|
33
|
+
if any(u.username == username for u in self._users):
|
|
47
34
|
return False
|
|
48
35
|
password_md5 = hashlib.md5(password.encode()).hexdigest()
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
"profile": {}, # User profile data
|
|
56
|
-
}
|
|
36
|
+
new_user = UserEntry(
|
|
37
|
+
username=username,
|
|
38
|
+
password_md5=password_md5,
|
|
39
|
+
is_active=True,
|
|
40
|
+
devices=[],
|
|
41
|
+
profile={},
|
|
57
42
|
)
|
|
58
|
-
|
|
43
|
+
self._users.append(new_user)
|
|
44
|
+
self._users_config.save(self._config.users_file)
|
|
59
45
|
return True
|
|
60
46
|
|
|
61
47
|
def deactivate_user(self, username: str) -> bool:
|
|
62
48
|
for user in self._users:
|
|
63
|
-
if user
|
|
64
|
-
user
|
|
65
|
-
|
|
49
|
+
if user.username == username:
|
|
50
|
+
user.is_active = False
|
|
51
|
+
self._users_config.save(self._config.users_file)
|
|
66
52
|
return True
|
|
67
53
|
return False
|
|
68
54
|
|
|
69
55
|
def check_user_exists(self, account: str) -> bool:
|
|
70
|
-
return any(u
|
|
56
|
+
return any(u.username == account for u in self._users)
|
|
71
57
|
|
|
72
58
|
def generate_random_code(self, account: str) -> tuple[str, str]:
|
|
73
59
|
"""Generate a random code for login challenge."""
|
|
@@ -77,30 +63,31 @@ class UserService:
|
|
|
77
63
|
self._random_codes[account] = (random_code, timestamp)
|
|
78
64
|
return random_code, timestamp
|
|
79
65
|
|
|
80
|
-
def _get_user(self, account: str) ->
|
|
66
|
+
def _get_user(self, account: str) -> UserEntry | None:
|
|
81
67
|
for user in self._users:
|
|
82
|
-
if user
|
|
68
|
+
if user.username == account:
|
|
83
69
|
return user
|
|
84
70
|
return None
|
|
85
71
|
|
|
86
72
|
def verify_password(self, account: str, password: str) -> bool:
|
|
87
73
|
user = self._get_user(account)
|
|
88
|
-
if not user or not user.
|
|
74
|
+
if not user or not user.is_active:
|
|
89
75
|
logger.info("User not found or inactive: %s", account)
|
|
90
76
|
return False
|
|
91
|
-
if
|
|
77
|
+
if not user.password_md5:
|
|
92
78
|
logger.info("MD5 password hash not found for user: %s", account)
|
|
93
79
|
return False
|
|
94
80
|
# Compute md5(password) and compare
|
|
95
81
|
password_bytes = password.encode()
|
|
96
82
|
hash_hex = hashlib.md5(password_bytes).hexdigest()
|
|
97
|
-
return bool(hash_hex == password_md5)
|
|
83
|
+
return bool(hash_hex == user.password_md5)
|
|
98
84
|
|
|
99
85
|
def verify_login_hash(self, account: str, client_hash: str, timestamp: str) -> bool:
|
|
100
86
|
user = self._get_user(account)
|
|
101
|
-
if not user or not user.
|
|
87
|
+
if not user or not user.is_active:
|
|
102
88
|
logger.info("User not found or inactive: %s", account)
|
|
103
89
|
return False
|
|
90
|
+
|
|
104
91
|
code_tuple = self._random_codes.get(account)
|
|
105
92
|
if not code_tuple or code_tuple[1] != timestamp:
|
|
106
93
|
logger.warning(
|
|
@@ -108,12 +95,15 @@ class UserService:
|
|
|
108
95
|
)
|
|
109
96
|
return False
|
|
110
97
|
random_code = code_tuple[0]
|
|
111
|
-
|
|
98
|
+
|
|
99
|
+
if not user.password_md5:
|
|
112
100
|
logger.info("MD5 password hash not found for user: %s", account)
|
|
113
101
|
return False
|
|
114
|
-
|
|
115
|
-
|
|
102
|
+
|
|
103
|
+
# Compute expected hash: sha256(password_md5 + random_code)
|
|
104
|
+
concat = user.password_md5 + random_code
|
|
116
105
|
expected_hash = hashlib.sha256(concat.encode()).hexdigest()
|
|
106
|
+
|
|
117
107
|
if expected_hash == client_hash:
|
|
118
108
|
return True
|
|
119
109
|
logger.info("Login hash mismatch for user: %s", account)
|
|
@@ -138,7 +128,7 @@ class UserService:
|
|
|
138
128
|
LoginResult if login is successful, None otherwise.
|
|
139
129
|
"""
|
|
140
130
|
user = self._get_user(account)
|
|
141
|
-
if not user or not user.
|
|
131
|
+
if not user or not user.is_active:
|
|
142
132
|
# TODO: Raise exceptions so we can return a useful error message
|
|
143
133
|
# to the web APIs.
|
|
144
134
|
logger.warning("Login failed: user not found or inactive: %s", account)
|
|
@@ -155,7 +145,7 @@ class UserService:
|
|
|
155
145
|
return None
|
|
156
146
|
|
|
157
147
|
# Check binding status
|
|
158
|
-
bound_devices = user.
|
|
148
|
+
bound_devices = user.devices
|
|
159
149
|
is_bind = "Y" if bound_devices else "N"
|
|
160
150
|
is_bind_equipment = "N"
|
|
161
151
|
if equipment_no and equipment_no in bound_devices:
|
|
@@ -165,9 +155,9 @@ class UserService:
|
|
|
165
155
|
"sub": account,
|
|
166
156
|
"equipment_no": equipment_no or "",
|
|
167
157
|
"iat": int(time.time()),
|
|
168
|
-
"exp": int(time.time()) + (
|
|
158
|
+
"exp": int(time.time()) + (self._config.expiration_hours * 3600),
|
|
169
159
|
}
|
|
170
|
-
token = jwt.encode(payload,
|
|
160
|
+
token = jwt.encode(payload, self._config.secret_key, algorithm=JWT_ALGORITHM)
|
|
171
161
|
|
|
172
162
|
return LoginResult(
|
|
173
163
|
token=token,
|
|
@@ -182,17 +172,17 @@ class UserService:
|
|
|
182
172
|
return None
|
|
183
173
|
|
|
184
174
|
# Default profile values
|
|
185
|
-
username = user
|
|
186
|
-
profile = user.
|
|
175
|
+
username = user.username
|
|
176
|
+
profile = user.profile
|
|
187
177
|
|
|
188
178
|
return UserVO(
|
|
189
179
|
user_name=profile.get("user_name", username),
|
|
190
|
-
email=profile.get("email", username),
|
|
191
|
-
phone=profile.get("phone", ""),
|
|
180
|
+
email=user.email or profile.get("email", username),
|
|
181
|
+
phone=user.mobile or profile.get("phone", ""),
|
|
192
182
|
country_code=profile.get("country_code", "1"),
|
|
193
183
|
total_capacity=profile.get("total_capacity", "25485312"),
|
|
194
184
|
file_server=profile.get("file_server", "0"),
|
|
195
|
-
avatars_url=profile.get("avatars_url", ""),
|
|
185
|
+
avatars_url=user.avatar or profile.get("avatars_url", ""),
|
|
196
186
|
birthday=profile.get("birthday", ""),
|
|
197
187
|
sex=profile.get("sex", ""),
|
|
198
188
|
)
|
|
@@ -205,11 +195,9 @@ class UserService:
|
|
|
205
195
|
logger.warning("User not found for binding: %s", account)
|
|
206
196
|
return False
|
|
207
197
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
user["devices"] = devices
|
|
212
|
-
_save_users(self._users_file, self._users)
|
|
198
|
+
if equipment_no not in user.devices:
|
|
199
|
+
user.devices.append(equipment_no)
|
|
200
|
+
self._users_config.save(self._config.users_file)
|
|
213
201
|
|
|
214
202
|
return True
|
|
215
203
|
|
|
@@ -218,13 +206,11 @@ class UserService:
|
|
|
218
206
|
logger.info("Unlinking equipment %s", equipment_no)
|
|
219
207
|
found = False
|
|
220
208
|
for user in self._users:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
devices.remove(equipment_no)
|
|
224
|
-
user["devices"] = devices
|
|
209
|
+
if equipment_no in user.devices:
|
|
210
|
+
user.devices.remove(equipment_no)
|
|
225
211
|
found = True
|
|
226
212
|
|
|
227
213
|
if found:
|
|
228
|
-
|
|
214
|
+
self._users_config.save(self._config.users_file)
|
|
229
215
|
|
|
230
216
|
return True
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: supernote
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
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,16 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
|
-
# Default port for Supernote Private Cloud
|
|
4
|
-
PORT = int(os.getenv("SUPERNOTE_PORT", "8080"))
|
|
5
|
-
HOST = os.getenv("SUPERNOTE_HOST", "0.0.0.0")
|
|
6
|
-
TRACE_LOG_FILE = os.getenv("SUPERNOTE_TRACE_LOG", "data/server_trace.log")
|
|
7
|
-
STORAGE_DIR = os.getenv("SUPERNOTE_STORAGE_DIR", "storage")
|
|
8
|
-
USER_CONFIG_FILE = os.getenv("SUPERNOTE_USER_CONFIG_FILE", "config/users.yaml")
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class Config:
|
|
12
|
-
PORT = PORT
|
|
13
|
-
HOST = HOST
|
|
14
|
-
TRACE_LOG_FILE = TRACE_LOG_FILE
|
|
15
|
-
STORAGE_DIR = STORAGE_DIR
|
|
16
|
-
USER_CONFIG_FILE = USER_CONFIG_FILE
|
|
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
|
|
File without changes
|