lfss 0.7.2__py3-none-any.whl → 0.7.4__py3-none-any.whl

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.
Readme.md CHANGED
@@ -2,8 +2,8 @@
2
2
  [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
3
3
 
4
4
  My experiment on a lightweight file/object storage service.
5
- It stores small files and metadata in sqlite, large files in the filesystem, and serves them through a simple REST API.
6
- Tested on 2 million files, and it works fine... thanks to the sqlite database!
5
+ It stores small files and metadata in sqlite, large files in the filesystem.
6
+ Tested on 2 million files, and it works fine...
7
7
 
8
8
  Usage:
9
9
  ```sh
docs/Permission.md CHANGED
@@ -1,22 +1,12 @@
1
1
 
2
2
  # Permission System
3
- There are two roles in the system: Admin and User (you can treat then as buckets).
3
+ There are two roles in the system: Admin and User ('user' are like 'bucket' to some extent).
4
4
 
5
- ## `PUT` and `DELETE` permissions
6
- Non-login user don't have `PUT/DELETE` permissions.
7
- Every user can have `PUT/DELETE` permissions of files under its own `/<user>/` path.
8
- The admin can have `PUT/DELETE` permissions of files of all users.
5
+ ## File access with `GET` permission
6
+ The `GET` is used to access the file (if path is not ending with `/`), or to list the files under a path (if path is ending with `/`).
9
7
 
10
- ## `GET` permissions
11
- The `GET` is used to access the file (if path is not ending with `/`), or to list the files under a path (if path is ending with `/`).
12
-
13
- ### Path-listing
14
- - Non-login users cannot list any files.
15
- - All users can list the files under their own path
16
- - Admins can list the files under other users' path.
17
-
18
- ### File-access
19
- For accessing file content, the user must have `GET` permission of the file, which is determined by the `permission` field of both the owner and the file.
8
+ ### File access
9
+ For accessing file content, the user must have `GET` permission of the file, which is determined by the `permission` field of both the owner and the file.
20
10
  (Note: The owner of the file is the user who created the file, may not necessarily be the user under whose path the file is stored.)
21
11
 
22
12
  There are four types of permissions: `unset`, `public`, `protected`, `private`.
@@ -26,4 +16,29 @@ Non-admin users can access files based on:
26
16
  - If the file is `protected`, then only the logged-in user can access it.
27
17
  - If the file is `private`, then only the owner can access it.
28
18
  - If the file is `unset`, then the file's permission is inherited from the owner's permission.
29
- - If both the owner and the file have `unset` permission, then the file is `public`.
19
+ - If both the owner and the file have `unset` permission, then the file is `public`.
20
+
21
+ ### Meta-data access
22
+ - Non-login users can't access any file-meta.
23
+ - All users can access the file-meta of files under their own path.
24
+ - For files under other users' path, the file-meta is determined in a way same as file access.
25
+ - Admins can access the path-meta of all users.
26
+ - All users can access the path-meta of their own path.
27
+
28
+ ### Path-listing
29
+ - Non-login users cannot list any files.
30
+ - All users can list the files under their own path
31
+ - Admins can list the files under other users' path.
32
+
33
+ ## File creation with `PUT` permission
34
+ The `PUT` is used to create a file.
35
+ - Non-login user don't have `PUT` permission.
36
+ - Every user can have `PUT` permission of files under its own `/<user>/` path.
37
+ - The admin can have `PUT` permission of files of all users.
38
+
39
+ ## `DELETE` and moving permissions
40
+ - Non-login user don't have `DELETE`/move permission.
41
+ - Every user can have `DELETE`/move permission that they own.
42
+ - The admin can have `DELETE` permission of files of all users
43
+ (The admin can't move files of other users, because move does not change the owner of the file.
44
+ If move is allowed, then its equivalent to create file on behalf of other users.)
lfss/cli/cli.py CHANGED
@@ -1,22 +1,32 @@
1
- from lfss.client import Connector, upload_directory
2
- from lfss.src.database import FileReadPermission
1
+ from lfss.client import Connector, upload_directory, upload_file, download_file, download_directory
2
+ from lfss.src.datatype import FileReadPermission
3
3
  from pathlib import Path
4
4
  import argparse
5
5
 
6
6
  def parse_arguments():
7
7
  parser = argparse.ArgumentParser(description="Command line interface, please set LFSS_ENDPOINT and LFSS_TOKEN environment variables.")
8
+ parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
8
9
 
9
10
  sp = parser.add_subparsers(dest="command", required=True)
10
11
 
11
12
  # upload
12
13
  sp_upload = sp.add_parser("upload", help="Upload files")
13
14
  sp_upload.add_argument("src", help="Source file or directory", type=str)
14
- sp_upload.add_argument("dst", help="Destination path", type=str)
15
+ sp_upload.add_argument("dst", help="Destination url path", type=str)
15
16
  sp_upload.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent uploads")
16
17
  sp_upload.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory upload")
17
18
  sp_upload.add_argument("--conflict", choices=["overwrite", "abort", "skip", "skip-ahead"], default="abort", help="Conflict resolution")
18
19
  sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
19
- sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries, only works with directory upload")
20
+ sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries")
21
+
22
+ # download
23
+ sp_download = sp.add_parser("download", help="Download files")
24
+ sp_download.add_argument("src", help="Source url path", type=str)
25
+ sp_download.add_argument("dst", help="Destination file or directory", type=str)
26
+ sp_download.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent downloads")
27
+ sp_download.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory download")
28
+ sp_download.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
29
+ sp_download.add_argument("--retries", type=int, default=0, help="Number of retries")
20
30
 
21
31
  return parser.parse_args()
22
32
 
@@ -28,9 +38,9 @@ def main():
28
38
  if src_path.is_dir():
29
39
  failed_upload = upload_directory(
30
40
  connector, args.src, args.dst,
31
- verbose=True,
41
+ verbose=args.verbose,
32
42
  n_concurrent=args.jobs,
33
- n_reties=args.retries,
43
+ n_retries=args.retries,
34
44
  interval=args.interval,
35
45
  conflict=args.conflict,
36
46
  permission=args.permission
@@ -40,13 +50,46 @@ def main():
40
50
  for path in failed_upload:
41
51
  print(f" {path}")
42
52
  else:
43
- with open(args.src, 'rb') as f:
44
- connector.put(
45
- args.dst,
46
- f.read(),
47
- conflict=args.conflict,
48
- permission=args.permission
49
- )
53
+ success = upload_file(
54
+ connector,
55
+ file_path = args.src,
56
+ dst_url = args.dst,
57
+ verbose=args.verbose,
58
+ n_retries=args.retries,
59
+ interval=args.interval,
60
+ conflict=args.conflict,
61
+ permission=args.permission
62
+ )
63
+ if not success:
64
+ print("Failed to upload.")
65
+
66
+ elif args.command == "download":
67
+ is_dir = args.src.endswith("/")
68
+ if is_dir:
69
+ failed_download = download_directory(
70
+ connector, args.src, args.dst,
71
+ verbose=args.verbose,
72
+ n_concurrent=args.jobs,
73
+ n_retries=args.retries,
74
+ interval=args.interval,
75
+ overwrite=args.overwrite
76
+ )
77
+ if failed_download:
78
+ print("Failed to download:")
79
+ for path in failed_download:
80
+ print(f" {path}")
81
+ else:
82
+ success = download_file(
83
+ connector,
84
+ src_url = args.src,
85
+ file_path = args.dst,
86
+ verbose=args.verbose,
87
+ n_retries=args.retries,
88
+ interval=args.interval,
89
+ overwrite=args.overwrite
90
+ )
91
+ if not success:
92
+ print("Failed to download.")
50
93
  else:
51
94
  raise NotImplementedError(f"Command {args.command} not implemented.")
52
95
 
lfss/cli/user.py CHANGED
@@ -45,6 +45,7 @@ async def _main():
45
45
  sp_list.add_argument("-l", "--long", action="store_true")
46
46
 
47
47
  args = parser.parse_args()
48
+ db = await Database().init()
48
49
 
49
50
  @asynccontextmanager
50
51
  async def get_uconn():
@@ -65,7 +66,6 @@ async def _main():
65
66
  print('User not found')
66
67
  exit(1)
67
68
  else:
68
- db = await Database().init()
69
69
  await db.delete_user(user.id)
70
70
  print('User deleted')
71
71
 
lfss/client/__init__.py CHANGED
@@ -1,14 +1,45 @@
1
- import os, time
1
+ import os, time, pathlib
2
2
  from threading import Lock
3
3
  from concurrent.futures import ThreadPoolExecutor
4
4
  from .api import Connector
5
5
 
6
+ def upload_file(
7
+ connector: Connector,
8
+ file_path: str,
9
+ dst_url: str,
10
+ n_retries: int = 0,
11
+ interval: float = 0,
12
+ verbose: bool = False,
13
+ **put_kwargs
14
+ ):
15
+ this_try = 0
16
+ while this_try <= n_retries:
17
+ try:
18
+ with open(file_path, 'rb') as f:
19
+ blob = f.read()
20
+ connector.put(dst_url, blob, **put_kwargs)
21
+ break
22
+ except Exception as e:
23
+ if isinstance(e, KeyboardInterrupt):
24
+ raise e
25
+ if verbose:
26
+ print(f"Error uploading {file_path}: {e}, retrying...")
27
+ this_try += 1
28
+ finally:
29
+ time.sleep(interval)
30
+
31
+ if this_try > n_retries:
32
+ if verbose:
33
+ print(f"Failed to upload {file_path} after {n_retries} retries.")
34
+ return False
35
+ return True
36
+
6
37
  def upload_directory(
7
38
  connector: Connector,
8
39
  directory: str,
9
40
  path: str,
10
41
  n_concurrent: int = 1,
11
- n_reties: int = 0,
42
+ n_retries: int = 0,
12
43
  interval: float = 0,
13
44
  verbose: bool = False,
14
45
  **put_kwargs
@@ -16,6 +47,7 @@ def upload_directory(
16
47
  assert path.endswith('/'), "Path must end with a slash."
17
48
  if path.startswith('/'):
18
49
  path = path[1:]
50
+ directory = str(directory)
19
51
 
20
52
  _counter = 0
21
53
  _counter_lock = Lock()
@@ -30,31 +62,94 @@ def upload_directory(
30
62
  if verbose:
31
63
  print(f"[{this_count}] Uploading {file_path} to {dst_path}")
32
64
 
33
- this_try = 0
34
- with open(file_path, 'rb') as f:
35
- blob = f.read()
36
-
37
- while this_try <= n_reties:
38
- try:
39
- connector.put(dst_path, blob, **put_kwargs)
40
- break
41
- except Exception as e:
42
- if isinstance(e, KeyboardInterrupt):
43
- raise e
44
- if verbose:
45
- print(f"[{this_count}] Error uploading {file_path}: {e}, retrying...")
46
- this_try += 1
47
- finally:
48
- time.sleep(interval)
49
-
50
- if this_try > n_reties:
65
+ if not upload_file(
66
+ connector, file_path, dst_path,
67
+ n_retries=n_retries, interval=interval, verbose=verbose, **put_kwargs
68
+ ):
51
69
  faild_files.append(file_path)
52
- if verbose:
53
- print(f"[{this_count}] Failed to upload {file_path} after {n_reties} retries.")
54
70
 
55
71
  with ThreadPoolExecutor(n_concurrent) as executor:
56
72
  for root, dirs, files in os.walk(directory):
57
73
  for file in files:
58
74
  executor.submit(put_file, os.path.join(root, file))
59
75
 
60
- return faild_files
76
+ return faild_files
77
+
78
+ def download_file(
79
+ connector: Connector,
80
+ src_url: str,
81
+ file_path: str,
82
+ n_retries: int = 0,
83
+ interval: float = 0,
84
+ verbose: bool = False,
85
+ overwrite: bool = False
86
+ ):
87
+ this_try = 0
88
+ while this_try <= n_retries:
89
+ if not overwrite and os.path.exists(file_path):
90
+ if verbose:
91
+ print(f"File {file_path} already exists, skipping download.")
92
+ return True
93
+ try:
94
+ blob = connector.get(src_url)
95
+ if not blob:
96
+ return False
97
+ pathlib.Path(file_path).parent.mkdir(parents=True, exist_ok=True)
98
+ with open(file_path, 'wb') as f:
99
+ f.write(blob)
100
+ break
101
+ except Exception as e:
102
+ if isinstance(e, KeyboardInterrupt):
103
+ raise e
104
+ if verbose:
105
+ print(f"Error downloading {src_url}: {e}, retrying...")
106
+ this_try += 1
107
+ finally:
108
+ time.sleep(interval)
109
+
110
+ if this_try > n_retries:
111
+ if verbose:
112
+ print(f"Failed to download {src_url} after {n_retries} retries.")
113
+ return False
114
+ return True
115
+
116
+ def download_directory(
117
+ connector: Connector,
118
+ src_path: str,
119
+ directory: str,
120
+ n_concurrent: int = 1,
121
+ n_retries: int = 0,
122
+ interval: float = 0,
123
+ verbose: bool = False,
124
+ overwrite: bool = False
125
+ ) -> list[str]:
126
+
127
+ directory = str(directory)
128
+
129
+ if not src_path.endswith('/'):
130
+ src_path += '/'
131
+ if not directory.endswith(os.sep):
132
+ directory += os.sep
133
+
134
+ _counter = 0
135
+ _counter_lock = Lock()
136
+ failed_files = []
137
+ def get_file(src_url):
138
+ nonlocal _counter, failed_files
139
+ with _counter_lock:
140
+ _counter += 1
141
+ this_count = _counter
142
+ dst_path = f"{directory}{os.path.relpath(src_url, src_path)}"
143
+ if verbose:
144
+ print(f"[{this_count}] Downloading {src_url} to {dst_path}")
145
+
146
+ if not download_file(
147
+ connector, src_url, dst_path,
148
+ n_retries=n_retries, interval=interval, verbose=verbose, overwrite=overwrite
149
+ ):
150
+ failed_files.append(src_url)
151
+
152
+ with ThreadPoolExecutor(n_concurrent) as executor:
153
+ for file in connector.list_path(src_path).files:
154
+ executor.submit(get_file, file.url)
155
+ return failed_files
lfss/client/api.py CHANGED
@@ -5,6 +5,7 @@ import urllib.parse
5
5
  from lfss.src.datatype import (
6
6
  FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents
7
7
  )
8
+ from lfss.src.utils import ensure_uri_compnents
8
9
 
9
10
  _default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
10
11
  _default_token = os.environ.get('LFSS_TOKEN', '')
@@ -17,12 +18,13 @@ class Connector:
17
18
  "token": token
18
19
  }
19
20
 
20
- def _fetch(
21
+ def _fetch_factory(
21
22
  self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
22
23
  path: str, search_params: dict = {}
23
24
  ):
24
25
  if path.startswith('/'):
25
26
  path = path[1:]
27
+ path = ensure_uri_compnents(path)
26
28
  def f(**kwargs):
27
29
  url = f"{self.config['endpoint']}/{path}" + "?" + urllib.parse.urlencode(search_params)
28
30
  headers: dict = kwargs.pop('headers', {})
@@ -46,7 +48,7 @@ class Connector:
46
48
  else:
47
49
  return {'status': 'skipped', 'path': path}
48
50
 
49
- response = self._fetch('PUT', path, search_params={
51
+ response = self._fetch_factory('PUT', path, search_params={
50
52
  'permission': int(permission),
51
53
  'conflict': conflict
52
54
  })(
@@ -68,7 +70,7 @@ class Connector:
68
70
  else:
69
71
  return {'status': 'skipped', 'path': path}
70
72
 
71
- response = self._fetch('PUT', path, search_params={
73
+ response = self._fetch_factory('PUT', path, search_params={
72
74
  'permission': int(permission),
73
75
  'conflict': conflict
74
76
  })(
@@ -79,7 +81,7 @@ class Connector:
79
81
 
80
82
  def _get(self, path: str) -> Optional[requests.Response]:
81
83
  try:
82
- response = self._fetch('GET', path)()
84
+ response = self._fetch_factory('GET', path)()
83
85
  except requests.exceptions.HTTPError as e:
84
86
  if e.response.status_code == 404:
85
87
  return None
@@ -100,12 +102,12 @@ class Connector:
100
102
 
101
103
  def delete(self, path: str):
102
104
  """Deletes the file at the specified path."""
103
- self._fetch('DELETE', path)()
105
+ self._fetch_factory('DELETE', path)()
104
106
 
105
107
  def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
106
108
  """Gets the metadata for the file at the specified path."""
107
109
  try:
108
- response = self._fetch('GET', '_api/meta', {'path': path})()
110
+ response = self._fetch_factory('GET', '_api/meta', {'path': path})()
109
111
  if path.endswith('/'):
110
112
  return DirectoryRecord(**response.json())
111
113
  else:
@@ -117,22 +119,24 @@ class Connector:
117
119
 
118
120
  def list_path(self, path: str) -> PathContents:
119
121
  assert path.endswith('/')
120
- response = self._fetch('GET', path)()
121
- return PathContents(**response.json())
122
+ response = self._fetch_factory('GET', path)()
123
+ dirs = [DirectoryRecord(**d) for d in response.json()['dirs']]
124
+ files = [FileRecord(**f) for f in response.json()['files']]
125
+ return PathContents(dirs=dirs, files=files)
122
126
 
123
127
  def set_file_permission(self, path: str, permission: int | FileReadPermission):
124
128
  """Sets the file permission for the specified path."""
125
- self._fetch('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
129
+ self._fetch_factory('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
126
130
  headers={'Content-Type': 'application/www-form-urlencoded'}
127
131
  )
128
132
 
129
133
  def move(self, path: str, new_path: str):
130
134
  """Move file or directory to a new path."""
131
- self._fetch('POST', '_api/meta', {'path': path, 'new_path': new_path})(
135
+ self._fetch_factory('POST', '_api/meta', {'path': path, 'new_path': new_path})(
132
136
  headers = {'Content-Type': 'application/www-form-urlencoded'}
133
137
  )
134
138
 
135
139
  def whoami(self) -> UserRecord:
136
140
  """Gets information about the current user."""
137
- response = self._fetch('GET', '_api/whoami')()
141
+ response = self._fetch_factory('GET', '_api/whoami')()
138
142
  return UserRecord(**response.json())
lfss/src/config.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- import os
2
+ import os, hashlib
3
3
 
4
4
  __default_dir = '.storage_data'
5
5
 
@@ -14,4 +14,4 @@ LARGE_BLOB_DIR.mkdir(exist_ok=True)
14
14
  # https://sqlite.org/fasterthanfs.html
15
15
  LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
16
16
  MAX_FILE_BYTES = 512 * 1024 * 1024 # 512MB
17
- MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
17
+ MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
@@ -139,6 +139,7 @@ async def unique_cursor(is_write: bool = False):
139
139
  finally:
140
140
  await g_pool.release(connection_obj)
141
141
 
142
+ # todo: add exclusive transaction option
142
143
  @asynccontextmanager
143
144
  async def transaction():
144
145
  async with unique_cursor(is_write=True) as cur:
lfss/src/database.py CHANGED
@@ -3,7 +3,7 @@ from typing import Optional, overload, Literal, AsyncIterable
3
3
  from abc import ABC
4
4
 
5
5
  import urllib.parse
6
- import hashlib, uuid
6
+ import uuid
7
7
  import zipfile, io, asyncio
8
8
 
9
9
  import aiosqlite, aiofiles
@@ -13,12 +13,9 @@ from .connection_pool import execute_sql, unique_cursor, transaction
13
13
  from .datatype import UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents
14
14
  from .config import LARGE_BLOB_DIR
15
15
  from .log import get_logger
16
- from .utils import decode_uri_compnents
16
+ from .utils import decode_uri_compnents, hash_credential
17
17
  from .error import *
18
18
 
19
- def hash_credential(username, password):
20
- return hashlib.sha256((username + password).encode()).hexdigest()
21
-
22
19
  class DBObjectBase(ABC):
23
20
  logger = get_logger('database', global_instance=True)
24
21
  _cur: aiosqlite.Cursor
@@ -153,16 +150,6 @@ class FileConn(DBObjectBase):
153
150
  return []
154
151
  return [self.parse_record(r) for r in res]
155
152
 
156
- async def get_user_file_records(self, owner_id: int) -> list[FileRecord]:
157
- await self.cur.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, ))
158
- res = await self.cur.fetchall()
159
- return [self.parse_record(r) for r in res]
160
-
161
- async def get_path_file_records(self, url: str) -> list[FileRecord]:
162
- await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', ))
163
- res = await self.cur.fetchall()
164
- return [self.parse_record(r) for r in res]
165
-
166
153
  async def list_root(self, *usernames: str) -> list[DirectoryRecord]:
167
154
  """
168
155
  Efficiently list users' directories, if usernames is empty, list all users' directories.
@@ -340,25 +327,27 @@ class FileConn(DBObjectBase):
340
327
  async def log_access(self, url: str):
341
328
  await self.cur.execute("UPDATE fmeta SET access_time = CURRENT_TIMESTAMP WHERE url = ?", (url, ))
342
329
 
343
- async def delete_file_record(self, url: str):
344
- file_record = await self.get_file_record(url)
345
- if file_record is None: return
346
- await self.cur.execute("DELETE FROM fmeta WHERE url = ?", (url, ))
330
+ async def delete_file_record(self, url: str) -> Optional[FileRecord]:
331
+ res = await self.cur.execute("DELETE FROM fmeta WHERE url = ? RETURNING *", (url, ))
332
+ row = await res.fetchone()
333
+ if row is None:
334
+ raise FileNotFoundError(f"File {url} not found")
335
+ file_record = FileRecord(*row)
347
336
  await self._user_size_dec(file_record.owner_id, file_record.file_size)
348
337
  self.logger.info(f"Deleted fmeta {url}")
338
+ return file_record
349
339
 
350
- async def delete_user_file_records(self, owner_id: int):
340
+ async def delete_user_file_records(self, owner_id: int) -> list[FileRecord]:
351
341
  cursor = await self.cur.execute("SELECT * FROM fmeta WHERE owner_id = ?", (owner_id, ))
352
342
  res = await cursor.fetchall()
353
- await self.cur.execute("DELETE FROM fmeta WHERE owner_id = ?", (owner_id, ))
354
343
  await self.cur.execute("DELETE FROM usize WHERE user_id = ?", (owner_id, ))
355
- self.logger.info(f"Deleted {len(res)} files for user {owner_id}") # type: ignore
344
+ res = await self.cur.execute("DELETE FROM fmeta WHERE owner_id = ? RETURNING *", (owner_id, ))
345
+ ret = [self.parse_record(r) for r in await res.fetchall()]
346
+ self.logger.info(f"Deleted {len(ret)} file(s) for user {owner_id}") # type: ignore
347
+ return ret
356
348
 
357
- async def delete_path_records(self, path: str):
349
+ async def delete_path_records(self, path: str, under_user_id: Optional[int] = None) -> list[FileRecord]:
358
350
  """Delete all records with url starting with path"""
359
- cursor = await self.cur.execute("SELECT * FROM fmeta WHERE url LIKE ?", (path + '%', ))
360
- all_f_rec = await cursor.fetchall()
361
-
362
351
  # update user size
363
352
  cursor = await self.cur.execute("SELECT DISTINCT owner_id FROM fmeta WHERE url LIKE ?", (path + '%', ))
364
353
  res = await cursor.fetchall()
@@ -368,8 +357,16 @@ class FileConn(DBObjectBase):
368
357
  if size is not None:
369
358
  await self._user_size_dec(r[0], size[0])
370
359
 
371
- await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ?", (path + '%', ))
372
- self.logger.info(f"Deleted {len(all_f_rec)} files for path {path}") # type: ignore
360
+ # if any new records are created here, the size update may be inconsistent
361
+ # but it's not a big deal...
362
+
363
+ if under_user_id is None:
364
+ res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? RETURNING *", (path + '%', ))
365
+ else:
366
+ res = await self.cur.execute("DELETE FROM fmeta WHERE url LIKE ? AND owner_id = ? RETURNING *", (path + '%', under_user_id))
367
+ all_f_rec = await res.fetchall()
368
+ self.logger.info(f"Deleted {len(all_f_rec)} file(s) for path {path}") # type: ignore
369
+ return [self.parse_record(r) for r in all_f_rec]
373
370
 
374
371
  async def set_file_blob(self, file_id: str, blob: bytes):
375
372
  await self.cur.execute("INSERT OR REPLACE INTO blobs.fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
@@ -546,28 +543,37 @@ class Database:
546
543
 
547
544
  return blob
548
545
 
549
- async def delete_file(self, url: str) -> Optional[FileRecord]:
546
+ async def delete_file(self, url: str, assure_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
550
547
  validate_url(url)
551
548
 
552
549
  async with transaction() as cur:
553
550
  fconn = FileConn(cur)
554
- r = await fconn.get_file_record(url)
551
+ r = await fconn.delete_file_record(url)
555
552
  if r is None:
556
553
  return None
554
+ if assure_user is not None:
555
+ if r.owner_id != assure_user.id:
556
+ # will rollback
557
+ raise PermissionDeniedError(f"Permission denied: {assure_user.username} cannot delete file {url}")
557
558
  f_id = r.file_id
558
- await fconn.delete_file_record(url)
559
559
  if r.external:
560
560
  await fconn.delete_file_blob_external(f_id)
561
561
  else:
562
562
  await fconn.delete_file_blob(f_id)
563
563
  return r
564
564
 
565
- async def move_file(self, old_url: str, new_url: str):
565
+ async def move_file(self, old_url: str, new_url: str, ensure_user: Optional[UserRecord] = None):
566
566
  validate_url(old_url)
567
567
  validate_url(new_url)
568
568
 
569
569
  async with transaction() as cur:
570
570
  fconn = FileConn(cur)
571
+ r = await fconn.get_file_record(old_url)
572
+ if r is None:
573
+ raise FileNotFoundError(f"File {old_url} not found")
574
+ if ensure_user is not None:
575
+ if r.owner_id != ensure_user.id:
576
+ raise PermissionDeniedError(f"Permission denied: {ensure_user.username} cannot move file {old_url}")
571
577
  await fconn.move_file(old_url, new_url)
572
578
 
573
579
  async def move_path(self, user: UserRecord, old_url: str, new_url: str):
@@ -615,16 +621,16 @@ class Database:
615
621
  await fconn.delete_file_blob_external(external_ids[i])
616
622
 
617
623
 
618
- async def delete_path(self, url: str):
624
+ async def delete_path(self, url: str, under_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
619
625
  validate_url(url, is_file=False)
626
+ user_id = under_user.id if under_user is not None else None
620
627
 
621
628
  async with transaction() as cur:
622
629
  fconn = FileConn(cur)
623
- records = await fconn.get_path_file_records(url)
630
+ records = await fconn.delete_path_records(url, user_id)
624
631
  if not records:
625
632
  return None
626
633
  await self.__batch_delete_file_blobs(fconn, records)
627
- await fconn.delete_path_records(url)
628
634
  return records
629
635
 
630
636
  async def delete_user(self, u: str | int):
@@ -633,12 +639,17 @@ class Database:
633
639
  if user is None:
634
640
  return
635
641
 
636
- fconn = FileConn(cur)
637
- records = await fconn.get_user_file_records(user.id)
638
- await self.__batch_delete_file_blobs(fconn, records)
639
- await fconn.delete_user_file_records(user.id)
642
+ # no new files can be added since profile deletion
640
643
  uconn = UserConn(cur)
641
644
  await uconn.delete_user(user.username)
645
+
646
+ fconn = FileConn(cur)
647
+ records = await fconn.delete_user_file_records(user.id)
648
+ await self.__batch_delete_file_blobs(fconn, records)
649
+
650
+ # make sure the user's directory is deleted,
651
+ # may contain admin's files, but delete them all
652
+ await fconn.delete_path_records(user.username + '/')
642
653
 
643
654
  async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes | AsyncIterable[bytes]]]:
644
655
  async with unique_cursor() as cur:
lfss/src/server.py CHANGED
@@ -76,6 +76,11 @@ async def get_current_user(
76
76
  raise HTTPException(status_code=401, detail="Invalid token")
77
77
  return user
78
78
 
79
+ async def registered_user(user: UserRecord = Depends(get_current_user)):
80
+ if user.id == 0:
81
+ raise HTTPException(status_code=401, detail="Permission denied")
82
+ return user
83
+
79
84
  app = FastAPI(docs_url=None, redoc_url=None, lifespan=lifespan)
80
85
  app.add_middleware(
81
86
  CORSMiddleware,
@@ -186,11 +191,8 @@ async def put_file(
186
191
  path: str,
187
192
  conflict: Literal["overwrite", "skip", "abort"] = "abort",
188
193
  permission: int = 0,
189
- user: UserRecord = Depends(get_current_user)):
194
+ user: UserRecord = Depends(registered_user)):
190
195
  path = ensure_uri_compnents(path)
191
- if user.id == 0:
192
- logger.debug("Reject put request from DECOY_USER")
193
- raise HTTPException(status_code=401, detail="Permission denied")
194
196
  if not path.startswith(f"{user.username}/") and not user.is_admin:
195
197
  logger.debug(f"Reject put request from {user.username} to {path}")
196
198
  raise HTTPException(status_code=403, detail="Permission denied")
@@ -217,6 +219,8 @@ async def put_file(
217
219
  }, content=json.dumps({"url": path}))
218
220
  # remove the old file
219
221
  exists_flag = True
222
+ if not user.is_admin and not file_record.owner_id == user.id:
223
+ raise HTTPException(status_code=403, detail="Permission denied, cannot overwrite other's file")
220
224
  await db.delete_file(path)
221
225
 
222
226
  # check content-type
@@ -267,19 +271,17 @@ async def put_file(
267
271
 
268
272
  @router_fs.delete("/{path:path}")
269
273
  @handle_exception
270
- async def delete_file(path: str, user: UserRecord = Depends(get_current_user)):
274
+ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
271
275
  path = ensure_uri_compnents(path)
272
- if user.id == 0:
273
- raise HTTPException(status_code=401, detail="Permission denied")
274
276
  if not path.startswith(f"{user.username}/") and not user.is_admin:
275
277
  raise HTTPException(status_code=403, detail="Permission denied")
276
278
 
277
279
  logger.info(f"DELETE {path}, user: {user.username}")
278
280
 
279
281
  if path.endswith("/"):
280
- res = await db.delete_path(path)
282
+ res = await db.delete_path(path, user if not user.is_admin else None)
281
283
  else:
282
- res = await db.delete_file(path)
284
+ res = await db.delete_file(path, user if not user.is_admin else None)
283
285
 
284
286
  await db.record_user_activity(user.username)
285
287
  if res:
@@ -291,21 +293,23 @@ router_api = APIRouter(prefix="/_api")
291
293
 
292
294
  @router_api.get("/bundle")
293
295
  @handle_exception
294
- async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
296
+ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
295
297
  logger.info(f"GET bundle({path}), user: {user.username}")
296
- if user.id == 0:
297
- raise HTTPException(status_code=401, detail="Permission denied")
298
298
  path = ensure_uri_compnents(path)
299
299
  assert path.endswith("/") or path == ""
300
300
 
301
301
  if not path == "" and path[0] == "/": # adapt to both /path and path
302
302
  path = path[1:]
303
303
 
304
- owner_records_cache = {} # cache owner records, ID -> UserRecord
304
+ owner_records_cache: dict[int, UserRecord] = {} # cache owner records, ID -> UserRecord
305
305
  async def is_access_granted(file_record: FileRecord):
306
306
  owner_id = file_record.owner_id
307
307
  owner = owner_records_cache.get(owner_id, None)
308
308
  if owner is None:
309
+ async with unique_cursor() as conn:
310
+ uconn = UserConn(conn)
311
+ owner = await uconn.get_user_by_id(owner_id)
312
+ assert owner is not None, "Owner not found"
309
313
  owner_records_cache[owner_id] = owner
310
314
 
311
315
  allow_access, _ = check_user_permission(user, owner, file_record)
@@ -334,16 +338,35 @@ async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
334
338
 
335
339
  @router_api.get("/meta")
336
340
  @handle_exception
337
- async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user)):
341
+ async def get_file_meta(path: str, user: UserRecord = Depends(registered_user)):
342
+ """
343
+ Permission:
344
+ for file:
345
+ if file is under user's path, return the meta,
346
+ else, determine by the permission same as get_file
347
+ for path:
348
+ if path is under user's path, return the meta, else return 403
349
+ """
338
350
  logger.info(f"GET meta({path}), user: {user.username}")
339
351
  path = ensure_uri_compnents(path)
352
+ is_file = not path.endswith("/")
340
353
  async with unique_cursor() as conn:
341
354
  fconn = FileConn(conn)
342
- get_fn = fconn.get_file_record if not path.endswith("/") else fconn.get_path_record
343
- record = await get_fn(path)
344
-
345
- if not record:
346
- raise HTTPException(status_code=404, detail="Path not found")
355
+ if is_file:
356
+ record = await fconn.get_file_record(path)
357
+ if not record:
358
+ raise HTTPException(status_code=404, detail="File not found")
359
+ if not path.startswith(f"{user.username}/") and not user.is_admin:
360
+ uconn = UserConn(conn)
361
+ owner = await uconn.get_user_by_id(record.owner_id)
362
+ assert owner is not None, "Owner not found"
363
+ is_allowed, reason = check_user_permission(user, owner, record)
364
+ if not is_allowed:
365
+ raise HTTPException(status_code=403, detail=reason)
366
+ else:
367
+ record = await fconn.get_path_record(path)
368
+ if not path.startswith(f"{user.username}/") and not user.is_admin:
369
+ raise HTTPException(status_code=403, detail="Permission denied")
347
370
  return record
348
371
 
349
372
  @router_api.post("/meta")
@@ -352,10 +375,8 @@ async def update_file_meta(
352
375
  path: str,
353
376
  perm: Optional[int] = None,
354
377
  new_path: Optional[str] = None,
355
- user: UserRecord = Depends(get_current_user)
378
+ user: UserRecord = Depends(registered_user)
356
379
  ):
357
- if user.id == 0:
358
- raise HTTPException(status_code=401, detail="Permission denied")
359
380
  path = ensure_uri_compnents(path)
360
381
  if path.startswith("/"):
361
382
  path = path[1:]
@@ -374,7 +395,7 @@ async def update_file_meta(
374
395
  if new_path is not None:
375
396
  new_path = ensure_uri_compnents(new_path)
376
397
  logger.info(f"Update path of {path} to {new_path}")
377
- await db.move_file(path, new_path)
398
+ await db.move_file(path, new_path, user)
378
399
 
379
400
  # directory
380
401
  else:
@@ -389,9 +410,7 @@ async def update_file_meta(
389
410
 
390
411
  @router_api.get("/whoami")
391
412
  @handle_exception
392
- async def whoami(user: UserRecord = Depends(get_current_user)):
393
- if user.id == 0:
394
- raise HTTPException(status_code=401, detail="Login required")
413
+ async def whoami(user: UserRecord = Depends(registered_user)):
395
414
  user.credential = "__HIDDEN__"
396
415
  return user
397
416
 
lfss/src/utils.py CHANGED
@@ -2,6 +2,10 @@ import datetime
2
2
  import urllib.parse
3
3
  import asyncio
4
4
  import functools
5
+ import hashlib
6
+
7
+ def hash_credential(username: str, password: str):
8
+ return hashlib.sha256((username + password).encode()).hexdigest()
5
9
 
6
10
  def encode_uri_compnents(path: str):
7
11
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.7.2
3
+ Version: 0.7.4
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -22,8 +22,8 @@ Description-Content-Type: text/markdown
22
22
  [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
23
23
 
24
24
  My experiment on a lightweight file/object storage service.
25
- It stores small files and metadata in sqlite, large files in the filesystem, and serves them through a simple REST API.
26
- Tested on 2 million files, and it works fine... thanks to the sqlite database!
25
+ It stores small files and metadata in sqlite, large files in the filesystem.
26
+ Tested on 2 million files, and it works fine...
27
27
 
28
28
  Usage:
29
29
  ```sh
@@ -1,6 +1,6 @@
1
- Readme.md,sha256=t5dninN6gFzve_NySxqGrEkmAYhF1h1RhaMdCOeCILA,1348
1
+ Readme.md,sha256=vsPotlwPAaHI5plh4aaszpi3rr7ZGDn7-wLdEYTWQ0k,1275
2
2
  docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
- docs/Permission.md,sha256=EY1Y4tT5PDdHDb2pXsVgMAJXxkUihTZfPT0_z7Q53FA,1485
3
+ docs/Permission.md,sha256=X0VNfBKU52f93QYqcVyiBFJ3yURiSkhIo9S_5fdSgzM,2265
4
4
  frontend/api.js,sha256=-ouhsmucEunAK3m1H__MqffQkXAjoeVEfM15BvqfIZs,7677
5
5
  frontend/index.html,sha256=VPJDs2LG8ep9kjlsKzjWzpN9vc1VGgdvOUlNTZWyQoQ,2088
6
6
  frontend/popup.css,sha256=VzkjG1ZTLxhHMtTyobnlvqYmVsTmdbJJed2Pu1cc06c,1007
@@ -9,25 +9,25 @@ frontend/scripts.js,sha256=hQ8m3L7P-LplLqrPUWD6pBo4C_tCUl2XZKRNtkWBy8I,21155
9
9
  frontend/styles.css,sha256=wly8O-zF4EUgV12Tv1bATSfmJsLITv2u3_SiyXVaxv4,4096
10
10
  frontend/utils.js,sha256=Ts4nlef8pkrEgpwX-uQwAhWvwxlIzex8ijDLNCa22ps,2372
11
11
  lfss/cli/balance.py,sha256=heOgwH6oNnfYsKJfA4VxWKdEXPstdVbbRXWxcDqLIS0,4176
12
- lfss/cli/cli.py,sha256=bJOeEyri_XVWUvnjohsw_oPYKp-bELxLrg5sVWOpKQA,2259
12
+ lfss/cli/cli.py,sha256=Yup3xIVEQPu10uM8dq1bvre1fK5ngweQHxXZsgQq4Hc,4187
13
13
  lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
14
14
  lfss/cli/serve.py,sha256=bO3GT0kuylMGN-7bZWP4e71MlugGZ_lEMkYaYld_Ntg,985
15
- lfss/cli/user.py,sha256=Bu2IOchLdClBqjBqVeDck3kE0UaKWpL6mG5SPihBorc,3498
16
- lfss/client/__init__.py,sha256=YttaGTBup7mxnW0CtxdZPF0HWga2cGM4twz9MXJIrts,1827
17
- lfss/client/api.py,sha256=ICqpcyvSf-9QmYNv9EQ5fA_MViSuLxSNn-CIBNqWkW8,5414
15
+ lfss/cli/user.py,sha256=h-USWF6lB0Ztm9vwQznqsghKJ5INq5mBmaQeX2D5F-w,3490
16
+ lfss/client/__init__.py,sha256=R9erioMInKIPZ0rXu1J-4mezbwyGMjig18532ATFP5s,4545
17
+ lfss/client/api.py,sha256=aun5HWVNPBeJK6x0_iaM8gVcE3wX6yaqX0TsfsfifSw,5728
18
18
  lfss/sql/init.sql,sha256=C-JtQAlaOjESI8uoF1Y_9dKukEVSw5Ll-7yA3gG-XHU,1210
19
19
  lfss/sql/pragma.sql,sha256=uENx7xXjARmro-A3XAK8OM8v5AxDMdCCRj47f86UuXg,206
20
20
  lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
- lfss/src/config.py,sha256=G-tOdk6RrE62_d-QoD6WXD4l70YriBazUFdumTz3P20,529
22
- lfss/src/connection_pool.py,sha256=qBtXr2j_O7vGI2Mv0HVsza35T8nljIx85jLlyEv1SgE,4918
23
- lfss/src/database.py,sha256=tGcHfxThmRTmtLIlEWdJtAe1tzP64wHSC1JcbQdRU-8,31226
21
+ lfss/src/config.py,sha256=z0aVOW8yGgKSryhQCWTf2RY3iHmKMcKIZ-HiosTnPRs,539
22
+ lfss/src/connection_pool.py,sha256=69QMJ4gRQ62qi39t0JKdvIaWRBrbU9S7slutIpCc30A,4959
23
+ lfss/src/database.py,sha256=VKTLJSUFImF3pzMUaqGWEh1H06Yk3VgJa78bw5rAvG8,32147
24
24
  lfss/src/datatype.py,sha256=BLS7vuuKnFZQg0nrKeP9SymqUhcN6HwPgejU0yBd_Ak,1622
25
25
  lfss/src/error.py,sha256=imbhwnbhnI3HLhkbfICROe3F0gleKrOk4XnqHJDOtuI,285
26
26
  lfss/src/log.py,sha256=qNE04sHoZ2rMbQ5dR2zT7Xaz1KVAfYp5hKpWJX42S9g,5244
27
- lfss/src/server.py,sha256=9mnBlcHJruwOV-aoHa1P146F4khpVDmif7rq58yZX3U,15306
27
+ lfss/src/server.py,sha256=4GYDAa9Fx4H4fYh_He1u1zlIro3zaQmaEBNKpPO2Q2E,16374
28
28
  lfss/src/stat.py,sha256=hTMtQyM_Ukmhc33Bb9FGCfBMIX02KrGHQg8nL7sC8sU,2082
29
- lfss/src/utils.py,sha256=8VkrtpSmurbMiX7GyK-n7Grvzy3uwSJXHdONEsuLCDI,2272
30
- lfss-0.7.2.dist-info/METADATA,sha256=pJcPBmMpKwNujl4CWZKRchT3uXyCPaEvi1mek7SUJUE,2040
31
- lfss-0.7.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
32
- lfss-0.7.2.dist-info/entry_points.txt,sha256=d_Ri3GXxUW-S0E6q953A8od0YMmUAnZGlJSKS46OiW8,172
33
- lfss-0.7.2.dist-info/RECORD,,
29
+ lfss/src/utils.py,sha256=ZE3isOS3gafEYw1z8s2ucY08eWQHIN4YUbKYH8F1hEQ,2409
30
+ lfss-0.7.4.dist-info/METADATA,sha256=XeJ4ft1B9KDdfcxyFrFdwUMTYc1QsUAkgqI8v5rwbq4,1967
31
+ lfss-0.7.4.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
32
+ lfss-0.7.4.dist-info/entry_points.txt,sha256=d_Ri3GXxUW-S0E6q953A8od0YMmUAnZGlJSKS46OiW8,172
33
+ lfss-0.7.4.dist-info/RECORD,,
File without changes