lfss 0.5.0__py3-none-any.whl → 0.5.2__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.
frontend/api.js CHANGED
@@ -57,13 +57,13 @@ export default class Connector {
57
57
  * @returns {Promise<string>} - the promise of the request, the url of the file
58
58
  */
59
59
  async put(path, file, {
60
- overwrite = false,
60
+ conflict = 'abort',
61
61
  permission = 0
62
62
  } = {}){
63
63
  if (path.startsWith('/')){ path = path.slice(1); }
64
64
  const fileBytes = await file.arrayBuffer();
65
65
  const dst = new URL(this.config.endpoint + '/' + path);
66
- dst.searchParams.append('overwrite', overwrite);
66
+ dst.searchParams.append('conflict', conflict);
67
67
  dst.searchParams.append('permission', permission);
68
68
  const res = await fetch(dst.toString(), {
69
69
  method: 'PUT',
@@ -191,7 +191,7 @@ export default class Connector {
191
191
  * @param {string} path - file path(url)
192
192
  * @param {string} newPath - new file path(url)
193
193
  */
194
- async moveFile(path, newPath){
194
+ async move(path, newPath){
195
195
  if (path.startsWith('/')){ path = path.slice(1); }
196
196
  if (newPath.startsWith('/')){ newPath = newPath.slice(1); }
197
197
  const dst = new URL(this.config.endpoint + '/_api/meta');
frontend/popup.js CHANGED
@@ -40,7 +40,7 @@ export function createFloatingWindow(innerHTML = '', {
40
40
  return [floatingWindow, closeWindow];
41
41
  }
42
42
 
43
- /* select can be "last-filename" */
43
+ /* select can be "last-filename" or "last-pathname" */
44
44
  export function showFloatingWindowLineInput(onSubmit = (v) => {}, {
45
45
  text = "",
46
46
  placeholder = "Enter text",
@@ -72,6 +72,7 @@ export function showFloatingWindowLineInput(onSubmit = (v) => {}, {
72
72
  };
73
73
 
74
74
  if (select === "last-filename") {
75
+ // select the last filename, e.g. "file" in "/path/to/file.txt"
75
76
  const inputVal = input.value;
76
77
  let lastSlash = inputVal.lastIndexOf("/");
77
78
  if (lastSlash === -1) {
@@ -84,6 +85,17 @@ export function showFloatingWindowLineInput(onSubmit = (v) => {}, {
84
85
  }
85
86
  input.setSelectionRange(lastSlash + 1, lastSlash + lastDot + 1);
86
87
  }
88
+ else if (select === "last-pathname") {
89
+ // select the last pathname, e.g. "to" in "/path/to/<filename>"
90
+ const lastSlash = input.value.lastIndexOf("/");
91
+ const secondLastSlash = input.value.lastIndexOf("/", input.value.lastIndexOf("/") - 1);
92
+ if (secondLastSlash !== -1) {
93
+ input.setSelectionRange(secondLastSlash + 1, lastSlash);
94
+ }
95
+ else {
96
+ input.setSelectionRange(0, lastSlash);
97
+ }
98
+ }
87
99
 
88
100
  return [floatingWindow, closeWindow];
89
101
  }
frontend/scripts.js CHANGED
@@ -5,6 +5,9 @@ import { formatSize, decodePathURI, ensurePathURI, copyToClipboard, getRandomStr
5
5
 
6
6
  const conn = new Connector();
7
7
  let userRecord = null;
8
+ const ensureSlashEnd = (path) => {
9
+ return path.endsWith('/') ? path : path + '/';
10
+ }
8
11
 
9
12
  const endpointInput = document.querySelector('input#endpoint');
10
13
  const tokenInput = document.querySelector('input#token');
@@ -128,11 +131,13 @@ uploadButton.addEventListener('click', () => {
128
131
  throw new Error('File name cannot end with /');
129
132
  }
130
133
  path = path + fileName;
134
+ showPopup('Uploading...', {level: 'info', timeout: 3000});
131
135
  conn.put(path, file)
132
136
  .then(() => {
133
137
  refreshFileList();
134
138
  uploadFileNameInput.value = '';
135
139
  onFileNameInpuChange();
140
+ showPopup('Upload success.', {level: 'success', timeout: 3000});
136
141
  },
137
142
  (err) => {
138
143
  showPopup('Failed to upload file: ' + err, {level: 'error', timeout: 5000});
@@ -176,7 +181,7 @@ Are you sure you want to proceed?
176
181
  async function uploadFile(...args){
177
182
  const [file, path] = args;
178
183
  try{
179
- await conn.put(path, file, {overwrite: true});
184
+ await conn.put(path, file, {conflict: 'overwrite'});
180
185
  }
181
186
  catch (err){
182
187
  showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
@@ -191,9 +196,14 @@ Are you sure you want to proceed?
191
196
  const path = dstPath + file.name;
192
197
  promises.push(uploadFile(file, path));
193
198
  }
199
+ showPopup('Uploading multiple files...', {level: 'info', timeout: 3000});
194
200
  Promise.all(promises).then(
195
201
  () => {
202
+ showPopup('Upload success.', {level: 'success', timeout: 3000});
196
203
  refreshFileList();
204
+ },
205
+ (err) => {
206
+ showPopup('Failed to upload some files: ' + err, {level: 'error', timeout: 5000});
197
207
  }
198
208
  );
199
209
  }
@@ -260,15 +270,16 @@ function refreshFileList(){
260
270
  tr.appendChild(accessTd);
261
271
  }
262
272
  {
273
+ const dirurl = ensureSlashEnd(dir.url);
263
274
  const actTd = document.createElement('td');
264
275
  const actContainer = document.createElement('div');
265
276
  actContainer.classList.add('action-container');
266
277
 
267
278
  const showMetaButton = document.createElement('a');
268
- showMetaButton.textContent = 'Details';
279
+ showMetaButton.textContent = 'Reveal';
269
280
  showMetaButton.style.cursor = 'pointer';
270
281
  showMetaButton.addEventListener('click', () => {
271
- const dirUrlEncap = dir.url + (dir.url.endsWith('/') ? '' : '/');
282
+ const dirUrlEncap = dirurl;
272
283
  conn.getMetadata(dirUrlEncap).then(
273
284
  (meta) => {
274
285
  sizeTd.textContent = formatSize(meta.size);
@@ -280,6 +291,30 @@ function refreshFileList(){
280
291
  });
281
292
  actContainer.appendChild(showMetaButton);
282
293
 
294
+ const moveButton = document.createElement('a');
295
+ moveButton.textContent = 'Move';
296
+ moveButton.style.cursor = 'pointer';
297
+ moveButton.addEventListener('click', () => {
298
+ showFloatingWindowLineInput((dstPath) => {
299
+ dstPath = encodePathURI(dstPath);
300
+ console.log("Moving", dirurl, "to", dstPath);
301
+ conn.move(dirurl, dstPath)
302
+ .then(() => {
303
+ refreshFileList();
304
+ },
305
+ (err) => {
306
+ showPopup('Failed to move path: ' + err, {level: 'error'});
307
+ }
308
+ );
309
+ }, {
310
+ text: 'Enter the destination path: ',
311
+ placeholder: 'Destination path',
312
+ value: decodePathURI(dirurl),
313
+ select: "last-pathname"
314
+ });
315
+ });
316
+ actContainer.appendChild(moveButton);
317
+
283
318
  const downloadButton = document.createElement('a');
284
319
  downloadButton.textContent = 'Download';
285
320
  downloadButton.href = conn.config.endpoint + '/_api/bundle?' +
@@ -402,10 +437,7 @@ function refreshFileList(){
402
437
  moveButton.addEventListener('click', () => {
403
438
  showFloatingWindowLineInput((dstPath) => {
404
439
  dstPath = encodePathURI(dstPath);
405
- if (dstPath.endsWith('/')){
406
- dstPath = dstPath.slice(0, -1);
407
- }
408
- conn.moveFile(file.url, dstPath)
440
+ conn.move(file.url, dstPath)
409
441
  .then(() => {
410
442
  refreshFileList();
411
443
  },
frontend/styles.css CHANGED
@@ -192,7 +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
+ justify-content: flex-end;
196
196
  }
197
197
  a{
198
198
  color: #195f8b;
lfss/cli/balance.py ADDED
@@ -0,0 +1,124 @@
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(batch_size: int = 10000):
72
+
73
+ tasks = []
74
+ start_time = time.time()
75
+
76
+ e_cout = 0
77
+ batch_count = 0
78
+ while True:
79
+ async with aiosqlite.connect(db_file) as conn:
80
+ exceeded_rows = list(await (await conn.execute(
81
+ "SELECT file_id FROM fmeta WHERE file_size > ? AND external = 0 LIMIT ? OFFSET ?",
82
+ (LARGE_FILE_BYTES, batch_size, batch_size * batch_count)
83
+ )).fetchall())
84
+ if not exceeded_rows:
85
+ break
86
+ e_cout += len(exceeded_rows)
87
+ for i in range(0, len(exceeded_rows)):
88
+ row = exceeded_rows[i]
89
+ f_id = row[0]
90
+ tasks.append(move_to_external(f_id, flag=f"[b{batch_count+1}-e{i+1}/{len(exceeded_rows)}] "))
91
+ await asyncio.gather(*tasks)
92
+
93
+ i_count = 0
94
+ batch_count = 0
95
+ while True:
96
+ async with aiosqlite.connect(db_file) as conn:
97
+ under_rows = list(await (await conn.execute(
98
+ "SELECT file_id, file_size, external FROM fmeta WHERE file_size <= ? AND external = 1 LIMIT ? OFFSET ?",
99
+ (LARGE_FILE_BYTES, batch_size, batch_size * batch_count)
100
+ )).fetchall())
101
+ if not under_rows:
102
+ break
103
+ i_count += len(under_rows)
104
+ for i in range(0, len(under_rows)):
105
+ row = under_rows[i]
106
+ f_id = row[0]
107
+ tasks.append(move_to_internal(f_id, flag=f"[b{batch_count+1}-i{i+1}/{len(under_rows)}] "))
108
+ await asyncio.gather(*tasks)
109
+
110
+ end_time = time.time()
111
+ print(f"Balancing complete, took {end_time - start_time:.2f} seconds. "
112
+ f"{e_cout} files moved to external storage, {i_count} files moved to internal storage.")
113
+
114
+ def main():
115
+ global sem
116
+ parser = argparse.ArgumentParser(description="Balance the storage by ensuring that large file thresholds are met.")
117
+ parser.add_argument("-j", "--jobs", type=int, default=2, help="Number of concurrent jobs")
118
+ parser.add_argument("-b", "--batch-size", type=int, default=10000, help="Batch size for processing files")
119
+ args = parser.parse_args()
120
+ sem = Semaphore(args.jobs)
121
+ asyncio.run(_main(args.batch_size))
122
+
123
+ if __name__ == '__main__':
124
+ main()
lfss/cli/cli.py CHANGED
@@ -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
 
@@ -32,7 +32,7 @@ def main():
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
38
  if failed_upload:
@@ -44,7 +44,7 @@ def main():
44
44
  connector.put(
45
45
  args.dst,
46
46
  f.read(),
47
- overwrite=args.overwrite,
47
+ conflict=args.conflict,
48
48
  permission=args.permission
49
49
  )
50
50
  else:
lfss/client/api.py CHANGED
@@ -34,33 +34,52 @@ 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
- if path.startswith('/'):
40
- path = path[1:]
41
39
  response = self._fetch('PUT', path, search_params={
42
40
  'permission': int(permission),
43
- 'overwrite': overwrite
41
+ 'conflict': conflict
44
42
  })(
45
43
  data=file_data,
46
44
  headers={'Content-Type': 'application/octet-stream'}
47
45
  )
48
46
  return response.json()
49
47
 
50
- def get(self, path: str) -> Optional[bytes]:
51
- """Downloads a file from the specified path."""
48
+ def put_json(self, path: str, data: dict, permission: int | FileReadPermission = 0, conflict: Literal['overwrite', 'abort', 'skip'] = 'abort'):
49
+ """Uploads a JSON file to the specified path."""
50
+ assert path.endswith('.json'), "Path must end with .json"
51
+ response = self._fetch('PUT', path, search_params={
52
+ 'permission': int(permission),
53
+ 'conflict': conflict
54
+ })(
55
+ json=data,
56
+ headers={'Content-Type': 'application/json'}
57
+ )
58
+ return response.json()
59
+
60
+ def _get(self, path: str) -> Optional[requests.Response]:
52
61
  try:
53
62
  response = self._fetch('GET', path)()
54
63
  except requests.exceptions.HTTPError as e:
55
64
  if e.response.status_code == 404:
56
65
  return None
57
66
  raise e
67
+ return response
68
+
69
+ def get(self, path: str) -> Optional[bytes]:
70
+ """Downloads a file from the specified path."""
71
+ response = self._get(path)
72
+ if response is None: return None
58
73
  return response.content
74
+
75
+ def get_json(self, path: str) -> Optional[dict]:
76
+ response = self._get(path)
77
+ if response is None: return None
78
+ assert response.headers['Content-Type'] == 'application/json'
79
+ return response.json()
59
80
 
60
81
  def delete(self, path: str):
61
82
  """Deletes the file at the specified path."""
62
- if path.startswith('/'):
63
- path = path[1:]
64
83
  self._fetch('DELETE', path)()
65
84
 
66
85
  def get_metadata(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
@@ -87,8 +106,8 @@ class Connector:
87
106
  headers={'Content-Type': 'application/www-form-urlencoded'}
88
107
  )
89
108
 
90
- def move_file(self, path: str, new_path: str):
91
- """Moves a file to a new location."""
109
+ def move(self, path: str, new_path: str):
110
+ """Move file or directory to a new path."""
92
111
  self._fetch('POST', '_api/meta', {'path': path, 'new_path': new_path})(
93
112
  headers = {'Content-Type': 'application/www-form-urlencoded'}
94
113
  )
lfss/sql/init.sql ADDED
@@ -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);
lfss/sql/pragma.sql ADDED
@@ -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;
lfss/src/config.py CHANGED
@@ -10,6 +10,7 @@ if not DATA_HOME.exists():
10
10
  LARGE_BLOB_DIR = DATA_HOME / 'large_blobs'
11
11
  LARGE_BLOB_DIR.mkdir(exist_ok=True)
12
12
 
13
- LARGE_FILE_BYTES = 64 * 1024 * 1024 # 64MB
13
+ # https://sqlite.org/fasterthanfs.html
14
+ LARGE_FILE_BYTES = 8 * 1024 * 1024 # 8MB
14
15
  MAX_FILE_BYTES = 1024 * 1024 * 1024 # 1GB
15
16
  MAX_BUNDLE_BYTES = 1024 * 1024 * 1024 # 1GB
lfss/src/database.py CHANGED
@@ -3,6 +3,7 @@ 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
@@ -23,6 +24,15 @@ _g_conn: Optional[aiosqlite.Connection] = None
23
24
  def hash_credential(username, password):
24
25
  return hashlib.sha256((username + password).encode()).hexdigest()
25
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
+
26
36
  _atomic_lock = Lock()
27
37
  def atomic(func):
28
38
  """ Ensure non-reentrancy """
@@ -48,7 +58,8 @@ class DBConnBase(ABC):
48
58
  global _g_conn
49
59
  if _g_conn is None:
50
60
  _g_conn = await aiosqlite.connect(DATA_HOME / 'lfss.db')
51
- await _g_conn.execute('PRAGMA journal_mode=memory')
61
+ await execute_sql(_g_conn, 'pragma.sql')
62
+ await execute_sql(_g_conn, 'init.sql')
52
63
 
53
64
  async def commit(self):
54
65
  await self.conn.commit()
@@ -82,26 +93,6 @@ class UserConn(DBConnBase):
82
93
 
83
94
  async def init(self):
84
95
  await super().init()
85
- # default to 1GB (1024x1024x1024 bytes)
86
- await self.conn.execute('''
87
- CREATE TABLE IF NOT EXISTS user (
88
- id INTEGER PRIMARY KEY AUTOINCREMENT,
89
- username VARCHAR(255) UNIQUE NOT NULL,
90
- credential VARCHAR(255) NOT NULL,
91
- is_admin BOOLEAN DEFAULT FALSE,
92
- create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
93
- last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
94
- max_storage INTEGER DEFAULT 1073741824,
95
- permission INTEGER DEFAULT 0
96
- )
97
- ''')
98
- await self.conn.execute('''
99
- CREATE INDEX IF NOT EXISTS idx_user_username ON user(username)
100
- ''')
101
- await self.conn.execute('''
102
- CREATE INDEX IF NOT EXISTS idx_user_credential ON user(credential)
103
- ''')
104
-
105
96
  return self
106
97
 
107
98
  async def get_user(self, username: str) -> Optional[UserRecord]:
@@ -222,37 +213,6 @@ class FileConn(DBConnBase):
222
213
 
223
214
  async def init(self):
224
215
  await super().init()
225
- await self.conn.execute('''
226
- CREATE TABLE IF NOT EXISTS fmeta (
227
- url VARCHAR(512) PRIMARY KEY,
228
- owner_id INTEGER NOT NULL,
229
- file_id VARCHAR(256) NOT NULL,
230
- file_size INTEGER,
231
- create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
232
- access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
233
- permission INTEGER DEFAULT 0,
234
- external BOOLEAN DEFAULT FALSE,
235
- FOREIGN KEY(owner_id) REFERENCES user(id)
236
- )
237
- ''')
238
- await self.conn.execute('''
239
- CREATE INDEX IF NOT EXISTS idx_fmeta_url ON fmeta(url)
240
- ''')
241
-
242
- await self.conn.execute('''
243
- CREATE TABLE IF NOT EXISTS fdata (
244
- file_id VARCHAR(256) PRIMARY KEY,
245
- data BLOB
246
- )
247
- ''')
248
-
249
- # user file size table
250
- await self.conn.execute('''
251
- CREATE TABLE IF NOT EXISTS usize (
252
- user_id INTEGER PRIMARY KEY,
253
- size INTEGER DEFAULT 0
254
- )
255
- ''')
256
216
  # backward compatibility, since 0.2.1
257
217
  async with self.conn.execute("SELECT * FROM user") as cursor:
258
218
  res = await cursor.fetchall()
@@ -371,7 +331,7 @@ class FileConn(DBConnBase):
371
331
  dirs = await asyncio.gather(*[get_dir(url + d) for d in dirs_str])
372
332
  return PathContents(dirs, files)
373
333
 
374
- async def get_path_record(self, url: str) -> Optional[DirectoryRecord]:
334
+ async def get_path_record(self, url: str) -> DirectoryRecord:
375
335
  assert url.endswith('/'), "Path must end with /"
376
336
  async with self.conn.execute("""
377
337
  SELECT MIN(create_time) as create_time,
@@ -461,6 +421,25 @@ class FileConn(DBConnBase):
461
421
  async with self.conn.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_url, old_url)):
462
422
  self.logger.info(f"Moved file {old_url} to {new_url}")
463
423
 
424
+ @atomic
425
+ async def move_path(self, old_url: str, new_url: str, conflict_handler: Literal['skip', 'overwrite'] = 'overwrite', user_id: Optional[int] = None):
426
+ assert old_url.endswith('/'), "Old path must end with /"
427
+ assert new_url.endswith('/'), "New path must end with /"
428
+ if user_id is None:
429
+ async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ?", (old_url + '%', )) as cursor:
430
+ res = await cursor.fetchall()
431
+ else:
432
+ async with self.conn.execute("SELECT * FROM fmeta WHERE url LIKE ? AND owner_id = ?", (old_url + '%', user_id)) as cursor:
433
+ res = await cursor.fetchall()
434
+ for r in res:
435
+ new_r = new_url + r[0][len(old_url):]
436
+ if conflict_handler == 'overwrite':
437
+ await self.conn.execute("DELETE FROM fmeta WHERE url = ?", (new_r, ))
438
+ elif conflict_handler == 'skip':
439
+ if (await self.conn.execute("SELECT url FROM fmeta WHERE url = ?", (new_r, ))) is not None:
440
+ continue
441
+ await self.conn.execute("UPDATE fmeta SET url = ?, create_time = CURRENT_TIMESTAMP WHERE url = ?", (new_r, r[0]))
442
+
464
443
  async def log_access(self, url: str):
465
444
  await self.conn.execute("UPDATE fmeta SET access_time = CURRENT_TIMESTAMP WHERE url = ?", (url, ))
466
445
 
@@ -704,6 +683,13 @@ class Database:
704
683
 
705
684
  async with transaction(self):
706
685
  await self.file.move_file(old_url, new_url)
686
+
687
+ async def move_path(self, old_url: str, new_url: str, user_id: Optional[int] = None):
688
+ validate_url(old_url, is_file=False)
689
+ validate_url(new_url, is_file=False)
690
+
691
+ async with transaction(self):
692
+ await self.file.move_path(old_url, new_url, 'overwrite', user_id)
707
693
 
708
694
  async def __batch_delete_file_blobs(self, file_records: list[FileRecord], batch_size: int = 512):
709
695
  # https://github.com/langchain-ai/langchain/issues/10321
lfss/src/server.py CHANGED
@@ -1,4 +1,4 @@
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
@@ -179,7 +179,7 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
179
179
  async def put_file(
180
180
  request: Request,
181
181
  path: str,
182
- overwrite: Optional[bool] = False,
182
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
183
183
  permission: int = 0,
184
184
  user: UserRecord = Depends(get_current_user)):
185
185
  path = ensure_uri_compnents(path)
@@ -201,8 +201,12 @@ async def put_file(
201
201
  exists_flag = False
202
202
  file_record = await conn.file.get_file_record(path)
203
203
  if file_record:
204
- if not overwrite:
204
+ if conflict == "abort":
205
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}))
206
210
  # remove the old file
207
211
  exists_flag = True
208
212
  await conn.delete_file(path)
@@ -226,8 +230,9 @@ async def put_file(
226
230
  blobs = await request.body()
227
231
  if len(blobs) > LARGE_FILE_BYTES:
228
232
  async def blob_reader():
229
- for b in range(0, len(blobs), 4096):
230
- yield blobs[b:b+4096]
233
+ chunk_size = 16 * 1024 * 1024 # 16MB
234
+ for b in range(0, len(blobs), chunk_size):
235
+ yield blobs[b:b+chunk_size]
231
236
  await conn.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
232
237
  else:
233
238
  await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission))
@@ -258,6 +263,7 @@ async def delete_file(path: str, user: UserRecord = Depends(get_current_user)):
258
263
  else:
259
264
  res = await conn.delete_file(path)
260
265
 
266
+ await conn.user.set_active(user.username)
261
267
  if res:
262
268
  return Response(status_code=200, content="Deleted")
263
269
  else:
@@ -330,26 +336,57 @@ async def update_file_meta(
330
336
  if user.id == 0:
331
337
  raise HTTPException(status_code=401, detail="Permission denied")
332
338
  path = ensure_uri_compnents(path)
333
- file_record = await conn.file.get_file_record(path)
334
- if not file_record:
335
- logger.debug(f"Reject update meta request from {user.username} to {path}")
336
- raise HTTPException(status_code=404, detail="File not found")
337
-
338
- if not (user.is_admin or user.id == file_record.owner_id):
339
- logger.debug(f"Reject update meta request from {user.username} to {path}")
340
- raise HTTPException(status_code=403, detail="Permission denied")
339
+ if path.startswith("/"):
340
+ path = path[1:]
341
+ await conn.user.set_active(user.username)
342
+
343
+ # file
344
+ if not path.endswith("/"):
345
+ file_record = await conn.file.get_file_record(path)
346
+ if not file_record:
347
+ logger.debug(f"Reject update meta request from {user.username} to {path}")
348
+ raise HTTPException(status_code=404, detail="File not found")
349
+
350
+ if not (user.is_admin or user.id == file_record.owner_id):
351
+ logger.debug(f"Reject update meta request from {user.username} to {path}")
352
+ raise HTTPException(status_code=403, detail="Permission denied")
353
+
354
+ if perm is not None:
355
+ logger.info(f"Update permission of {path} to {perm}")
356
+ await conn.file.set_file_record(
357
+ url = file_record.url,
358
+ permission = FileReadPermission(perm)
359
+ )
341
360
 
342
- if perm is not None:
343
- logger.info(f"Update permission of {path} to {perm}")
344
- await conn.file.set_file_record(
345
- url = file_record.url,
346
- permission = FileReadPermission(perm)
347
- )
361
+ if new_path is not None:
362
+ new_path = ensure_uri_compnents(new_path)
363
+ logger.info(f"Update path of {path} to {new_path}")
364
+ await conn.move_file(path, new_path)
348
365
 
349
- if new_path is not None:
350
- new_path = ensure_uri_compnents(new_path)
351
- logger.info(f"Update path of {path} to {new_path}")
352
- await conn.move_file(path, new_path)
366
+ # directory
367
+ else:
368
+ assert perm is None, "Permission is not supported for directory"
369
+ if new_path is not None:
370
+ new_path = ensure_uri_compnents(new_path)
371
+ logger.info(f"Update path of {path} to {new_path}")
372
+ assert new_path.endswith("/"), "New path must end with /"
373
+ if new_path.startswith("/"):
374
+ new_path = new_path[1:]
375
+
376
+ # check if new path is under the user's directory
377
+ first_component = new_path.split("/")[0]
378
+ if not (first_component == user.username or user.is_admin):
379
+ raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
380
+ elif user.is_admin:
381
+ _is_user = await conn.user.get_user(first_component)
382
+ if not _is_user:
383
+ raise HTTPException(status_code=404, detail="User not found, path must start with username")
384
+
385
+ # check if old path is under the user's directory (non-admin)
386
+ if not path.startswith(f"{user.username}/") and not user.is_admin:
387
+ raise HTTPException(status_code=403, detail="Permission denied, path must start with username")
388
+ # currently only move own file, with overwrite
389
+ await conn.move_path(path, new_path, user_id = user.id)
353
390
 
354
391
  return Response(status_code=200, content="OK")
355
392
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -0,0 +1,31 @@
1
+ Readme.md,sha256=8ZAADV1K20gEDhwiL-Qq_rBAePlwWQn8c5uJ_X7OCIg,1128
2
+ docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
+ docs/Permission.md,sha256=EY1Y4tT5PDdHDb2pXsVgMAJXxkUihTZfPT0_z7Q53FA,1485
4
+ frontend/api.js,sha256=-ouhsmucEunAK3m1H__MqffQkXAjoeVEfM15BvqfIZs,7677
5
+ frontend/index.html,sha256=VPJDs2LG8ep9kjlsKzjWzpN9vc1VGgdvOUlNTZWyQoQ,2088
6
+ frontend/popup.css,sha256=VzkjG1ZTLxhHMtTyobnlvqYmVsTmdbJJed2Pu1cc06c,1007
7
+ frontend/popup.js,sha256=3PgaGZmxSdV1E-D_MWgcR7aHWkcsHA1BNKSOkmP66tA,5191
8
+ frontend/scripts.js,sha256=JkjcyT-IpzSypwI4oWwgY9UDdKkR1ZSYdSc4c6MlukE,21128
9
+ frontend/styles.css,sha256=wly8O-zF4EUgV12Tv1bATSfmJsLITv2u3_SiyXVaxv4,4096
10
+ frontend/utils.js,sha256=Ts4nlef8pkrEgpwX-uQwAhWvwxlIzex8ijDLNCa22ps,2372
11
+ lfss/cli/balance.py,sha256=5kyjfknN7oNiyEs2DcDYzTMKzoOTDm0caF2nZyxJcHw,4707
12
+ lfss/cli/cli.py,sha256=-BcLIH-6jCJxZfg48nA3gulnitO59Hnba3HoKqyKeUA,2245
13
+ lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
14
+ lfss/cli/serve.py,sha256=bO3GT0kuylMGN-7bZWP4e71MlugGZ_lEMkYaYld_Ntg,985
15
+ lfss/cli/user.py,sha256=uSNgF8wGYpdOowN8Mah3V_ii6NlxRMNecrrVj3HaemU,3328
16
+ lfss/client/__init__.py,sha256=YttaGTBup7mxnW0CtxdZPF0HWga2cGM4twz9MXJIrts,1827
17
+ lfss/client/api.py,sha256=mGSFZN0i-kdo8nEdL4pfzIKEX1Em09o9cDNMN-x4ECA,4682
18
+ lfss/sql/init.sql,sha256=DlXb47mZ1I7nvVht55UbBmCr1yJK50sBJvMC7eOzCjk,1148
19
+ lfss/sql/pragma.sql,sha256=krTf0ALmU_4s2hGM4PSYwmSCqjRXcaOxChmgMEhtJqI,134
20
+ lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ lfss/src/config.py,sha256=SM6WVmpkf7e-YkV3RELTJltOnQGRtXkVtA1rVHwsg0g,484
22
+ lfss/src/database.py,sha256=WKl9ZfV54g9ff7vmFivk3FZ2uVHlhPBEx_hFY1jtwzQ,33503
23
+ lfss/src/error.py,sha256=imbhwnbhnI3HLhkbfICROe3F0gleKrOk4XnqHJDOtuI,285
24
+ lfss/src/log.py,sha256=qNE04sHoZ2rMbQ5dR2zT7Xaz1KVAfYp5hKpWJX42S9g,5244
25
+ lfss/src/server.py,sha256=oBN32zoUGEfy3eA_XBir7_iviPELZJVHfKnIvDqJgEc,16246
26
+ lfss/src/stat.py,sha256=hTMtQyM_Ukmhc33Bb9FGCfBMIX02KrGHQg8nL7sC8sU,2082
27
+ lfss/src/utils.py,sha256=8VkrtpSmurbMiX7GyK-n7Grvzy3uwSJXHdONEsuLCDI,2272
28
+ lfss-0.5.2.dist-info/METADATA,sha256=FwF3kDcWgvuU378v1LO9lZowFbHUlezY_fTMROkiTQs,1820
29
+ lfss-0.5.2.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
30
+ lfss-0.5.2.dist-info/entry_points.txt,sha256=d_Ri3GXxUW-S0E6q953A8od0YMmUAnZGlJSKS46OiW8,172
31
+ lfss-0.5.2.dist-info/RECORD,,
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ lfss-balance=lfss.cli.balance:main
2
3
  lfss-cli=lfss.cli.cli:main
3
4
  lfss-panel=lfss.cli.panel:main
4
5
  lfss-serve=lfss.cli.serve:main
@@ -1,28 +0,0 @@
1
- Readme.md,sha256=8ZAADV1K20gEDhwiL-Qq_rBAePlwWQn8c5uJ_X7OCIg,1128
2
- docs/Known_issues.md,sha256=rfdG3j1OJF-59S9E06VPyn0nZKbW-ybPxkoZ7MEZWp8,81
3
- docs/Permission.md,sha256=EY1Y4tT5PDdHDb2pXsVgMAJXxkUihTZfPT0_z7Q53FA,1485
4
- frontend/api.js,sha256=aIeeaHlkxKUAHLI-BxYxqYqyfJIRxmeFPdXLYyMSdbs,7682
5
- frontend/index.html,sha256=VPJDs2LG8ep9kjlsKzjWzpN9vc1VGgdvOUlNTZWyQoQ,2088
6
- frontend/popup.css,sha256=VzkjG1ZTLxhHMtTyobnlvqYmVsTmdbJJed2Pu1cc06c,1007
7
- frontend/popup.js,sha256=dH5n7C2Vo9gCsMfQ4ajL4D1ETl0Wk9rIldxUb7x0f_c,4634
8
- frontend/scripts.js,sha256=znnV43tRupV7-GuZUlYIkNpi49J9E0DzYdlodSlTO9s,19486
9
- frontend/styles.css,sha256=kN2XmEeRXLMyZQowSXYPhD4_1XVXgKwTKdTdtAKuX0E,4102
10
- frontend/utils.js,sha256=Ts4nlef8pkrEgpwX-uQwAhWvwxlIzex8ijDLNCa22ps,2372
11
- lfss/cli/cli.py,sha256=2sL0RcJWjnMUjYwSHxO5m_oJP-61WUFJZC9eH3m-Jq8,2223
12
- lfss/cli/panel.py,sha256=iGdVmdWYjA_7a78ZzWEB_3ggIOBeUKTzg6F5zLaB25c,1401
13
- lfss/cli/serve.py,sha256=bO3GT0kuylMGN-7bZWP4e71MlugGZ_lEMkYaYld_Ntg,985
14
- lfss/cli/user.py,sha256=uSNgF8wGYpdOowN8Mah3V_ii6NlxRMNecrrVj3HaemU,3328
15
- lfss/client/__init__.py,sha256=YttaGTBup7mxnW0CtxdZPF0HWga2cGM4twz9MXJIrts,1827
16
- lfss/client/api.py,sha256=L2LrnUO7Ef4mUlvZ40_GLu0pqf9wlFgieHgaQXP0Dk0,3827
17
- lfss/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
- lfss/src/config.py,sha256=i96PgONQnsmQ34hcqRXUcmo_Ullq2aRQvHbD54pIguM,447
19
- lfss/src/database.py,sha256=XproulWille_i_aRr3IFE0isRfL98WgY6y6MRcOlS1o,33473
20
- lfss/src/error.py,sha256=imbhwnbhnI3HLhkbfICROe3F0gleKrOk4XnqHJDOtuI,285
21
- lfss/src/log.py,sha256=qNE04sHoZ2rMbQ5dR2zT7Xaz1KVAfYp5hKpWJX42S9g,5244
22
- lfss/src/server.py,sha256=Zl5O3KY16ASB674xaY3YmqnyQ_8d4MLjqZwtcz4Phgs,14345
23
- lfss/src/stat.py,sha256=hTMtQyM_Ukmhc33Bb9FGCfBMIX02KrGHQg8nL7sC8sU,2082
24
- lfss/src/utils.py,sha256=8VkrtpSmurbMiX7GyK-n7Grvzy3uwSJXHdONEsuLCDI,2272
25
- lfss-0.5.0.dist-info/METADATA,sha256=0z_QH6HMzuhFHQwYUapOTACU1tqqrEL_D86ekyYJABc,1820
26
- lfss-0.5.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
27
- lfss-0.5.0.dist-info/entry_points.txt,sha256=_FXOyxodFtVBaxtBhdHcupqW_IolIIx-S6y6p7CkDFk,137
28
- lfss-0.5.0.dist-info/RECORD,,
File without changes