lfss 0.7.1__py3-none-any.whl → 0.7.3__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 +4 -2
- docs/Permission.md +24 -16
- lfss/cli/balance.py +14 -32
- lfss/cli/cli.py +1 -1
- lfss/cli/user.py +1 -1
- lfss/client/api.py +13 -11
- lfss/src/config.py +6 -3
- lfss/src/connection_pool.py +17 -15
- lfss/src/database.py +57 -40
- lfss/src/server.py +21 -21
- {lfss-0.7.1.dist-info → lfss-0.7.3.dist-info}/METADATA +5 -3
- {lfss-0.7.1.dist-info → lfss-0.7.3.dist-info}/RECORD +14 -14
- {lfss-0.7.1.dist-info → lfss-0.7.3.dist-info}/WHEEL +0 -0
- {lfss-0.7.1.dist-info → lfss-0.7.3.dist-info}/entry_points.txt +0 -0
Readme.md
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
# Lightweight File Storage Service (LFSS)
|
2
2
|
[](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.
|
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
|
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.
|
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 (
|
3
|
+
There are two roles in the system: Admin and User ('user' are like 'bucket' to some extent).
|
4
4
|
|
5
|
-
##
|
6
|
-
|
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
|
-
|
11
|
-
|
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.)
|
lfss/cli/balance.py
CHANGED
@@ -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
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
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("
|
60
|
-
|
61
|
-
|
62
|
-
|
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):
|
lfss/cli/cli.py
CHANGED
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/api.py
CHANGED
@@ -17,7 +17,7 @@ class Connector:
|
|
17
17
|
"token": token
|
18
18
|
}
|
19
19
|
|
20
|
-
def
|
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
121
|
-
|
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.
|
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.
|
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.
|
139
|
+
response = self._fetch_factory('GET', '_api/whoami')()
|
138
140
|
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
|
|
@@ -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 =
|
17
|
-
MAX_BUNDLE_BYTES =
|
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()
|
lfss/src/connection_pool.py
CHANGED
@@ -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
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
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:
|
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
|
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
|
-
|
345
|
-
|
346
|
-
|
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.
|
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
|
-
|
372
|
-
|
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
|
-
|
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.
|
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.
|
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
|
-
|
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:
|
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(
|
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(
|
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(
|
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(
|
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(
|
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(
|
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
|
Metadata-Version: 2.1
|
2
2
|
Name: lfss
|
3
|
-
Version: 0.7.
|
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
|
[](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.
|
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
|
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,6 +1,6 @@
|
|
1
|
-
Readme.md,sha256=
|
1
|
+
Readme.md,sha256=vsPotlwPAaHI5plh4aaszpi3rr7ZGDn7-wLdEYTWQ0k,1275
|
2
2
|
docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
|
3
|
-
docs/Permission.md,sha256=
|
3
|
+
docs/Permission.md,sha256=e0ZcogBpTxHsKKOUVmNB460-P6AALQToU2ePq-RHHSQ,1930
|
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
|
@@ -8,26 +8,26 @@ frontend/popup.js,sha256=3PgaGZmxSdV1E-D_MWgcR7aHWkcsHA1BNKSOkmP66tA,5191
|
|
8
8
|
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
|
-
lfss/cli/balance.py,sha256=
|
12
|
-
lfss/cli/cli.py,sha256=
|
11
|
+
lfss/cli/balance.py,sha256=heOgwH6oNnfYsKJfA4VxWKdEXPstdVbbRXWxcDqLIS0,4176
|
12
|
+
lfss/cli/cli.py,sha256=ehdsWJrIuIKEawAN9yVGUZjcVoIwwwZUqxLudQ7S9Ig,2259
|
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=
|
15
|
+
lfss/cli/user.py,sha256=h-USWF6lB0Ztm9vwQznqsghKJ5INq5mBmaQeX2D5F-w,3490
|
16
16
|
lfss/client/__init__.py,sha256=YttaGTBup7mxnW0CtxdZPF0HWga2cGM4twz9MXJIrts,1827
|
17
|
-
lfss/client/api.py,sha256=
|
17
|
+
lfss/client/api.py,sha256=x8-bb2K_dOvQPfNa7IsQE4H755iNlvkwzOPY7o9Bu-g,5638
|
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=
|
22
|
-
lfss/src/connection_pool.py,sha256=
|
23
|
-
lfss/src/database.py,sha256=
|
21
|
+
lfss/src/config.py,sha256=Dn_94Wt8RHnJTSTawyh0UIhnpBmka7ajPkeRN1pCWTQ,651
|
22
|
+
lfss/src/connection_pool.py,sha256=69QMJ4gRQ62qi39t0JKdvIaWRBrbU9S7slutIpCc30A,4959
|
23
|
+
lfss/src/database.py,sha256=kDPkfA-h7n8c-8M0JMBhRGC7nH8s518W5NbNab5a7Wk,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=
|
27
|
+
lfss/src/server.py,sha256=J1pYq65thHC9ecFqeszHFoQuoFLEsgg6hWmW3XA9U-s,15447
|
28
28
|
lfss/src/stat.py,sha256=hTMtQyM_Ukmhc33Bb9FGCfBMIX02KrGHQg8nL7sC8sU,2082
|
29
29
|
lfss/src/utils.py,sha256=8VkrtpSmurbMiX7GyK-n7Grvzy3uwSJXHdONEsuLCDI,2272
|
30
|
-
lfss-0.7.
|
31
|
-
lfss-0.7.
|
32
|
-
lfss-0.7.
|
33
|
-
lfss-0.7.
|
30
|
+
lfss-0.7.3.dist-info/METADATA,sha256=Ize9PqeFnq-Ki18BgTteSs9HSzaICY20mRUE35s9kV8,1967
|
31
|
+
lfss-0.7.3.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
32
|
+
lfss-0.7.3.dist-info/entry_points.txt,sha256=d_Ri3GXxUW-S0E6q953A8od0YMmUAnZGlJSKS46OiW8,172
|
33
|
+
lfss-0.7.3.dist-info/RECORD,,
|
File without changes
|
File without changes
|