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

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 

18 

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 

25 

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 

42 

43# Globals for connectivity 

44 

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) 

50 

51_CREDENTIALS, _PROJECT_ID = google.auth.default() 

52GOOGLE_STORAGE_CLIENT = storage.Client(_PROJECT_ID, _CREDENTIALS) 

53 

54PRIVATE_BUCKET_NAME = f"""{_PROJECT_ID}.appspot.com""" 

55PUBLIC_BUCKET_NAME = f"""public-dot-{_PROJECT_ID}""" 

56PUBLIC_DLKEY_SUFFIX = "_pub" 

57 

58_private_bucket = GOOGLE_STORAGE_CLIENT.lookup_bucket(PRIVATE_BUCKET_NAME) 

59_public_bucket = None 

60 

61# FilePath is a descriptor for ViUR file components 

62FilePath = namedtuple("FilePath", ("dlkey", "is_derived", "filename")) 

63 

64 

65def importBlobFromViur2(dlKey, fileName): 

66 bucket = conf.main_app.file.get_bucket(dlKey) 

67 

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"] 

112 

113 

114def thumbnailer(fileSkel, existingFiles, params): 

115 file_name = html.unescape(fileSkel["name"]) 

116 bucket = conf.main_app.file.get_bucket(fileSkel["dlkey"]) 

117 

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 

122 

123 source = io.BytesIO() 

124 blob.download_to_file(source) 

125 

126 result = [] 

127 

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 

135 

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 

152 

153 file_extension = info.get("fileExtension", "webp") 

154 mimetype = info.get("mimeType", "image/webp") 

155 

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}" 

160 

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}" 

165 

166 else: # No default fallback - ignore 

167 continue 

168 

169 # Create resized version of the source 

170 target = io.BytesIO() 

171 

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 

179 

180 img.save(target, file_extension) 

181 

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) 

187 

188 result.append( 

189 (target_filename, target_size, mimetype, {"mimetype": mimetype, "width": width, "height": height}) 

190 ) 

191 

192 return result 

193 

194 

195def cloudfunction_thumbnailer(fileSkel, existingFiles, params): 

196 """External Thumbnailer for images. 

197 

198 The corresponding cloudfunction can be found here . 

199 https://github.com/viur-framework/viur-cloudfunctions/tree/main/thumbnailer 

200 

201 You can use it like so: 

202 main.py: 

203 

204 .. code-block:: python 

205 

206 from viur.core.modules.file import cloudfunction_thumbnailer 

207 

208 conf.file_thumbnailer_url = "https://xxxxx.cloudfunctions.net/imagerenderer" 

209 conf.file_derivations = {"thumbnail": cloudfunction_thumbnailer} 

210 

211 conf.derives_pdf = { 

212 "thumbnail": [{"width": 1920,"sites":"1,2"}] 

213 } 

214 

215 skeletons/xxx.py: 

216 .. code-block:: python 

217 

218 test = FileBone(derive=conf.derives_pdf) 

219 """ 

220 

221 if not conf.file_thumbnailer_url: 

222 raise ValueError("conf.file_thumbnailer_url is not set") 

223 

224 bucket = conf.main_app.file.get_bucket(fileSkel["dlkey"]) 

225 

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 

244 

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 

265 

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 

274 

275 return response_data 

276 

277 file_name = html.unescape(fileSkel["name"]) 

278 

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 

292 

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""") 

297 

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"]) 

302 

303 if not (url := getsignedurl()): 

304 return 

305 

306 dataDict["url"] = url 

307 dataDict["nameOnly"] = False 

308 dataDict["uploadUrls"] = uploadUrls 

309 

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"])) 

317 

318 except Exception as e: 

319 logging.error(f"cloudfunction_thumbnailer failed with: {e=}") 

320 return reslist 

321 

322 

323class DownloadUrlBone(BaseBone): 

324 """ 

325 This bone is used to inject a freshly signed download url into a FileSkel. 

326 """ 

327 

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 

334 

335 return False 

336 

337 

338class FileLeafSkel(TreeSkel): 

339 """ 

340 Default file leaf skeleton. 

341 """ 

342 kindName = "file" 

343 

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 ) 

350 

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 ) 

359 

360 size = NumericBone( 

361 descr="Filesize in Bytes", 

362 readOnly=True, 

363 searchable=True, 

364 ) 

365 

366 dlkey = StringBone( 

367 descr="Download-Key", 

368 readOnly=True, 

369 ) 

370 

371 mimetype = StringBone( 

372 descr="MIME-Type", 

373 readOnly=True, 

374 ) 

375 

376 weak = BooleanBone( 

377 descr="Weak reference", 

378 readOnly=True, 

379 visible=False, 

380 ) 

381 

382 pending = BooleanBone( 

383 descr="Pending upload", 

384 readOnly=True, 

385 visible=False, 

386 defaultValue=False, 

387 ) 

388 

389 width = NumericBone( 

390 descr="Width", 

391 readOnly=True, 

392 searchable=True, 

393 ) 

394 

395 height = NumericBone( 

396 descr="Height", 

397 readOnly=True, 

398 searchable=True, 

399 ) 

400 

401 downloadUrl = DownloadUrlBone( 

402 descr="Download-URL", 

403 readOnly=True, 

404 visible=False, 

405 ) 

406 

407 derived = JsonBone( 

408 descr="Derived Files", 

409 readOnly=True, 

410 visible=False, 

411 ) 

412 

413 pendingparententry = KeyBone( 

414 descr="Pending key Reference", 

415 readOnly=True, 

416 visible=False, 

417 ) 

418 

419 crc32c_checksum = StringBone( 

420 descr="CRC32C checksum", 

421 readOnly=True, 

422 ) 

423 

424 md5_checksum = StringBone( 

425 descr="MD5 checksum", 

426 readOnly=True, 

427 ) 

428 

429 public = BooleanBone( 

430 descr="Public File", 

431 readOnly=True, 

432 defaultValue=False, 

433 ) 

434 

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'])}" 

447 

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 

453 

454 try: 

455 # Generate Google Image Manipulation API Serving-URL 

456 return images.get_serving_url(None, secure_url=True, filename=filename) 

457 

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) 

461 

462 return None 

463 

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 ) 

471 

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"]) 

478 

479 return locks 

480 

481 

482class FileNodeSkel(TreeSkel): 

483 """ 

484 Default file node skeleton. 

485 """ 

486 kindName = "file_rootNode" # FIXME: VIUR4, don't use "_rootNode" kindname 

487 

488 name = StringBone( 

489 descr="Name", 

490 required=True, 

491 searchable=True 

492 ) 

493 

494 rootNode = BooleanBone( 

495 descr="Is RootNode", 

496 defaultValue=False, 

497 readOnly=True, 

498 visible=False, 

499 ) 

500 

501 public = BooleanBone( 

502 descr="Is public?", 

503 defaultValue=False, 

504 readOnly=True, 

505 visible=False, 

506 ) 

507 

508 viurCurrentSeoKeys = None 

509 

510 

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""" 

519 

520 leafSkelCls = FileLeafSkel 

521 nodeSkelCls = FileNodeSkel 

522 

523 handler = "tree.simple.file" 

524 adminInfo = { 

525 "icon": "folder-fill", 

526 "handler": handler, # fixme: Use static handler; Remove with VIUR4! 

527 } 

528 

529 roles = { 

530 "*": "view", 

531 "editor": ("add", "edit"), 

532 "admin": "*", 

533 } 

534 

535 default_order = "name" 

536 

537 # Helper functions currently resist here 

538 

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 

548 

549 raise ValueError( 

550 f"""The bucket '{PUBLIC_BUCKET_NAME}' does not exist! Please create it with ACL access.""" 

551 ) 

552 

553 return _private_bucket 

554 

555 @classmethod 

556 def is_valid_filename(cls, filename: str) -> bool: 

557 """ 

558 Verifies a valid filename. 

559 

560 The filename should be valid on Linux, Mac OS and Windows. 

561 It should not be longer than MAX_FILENAME_LEN chars. 

562 

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 

568 

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 

571 

572 return bool(re.match(VALID_FILENAME_REGEX, filename)) 

573 

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() 

580 

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 

587 

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 

599 

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) 

612 

613 filename = html.unescape(filename) 

614 filepath = f"""{dlkey}/{"derived" if derived else "source"}/{filename}""" 

615 

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") 

619 

620 download_filename = urlquote(download_filename) 

621 

622 expires = (datetime.datetime.now() + expires).strftime("%Y%m%d%H%M") if expires else 0 

623 

624 data = base64.urlsafe_b64encode(f"""{filepath}\0{expires}\0{download_filename or ""}""".encode("UTF-8")) 

625 sig = cls.hmac_sign(data) 

626 

627 return f"""{cls.DOWNLOAD_URL_PREFIX}{data.decode("ASCII")}?sig={sig}""" 

628 

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. 

633 

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. 

637 

638 If the URL cannot be parsed, the function returns None. 

639 

640 :param url: The file download URL to be parsed. 

641 :return: A FilePath on success, None otherwise. 

642 """ 

643 parsed_url = urlsplit(url) 

644 

645 if not parsed_url.path.startswith(cls.DOWNLOAD_URL_PREFIX) or not parsed_url.query: 

646 return None 

647 

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] 

652 

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 

656 

657 # Split the blobKey into the individual fields it should contain 

658 data = base64.urlsafe_b64decode(data).decode("UTF-8") 

659 

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 

669 

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 

673 

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 

677 

678 dlkey, derived, filename = dlpath.split("/") 

679 return FilePath(dlkey, derived != "source", filename) 

680 

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. 

694 

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 "" 

710 

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() 

714 

715 if not file: 715 ↛ 716line 715 didn't jump to line 716 because the condition on line 715 was never true

716 return "" 

717 

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 "" 

722 

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"] 

725 

726 from viur.core.skeleton import SkeletonInstance # avoid circular imports 

727 

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 "" 

735 

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 "" 

739 

740 src_set = [] 

741 for filename, derivate in file["derived"]["files"].items(): 

742 customData = derivate.get("customData", {}) 

743 

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 ) 

748 

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 ) 

753 

754 return ", ".join(src_set) 

755 

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. 

770 

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. 

774 

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. 

777 

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. 

786 

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") 

792 

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 

798 

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") 

802 

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() 

806 

807 parentrepokey = rootnode.key 

808 parentfolderkey = rootnode.key 

809 

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) 

815 

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") 

822 

823 folder_skel["name"] = foldername 

824 folder_skel["parentrepo"] = parentrepokey 

825 folder_skel["parententry"] = parentfolderkey 

826 folder_skel.write() 

827 

828 parentfolderkey = folder_skel["key"] 

829 

830 else: 

831 parentrepokey = None 

832 parentfolderkey = None 

833 

834 # Write the file 

835 dl_key = utils.string.random() 

836 

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 

839 

840 bucket = self.get_bucket(dl_key) 

841 

842 blob = bucket.blob(f"{dl_key}/source/{filename}") 

843 blob.upload_from_file(io.BytesIO(content), content_type=mimetype) 

844 

845 fileskel = self.addSkel("leaf") 

846 

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 

860 

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

862 

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. 

870 

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. 

873 

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. 

876 

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") 

881 

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"]}""" 

889 

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 

893 

894 blob = bucket.blob(path) 

895 return io.BytesIO(blob.download_as_bytes()), blob.content_type 

896 

897 def onDeleteRecursive(self, skelType: SkelType, skel: SkeletonInstance) -> None: 

898 """ 

899 Mark the blob of each cascaded file for deletion. 

900 

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"]) 

908 

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 }) 

941 

942 filename = fileName.strip() # VIUR4 FIXME: just for compatiblity of the parameter names 

943 

944 if not self.is_valid_filename(filename): 

945 raise errors.UnprocessableEntity(f"Invalid filename {filename!r} provided") 

946 

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") 

955 

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() 

961 

962 authData = json.loads(base64.b64decode(authData.encode("ASCII")).decode("UTF-8")) 

963 

964 if datetime.datetime.strptime(authData["validUntil"], "%Y%m%d%H%M") < datetime.datetime.now(): 

965 raise errors.Gone("The upload URL has expired") 

966 

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") 

976 

977 node = authData["node"] 

978 maxSize = authData["maxSize"] 

979 

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=}") 

984 

985 if not self.canAdd("leaf", rootNode): 

986 raise errors.Forbidden() 

987 

988 if rootNode and public != bool(rootNode.get("public")): 

989 raise errors.Forbidden("Cannot upload a public file into private repository or vice versa") 

990 

991 maxSize = None # The user has some file/add permissions, don't restrict fileSize 

992 

993 if maxSize: 

994 if size > maxSize: 

995 raise errors.UnprocessableEntity(f"Size {size} exceeds maximum size {maxSize}") 

996 else: 

997 size = None 

998 

999 # Create upload-URL and download key 

1000 dlkey = utils.string.random() # let's roll a random key 

1001 

1002 if public: 

1003 dlkey += PUBLIC_DLKEY_SUFFIX # mark file as public 

1004 

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) 

1007 

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") 

1011 

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 

1023 

1024 file_skel.write() 

1025 key = str(file_skel["key"]) 

1026 

1027 # Mark that entry dirty as we might never receive an add 

1028 self.mark_for_deletion(dlkey) 

1029 

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() 

1033 

1034 if "pendingFileUploadKeys" not in session: 

1035 session["pendingFileUploadKeys"] = [] 

1036 

1037 session["pendingFileUploadKeys"].append(key) 

1038 

1039 # Clamp to the latest 50 pending uploads 

1040 session["pendingFileUploadKeys"] = session["pendingFileUploadKeys"][-50:] 

1041 session.markChanged() 

1042 

1043 return self.render.view({ 

1044 "uploadKey": key, 

1045 "uploadUrl": upload_url, 

1046 }) 

1047 

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!") 

1059 

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!") 

1071 

1072 bucket = self.get_bucket(dlPath.split("/", 1)[0]) 

1073 

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) 

1083 

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() 

1089 

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) 

1094 

1095 if not blob: 

1096 raise errors.Gone("The requested blob has expired.") 

1097 

1098 if not filename: 

1099 filename = urlunquote(download_filename) if download_filename else blob.name.rsplit("/", 1)[-1] 

1100 

1101 content_disposition = utils.build_content_disposition_header(filename, attachment=download) 

1102 

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) 

1107 

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() 

1114 

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() 

1123 

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") 

1133 

1134 raise errors.Redirect(signedUrl) 

1135 

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") 

1145 

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() 

1148 

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() 

1158 

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] 

1166 

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) 

1174 

1175 skel.patch(_refresh_metadata) 

1176 self.onEdited("leaf", skel) 

1177 

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) 

1182 

1183 skel["pending"] = False 

1184 skel["parententry"] = skel["pendingparententry"] 

1185 

1186 if skel["parententry"]: 

1187 rootNode = self.getRootNode(skel["parententry"]) 

1188 else: 

1189 rootNode = None 

1190 

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() 

1198 

1199 # Now read the blob from the dlkey folder 

1200 bucket = self.get_bucket(skel["dlkey"]) 

1201 

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() 

1207 

1208 # only one item is allowed here! 

1209 blob = blobs[0] 

1210 

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) 

1222 

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 ) 

1228 

1229 return self.render.addSuccess(skel) 

1230 

1231 return super().add(skelType, node, *args, **kwargs) 

1232 

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") 

1254 

1255 if not (skel and skel.read(key)): 

1256 raise errors.NotFound() 

1257 

1258 if not self.canView("leaf", skel): 

1259 raise errors.Unauthorized() 

1260 

1261 dlkey = skel["dlkey"] 

1262 

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") 

1277 

1278 res = self.create_download_url(dlkey, filename, derived) 

1279 

1280 return self.render.view(res) 

1281 

1282 def onEdit(self, skelType: SkelType, skel: SkeletonInstance): 

1283 super().onEdit(skelType, skel) 

1284 

1285 if skelType == "leaf": 

1286 old_skel = self.editSkel(skelType) 

1287 old_skel.setEntity(skel.dbEntity) 

1288 

1289 if old_skel["name"] == skel["name"]: # name not changed we can return 

1290 return 

1291 

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"])}""" 

1296 

1297 bucket = self.get_bucket(skel["dlkey"]) 

1298 

1299 if not (old_blob := bucket.get_blob(old_path)): 

1300 raise errors.Gone() 

1301 

1302 bucket.copy_blob(old_blob, bucket, new_path, if_generation_match=0) 

1303 bucket.delete_blob(old_path) 

1304 

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"]) 

1311 

1312 super().onAdded(skelType, skel) 

1313 

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) 

1318 

1319 if not skel.read(key): 

1320 logging.error(f"File {key} does not exist") 

1321 return 

1322 

1323 if skel["width"] and skel["height"]: 

1324 logging.info(f'File {skel["key"]} has already {skel["width"]=} and {skel["height"]=}') 

1325 return 

1326 

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 

1332 

1333 file_obj = io.BytesIO() 

1334 blob.download_to_file(file_obj) 

1335 file_obj.seek(0) 

1336 

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 

1342 

1343 skel.patch( 

1344 values={ 

1345 "width": img.width, 

1346 "height": img.height, 

1347 }, 

1348 ) 

1349 

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. 

1353 

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. 

1358 

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() 

1362 

1363 if fileObj: # Its allready marked 

1364 return 

1365 

1366 fileObj = db.Entity(db.Key("viur-deleted-files")) 

1367 fileObj["itercount"] = 0 

1368 fileObj["dlkey"] = str(dlkey) 

1369 

1370 db.put(fileObj) 

1371 

1372 

1373@PeriodicTask(interval=datetime.timedelta(hours=4)) 

1374def startCheckForUnreferencedBlobs(): 

1375 """ 

1376 Start searching for blob locks that have been recently freed 

1377 """ 

1378 doCheckForUnreferencedBlobs() 

1379 

1380 

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 

1396 

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) 

1418 

1419 

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() 

1427 

1428 

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() 

1452 

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) 

1466 

1467 

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 ) 

1478 

1479 

1480# DEPRECATED ATTRIBUTES HANDLING 

1481 

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] 

1491 

1492 return super(__import__(__name__).__class__).__getattribute__(attr)