Skip to content

Commit a0f8a31

Browse files
bigcat88Review
andauthored
feat: expose S3 presigned download URL on FsNode (#419)
Add download_url and download_url_expiration properties to `FsNodeInfo`. The `oc:downloadURL` property was already requested via PROPFIND but never parsed - now it is. Also request` nc:download-url-expiration` property. When the Nextcloud storage backend is S3 with use_presigned_url enabled, these provide a direct download URL bypassing the Nextcloud proxy. Returns empty string / zero when not available (non-S3 backends). Closes #418 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * File and folder info now include temporary direct-download URLs and associated expiration timestamps so clients can offer time-limited download links and surface availability information to users. * **Tests** * Added unit tests verifying defaults, preservation of provided download info, correct parsing of server-provided values, and graceful handling of malformed expiration values. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Review <review@local>
1 parent ef519ae commit a0f8a31

3 files changed

Lines changed: 118 additions & 1 deletion

File tree

nc_py_api/files/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ def __init__(self, **kwargs):
107107
self.creation_date = kwargs.get("creation_date", datetime.datetime(1970, 1, 1))
108108
except (ValueError, TypeError):
109109
self.creation_date = datetime.datetime(1970, 1, 1)
110+
self._download_url: str = kwargs.get("download_url", "")
111+
self._download_url_expiration: int = kwargs.get("download_url_expiration", 0)
110112
self._trashbin: dict[str, str | int] = {}
111113
for i in ("trashbin_filename", "trashbin_original_location", "trashbin_deletion_time"):
112114
if i in kwargs:
@@ -132,6 +134,20 @@ def permissions(self) -> str:
132134
"""Permissions for the object."""
133135
return self._raw_data["permissions"]
134136

137+
@property
138+
def download_url(self) -> str:
139+
"""S3 presigned URL for direct download, bypassing Nextcloud.
140+
141+
Only available when the storage backend is S3 with ``use_presigned_url`` enabled.
142+
Empty string when not available.
143+
"""
144+
return self._download_url
145+
146+
@property
147+
def download_url_expiration(self) -> int:
148+
"""Expiration timestamp for :py:attr:`download_url`. Zero when not available."""
149+
return self._download_url_expiration
150+
135151
@property
136152
def last_modified(self) -> datetime.datetime:
137153
"""Time when the object was last modified.

nc_py_api/files/_files.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Helper functions for **FilesAPI** and **AsyncFilesAPI** classes."""
22

3+
import contextlib
34
import enum
45
from datetime import datetime, timezone
56
from io import BytesIO
@@ -26,6 +27,7 @@
2627
"oc:id",
2728
"oc:fileid",
2829
"oc:downloadURL",
30+
"nc:download-url-expiration",
2931
"oc:dDC",
3032
"oc:permissions",
3133
"oc:checksums",
@@ -306,6 +308,13 @@ def _parse_record(full_path: str, prop_stats: list[dict]) -> FsNode: # noqa pyl
306308
fs_node_args["mimetype"] = prop["d:getcontenttype"]
307309
if "oc:permissions" in prop_keys:
308310
fs_node_args["permissions"] = prop["oc:permissions"]
311+
if "oc:downloadURL" in prop_keys:
312+
_download_url = prop["oc:downloadURL"]
313+
if isinstance(_download_url, str) and _download_url.lower() != "false" and _download_url:
314+
fs_node_args["download_url"] = _download_url
315+
if "nc:download-url-expiration" in prop_keys and prop["nc:download-url-expiration"]:
316+
with contextlib.suppress(TypeError, ValueError):
317+
fs_node_args["download_url_expiration"] = int(prop["nc:download-url-expiration"])
309318
if "oc:favorite" in prop_keys:
310319
fs_node_args["favorite"] = bool(int(prop["oc:favorite"]))
311320
if "nc:trashbin-filename" in prop_keys:
@@ -367,7 +376,7 @@ def _webdav_response_to_records(webdav_res: Response, info: str) -> list[dict]:
367376
if "d:error" in response_data:
368377
err = response_data["d:error"]
369378
raise NextcloudException(
370-
reason=f'{err["s:exception"]}: {err["s:message"]}'.replace("\n", ""), info=info, response=webdav_res
379+
reason=f"{err['s:exception']}: {err['s:message']}".replace("\n", ""), info=info, response=webdav_res
371380
)
372381
response = response_data["d:multistatus"].get("d:response", [])
373382
return [response] if isinstance(response, dict) else response

tests_unit/test_download_url.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Tests for S3 presigned download URL properties on FsNode/FsNodeInfo."""
2+
3+
from nc_py_api.files import FsNode, FsNodeInfo
4+
from nc_py_api.files._files import _parse_record
5+
6+
7+
def test_fsnode_info_download_url_defaults():
8+
info = FsNodeInfo()
9+
assert info.download_url == ""
10+
assert info.download_url_expiration == 0
11+
12+
13+
def test_fsnode_info_download_url_with_values():
14+
info = FsNodeInfo(download_url="https://s3.example.com/bucket/obj?sig=abc", download_url_expiration=1700000000)
15+
assert info.download_url == "https://s3.example.com/bucket/obj?sig=abc"
16+
assert info.download_url_expiration == 1700000000
17+
18+
19+
def test_fsnode_passes_download_url_to_info():
20+
node = FsNode(
21+
"files/admin/test.txt", file_id="00000123", download_url="https://s3.test/f", download_url_expiration=9999
22+
)
23+
assert node.info.download_url == "https://s3.test/f"
24+
assert node.info.download_url_expiration == 9999
25+
26+
27+
def test_parse_record_with_download_url():
28+
prop_stat = {
29+
"d:status": "HTTP/1.1 200 OK",
30+
"d:prop": {
31+
"oc:id": "00000123",
32+
"oc:fileid": "123",
33+
"oc:permissions": "RGDNVW",
34+
"d:getetag": '"abc123"',
35+
"oc:downloadURL": "https://s3.example.com/bucket/urn:oid:123?X-Amz-Signature=abc",
36+
"nc:download-url-expiration": "1700000000",
37+
},
38+
}
39+
node = _parse_record("files/admin/test.txt", [prop_stat])
40+
assert node.info.download_url == "https://s3.example.com/bucket/urn:oid:123?X-Amz-Signature=abc"
41+
assert node.info.download_url_expiration == 1700000000
42+
43+
44+
def test_parse_record_without_download_url():
45+
prop_stat = {
46+
"d:status": "HTTP/1.1 200 OK",
47+
"d:prop": {
48+
"oc:id": "00000123",
49+
"oc:fileid": "123",
50+
"oc:permissions": "RGDNVW",
51+
"d:getetag": '"abc123"',
52+
},
53+
}
54+
node = _parse_record("files/admin/test.txt", [prop_stat])
55+
assert node.info.download_url == ""
56+
assert node.info.download_url_expiration == 0
57+
58+
59+
def test_parse_record_with_false_download_url():
60+
"""When storage doesn't support presigned URLs, server returns 'false'."""
61+
prop_stat = {
62+
"d:status": "HTTP/1.1 200 OK",
63+
"d:prop": {
64+
"oc:id": "00000123",
65+
"oc:fileid": "123",
66+
"oc:permissions": "RGDNVW",
67+
"d:getetag": '"abc123"',
68+
"oc:downloadURL": "false",
69+
"nc:download-url-expiration": "false",
70+
},
71+
}
72+
node = _parse_record("files/admin/test.txt", [prop_stat])
73+
assert node.info.download_url == ""
74+
assert node.info.download_url_expiration == 0
75+
76+
77+
def test_parse_record_with_malformed_expiration():
78+
"""Malformed expiration should not crash parsing."""
79+
prop_stat = {
80+
"d:status": "HTTP/1.1 200 OK",
81+
"d:prop": {
82+
"oc:id": "00000123",
83+
"oc:fileid": "123",
84+
"oc:permissions": "RGDNVW",
85+
"d:getetag": '"abc123"',
86+
"oc:downloadURL": "https://s3.test/f",
87+
"nc:download-url-expiration": "not-a-number",
88+
},
89+
}
90+
node = _parse_record("files/admin/test.txt", [prop_stat])
91+
assert node.info.download_url == "https://s3.test/f"
92+
assert node.info.download_url_expiration == 0

0 commit comments

Comments
 (0)