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.
- Readme.md +6 -1
- docs/Webdav.md +22 -0
- lfss/api/__init__.py +3 -3
- lfss/api/connector.py +3 -3
- lfss/cli/balance.py +3 -3
- lfss/cli/cli.py +2 -2
- lfss/cli/panel.py +8 -0
- lfss/cli/serve.py +4 -2
- lfss/cli/user.py +4 -4
- lfss/cli/vacuum.py +5 -5
- lfss/{src → eng}/config.py +1 -0
- lfss/{src → eng}/database.py +99 -4
- lfss/{src → eng}/error.py +4 -0
- lfss/{src → eng}/thumb.py +10 -9
- lfss/{src → eng}/utils.py +9 -1
- lfss/svc/app.py +9 -0
- lfss/svc/app_base.py +152 -0
- lfss/svc/app_dav.py +374 -0
- lfss/svc/app_native.py +247 -0
- lfss/svc/common_impl.py +270 -0
- lfss/{src/stat.py → svc/request_log.py} +2 -2
- {lfss-0.9.0.dist-info → lfss-0.9.1.dist-info}/METADATA +9 -4
- lfss-0.9.1.dist-info/RECORD +49 -0
- lfss/src/server.py +0 -604
- lfss-0.9.0.dist-info/RECORD +0 -44
- /lfss/{src → eng}/__init__.py +0 -0
- /lfss/{src → eng}/bounded_pool.py +0 -0
- /lfss/{src → eng}/connection_pool.py +0 -0
- /lfss/{src → eng}/datatype.py +0 -0
- /lfss/{src → eng}/log.py +0 -0
- {lfss-0.9.0.dist-info → lfss-0.9.1.dist-info}/WHEEL +0 -0
- {lfss-0.9.0.dist-info → lfss-0.9.1.dist-info}/entry_points.txt +0 -0
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
|
+
]
|