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.
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 get_file_impl, put_file_impl, delete_impl, copy_impl
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
- async with unique_cursor() as c:
57
- fconn = FileConn(c)
58
- if await fconn.count_path_files(path, flat=True) == 0:
59
- return None, lfss_path, None
60
- return "dir", lfss_path, await fconn.get_path_record(path)
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.count_path_files(path + "/") > 0:
73
- lfss_path = path + "/"
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 query_lock_el(p: str, top_el_name: str = f"{{{DAV_NS}}}lockinfo") -> Optional[ET.Element]:
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 query_lock_el(frecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
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 query_lock_el(drecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
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
- depth = request.headers.get("Depth", "1")
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
- return_status = 200
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
- return_status = 207
254
+ # query directory content
243
255
  async with unique_cursor() as c:
244
- flist = await FileConn(c).list_path_files(lfss_path, flat = True if depth == "infinity" else False)
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.split("/")[-1] == MKDIR_PLACEHOLDER: continue
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=return_status)
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, ddav_path = await eval_path(destination)
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.move_path(lfss_path, dlfss_path, user)
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 query_lock_el(path, top_el_name=f"{{{DAV_NS}}}activelock")
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, UserConn, FileConn
11
+ from ..eng.database import check_file_read_permission, check_path_permission, FileConn, delayed_log_access
10
12
  from ..eng.datatype import (
11
- FileReadPermission, FileRecord, UserRecord, AccessLevel,
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 get_file_impl, put_file_impl, post_file_impl, delete_impl, copy_impl
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 get_file_impl(
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
- if path.endswith("/"):
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"] = "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"] = "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
- assert path.endswith("/") or path == ""
87
-
88
- if not path == "" and path[0] == "/": # adapt to both /path and path
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
- # TODO: may check peer users here
92
- owner_records_cache: dict[int, UserRecord] = {} # cache owner records, ID -> UserRecord
93
- async def is_access_granted(file_record: FileRecord):
94
- owner_id = file_record.owner_id
95
- owner = owner_records_cache.get(owner_id, None)
96
- if owner is None:
97
- async with unique_cursor() as conn:
98
- uconn = UserConn(conn)
99
- owner = await uconn.get_user_by_id(owner_id, throw=True)
100
- owner_records_cache[owner_id] = owner
101
-
102
- allow_access, _ = check_file_read_permission(user, owner, file_record)
103
- return allow_access
104
-
105
- async with unique_cursor() as conn:
106
- fconn = FileConn(conn)
107
- files = await fconn.list_path_files(
108
- url = path, flat = True,
109
- limit=(await fconn.count_path_files(url = path, flat = True))
110
- )
111
- files = [f for f in files if await is_access_granted(f)]
112
- if len(files) == 0:
113
- raise HTTPException(status_code=404, detail="No files found")
114
-
115
- # return bundle of files
116
- total_size = sum([f.file_size for f in files])
117
- if total_size > MAX_BUNDLE_BYTES:
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
- uconn = UserConn(cur)
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.get_path_record(path)
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.move_path(path, new_path, user)
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.count_path_files(url = path, flat = flat) }
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.list_path_files(
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