lfss 0.9.0__py3-none-any.whl → 0.9.1__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 ADDED
@@ -0,0 +1,374 @@
1
+ """ WebDAV service """
2
+
3
+ from fastapi import Request, Response, Depends, HTTPException
4
+ import time, uuid, os
5
+ import aiosqlite
6
+ from typing import Literal, Optional
7
+ import xml.etree.ElementTree as ET
8
+ from ..eng.connection_pool import unique_cursor
9
+ from ..eng.error import *
10
+ from ..eng.config import DATA_HOME
11
+ from ..eng.datatype import UserRecord, FileRecord, DirectoryRecord
12
+ from ..eng.database import FileConn
13
+ from ..eng.utils import ensure_uri_compnents, decode_uri_compnents, format_last_modified
14
+ from .app_base import *
15
+ from .common_impl import get_file_impl, put_file_impl, delete_file_impl
16
+
17
+ LOCK_DB_PATH = DATA_HOME / "lock.db"
18
+ MKDIR_PLACEHOLDER = ".lfss_keep"
19
+ DAV_NS = "DAV:"
20
+
21
+ # at the beginning of the service, remove the lock database
22
+ try: os.remove(LOCK_DB_PATH)
23
+ except Exception: ...
24
+
25
+ ET.register_namespace("d", DAV_NS) # Register the default namespace
26
+ ptype = Literal["file", "dir", None]
27
+ async def eval_path(path: str) -> tuple[ptype, str, Optional[FileRecord | DirectoryRecord]]:
28
+ """
29
+ Evaluate the type of the path,
30
+ the return value is a uri-safe string,
31
+ return (ptype, lfss_path, record)
32
+
33
+ lfss_path is the path recorded in the database,
34
+ it should not start with /,
35
+ and should end with / if it is a directory, otherwise it is a file
36
+ record is the FileRecord or DirectoryRecord object, it is None if the path does not exist
37
+ """
38
+ path = decode_uri_compnents(path)
39
+ if "://" in path:
40
+ if not path.startswith("http://") and not path.startswith("https://"):
41
+ raise HTTPException(status_code=400, detail="Bad Request, unsupported protocol")
42
+ # pop the protocol part, host part, and port part
43
+ path = path.split("/", 3)[-1]
44
+ route_prefix = router_dav.prefix
45
+ if route_prefix.startswith("/"): route_prefix = route_prefix[1:]
46
+ assert path.startswith(route_prefix), "Path should start with the route prefix, got: " + path
47
+ path = path[len(route_prefix):]
48
+
49
+ path = ensure_uri_compnents(path)
50
+ if path.startswith("/"): path = path[1:]
51
+
52
+ # path now is url-safe and without leading slash
53
+ if path.endswith("/"):
54
+ lfss_path = path
55
+ async with unique_cursor() as c:
56
+ fconn = FileConn(c)
57
+ if await fconn.count_path_files(path, flat=True) == 0:
58
+ return None, lfss_path, None
59
+ return "dir", lfss_path, await fconn.get_path_record(path)
60
+
61
+ # not end with /, check if it is a file
62
+ async with unique_cursor() as c:
63
+ res = await FileConn(c).get_file_record(path)
64
+ if res:
65
+ lfss_path = path
66
+ return "file", lfss_path, res
67
+
68
+ if path == "": return "dir", "", DirectoryRecord("")
69
+ async with unique_cursor() as c:
70
+ fconn = FileConn(c)
71
+ if await fconn.count_path_files(path + "/") > 0:
72
+ lfss_path = path + "/"
73
+ return "dir", lfss_path, await fconn.get_path_record(lfss_path)
74
+
75
+ return None, path, None
76
+
77
+ lock_table_create_sql = """
78
+ CREATE TABLE IF NOT EXISTS locks (
79
+ path TEXT PRIMARY KEY,
80
+ user TEXT,
81
+ token TEXT,
82
+ timeout float,
83
+ lock_time float
84
+ );
85
+ """
86
+ async def lock_path(user: UserRecord, p: str, token: str, timeout: int = 600):
87
+ async with aiosqlite.connect(LOCK_DB_PATH) as conn:
88
+ await conn.execute(lock_table_create_sql)
89
+ async with conn.execute("SELECT user, timeout, lock_time FROM locks WHERE path=?", (p,)) as cur:
90
+ row = await cur.fetchone()
91
+ if row:
92
+ user_, timeout_, lock_time_ = row
93
+ curr_time = time.time()
94
+ if timeout > 0 and curr_time - lock_time_ < timeout_:
95
+ raise FileLockedError(f"File is locked (by {user_}) [{p}]")
96
+ await cur.execute("INSERT OR REPLACE INTO locks VALUES (?, ?, ?, ?, ?)", (p, user.username, token, timeout, time.time()))
97
+ await conn.commit()
98
+ async def unlock_path(user: UserRecord, p: str, token: str):
99
+ async with aiosqlite.connect(LOCK_DB_PATH) as conn:
100
+ await conn.execute(lock_table_create_sql)
101
+ async with conn.execute("SELECT user, token FROM locks WHERE path=?", (p,)) as cur:
102
+ row = await cur.fetchone()
103
+ if not row: return
104
+ user_, token_ = row
105
+ if user_ != user.username or token_ != token:
106
+ raise FileLockedError(f"Failed to unlock file [{p}] with token {token}")
107
+ await cur.execute("DELETE FROM locks WHERE path=?", (p,))
108
+ await conn.commit()
109
+ async def query_lock_el(p: str, top_el_name: str = f"{{{DAV_NS}}}lockinfo") -> Optional[ET.Element]:
110
+ async with aiosqlite.connect(LOCK_DB_PATH) as conn:
111
+ await conn.execute(lock_table_create_sql)
112
+ async with conn.execute("SELECT user, token, timeout, lock_time FROM locks WHERE path=?", (p,)) as cur:
113
+ row = await cur.fetchone()
114
+ if not row: return None
115
+ curr_time = time.time()
116
+ user_, token, timeout, lock_time = row
117
+ if timeout > 0 and curr_time - lock_time > timeout:
118
+ await cur.execute("DELETE FROM locks WHERE path=?", (p,))
119
+ await conn.commit()
120
+ return None
121
+ lock_info = ET.Element(top_el_name)
122
+ locktype = ET.SubElement(lock_info, f"{{{DAV_NS}}}locktype")
123
+ ET.SubElement(locktype, f"{{{DAV_NS}}}write")
124
+ lockscope = ET.SubElement(lock_info, f"{{{DAV_NS}}}lockscope")
125
+ ET.SubElement(lockscope, f"{{{DAV_NS}}}exclusive")
126
+ owner = ET.SubElement(lock_info, f"{{{DAV_NS}}}owner")
127
+ owner.text = user_
128
+ timeout = ET.SubElement(lock_info, f"{{{DAV_NS}}}timeout")
129
+ timeout.text = f"Second-{timeout}"
130
+ locktoken = ET.SubElement(lock_info, f"{{{DAV_NS}}}locktoken")
131
+ href = ET.SubElement(locktoken, f"{{{DAV_NS}}}href")
132
+ href.text = f"{token}"
133
+ return lock_info
134
+
135
+ async def create_file_xml_element(frecord: FileRecord) -> ET.Element:
136
+ file_el = ET.Element(f"{{{DAV_NS}}}response")
137
+ href = ET.SubElement(file_el, f"{{{DAV_NS}}}href")
138
+ href.text = f"/{frecord.url}"
139
+ propstat = ET.SubElement(file_el, f"{{{DAV_NS}}}propstat")
140
+ prop = ET.SubElement(propstat, f"{{{DAV_NS}}}prop")
141
+ ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = frecord.url.split("/")[-1]
142
+ ET.SubElement(prop, f"{{{DAV_NS}}}resourcetype")
143
+ ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(frecord.file_size)
144
+ ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(frecord.create_time)
145
+ ET.SubElement(prop, f"{{{DAV_NS}}}getcontenttype").text = frecord.mime_type
146
+ lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
147
+ lock_el = await query_lock_el(frecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
148
+ if lock_el is not None:
149
+ lock_discovery.append(lock_el)
150
+ return file_el
151
+
152
+ async def create_dir_xml_element(drecord: DirectoryRecord) -> ET.Element:
153
+ dir_el = ET.Element(f"{{{DAV_NS}}}response")
154
+ href = ET.SubElement(dir_el, f"{{{DAV_NS}}}href")
155
+ href.text = f"/{drecord.url}"
156
+ propstat = ET.SubElement(dir_el, f"{{{DAV_NS}}}propstat")
157
+ prop = ET.SubElement(propstat, f"{{{DAV_NS}}}prop")
158
+ ET.SubElement(prop, f"{{{DAV_NS}}}displayname").text = drecord.url.split("/")[-2]
159
+ ET.SubElement(prop, f"{{{DAV_NS}}}resourcetype").append(ET.Element(f"{{{DAV_NS}}}collection"))
160
+ if drecord.size >= 0:
161
+ ET.SubElement(prop, f"{{{DAV_NS}}}getlastmodified").text = format_last_modified(drecord.create_time)
162
+ ET.SubElement(prop, f"{{{DAV_NS}}}getcontentlength").text = str(drecord.size)
163
+ lock_discovery = ET.SubElement(prop, f"{{{DAV_NS}}}lockdiscovery")
164
+ lock_el = await query_lock_el(drecord.url, top_el_name=f"{{{DAV_NS}}}activelock")
165
+ if lock_el is not None:
166
+ lock_discovery.append(lock_el)
167
+ return dir_el
168
+
169
+ async def xml_request_body(request: Request) -> Optional[ET.Element]:
170
+ try:
171
+ assert request.headers.get("Content-Type") == "application/xml"
172
+ body = await request.body()
173
+ return ET.fromstring(body)
174
+ except Exception as e:
175
+ return None
176
+
177
+ @router_dav.options("/{path:path}")
178
+ async def dav_options(request: Request, path: str):
179
+ return Response(headers={
180
+ "DAV": "1,2",
181
+ "MS-Author-Via": "DAV",
182
+ "Allow": "OPTIONS, GET, HEAD, POST, DELETE, TRACE, PROPFIND, PROPPATCH, COPY, MOVE, LOCK, UNLOCK",
183
+ "Content-Length": "0"
184
+ })
185
+
186
+ @router_dav.get("/{path:path}")
187
+ @handle_exception
188
+ async def dav_get(request: Request, path: str, user: UserRecord = Depends(registered_user)):
189
+ ptype, path, _ = await eval_path(path)
190
+ if ptype is None: raise PathNotFoundError(path)
191
+ elif ptype == "dir": raise InvalidOptionsError("Directory should not be fetched")
192
+ else: return await get_file_impl(request, user=user, path=path)
193
+
194
+ @router_dav.head("/{path:path}")
195
+ @handle_exception
196
+ async def dav_head(request: Request, path: str, user: UserRecord = Depends(registered_user)):
197
+ ptype, path, _ = await eval_path(path)
198
+ # some clients may send HEAD request to check if the file exists
199
+ if ptype is None: raise PathNotFoundError(path)
200
+ elif ptype == "dir": return Response(status_code=200)
201
+ else: return await get_file_impl(request, user=user, path=path, is_head=True)
202
+
203
+ @router_dav.put("/{path:path}")
204
+ @handle_exception
205
+ async def dav_put(request: Request, path: str, user: UserRecord = Depends(registered_user)):
206
+ _, path, _ = await eval_path(path)
207
+ return await put_file_impl(request, user=user, path=path, conflict='overwrite')
208
+
209
+ @router_dav.delete("/{path:path}")
210
+ @handle_exception
211
+ async def dav_delete(path: str, user: UserRecord = Depends(registered_user)):
212
+ _, path, _ = await eval_path(path)
213
+ return await delete_file_impl(user=user, path=path)
214
+
215
+ @router_dav.api_route("/{path:path}", methods=["PROPFIND"])
216
+ @handle_exception
217
+ async def dav_propfind(request: Request, path: str, user: UserRecord = Depends(registered_user)):
218
+ if path.startswith("/"): path = path[1:]
219
+ path = ensure_uri_compnents(path)
220
+
221
+ depth = request.headers.get("Depth", "1")
222
+ # Generate XML response
223
+ multistatus = ET.Element(f"{{{DAV_NS}}}multistatus")
224
+ path_type, lfss_path, record = await eval_path(path)
225
+ logger.info(f"PROPFIND {lfss_path} (depth: {depth})")
226
+ return_status = 200
227
+ if path_type == "dir" and depth == "0":
228
+ # query the directory itself
229
+ return_status = 200
230
+ assert isinstance(record, DirectoryRecord)
231
+ dir_el = await create_dir_xml_element(record)
232
+ multistatus.append(dir_el)
233
+
234
+ elif path_type == "dir":
235
+ return_status = 207
236
+ async with unique_cursor() as c:
237
+ flist = await FileConn(c).list_path_files(lfss_path, flat = True if depth == "infinity" else False)
238
+ for frecord in flist:
239
+ if frecord.url.split("/")[-1] == MKDIR_PLACEHOLDER: continue
240
+ file_el = await create_file_xml_element(frecord)
241
+ multistatus.append(file_el)
242
+
243
+ async with unique_cursor() as c:
244
+ drecords = await FileConn(c).list_path_dirs(lfss_path)
245
+ for drecord in drecords:
246
+ dir_el = await create_dir_xml_element(drecord)
247
+ multistatus.append(dir_el)
248
+
249
+ elif path_type == "file":
250
+ assert isinstance(record, FileRecord)
251
+ file_el = await create_file_xml_element(record)
252
+ multistatus.append(file_el)
253
+
254
+ else:
255
+ raise PathNotFoundError(path)
256
+
257
+ xml_response = ET.tostring(multistatus, encoding="utf-8", method="xml")
258
+ return Response(content=xml_response, media_type="application/xml", status_code=return_status)
259
+
260
+ @router_dav.api_route("/{path:path}", methods=["MKCOL"])
261
+ @handle_exception
262
+ async def dav_mkcol(path: str, user: UserRecord = Depends(registered_user)):
263
+ # TODO: implement MKCOL more elegantly
264
+ if path.endswith("/"): path = path[:-1] # make sure returned path is a file
265
+ ptype, lfss_path, _ = await eval_path(path)
266
+ if not ptype is None:
267
+ raise HTTPException(status_code=409, detail="Conflict")
268
+ logger.info(f"MKCOL {path}")
269
+ fpath = lfss_path + "/" + MKDIR_PLACEHOLDER
270
+ async def _ustream():
271
+ yield b""
272
+ await db.save_file(user.username, fpath, _ustream())
273
+ return Response(status_code=201)
274
+
275
+ @router_dav.api_route("/{path:path}", methods=["MOVE"])
276
+ @handle_exception
277
+ async def dav_move(request: Request, path: str, user: UserRecord = Depends(registered_user)):
278
+ destination = request.headers.get("Destination")
279
+ if not destination:
280
+ raise HTTPException(status_code=400, detail="Destination header is required")
281
+
282
+ ptype, lfss_path, _ = await eval_path(path)
283
+ if ptype is None:
284
+ raise PathNotFoundError(path)
285
+ dptype, dlfss_path, ddav_path = await eval_path(destination)
286
+ if dptype is not None:
287
+ raise HTTPException(status_code=409, detail="Conflict")
288
+
289
+ logger.info(f"MOVE {path} -> {destination}")
290
+ if ptype == "file":
291
+ assert not lfss_path.endswith("/"), "File path should not end with /"
292
+ assert not dlfss_path.endswith("/"), "File path should not end with /"
293
+ await db.move_file(lfss_path, dlfss_path, user)
294
+ else:
295
+ assert ptype == "dir", "Directory path should end with /"
296
+ assert lfss_path.endswith("/"), "Directory path should end with /"
297
+ if not dlfss_path.endswith("/"): dlfss_path += "/" # the header destination may not end with /
298
+ await db.move_path(lfss_path, dlfss_path, user)
299
+ return Response(status_code=201)
300
+
301
+ @router_dav.api_route("/{path:path}", methods=["COPY"])
302
+ @handle_exception
303
+ async def dav_copy(request: Request, path: str, user: UserRecord = Depends(registered_user)):
304
+ destination = request.headers.get("Destination")
305
+ if not destination:
306
+ raise HTTPException(status_code=400, detail="Destination header is required")
307
+
308
+ ptype, lfss_path, _ = await eval_path(path)
309
+ if ptype is None:
310
+ raise PathNotFoundError(path)
311
+ dptype, dlfss_path, ddav_path = await eval_path(destination)
312
+ if dptype is not None:
313
+ raise HTTPException(status_code=409, detail="Conflict")
314
+
315
+ logger.info(f"COPY {path} -> {destination}")
316
+ if ptype == "file":
317
+ assert not lfss_path.endswith("/"), "File path should not end with /"
318
+ assert not dlfss_path.endswith("/"), "File path should not end with /"
319
+ await db.copy_file(lfss_path, dlfss_path, user)
320
+ else:
321
+ assert ptype == "dir", "Directory path should end with /"
322
+ assert lfss_path.endswith("/"), "Directory path should end with /"
323
+ assert dlfss_path.endswith("/"), "Directory path should end with /"
324
+ await db.copy_path(lfss_path, dlfss_path, user)
325
+ return Response(status_code=201)
326
+
327
+ @router_dav.api_route("/{path:path}", methods=["LOCK"])
328
+ @handle_exception
329
+ async def dav_lock(request: Request, path: str, user: UserRecord = Depends(registered_user)):
330
+ raw_timeout = request.headers.get("Timeout", "Second-3600")
331
+ if raw_timeout == "Infinite": timeout = -1
332
+ else:
333
+ if not raw_timeout.startswith("Second-"):
334
+ raise HTTPException(status_code=400, detail="Bad Request, invalid timeout: " + raw_timeout + ", expected Second-<seconds> or Infinite")
335
+ _, timeout_str = raw_timeout.split("-")
336
+ timeout = int(timeout_str)
337
+
338
+ _, path, _ = await eval_path(path)
339
+ # lock_token = f"opaquelocktoken:{uuid.uuid4().hex}"
340
+ lock_token = f"urn:uuid:{uuid.uuid4()}"
341
+ logger.info(f"LOCK {path} (timeout: {timeout}), token: {lock_token}")
342
+ await lock_path(user, path, lock_token, timeout=timeout)
343
+ response_elem = ET.Element(f"{{{DAV_NS}}}prop")
344
+ lockdiscovery = ET.SubElement(response_elem, f"{{{DAV_NS}}}lockdiscovery")
345
+ activelock = await query_lock_el(path, top_el_name=f"{{{DAV_NS}}}activelock")
346
+ assert activelock is not None, "Lock info should not be None"
347
+ lockdiscovery.append(activelock)
348
+ lock_response = ET.tostring(response_elem, encoding="utf-8", method="xml")
349
+ return Response(content=lock_response, media_type="application/xml", status_code=201, headers={
350
+ "Lock-Token": f"<{lock_token}>"
351
+ })
352
+
353
+ @router_dav.api_route("/{path:path}", methods=["UNLOCK"])
354
+ @handle_exception
355
+ async def dav_unlock(request: Request, path: str, user: UserRecord = Depends(registered_user)):
356
+ lock_token = request.headers.get("Lock-Token")
357
+ if not lock_token:
358
+ raise HTTPException(status_code=400, detail="Lock-Token header is required")
359
+ if lock_token.startswith("<") and lock_token.endswith(">"):
360
+ lock_token = lock_token[1:-1]
361
+ logger.info(f"UNLOCK {path}, token: {lock_token}")
362
+ _, path, _ = await eval_path(path)
363
+ await unlock_path(user, path, lock_token)
364
+ return Response(status_code=204)
365
+
366
+ @router_dav.api_route("/{path:path}", methods=["PROPPATCH"])
367
+ @handle_exception
368
+ async def dav_proppatch(request: Request, path: str, user: UserRecord = Depends(registered_user), body: ET.Element = Depends(xml_request_body)):
369
+ # TODO: implement PROPPATCH
370
+ print("PROPPATCH", path, body)
371
+ multistatus = ET.Element(f"{{{DAV_NS}}}multistatus")
372
+ return Response(content=ET.tostring(multistatus, encoding="utf-8", method="xml"), media_type="application/xml", status_code=207)
373
+
374
+ __all__ = ["router_dav"]
lfss/svc/app_native.py ADDED
@@ -0,0 +1,247 @@
1
+ from typing import Optional, Literal
2
+
3
+ from fastapi import Depends, Request, Response, UploadFile
4
+ from fastapi.exceptions import HTTPException
5
+
6
+ from ..eng.config import MAX_BUNDLE_BYTES
7
+ from ..eng.utils import ensure_uri_compnents
8
+ from ..eng.connection_pool import unique_cursor
9
+ from ..eng.database import check_file_read_permission, check_path_permission, UserConn, FileConn
10
+ from ..eng.datatype import (
11
+ FileReadPermission, FileRecord, UserRecord, AccessLevel,
12
+ FileSortKey, DirSortKey
13
+ )
14
+
15
+ from .app_base import *
16
+ from .common_impl import get_file_impl, put_file_impl, post_file_impl, delete_file_impl
17
+
18
+ @router_fs.get("/{path:path}")
19
+ @handle_exception
20
+ async def get_file(
21
+ request: Request,
22
+ path: str,
23
+ download: bool = False, thumb: bool = False,
24
+ user: UserRecord = Depends(get_current_user)
25
+ ):
26
+ return await get_file_impl(
27
+ request = request,
28
+ user = user, path = path, download = download, thumb = thumb
29
+ )
30
+
31
+ @router_fs.head("/{path:path}")
32
+ @handle_exception
33
+ async def head_file(
34
+ request: Request,
35
+ path: str,
36
+ download: bool = False, thumb: bool = False,
37
+ user: UserRecord = Depends(get_current_user)
38
+ ):
39
+ if path.startswith("_api/"):
40
+ 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
+ request = request,
45
+ user = user, path = path, download = download, thumb = thumb, is_head = True
46
+ )
47
+
48
+ @router_fs.put("/{path:path}")
49
+ @handle_exception
50
+ async def put_file(
51
+ request: Request,
52
+ path: str,
53
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
54
+ permission: int = 0,
55
+ user: UserRecord = Depends(registered_user)
56
+ ):
57
+ return await put_file_impl(
58
+ request = request, user = user, path = path, conflict = conflict, permission = permission
59
+ )
60
+
61
+ # using form-data instead of raw body
62
+ @router_fs.post("/{path:path}")
63
+ @handle_exception
64
+ async def post_file(
65
+ path: str,
66
+ file: UploadFile,
67
+ conflict: Literal["overwrite", "skip", "abort"] = "abort",
68
+ permission: int = 0,
69
+ user: UserRecord = Depends(registered_user)
70
+ ):
71
+ return await post_file_impl(
72
+ file = file, user = user, path = path, conflict = conflict, permission = permission
73
+ )
74
+
75
+ @router_fs.delete("/{path:path}")
76
+ @handle_exception
77
+ async def delete_file(path: str, user: UserRecord = Depends(registered_user)):
78
+ return await delete_file_impl(path, user)
79
+
80
+
81
+ @router_api.get("/bundle")
82
+ @handle_exception
83
+ async def bundle_files(path: str, user: UserRecord = Depends(registered_user)):
84
+ logger.info(f"GET bundle({path}), user: {user.username}")
85
+ 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
89
+ path = path[1:]
90
+
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
+ )
128
+
129
+ @router_api.get("/meta")
130
+ @handle_exception
131
+ async def get_file_meta(path: str, user: UserRecord = Depends(registered_user)):
132
+ logger.info(f"GET meta({path}), user: {user.username}")
133
+ path = ensure_uri_compnents(path)
134
+ is_file = not path.endswith("/")
135
+ async with unique_cursor() as cur:
136
+ fconn = FileConn(cur)
137
+ if is_file:
138
+ record = await fconn.get_file_record(path, throw=True)
139
+ 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)
143
+ if not is_allowed:
144
+ raise HTTPException(status_code=403, detail=reason)
145
+ else:
146
+ if await check_path_permission(path, user, cursor=cur) < AccessLevel.READ:
147
+ raise HTTPException(status_code=403, detail="Permission denied")
148
+ record = await fconn.get_path_record(path)
149
+ return record
150
+
151
+ @router_api.post("/meta")
152
+ @handle_exception
153
+ async def update_file_meta(
154
+ path: str,
155
+ perm: Optional[int] = None,
156
+ new_path: Optional[str] = None,
157
+ user: UserRecord = Depends(registered_user)
158
+ ):
159
+ path = ensure_uri_compnents(path)
160
+ if path.startswith("/"):
161
+ path = path[1:]
162
+
163
+ # file
164
+ if not path.endswith("/"):
165
+ if perm is not None:
166
+ logger.info(f"Update permission of {path} to {perm}")
167
+ await db.update_file_record(
168
+ url = path,
169
+ permission = FileReadPermission(perm),
170
+ op_user = user,
171
+ )
172
+
173
+ if new_path is not None:
174
+ new_path = ensure_uri_compnents(new_path)
175
+ logger.info(f"Update path of {path} to {new_path}")
176
+ await db.move_file(path, new_path, user)
177
+
178
+ # directory
179
+ else:
180
+ assert perm is None, "Permission is not supported for directory"
181
+ if new_path is not None:
182
+ new_path = ensure_uri_compnents(new_path)
183
+ logger.info(f"Update path of {path} to {new_path}")
184
+ # currently only move own file, with overwrite
185
+ await db.move_path(path, new_path, user)
186
+
187
+ return Response(status_code=200, content="OK")
188
+
189
+ async def validate_path_read_permission(path: str, user: UserRecord):
190
+ if not path.endswith("/"):
191
+ raise HTTPException(status_code=400, detail="Path must end with /")
192
+ if not await check_path_permission(path, user) >= AccessLevel.READ:
193
+ raise HTTPException(status_code=403, detail="Permission denied")
194
+ @router_api.get("/count-files")
195
+ async def count_files(path: str, flat: bool = False, user: UserRecord = Depends(registered_user)):
196
+ await validate_path_read_permission(path, user)
197
+ path = ensure_uri_compnents(path)
198
+ async with unique_cursor() as conn:
199
+ fconn = FileConn(conn)
200
+ return { "count": await fconn.count_path_files(url = path, flat = flat) }
201
+ @router_api.get("/list-files")
202
+ async def list_files(
203
+ path: str, offset: int = 0, limit: int = 1000,
204
+ order_by: FileSortKey = "", order_desc: bool = False,
205
+ flat: bool = False, user: UserRecord = Depends(registered_user)
206
+ ):
207
+ await validate_path_read_permission(path, user)
208
+ path = ensure_uri_compnents(path)
209
+ async with unique_cursor() as conn:
210
+ fconn = FileConn(conn)
211
+ return await fconn.list_path_files(
212
+ url = path, offset = offset, limit = limit,
213
+ order_by=order_by, order_desc=order_desc,
214
+ flat=flat
215
+ )
216
+
217
+ @router_api.get("/count-dirs")
218
+ async def count_dirs(path: str, user: UserRecord = Depends(registered_user)):
219
+ await validate_path_read_permission(path, user)
220
+ path = ensure_uri_compnents(path)
221
+ async with unique_cursor() as conn:
222
+ fconn = FileConn(conn)
223
+ return { "count": await fconn.count_path_dirs(url = path) }
224
+ @router_api.get("/list-dirs")
225
+ async def list_dirs(
226
+ path: str, offset: int = 0, limit: int = 1000,
227
+ order_by: DirSortKey = "", order_desc: bool = False,
228
+ skim: bool = True, user: UserRecord = Depends(registered_user)
229
+ ):
230
+ await validate_path_read_permission(path, user)
231
+ path = ensure_uri_compnents(path)
232
+ async with unique_cursor() as conn:
233
+ fconn = FileConn(conn)
234
+ return await fconn.list_path_dirs(
235
+ url = path, offset = offset, limit = limit,
236
+ order_by=order_by, order_desc=order_desc, skim=skim
237
+ )
238
+
239
+ @router_api.get("/whoami")
240
+ @handle_exception
241
+ async def whoami(user: UserRecord = Depends(registered_user)):
242
+ user.credential = "__HIDDEN__"
243
+ return user
244
+
245
+ __all__ = [
246
+ "app", "router_api", "router_fs"
247
+ ]