lockbot 2.2.0__tar.gz → 2.3.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.
Files changed (59) hide show
  1. {lockbot-2.2.0/python/lockbot.egg-info → lockbot-2.3.0}/PKG-INFO +1 -1
  2. {lockbot-2.2.0 → lockbot-2.3.0}/pyproject.toml +1 -1
  3. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/admin/router.py +37 -16
  4. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/auth/dependencies.py +49 -1
  5. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/auth/models.py +6 -0
  6. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/auth/router.py +3 -3
  7. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/bots/router.py +15 -5
  8. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/main.py +16 -0
  9. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/settings/router.py +27 -1
  10. {lockbot-2.2.0 → lockbot-2.3.0/python/lockbot.egg-info}/PKG-INFO +1 -1
  11. {lockbot-2.2.0 → lockbot-2.3.0}/LICENSE +0 -0
  12. {lockbot-2.2.0 → lockbot-2.3.0}/MANIFEST.in +0 -0
  13. {lockbot-2.2.0 → lockbot-2.3.0}/README.md +0 -0
  14. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/__init__.py +0 -0
  15. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/__init__.py +0 -0
  16. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/__init__.py +0 -0
  17. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/admin/__init__.py +0 -0
  18. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/auth/__init__.py +0 -0
  19. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/auth/schemas.py +0 -0
  20. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/bots/__init__.py +0 -0
  21. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/bots/encryption.py +0 -0
  22. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/bots/manager.py +0 -0
  23. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/bots/models.py +0 -0
  24. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/bots/schemas.py +0 -0
  25. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/bots/webhook_handler.py +0 -0
  26. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/config.py +0 -0
  27. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/database.py +0 -0
  28. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/logs/__init__.py +0 -0
  29. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/settings/__init__.py +0 -0
  30. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/backend/app/settings/models.py +0 -0
  31. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/__init__.py +0 -0
  32. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/base_bot.py +0 -0
  33. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/bot_instance.py +0 -0
  34. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/config.py +0 -0
  35. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/device_bot.py +0 -0
  36. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/device_usage_alert.py +0 -0
  37. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/device_usage_utils.py +0 -0
  38. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/entry.py +0 -0
  39. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/env.py +0 -0
  40. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/handler.py +0 -0
  41. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/i18n/__init__.py +0 -0
  42. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/i18n/en.py +0 -0
  43. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/i18n/zh.py +0 -0
  44. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/io.py +0 -0
  45. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/message_adapter.py +0 -0
  46. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/msg_utils.py +0 -0
  47. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/node_bot.py +0 -0
  48. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/platforms/__init__.py +0 -0
  49. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/platforms/infoflow.py +0 -0
  50. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/queue_bot.py +0 -0
  51. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/request.py +0 -0
  52. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot/core/utils.py +0 -0
  53. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot.egg-info/SOURCES.txt +0 -0
  54. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot.egg-info/dependency_links.txt +0 -0
  55. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot.egg-info/requires.txt +0 -0
  56. {lockbot-2.2.0 → lockbot-2.3.0}/python/lockbot.egg-info/top_level.txt +0 -0
  57. {lockbot-2.2.0 → lockbot-2.3.0}/setup.cfg +0 -0
  58. {lockbot-2.2.0 → lockbot-2.3.0}/tools/create_super_admin.py +0 -0
  59. {lockbot-2.2.0 → lockbot-2.3.0}/tools/gen_keys.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lockbot
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Cluster resource management bot for IM platforms
5
5
  Author-email: Jianbang Yang <yangjianbang112@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lockbot"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "Cluster resource management bot for IM platforms"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -16,7 +16,13 @@ from fastapi.responses import FileResponse, Response
16
16
  from sqlalchemy.orm import Session
17
17
  from starlette.background import BackgroundTask
18
18
 
19
- from lockbot.backend.app.auth.dependencies import can_manage_user, require_admin, require_super_admin
19
+ from lockbot.backend.app.auth.dependencies import (
20
+ can_assign_role,
21
+ can_create_user_with_role,
22
+ can_manage_user,
23
+ require_admin,
24
+ require_super_admin,
25
+ )
20
26
  from lockbot.backend.app.auth.models import User
21
27
  from lockbot.backend.app.auth.router import _generate_password, _hash_password
22
28
  from lockbot.backend.app.auth.schemas import (
@@ -31,10 +37,6 @@ from lockbot.backend.app.database import get_db
31
37
 
32
38
  router = APIRouter(prefix="/api/admin", tags=["admin"])
33
39
 
34
- ADMIN_VISIBLE_ROLES = ("admin", "user")
35
- SUPER_ADMIN_VISIBLE_ROLES = ("super_admin", "admin", "user")
36
- ADMIN_CREATABLE_ROLES = ("user",)
37
-
38
40
 
39
41
  @router.post("/users", response_model=PasswordResetOut, status_code=201)
40
42
  def admin_create_user(
@@ -43,12 +45,10 @@ def admin_create_user(
43
45
  db: Session = Depends(get_db),
44
46
  ):
45
47
  """Admin creates a user. Super_admin can create admin/user; admin can only create user."""
46
- # Non-super_admin can only create regular users
47
- if operator.role != "super_admin" and body.role not in ADMIN_CREATABLE_ROLES:
48
- raise HTTPException(status_code=403, detail="Cannot create user with this role")
49
-
50
- if body.role not in SUPER_ADMIN_VISIBLE_ROLES:
51
- raise HTTPException(status_code=400, detail=f"Invalid role, must be one of {SUPER_ADMIN_VISIBLE_ROLES}")
48
+ # Use unified permission check
49
+ allowed, status_code, error_msg = can_create_user_with_role(operator, body.role)
50
+ if not allowed:
51
+ raise HTTPException(status_code=status_code, detail=error_msg)
52
52
 
53
53
  exists = db.query(User).filter(User.username == body.username).first()
54
54
  if exists:
@@ -133,11 +133,13 @@ def admin_edit_user(
133
133
  target.email = body.email
134
134
 
135
135
  if body.role is not None:
136
- if body.role not in SUPER_ADMIN_VISIBLE_ROLES:
137
- raise HTTPException(status_code=400, detail="Invalid role")
138
- # Only super_admin can promote to admin
139
- if body.role in ("admin", "super_admin") and operator.role != "super_admin":
140
- raise HTTPException(status_code=403, detail="Only super admin can set this role")
136
+ # Use unified permission check
137
+ allowed, status_code, error_msg = can_assign_role(operator, target, body.role)
138
+ if not allowed:
139
+ raise HTTPException(status_code=status_code, detail=error_msg)
140
+ # Force logout when role changes
141
+ if target.role != body.role:
142
+ target.token_version = target.effective_token_version + 1
141
143
  target.role = body.role
142
144
 
143
145
  if body.max_running_bots is not None:
@@ -181,11 +183,30 @@ def admin_reset_password(
181
183
  raw_password = _generate_password()
182
184
  target.password_hash = _hash_password(raw_password)
183
185
  target.must_change_password = True
186
+ # Force logout - invalidate all existing tokens
187
+ target.token_version = target.effective_token_version + 1
184
188
  db.commit()
185
189
  db.refresh(target)
186
190
  return PasswordResetOut(id=target.id, username=target.username, new_password=raw_password)
187
191
 
188
192
 
193
+ @router.post("/users/{user_id}/force-logout")
194
+ def force_logout_user(
195
+ user_id: int,
196
+ operator: User = Depends(require_admin),
197
+ db: Session = Depends(get_db),
198
+ ):
199
+ """Force a user to logout by invalidating all their tokens."""
200
+ target = db.get(User, user_id)
201
+ if not target:
202
+ raise HTTPException(status_code=404, detail="User not found")
203
+ if not can_manage_user(operator, target.role):
204
+ raise HTTPException(status_code=403, detail="Cannot manage this user")
205
+ target.token_version = target.effective_token_version + 1
206
+ db.commit()
207
+ return {"id": target.id, "username": target.username, "message": "User logged out successfully"}
208
+
209
+
189
210
  @router.get("/bots")
190
211
  def list_all_bots(
191
212
  _admin: User = Depends(require_admin),
@@ -23,10 +23,11 @@ _security = HTTPBearer(auto_error=False)
23
23
  _ROLE_LEVELS = {"super_admin": 0, "admin": 1, "user": 2}
24
24
 
25
25
 
26
- def create_access_token(user_id: int, must_change_password: bool = False) -> str:
26
+ def create_access_token(user_id: int, token_version: int = 0, must_change_password: bool = False) -> str:
27
27
  payload = {
28
28
  "sub": str(user_id),
29
29
  "exp": datetime.now(timezone.utc) + timedelta(minutes=JWT_EXPIRE_MINUTES),
30
+ "ver": token_version,
30
31
  "mcp": must_change_password,
31
32
  }
32
33
  return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
@@ -42,12 +43,18 @@ def get_current_user(
42
43
  try:
43
44
  payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
44
45
  user_id = int(payload["sub"])
46
+ token_version = payload.get("ver", 0) # Default to 0 for old tokens without version
45
47
  except (jwt.PyJWTError, KeyError, ValueError):
46
48
  raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from None
47
49
 
48
50
  user = db.get(User, user_id)
49
51
  if user is None:
50
52
  raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
53
+
54
+ # Check token version - invalidate token if version mismatch
55
+ if user.effective_token_version != token_version:
56
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired, please login again")
57
+
51
58
  return user
52
59
 
53
60
 
@@ -70,3 +77,44 @@ def can_manage_user(operator: User, target_role: str) -> bool:
70
77
  op_level = _ROLE_LEVELS.get(operator.role, 3)
71
78
  tgt_level = _ROLE_LEVELS.get(target_role, 3)
72
79
  return op_level < tgt_level
80
+
81
+
82
+ def can_assign_role(operator: User, target: User, new_role: str) -> tuple[bool, int, str]:
83
+ """
84
+ Check if operator can change target's role to new_role.
85
+
86
+ Returns (allowed, http_status_code, error_message).
87
+ This consolidates all role assignment permission checks in one place.
88
+ """
89
+ # Validate role
90
+ valid_roles = ("super_admin", "admin", "user")
91
+ if new_role not in valid_roles:
92
+ return False, 400, f"Invalid role, must be one of {valid_roles}"
93
+
94
+ # Cannot manage users at or above your own level
95
+ if not can_manage_user(operator, target.role):
96
+ return False, 403, "Cannot manage this user"
97
+
98
+ # Only super_admin can assign admin or super_admin role
99
+ if new_role in ("admin", "super_admin") and operator.role != "super_admin":
100
+ return False, 403, "Only super admin can assign this role"
101
+
102
+ return True, 200, ""
103
+
104
+
105
+ def can_create_user_with_role(operator: User, role: str) -> tuple[bool, int, str]:
106
+ """
107
+ Check if operator can create a user with the given role.
108
+
109
+ Returns (allowed, http_status_code, error_message).
110
+ """
111
+ # Validate role
112
+ valid_roles = ("admin", "user")
113
+ if role not in valid_roles:
114
+ return False, 400, f"Invalid role, must be one of {valid_roles}"
115
+
116
+ # Only super_admin can create admin
117
+ if role == "admin" and operator.role != "super_admin":
118
+ return False, 403, "Only super admin can create user with this role"
119
+
120
+ return True, 200, ""
@@ -20,5 +20,11 @@ class User(Base):
20
20
  role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
21
21
  max_running_bots: Mapped[int] = mapped_column(Integer, default=10)
22
22
  must_change_password: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
23
+ token_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
23
24
  created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
24
25
  updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
26
+
27
+ @property
28
+ def effective_token_version(self) -> int:
29
+ """Return token_version, treating NULL (from pre-migration rows) as 0."""
30
+ return self.token_version if self.token_version is not None else 0
@@ -75,7 +75,7 @@ def login(body: UserLogin, db: Session = Depends(get_db)):
75
75
  if not user or not _verify_password(body.password, user.password_hash):
76
76
  raise HTTPException(status_code=401, detail="Invalid credentials")
77
77
  return TokenOut(
78
- access_token=create_access_token(user.id, user.must_change_password),
78
+ access_token=create_access_token(user.id, user.effective_token_version, user.must_change_password),
79
79
  must_change_password=user.must_change_password,
80
80
  )
81
81
 
@@ -99,7 +99,7 @@ def change_password(
99
99
  user.must_change_password = False
100
100
  db.commit()
101
101
  return TokenOut(
102
- access_token=create_access_token(user.id, False),
102
+ access_token=create_access_token(user.id, user.effective_token_version, False),
103
103
  must_change_password=False,
104
104
  )
105
105
 
@@ -120,7 +120,7 @@ def force_change_password(
120
120
  user.must_change_password = False
121
121
  db.commit()
122
122
  return TokenOut(
123
- access_token=create_access_token(user.id, False),
123
+ access_token=create_access_token(user.id, user.effective_token_version, False),
124
124
  must_change_password=False,
125
125
  )
126
126
 
@@ -400,6 +400,10 @@ def start_bot(
400
400
  ):
401
401
  bot = _get_user_bot(bot_id, user, db)
402
402
 
403
+ # Only owner or super_admin can start
404
+ if user.role != "super_admin" and bot.user_id != user.id:
405
+ raise HTTPException(status_code=403, detail="Cannot start another user's bot")
406
+
403
407
  if bot.status == "running":
404
408
  return BotStatusOut(
405
409
  id=bot.id,
@@ -462,6 +466,10 @@ def stop_bot(
462
466
  ):
463
467
  bot = _get_user_bot(bot_id, user, db)
464
468
 
469
+ # Only owner or super_admin can stop
470
+ if user.role != "super_admin" and bot.user_id != user.id:
471
+ raise HTTPException(status_code=403, detail="Cannot stop another user's bot")
472
+
465
473
  if bot.status not in ("running", "error"):
466
474
  raise HTTPException(status_code=409, detail=f"Bot is not running (status={bot.status})")
467
475
 
@@ -485,6 +493,10 @@ def restart_bot(
485
493
  ):
486
494
  bot = _get_user_bot(bot_id, user, db)
487
495
 
496
+ # Only owner or super_admin can restart
497
+ if user.role != "super_admin" and bot.user_id != user.id:
498
+ raise HTTPException(status_code=403, detail="Cannot restart another user's bot")
499
+
488
500
  config_dict = _build_config_dict(bot, db)
489
501
 
490
502
  try:
@@ -785,11 +797,9 @@ def update_bot_state(
785
797
 
786
798
  bot = _get_user_bot(bot_id, user, db)
787
799
 
788
- # Admin cannot edit other admins' bot state
789
- if user.role == "admin" and bot.user_id != user.id:
790
- owner = db.get(User, bot.user_id)
791
- if owner and owner.role in ("admin", "super_admin"):
792
- raise HTTPException(status_code=403, detail="Cannot edit another admin's bot state")
800
+ # Admin can only edit own bot state, super_admin can edit any
801
+ if user.role != "super_admin" and bot.user_id != user.id:
802
+ raise HTTPException(status_code=403, detail="Cannot edit another user's bot state")
793
803
 
794
804
  if bot.status == "running":
795
805
  raise HTTPException(status_code=409, detail="Stop the bot before editing state")
@@ -91,6 +91,21 @@ def _migrate_bot_soft_delete():
91
91
  logger.info("Migrated bots: added 'deleted_at' column")
92
92
 
93
93
 
94
+ def _migrate_users_token_version():
95
+ """Add 'token_version' column to users if it doesn't exist (SQLite migration)."""
96
+ from sqlalchemy import inspect as sa_inspect
97
+ from sqlalchemy import text
98
+
99
+ insp = sa_inspect(engine)
100
+ if "users" not in insp.get_table_names():
101
+ return
102
+ columns = [c["name"] for c in insp.get_columns("users")]
103
+ if "token_version" not in columns:
104
+ with engine.begin() as conn:
105
+ conn.execute(text("ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0"))
106
+ logger.info("Migrated users: added 'token_version' column")
107
+
108
+
94
109
  def _seed_dev_admin():
95
110
  """Create admin user in dev mode if it doesn't exist."""
96
111
  from lockbot.backend.app.config import (
@@ -172,6 +187,7 @@ async def lifespan(app: FastAPI):
172
187
  _migrate_bot_logs_category()
173
188
  _migrate_bot_consecutive_failures()
174
189
  _migrate_users_must_change_password()
190
+ _migrate_users_token_version()
175
191
  _migrate_bot_soft_delete()
176
192
  _seed_dev_admin()
177
193
  _seed_dev_users()
@@ -7,10 +7,12 @@ from datetime import datetime
7
7
 
8
8
  from fastapi import APIRouter, Depends, HTTPException
9
9
  from pydantic import BaseModel
10
+ from sqlalchemy import func as sa_func
10
11
  from sqlalchemy.orm import Session
11
12
 
12
- from lockbot.backend.app.auth.dependencies import require_super_admin
13
+ from lockbot.backend.app.auth.dependencies import get_current_user, require_super_admin
13
14
  from lockbot.backend.app.auth.models import User
15
+ from lockbot.backend.app.bots.models import Bot
14
16
  from lockbot.backend.app.database import get_db
15
17
  from lockbot.backend.app.settings.models import SiteSetting
16
18
 
@@ -64,6 +66,30 @@ def public_settings(db: Session = Depends(get_db)):
64
66
  return [SettingOut(key=k, value=_get_setting(db, k)) for k in sorted(PUBLIC_KEYS)]
65
67
 
66
68
 
69
+ class PlatformStats(BaseModel):
70
+ """Public platform statistics."""
71
+
72
+ total_users: int
73
+ total_bots: int
74
+ running_bots: int
75
+
76
+
77
+ @router.get("/api/public/stats", response_model=PlatformStats)
78
+ def get_public_stats(
79
+ _user: User = Depends(get_current_user),
80
+ db: Session = Depends(get_db),
81
+ ):
82
+ """Return platform statistics (requires login)."""
83
+ total_users = db.query(sa_func.count(User.id)).scalar()
84
+ total_bots = db.query(sa_func.count(Bot.id)).filter(Bot.is_deleted.is_(False)).scalar()
85
+ running_bots = db.query(sa_func.count(Bot.id)).filter(Bot.is_deleted.is_(False), Bot.status == "running").scalar()
86
+ return PlatformStats(
87
+ total_users=total_users,
88
+ total_bots=total_bots,
89
+ running_bots=running_bots,
90
+ )
91
+
92
+
67
93
  # ── Admin endpoints (super_admin only) ──────────────────
68
94
 
69
95
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lockbot
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Cluster resource management bot for IM platforms
5
5
  Author-email: Jianbang Yang <yangjianbang112@gmail.com>
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes
File without changes