lfss 0.9.2__py3-none-any.whl → 0.11.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- Readme.md +4 -4
- docs/Enviroment_variables.md +4 -2
- docs/Permission.md +4 -4
- docs/Webdav.md +3 -3
- docs/changelog.md +58 -0
- frontend/api.js +66 -4
- frontend/login.js +0 -1
- frontend/popup.js +18 -3
- frontend/scripts.js +46 -39
- frontend/utils.js +98 -1
- lfss/api/__init__.py +7 -4
- lfss/api/connector.py +47 -11
- lfss/cli/cli.py +9 -9
- lfss/cli/log.py +77 -0
- lfss/cli/vacuum.py +69 -19
- lfss/eng/config.py +7 -5
- lfss/eng/connection_pool.py +12 -8
- lfss/eng/database.py +350 -140
- lfss/eng/error.py +6 -2
- lfss/eng/log.py +91 -21
- lfss/eng/thumb.py +20 -23
- lfss/eng/utils.py +50 -29
- lfss/sql/init.sql +9 -4
- lfss/svc/app.py +1 -1
- lfss/svc/app_base.py +8 -3
- lfss/svc/app_dav.py +74 -61
- lfss/svc/app_native.py +95 -59
- lfss/svc/common_impl.py +72 -37
- {lfss-0.9.2.dist-info → lfss-0.11.4.dist-info}/METADATA +10 -8
- lfss-0.11.4.dist-info/RECORD +52 -0
- {lfss-0.9.2.dist-info → lfss-0.11.4.dist-info}/entry_points.txt +1 -0
- lfss-0.9.2.dist-info/RECORD +0 -50
- {lfss-0.9.2.dist-info → lfss-0.11.4.dist-info}/WHEEL +0 -0
lfss/svc/app_dav.py
CHANGED
@@ -9,11 +9,11 @@ import xml.etree.ElementTree as ET
|
|
9
9
|
from ..eng.connection_pool import unique_cursor
|
10
10
|
from ..eng.error import *
|
11
11
|
from ..eng.config import DATA_HOME, DEBUG_MODE
|
12
|
-
from ..eng.datatype import UserRecord, FileRecord, DirectoryRecord
|
13
|
-
from ..eng.database import FileConn
|
12
|
+
from ..eng.datatype import UserRecord, FileRecord, DirectoryRecord, AccessLevel
|
13
|
+
from ..eng.database import FileConn, UserConn, check_path_permission
|
14
14
|
from ..eng.utils import ensure_uri_compnents, decode_uri_compnents, format_last_modified, static_vars
|
15
15
|
from .app_base import *
|
16
|
-
from .common_impl import
|
16
|
+
from .common_impl import copy_impl
|
17
17
|
|
18
18
|
LOCK_DB_PATH = DATA_HOME / "lock.db"
|
19
19
|
MKDIR_PLACEHOLDER = ".lfss_keep"
|
@@ -53,11 +53,27 @@ async def eval_path(path: str) -> tuple[ptype, str, Optional[FileRecord | Direct
|
|
53
53
|
# path now is url-safe and without leading slash
|
54
54
|
if path.endswith("/"):
|
55
55
|
lfss_path = path
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
56
|
+
dir_path_sp = path.split("/")
|
57
|
+
if len(dir_path_sp) > 2:
|
58
|
+
async with unique_cursor() as c:
|
59
|
+
fconn = FileConn(c)
|
60
|
+
if await fconn.count_dir_files(path, flat=True) == 0:
|
61
|
+
return None, lfss_path, None
|
62
|
+
return "dir", lfss_path, await fconn.get_dir_record(path)
|
63
|
+
else:
|
64
|
+
# test if its a user's root directory
|
65
|
+
assert len(dir_path_sp) == 2
|
66
|
+
username = path.split("/")[0]
|
67
|
+
async with unique_cursor() as c:
|
68
|
+
uconn = UserConn(c)
|
69
|
+
u = await uconn.get_user(username)
|
70
|
+
if u is None:
|
71
|
+
return None, lfss_path, None
|
72
|
+
return "dir", lfss_path, DirectoryRecord(lfss_path)
|
73
|
+
|
74
|
+
# may be root directory
|
75
|
+
if path == "":
|
76
|
+
return "dir", "", DirectoryRecord("")
|
61
77
|
|
62
78
|
# not end with /, check if it is a file
|
63
79
|
async with unique_cursor() as c:
|
@@ -66,12 +82,11 @@ async def eval_path(path: str) -> tuple[ptype, str, Optional[FileRecord | Direct
|
|
66
82
|
lfss_path = path
|
67
83
|
return "file", lfss_path, res
|
68
84
|
|
69
|
-
if path == "": return "dir", "", DirectoryRecord("")
|
70
85
|
async with unique_cursor() as c:
|
86
|
+
lfss_path = path + "/"
|
71
87
|
fconn = FileConn(c)
|
72
|
-
if await fconn.
|
73
|
-
|
74
|
-
return "dir", lfss_path, await fconn.get_path_record(lfss_path)
|
88
|
+
if await fconn.count_dir_files(lfss_path) > 0:
|
89
|
+
return "dir", lfss_path, await fconn.get_dir_record(lfss_path)
|
75
90
|
|
76
91
|
return None, path, None
|
77
92
|
|
@@ -110,7 +125,7 @@ async def unlock_path(user: UserRecord, p: str, token: str):
|
|
110
125
|
raise FileLockedError(f"Failed to unlock file [{p}] with token {token}")
|
111
126
|
await cur.execute("DELETE FROM locks WHERE path=?", (p,))
|
112
127
|
await conn.commit()
|
113
|
-
async def
|
128
|
+
async def query_lock_element(p: str, top_el_name: str = f"{{{DAV_NS}}}lockinfo") -> Optional[ET.Element]:
|
114
129
|
async with aiosqlite.connect(LOCK_DB_PATH) as conn:
|
115
130
|
await conn.execute("BEGIN EXCLUSIVE")
|
116
131
|
await conn.execute(lock_table_create_sql)
|
@@ -145,15 +160,16 @@ async def create_file_xml_element(frecord: FileRecord) -> ET.Element:
|
|
145
160
|
href.text = f"/{frecord.url}"
|
146
161
|
propstat = ET.SubElement(file_el, f"{{{DAV_NS}}}propstat")
|
147
162
|
prop = ET.SubElement(propstat, f"{{{DAV_NS}}}prop")
|
148
|
-
ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = frecord.url.split("/")[-1]
|
163
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = decode_uri_compnents(frecord.url.split("/")[-1])
|
149
164
|
ET.SubElement(prop, f"{{{DAV_NS}}}resourcetype")
|
150
165
|
ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(frecord.file_size)
|
151
166
|
ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(frecord.create_time)
|
152
167
|
ET.SubElement(prop, f"{{{DAV_NS}}}getcontenttype").text = frecord.mime_type
|
153
|
-
lock_el = await
|
168
|
+
lock_el = await query_lock_element(frecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
|
154
169
|
if lock_el is not None:
|
155
170
|
lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
|
156
171
|
lock_discovery.append(lock_el)
|
172
|
+
ET.SubElement(propstat, f"{{{DAV_NS}}}status").text = "HTTP/1.1 200 OK"
|
157
173
|
return file_el
|
158
174
|
|
159
175
|
async def create_dir_xml_element(drecord: DirectoryRecord) -> ET.Element:
|
@@ -162,15 +178,16 @@ async def create_dir_xml_element(drecord: DirectoryRecord) -> ET.Element:
|
|
162
178
|
href.text = f"/{drecord.url}"
|
163
179
|
propstat = ET.SubElement(dir_el, f"{{{DAV_NS}}}propstat")
|
164
180
|
prop = ET.SubElement(propstat, f"{{{DAV_NS}}}prop")
|
165
|
-
ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = drecord.url.split("/")[-2]
|
181
|
+
ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = decode_uri_compnents(drecord.url.split("/")[-2])
|
166
182
|
ET.SubElement(prop, f"{{{DAV_NS}}}resourcetype").append(ET.Element(f"{{{DAV_NS}}}collection"))
|
167
183
|
if drecord.size >= 0:
|
168
184
|
ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(drecord.create_time)
|
169
185
|
ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(drecord.size)
|
170
|
-
lock_el = await
|
186
|
+
lock_el = await query_lock_element(drecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
|
171
187
|
if lock_el is not None:
|
172
188
|
lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
|
173
189
|
lock_discovery.append(lock_el)
|
190
|
+
ET.SubElement(propstat, f"{{{DAV_NS}}}status").text = "HTTP/1.1 200 OK"
|
174
191
|
return dir_el
|
175
192
|
|
176
193
|
async def xml_request_body(request: Request) -> Optional[ET.Element]:
|
@@ -186,64 +203,59 @@ async def dav_options(request: Request, path: str):
|
|
186
203
|
return Response(headers={
|
187
204
|
"DAV": "1,2",
|
188
205
|
"MS-Author-Via": "DAV",
|
189
|
-
"Allow": "OPTIONS, GET, HEAD, POST, DELETE, TRACE, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK",
|
206
|
+
"Allow": "OPTIONS, GET, HEAD, POST, DELETE, TRACE, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK, MKCOL",
|
190
207
|
"Content-Length": "0"
|
191
208
|
})
|
192
209
|
|
193
|
-
@router_dav.get("/{path:path}")
|
194
|
-
@handle_exception
|
195
|
-
async def dav_get(request: Request, path: str, user: UserRecord = Depends(get_current_user)):
|
196
|
-
ptype, path, _ = await eval_path(path)
|
197
|
-
if ptype is None: raise PathNotFoundError(path)
|
198
|
-
# elif ptype == "dir": raise InvalidOptionsError("Directory should not be fetched")
|
199
|
-
else: return await get_file_impl(request, user=user, path=path)
|
200
|
-
|
201
|
-
@router_dav.head("/{path:path}")
|
202
|
-
@handle_exception
|
203
|
-
async def dav_head(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
204
|
-
ptype, path, _ = await eval_path(path)
|
205
|
-
# some clients may send HEAD request to check if the file exists
|
206
|
-
if ptype is None: raise PathNotFoundError(path)
|
207
|
-
elif ptype == "dir": return Response(status_code=200)
|
208
|
-
else: return await get_file_impl(request, user=user, path=path, is_head=True)
|
209
|
-
|
210
|
-
@router_dav.put("/{path:path}")
|
211
|
-
@handle_exception
|
212
|
-
async def dav_put(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
213
|
-
_, path, _ = await eval_path(path)
|
214
|
-
return await put_file_impl(request, user=user, path=path, conflict='overwrite')
|
215
|
-
|
216
|
-
@router_dav.delete("/{path:path}")
|
217
|
-
@handle_exception
|
218
|
-
async def dav_delete(path: str, user: UserRecord = Depends(registered_user)):
|
219
|
-
_, path, _ = await eval_path(path)
|
220
|
-
return await delete_impl(user=user, path=path)
|
221
|
-
|
222
210
|
@router_dav.api_route("/{path:path}", methods=["PROPFIND"])
|
223
211
|
@handle_exception
|
224
|
-
async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(registered_user)):
|
212
|
+
async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(registered_user), body: Optional[ET.Element] = Depends(xml_request_body)):
|
225
213
|
if path.startswith("/"): path = path[1:]
|
226
214
|
path = ensure_uri_compnents(path)
|
227
215
|
|
228
|
-
|
216
|
+
if body and DEBUG_MODE:
|
217
|
+
print("Propfind-body:", ET.tostring(body, encoding="utf-8", method="xml"))
|
218
|
+
|
219
|
+
depth = request.headers.get("Depth", "0")
|
229
220
|
# Generate XML response
|
230
221
|
multistatus = ET.Element(f"{{{DAV_NS}}}multistatus")
|
231
222
|
path_type, lfss_path, record = await eval_path(path)
|
232
|
-
logger.info(f"PROPFIND {lfss_path} (depth: {depth})")
|
233
|
-
|
223
|
+
logger.info(f"PROPFIND {lfss_path} (depth: {depth}), type: {path_type}, record: {record}")
|
224
|
+
|
225
|
+
if lfss_path and await check_path_permission(lfss_path, user) < AccessLevel.READ:
|
226
|
+
raise PermissionDeniedError(lfss_path)
|
227
|
+
|
234
228
|
if path_type == "dir" and depth == "0":
|
235
229
|
# query the directory itself
|
236
|
-
return_status = 200
|
237
230
|
assert isinstance(record, DirectoryRecord)
|
238
231
|
dir_el = await create_dir_xml_element(record)
|
239
232
|
multistatus.append(dir_el)
|
240
233
|
|
234
|
+
elif path_type == "dir" and lfss_path == "":
|
235
|
+
# query root directory content
|
236
|
+
async def user_path_record(user_name: str, cur) -> DirectoryRecord:
|
237
|
+
try:
|
238
|
+
return await FileConn(cur).get_dir_record(user_name + "/")
|
239
|
+
except PathNotFoundError:
|
240
|
+
return DirectoryRecord(user_name + "/", size=0, n_files=0, create_time="1970-01-01 00:00:00", update_time="1970-01-01 00:00:00", access_time="1970-01-01 00:00:00")
|
241
|
+
|
242
|
+
async with unique_cursor() as c:
|
243
|
+
uconn = UserConn(c)
|
244
|
+
if not user.is_admin:
|
245
|
+
for u in [user] + await uconn.list_peer_users(user.id, AccessLevel.READ):
|
246
|
+
dir_el = await create_dir_xml_element(await user_path_record(u.username, c))
|
247
|
+
multistatus.append(dir_el)
|
248
|
+
else:
|
249
|
+
async for u in uconn.all():
|
250
|
+
dir_el = await create_dir_xml_element(await user_path_record(u.username, c))
|
251
|
+
multistatus.append(dir_el)
|
252
|
+
|
241
253
|
elif path_type == "dir":
|
242
|
-
|
254
|
+
# query directory content
|
243
255
|
async with unique_cursor() as c:
|
244
|
-
flist = await FileConn(c).
|
256
|
+
flist = await FileConn(c).list_dir_files(lfss_path, flat = True if depth == "infinity" else False)
|
245
257
|
for frecord in flist:
|
246
|
-
if frecord.url.
|
258
|
+
if frecord.url.endswith(f"/{MKDIR_PLACEHOLDER}"): continue
|
247
259
|
file_el = await create_file_xml_element(frecord)
|
248
260
|
multistatus.append(file_el)
|
249
261
|
|
@@ -254,6 +266,7 @@ async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(r
|
|
254
266
|
multistatus.append(dir_el)
|
255
267
|
|
256
268
|
elif path_type == "file":
|
269
|
+
# query file
|
257
270
|
assert isinstance(record, FileRecord)
|
258
271
|
file_el = await create_file_xml_element(record)
|
259
272
|
multistatus.append(file_el)
|
@@ -262,7 +275,7 @@ async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(r
|
|
262
275
|
raise PathNotFoundError(path)
|
263
276
|
|
264
277
|
xml_response = ET.tostring(multistatus, encoding="utf-8", method="xml")
|
265
|
-
return Response(content=xml_response, media_type="application/xml", status_code=
|
278
|
+
return Response(content=xml_response, media_type="application/xml", status_code=207)
|
266
279
|
|
267
280
|
@router_dav.api_route("/{path:path}", methods=["MKCOL"])
|
268
281
|
@handle_exception
|
@@ -289,7 +302,7 @@ async def dav_move(request: Request, path: str, user: UserRecord = Depends(regis
|
|
289
302
|
ptype, lfss_path, _ = await eval_path(path)
|
290
303
|
if ptype is None:
|
291
304
|
raise PathNotFoundError(path)
|
292
|
-
dptype, dlfss_path,
|
305
|
+
dptype, dlfss_path, _ = await eval_path(destination)
|
293
306
|
if dptype is not None:
|
294
307
|
raise HTTPException(status_code=409, detail="Conflict")
|
295
308
|
|
@@ -302,7 +315,7 @@ async def dav_move(request: Request, path: str, user: UserRecord = Depends(regis
|
|
302
315
|
assert ptype == "dir", "Directory path should end with /"
|
303
316
|
assert lfss_path.endswith("/"), "Directory path should end with /"
|
304
317
|
if not dlfss_path.endswith("/"): dlfss_path += "/" # the header destination may not end with /
|
305
|
-
await db.
|
318
|
+
await db.move_dir(lfss_path, dlfss_path, user)
|
306
319
|
return Response(status_code=201)
|
307
320
|
|
308
321
|
@router_dav.api_route("/{path:path}", methods=["COPY"])
|
@@ -339,13 +352,13 @@ async def dav_lock(request: Request, path: str, user: UserRecord = Depends(regis
|
|
339
352
|
# lock_token = f"opaquelocktoken:{uuid.uuid4().hex}"
|
340
353
|
lock_token = f"urn:uuid:{uuid.uuid4()}"
|
341
354
|
logger.info(f"LOCK {path} (timeout: {timeout}), token: {lock_token}, depth: {lock_depth}")
|
342
|
-
if DEBUG_MODE:
|
355
|
+
if DEBUG_MODE and body:
|
343
356
|
print("Lock-body:", ET.tostring(body, encoding="utf-8", method="xml"))
|
344
357
|
async with dav_lock.lock:
|
345
358
|
await lock_path(user, path, lock_token, lock_depth, timeout=timeout)
|
346
359
|
response_elem = ET.Element(f"{{{DAV_NS}}}prop")
|
347
360
|
lockdiscovery = ET.SubElement(response_elem, f"{{{DAV_NS}}}lockdiscovery")
|
348
|
-
activelock = await
|
361
|
+
activelock = await query_lock_element(path, top_el_name=f"{{{DAV_NS}}}activelock")
|
349
362
|
assert activelock is not None
|
350
363
|
lockdiscovery.append(activelock)
|
351
364
|
lock_response = ET.tostring(response_elem, encoding="utf-8", method="xml")
|
@@ -362,7 +375,7 @@ async def dav_unlock(request: Request, path: str, user: UserRecord = Depends(reg
|
|
362
375
|
if lock_token.startswith("<") and lock_token.endswith(">"):
|
363
376
|
lock_token = lock_token[1:-1]
|
364
377
|
logger.info(f"UNLOCK {path}, token: {lock_token}")
|
365
|
-
if DEBUG_MODE:
|
378
|
+
if DEBUG_MODE and body:
|
366
379
|
print("Unlock-body:", ET.tostring(body, encoding="utf-8", method="xml"))
|
367
380
|
_, path, _ = await eval_path(path)
|
368
381
|
await unlock_path(user, path, lock_token)
|
lfss/svc/app_native.py
CHANGED
@@ -1,19 +1,22 @@
|
|
1
|
-
from typing import Optional, Literal
|
1
|
+
from typing import Optional, Literal, Annotated
|
2
|
+
from collections import OrderedDict
|
2
3
|
|
3
|
-
from fastapi import Depends, Request, Response, UploadFile
|
4
|
+
from fastapi import Depends, Request, Response, UploadFile, Query
|
5
|
+
from fastapi.responses import StreamingResponse, JSONResponse
|
4
6
|
from fastapi.exceptions import HTTPException
|
5
7
|
|
6
|
-
from ..eng.config import MAX_BUNDLE_BYTES
|
7
8
|
from ..eng.utils import ensure_uri_compnents
|
9
|
+
from ..eng.config import MAX_MEM_FILE_BYTES
|
8
10
|
from ..eng.connection_pool import unique_cursor
|
9
|
-
from ..eng.database import check_file_read_permission, check_path_permission,
|
11
|
+
from ..eng.database import check_file_read_permission, check_path_permission, FileConn, delayed_log_access
|
10
12
|
from ..eng.datatype import (
|
11
|
-
FileReadPermission,
|
13
|
+
FileReadPermission, UserRecord, AccessLevel,
|
12
14
|
FileSortKey, DirSortKey
|
13
15
|
)
|
16
|
+
from ..eng.error import InvalidPathError
|
14
17
|
|
15
18
|
from .app_base import *
|
16
|
-
from .common_impl import
|
19
|
+
from .common_impl import get_impl, put_file_impl, post_file_impl, delete_impl, copy_impl
|
17
20
|
|
18
21
|
@router_fs.get("/{path:path}")
|
19
22
|
@handle_exception
|
@@ -23,7 +26,7 @@ async def get_file(
|
|
23
26
|
download: bool = False, thumb: bool = False,
|
24
27
|
user: UserRecord = Depends(get_current_user)
|
25
28
|
):
|
26
|
-
return await
|
29
|
+
return await get_impl(
|
27
30
|
request = request,
|
28
31
|
user = user, path = path, download = download, thumb = thumb
|
29
32
|
)
|
@@ -38,9 +41,7 @@ async def head_file(
|
|
38
41
|
):
|
39
42
|
if path.startswith("_api/"):
|
40
43
|
raise HTTPException(status_code=405, detail="HEAD not supported for API")
|
41
|
-
|
42
|
-
raise HTTPException(status_code=405, detail="HEAD not supported for directory")
|
43
|
-
return await get_file_impl(
|
44
|
+
return await get_impl(
|
44
45
|
request = request,
|
45
46
|
user = user, path = path, download = download, thumb = thumb, is_head = True
|
46
47
|
)
|
@@ -50,7 +51,7 @@ async def head_file(
|
|
50
51
|
async def put_file(
|
51
52
|
request: Request,
|
52
53
|
path: str,
|
53
|
-
conflict: Literal["overwrite", "skip", "abort"] = "
|
54
|
+
conflict: Literal["overwrite", "skip", "abort"] = "overwrite",
|
54
55
|
permission: int = 0,
|
55
56
|
user: UserRecord = Depends(registered_user)
|
56
57
|
):
|
@@ -64,7 +65,7 @@ async def put_file(
|
|
64
65
|
async def post_file(
|
65
66
|
path: str,
|
66
67
|
file: UploadFile,
|
67
|
-
conflict: Literal["overwrite", "skip", "abort"] = "
|
68
|
+
conflict: Literal["overwrite", "skip", "abort"] = "overwrite",
|
68
69
|
permission: int = 0,
|
69
70
|
user: UserRecord = Depends(registered_user)
|
70
71
|
):
|
@@ -83,48 +84,40 @@ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
|
|
83
84
|
async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
|
84
85
|
logger.info(f"GET bundle({path}), user: {user.username}")
|
85
86
|
path = ensure_uri_compnents(path)
|
86
|
-
|
87
|
-
|
88
|
-
if
|
87
|
+
if not path.endswith("/"):
|
88
|
+
raise HTTPException(status_code=400, detail="Path must end with /")
|
89
|
+
if path[0] == "/": # adapt to both /path and path
|
89
90
|
path = path[1:]
|
91
|
+
if path == "":
|
92
|
+
raise HTTPException(status_code=400, detail="Cannot bundle root")
|
90
93
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
raise HTTPException(status_code=400, detail="Too large to zip")
|
119
|
-
|
120
|
-
file_paths = [f.url for f in files]
|
121
|
-
zip_buffer = await db.zip_path(path, file_paths)
|
122
|
-
return Response(
|
123
|
-
content=zip_buffer.getvalue(), media_type="application/zip", headers={
|
124
|
-
"Content-Disposition": f"attachment; filename=bundle.zip",
|
125
|
-
"Content-Length": str(zip_buffer.getbuffer().nbytes)
|
126
|
-
}
|
127
|
-
)
|
94
|
+
async with unique_cursor() as cur:
|
95
|
+
dir_record = await FileConn(cur).get_dir_record(path)
|
96
|
+
|
97
|
+
pathname = f"{path.split('/')[-2]}"
|
98
|
+
|
99
|
+
if dir_record.size < MAX_MEM_FILE_BYTES:
|
100
|
+
logger.debug(f"Bundle {path} in memory")
|
101
|
+
dir_bytes = (await db.zip_dir(path, op_user=user)).getvalue()
|
102
|
+
return Response(
|
103
|
+
content = dir_bytes,
|
104
|
+
media_type = "application/zip",
|
105
|
+
headers = {
|
106
|
+
f"Content-Disposition": f"attachment; filename=bundle-{pathname}.zip",
|
107
|
+
"Content-Length": str(len(dir_bytes)),
|
108
|
+
"X-Content-Bytes": str(dir_record.size),
|
109
|
+
}
|
110
|
+
)
|
111
|
+
else:
|
112
|
+
logger.debug(f"Bundle {path} in stream")
|
113
|
+
return StreamingResponse(
|
114
|
+
content = await db.zip_dir_stream(path, op_user=user),
|
115
|
+
media_type = "application/zip",
|
116
|
+
headers = {
|
117
|
+
f"Content-Disposition": f"attachment; filename=bundle-{pathname}.zip",
|
118
|
+
"X-Content-Bytes": str(dir_record.size),
|
119
|
+
}
|
120
|
+
)
|
128
121
|
|
129
122
|
@router_api.get("/meta")
|
130
123
|
@handle_exception
|
@@ -137,15 +130,13 @@ async def get_file_meta(path: str, user: UserRecord = Depends(registered_user)):
|
|
137
130
|
if is_file:
|
138
131
|
record = await fconn.get_file_record(path, throw=True)
|
139
132
|
if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
|
140
|
-
|
141
|
-
owner = await uconn.get_user_by_id(record.owner_id, throw=True)
|
142
|
-
is_allowed, reason = check_file_read_permission(user, owner, record)
|
133
|
+
is_allowed, reason = await check_file_read_permission(user, record, cursor=cur)
|
143
134
|
if not is_allowed:
|
144
135
|
raise HTTPException(status_code=403, detail=reason)
|
145
136
|
else:
|
146
137
|
if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
|
147
138
|
raise HTTPException(status_code=403, detail="Permission denied")
|
148
|
-
record = await fconn.
|
139
|
+
record = await fconn.get_dir_record(path)
|
149
140
|
return record
|
150
141
|
|
151
142
|
@router_api.post("/meta")
|
@@ -182,7 +173,7 @@ async def update_file_meta(
|
|
182
173
|
new_path = ensure_uri_compnents(new_path)
|
183
174
|
logger.info(f"Update path of {path} to {new_path}")
|
184
175
|
# will raise duplicate path error if same name path exists in the new path
|
185
|
-
await db.
|
176
|
+
await db.move_dir(path, new_path, user)
|
186
177
|
|
187
178
|
return Response(status_code=200, content="OK")
|
188
179
|
|
@@ -200,13 +191,15 @@ async def validate_path_read_permission(path: str, user: UserRecord):
|
|
200
191
|
if not await check_path_permission(path, user) >= AccessLevel.READ:
|
201
192
|
raise HTTPException(status_code=403, detail="Permission denied")
|
202
193
|
@router_api.get("/count-files")
|
194
|
+
@handle_exception
|
203
195
|
async def count_files(path: str, flat: bool = False, user: UserRecord = Depends(registered_user)):
|
204
196
|
await validate_path_read_permission(path, user)
|
205
197
|
path = ensure_uri_compnents(path)
|
206
198
|
async with unique_cursor() as conn:
|
207
199
|
fconn = FileConn(conn)
|
208
|
-
return { "count": await fconn.
|
200
|
+
return { "count": await fconn.count_dir_files(url = path, flat = flat) }
|
209
201
|
@router_api.get("/list-files")
|
202
|
+
@handle_exception
|
210
203
|
async def list_files(
|
211
204
|
path: str, offset: int = 0, limit: int = 1000,
|
212
205
|
order_by: FileSortKey = "", order_desc: bool = False,
|
@@ -216,13 +209,14 @@ async def list_files(
|
|
216
209
|
path = ensure_uri_compnents(path)
|
217
210
|
async with unique_cursor() as conn:
|
218
211
|
fconn = FileConn(conn)
|
219
|
-
return await fconn.
|
212
|
+
return await fconn.list_dir_files(
|
220
213
|
url = path, offset = offset, limit = limit,
|
221
214
|
order_by=order_by, order_desc=order_desc,
|
222
215
|
flat=flat
|
223
216
|
)
|
224
217
|
|
225
218
|
@router_api.get("/count-dirs")
|
219
|
+
@handle_exception
|
226
220
|
async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
|
227
221
|
await validate_path_read_permission(path, user)
|
228
222
|
path = ensure_uri_compnents(path)
|
@@ -230,6 +224,7 @@ async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
|
|
230
224
|
fconn = FileConn(conn)
|
231
225
|
return { "count": await fconn.count_path_dirs(url = path) }
|
232
226
|
@router_api.get("/list-dirs")
|
227
|
+
@handle_exception
|
233
228
|
async def list_dirs(
|
234
229
|
path: str, offset: int = 0, limit: int = 1000,
|
235
230
|
order_by: DirSortKey = "", order_desc: bool = False,
|
@@ -243,6 +238,47 @@ async def list_dirs(
|
|
243
238
|
url = path, offset = offset, limit = limit,
|
244
239
|
order_by=order_by, order_desc=order_desc, skim=skim
|
245
240
|
)
|
241
|
+
|
242
|
+
# https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#query-parameter-list-multiple-values
|
243
|
+
@router_api.get("/get-multiple")
|
244
|
+
@handle_exception
|
245
|
+
async def get_multiple_files(
|
246
|
+
path: Annotated[list[str], Query()],
|
247
|
+
skip_content: bool = False,
|
248
|
+
user: UserRecord = Depends(registered_user)
|
249
|
+
):
|
250
|
+
"""
|
251
|
+
Get multiple files by path.
|
252
|
+
Please note that the content is supposed to be text and are small enough to fit in memory.
|
253
|
+
|
254
|
+
Not existing files will have content null, and the response will be 206 Partial Content if not all files are found.
|
255
|
+
if skip_content is True, the content of the files will always be ''
|
256
|
+
"""
|
257
|
+
for p in path:
|
258
|
+
if p.endswith("/"):
|
259
|
+
raise InvalidPathError(f"Path '{p}' must not end with /")
|
260
|
+
|
261
|
+
# here we unify the path, so need to keep a record of the inputs
|
262
|
+
# make output keys consistent with inputs
|
263
|
+
upath2path = OrderedDict[str, str]()
|
264
|
+
for p in path:
|
265
|
+
p_ = p if not p.startswith("/") else p[1:]
|
266
|
+
upath2path[ensure_uri_compnents(p_)] = p
|
267
|
+
upaths = list(upath2path.keys())
|
268
|
+
|
269
|
+
# get files
|
270
|
+
raw_res = await db.read_files_bulk(upaths, skip_content=skip_content, op_user=user)
|
271
|
+
for k in raw_res.keys():
|
272
|
+
await delayed_log_access(k)
|
273
|
+
partial_content = len(raw_res) != len(upaths)
|
274
|
+
|
275
|
+
return JSONResponse(
|
276
|
+
content = {
|
277
|
+
upath2path[k]: v.decode('utf-8') if v is not None else None for k, v in raw_res.items()
|
278
|
+
},
|
279
|
+
status_code = 206 if partial_content else 200
|
280
|
+
)
|
281
|
+
|
246
282
|
|
247
283
|
@router_api.get("/whoami")
|
248
284
|
@handle_exception
|