lfss 0.5.2__tar.gz → 0.6.0__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.5.2 → lfss-0.6.0}/PKG-INFO +1 -1
  2. {lfss-0.5.2 → lfss-0.6.0}/lfss/client/api.py +1 -1
  3. {lfss-0.5.2 → lfss-0.6.0}/lfss/sql/init.sql +7 -6
  4. {lfss-0.5.2 → lfss-0.6.0}/lfss/src/database.py +70 -89
  5. lfss-0.6.0/lfss/src/datatype.py +55 -0
  6. {lfss-0.5.2 → lfss-0.6.0}/lfss/src/server.py +16 -13
  7. {lfss-0.5.2 → lfss-0.6.0}/pyproject.toml +1 -1
  8. {lfss-0.5.2 → lfss-0.6.0}/Readme.md +0 -0
  9. {lfss-0.5.2 → lfss-0.6.0}/docs/Known_issues.md +0 -0
  10. {lfss-0.5.2 → lfss-0.6.0}/docs/Permission.md +0 -0
  11. {lfss-0.5.2 → lfss-0.6.0}/frontend/api.js +0 -0
  12. {lfss-0.5.2 → lfss-0.6.0}/frontend/index.html +0 -0
  13. {lfss-0.5.2 → lfss-0.6.0}/frontend/popup.css +0 -0
  14. {lfss-0.5.2 → lfss-0.6.0}/frontend/popup.js +0 -0
  15. {lfss-0.5.2 → lfss-0.6.0}/frontend/scripts.js +0 -0
  16. {lfss-0.5.2 → lfss-0.6.0}/frontend/styles.css +0 -0
  17. {lfss-0.5.2 → lfss-0.6.0}/frontend/utils.js +0 -0
  18. {lfss-0.5.2 → lfss-0.6.0}/lfss/cli/balance.py +0 -0
  19. {lfss-0.5.2 → lfss-0.6.0}/lfss/cli/cli.py +0 -0
  20. {lfss-0.5.2 → lfss-0.6.0}/lfss/cli/panel.py +0 -0
  21. {lfss-0.5.2 → lfss-0.6.0}/lfss/cli/serve.py +0 -0
  22. {lfss-0.5.2 → lfss-0.6.0}/lfss/cli/user.py +0 -0
  23. {lfss-0.5.2 → lfss-0.6.0}/lfss/client/__init__.py +0 -0
  24. {lfss-0.5.2 → lfss-0.6.0}/lfss/sql/pragma.sql +0 -0
  25. {lfss-0.5.2 → lfss-0.6.0}/lfss/src/__init__.py +0 -0
  26. {lfss-0.5.2 → lfss-0.6.0}/lfss/src/config.py +0 -0
  27. {lfss-0.5.2 → lfss-0.6.0}/lfss/src/error.py +0 -0
  28. {lfss-0.5.2 → lfss-0.6.0}/lfss/src/log.py +0 -0
  29. {lfss-0.5.2 → lfss-0.6.0}/lfss/src/stat.py +0 -0
  30. {lfss-0.5.2 → lfss-0.6.0}/lfss/src/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lfss
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: Lightweight file storage service
5
5
  Home-page: https://github.com/MenxLi/lfss
6
6
  Author: li, mengxun
@@ -2,7 +2,7 @@ from typing import Optional, Literal
2
2
  import os
3
3
  import requests
4
4
  import urllib.parse
5
- from lfss.src.database import (
5
+ from lfss.src.datatype import (
6
6
  FileReadPermission, FileRecord, DirectoryRecord, UserRecord, PathContents
7
7
  )
8
8
 
@@ -1,7 +1,7 @@
1
1
  CREATE TABLE IF NOT EXISTS user (
2
2
  id INTEGER PRIMARY KEY AUTOINCREMENT,
3
- username VARCHAR(255) UNIQUE NOT NULL,
4
- credential VARCHAR(255) NOT NULL,
3
+ username VARCHAR(256) UNIQUE NOT NULL,
4
+ credential VARCHAR(256) NOT NULL,
5
5
  is_admin BOOLEAN DEFAULT FALSE,
6
6
  create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
7
7
  last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -10,19 +10,20 @@ CREATE TABLE IF NOT EXISTS user (
10
10
  );
11
11
 
12
12
  CREATE TABLE IF NOT EXISTS fmeta (
13
- url VARCHAR(512) PRIMARY KEY,
13
+ url VARCHAR(1024) PRIMARY KEY,
14
14
  owner_id INTEGER NOT NULL,
15
- file_id VARCHAR(256) NOT NULL,
15
+ file_id CHAR(32) NOT NULL,
16
16
  file_size INTEGER,
17
17
  create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
18
18
  access_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
19
19
  permission INTEGER DEFAULT 0,
20
- external BOOLEAN DEFAULT FALSE,
20
+ external BOOLEAN DEFAULT FALSE,
21
+ mime_type VARCHAR(256) DEFAULT 'application/octet-stream',
21
22
  FOREIGN KEY(owner_id) REFERENCES user(id)
22
23
  );
23
24
 
24
25
  CREATE TABLE IF NOT EXISTS fdata (
25
- file_id VARCHAR(256) PRIMARY KEY,
26
+ file_id CHAR(32) PRIMARY KEY,
26
27
  data BLOB
27
28
  );
28
29
 
@@ -4,16 +4,16 @@ from abc import ABC, abstractmethod
4
4
 
5
5
  import urllib.parse
6
6
  from pathlib import Path
7
- import dataclasses, hashlib, uuid
7
+ import hashlib, uuid
8
8
  from contextlib import asynccontextmanager
9
9
  from functools import wraps
10
- from enum import IntEnum
11
10
  import zipfile, io, asyncio
12
11
 
13
12
  import aiosqlite, aiofiles
14
13
  import aiofiles.os
15
14
  from asyncio import Lock
16
15
 
16
+ from .datatype import UserRecord, FileReadPermission, FileRecord, DirectoryRecord, PathContents
17
17
  from .config import DATA_HOME, LARGE_BLOB_DIR
18
18
  from .log import get_logger
19
19
  from .utils import decode_uri_compnents
@@ -64,26 +64,6 @@ class DBConnBase(ABC):
64
64
  async def commit(self):
65
65
  await self.conn.commit()
66
66
 
67
- class FileReadPermission(IntEnum):
68
- UNSET = 0 # not set
69
- PUBLIC = 1 # accessible by anyone
70
- PROTECTED = 2 # accessible by any user
71
- PRIVATE = 3 # accessible by owner only (including admin)
72
-
73
- @dataclasses.dataclass
74
- class UserRecord:
75
- id: int
76
- username: str
77
- credential: str
78
- is_admin: bool
79
- create_time: str
80
- last_active: str
81
- max_storage: int
82
- permission: 'FileReadPermission'
83
-
84
- def __str__(self):
85
- return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}, storage={self.max_storage}, permission={self.permission})"
86
-
87
67
  DECOY_USER = UserRecord(0, 'decoy', 'decoy', False, '2021-01-01 00:00:00', '2021-01-01 00:00:00', 0, FileReadPermission.PRIVATE)
88
68
  class UserConn(DBConnBase):
89
69
 
@@ -174,37 +154,6 @@ class UserConn(DBConnBase):
174
154
  await self.conn.execute("DELETE FROM user WHERE username = ?", (username, ))
175
155
  self.logger.info(f"Delete user {username}")
176
156
 
177
- @dataclasses.dataclass
178
- class FileRecord:
179
- url: str
180
- owner_id: int
181
- file_id: str # defines mapping from fmata to fdata
182
- file_size: int
183
- create_time: str
184
- access_time: str
185
- permission: FileReadPermission
186
- external: bool
187
-
188
- def __str__(self):
189
- return f"File {self.url} (owner={self.owner_id}, created at {self.create_time}, accessed at {self.access_time}, " + \
190
- f"file_id={self.file_id}, permission={self.permission}, size={self.file_size}, external={self.external})"
191
-
192
- @dataclasses.dataclass
193
- class DirectoryRecord:
194
- url: str
195
- size: int
196
- create_time: str = ""
197
- update_time: str = ""
198
- access_time: str = ""
199
-
200
- def __str__(self):
201
- return f"Directory {self.url} (size={self.size})"
202
-
203
- @dataclasses.dataclass
204
- class PathContents:
205
- dirs: list[DirectoryRecord]
206
- files: list[FileRecord]
207
-
208
157
  class FileConn(DBConnBase):
209
158
 
210
159
  @staticmethod
@@ -235,6 +184,38 @@ class FileConn(DBConnBase):
235
184
  ALTER TABLE fmeta ADD COLUMN external BOOLEAN DEFAULT FALSE
236
185
  ''')
237
186
 
187
+ # backward compatibility, since 0.6.0
188
+ async with self.conn.execute("SELECT * FROM fmeta") as cursor:
189
+ res = await cursor.fetchone()
190
+ if res and len(res) < 9:
191
+ self.logger.info("Updating fmeta table")
192
+ await self.conn.execute('''
193
+ ALTER TABLE fmeta ADD COLUMN mime_type TEXT DEFAULT 'application/octet-stream'
194
+ ''')
195
+ # check all mime types
196
+ import mimetypes, mimesniff
197
+ async with self.conn.execute("SELECT url, file_id, external FROM fmeta") as cursor:
198
+ res = await cursor.fetchall()
199
+ async with self.conn.execute("SELECT count(*) FROM fmeta") as cursor:
200
+ count = await cursor.fetchone()
201
+ assert count is not None
202
+ for counter, r in enumerate(res, start=1):
203
+ print(f"Checking mimetype for {counter}/{count[0]}")
204
+ url, f_id, external = r
205
+ fname = url.split('/')[-1]
206
+ mime_type, _ = mimetypes.guess_type(fname)
207
+ if mime_type is None:
208
+ # try to sniff the file
209
+ if not external:
210
+ async with self.conn.execute("SELECT data FROM fdata WHERE file_id = ?", (f_id, )) as cursor:
211
+ blob = await cursor.fetchone()
212
+ assert blob is not None
213
+ blob = blob[0]
214
+ mime_type = mimesniff.what(blob)
215
+ else:
216
+ mime_type = mimesniff.what(LARGE_BLOB_DIR / f_id)
217
+ await self.conn.execute("UPDATE fmeta SET mime_type = ? WHERE url = ?", (mime_type, url))
218
+
238
219
  return self
239
220
 
240
221
  async def get_file_record(self, url: str) -> Optional[FileRecord]:
@@ -372,43 +353,42 @@ class FileConn(DBConnBase):
372
353
  assert res is not None
373
354
  return res[0] or 0
374
355
 
356
+ @atomic
357
+ async def update_file_record(
358
+ self, url, owner_id: Optional[int] = None, permission: Optional[FileReadPermission] = None
359
+ ):
360
+ old = await self.get_file_record(url)
361
+ assert old is not None, f"File {url} not found"
362
+ if owner_id is None:
363
+ owner_id = old.owner_id
364
+ if permission is None:
365
+ permission = old.permission
366
+ await self.conn.execute(
367
+ "UPDATE fmeta SET owner_id = ?, permission = ? WHERE url = ?",
368
+ (owner_id, int(permission), url)
369
+ )
370
+ self.logger.info(f"Updated file {url}")
371
+
375
372
  @atomic
376
373
  async def set_file_record(
377
374
  self, url: str,
378
- owner_id: Optional[int] = None,
379
- file_id: Optional[str] = None,
380
- file_size: Optional[int] = None,
381
- permission: Optional[ FileReadPermission ] = None,
382
- external: Optional[bool] = None
375
+ owner_id: int,
376
+ file_id:str,
377
+ file_size: int,
378
+ permission: FileReadPermission,
379
+ external: bool,
380
+ mime_type: str
383
381
  ):
384
-
385
- old = await self.get_file_record(url)
386
- if old is not None:
387
- self.logger.debug(f"Updating fmeta {url}: permission={permission}, owner_id={owner_id}")
388
- # should delete the old blob if file_id is changed
389
- assert file_id is None, "Cannot update file id"
390
- assert file_size is None, "Cannot update file size"
391
- assert external is None, "Cannot update external"
392
-
393
- if owner_id is None: owner_id = old.owner_id
394
- if permission is None: permission = old.permission
395
- await self.conn.execute(
396
- """
397
- UPDATE fmeta SET owner_id = ?, permission = ?,
398
- access_time = CURRENT_TIMESTAMP WHERE url = ?
399
- """, (owner_id, int(permission), url))
400
- self.logger.info(f"File {url} updated")
401
- else:
402
- self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}, external={external}")
403
- if permission is None:
404
- permission = FileReadPermission.UNSET
405
- assert owner_id is not None and file_id is not None and file_size is not None and external is not None
406
- await self.conn.execute(
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)
409
- )
410
- await self._user_size_inc(owner_id, file_size)
411
- self.logger.info(f"File {url} created")
382
+ self.logger.debug(f"Creating fmeta {url}: permission={permission}, owner_id={owner_id}, file_id={file_id}, file_size={file_size}, external={external}, mime_type={mime_type}")
383
+ if permission is None:
384
+ permission = FileReadPermission.UNSET
385
+ assert owner_id is not None and file_id is not None and file_size is not None and external is not None
386
+ await self.conn.execute(
387
+ "INSERT INTO fmeta (url, owner_id, file_id, file_size, permission, external, mime_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
388
+ (url, owner_id, file_id, file_size, int(permission), external, mime_type)
389
+ )
390
+ await self._user_size_inc(owner_id, file_size)
391
+ self.logger.info(f"File {url} created")
412
392
 
413
393
  @atomic
414
394
  async def move_file(self, old_url: str, new_url: str):
@@ -587,7 +567,8 @@ class Database:
587
567
  async def save_file(
588
568
  self, u: int | str, url: str,
589
569
  blob: bytes | AsyncIterable[bytes],
590
- permission: FileReadPermission = FileReadPermission.UNSET
570
+ permission: FileReadPermission = FileReadPermission.UNSET,
571
+ mime_type: str = 'application/octet-stream'
591
572
  ):
592
573
  """
593
574
  if file_size is not provided, the blob must be bytes
@@ -619,7 +600,7 @@ class Database:
619
600
  await self.file.set_file_blob(f_id, blob)
620
601
  await self.file.set_file_record(
621
602
  url, owner_id=user.id, file_id=f_id, file_size=file_size,
622
- permission=permission, external=False)
603
+ permission=permission, external=False, mime_type=mime_type)
623
604
  await self.user.set_active(user.username)
624
605
  else:
625
606
  assert isinstance(blob, AsyncIterable)
@@ -631,7 +612,7 @@ class Database:
631
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}")
632
613
  await self.file.set_file_record(
633
614
  url, owner_id=user.id, file_id=f_id, file_size=file_size,
634
- permission=permission, external=True)
615
+ permission=permission, external=True, mime_type=mime_type)
635
616
  await self.user.set_active(user.username)
636
617
 
637
618
  async def read_file_stream(self, url: str) -> AsyncIterable[bytes]:
@@ -0,0 +1,55 @@
1
+ from enum import IntEnum
2
+ import dataclasses
3
+
4
+ class FileReadPermission(IntEnum):
5
+ UNSET = 0 # not set
6
+ PUBLIC = 1 # accessible by anyone
7
+ PROTECTED = 2 # accessible by any user
8
+ PRIVATE = 3 # accessible by owner only (including admin)
9
+
10
+ @dataclasses.dataclass
11
+ class UserRecord:
12
+ id: int
13
+ username: str
14
+ credential: str
15
+ is_admin: bool
16
+ create_time: str
17
+ last_active: str
18
+ max_storage: int
19
+ permission: 'FileReadPermission'
20
+
21
+ def __str__(self):
22
+ return f"User {self.username} (id={self.id}, admin={self.is_admin}, created at {self.create_time}, last active at {self.last_active}, storage={self.max_storage}, permission={self.permission})"
23
+
24
+ @dataclasses.dataclass
25
+ class FileRecord:
26
+ url: str
27
+ owner_id: int
28
+ file_id: str # defines mapping from fmata to fdata
29
+ file_size: int
30
+ create_time: str
31
+ access_time: str
32
+ permission: FileReadPermission
33
+ external: bool
34
+ mime_type: str
35
+
36
+ def __str__(self):
37
+ return f"File {self.url} [{self.mime_type}] (owner={self.owner_id}, created at {self.create_time}, accessed at {self.access_time}, " + \
38
+ f"file_id={self.file_id}, permission={self.permission}, size={self.file_size}, external={self.external})"
39
+
40
+ @dataclasses.dataclass
41
+ class DirectoryRecord:
42
+ url: str
43
+ size: int
44
+ create_time: str = ""
45
+ update_time: str = ""
46
+ access_time: str = ""
47
+
48
+ def __str__(self):
49
+ return f"Directory {self.url} (size={self.size})"
50
+
51
+ @dataclasses.dataclass
52
+ class PathContents:
53
+ dirs: list[DirectoryRecord]
54
+ files: list[FileRecord]
55
+
@@ -15,7 +15,7 @@ from contextlib import asynccontextmanager
15
15
  from .error import *
16
16
  from .log import get_logger
17
17
  from .stat import RequestDB
18
- from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_BLOB_DIR, LARGE_FILE_BYTES
18
+ from .config import MAX_BUNDLE_BYTES, MAX_FILE_BYTES, LARGE_FILE_BYTES
19
19
  from .utils import ensure_uri_compnents, format_last_modified, now_stamp
20
20
  from .database import Database, UserRecord, DECOY_USER, FileRecord, check_user_permission, FileReadPermission
21
21
 
@@ -142,12 +142,10 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
142
142
 
143
143
  fname = path.split("/")[-1]
144
144
  async def send(media_type: Optional[str] = None, disposition = "attachment"):
145
+ if media_type is None:
146
+ media_type = file_record.mime_type
145
147
  if not file_record.external:
146
148
  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
149
  return Response(
152
150
  content=fblob, media_type=media_type, headers={
153
151
  "Content-Disposition": f"{disposition}; filename={fname}",
@@ -155,12 +153,7 @@ async def get_file(path: str, download = False, user: UserRecord = Depends(get_c
155
153
  "Last-Modified": format_last_modified(file_record.create_time)
156
154
  }
157
155
  )
158
-
159
156
  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
157
  return StreamingResponse(
165
158
  await conn.read_file_stream(path), media_type=media_type, headers={
166
159
  "Content-Disposition": f"{disposition}; filename={fname}",
@@ -228,14 +221,24 @@ async def put_file(
228
221
  blobs = await request.body()
229
222
  else:
230
223
  blobs = await request.body()
224
+
225
+ # check file type
226
+ assert not path.endswith("/"), "Path must be a file"
227
+ fname = path.split("/")[-1]
228
+ mime_t, _ = mimetypes.guess_type(fname)
229
+ if mime_t is None:
230
+ mime_t = mimesniff.what(blobs)
231
+ if mime_t is None:
232
+ mime_t = "application/octet-stream"
233
+
231
234
  if len(blobs) > LARGE_FILE_BYTES:
232
235
  async def blob_reader():
233
236
  chunk_size = 16 * 1024 * 1024 # 16MB
234
237
  for b in range(0, len(blobs), chunk_size):
235
238
  yield blobs[b:b+chunk_size]
236
- await conn.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission))
239
+ await conn.save_file(user.id, path, blob_reader(), permission = FileReadPermission(permission), mime_type = mime_t)
237
240
  else:
238
- await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission))
241
+ await conn.save_file(user.id, path, blobs, permission = FileReadPermission(permission), mime_type=mime_t)
239
242
 
240
243
  # https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Methods/PUT
241
244
  if exists_flag:
@@ -353,7 +356,7 @@ async def update_file_meta(
353
356
 
354
357
  if perm is not None:
355
358
  logger.info(f"Update permission of {path} to {perm}")
356
- await conn.file.set_file_record(
359
+ await conn.file.update_file_record(
357
360
  url = file_record.url,
358
361
  permission = FileReadPermission(perm)
359
362
  )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "lfss"
3
- version = "0.5.2"
3
+ version = "0.6.0"
4
4
  description = "Lightweight file storage service"
5
5
  authors = ["li, mengxun <limengxun45@outlook.com>"]
6
6
  readme = "Readme.md"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes