Skip to content

Commit 7642ef4

Browse files
committed
virtual user core
1 parent a473178 commit 7642ef4

File tree

10 files changed

+257
-39
lines changed

10 files changed

+257
-39
lines changed

lfss/api/connector.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,8 @@ def list_path(
378378
path = _p(path)
379379
if path == '/':
380380
# handle root path separately
381-
dirnames = [f'{self.whoami().username}/'] + [f'{p.username}/' for p in self.peers(AccessLevel.READ)]
381+
my_username = self.whoami().username
382+
dirnames = [f'{my_username}/'] if not my_username.startswith('.') else [] + [f'{p.username}/' for p in self.peers(AccessLevel.READ)]
382383
return PathContents(
383384
dirs = [DirectoryRecord(url = d) for d in dirnames],
384385
files = []

lfss/cli/user.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import argparse, asyncio, os
22
from contextlib import asynccontextmanager
33
from .cli import parse_permission, FileReadPermission
4-
from ..eng.utils import parse_storage_size, fmt_storage_size
4+
from ..eng.utils import parse_storage_size, fmt_storage_size, fmt_sec_time
55
from ..eng.datatype import AccessLevel
66
from ..eng.database import Database, FileReadPermission, transaction, UserConn, unique_cursor, FileConn
77
from ..eng.connection_pool import global_entrance
@@ -17,6 +17,11 @@ async def _main():
1717
sp_add.add_argument('--admin', action='store_true', help='Set user as admin')
1818
sp_add.add_argument("--permission", type=parse_permission, default=FileReadPermission.UNSET, help="File fallback read permission, can be public, protected, private, or unset")
1919
sp_add.add_argument('--max-storage', type=parse_storage_size, default="10G", help="Maximum storage size, e.g. 1G, 100M, 10K, default is 10G")
20+
21+
sp_add_virtual = sp.add_parser('add-virtual', help="Add a virtual (hidden) user, username will be prefixed with '.v-'")
22+
sp_add_virtual.add_argument('--tag', type=str, default=None, help="Tag for the virtual user, will be embedded in the username for easier identification")
23+
sp_add_virtual.add_argument('--peers', type=str, default="", help="Peer users and their access levels in the format 'READ:user1,user2;WRITE:user3'")
24+
sp_add_virtual.add_argument('--max-storage', type=parse_storage_size, default="1G", help="Maximum storage size for the virtual user, e.g. 1G, 100M, 10K, default is 1G")
2025

2126
sp_delete = sp.add_parser('delete')
2227
sp_delete.add_argument('username', type=str)
@@ -37,11 +42,16 @@ def parse_bool(s):
3742
sp_list = sp.add_parser('list')
3843
sp_list.add_argument("username", nargs='*', type=str, default=None)
3944
sp_list.add_argument("-l", "--long", action="store_true", help="Show detailed information, including credential and peer users")
45+
sp_list.add_argument("--hidden", action="store_true", help="Include hidden users (virtual users) in the listing")
4046

4147
sp_peer = sp.add_parser('set-peer')
4248
sp_peer.add_argument('src_username', type=str)
4349
sp_peer.add_argument('dst_username', type=str)
4450
sp_peer.add_argument('--level', type=parse_access_level, default=AccessLevel.READ, help="Access level")
51+
52+
sp_expire = sp.add_parser('set-expire')
53+
sp_expire.add_argument('username', type=str)
54+
sp_expire.add_argument('expire_time', type=str, nargs='?', default=None, help="Expire time in seconds or a string like '1d2h3m4s'. If not provided, the user will never expire.")
4555

4656
args = parser.parse_args()
4757
db = await Database().init()
@@ -61,6 +71,14 @@ async def get_uconn():
6171
)
6272
print('User created, credential:', user.credential)
6373

74+
if args.subparser_name == 'add-virtual':
75+
user = await UserCtl.add_virtual(
76+
tag=args.tag,
77+
peers=args.peers,
78+
max_storage=args.max_storage
79+
)
80+
print('Virtual user created, username:', user.username, ', credential:', user.credential)
81+
6482
if args.subparser_name == 'delete':
6583
user = await UserCtl.delete(args.username)
6684
print('User deleted')
@@ -79,10 +97,20 @@ async def get_uconn():
7997
await UserCtl.set_peer(args.src_username, args.dst_username, args.level)
8098
print(f"Peer set: [{args.src_username}] now have [{args.level.name}] access to [{args.dst_username}]")
8199

100+
if args.subparser_name == 'set-expire':
101+
await UserCtl.set_expire(args.username, args.expire_time)
102+
print(f"User [{args.username}] expire time set.")
103+
82104
if args.subparser_name == 'list':
83105
async with get_uconn() as uconn:
84106
term_width = os.get_terminal_size().columns
85-
async for user in uconn.all():
107+
async def __iter_users():
108+
if args.hidden:
109+
async for user in uconn.iter_all(): yield user
110+
async for user in uconn.iter_hidden(): yield user
111+
else:
112+
async for user in uconn.iter_all(): yield user
113+
async for user in __iter_users():
86114
if args.username and not user.username in args.username:
87115
continue
88116
print("\033[90m-\033[0m" * term_width)
@@ -93,6 +121,7 @@ async def get_uconn():
93121
user_size_used = await fconn.user_size(user.id)
94122
print('- Credential: ', user.credential)
95123
print(f'- Storage: {fmt_storage_size(user_size_used)} / {fmt_storage_size(user.max_storage)}')
124+
print(f'- Expire: {fmt_sec_time(exp_time) if (exp_time := await uconn.query_user_expire(user.id)) is not None else "never"}')
96125
for p in AccessLevel:
97126
if p > AccessLevel.NONE:
98127
usernames = [x.username for x in await uconn.list_peer_users(user.id, p)]

lfss/eng/database_conn.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Optional, Literal, overload
44
from collections.abc import AsyncIterable
55
from abc import ABC
6-
import re
6+
import re, time
77

88
import urllib.parse
99
import asyncio
@@ -86,15 +86,17 @@ async def get_user_by_credential(self, credential: str) -> Optional[UserRecord]:
8686

8787
async def create_user(
8888
self, username: str, password: str, is_admin: bool = False,
89-
max_storage: int = 1073741824, permission: FileReadPermission = FileReadPermission.UNSET
89+
max_storage: int = 1073741824, permission: FileReadPermission = FileReadPermission.UNSET,
90+
_validate = True
9091
) -> int:
9192
def validate_username(username: str):
9293
assert_or(not set(username) & {'/', ':'}, InvalidInputError("Invalid username"))
9394
assert_or(not username.startswith('_'), InvalidInputError("Error: reserved username"))
9495
assert_or(not username.startswith('.'), InvalidInputError("Error: reserved username"))
9596
assert_or(not (len(username) > 255), InvalidInputError("Username too long"))
9697
assert_or(urllib.parse.quote(username) == username, InvalidInputError("Invalid username, must be URL safe"))
97-
validate_username(username)
98+
if _validate:
99+
validate_username(username)
98100
self.logger.debug(f"Creating user {username}")
99101
credential = hash_credential(username, password)
100102
assert_or(await self.get_user(username) is None, InvalidDataError(f"Duplicate username: {username}"))
@@ -103,6 +105,22 @@ def validate_username(username: str):
103105
assert self.cur.lastrowid is not None
104106
return self.cur.lastrowid
105107

108+
async def query_user_expire(self, user_id: int) -> Optional[int]:
109+
""" Return the remaining seconds before user expire, None if no expire is set. """
110+
await self.cur.execute("SELECT posix_stamp FROM uexpire WHERE user_id = ?", (user_id, ))
111+
res = await self.cur.fetchone()
112+
return res[0] - int(time.time()) if res is not None else None
113+
114+
async def set_user_expire(self, user_id: int, expire_seconds: int):
115+
""" Set the user to expire in `expire_seconds` seconds from now. """
116+
expire_time = int(time.time()) + expire_seconds
117+
await self.cur.execute("INSERT OR REPLACE INTO uexpire (user_id, posix_stamp) VALUES (?, ?)", (user_id, expire_time))
118+
self.logger.info(f"Set user {user_id} to expire in {expire_seconds} seconds")
119+
120+
async def clear_user_expire(self, user_id: int):
121+
await self.cur.execute("DELETE FROM uexpire WHERE user_id = ?", (user_id, ))
122+
self.logger.info(f"Cleared expire for user {user_id}")
123+
106124
async def update_user(
107125
self, username: str, password: Optional[str] = None, is_admin: Optional[bool] = None,
108126
max_storage: Optional[int] = None, permission: Optional[FileReadPermission] = None
@@ -130,17 +148,25 @@ async def update_user(
130148
)
131149
self.logger.info(f"User {username} updated")
132150

133-
async def all(self):
134-
await self.cur.execute("SELECT * FROM user")
151+
async def iter_all(self) -> AsyncIterable[UserRecord]:
152+
await self.cur.execute("SELECT * FROM user where username NOT LIKE '.%'")
153+
for record in await self.cur.fetchall():
154+
yield self.parse_record(record)
155+
156+
async def iter_hidden(self) -> AsyncIterable[UserRecord]:
157+
await self.cur.execute("SELECT * FROM user where username LIKE '.%'")
135158
for record in await self.cur.fetchall():
136159
yield self.parse_record(record)
137160

138161
async def set_active(self, username: str):
139162
await self.cur.execute("UPDATE user SET last_active = CURRENT_TIMESTAMP WHERE username = ?", (username, ))
140163

141164
async def delete_user(self, username: str):
165+
""" Note: this will not delete files owned by the user, please use higher level API to delete user and files together. """
142166
await self.cur.execute("DELETE FROM upeer WHERE src_user_id = (SELECT id FROM user WHERE username = ?) OR dst_user_id = (SELECT id FROM user WHERE username = ?)", (username, username))
143167
await self.cur.execute("DELETE FROM user WHERE username = ?", (username, ))
168+
await self.cur.execute("DELETE FROM usize WHERE user_id = (SELECT id FROM user WHERE username = ?)", (username, ))
169+
await self.cur.execute("DELETE FROM uexpire WHERE user_id = (SELECT id FROM user WHERE username = ?)", (username, ))
144170
self.logger.info(f"Delete user {username}")
145171

146172
async def set_peer_level(self, src_user: int | str, dst_user: int | str, level: AccessLevel):
@@ -185,7 +211,7 @@ async def query_peer_level(self, src_user_id: int, dst_user_id: int) -> AccessLe
185211
return AccessLevel(res[0])
186212

187213
async def list_all_users(self) -> list[UserRecord]:
188-
return [u async for u in self.all()]
214+
return [u async for u in self.iter_all()]
189215

190216
async def list_admin_users(self) -> list[UserRecord]:
191217
await self.cur.execute("SELECT * FROM user WHERE is_admin = 1")

lfss/eng/log.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .config import LOG_DIR, DISABLE_LOGGING
1+
from .config import LOG_DIR, DISABLE_LOGGING, DEBUG_MODE
22
import time, sqlite3, dataclasses
33
from typing import TypeVar, Callable, Literal, Optional
44
from concurrent.futures import ThreadPoolExecutor
@@ -130,7 +130,7 @@ def get_logger(
130130
name = 'default',
131131
log_home = LOG_DIR,
132132
level = 'DEBUG',
133-
term_level = 'INFO',
133+
term_level = 'INFO' if not DEBUG_MODE else 'DEBUG',
134134
file_handler_type: _fh_T = 'sqlite',
135135
global_instance = True
136136
)->BaseLogger:

lfss/eng/permission.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ async def this_cur():
8181
yield _cur
8282
else:
8383
yield cursor
84+
85+
# reserved paths
86+
if path.startswith('.') or path.startswith('/.'):
87+
return AccessLevel.NONE
8488

8589
# check if path user exists, may raise exception
8690
async with this_cur() as cur:

0 commit comments

Comments
 (0)