Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: extend File.write() to accept a rootnode and foldername #1308

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 121 additions & 74 deletions src/viur/core/modules/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
from viur.core.skeleton import SkeletonInstance, skeletonByKind
from viur.core.tasks import CallDeferred, DeleteEntitiesIter, PeriodicTask


# Globals for connectivity

VALID_FILENAME_REGEX = re.compile(
Expand Down Expand Up @@ -498,11 +497,11 @@ def hmac_verify(data: t.Any, signature: str) -> bool:

@staticmethod
def create_internal_serving_url(
serving_url: str,
size: int = 0,
filename: str = "",
options: str = "",
download: bool = False
serving_url: str,
size: int = 0,
filename: str = "",
options: str = "",
download: bool = False
) -> str:
"""
Helper function to generate an internal serving url (endpoint: /file/serve) from a Google serving url.
Expand Down Expand Up @@ -531,24 +530,24 @@ def create_internal_serving_url(

# Append additional parameters
if params := {
k: v for k, v in {
"download": download,
"filename": filename,
"options": options,
"size": size,
}.items() if v
k: v for k, v in {
"download": download,
"filename": filename,
"options": options,
"size": size,
}.items() if v
}:
serving_url += f"?{urlencode(params)}"

return serving_url

@staticmethod
def create_download_url(
dlkey: str,
filename: str,
derived: bool = False,
expires: t.Optional[datetime.timedelta | int] = datetime.timedelta(hours=1),
download_filename: t.Optional[str] = None
dlkey: str,
filename: str,
derived: bool = False,
expires: t.Optional[datetime.timedelta | int] = datetime.timedelta(hours=1),
download_filename: t.Optional[str] = None
) -> str:
"""
Utility function that creates a signed download-url for the given folder/filename combination
Expand Down Expand Up @@ -629,11 +628,11 @@ def parse_download_url(url) -> t.Optional[FilePath]:

@staticmethod
def create_src_set(
file: t.Union["SkeletonInstance", dict, str],
expires: t.Optional[datetime.timedelta | int] = datetime.timedelta(hours=1),
width: t.Optional[int] = None,
height: t.Optional[int] = None,
language: t.Optional[str] = None,
file: t.Union["SkeletonInstance", dict, str],
expires: t.Optional[datetime.timedelta | int] = datetime.timedelta(hours=1),
width: t.Optional[int] = None,
height: t.Optional[int] = None,
language: t.Optional[str] = None,
) -> str:
"""
Generates a string suitable for use as the srcset tag in html. This functionality provides the browser
Expand Down Expand Up @@ -673,9 +672,9 @@ def create_src_set(
from viur.core.skeleton import SkeletonInstance # avoid circular imports

if not (
isinstance(file, (SkeletonInstance, dict))
and "dlkey" in file
and "derived" in file
isinstance(file, (SkeletonInstance, dict))
and "dlkey" in file
and "derived" in file
):
logging.error("Invalid file supplied")
return ""
Expand All @@ -701,57 +700,105 @@ def create_src_set(
return ", ".join(src_set)

def write(
self,
filename: str,
content: t.Any,
mimetype: str = "text/plain",
width: int = None,
height: int = None,
public: bool = False,
self,
filename: str,
content: t.Any,
mimetype: str = "text/plain",
width: int = None,
height: int = None,
public: bool = False,
foldername: t.Optional[str | t.Iterable[str]] = None,
rootnode: t.Optional[db.Key] = None,
) -> db.Key:
"""
Write a file from any buffer into the file module.
Write a file from any bytes-like object into the file module.

If foldername and rootnode are both set, the file is added to the repository in that folder.
If only foldername is set, the file is added to the default repository in that folder.
If only rootnode is set, the file is added to that repository in the root folder.
If both are not set, the file is added without a path or repository. It will not be visible in admin.

:param filename: Filename to be written.
:param content: The file content to be written, as bytes-like object.
:param mimetype: The file's mimetype.
:param width: Optional width information for the file.
:param height: Optional height information for the file.
:param public: True if the file should be publicly accessible.
:param foldername: Optional folder the file should be written into.
:param rootnode: Optional root-node of the repository to add the file to
:return: Returns the key of the file object written. This can be associated e.g. with a FileBone.
"""
if not File.is_valid_filename(filename):
if not self.is_valid_filename(filename):
raise ValueError(f"{filename=} is invalid")

# Check for foldername
if rootnode is None:
rootnode = self.ensureOwnModuleRootNode()
elif not foldername:
# if rootnode is set and foldername is not, save the file in the root of the rootnode
foldername = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like tuples ;)

Suggested change
foldername = []
foldername = ()


if foldername is not None:
foldernames = foldername
if isinstance(foldername, str):
foldernames = foldernames.replace('\\', '/')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if we should handle this case. What do the others think? @sveneberth?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this write method is only for internal usage and we don't expect pathes from client users I don't see the necessity of it.

foldernames = (i for i in foldernames.split('/'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
foldernames = (i for i in foldernames.split('/'))
foldernames = foldernames.split("/")

foldernames = (i for i in foldernames if i)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
foldernames = (i for i in foldernames if i)
foldernames = tuple(foldernames) # ensure tuple, run any iterators


parentrepokey = rootnode.key
parentfolderkey = rootnode.key

for fname in foldernames:
currentfolder = (self.addSkel("node").all()
.filter("parentrepo", parentrepokey)
.filter("parententry", parentfolderkey)
.filter("name", fname)
.getSkel())
if currentfolder:
parentfolderkey = currentfolder.key
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since getSkel() returns an SkeletonInstance, skel.key would be a the KeyBone key (or am I wrong?).

Suggested change
parentfolderkey = currentfolder.key
parentfolderkey = currentfolder["key"]

else:
newfolderskel = self.addSkel("node")
newfolderskel["name"] = fname
newfolderskel["parentrepo"] = parentrepokey
newfolderskel["parententry"] = parentfolderkey
newfolderskel.write()
parentfolderkey = newfolderskel["key"]

# Save the file into the folder.
dl_key = utils.string.random()

if public:
dl_key += PUBLIC_DLKEY_SUFFIX # mark file as public

bucket = File.get_bucket(dl_key)
bucket = self.get_bucket(dl_key)

blob = bucket.blob(f"{dl_key}/source/{filename}")
blob.upload_from_file(io.BytesIO(content), content_type=mimetype)

skel = self.addSkel("leaf")
skel["name"] = filename
skel["size"] = blob.size
skel["mimetype"] = mimetype
skel["dlkey"] = dl_key
skel["weak"] = True
skel["public"] = public
skel["width"] = width
skel["height"] = height
skel["crc32c_checksum"] = base64.b64decode(blob.crc32c).hex()
skel["md5_checksum"] = base64.b64decode(blob.md5_hash).hex()

skel.write()
return skel["key"]
fileskel = self.addSkel("leaf")
fileskel["name"] = filename
fileskel["size"] = blob.size
fileskel["mimetype"] = mimetype
fileskel["dlkey"] = dl_key
fileskel["weak"] = foldername is None
fileskel["public"] = public
fileskel["width"] = width
fileskel["height"] = height
fileskel["crc32c_checksum"] = base64.b64decode(blob.crc32c).hex()
fileskel["md5_checksum"] = base64.b64decode(blob.md5_hash).hex()
fileskel["pending"] = False

bb-mausbrand marked this conversation as resolved.
Show resolved Hide resolved
if foldername is not None:
fileskel["parentrepo"] = parentrepokey
fileskel["parententry"] = parentfolderkey

fileskel.write()
return fileskel["key"]

def read(
self,
key: db.Key | int | str | None = None,
path: str | None = None,
self,
key: db.Key | int | str | None = None,
path: str | None = None,
) -> tuple[io.BytesIO, str]:
"""
Read a file from the Cloud Storage.
Expand Down Expand Up @@ -801,14 +848,14 @@ def deleteRecursive(self, parentKey):
@exposed
@skey
def getUploadURL(
self,
fileName: str,
mimeType: str,
size: t.Optional[int] = None,
node: t.Optional[str | db.Key] = None,
authData: t.Optional[str] = None,
authSig: t.Optional[str] = None,
public: bool = False,
self,
fileName: str,
mimeType: str,
size: t.Optional[int] = None,
node: t.Optional[str | db.Key] = None,
authData: t.Optional[str] = None,
authSig: t.Optional[str] = None,
public: bool = False,
):
filename = fileName.strip() # VIUR4 FIXME: just for compatiblity of the parameter names

Expand All @@ -818,9 +865,9 @@ def getUploadURL(
# Validate the mimetype from the client seems legit
mimetype = mimeType.strip().lower()
if not (
mimetype
and mimetype.count("/") == 1
and all(ch in string.ascii_letters + string.digits + "/-.+" for ch in mimetype)
mimetype
and mimetype.count("/") == 1
and all(ch in string.ascii_letters + string.digits + "/-.+" for ch in mimetype)
):
raise errors.UnprocessableEntity(f"Invalid mime-type {mimetype!r} provided")

Expand All @@ -838,8 +885,8 @@ def getUploadURL(
if authData["validMimeTypes"]:
for validMimeType in authData["validMimeTypes"]:
if (
validMimeType == mimetype
or (validMimeType.endswith("*") and mimetype.startswith(validMimeType[:-1]))
validMimeType == mimetype
or (validMimeType.endswith("*") and mimetype.startswith(validMimeType[:-1]))
):
break
else:
Expand Down Expand Up @@ -1031,13 +1078,13 @@ def download(self, blobKey: str, fileName: str = "", download: bool = False, sig

@exposed
def serve(
self,
host: str,
key: str,
size: t.Optional[int] = None,
filename: t.Optional[str] = None,
options: str = "",
download: bool = False,
self,
host: str,
key: str,
size: t.Optional[int] = None,
filename: t.Optional[str] = None,
options: str = "",
download: bool = False,
):
"""
Requests an image using the serving url to bypass direct Google requests.
Expand Down Expand Up @@ -1332,8 +1379,8 @@ def start_delete_pending_files():

def __getattr__(attr: str) -> object:
if entry := {
# stuff prior viur-core < 3.7
"GOOGLE_STORAGE_BUCKET": ("File.get_bucket()", _private_bucket),
# stuff prior viur-core < 3.7
"GOOGLE_STORAGE_BUCKET": ("File.get_bucket()", _private_bucket),
}.get(attr):
msg = f"{attr} was replaced by {entry[0]}"
warnings.warn(msg, DeprecationWarning, stacklevel=2)
Expand Down
Loading