Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/modules/file.py: 30%
767 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 10:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-27 10:50 +0000
1import base64
2import datetime
3import google.auth
4import hashlib
5import hmac
6import html
7import io
8import json
9import logging
10import PIL
11import PIL.Image
12import PIL.ImageCms
13import re
14import requests
15import string
16import typing as t
17import warnings
19from collections import namedtuple
20from urllib.parse import parse_qs, quote as urlquote, unquote as urlunquote, urlsplit
21from urllib.request import urlopen
22from google.appengine.api import blobstore, images
23from google.cloud import storage
24from google.oauth2.service_account import Credentials as ServiceAccountCredentials
26from viur.core import conf, current, db, errors, utils, i18n
27from viur.core.bones import (
28 BaseBone,
29 BooleanBone,
30 Compute,
31 ComputeInterval,
32 ComputeMethod,
33 JsonBone,
34 KeyBone,
35 NumericBone,
36 StringBone,
37)
38from viur.core.decorators import exposed, force_post, force_ssl, skey
39from viur.core.prototypes.tree import SkelType, Tree, TreeSkel
40from viur.core.skeleton import SkeletonInstance, skeletonByKind
41from viur.core.tasks import CallDeferred, DeleteEntitiesIter, PeriodicTask
43# Globals for connectivity
45VALID_FILENAME_REGEX = re.compile(
46 # || MAY NOT BE THE NAME | MADE OF SPECIAL CHARS | SPECIAL CHARS + `. `|`
47 r"^(?!^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)[^\x00-\x1F<>:\"\/\\|?*]*[^\x00-\x1F<>:\"\/\\|?*. ]$",
48 re.IGNORECASE
49)
51_CREDENTIALS, _PROJECT_ID = google.auth.default()
52GOOGLE_STORAGE_CLIENT = storage.Client(_PROJECT_ID, _CREDENTIALS)
54PRIVATE_BUCKET_NAME = f"""{_PROJECT_ID}.appspot.com"""
55PUBLIC_BUCKET_NAME = f"""public-dot-{_PROJECT_ID}"""
56PUBLIC_DLKEY_SUFFIX = "_pub"
58_private_bucket = GOOGLE_STORAGE_CLIENT.lookup_bucket(PRIVATE_BUCKET_NAME)
59_public_bucket = None
61# FilePath is a descriptor for ViUR file components
62FilePath = namedtuple("FilePath", ("dlkey", "is_derived", "filename"))
65def importBlobFromViur2(dlKey, fileName):
66 bucket = conf.main_app.file.get_bucket(dlKey)
68 if not conf.viur2import_blobsource:
69 return False
70 existingImport = db.get(db.Key("viur-viur2-blobimport", dlKey))
71 if existingImport:
72 if existingImport["success"]:
73 return existingImport["dlurl"]
74 return False
75 if conf.viur2import_blobsource["infoURL"]:
76 try:
77 importDataReq = urlopen(conf.viur2import_blobsource["infoURL"] + dlKey)
78 except Exception as e:
79 marker = db.Entity(db.Key("viur-viur2-blobimport", dlKey))
80 marker["success"] = False
81 marker["error"] = "Failed URL-FETCH 1"
82 db.put(marker)
83 return False
84 if importDataReq.status != 200:
85 marker = db.Entity(db.Key("viur-viur2-blobimport", dlKey))
86 marker["success"] = False
87 marker["error"] = "Failed URL-FETCH 2"
88 db.put(marker)
89 return False
90 importData = json.loads(importDataReq.read())
91 oldBlobName = conf.viur2import_blobsource["gsdir"] + "/" + importData["key"]
92 srcBlob = storage.Blob(bucket=bucket,
93 name=conf.viur2import_blobsource["gsdir"] + "/" + importData["key"])
94 else:
95 oldBlobName = conf.viur2import_blobsource["gsdir"] + "/" + dlKey
96 srcBlob = storage.Blob(bucket=bucket, name=conf.viur2import_blobsource["gsdir"] + "/" + dlKey)
97 if not srcBlob.exists():
98 marker = db.Entity(db.Key("viur-viur2-blobimport", dlKey))
99 marker["success"] = False
100 marker["error"] = "Local SRC-Blob missing"
101 marker["oldBlobName"] = oldBlobName
102 db.put(marker)
103 return False
104 bucket.rename_blob(srcBlob, f"{dlKey}/source/{fileName}")
105 marker = db.Entity(db.Key("viur-viur2-blobimport", dlKey))
106 marker["success"] = True
107 marker["old_src_key"] = dlKey
108 marker["old_src_name"] = fileName
109 marker["dlurl"] = conf.main_app.file.create_download_url(dlKey, fileName, False, None)
110 db.put(marker)
111 return marker["dlurl"]
114def thumbnailer(fileSkel, existingFiles, params):
115 file_name = html.unescape(fileSkel["name"])
116 bucket = conf.main_app.file.get_bucket(fileSkel["dlkey"])
118 blob = bucket.get_blob(f"""{fileSkel["dlkey"]}/source/{file_name}""")
119 if not blob:
120 logging.warning(f"""Blob {fileSkel["dlkey"]}/source/{file_name} is missing from cloud storage!""")
121 return
123 source = io.BytesIO()
124 blob.download_to_file(source)
126 result = []
128 for info in params:
129 # Read the image into PIL
130 try:
131 source.seek(0)
132 img = PIL.Image.open(source)
133 except PIL.Image.UnidentifiedImageError: # Can't load this image; so there's no need to try other resolutions
134 break
136 if icc_profile := img.info.get("icc_profile"):
137 # JPEGs might be encoded with a non-standard color-profile; we need to compensate for this if we convert
138 # to WEBp as we'll loose this color-profile information
139 f = io.BytesIO(icc_profile)
140 src_profile = PIL.ImageCms.ImageCmsProfile(f)
141 dst_profile = PIL.ImageCms.createProfile("sRGB")
142 try:
143 img = PIL.ImageCms.profileToProfile(
144 img,
145 inputProfile=src_profile,
146 outputProfile=dst_profile,
147 outputMode="RGBA" if img.has_transparency_data else "RGB")
148 except Exception as e:
149 logging.debug(f"{info=}")
150 logging.exception(e)
151 continue
153 file_extension = info.get("fileExtension", "webp")
154 mimetype = info.get("mimeType", "image/webp")
156 if "width" in info and "height" in info:
157 width = info["width"]
158 height = info["height"]
159 target_filename = f"thumbnail-{width}-{height}.{file_extension}"
161 elif "width" in info:
162 width = info["width"]
163 height = int((float(img.size[1]) * float(width / float(img.size[0]))))
164 target_filename = f"thumbnail-w{width}.{file_extension}"
166 else: # No default fallback - ignore
167 continue
169 # Create resized version of the source
170 target = io.BytesIO()
172 try:
173 img = img.resize((width, height), PIL.Image.LANCZOS)
174 except ValueError as e:
175 # Usually happens to some files, like TIFF-images.
176 logging.debug(f"{info=}")
177 logging.exception(e)
178 break
180 img.save(target, file_extension)
182 # Safe derived target file
183 target_size = target.tell()
184 target.seek(0)
185 target_blob = bucket.blob(f"""{fileSkel["dlkey"]}/derived/{target_filename}""")
186 target_blob.upload_from_file(target, content_type=mimetype)
188 result.append(
189 (target_filename, target_size, mimetype, {"mimetype": mimetype, "width": width, "height": height})
190 )
192 return result
195def cloudfunction_thumbnailer(fileSkel, existingFiles, params):
196 """External Thumbnailer for images.
198 The corresponding cloudfunction can be found here .
199 https://github.com/viur-framework/viur-cloudfunctions/tree/main/thumbnailer
201 You can use it like so:
202 main.py:
204 .. code-block:: python
206 from viur.core.modules.file import cloudfunction_thumbnailer
208 conf.file_thumbnailer_url = "https://xxxxx.cloudfunctions.net/imagerenderer"
209 conf.file_derivations = {"thumbnail": cloudfunction_thumbnailer}
211 conf.derives_pdf = {
212 "thumbnail": [{"width": 1920,"sites":"1,2"}]
213 }
215 skeletons/xxx.py:
216 .. code-block:: python
218 test = FileBone(derive=conf.derives_pdf)
219 """
221 if not conf.file_thumbnailer_url:
222 raise ValueError("conf.file_thumbnailer_url is not set")
224 bucket = conf.main_app.file.get_bucket(fileSkel["dlkey"])
226 def getsignedurl():
227 if conf.instance.is_dev_server:
228 signedUrl = conf.main_app.file.create_download_url(fileSkel["dlkey"], fileSkel["name"])
229 else:
230 path = f"""{fileSkel["dlkey"]}/source/{file_name}"""
231 if not (blob := bucket.get_blob(path)):
232 logging.warning(f"Blob {path} is missing from cloud storage!")
233 return None
234 authRequest = google.auth.transport.requests.Request()
235 expiresAt = datetime.datetime.now() + datetime.timedelta(seconds=60)
236 signing_credentials = google.auth.compute_engine.IDTokenCredentials(authRequest, "")
237 content_disposition = utils.build_content_disposition_header(fileSkel["name"])
238 signedUrl = blob.generate_signed_url(
239 expiresAt,
240 credentials=signing_credentials,
241 response_disposition=content_disposition,
242 version="v4")
243 return signedUrl
245 def make_request():
246 headers = {"Content-Type": "application/json"}
247 data_str = base64.b64encode(json.dumps(dataDict).encode("UTF-8"))
248 sig = conf.main_app.file.hmac_sign(data_str)
249 datadump = json.dumps({"dataStr": data_str.decode('ASCII'), "sign": sig})
250 resp = requests.post(conf.file_thumbnailer_url, data=datadump, headers=headers, allow_redirects=False)
251 if resp.status_code != 200: # Error Handling
252 match resp.status_code:
253 case 302:
254 # The problem is Google resposen 302 to an auth Site when the cloudfunction was not found
255 # https://cloud.google.com/functions/docs/troubleshooting#login
256 logging.error("Cloudfunction not found")
257 case 404:
258 logging.error("Cloudfunction not found")
259 case 403:
260 logging.error("No permission for the Cloudfunction")
261 case _:
262 logging.error(
263 f"cloudfunction_thumbnailer failed with code: {resp.status_code} and data: {resp.content}")
264 return
266 try:
267 response_data = resp.json()
268 except Exception as e:
269 logging.error(f"response could not be converted in json failed with: {e=}")
270 return
271 if "error" in response_data:
272 logging.error(f"cloudfunction_thumbnailer failed with: {response_data.get('error')}")
273 return
275 return response_data
277 file_name = html.unescape(fileSkel["name"])
279 if not (url := getsignedurl()):
280 return
281 dataDict = {
282 "url": url,
283 "name": fileSkel["name"],
284 "params": params,
285 "minetype": fileSkel["mimetype"],
286 "baseUrl": current.request.get().request.host_url.lower(),
287 "targetKey": fileSkel["dlkey"],
288 "nameOnly": True
289 }
290 if not (derivedData := make_request()):
291 return
293 uploadUrls = {}
294 for data in derivedData["values"]:
295 if not conf.main_app.file.is_valid_filename(data["name"]):
296 raise errors.UnprocessableEntity(f"""Invalid derived filename {data["name"]!r} provided""")
298 fileName = urlquote(data["name"])
299 blob = bucket.blob(f"""{fileSkel["dlkey"]}/derived/{fileName}""")
300 uploadUrls[fileSkel["dlkey"] + fileName] = blob.create_resumable_upload_session(timeout=60,
301 content_type=data["mimeType"])
303 if not (url := getsignedurl()):
304 return
306 dataDict["url"] = url
307 dataDict["nameOnly"] = False
308 dataDict["uploadUrls"] = uploadUrls
310 if not (derivedData := make_request()):
311 return
312 reslist = []
313 try:
314 for derived in derivedData["values"]:
315 for key, value in derived.items():
316 reslist.append((key, value["size"], value["mimetype"], value["customData"]))
318 except Exception as e:
319 logging.error(f"cloudfunction_thumbnailer failed with: {e=}")
320 return reslist
323class DownloadUrlBone(BaseBone):
324 """
325 This bone is used to inject a freshly signed download url into a FileSkel.
326 """
328 def unserialize(self, skel, name):
329 if "dlkey" in skel.dbEntity and "name" in skel.dbEntity:
330 skel.accessedValues[name] = conf.main_app.file.create_download_url(
331 skel["dlkey"], skel["name"], expires=conf.render_json_download_url_expiration
332 )
333 return True
335 return False
338class FileLeafSkel(TreeSkel):
339 """
340 Default file leaf skeleton.
341 """
342 kindName = "file"
344 name = StringBone(
345 descr="Filename",
346 caseSensitive=False,
347 searchable=True,
348 vfunc=lambda val: None if File.is_valid_filename(val) else "Invalid filename provided",
349 )
351 alt = StringBone(
352 descr=i18n.translate(
353 "viur.core.image.alt",
354 defaultText="Alternative description",
355 ),
356 searchable=True,
357 languages=conf.i18n.available_languages,
358 )
360 size = NumericBone(
361 descr="Filesize in Bytes",
362 readOnly=True,
363 searchable=True,
364 )
366 dlkey = StringBone(
367 descr="Download-Key",
368 readOnly=True,
369 )
371 mimetype = StringBone(
372 descr="MIME-Type",
373 readOnly=True,
374 )
376 weak = BooleanBone(
377 descr="Weak reference",
378 readOnly=True,
379 visible=False,
380 )
382 pending = BooleanBone(
383 descr="Pending upload",
384 readOnly=True,
385 visible=False,
386 defaultValue=False,
387 )
389 width = NumericBone(
390 descr="Width",
391 readOnly=True,
392 searchable=True,
393 )
395 height = NumericBone(
396 descr="Height",
397 readOnly=True,
398 searchable=True,
399 )
401 downloadUrl = DownloadUrlBone(
402 descr="Download-URL",
403 readOnly=True,
404 visible=False,
405 )
407 derived = JsonBone(
408 descr="Derived Files",
409 readOnly=True,
410 visible=False,
411 )
413 pendingparententry = KeyBone(
414 descr="Pending key Reference",
415 readOnly=True,
416 visible=False,
417 )
419 crc32c_checksum = StringBone(
420 descr="CRC32C checksum",
421 readOnly=True,
422 )
424 md5_checksum = StringBone(
425 descr="MD5 checksum",
426 readOnly=True,
427 )
429 public = BooleanBone(
430 descr="Public File",
431 readOnly=True,
432 defaultValue=False,
433 )
435 def _compute_serving_url(skel: SkeletonInstance):
436 """
437 Standard compute function for generating a Google Image Manipulation API serving URL for public files.
438 """
439 logging.info("_compute_serving_url")
440 if (
441 skel["public"]
442 and skel["mimetype"]
443 and skel["mimetype"].startswith("image/")
444 ):
445 bucket = File.get_bucket(skel["dlkey"])
446 filename = f"/gs/{bucket.name}/{skel['dlkey']}/source/{utils.string.unescape(skel['name'])}"
448 # Trying this on local development server will raise a
449 # `google.appengine.runtime.apiproxy_errors.RPCFailedError`
450 if conf.instance.is_dev_server:
451 logging.warning(f"Can't create serving_url for {filename!r} on local development server")
452 return
454 try:
455 # Generate Google Image Manipulation API Serving-URL
456 return images.get_serving_url(None, secure_url=True, filename=filename)
458 except Exception as e:
459 logging.warning(f"Failed to create serving_url for {filename!r} with exception {e!r}")
460 logging.exception(e)
462 return None
464 serving_url = StringBone(
465 descr="Serving-URL",
466 params={
467 "tooltip": "The 'serving_url' is only available in public file repositories.",
468 },
469 compute=Compute(_compute_serving_url, ComputeInterval(ComputeMethod.Once)),
470 )
472 def preProcessBlobLocks(self, locks):
473 """
474 Ensure that our dlkey is locked even if we don't have a filebone here
475 """
476 if not self["weak"] and self["dlkey"]:
477 locks.add(self["dlkey"])
479 return locks
482class FileNodeSkel(TreeSkel):
483 """
484 Default file node skeleton.
485 """
486 kindName = "file_rootNode" # FIXME: VIUR4, don't use "_rootNode" kindname
488 name = StringBone(
489 descr="Name",
490 required=True,
491 searchable=True
492 )
494 rootNode = BooleanBone(
495 descr="Is RootNode",
496 defaultValue=False,
497 readOnly=True,
498 visible=False,
499 )
501 public = BooleanBone(
502 descr="Is public?",
503 defaultValue=False,
504 readOnly=True,
505 visible=False,
506 )
508 viurCurrentSeoKeys = None
511class File(Tree):
512 PENDING_POSTFIX = " (pending)"
513 DOWNLOAD_URL_PREFIX = "/file/download/"
514 INTERNAL_SERVING_URL_PREFIX = "/file/serve/"
515 MAX_FILENAME_LEN = 256
516 IMAGE_META_MAX_SIZE: t.Final[int] = 10 * 1024 ** 2
517 """Maximum size of image files that should be analysed in :meth:`set_image_meta`.
518 Default: 10 MiB"""
520 leafSkelCls = FileLeafSkel
521 nodeSkelCls = FileNodeSkel
523 handler = "tree.simple.file"
524 adminInfo = {
525 "icon": "folder-fill",
526 "handler": handler, # fixme: Use static handler; Remove with VIUR4!
527 }
529 roles = {
530 "*": "view",
531 "editor": ("add", "edit"),
532 "admin": "*",
533 }
535 default_order = "name"
537 # Helper functions currently resist here
539 @staticmethod
540 def get_bucket(dlkey: str) -> google.cloud.storage.bucket.Bucket:
541 """
542 Retrieves a Google Cloud Storage bucket for the given dlkey.
543 """
544 global _public_bucket
545 if dlkey and dlkey.endswith(PUBLIC_DLKEY_SUFFIX):
546 if _public_bucket or (_public_bucket := GOOGLE_STORAGE_CLIENT.lookup_bucket(PUBLIC_BUCKET_NAME)):
547 return _public_bucket
549 raise ValueError(
550 f"""The bucket '{PUBLIC_BUCKET_NAME}' does not exist! Please create it with ACL access."""
551 )
553 return _private_bucket
555 @classmethod
556 def is_valid_filename(cls, filename: str) -> bool:
557 """
558 Verifies a valid filename.
560 The filename should be valid on Linux, Mac OS and Windows.
561 It should not be longer than MAX_FILENAME_LEN chars.
563 Rule set: https://stackoverflow.com/a/31976060/3749896
564 Regex test: https://regex101.com/r/iBYpoC/1
565 """
566 if not filename.strip(): 566 ↛ 567line 566 didn't jump to line 567 because the condition on line 566 was never true
567 return False
569 if len(filename) > cls.MAX_FILENAME_LEN: 569 ↛ 570line 569 didn't jump to line 570 because the condition on line 569 was never true
570 return False
572 return bool(re.match(VALID_FILENAME_REGEX, filename))
574 @staticmethod
575 def hmac_sign(data: t.Any) -> str:
576 assert conf.file_hmac_key is not None, "No hmac-key set!"
577 if not isinstance(data, bytes): 577 ↛ 578line 577 didn't jump to line 578 because the condition on line 577 was never true
578 data = str(data).encode("UTF-8")
579 return hmac.new(conf.file_hmac_key, msg=data, digestmod=hashlib.sha3_384).hexdigest()
581 @classmethod
582 def hmac_verify(cls, data: t.Any, signature: str) -> bool:
583 try:
584 return hmac.compare_digest(cls.hmac_sign(data.encode("ASCII")), signature)
585 except (TypeError, UnicodeEncodeError):
586 return False
588 @classmethod
589 def create_download_url(
590 cls,
591 dlkey: str,
592 filename: str,
593 derived: bool = False,
594 expires: t.Optional[datetime.timedelta | int] = datetime.timedelta(hours=1),
595 download_filename: t.Optional[str] = None
596 ) -> str:
597 """
598 Utility function that creates a signed download-url for the given folder/filename combination
600 :param folder: The GCS-Folder (= the download-key) for that file
601 :param filename: The name of the file. Either the original filename or the name of a derived file.
602 :param derived: True, if it points to a derived file, False if it points to the original uploaded file
603 :param expires:
604 None if the file is supposed to be public (which causes it to be cached on the google ede caches),
605 otherwise a datetime.timedelta of how long that link should be valid
606 :param download_filename: If set, browser is enforced to download this blob with the given alternate
607 filename
608 :return: The signed download-url relative to the current domain (eg /download/...)
609 """
610 if isinstance(expires, int): 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true
611 expires = datetime.timedelta(minutes=expires)
613 filename = html.unescape(filename)
614 filepath = f"""{dlkey}/{"derived" if derived else "source"}/{filename}"""
616 if download_filename:
617 if not cls.is_valid_filename(download_filename): 617 ↛ 618line 617 didn't jump to line 618 because the condition on line 617 was never true
618 raise errors.UnprocessableEntity(f"Invalid download_filename {download_filename!r} provided")
620 download_filename = urlquote(download_filename)
622 expires = (datetime.datetime.now() + expires).strftime("%Y%m%d%H%M") if expires else 0
624 data = base64.urlsafe_b64encode(f"""{filepath}\0{expires}\0{download_filename or ""}""".encode("UTF-8"))
625 sig = cls.hmac_sign(data)
627 return f"""{cls.DOWNLOAD_URL_PREFIX}{data.decode("ASCII")}?sig={sig}"""
629 @classmethod
630 def parse_download_url(cls, url) -> t.Optional[FilePath]:
631 """
632 Parses a file download URL in the format `/file/download/xxxx?sig=yyyy` into its FilePath.
634 The URL may be absolute, because admin frontends store it including scheme and host, and
635 it may carry an additional path segment after the payload: :meth:`download` accepts the
636 download file name that way, and a bare trailing slash occurs as well.
638 If the URL cannot be parsed, the function returns None.
640 :param url: The file download URL to be parsed.
641 :return: A FilePath on success, None otherwise.
642 """
643 parsed_url = urlsplit(url)
645 if not parsed_url.path.startswith(cls.DOWNLOAD_URL_PREFIX) or not parsed_url.query:
646 return None
648 # Strip "/file/download/"; the payload is urlsafe-base64 and therefore never contains a
649 # slash, so anything after the first one is the optional download file name.
650 data = parsed_url.path.removeprefix(cls.DOWNLOAD_URL_PREFIX).split("/", 1)[0]
651 sig = parse_qs(parsed_url.query).get("sig", [""])[0]
653 if not cls.hmac_verify(data, sig): 653 ↛ 655line 653 didn't jump to line 655 because the condition on line 653 was never true
654 # Invalid signature
655 return None
657 # Split the blobKey into the individual fields it should contain
658 data = base64.urlsafe_b64decode(data).decode("UTF-8")
660 match data.count("\0"):
661 case 2: 661 ↛ 663line 661 didn't jump to line 663 because the pattern on line 661 always matched
662 dlpath, valid_until, _ = data.split("\0")
663 case 1:
664 # It's the old format, without an downloadFileName
665 dlpath, valid_until = data.split("\0")
666 case _:
667 # Invalid path
668 return None
670 if valid_until != "0" and datetime.datetime.strptime(valid_until, "%Y%m%d%H%M") < datetime.datetime.now():
671 # Signature expired
672 return None
674 if dlpath.count("/") != 2: 674 ↛ 676line 674 didn't jump to line 676 because the condition on line 674 was never true
675 # Invalid path
676 return None
678 dlkey, derived, filename = dlpath.split("/")
679 return FilePath(dlkey, derived != "source", filename)
681 @classmethod
682 def create_src_set(
683 cls,
684 file: t.Union["SkeletonInstance", dict, str],
685 expires: t.Optional[datetime.timedelta | int] = datetime.timedelta(hours=1),
686 width: t.Optional[int] = None,
687 height: t.Optional[int] = None,
688 language: t.Optional[str] = None,
689 ) -> str:
690 """
691 Generates a string suitable for use as the srcset tag in html. This functionality provides the browser
692 with a list of images in different sizes and allows it to choose the smallest file that will fill it's
693 viewport without upscaling.
695 :param file: The file skeleton (or if multiple=True a single value from it) to generate the srcset.
696 :param expires:
697 None if the file is supposed to be public (which causes it to be cached on the google edecaches),
698 otherwise it's lifetime in seconds
699 :param width:
700 A list of widths that should be included in the srcset.
701 If a given width is not available, it will be skipped.
702 :param height: A list of heights that should be included in the srcset. If a given height is not available,
703 it will be skipped.
704 :param language: Language overwrite if file has multiple languages, and we want to explicitly specify one
705 :return: The srctag generated or an empty string if a invalid file object was supplied
706 """
707 if not width and not height: 707 ↛ 708line 707 didn't jump to line 708 because the condition on line 707 was never true
708 logging.error("Neither width or height supplied")
709 return ""
711 if isinstance(file, str): 711 ↛ 712line 711 didn't jump to line 712 because the condition on line 711 was never true
712 file = db.Query("file").filter("dlkey =", file).order(
713 db.QueryOrder("creationdate")).getEntry()
715 if not file: 715 ↛ 716line 715 didn't jump to line 716 because the condition on line 715 was never true
716 return ""
718 if isinstance(file, i18n.LanguageWrapper): 718 ↛ 723line 718 didn't jump to line 723 because the condition on line 718 was always true
719 language = language or current.language.get()
720 if not language or not (file := file.get(language)):
721 return ""
723 if "dlkey" not in file and "dest" in file: 723 ↛ 724line 723 didn't jump to line 724 because the condition on line 723 was never true
724 file = file["dest"]
726 from viur.core.skeleton import SkeletonInstance # avoid circular imports
728 if not ( 728 ↛ 733line 728 didn't jump to line 733 because the condition on line 728 was never true
729 isinstance(file, (SkeletonInstance, dict))
730 and "dlkey" in file
731 and "derived" in file
732 ):
733 logging.error("Invalid file supplied")
734 return ""
736 if not isinstance(file["derived"], dict): 736 ↛ 737line 736 didn't jump to line 737 because the condition on line 736 was never true
737 logging.error("No derives available")
738 return ""
740 src_set = []
741 for filename, derivate in file["derived"]["files"].items():
742 customData = derivate.get("customData", {})
744 if width and customData.get("width") in width: 744 ↛ 749line 744 didn't jump to line 749 because the condition on line 744 was always true
745 src_set.append(
746 f"""{cls.create_download_url(file["dlkey"], filename, True, expires)} {customData["width"]}w"""
747 )
749 if height and customData.get("height") in height: 749 ↛ 750line 749 didn't jump to line 750 because the condition on line 749 was never true
750 src_set.append(
751 f"""{cls.create_download_url(file["dlkey"], filename, True, expires)} {customData["height"]}h"""
752 )
754 return ", ".join(src_set)
756 def write(
757 self,
758 filename: str,
759 content: t.Any,
760 mimetype: str = "text/plain",
761 *,
762 width: int = None,
763 height: int = None,
764 public: bool = False,
765 rootnode: t.Optional[db.Key] = None,
766 folder: t.Iterable[str] | str = (),
767 ) -> db.Key:
768 """
769 Write a file from any bytes-like object into the file module.
771 If *folder* and *rootnode* are both set, the file is added to the repository in that folder.
772 If only *folder* is set, the file is added to the default repository in that folder.
773 If only *rootnode* is set, the file is added to that repository in the root folder.
775 If both are not set, the file is added without a path or repository as a weak file.
776 It will not be visible in admin in this case.
778 :param filename: Filename to be written.
779 :param content: The file content to be written, as bytes-like object.
780 :param mimetype: The file's mimetype.
781 :param width: Optional width information for the file.
782 :param height: Optional height information for the file.
783 :param public: True if the file should be publicly accessible.
784 :param rootnode: Optional root-node of the repository to add the file to
785 :param folder: Optional folder the file should be written into.
787 :return: Returns the key of the file object written. This can be associated e.g. with a FileBone.
788 """
789 # logging.info(f"{filename=} {mimetype=} {width=} {height=} {public=}")
790 if not self.is_valid_filename(filename): 790 ↛ 791line 790 didn't jump to line 791 because the condition on line 790 was never true
791 raise ValueError(f"{filename=} is invalid")
793 # Folder mode?
794 if folder:
795 # Validate correct folder naming
796 if isinstance(folder, str): 796 ↛ 799line 796 didn't jump to line 799 because the condition on line 796 was always true
797 folder = folder, # make it a tuple
799 for foldername in folder:
800 if not self.is_valid_filename(foldername): 800 ↛ 801line 800 didn't jump to line 801 because the condition on line 800 was never true
801 raise ValueError(f"{foldername=} is invalid")
803 # When in folder-mode, a rootnode must exist!
804 if rootnode is None: 804 ↛ 807line 804 didn't jump to line 807 because the condition on line 804 was always true
805 rootnode = self.ensureOwnModuleRootNode()
807 parentrepokey = rootnode.key
808 parentfolderkey = rootnode.key
810 for foldername in folder:
811 query = self.addSkel("node").all()
812 query.filter("parentrepo", parentrepokey)
813 query.filter("parententry", parentfolderkey)
814 query.filter("name", foldername)
816 if folder_skel := query.getSkel(): 816 ↛ 821line 816 didn't jump to line 821 because the condition on line 816 was always true
817 # Skip existing folder
818 parentfolderkey = folder_skel["key"]
819 else:
820 # Create new folder
821 folder_skel = self.addSkel("node")
823 folder_skel["name"] = foldername
824 folder_skel["parentrepo"] = parentrepokey
825 folder_skel["parententry"] = parentfolderkey
826 folder_skel.write()
828 parentfolderkey = folder_skel["key"]
830 else:
831 parentrepokey = None
832 parentfolderkey = None
834 # Write the file
835 dl_key = utils.string.random()
837 if public: 837 ↛ 838line 837 didn't jump to line 838 because the condition on line 837 was never true
838 dl_key += PUBLIC_DLKEY_SUFFIX # mark file as public
840 bucket = self.get_bucket(dl_key)
842 blob = bucket.blob(f"{dl_key}/source/{filename}")
843 blob.upload_from_file(io.BytesIO(content), content_type=mimetype)
845 fileskel = self.addSkel("leaf")
847 fileskel["parentrepo"] = parentrepokey
848 fileskel["parententry"] = parentfolderkey
849 fileskel["name"] = filename
850 fileskel["size"] = blob.size
851 fileskel["mimetype"] = mimetype
852 fileskel["dlkey"] = dl_key
853 fileskel["weak"] = not parentrepokey
854 fileskel["public"] = public
855 fileskel["width"] = width
856 fileskel["height"] = height
857 fileskel["crc32c_checksum"] = base64.b64decode(blob.crc32c).hex()
858 fileskel["md5_checksum"] = base64.b64decode(blob.md5_hash).hex()
859 fileskel["pending"] = False
861 return fileskel.write()["key"]
863 def read(
864 self,
865 key: db.KeyType | None = None,
866 path: str | None = None,
867 ) -> tuple[io.BytesIO, str]:
868 """
869 Read a file from the Cloud Storage.
871 If a key and a path are provided, the key is preferred.
872 This means that the entry in the db is searched first and if this is not found, the path is used.
874 :param key: Key of the LeafSkel that contains the "dlkey" and the "name".
875 :param path: The path of the file in the Cloud Storage Bucket.
877 :return: Returns the file as a io.BytesIO buffer and the content-type
878 """
879 if not key and not path:
880 raise ValueError("Please provide a key or a path")
882 if key:
883 skel = self.viewSkel("leaf")
884 if not skel.read(db.key_helper(key, skel.kindName)):
885 if not path:
886 raise ValueError("This skeleton is not in the database!")
887 else:
888 path = f"""{skel["dlkey"]}/source/{skel["name"]}"""
890 bucket = self.get_bucket(skel["dlkey"])
891 else:
892 bucket = self.get_bucket(path.split("/", 1)[0]) # path's first part is dlkey plus eventual postfix
894 blob = bucket.blob(path)
895 return io.BytesIO(blob.download_as_bytes()), blob.content_type
897 def onDeleteRecursive(self, skelType: SkelType, skel: SkeletonInstance) -> None:
898 """
899 Mark the blob of each cascaded file for deletion.
901 The generic recursive delete of the Tree prototype is inherited as-is
902 (see :meth:`Tree.deleteRecursive`); the only File-specific step is
903 marking a leaf's blob for deletion, which is injected via this hook.
904 Directories (nodes) have no blob and are ignored.
905 """
906 if skelType == "leaf":
907 self.mark_for_deletion(skel["dlkey"])
909 @exposed
910 @skey
911 def getUploadURL(
912 self,
913 fileName: str,
914 mimeType: str,
915 size: t.Optional[int] = None,
916 node: t.Optional[str | db.Key] = None,
917 authData: t.Optional[str] = None,
918 authSig: t.Optional[str] = None,
919 public: bool = False,
920 amend: db.KeyType | None = None,
921 ):
922 # Optional amending: replace the blob of an existing file
923 # instead of creating a new one. No new dlkey, no relink — the same
924 # file entity keeps its key, tree position and download URLs; only its
925 # blob content is replaced. The caller must be allowed to edit the file
926 # (canEdit); the metadata (size/mimetype/checksums, and serving_url for
927 # public images) is refreshed in add() once the new blob is uploaded.
928 if amend is not None: 928 ↛ 942line 928 didn't jump to line 942 because the condition on line 928 was always true
929 skel = self.editSkel("leaf")
930 if not skel.read(amend): 930 ↛ 931line 930 didn't jump to line 931 because the condition on line 930 was never true
931 raise errors.NotFound()
932 if not self.canEdit("leaf", skel):
933 raise errors.Forbidden()
934 blob = self.get_bucket(skel["dlkey"]).blob(
935 f"""{skel["dlkey"]}/source/{utils.string.unescape(skel["name"])}""")
936 upload_url = blob.create_resumable_upload_session(content_type=mimeType, size=size, timeout=60)
937 return self.render.view({
938 "uploadKey": str(skel["key"]),
939 "uploadUrl": upload_url,
940 })
942 filename = fileName.strip() # VIUR4 FIXME: just for compatiblity of the parameter names
944 if not self.is_valid_filename(filename):
945 raise errors.UnprocessableEntity(f"Invalid filename {filename!r} provided")
947 # Validate the mimetype from the client seems legit
948 mimetype = mimeType.strip().lower()
949 if not (
950 mimetype
951 and mimetype.count("/") == 1
952 and all(ch in string.ascii_letters + string.digits + "/-.+" for ch in mimetype)
953 ):
954 raise errors.UnprocessableEntity(f"Invalid mime-type {mimetype!r} provided")
956 # Validate authentication data
957 if authData and authSig:
958 # First, validate the signature, otherwise we don't need to proceed further
959 if not self.hmac_verify(authData, authSig):
960 raise errors.Unauthorized()
962 authData = json.loads(base64.b64decode(authData.encode("ASCII")).decode("UTF-8"))
964 if datetime.datetime.strptime(authData["validUntil"], "%Y%m%d%H%M") < datetime.datetime.now():
965 raise errors.Gone("The upload URL has expired")
967 if authData["validMimeTypes"]:
968 for validMimeType in authData["validMimeTypes"]:
969 if (
970 validMimeType == mimetype
971 or (validMimeType.endswith("*") and mimetype.startswith(validMimeType[:-1]))
972 ):
973 break
974 else:
975 raise errors.UnprocessableEntity(f"Invalid mime-type {mimetype} provided")
977 node = authData["node"]
978 maxSize = authData["maxSize"]
980 else:
981 rootNode = None
982 if node and not (rootNode := self.getRootNode(node)):
983 raise errors.NotFound(f"No valid root node found for {node=}")
985 if not self.canAdd("leaf", rootNode):
986 raise errors.Forbidden()
988 if rootNode and public != bool(rootNode.get("public")):
989 raise errors.Forbidden("Cannot upload a public file into private repository or vice versa")
991 maxSize = None # The user has some file/add permissions, don't restrict fileSize
993 if maxSize:
994 if size > maxSize:
995 raise errors.UnprocessableEntity(f"Size {size} exceeds maximum size {maxSize}")
996 else:
997 size = None
999 # Create upload-URL and download key
1000 dlkey = utils.string.random() # let's roll a random key
1002 if public:
1003 dlkey += PUBLIC_DLKEY_SUFFIX # mark file as public
1005 blob = self.get_bucket(dlkey).blob(f"{dlkey}/source/{filename}")
1006 upload_url = blob.create_resumable_upload_session(content_type=mimeType, size=size, timeout=60)
1008 # Create a corresponding file-lock object early, otherwise we would have to ensure that the file-lock object
1009 # the user creates matches the file he had uploaded
1010 file_skel = self.addSkel("leaf")
1012 file_skel["name"] = filename + self.PENDING_POSTFIX
1013 file_skel["size"] = 0
1014 file_skel["mimetype"] = "application/octetstream"
1015 file_skel["dlkey"] = dlkey
1016 file_skel["parentdir"] = None
1017 file_skel["pendingparententry"] = db.key_helper(node, self.addSkel("node").kindName) if node else None
1018 file_skel["pending"] = True
1019 file_skel["weak"] = True
1020 file_skel["public"] = public
1021 file_skel["width"] = 0
1022 file_skel["height"] = 0
1024 file_skel.write()
1025 key = str(file_skel["key"])
1027 # Mark that entry dirty as we might never receive an add
1028 self.mark_for_deletion(dlkey)
1030 # In this case, we'd have to store the key in the users session so he can call add() later on
1031 if authData and authSig:
1032 session = current.session.get()
1034 if "pendingFileUploadKeys" not in session:
1035 session["pendingFileUploadKeys"] = []
1037 session["pendingFileUploadKeys"].append(key)
1039 # Clamp to the latest 50 pending uploads
1040 session["pendingFileUploadKeys"] = session["pendingFileUploadKeys"][-50:]
1041 session.markChanged()
1043 return self.render.view({
1044 "uploadKey": key,
1045 "uploadUrl": upload_url,
1046 })
1048 @exposed
1049 def download(self, blobKey: str, fileName: str = "", download: bool = False, sig: str = "", *args, **kwargs):
1050 """
1051 Download a file.
1052 :param blobKey: The unique blob key of the file.
1053 :param fileName: Optional filename to provide in the header.
1054 :param download: Set header to attachment retrival, set explictly to "1" if download is wanted.
1055 """
1056 if filename := fileName.strip():
1057 if not self.is_valid_filename(filename):
1058 raise errors.UnprocessableEntity(f"The provided filename {filename!r} is invalid!")
1060 try:
1061 values = base64.urlsafe_b64decode(blobKey).decode("UTF-8").split("\0")
1062 except ValueError:
1063 raise errors.BadRequest(f"Invalid encoding of blob key {blobKey!r}!")
1064 try:
1065 dlPath, validUntil, *download_filename = values
1066 # Maybe it's the old format, without a download_filename
1067 download_filename = download_filename[0] if download_filename else ""
1068 except ValueError:
1069 logging.error(f"Encoding of {blobKey=!r} OK. {values=} invalid.")
1070 raise errors.BadRequest(f"The blob key {blobKey!r} has an invalid amount of encoded values!")
1072 bucket = self.get_bucket(dlPath.split("/", 1)[0])
1074 if not sig:
1075 # Check if the current user has the right to download *any* blob present in this application.
1076 # blobKey is then the path inside cloudstore - not a base64 encoded tuple
1077 if not (usr := current.user.get()):
1078 raise errors.Unauthorized()
1079 if "root" not in usr["access"] and "file-view" not in usr["access"]:
1080 raise errors.Forbidden()
1081 validUntil = "-1" # Prevent this from being cached down below
1082 blob = bucket.get_blob(blobKey)
1084 else:
1085 # We got an request including a signature (probably a guest or a user without file-view access)
1086 # First, validate the signature, otherwise we don't need to proceed any further
1087 if not self.hmac_verify(blobKey, sig):
1088 raise errors.Forbidden()
1090 if validUntil != "0" and datetime.datetime.strptime(validUntil, "%Y%m%d%H%M") < datetime.datetime.now():
1091 blob = None
1092 else:
1093 blob = bucket.get_blob(dlPath)
1095 if not blob:
1096 raise errors.Gone("The requested blob has expired.")
1098 if not filename:
1099 filename = urlunquote(download_filename) if download_filename else blob.name.rsplit("/", 1)[-1]
1101 content_disposition = utils.build_content_disposition_header(filename, attachment=download)
1103 if isinstance(_CREDENTIALS, ServiceAccountCredentials):
1104 expiresAt = datetime.datetime.now() + datetime.timedelta(seconds=60)
1105 signedUrl = blob.generate_signed_url(expiresAt, response_disposition=content_disposition, version="v4")
1106 raise errors.Redirect(signedUrl)
1108 elif conf.instance.is_dev_server: # No Service-Account to sign with - Serve everything directly
1109 response = current.request.get().response
1110 response.headers["Content-Type"] = blob.content_type
1111 if content_disposition:
1112 response.headers["Content-Disposition"] = content_disposition
1113 return blob.download_as_bytes()
1115 if validUntil == "0" or blobKey.endswith(PUBLIC_DLKEY_SUFFIX): # Its an indefinitely valid URL
1116 if blob.size < 5 * 1024 * 1024: # Less than 5 MB - Serve directly and push it into the ede caches
1117 response = current.request.get().response
1118 response.headers["Content-Type"] = blob.content_type
1119 response.headers["Cache-Control"] = "public, max-age=604800" # 7 Days
1120 if content_disposition:
1121 response.headers["Content-Disposition"] = content_disposition
1122 return blob.download_as_bytes()
1124 # Default fallback - create a signed URL and redirect
1125 authRequest = google.auth.transport.requests.Request()
1126 expiresAt = datetime.datetime.now() + datetime.timedelta(seconds=60)
1127 signing_credentials = google.auth.compute_engine.IDTokenCredentials(authRequest, "")
1128 signedUrl = blob.generate_signed_url(
1129 expiresAt,
1130 credentials=signing_credentials,
1131 response_disposition=content_disposition,
1132 version="v4")
1134 raise errors.Redirect(signedUrl)
1136 @exposed
1137 @force_ssl
1138 @force_post
1139 @skey(allow_empty=True)
1140 def add(self, skelType: SkelType, node: db.KeyType | None = None, *args, **kwargs):
1141 # We can't add files directly (they need to be uploaded
1142 if skelType == "leaf": # We need to handle leafs separately here 1142 ↛ 1231line 1142 didn't jump to line 1231 because the condition on line 1142 was always true
1143 targetKey = kwargs.get("key")
1144 skel = self.addSkel("leaf")
1146 if not skel.read(targetKey): 1146 ↛ 1147line 1146 didn't jump to line 1147 because the condition on line 1146 was never true
1147 raise errors.NotFound()
1149 if not skel["pending"]: 1149 ↛ 1183line 1149 didn't jump to line 1183 because the condition on line 1149 was always true
1150 # Amending: the blob of an already-added file was
1151 # replaced via getUploadURL(amend=...). Refresh the derived
1152 # metadata from the new blob and keep the file's tree position
1153 # untouched (no re-parent, no weak-flag change). write() runs
1154 # _inject_serving_url, which refreshes serving_url for public
1155 # images (no-op if it is already set).
1156 if not self.canEdit("leaf", skel):
1157 raise errors.Forbidden()
1159 bucket = self.get_bucket(skel["dlkey"])
1160 blobs = list(bucket.list_blobs(prefix=f"""{skel["dlkey"]}/"""))
1161 if len(blobs) != 1: 1161 ↛ 1162line 1161 didn't jump to line 1162 because the condition on line 1161 was never true
1162 logging.error("Invalid number of blobs in folder")
1163 logging.error(targetKey)
1164 raise errors.PreconditionFailed()
1165 blob = blobs[0]
1167 # The blob is read outside, the entity is re-read and written within a transaction.
1168 def _refresh_metadata(skel: SkeletonInstance) -> None:
1169 skel["mimetype"] = utils.string.escape(blob.content_type)
1170 skel["size"] = blob.size
1171 skel["crc32c_checksum"] = base64.b64decode(blob.crc32c).hex()
1172 skel["md5_checksum"] = base64.b64decode(blob.md5_hash).hex()
1173 self.onEdit("leaf", skel)
1175 skel.patch(_refresh_metadata)
1176 self.onEdited("leaf", skel)
1178 skel["downloadUrl"] = self.create_download_url(
1179 skel["dlkey"], skel["name"], expires=conf.render_json_download_url_expiration
1180 )
1181 return self.render.editSuccess(skel)
1183 skel["pending"] = False
1184 skel["parententry"] = skel["pendingparententry"]
1186 if skel["parententry"]:
1187 rootNode = self.getRootNode(skel["parententry"])
1188 else:
1189 rootNode = None
1191 if not self.canAdd("leaf", rootNode):
1192 # Check for a marker in this session (created if using a signed upload URL)
1193 session = current.session.get()
1194 if targetKey not in (session.get("pendingFileUploadKeys") or []):
1195 raise errors.Forbidden()
1196 session["pendingFileUploadKeys"].remove(targetKey)
1197 session.markChanged()
1199 # Now read the blob from the dlkey folder
1200 bucket = self.get_bucket(skel["dlkey"])
1202 blobs = list(bucket.list_blobs(prefix=f"""{skel["dlkey"]}/"""))
1203 if len(blobs) != 1:
1204 logging.error("Invalid number of blobs in folder")
1205 logging.error(targetKey)
1206 raise errors.PreconditionFailed()
1208 # only one item is allowed here!
1209 blob = blobs[0]
1211 # update the corresponding file skeleton
1212 skel["name"] = skel["name"].removesuffix(self.PENDING_POSTFIX)
1213 skel["mimetype"] = utils.string.escape(blob.content_type)
1214 skel["size"] = blob.size
1215 skel["parentrepo"] = rootNode["key"] if rootNode else None
1216 skel["weak"] = rootNode is None
1217 skel["crc32c_checksum"] = base64.b64decode(blob.crc32c).hex()
1218 skel["md5_checksum"] = base64.b64decode(blob.md5_hash).hex()
1219 self.onAdd("leaf", skel)
1220 skel.write()
1221 self.onAdded("leaf", skel)
1223 # Add updated download-URL as the auto-generated isn't valid yet.
1224 # Same lifetime as DownloadUrlBone, which this replaces.
1225 skel["downloadUrl"] = self.create_download_url(
1226 skel["dlkey"], skel["name"], expires=conf.render_json_download_url_expiration
1227 )
1229 return self.render.addSuccess(skel)
1231 return super().add(skelType, node, *args, **kwargs)
1233 @exposed
1234 def get_download_url(
1235 self,
1236 key: t.Optional[db.Key] = None,
1237 dlkey: t.Optional[str] = None,
1238 filename: t.Optional[str] = None,
1239 derived: bool = False,
1240 ):
1241 """
1242 Request a download url for a given file
1243 :param key: The key of the file
1244 :param dlkey: The download key of the file
1245 :param filename: The filename to be given. If no filename is provided
1246 downloadUrls for all derived files are returned in case of `derived=True`.
1247 :param derived: True, if a derived file download URL is being requested.
1248 """
1249 skel = self.viewSkel("leaf")
1250 if dlkey is not None:
1251 skel = skel.all().filter("dlkey", dlkey).getSkel()
1252 elif key is None and dlkey is None:
1253 raise errors.BadRequest("No key or dlkey provided")
1255 if not (skel and skel.read(key)):
1256 raise errors.NotFound()
1258 if not self.canView("leaf", skel):
1259 raise errors.Unauthorized()
1261 dlkey = skel["dlkey"]
1263 if derived and filename is None:
1264 res = {}
1265 for filename in skel["derived"]["files"]:
1266 res[filename] = self.create_download_url(dlkey, filename, derived)
1267 else:
1268 if derived:
1269 # Check if Filename exist in the Derives. We sign nothing that not exist.
1270 if filename not in skel["derived"]["files"]:
1271 raise errors.NotFound("File not in derives")
1272 else:
1273 if filename is None:
1274 filename = skel["name"]
1275 elif filename != skel["name"]:
1276 raise errors.NotFound("Filename not match")
1278 res = self.create_download_url(dlkey, filename, derived)
1280 return self.render.view(res)
1282 def onEdit(self, skelType: SkelType, skel: SkeletonInstance):
1283 super().onEdit(skelType, skel)
1285 if skelType == "leaf":
1286 old_skel = self.editSkel(skelType)
1287 old_skel.setEntity(skel.dbEntity)
1289 if old_skel["name"] == skel["name"]: # name not changed we can return
1290 return
1292 # Move Blob to new name
1293 # https://cloud.google.com/storage/docs/copying-renaming-moving-objects
1294 old_path = f"""{skel["dlkey"]}/source/{html.unescape(old_skel["name"])}"""
1295 new_path = f"""{skel["dlkey"]}/source/{html.unescape(skel["name"])}"""
1297 bucket = self.get_bucket(skel["dlkey"])
1299 if not (old_blob := bucket.get_blob(old_path)):
1300 raise errors.Gone()
1302 bucket.copy_blob(old_blob, bucket, new_path, if_generation_match=0)
1303 bucket.delete_blob(old_path)
1305 def onAdded(self, skelType: SkelType, skel: SkeletonInstance) -> None:
1306 if skelType == "leaf" and skel["mimetype"].startswith("image/"):
1307 if skel["size"] > self.IMAGE_META_MAX_SIZE:
1308 logging.warning(f"File size {skel['size']} exceeds limit {self.IMAGE_META_MAX_SIZE=}")
1309 return
1310 self.set_image_meta(skel["key"])
1312 super().onAdded(skelType, skel)
1314 @CallDeferred
1315 def set_image_meta(self, key: db.Key) -> None:
1316 """Write image metadata (height and width) to FileSkel"""
1317 skel = self.editSkel("leaf", key)
1319 if not skel.read(key):
1320 logging.error(f"File {key} does not exist")
1321 return
1323 if skel["width"] and skel["height"]:
1324 logging.info(f'File {skel["key"]} has already {skel["width"]=} and {skel["height"]=}')
1325 return
1327 file_name = html.unescape(skel["name"])
1328 blob = self.get_bucket(skel["dlkey"]).get_blob(f"""{skel["dlkey"]}/source/{file_name}""")
1329 if not blob:
1330 logging.error(f'Blob {skel["dlkey"]}/source/{file_name} is missing in Cloud Storage!')
1331 return
1333 file_obj = io.BytesIO()
1334 blob.download_to_file(file_obj)
1335 file_obj.seek(0)
1337 try:
1338 img = PIL.Image.open(file_obj)
1339 except PIL.Image.UnidentifiedImageError as e: # Can't load this image
1340 logging.exception(f'Cannot open {skel["key"]} | {skel["name"]} to set image meta data: {e}')
1341 return
1343 skel.patch(
1344 values={
1345 "width": img.width,
1346 "height": img.height,
1347 },
1348 )
1350 def mark_for_deletion(self, dlkey: str) -> None:
1351 """
1352 Adds a marker to the datastore that the file specified as *dlkey* can be deleted.
1354 Once the mark has been set, the data store is checked four times (default: every 4 hours)
1355 if the file is in use somewhere. If it is still in use, the mark goes away, otherwise
1356 the mark and the file are removed from the datastore. These delayed checks are necessary
1357 due to database inconsistency.
1359 :param dlkey: Unique download-key of the file that shall be marked for deletion.
1360 """
1361 fileObj = db.Query("viur-deleted-files").filter("dlkey", dlkey).getEntry()
1363 if fileObj: # Its allready marked
1364 return
1366 fileObj = db.Entity(db.Key("viur-deleted-files"))
1367 fileObj["itercount"] = 0
1368 fileObj["dlkey"] = str(dlkey)
1370 db.put(fileObj)
1373@PeriodicTask(interval=datetime.timedelta(hours=4))
1374def startCheckForUnreferencedBlobs():
1375 """
1376 Start searching for blob locks that have been recently freed
1377 """
1378 doCheckForUnreferencedBlobs()
1381@CallDeferred
1382def doCheckForUnreferencedBlobs(cursor=None):
1383 def getOldBlobKeysTxn(dbKey):
1384 obj = db.get(dbKey)
1385 if obj is None:
1386 # The lock was already processed and removed by a concurrent run
1387 return []
1388 res = obj["old_blob_references"] or []
1389 if obj["is_stale"]:
1390 db.delete(dbKey)
1391 else:
1392 obj["has_old_blob_references"] = False
1393 obj["old_blob_references"] = []
1394 db.put(obj)
1395 return res
1397 query = db.Query("viur-blob-locks").filter("has_old_blob_references", True).setCursor(cursor)
1398 for lockObj in query.run(100):
1399 oldBlobKeys = db.run_in_transaction(getOldBlobKeysTxn, lockObj.key)
1400 for blobKey in oldBlobKeys:
1401 if db.Query("viur-blob-locks").filter("active_blob_references =", blobKey).getEntry(): 1401 ↛ 1403line 1401 didn't jump to line 1403 because the condition on line 1401 was never true
1402 # This blob is referenced elsewhere
1403 logging.info(f"Stale blob is still referenced, {blobKey}")
1404 continue
1405 # Add a marker and schedule it for deletion
1406 fileObj = db.Query("viur-deleted-files").filter("dlkey", blobKey).getEntry()
1407 if fileObj: # Its already marked
1408 logging.info(f"Stale blob already marked for deletion, {blobKey}")
1409 continue
1410 fileObj = db.Entity(db.Key("viur-deleted-files"))
1411 fileObj["itercount"] = 0
1412 fileObj["dlkey"] = str(blobKey)
1413 logging.info(f"Stale blob marked dirty, {blobKey}")
1414 db.put(fileObj)
1415 newCursor = query.getCursor()
1416 if newCursor: 1416 ↛ 1417line 1416 didn't jump to line 1417 because the condition on line 1416 was never true
1417 doCheckForUnreferencedBlobs(newCursor)
1420@PeriodicTask(interval=datetime.timedelta(hours=4))
1421def startCleanupDeletedFiles():
1422 """
1423 Increase deletion counter on each blob currently not referenced and delete
1424 it if that counter reaches maxIterCount
1425 """
1426 doCleanupDeletedFiles()
1429@CallDeferred
1430def doCleanupDeletedFiles(cursor=None):
1431 maxIterCount = 2 # How often a file will be checked for deletion
1432 query = db.Query("viur-deleted-files")
1433 if cursor:
1434 query.setCursor(cursor)
1435 for file in query.run(100):
1436 if "dlkey" not in file:
1437 db.delete(file.key)
1438 elif db.Query("viur-blob-locks").filter("active_blob_references =", file["dlkey"]).getEntry():
1439 logging.info(f"""is referenced, {file["dlkey"]}""")
1440 db.delete(file.key)
1441 else:
1442 if file["itercount"] > maxIterCount:
1443 logging.info(f"""Finally deleting, {file["dlkey"]}""")
1444 bucket = conf.main_app.file.get_bucket(file["dlkey"])
1445 blobs = bucket.list_blobs(prefix=f"""{file["dlkey"]}/""")
1446 for blob in blobs:
1447 blob.delete()
1448 db.delete(file.key)
1449 # There should be exactly 1 or 0 of these
1450 for f in skeletonByKind("file")().all().filter("dlkey =", file["dlkey"]).fetch(99):
1451 f.delete()
1453 if f["serving_url"]:
1454 bucket = conf.main_app.file.get_bucket(f["dlkey"])
1455 blob_key = blobstore.create_gs_key(
1456 f"/gs/{bucket.name}/{f['dlkey']}/source/{f['name']}"
1457 )
1458 images.delete_serving_url(blob_key) # delete serving url
1459 else:
1460 logging.debug(f"""Increasing count, {file["dlkey"]}""")
1461 file["itercount"] += 1
1462 db.put(file)
1463 newCursor = query.getCursor()
1464 if newCursor:
1465 doCleanupDeletedFiles(newCursor)
1468@PeriodicTask(interval=datetime.timedelta(hours=4))
1469def start_delete_pending_files():
1470 """
1471 Start deletion of pending FileSkels that are older than 7 days.
1472 """
1473 DeleteEntitiesIter.startIterOnQuery(
1474 FileLeafSkel().all()
1475 .filter("pending =", True)
1476 .filter("creationdate <", utils.utcNow() - datetime.timedelta(days=7))
1477 )
1480# DEPRECATED ATTRIBUTES HANDLING
1482def __getattr__(attr: str) -> object:
1483 if entry := { 1483 ↛ 1487line 1483 didn't jump to line 1487 because the condition on line 1483 was never true
1484 # stuff prior viur-core < 3.7
1485 "GOOGLE_STORAGE_BUCKET": ("conf.main_app.file.get_bucket()", _private_bucket),
1486 }.get(attr):
1487 msg = f"{attr} was replaced by {entry[0]}"
1488 warnings.warn(msg, DeprecationWarning, stacklevel=2)
1489 logging.warning(msg, stacklevel=2)
1490 return entry[1]
1492 return super(__import__(__name__).__class__).__getattribute__(attr)