lfss 0.7.1__tar.gz → 0.7.3__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 (31) hide show
  1. {lfss-0.7.1 → lfss-0.7.3}/PKG-INFO +5 -3
  2. {lfss-0.7.1 → lfss-0.7.3}/Readme.md +4 -2
  3. {lfss-0.7.1 → lfss-0.7.3}/docs/Permission.md +24 -16
  4. {lfss-0.7.1 → lfss-0.7.3}/lfss/cli/balance.py +14 -32
  5. {lfss-0.7.1 → lfss-0.7.3}/lfss/cli/cli.py +1 -1
  6. {lfss-0.7.1 → lfss-0.7.3}/lfss/cli/user.py +1 -1
  7. {lfss-0.7.1 → lfss-0.7.3}/lfss/client/api.py +13 -11
  8. {lfss-0.7.1 → lfss-0.7.3}/lfss/src/config.py +6 -3
  9. {lfss-0.7.1 → lfss-0.7.3}/lfss/src/connection_pool.py +17 -15
  10. {lfss-0.7.1 → lfss-0.7.3}/lfss/src/database.py +57 -40
  11. {lfss-0.7.1 → lfss-0.7.3}/lfss/src/server.py +21 -21
  12. {lfss-0.7.1 → lfss-0.7.3}/pyproject.toml +5 -1
  13. {lfss-0.7.1 → lfss-0.7.3}/docs/Known_issues.md +0 -0
  14. {lfss-0.7.1 → lfss-0.7.3}/frontend/api.js +0 -0
  15. {lfss-0.7.1 → lfss-0.7.3}/frontend/index.html +0 -0
  16. {lfss-0.7.1 → lfss-0.7.3}/frontend/popup.css +0 -0
  17. {lfss-0.7.1 → lfss-0.7.3}/frontend/popup.js +0 -0
  18. {lfss-0.7.1 → lfss-0.7.3}/frontend/scripts.js +0 -0
  19. {lfss-0.7.1 → lfss-0.7.3}/frontend/styles.css +0 -0
  20. {lfss-0.7.1 → lfss-0.7.3}/frontend/utils.js +0 -0
  21. {lfss-0.7.1 → lfss-0.7.3}/lfss/cli/panel.py +0 -0
  22. {lfss-0.7.1 → lfss-0.7.3}/lfss/cli/serve.py +0 -0
  23. {lfss-0.7.1 → lfss-0.7.3}/lfss/client/__init__.py +0 -0
  24. {lfss-0.7.1 → lfss-0.7.3}/lfss/sql/init.sql +0 -0
  25. {lfss-0.7.1 → lfss-0.7.3}/lfss/sql/pragma.sql +0 -0
  26. {lfss-0.7.1 → lfss-0.7.3}/lfss/src/__init__.py +0 -0
  27. {lfss-0.7.1 → lfss-0.7.3}/lfss/src/datatype.py +0 -0
  28. {lfss-0.7.1 → lfss-0.7.3}/lfss/src/error.py +0 -0
  29. {lfss-0.7.1 → lfss-0.7.3}/lfss/src/log.py +0 -0
  30. {lfss-0.7.1 → lfss-0.7.3}/lfss/src/stat.py +0 -0
  31. {lfss-0.7.1 → lfss-0.7.3}/lfss/src/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.7.1
3
+ Version: 0.7.3
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -21,7 +21,9 @@ Description-Content-Type: text/markdown
21
21
  # Lightweight File Storage Service (LFSS)
22
22
  [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
23
23
 
24
- A lightweight file/object storage service!
24
+ My experiment on a lightweight file/object storage service.
25
+ It stores small files and metadata in sqlite, large files in the filesystem.
26
+ Tested on 2 million files, and it works fine...
25
27
 
26
28
  Usage:
27
29
  ```sh
@@ -41,7 +43,7 @@ Or, you can start a web server at `/frontend` and open `index.html` in your brow
41
43
 
42
44
  The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
43
45
  Authentication is done via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
44
- You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss.client.api.py` for the API usage.
46
+ You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss/client/api.py` for the API usage.
45
47
 
46
48
  By default, the service exposes all files to the public for `GET` requests,
47
49
  but file-listing is restricted to the user's own files.
@@ -1,7 +1,9 @@
1
1
  # Lightweight File Storage Service (LFSS)
2
2
  [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
3
3
 
4
- A lightweight file/object storage service!
4
+ My experiment on a lightweight file/object storage service.
5
+ It stores small files and metadata in sqlite, large files in the filesystem.
6
+ Tested on 2 million files, and it works fine...
5
7
 
6
8
  Usage:
7
9
  ```sh
@@ -21,7 +23,7 @@ Or, you can start a web server at `/frontend` and open `index.html` in your brow
21
23
 
22
24
  The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
23
25
  Authentication is done via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
24
- You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss.client.api.py` for the API usage.
26
+ You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss/client/api.py` for the API usage.
25
27
 
26
28
  By default, the service exposes all files to the public for `GET` requests,
27
29
  but file-listing is restricted to the user's own files.
@@ -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,22 @@ 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
+ ### Path-listing
22
+ - Non-login users cannot list any files.
23
+ - All users can list the files under their own path
24
+ - Admins can list the files under other users' path.
25
+
26
+ ## File creation with `PUT` permission
27
+ The `PUT` is used to create a file.
28
+ - Non-login user don't have `PUT` permission.
29
+ - Every user can have `PUT` permission of files under its own `/<user>/` path.
30
+ - The admin can have `PUT` permission of files of all users.
31
+
32
+ ## `DELETE` and moving permissions
33
+ - Non-login user don't have `DELETE`/move permission.
34
+ - Every user can have `DELETE`/move permission that they own.
35
+ - The admin can have `DELETE` permission of files of all users
36
+ (The admin can't move files of other users, because move does not change the owner of the file.
37
+ If move is allowed, then its equivalent to create file on behalf of other users.)
@@ -24,28 +24,19 @@ def barriered(func):
24
24
 
25
25
  @barriered
26
26
  async def move_to_external(f_id: str, flag: str = ''):
27
- # async with aiosqlite.connect(db_file, timeout = 60) as c:
28
27
  async with transaction() as c:
29
- async with c.execute( "SELECT data FROM blobs.fdata WHERE file_id = ?", (f_id,)) as cursor:
30
- blob_row = await cursor.fetchone()
31
- if blob_row is None:
32
- print(f"{flag}File {f_id} not found in blobs.fdata")
33
- return
28
+ cursor = await c.execute( "SELECT data FROM blobs.fdata WHERE file_id = ?", (f_id,))
29
+ blob_row = await cursor.fetchone()
30
+ if blob_row is None:
31
+ print(f"{flag}File {f_id} not found in blobs.fdata")
32
+ return
34
33
  await c.execute("BEGIN")
35
34
  blob: bytes = blob_row[0]
36
- try:
37
- async with aiofiles.open(LARGE_BLOB_DIR / f_id, 'wb') as f:
38
- await f.write(blob)
39
- await c.execute( "UPDATE fmeta SET external = 1 WHERE file_id = ?", (f_id,))
40
- await c.execute( "DELETE FROM blobs.fdata WHERE file_id = ?", (f_id,))
41
- await c.commit()
42
- print(f"{flag}Moved {f_id} to external storage")
43
- except Exception as e:
44
- await c.rollback()
45
- print(f"{flag}Error moving {f_id}: {e}")
46
-
47
- if isinstance(e, KeyboardInterrupt):
48
- raise e
35
+ async with aiofiles.open(LARGE_BLOB_DIR / f_id, 'wb') as f:
36
+ await f.write(blob)
37
+ await c.execute( "UPDATE fmeta SET external = 1 WHERE file_id = ?", (f_id,))
38
+ await c.execute( "DELETE FROM blobs.fdata WHERE file_id = ?", (f_id,))
39
+ print(f"{flag}Moved {f_id} to external storage")
49
40
 
50
41
  @barriered
51
42
  async def move_to_internal(f_id: str, flag: str = ''):
@@ -56,19 +47,10 @@ async def move_to_internal(f_id: str, flag: str = ''):
56
47
  async with aiofiles.open(LARGE_BLOB_DIR / f_id, 'rb') as f:
57
48
  blob = await f.read()
58
49
 
59
- await c.execute("BEGIN")
60
- try:
61
- await c.execute("INSERT INTO blobs.fdata (file_id, data) VALUES (?, ?)", (f_id, blob))
62
- await c.execute("UPDATE fmeta SET external = 0 WHERE file_id = ?", (f_id,))
63
- await c.commit()
64
- (LARGE_BLOB_DIR / f_id).unlink(missing_ok=True)
65
- print(f"{flag}Moved {f_id} to internal storage")
66
- except Exception as e:
67
- await c.rollback()
68
- print(f"{flag}Error moving {f_id}: {e}")
69
- if isinstance(e, KeyboardInterrupt):
70
- raise e
71
-
50
+ await c.execute("INSERT INTO blobs.fdata (file_id, data) VALUES (?, ?)", (f_id, blob))
51
+ await c.execute("UPDATE fmeta SET external = 0 WHERE file_id = ?", (f_id,))
52
+ (LARGE_BLOB_DIR / f_id).unlink(missing_ok=True)
53
+ print(f"{flag}Moved {f_id} to internal storage")
72
54
 
73
55
  @global_entrance()
74
56
  async def _main(batch_size: int = 10000):
@@ -1,5 +1,5 @@
1
1
  from lfss.client import Connector, upload_directory
2
- from lfss.src.database import FileReadPermission
2
+ from lfss.src.datatype import FileReadPermission
3
3
  from pathlib import Path
4
4
  import argparse
5
5
 
@@ -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
 
@@ -17,7 +17,7 @@ class Connector:
17
17
  "token": token
18
18
  }
19
19
 
20
- def _fetch(
20
+ def _fetch_factory(
21
21
  self, method: Literal['GET', 'POST', 'PUT', 'DELETE'],
22
22
  path: str, search_params: dict = {}
23
23
  ):
@@ -46,7 +46,7 @@ class Connector:
46
46
  else:
47
47
  return {'status': 'skipped', 'path': path}
48
48
 
49
- response = self._fetch('PUT', path, search_params={
49
+ response = self._fetch_factory('PUT', path, search_params={
50
50
  'permission': int(permission),
51
51
  'conflict': conflict
52
52
  })(
@@ -68,7 +68,7 @@ class Connector:
68
68
  else:
69
69
  return {'status': 'skipped', 'path': path}
70
70
 
71
- response = self._fetch('PUT', path, search_params={
71
+ response = self._fetch_factory('PUT', path, search_params={
72
72
  'permission': int(permission),
73
73
  'conflict': conflict
74
74
  })(
@@ -79,7 +79,7 @@ class Connector:
79
79
 
80
80
  def _get(self, path: str) -> Optional[requests.Response]:
81
81
  try:
82
- response = self._fetch('GET', path)()
82
+ response = self._fetch_factory('GET', path)()
83
83
  except requests.exceptions.HTTPError as e:
84
84
  if e.response.status_code == 404:
85
85
  return None
@@ -100,12 +100,12 @@ class Connector:
100
100
 
101
101
  def delete(self, path: str):
102
102
  """Deletes the file at the specified path."""
103
- self._fetch('DELETE', path)()
103
+ self._fetch_factory('DELETE', path)()
104
104
 
105
105
  def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
106
106
  """Gets the metadata for the file at the specified path."""
107
107
  try:
108
- response = self._fetch('GET', '_api/meta', {'path': path})()
108
+ response = self._fetch_factory('GET', '_api/meta', {'path': path})()
109
109
  if path.endswith('/'):
110
110
  return DirectoryRecord(**response.json())
111
111
  else:
@@ -117,22 +117,24 @@ class Connector:
117
117
 
118
118
  def list_path(self, path: str) -> PathContents:
119
119
  assert path.endswith('/')
120
- response = self._fetch('GET', path)()
121
- return PathContents(**response.json())
120
+ response = self._fetch_factory('GET', path)()
121
+ dirs = [DirectoryRecord(**d) for d in response.json()['dirs']]
122
+ files = [FileRecord(**f) for f in response.json()['files']]
123
+ return PathContents(dirs=dirs, files=files)
122
124
 
123
125
  def set_file_permission(self, path: str, permission: int | FileReadPermission):
124
126
  """Sets the file permission for the specified path."""
125
- self._fetch('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
127
+ self._fetch_factory('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
126
128
  headers={'Content-Type': 'application/www-form-urlencoded'}
127
129
  )
128
130
 
129
131
  def move(self, path: str, new_path: str):
130
132
  """Move file or directory to a new path."""
131
- self._fetch('POST', '_api/meta', {'path': path, 'new_path': new_path})(
133
+ self._fetch_factory('POST', '_api/meta', {'path': path, 'new_path': new_path})(
132
134
  headers = {'Content-Type': 'application/www-form-urlencoded'}
133
135
  )
134
136
 
135
137
  def whoami(self) -> UserRecord:
136
138
  """Gets information about the current user."""
137
- response = self._fetch('GET', '_api/whoami')()
139
+ response = self._fetch_factory('GET', '_api/whoami')()
138
140
  return UserRecord(**response.json())
@@ -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
 
@@ -13,5 +13,8 @@ LARGE_BLOB_DIR.mkdir(exist_ok=True)
13
13
 
14
14
  # https://sqlite.org/fasterthanfs.html
15
15
  LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
16
- MAX_FILE_BYTES = 1024 * 1024 * 1024 # 1GB
17
- MAX_BUNDLE_BYTES = 1024 * 1024 * 1024 # 1GB
16
+ MAX_FILE_BYTES = 512 * 1024 * 1024 # 512MB
17
+ MAX_BUNDLE_BYTES = 512 * 1024 * 1024 # 512MB
18
+
19
+ def hash_credential(username, password):
20
+ return hashlib.sha256((username + password).encode()).hexdigest()
@@ -66,26 +66,27 @@ class SqlConnectionPool:
66
66
  if len(self._connections) == 0:
67
67
  raise Exception("No available connections, please init the pool first")
68
68
 
69
- if w:
70
- assert self._w_connection
71
- if self._w_connection.is_available:
72
- self._w_connection.is_available = False
73
- return self._w_connection
74
- raise Exception("Write connection is not available")
75
-
76
69
  async with self._lock:
77
- for c in self._connections:
78
- if c.is_available:
79
- c.is_available = False
80
- return c
70
+ if w:
71
+ assert self._w_connection
72
+ if self._w_connection.is_available:
73
+ self._w_connection.is_available = False
74
+ return self._w_connection
75
+ raise Exception("Write connection is not available")
76
+
77
+ else:
78
+ for c in self._connections:
79
+ if c.is_available:
80
+ c.is_available = False
81
+ return c
81
82
  raise Exception("No available connections, impossible?")
82
83
 
83
84
  async def release(self, conn: SqlConnection):
84
- if conn == self._w_connection:
85
- conn.is_available = True
86
- return
87
-
88
85
  async with self._lock:
86
+ if conn == self._w_connection:
87
+ conn.is_available = True
88
+ return
89
+
89
90
  if not conn in self._connections:
90
91
  raise Exception("Connection not in pool")
91
92
  conn.is_available = True
@@ -138,6 +139,7 @@ async def unique_cursor(is_write: bool = False):
138
139
  finally:
139
140
  await g_pool.release(connection_obj)
140
141
 
142
+ # todo: add exclusive transaction option
141
143
  @asynccontextmanager
142
144
  async def transaction():
143
145
  async with unique_cursor(is_write=True) as cur:
@@ -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
@@ -11,14 +11,11 @@ import aiofiles.os
11
11
 
12
12
  from .connection_pool import execute_sql, unique_cursor, transaction
13
13
  from .datatype import UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents
14
- from .config import LARGE_BLOB_DIR
14
+ from .config import LARGE_BLOB_DIR, hash_credential
15
15
  from .log import get_logger
16
16
  from .utils import decode_uri_compnents
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))
@@ -519,7 +516,13 @@ class Database:
519
516
  raise FileNotFoundError(f"File {url} not found")
520
517
  if not r.external:
521
518
  raise ValueError(f"File {url} is not stored externally, should use read_file instead")
522
- return fconn.get_file_blob_external(r.file_id)
519
+ ret = fconn.get_file_blob_external(r.file_id)
520
+
521
+ async with transaction() as w_cur:
522
+ await FileConn(w_cur).log_access(url)
523
+
524
+ return ret
525
+
523
526
 
524
527
  async def read_file(self, url: str) -> bytes:
525
528
  validate_url(url)
@@ -540,28 +543,37 @@ class Database:
540
543
 
541
544
  return blob
542
545
 
543
- async def delete_file(self, url: str) -> Optional[FileRecord]:
546
+ async def delete_file(self, url: str, assure_user: Optional[UserRecord] = None) -> Optional[FileRecord]:
544
547
  validate_url(url)
545
548
 
546
549
  async with transaction() as cur:
547
550
  fconn = FileConn(cur)
548
- r = await fconn.get_file_record(url)
551
+ r = await fconn.delete_file_record(url)
549
552
  if r is None:
550
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}")
551
558
  f_id = r.file_id
552
- await fconn.delete_file_record(url)
553
559
  if r.external:
554
560
  await fconn.delete_file_blob_external(f_id)
555
561
  else:
556
562
  await fconn.delete_file_blob(f_id)
557
563
  return r
558
564
 
559
- 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):
560
566
  validate_url(old_url)
561
567
  validate_url(new_url)
562
568
 
563
569
  async with transaction() as cur:
564
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}")
565
577
  await fconn.move_file(old_url, new_url)
566
578
 
567
579
  async def move_path(self, user: UserRecord, old_url: str, new_url: str):
@@ -609,16 +621,16 @@ class Database:
609
621
  await fconn.delete_file_blob_external(external_ids[i])
610
622
 
611
623
 
612
- async def delete_path(self, url: str):
624
+ async def delete_path(self, url: str, under_user: Optional[UserRecord] = None) -> Optional[list[FileRecord]]:
613
625
  validate_url(url, is_file=False)
626
+ user_id = under_user.id if under_user is not None else None
614
627
 
615
628
  async with transaction() as cur:
616
629
  fconn = FileConn(cur)
617
- records = await fconn.get_path_file_records(url)
630
+ records = await fconn.delete_path_records(url, user_id)
618
631
  if not records:
619
632
  return None
620
633
  await self.__batch_delete_file_blobs(fconn, records)
621
- await fconn.delete_path_records(url)
622
634
  return records
623
635
 
624
636
  async def delete_user(self, u: str | int):
@@ -627,12 +639,17 @@ class Database:
627
639
  if user is None:
628
640
  return
629
641
 
630
- fconn = FileConn(cur)
631
- records = await fconn.get_user_file_records(user.id)
632
- await self.__batch_delete_file_blobs(fconn, records)
633
- await fconn.delete_user_file_records(user.id)
642
+ # no new files can be added since profile deletion
634
643
  uconn = UserConn(cur)
635
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 + '/')
636
653
 
637
654
  async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes | AsyncIterable[bytes]]]:
638
655
  async with unique_cursor() as cur:
@@ -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,7 +338,7 @@ 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)):
338
342
  logger.info(f"GET meta({path}), user: {user.username}")
339
343
  path = ensure_uri_compnents(path)
340
344
  async with unique_cursor() as conn:
@@ -352,10 +356,8 @@ async def update_file_meta(
352
356
  path: str,
353
357
  perm: Optional[int] = None,
354
358
  new_path: Optional[str] = None,
355
- user: UserRecord = Depends(get_current_user)
359
+ user: UserRecord = Depends(registered_user)
356
360
  ):
357
- if user.id == 0:
358
- raise HTTPException(status_code=401, detail="Permission denied")
359
361
  path = ensure_uri_compnents(path)
360
362
  if path.startswith("/"):
361
363
  path = path[1:]
@@ -374,7 +376,7 @@ async def update_file_meta(
374
376
  if new_path is not None:
375
377
  new_path = ensure_uri_compnents(new_path)
376
378
  logger.info(f"Update path of {path} to {new_path}")
377
- await db.move_file(path, new_path)
379
+ await db.move_file(path, new_path, user)
378
380
 
379
381
  # directory
380
382
  else:
@@ -389,9 +391,7 @@ async def update_file_meta(
389
391
 
390
392
  @router_api.get("/whoami")
391
393
  @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")
394
+ async def whoami(user: UserRecord = Depends(registered_user)):
395
395
  user.credential = "__HIDDEN__"
396
396
  return user
397
397
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.7.1"
3
+ version = "0.7.3"
4
4
  description = "Lightweight file storage service"
5
5
  authors = ["li, mengxun <limengxun45@outlook.com>"]
6
6
  readme = "Readme.md"
@@ -15,6 +15,10 @@ aiosqlite = "0.*"
15
15
  aiofiles = "23.*"
16
16
  mimesniff = "1.*"
17
17
 
18
+ [tool.poetry.dev-dependencies]
19
+ pytest = "*"
20
+ pytest-html = "*"
21
+
18
22
  [tool.poetry.scripts]
19
23
  lfss-serve = "lfss.cli.serve:main"
20
24
  lfss-user = "lfss.cli.user:main"
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