lfss 0.4.1__tar.gz → 0.5.1__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 (30) hide show
  1. {lfss-0.4.1 → lfss-0.5.1}/PKG-INFO +2 -1
  2. {lfss-0.4.1 → lfss-0.5.1}/frontend/api.js +9 -7
  3. {lfss-0.4.1 → lfss-0.5.1}/frontend/scripts.js +29 -11
  4. {lfss-0.4.1 → lfss-0.5.1}/frontend/styles.css +1 -0
  5. {lfss-0.4.1 → lfss-0.5.1}/frontend/utils.js +6 -0
  6. lfss-0.5.1/lfss/cli/balance.py +111 -0
  7. {lfss-0.4.1 → lfss-0.5.1}/lfss/cli/cli.py +9 -5
  8. {lfss-0.4.1 → lfss-0.5.1}/lfss/cli/user.py +2 -0
  9. {lfss-0.4.1 → lfss-0.5.1}/lfss/client/__init__.py +2 -0
  10. {lfss-0.4.1 → lfss-0.5.1}/lfss/client/api.py +11 -8
  11. lfss-0.5.1/lfss/sql/init.sql +38 -0
  12. lfss-0.5.1/lfss/sql/pragma.sql +5 -0
  13. lfss-0.5.1/lfss/src/config.py +16 -0
  14. {lfss-0.4.1 → lfss-0.5.1}/lfss/src/database.py +163 -85
  15. {lfss-0.4.1 → lfss-0.5.1}/lfss/src/error.py +2 -0
  16. {lfss-0.4.1 → lfss-0.5.1}/lfss/src/server.py +54 -31
  17. {lfss-0.4.1 → lfss-0.5.1}/lfss/src/stat.py +1 -1
  18. {lfss-0.4.1 → lfss-0.5.1}/pyproject.toml +4 -2
  19. lfss-0.4.1/lfss/src/config.py +0 -12
  20. {lfss-0.4.1 → lfss-0.5.1}/Readme.md +0 -0
  21. {lfss-0.4.1 → lfss-0.5.1}/docs/Known_issues.md +0 -0
  22. {lfss-0.4.1 → lfss-0.5.1}/docs/Permission.md +0 -0
  23. {lfss-0.4.1 → lfss-0.5.1}/frontend/index.html +0 -0
  24. {lfss-0.4.1 → lfss-0.5.1}/frontend/popup.css +0 -0
  25. {lfss-0.4.1 → lfss-0.5.1}/frontend/popup.js +0 -0
  26. {lfss-0.4.1 → lfss-0.5.1}/lfss/cli/panel.py +0 -0
  27. {lfss-0.4.1 → lfss-0.5.1}/lfss/cli/serve.py +0 -0
  28. {lfss-0.4.1 → lfss-0.5.1}/lfss/src/__init__.py +0 -0
  29. {lfss-0.4.1 → lfss-0.5.1}/lfss/src/log.py +0 -0
  30. {lfss-0.4.1 → lfss-0.5.1}/lfss/src/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.4.1
3
+ Version: 0.5.1
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -11,6 +11,7 @@ Classifier: Programming Language :: Python :: 3.9
11
11
  Classifier: Programming Language :: Python :: 3.10
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: aiofiles (==23.*)
14
15
  Requires-Dist: aiosqlite (==0.*)
15
16
  Requires-Dist: fastapi (==0.*)
16
17
  Requires-Dist: mimesniff (==1.*)
@@ -25,6 +25,8 @@
25
25
  * @typedef {Object} DirectoryRecord
26
26
  * @property {string} url - the url of the directory
27
27
  * @property {string} size - the size of the directory, in bytes
28
+ * @property {string} create_time - the time the directory was created
29
+ * @property {string} access_time - the time the directory was last accessed
28
30
  *
29
31
  * @typedef {Object} PathListResponse
30
32
  * @property {DirectoryRecord[]} dirs - the list of directories in the directory
@@ -55,13 +57,13 @@ export default class Connector {
55
57
  * @returns {Promise<string>} - the promise of the request, the url of the file
56
58
  */
57
59
  async put(path, file, {
58
- overwrite = false,
60
+ conflict = 'abort',
59
61
  permission = 0
60
62
  } = {}){
61
63
  if (path.startsWith('/')){ path = path.slice(1); }
62
64
  const fileBytes = await file.arrayBuffer();
63
65
  const dst = new URL(this.config.endpoint + '/' + path);
64
- dst.searchParams.append('overwrite', overwrite);
66
+ dst.searchParams.append('conflict', conflict);
65
67
  dst.searchParams.append('permission', permission);
66
68
  const res = await fetch(dst.toString(), {
67
69
  method: 'PUT',
@@ -112,12 +114,12 @@ export default class Connector {
112
114
  }
113
115
 
114
116
  /**
115
- * @param {string} path - the path to the file
116
- * @returns {Promise<FileRecord | null>} - the promise of the request
117
+ * @param {string} path - the path to the file or directory
118
+ * @returns {Promise<FileRecord | DirectoryRecord | null>} - the promise of the request
117
119
  */
118
120
  async getMetadata(path){
119
121
  if (path.startsWith('/')){ path = path.slice(1); }
120
- const res = await fetch(this.config.endpoint + '/_api/fmeta?path=' + path, {
122
+ const res = await fetch(this.config.endpoint + '/_api/meta?path=' + path, {
121
123
  method: 'GET',
122
124
  headers: {
123
125
  'Authorization': 'Bearer ' + this.config.token
@@ -171,7 +173,7 @@ export default class Connector {
171
173
  */
172
174
  async setFilePermission(path, permission){
173
175
  if (path.startsWith('/')){ path = path.slice(1); }
174
- const dst = new URL(this.config.endpoint + '/_api/fmeta');
176
+ const dst = new URL(this.config.endpoint + '/_api/meta');
175
177
  dst.searchParams.append('path', path);
176
178
  dst.searchParams.append('perm', permission);
177
179
  const res = await fetch(dst.toString(), {
@@ -192,7 +194,7 @@ export default class Connector {
192
194
  async moveFile(path, newPath){
193
195
  if (path.startsWith('/')){ path = path.slice(1); }
194
196
  if (newPath.startsWith('/')){ newPath = newPath.slice(1); }
195
- const dst = new URL(this.config.endpoint + '/_api/fmeta');
197
+ const dst = new URL(this.config.endpoint + '/_api/meta');
196
198
  dst.searchParams.append('path', path);
197
199
  dst.searchParams.append('new_path', newPath);
198
200
  const res = await fetch(dst.toString(), {
@@ -79,6 +79,10 @@ pathBackButton.addEventListener('click', () => {
79
79
 
80
80
  function onFileNameInpuChange(){
81
81
  const fileName = uploadFileNameInput.value;
82
+ if (fileName.endsWith('/')){
83
+ uploadFileNameInput.classList.add('duplicate');
84
+ return;
85
+ }
82
86
  if (fileName.length === 0){
83
87
  uploadFileNameInput.classList.remove('duplicate');
84
88
  }
@@ -172,7 +176,7 @@ Are you sure you want to proceed?
172
176
  async function uploadFile(...args){
173
177
  const [file, path] = args;
174
178
  try{
175
- await conn.put(path, file, {overwrite: true});
179
+ await conn.put(path, file, {conflict: 'overwrite'});
176
180
  }
177
181
  catch (err){
178
182
  showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
@@ -219,6 +223,9 @@ function refreshFileList(){
219
223
 
220
224
  data.dirs.forEach(dir => {
221
225
  const tr = document.createElement('tr');
226
+ const sizeTd = document.createElement('td');
227
+ const accessTimeTd = document.createElement('td');
228
+ const createTimeTd = document.createElement('td');
222
229
  {
223
230
  const nameTd = document.createElement('td');
224
231
  if (dir.url.endsWith('/')){
@@ -239,19 +246,14 @@ function refreshFileList(){
239
246
  tr.appendChild(nameTd);
240
247
  tbody.appendChild(tr);
241
248
  }
242
-
243
249
  {
244
- const sizeTd = document.createElement('td');
250
+ // these are initialized meta
245
251
  sizeTd.textContent = formatSize(dir.size);
246
252
  tr.appendChild(sizeTd);
247
- }
248
- {
249
- const dateTd = document.createElement('td');
250
- tr.appendChild(dateTd);
251
- }
252
- {
253
- const dateTd = document.createElement('td');
254
- tr.appendChild(dateTd);
253
+ accessTimeTd.textContent = cvtGMT2Local(dir.access_time);
254
+ tr.appendChild(accessTimeTd);
255
+ createTimeTd.textContent = cvtGMT2Local(dir.create_time);
256
+ tr.appendChild(createTimeTd);
255
257
  }
256
258
  {
257
259
  const accessTd = document.createElement('td');
@@ -262,6 +264,22 @@ function refreshFileList(){
262
264
  const actContainer = document.createElement('div');
263
265
  actContainer.classList.add('action-container');
264
266
 
267
+ const showMetaButton = document.createElement('a');
268
+ showMetaButton.textContent = 'Details';
269
+ showMetaButton.style.cursor = 'pointer';
270
+ showMetaButton.addEventListener('click', () => {
271
+ const dirUrlEncap = dir.url + (dir.url.endsWith('/') ? '' : '/');
272
+ conn.getMetadata(dirUrlEncap).then(
273
+ (meta) => {
274
+ sizeTd.textContent = formatSize(meta.size);
275
+ accessTimeTd.textContent = cvtGMT2Local(meta.access_time);
276
+ createTimeTd.textContent = cvtGMT2Local(meta.create_time);
277
+ }
278
+ );
279
+ showPopup('Fetching metadata...', {level: 'info', timeout: 3000});
280
+ });
281
+ actContainer.appendChild(showMetaButton);
282
+
265
283
  const downloadButton = document.createElement('a');
266
284
  downloadButton.textContent = 'Download';
267
285
  downloadButton.href = conn.config.endpoint + '/_api/bundle?' +
@@ -192,6 +192,7 @@ input#file-name.duplicate{
192
192
  display: flex;
193
193
  flex-direction: row;
194
194
  gap: 10px;
195
+ /* justify-content: flex-end; */
195
196
  }
196
197
  a{
197
198
  color: #195f8b;
@@ -1,5 +1,8 @@
1
1
 
2
2
  export function formatSize(size){
3
+ if (size < 0){
4
+ return '';
5
+ }
3
6
  const sizeInKb = size / 1024;
4
7
  const sizeInMb = sizeInKb / 1024;
5
8
  const sizeInGb = sizeInMb / 1024;
@@ -68,6 +71,9 @@ export function getRandomString(n, additionalCharset='0123456789_-(=)[]{}'){
68
71
  * @returns {string}
69
72
  */
70
73
  export function cvtGMT2Local(dateStr){
74
+ if (!dateStr || dateStr === 'N/A'){
75
+ return '';
76
+ }
71
77
  const gmtdate = new Date(dateStr);
72
78
  const localdate = new Date(gmtdate.getTime() + gmtdate.getTimezoneOffset() * 60000);
73
79
  return localdate.toISOString().slice(0, 19).replace('T', ' ');
@@ -0,0 +1,111 @@
1
+ """
2
+ Balance the storage by ensuring that large file thresholds are met.
3
+ """
4
+
5
+ from lfss.src.config import DATA_HOME, LARGE_BLOB_DIR, LARGE_FILE_BYTES
6
+ import argparse, time
7
+ from functools import wraps
8
+ from asyncio import Semaphore
9
+ import aiosqlite, aiofiles, asyncio
10
+
11
+ sem = Semaphore(1)
12
+ db_file = DATA_HOME / 'lfss.db'
13
+
14
+ def _get_sem():
15
+ return sem
16
+
17
+ def barriered(func):
18
+ @wraps(func)
19
+ async def wrapper(*args, **kwargs):
20
+ async with _get_sem():
21
+ return await func(*args, **kwargs)
22
+ return wrapper
23
+
24
+ @barriered
25
+ async def move_to_external(f_id: str, flag: str = ''):
26
+ async with aiosqlite.connect(db_file, timeout = 60) as c:
27
+ async with c.execute( "SELECT data FROM fdata WHERE file_id = ?", (f_id,)) as cursor:
28
+ blob_row = await cursor.fetchone()
29
+ if blob_row is None:
30
+ print(f"{flag}File {f_id} not found in fdata")
31
+ return
32
+ await c.execute("BEGIN")
33
+ blob: bytes = blob_row[0]
34
+ try:
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 fdata WHERE file_id = ?", (f_id,))
39
+ await c.commit()
40
+ print(f"{flag}Moved {f_id} to external storage")
41
+ except Exception as e:
42
+ await c.rollback()
43
+ print(f"{flag}Error moving {f_id}: {e}")
44
+
45
+ if isinstance(e, KeyboardInterrupt):
46
+ raise e
47
+
48
+ @barriered
49
+ async def move_to_internal(f_id: str, flag: str = ''):
50
+ async with aiosqlite.connect(db_file, timeout = 60) as c:
51
+ if not (LARGE_BLOB_DIR / f_id).exists():
52
+ print(f"{flag}File {f_id} not found in external storage")
53
+ return
54
+ async with aiofiles.open(LARGE_BLOB_DIR / f_id, 'rb') as f:
55
+ blob = await f.read()
56
+
57
+ await c.execute("BEGIN")
58
+ try:
59
+ await c.execute("INSERT INTO fdata (file_id, data) VALUES (?, ?)", (f_id, blob))
60
+ await c.execute("UPDATE fmeta SET external = 0 WHERE file_id = ?", (f_id,))
61
+ await c.commit()
62
+ (LARGE_BLOB_DIR / f_id).unlink(missing_ok=True)
63
+ print(f"{flag}Moved {f_id} to internal storage")
64
+ except Exception as e:
65
+ await c.rollback()
66
+ print(f"{flag}Error moving {f_id}: {e}")
67
+ if isinstance(e, KeyboardInterrupt):
68
+ raise e
69
+
70
+
71
+ async def _main():
72
+
73
+ tasks = []
74
+ start_time = time.time()
75
+ async with aiosqlite.connect(db_file) as conn:
76
+ exceeded_rows = await (await conn.execute(
77
+ "SELECT file_id FROM fmeta WHERE file_size > ? AND external = 0",
78
+ (LARGE_FILE_BYTES,)
79
+ )).fetchall()
80
+
81
+ for i in range(0, len(exceeded_rows)):
82
+ row = exceeded_rows[i]
83
+ f_id = row[0]
84
+ tasks.append(move_to_external(f_id, flag=f"[e-{i+1}/{len(exceeded_rows)}] "))
85
+
86
+ async with aiosqlite.connect(db_file) as conn:
87
+ under_rows = await (await conn.execute(
88
+ "SELECT file_id, file_size, external FROM fmeta WHERE file_size <= ? AND external = 1",
89
+ (LARGE_FILE_BYTES,)
90
+ )).fetchall()
91
+
92
+ for i in range(0, len(under_rows)):
93
+ row = under_rows[i]
94
+ f_id = row[0]
95
+ tasks.append(move_to_internal(f_id, flag=f"[i-{i+1}/{len(under_rows)}] "))
96
+
97
+ await asyncio.gather(*tasks)
98
+ end_time = time.time()
99
+ print(f"Balancing complete, took {end_time - start_time:.2f} seconds. "
100
+ f"{len(exceeded_rows)} files moved to external storage, {len(under_rows)} files moved to internal storage.")
101
+
102
+ def main():
103
+ global sem
104
+ parser = argparse.ArgumentParser(description="Balance the storage by ensuring that large file thresholds are met.")
105
+ parser.add_argument("-j", "--jobs", type=int, default=2, help="Number of concurrent jobs")
106
+ args = parser.parse_args()
107
+ sem = Semaphore(args.jobs)
108
+ asyncio.run(_main())
109
+
110
+ if __name__ == '__main__':
111
+ main()
@@ -13,8 +13,8 @@ def parse_arguments():
13
13
  sp_upload.add_argument("src", help="Source file or directory", type=str)
14
14
  sp_upload.add_argument("dst", help="Destination path", type=str)
15
15
  sp_upload.add_argument("-j", "--jobs", type=int, default=1, help="Number of concurrent uploads")
16
- sp_upload.add_argument("--interval", type=float, default=0, help="Interval between retries, only works with directory upload")
17
- sp_upload.add_argument("--overwrite", action="store_true", help="Overwrite existing files")
16
+ sp_upload.add_argument("--interval", type=float, default=0, help="Interval between files, only works with directory upload")
17
+ sp_upload.add_argument("--conflict", choices=["overwrite", "abort", "skip"], default="abort", help="Conflict resolution")
18
18
  sp_upload.add_argument("--permission", type=FileReadPermission, default=FileReadPermission.UNSET, help="File permission")
19
19
  sp_upload.add_argument("--retries", type=int, default=0, help="Number of retries, only works with directory upload")
20
20
 
@@ -26,21 +26,25 @@ def main():
26
26
  if args.command == "upload":
27
27
  src_path = Path(args.src)
28
28
  if src_path.is_dir():
29
- upload_directory(
29
+ failed_upload = upload_directory(
30
30
  connector, args.src, args.dst,
31
31
  verbose=True,
32
32
  n_concurrent=args.jobs,
33
33
  n_reties=args.retries,
34
34
  interval=args.interval,
35
- overwrite=args.overwrite,
35
+ conflict=args.conflict,
36
36
  permission=args.permission
37
37
  )
38
+ if failed_upload:
39
+ print("Failed to upload:")
40
+ for path in failed_upload:
41
+ print(f" {path}")
38
42
  else:
39
43
  with open(args.src, 'rb') as f:
40
44
  connector.put(
41
45
  args.dst,
42
46
  f.read(),
43
- overwrite=args.overwrite,
47
+ conflict=args.conflict,
44
48
  permission=args.permission
45
49
  )
46
50
  else:
@@ -8,6 +8,8 @@ def parse_storage_size(s: str) -> int:
8
8
  return int(s[:-1]) * 1024 * 1024
9
9
  if s[-1] in 'Gg':
10
10
  return int(s[:-1]) * 1024 * 1024 * 1024
11
+ if s[-1] in 'Tt':
12
+ return int(s[:-1]) * 1024 * 1024 * 1024 * 1024
11
13
  return int(s)
12
14
 
13
15
  async def _main():
@@ -39,6 +39,8 @@ def upload_directory(
39
39
  connector.put(dst_path, blob, **put_kwargs)
40
40
  break
41
41
  except Exception as e:
42
+ if isinstance(e, KeyboardInterrupt):
43
+ raise e
42
44
  if verbose:
43
45
  print(f"[{this_count}] Error uploading {file_path}: {e}, retrying...")
44
46
  this_try += 1
@@ -3,7 +3,7 @@ import os
3
3
  import requests
4
4
  import urllib.parse
5
5
  from lfss.src.database import (
6
- FileReadPermission, FileRecord, UserRecord, PathContents
6
+ FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents
7
7
  )
8
8
 
9
9
  _default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
@@ -34,13 +34,13 @@ class Connector:
34
34
  return response
35
35
  return f
36
36
 
37
- def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, overwrite: bool = False):
37
+ def put(self, path: str, file_data: bytes, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip'] = 'abort'):
38
38
  """Uploads a file to the specified path."""
39
39
  if path.startswith('/'):
40
40
  path = path[1:]
41
41
  response = self._fetch('PUT', path, search_params={
42
42
  'permission': int(permission),
43
- 'overwrite': overwrite
43
+ 'conflict': conflict
44
44
  })(
45
45
  data=file_data,
46
46
  headers={'Content-Type': 'application/octet-stream'}
@@ -63,11 +63,14 @@ class Connector:
63
63
  path = path[1:]
64
64
  self._fetch('DELETE', path)()
65
65
 
66
- def get_metadata(self, path: str) -> Optional[FileRecord]:
66
+ def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
67
67
  """Gets the metadata for the file at the specified path."""
68
68
  try:
69
- response = self._fetch('GET', '_api/fmeta', {'path': path})()
70
- return FileRecord(**response.json())
69
+ response = self._fetch('GET', '_api/meta', {'path': path})()
70
+ if path.endswith('/'):
71
+ return DirectoryRecord(**response.json())
72
+ else:
73
+ return FileRecord(**response.json())
71
74
  except requests.exceptions.HTTPError as e:
72
75
  if e.response.status_code == 404:
73
76
  return None
@@ -80,13 +83,13 @@ class Connector:
80
83
 
81
84
  def set_file_permission(self, path: str, permission: int | FileReadPermission):
82
85
  """Sets the file permission for the specified path."""
83
- self._fetch('POST', '_api/fmeta', {'path': path, 'perm': int(permission)})(
86
+ self._fetch('POST', '_api/meta', {'path': path, 'perm': int(permission)})(
84
87
  headers={'Content-Type': 'application/www-form-urlencoded'}
85
88
  )
86
89
 
87
90
  def move_file(self, path: str, new_path: str):
88
91
  """Moves a file to a new location."""
89
- self._fetch('POST', '_api/fmeta', {'path': path, 'new_path': new_path})(
92
+ self._fetch('POST', '_api/meta', {'path': path, 'new_path': new_path})(
90
93
  headers = {'Content-Type': 'application/www-form-urlencoded'}
91
94
  )
92
95
 
@@ -0,0 +1,38 @@
1
+ CREATE TABLE IF NOT EXISTS user (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ username VARCHAR(255) UNIQUE NOT NULL,
4
+ credential VARCHAR(255) NOT NULL,
5
+ is_admin BOOLEAN DEFAULT FALSE,
6
+ create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
7
+ last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
8
+ max_storage INTEGER DEFAULT 1073741824,
9
+ permission INTEGER DEFAULT 0
10
+ );
11
+
12
+ CREATE TABLE IF NOT EXISTS fmeta (
13
+ url VARCHAR(512) PRIMARY KEY,
14
+ owner_id INTEGER NOT NULL,
15
+ file_id VARCHAR(256) NOT NULL,
16
+ file_size INTEGER,
17
+ create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
18
+ access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
19
+ permission INTEGER DEFAULT 0,
20
+ external BOOLEAN DEFAULT FALSE,
21
+ FOREIGN KEY(owner_id) REFERENCES user(id)
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS fdata (
25
+ file_id VARCHAR(256) PRIMARY KEY,
26
+ data BLOB
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS usize (
30
+ user_id INTEGER PRIMARY KEY,
31
+ size INTEGER DEFAULT 0
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url);
35
+
36
+ CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
37
+
38
+ CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential);
@@ -0,0 +1,5 @@
1
+ PRAGMA journal_mode=MEMROY;
2
+ PRAGMA temp_store=MEMORY;
3
+ PRAGMA page_size=8192;
4
+ PRAGMA synchronous=NORMAL;
5
+ PRAGMA case_sensitive_like=ON;
@@ -0,0 +1,16 @@
1
+ from pathlib import Path
2
+ import os
3
+
4
+ __default_dir = '.storage_data'
5
+
6
+ DATA_HOME = Path(os.environ.get('LFSS_DATA', __default_dir))
7
+ if not DATA_HOME.exists():
8
+ DATA_HOME.mkdir()
9
+ print(f"[init] Created data home at {DATA_HOME}")
10
+ LARGE_BLOB_DIR = DATA_HOME / 'large_blobs'
11
+ LARGE_BLOB_DIR.mkdir(exist_ok=True)
12
+
13
+ # https://sqlite.org/fasterthanfs.html
14
+ LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
15
+ MAX_FILE_BYTES = 1024 * 1024 * 1024 # 1GB
16
+ MAX_BUNDLE_BYTES = 1024 * 1024 * 1024 # 1GB
@@ -3,16 +3,18 @@ from typing import Optional, overload, Literal, AsyncIterable
3
3
  from abc import ABC, abstractmethod
4
4
 
5
5
  import urllib.parse
6
+ from pathlib import Path
6
7
  import dataclasses, hashlib, uuid
7
8
  from contextlib import asynccontextmanager
8
9
  from functools import wraps
9
10
  from enum import IntEnum
10
- import zipfile, io
11
+ import zipfile, io, asyncio
11
12
 
12
- import aiosqlite
13
+ import aiosqlite, aiofiles
14
+ import aiofiles.os
13
15
  from asyncio import Lock
14
16
 
15
- from .config import DATA_HOME
17
+ from .config import DATA_HOME, LARGE_BLOB_DIR
16
18
  from .log import get_logger
17
19
  from .utils import decode_uri_compnents
18
20
  from .error import *
@@ -22,6 +24,15 @@ _g_conn: Optional[aiosqlite.Connection] = None
22
24
  def hash_credential(username, password):
23
25
  return hashlib.sha256((username + password).encode()).hexdigest()
24
26
 
27
+ async def execute_sql(conn: aiosqlite.Connection, name: str):
28
+ this_dir = Path(__file__).parent
29
+ sql_dir = this_dir.parent / 'sql'
30
+ async with aiofiles.open(sql_dir / name, 'r') as f:
31
+ sql = await f.read()
32
+ sql = sql.split(';')
33
+ for s in sql:
34
+ await conn.execute(s)
35
+
25
36
  _atomic_lock = Lock()
26
37
  def atomic(func):
27
38
  """ Ensure non-reentrancy """
@@ -47,6 +58,8 @@ class DBConnBase(ABC):
47
58
  global _g_conn
48
59
  if _g_conn is None:
49
60
  _g_conn = await aiosqlite.connect(DATA_HOME / 'lfss.db')
61
+ await execute_sql(_g_conn, 'pragma.sql')
62
+ await execute_sql(_g_conn, 'init.sql')
50
63
 
51
64
  async def commit(self):
52
65
  await self.conn.commit()
@@ -80,26 +93,6 @@ class UserConn(DBConnBase):
80
93
 
81
94
  async def init(self):
82
95
  await super().init()
83
- # default to 1GB (1024x1024x1024 bytes)
84
- await self.conn.execute('''
85
- CREATE TABLE IF NOT EXISTS user (
86
- id INTEGER PRIMARY KEY AUTOINCREMENT,
87
- username VARCHAR(255) UNIQUE NOT NULL,
88
- credential VARCHAR(255) NOT NULL,
89
- is_admin BOOLEAN DEFAULT FALSE,
90
- create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
91
- last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
92
- max_storage INTEGER DEFAULT 1073741824,
93
- permission INTEGER DEFAULT 0
94
- )
95
- ''')
96
- await self.conn.execute('''
97
- CREATE INDEX IF NOT EXISTS idx_user_username ON user(username)
98
- ''')
99
- await self.conn.execute('''
100
- CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential)
101
- ''')
102
-
103
96
  return self
104
97
 
105
98
  async def get_user(self, username: str) -> Optional[UserRecord]:
@@ -190,15 +183,19 @@ class FileRecord:
190
183
  create_time: str
191
184
  access_time: str
192
185
  permission: FileReadPermission
186
+ external: bool
193
187
 
194
188
  def __str__(self):
195
189
  return f"File {self.url} (owner={self.owner_id}, created at {self.create_time}, accessed at {self.access_time}, " + \
196
- f"file_id={self.file_id}, permission={self.permission}, size={self.file_size})"
190
+ f"file_id={self.file_id}, permission={self.permission}, size={self.file_size}, external={self.external})"
197
191
 
198
192
  @dataclasses.dataclass
199
193
  class DirectoryRecord:
200
194
  url: str
201
195
  size: int
196
+ create_time: str = ""
197
+ update_time: str = ""
198
+ access_time: str = ""
202
199
 
203
200
  def __str__(self):
204
201
  return f"Directory {self.url} (size={self.size})"
@@ -216,35 +213,6 @@ class FileConn(DBConnBase):
216
213
 
217
214
  async def init(self):
218
215
  await super().init()
219
- await self.conn.execute('''
220
- CREATE TABLE IF NOT EXISTS fmeta (
221
- url VARCHAR(512) PRIMARY KEY,
222
- owner_id INTEGER NOT NULL,
223
- file_id VARCHAR(256) NOT NULL,
224
- file_size INTEGER,
225
- create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
226
- access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
227
- permission INTEGER DEFAULT 0
228
- )
229
- ''')
230
- await self.conn.execute('''
231
- CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url)
232
- ''')
233
-
234
- await self.conn.execute('''
235
- CREATE TABLE IF NOT EXISTS fdata (
236
- file_id VARCHAR(256) PRIMARY KEY,
237
- data BLOB
238
- )
239
- ''')
240
-
241
- # user file size table
242
- await self.conn.execute('''
243
- CREATE TABLE IF NOT EXISTS usize (
244
- user_id INTEGER PRIMARY KEY,
245
- size INTEGER DEFAULT 0
246
- )
247
- ''')
248
216
  # backward compatibility, since 0.2.1
249
217
  async with self.conn.execute("SELECT * FROM user") as cursor:
250
218
  res = await cursor.fetchall()
@@ -256,6 +224,16 @@ class FileConn(DBConnBase):
256
224
  size = await cursor.fetchone()
257
225
  if size is not None and size[0] is not None:
258
226
  await self._user_size_inc(r[0], size[0])
227
+
228
+ # backward compatibility, since 0.5.0
229
+ # 'external' means the file is not stored in the database, but in the external storage
230
+ async with self.conn.execute("SELECT * FROM fmeta") as cursor:
231
+ res = await cursor.fetchone()
232
+ if res and len(res) < 8:
233
+ self.logger.info("Updating fmeta table")
234
+ await self.conn.execute('''
235
+ ALTER TABLE fmeta ADD COLUMN external BOOLEAN DEFAULT FALSE
236
+ ''')
259
237
 
260
238
  return self
261
239
 
@@ -278,7 +256,7 @@ class FileConn(DBConnBase):
278
256
  res = await cursor.fetchall()
279
257
  return [self.parse_record(r) for r in res]
280
258
 
281
- async def get_path_records(self, url: str) -> list[FileRecord]:
259
+ async def get_path_file_records(self, url: str) -> list[FileRecord]:
282
260
  async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (url + '%', )) as cursor:
283
261
  res = await cursor.fetchall()
284
262
  return [self.parse_record(r) for r in res]
@@ -348,10 +326,27 @@ class FileConn(DBConnBase):
348
326
  ) as cursor:
349
327
  res = await cursor.fetchall()
350
328
  dirs_str = [r[0] + '/' for r in res if r[0] != '/']
351
- dirs = [DirectoryRecord(url + d, await self.path_size(url + d, include_subpath=True)) for d in dirs_str]
352
-
329
+ async def get_dir(dir_url):
330
+ return DirectoryRecord(dir_url, -1)
331
+ dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
353
332
  return PathContents(dirs, files)
354
333
 
334
+ async def get_path_record(self, url: str) -> Optional[DirectoryRecord]:
335
+ assert url.endswith('/'), "Path must end with /"
336
+ async with self.conn.execute("""
337
+ SELECT MIN(create_time) as create_time,
338
+ MAX(create_time) as update_time,
339
+ MAX(access_time) as access_time
340
+ FROM fmeta
341
+ WHERE url LIKE ?
342
+ """, (url + '%', )) as cursor:
343
+ result = await cursor.fetchone()
344
+ if result is None or any(val is None for val in result):
345
+ raise PathNotFoundError(f"Path {url} not found")
346
+ create_time, update_time, access_time = result
347
+ p_size = await self.path_size(url, include_subpath=True)
348
+ return DirectoryRecord(url, p_size, create_time=create_time, update_time=update_time, access_time=access_time)
349
+
355
350
  async def user_size(self, user_id: int) -> int:
356
351
  async with self.conn.execute("SELECT size FROM usize WHERE user_id = ?", (user_id, )) as cursor:
357
352
  res = await cursor.fetchone()
@@ -383,7 +378,8 @@ class FileConn(DBConnBase):
383
378
  owner_id: Optional[int] = None,
384
379
  file_id: Optional[str] = None,
385
380
  file_size: Optional[int] = None,
386
- permission: Optional[ FileReadPermission ] = None
381
+ permission: Optional[ FileReadPermission ] = None,
382
+ external: Optional[bool] = None
387
383
  ):
388
384
 
389
385
  old = await self.get_file_record(url)
@@ -392,6 +388,7 @@ class FileConn(DBConnBase):
392
388
  # should delete the old blob if file_id is changed
393
389
  assert file_id is None, "Cannot update file id"
394
390
  assert file_size is None, "Cannot update file size"
391
+ assert external is None, "Cannot update external"
395
392
 
396
393
  if owner_id is None: owner_id = old.owner_id
397
394
  if permission is None: permission = old.permission
@@ -402,13 +399,13 @@ class FileConn(DBConnBase):
402
399
  """, (owner_id, int(permission), url))
403
400
  self.logger.info(f"File {url} updated")
404
401
  else:
405
- self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}")
402
+ self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}, external={external}")
406
403
  if permission is None:
407
404
  permission = FileReadPermission.UNSET
408
- assert owner_id is not None and file_id is not None and file_size is not None, "Missing required fields"
405
+ assert owner_id is not None and file_id is not None and file_size is not None and external is not None
409
406
  await self.conn.execute(
410
- "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission) VALUES (?, ?, ?, ?, ?)",
411
- (url, owner_id, file_id, file_size, int(permission))
407
+ "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission, external) VALUES (?, ?, ?, ?, ?, ?)",
408
+ (url, owner_id, file_id, file_size, int(permission), external)
412
409
  )
413
410
  await self._user_size_inc(owner_id, file_size)
414
411
  self.logger.info(f"File {url} created")
@@ -465,6 +462,20 @@ class FileConn(DBConnBase):
465
462
  async def set_file_blob(self, file_id: str, blob: bytes):
466
463
  await self.conn.execute("INSERT OR REPLACE INTO fdata (file_id, data) VALUES (?, ?)", (file_id, blob))
467
464
 
465
+ @atomic
466
+ async def set_file_blob_external(self, file_id: str, stream: AsyncIterable[bytes])->int:
467
+ size_sum = 0
468
+ try:
469
+ async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'wb') as f:
470
+ async for chunk in stream:
471
+ size_sum += len(chunk)
472
+ await f.write(chunk)
473
+ except Exception as e:
474
+ if (LARGE_BLOB_DIR / file_id).exists():
475
+ await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
476
+ raise
477
+ return size_sum
478
+
468
479
  async def get_file_blob(self, file_id: str) -> Optional[bytes]:
469
480
  async with self.conn.execute("SELECT data FROM fdata WHERE file_id = ?", (file_id, )) as cursor:
470
481
  res = await cursor.fetchone()
@@ -472,6 +483,17 @@ class FileConn(DBConnBase):
472
483
  return None
473
484
  return res[0]
474
485
 
486
+ async def get_file_blob_external(self, file_id: str) -> AsyncIterable[bytes]:
487
+ assert (LARGE_BLOB_DIR / file_id).exists(), f"File {file_id} not found"
488
+ async with aiofiles.open(LARGE_BLOB_DIR / file_id, 'rb') as f:
489
+ async for chunk in f:
490
+ yield chunk
491
+
492
+ @atomic
493
+ async def delete_file_blob_external(self, file_id: str):
494
+ if (LARGE_BLOB_DIR / file_id).exists():
495
+ await aiofiles.os.remove(LARGE_BLOB_DIR / file_id)
496
+
475
497
  @atomic
476
498
  async def delete_file_blob(self, file_id: str):
477
499
  await self.conn.execute("DELETE FROM fdata WHERE file_id = ?", (file_id, ))
@@ -543,9 +565,15 @@ class Database:
543
565
  if _g_conn is not None:
544
566
  await _g_conn.rollback()
545
567
 
546
- async def save_file(self, u: int | str, url: str, blob: bytes, permission: FileReadPermission = FileReadPermission.UNSET):
568
+ async def save_file(
569
+ self, u: int | str, url: str,
570
+ blob: bytes | AsyncIterable[bytes],
571
+ permission: FileReadPermission = FileReadPermission.UNSET
572
+ ):
573
+ """
574
+ if file_size is not provided, the blob must be bytes
575
+ """
547
576
  validate_url(url)
548
- assert isinstance(blob, bytes), "blob must be bytes"
549
577
 
550
578
  user = await get_user(self, u)
551
579
  if user is None:
@@ -562,25 +590,48 @@ class Database:
562
590
  if await get_user(self, first_component) is None:
563
591
  raise PermissionDeniedError(f"Invalid path: {first_component} is not a valid username")
564
592
 
565
- # check if fize_size is within limit
566
- file_size = len(blob)
567
593
  user_size_used = await self.file.user_size(user.id)
568
- if user_size_used + file_size > user.max_storage:
569
- raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
570
-
571
- f_id = uuid.uuid4().hex
572
- async with transaction(self):
573
- await self.file.set_file_blob(f_id, blob)
574
- await self.file.set_file_record(url, owner_id=user.id, file_id=f_id, file_size=file_size, permission=permission)
575
- await self.user.set_active(user.username)
594
+ if isinstance(blob, bytes):
595
+ file_size = len(blob)
596
+ if user_size_used + file_size > user.max_storage:
597
+ raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
598
+ f_id = uuid.uuid4().hex
599
+ async with transaction(self):
600
+ await self.file.set_file_blob(f_id, blob)
601
+ await self.file.set_file_record(
602
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
603
+ permission=permission, external=False)
604
+ await self.user.set_active(user.username)
605
+ else:
606
+ assert isinstance(blob, AsyncIterable)
607
+ async with transaction(self):
608
+ f_id = uuid.uuid4().hex
609
+ file_size = await self.file.set_file_blob_external(f_id, blob)
610
+ if user_size_used + file_size > user.max_storage:
611
+ await self.file.delete_file_blob_external(f_id)
612
+ raise StorageExceededError(f"Unable to save file, user {user.username} has storage limit of {user.max_storage}, used {user_size_used}, requested {file_size}")
613
+ await self.file.set_file_record(
614
+ url, owner_id=user.id, file_id=f_id, file_size=file_size,
615
+ permission=permission, external=True)
616
+ await self.user.set_active(user.username)
617
+
618
+ async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
619
+ validate_url(url)
620
+ r = await self.file.get_file_record(url)
621
+ if r is None:
622
+ raise FileNotFoundError(f"File {url} not found")
623
+ if not r.external:
624
+ raise ValueError(f"File {url} is not stored externally, should use read_file instead")
625
+ return self.file.get_file_blob_external(r.file_id)
576
626
 
577
- # async def read_file_stream(self, url: str): ...
578
627
  async def read_file(self, url: str) -> bytes:
579
628
  validate_url(url)
580
629
 
581
630
  r = await self.file.get_file_record(url)
582
631
  if r is None:
583
632
  raise FileNotFoundError(f"File {url} not found")
633
+ if r.external:
634
+ raise ValueError(f"File {url} is stored externally, should use read_file_stream instead")
584
635
 
585
636
  f_id = r.file_id
586
637
  blob = await self.file.get_file_blob(f_id)
@@ -600,8 +651,11 @@ class Database:
600
651
  if r is None:
601
652
  return None
602
653
  f_id = r.file_id
603
- await self.file.delete_file_blob(f_id)
604
654
  await self.file.delete_file_record(url)
655
+ if r.external:
656
+ await self.file.delete_file_blob_external(f_id)
657
+ else:
658
+ await self.file.delete_file_blob(f_id)
605
659
  return r
606
660
 
607
661
  async def move_file(self, old_url: str, new_url: str):
@@ -611,17 +665,33 @@ class Database:
611
665
  async with transaction(self):
612
666
  await self.file.move_file(old_url, new_url)
613
667
 
668
+ async def __batch_delete_file_blobs(self, file_records: list[FileRecord], batch_size: int = 512):
669
+ # https://github.com/langchain-ai/langchain/issues/10321
670
+ internal_ids = []
671
+ external_ids = []
672
+ for r in file_records:
673
+ if r.external:
674
+ external_ids.append(r.file_id)
675
+ else:
676
+ internal_ids.append(r.file_id)
677
+
678
+ for i in range(0, len(internal_ids), batch_size):
679
+ await self.file.delete_file_blobs([r for r in internal_ids[i:i+batch_size]])
680
+ for i in range(0, len(external_ids)):
681
+ await self.file.delete_file_blob_external(external_ids[i])
682
+
683
+
614
684
  async def delete_path(self, url: str):
615
685
  validate_url(url, is_file=False)
616
686
 
617
687
  async with transaction(self):
618
- records = await self.file.get_path_records(url)
688
+ records = await self.file.get_path_file_records(url)
619
689
  if not records:
620
690
  return None
621
- await self.file.delete_file_blobs([r.file_id for r in records])
691
+ await self.__batch_delete_file_blobs(records)
622
692
  await self.file.delete_path_records(url)
623
693
  return records
624
-
694
+
625
695
  async def delete_user(self, u: str | int):
626
696
  user = await get_user(self, u)
627
697
  if user is None:
@@ -629,11 +699,11 @@ class Database:
629
699
 
630
700
  async with transaction(self):
631
701
  records = await self.file.get_user_file_records(user.id)
632
- await self.file.delete_file_blobs([r.file_id for r in records])
702
+ await self.__batch_delete_file_blobs(records)
633
703
  await self.file.delete_user_file_records(user.id)
634
704
  await self.user.delete_user(user.username)
635
705
 
636
- async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes]]:
706
+ async def iter_path(self, top_url: str, urls: Optional[list[str]]) -> AsyncIterable[tuple[FileRecord, bytes | AsyncIterable[bytes]]]:
637
707
  if urls is None:
638
708
  urls = [r.url for r in await self.file.list_path(top_url, flat=True)]
639
709
 
@@ -644,10 +714,13 @@ class Database:
644
714
  if r is None:
645
715
  continue
646
716
  f_id = r.file_id
647
- blob = await self.file.get_file_blob(f_id)
648
- if blob is None:
649
- self.logger.warning(f"Blob not found for {url}")
650
- continue
717
+ if r.external:
718
+ blob = self.file.get_file_blob_external(f_id)
719
+ else:
720
+ blob = await self.file.get_file_blob(f_id)
721
+ if blob is None:
722
+ self.logger.warning(f"Blob not found for {url}")
723
+ continue
651
724
  yield r, blob
652
725
 
653
726
  async def zip_path(self, top_url: str, urls: Optional[list[str]]) -> io.BytesIO:
@@ -658,7 +731,12 @@ class Database:
658
731
  async for (r, blob) in self.iter_path(top_url, urls):
659
732
  rel_path = r.url[len(top_url):]
660
733
  rel_path = decode_uri_compnents(rel_path)
661
- zf.writestr(rel_path, blob)
734
+ if r.external:
735
+ assert isinstance(blob, AsyncIterable)
736
+ zf.writestr(rel_path, b''.join([chunk async for chunk in blob]))
737
+ else:
738
+ assert isinstance(blob, bytes)
739
+ zf.writestr(rel_path, blob)
662
740
  buffer.seek(0)
663
741
  return buffer
664
742
 
@@ -1,6 +1,8 @@
1
1
 
2
2
  class LFSSExceptionBase(Exception):...
3
3
 
4
+ class PathNotFoundError(LFSSExceptionBase, FileNotFoundError):...
5
+
4
6
  class PermissionDeniedError(LFSSExceptionBase, PermissionError):...
5
7
 
6
8
  class InvalidPathError(LFSSExceptionBase, ValueError):...
@@ -1,7 +1,8 @@
1
- from typing import Optional
1
+ from typing import Optional, Literal
2
2
  from functools import wraps
3
3
 
4
4
  from fastapi import FastAPI, APIRouter, Depends, Request, Response
5
+ from fastapi.responses import StreamingResponse
5
6
  from fastapi.exceptions import HTTPException
6
7
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
7
8
  from fastapi.middleware.cors import CORSMiddleware
@@ -14,7 +15,7 @@ from contextlib import asynccontextmanager
14
15
  from .error import *
15
16
  from .log import get_logger
16
17
  from .stat import RequestDB
17
- from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES
18
+ from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_BLOB_DIR, LARGE_FILE_BYTES
18
19
  from .utils import ensure_uri_compnents, format_last_modified, now_stamp
19
20
  from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission
20
21
 
@@ -141,19 +142,32 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
141
142
 
142
143
  fname = path.split("/")[-1]
143
144
  async def send(media_type: Optional[str] = None, disposition = "attachment"):
144
- fblob = await conn.read_file(path)
145
- if media_type is None:
146
- media_type, _ = mimetypes.guess_type(fname)
147
- if media_type is None:
148
- media_type = mimesniff.what(fblob)
149
-
150
- return Response(
151
- content=fblob, media_type=media_type, headers={
152
- "Content-Disposition": f"{disposition}; filename={fname}",
153
- "Content-Length": str(len(fblob)),
154
- "Last-Modified": format_last_modified(file_record.create_time)
155
- }
156
- )
145
+ if not file_record.external:
146
+ fblob = await conn.read_file(path)
147
+ if media_type is None:
148
+ media_type, _ = mimetypes.guess_type(fname)
149
+ if media_type is None:
150
+ media_type = mimesniff.what(fblob)
151
+ return Response(
152
+ content=fblob, media_type=media_type, headers={
153
+ "Content-Disposition": f"{disposition}; filename={fname}",
154
+ "Content-Length": str(len(fblob)),
155
+ "Last-Modified": format_last_modified(file_record.create_time)
156
+ }
157
+ )
158
+
159
+ else:
160
+ if media_type is None:
161
+ media_type, _ = mimetypes.guess_type(fname)
162
+ if media_type is None:
163
+ media_type = mimesniff.what(str((LARGE_BLOB_DIR / file_record.file_id).absolute()))
164
+ return StreamingResponse(
165
+ await conn.read_file_stream(path), media_type=media_type, headers={
166
+ "Content-Disposition": f"{disposition}; filename={fname}",
167
+ "Content-Length": str(file_record.file_size),
168
+ "Last-Modified": format_last_modified(file_record.create_time)
169
+ }
170
+ )
157
171
 
158
172
  if download:
159
173
  return await send('application/octet-stream', "attachment")
@@ -165,7 +179,7 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
165
179
  async def put_file(
166
180
  request: Request,
167
181
  path: str,
168
- overwrite: Optional[bool] = False,
182
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
169
183
  permission: int = 0,
170
184
  user: UserRecord = Depends(get_current_user)):
171
185
  path = ensure_uri_compnents(path)
@@ -187,8 +201,12 @@ async def put_file(
187
201
  exists_flag = False
188
202
  file_record = await conn.file.get_file_record(path)
189
203
  if file_record:
190
- if not overwrite:
204
+ if conflict == "abort":
191
205
  raise HTTPException(status_code=409, detail="File exists")
206
+ if conflict == "skip":
207
+ return Response(status_code=200, headers={
208
+ "Content-Type": "application/json",
209
+ }, content=json.dumps({"url": path}))
192
210
  # remove the old file
193
211
  exists_flag = True
194
212
  await conn.delete_file(path)
@@ -198,20 +216,26 @@ async def put_file(
198
216
  logger.debug(f"Content-Type: {content_type}")
199
217
  if content_type == "application/json":
200
218
  body = await request.json()
201
- await conn.save_file(user.id, path, json.dumps(body).encode('utf-8'), permission = FileReadPermission(permission))
219
+ blobs = json.dumps(body).encode('utf-8')
202
220
  elif content_type == "application/x-www-form-urlencoded":
203
221
  # may not work...
204
222
  body = await request.form()
205
223
  file = body.get("file")
206
224
  if isinstance(file, str) or file is None:
207
225
  raise HTTPException(status_code=400, detail="Invalid form data, file required")
208
- await conn.save_file(user.id, path, await file.read(), permission = FileReadPermission(permission))
226
+ blobs = await file.read()
209
227
  elif content_type == "application/octet-stream":
210
- body = await request.body()
211
- await conn.save_file(user.id, path, body, permission = FileReadPermission(permission))
228
+ blobs = await request.body()
229
+ else:
230
+ blobs = await request.body()
231
+ if len(blobs) > LARGE_FILE_BYTES:
232
+ async def blob_reader():
233
+ chunk_size = 16 * 1024 * 1024 # 16MB
234
+ for b in range(0, len(blobs), chunk_size):
235
+ yield blobs[b:b+chunk_size]
236
+ await conn.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
212
237
  else:
213
- body = await request.body()
214
- await conn.save_file(user.id, path, body, permission = FileReadPermission(permission))
238
+ await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission))
215
239
 
216
240
  # https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
217
241
  if exists_flag:
@@ -289,19 +313,18 @@ async def bundle_files(path: str, user: UserRecord = Depends(get_current_user)):
289
313
  }
290
314
  )
291
315
 
292
- @router_api.get("/fmeta")
316
+ @router_api.get("/meta")
293
317
  @handle_exception
294
318
  async def get_file_meta(path: str, user: UserRecord = Depends(get_current_user)):
295
319
  logger.info(f"GET meta({path}), user: {user.username}")
296
- if path.endswith("/"):
297
- raise HTTPException(status_code=400, detail="Invalid path")
298
320
  path = ensure_uri_compnents(path)
299
- file_record = await conn.file.get_file_record(path)
300
- if not file_record:
301
- raise HTTPException(status_code=404, detail="File not found")
302
- return file_record
321
+ get_fn = conn.file.get_file_record if not path.endswith("/") else conn.file.get_path_record
322
+ record = await get_fn(path)
323
+ if not record:
324
+ raise HTTPException(status_code=404, detail="Path not found")
325
+ return record
303
326
 
304
- @router_api.post("/fmeta")
327
+ @router_api.post("/meta")
305
328
  @handle_exception
306
329
  async def update_file_meta(
307
330
  path: str,
@@ -32,7 +32,7 @@ class RequestDB:
32
32
  async def commit(self):
33
33
  await self.conn.commit()
34
34
 
35
- @debounce_async(0.1)
35
+ @debounce_async(0.05)
36
36
  async def ensure_commit_once(self):
37
37
  await self.commit()
38
38
 
@@ -1,17 +1,18 @@
1
1
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.4.1"
3
+ version = "0.5.1"
4
4
  description = "Lightweight file storage service"
5
5
  authors = ["li, mengxun <limengxun45@outlook.com>"]
6
6
  readme = "Readme.md"
7
7
  homepage = "https://github.com/MenxLi/lfss"
8
8
  repository = "https://github.com/MenxLi/lfss"
9
- include = ["Readme.md", "docs/*", "frontend/*"]
9
+ include = ["Readme.md", "docs/*", "frontend/*", "lfss/sql/*"]
10
10
 
11
11
  [tool.poetry.dependencies]
12
12
  python = ">=3.9"
13
13
  fastapi = "0.*"
14
14
  aiosqlite = "0.*"
15
+ aiofiles = "23.*"
15
16
  mimesniff = "1.*"
16
17
 
17
18
  [tool.poetry.scripts]
@@ -19,6 +20,7 @@ lfss-serve = "lfss.cli.serve:main"
19
20
  lfss-user = "lfss.cli.user:main"
20
21
  lfss-panel = "lfss.cli.panel:main"
21
22
  lfss-cli = "lfss.cli.cli:main"
23
+ lfss-balance = "lfss.cli.balance:main"
22
24
 
23
25
  [build-system]
24
26
  requires = ["poetry-core>=1.0.0"]
@@ -1,12 +0,0 @@
1
- from pathlib import Path
2
- import os
3
-
4
- __default_dir = '.storage_data'
5
-
6
- DATA_HOME = Path(os.environ.get('LFSS_DATA', __default_dir))
7
- if not DATA_HOME.exists():
8
- DATA_HOME.mkdir()
9
- print(f"[init] Created data home at {DATA_HOME}")
10
-
11
- MAX_FILE_BYTES = 256 * 1024 * 1024 # 256MB
12
- MAX_BUNDLE_BYTES = 256 * 1024 * 1024 # 256MB
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