github.com/minio/console@v1.4.1/web-app/src/screens/Console/Buckets/ListBuckets/Objects/utils.ts (about) 1 // This file is part of MinIO Console Server 2 // Copyright (c) 2021 MinIO, Inc. 3 // 4 // This program is free software: you can redistribute it and/or modify 5 // it under the terms of the GNU Affero General Public License as published by 6 // the Free Software Foundation, either version 3 of the License, or 7 // (at your option) any later version. 8 // 9 // This program is distributed in the hope that it will be useful, 10 // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 // GNU Affero General Public License for more details. 13 // 14 // You should have received a copy of the GNU Affero General Public License 15 // along with this program. If not, see <http://www.gnu.org/licenses/>. 16 17 import { BucketObjectItem } from "./ListObjects/types"; 18 import { decodeURLString, encodeURLString } from "../../../../../common/utils"; 19 import { removeTrace } from "../../../ObjectBrowser/transferManager"; 20 import store from "../../../../../store"; 21 import { ContentType, PermissionResource } from "api/consoleApi"; 22 import { api } from "../../../../../api"; 23 import { setErrorSnackMessage } from "../../../../../systemSlice"; 24 import { StatusCodes } from "http-status-codes"; 25 const downloadWithLink = (href: string, downloadFileName: string) => { 26 const link = document.createElement("a"); 27 link.href = href; 28 link.download = downloadFileName; 29 document.body.appendChild(link); 30 link.click(); 31 document.body.removeChild(link); 32 }; 33 34 export const downloadSelectedAsZip = async ( 35 bucketName: string, 36 objectList: string[], 37 resultFileName: string, 38 ) => { 39 const state = store.getState(); 40 const anonymousMode = state.system.anonymousMode; 41 42 try { 43 const resp = await api.buckets.downloadMultipleObjects( 44 bucketName, 45 objectList, 46 { 47 type: ContentType.Json, 48 headers: anonymousMode 49 ? { 50 "X-Anonymous": "1", 51 } 52 : undefined, 53 }, 54 ); 55 const blob = await resp.blob(); 56 const href = window.URL.createObjectURL(blob); 57 downloadWithLink(href, resultFileName); 58 } catch (err: any) { 59 store.dispatch( 60 setErrorSnackMessage({ 61 errorMessage: `Download of multiple files failed. ${err.statusText}`, 62 detailedError: "", 63 }), 64 ); 65 } 66 }; 67 68 const isFolder = (objectPath: string) => { 69 return decodeURLString(objectPath).endsWith("/"); 70 }; 71 72 export const download = ( 73 bucketName: string, 74 objectPath: string, 75 versionID: any, 76 fileSize: number, 77 overrideFileName: string | null = null, 78 id: string, 79 progressCallback: (progress: number) => void, 80 completeCallback: () => void, 81 errorCallback: (msg: string) => void, 82 abortCallback: () => void, 83 toastCallback: () => void, 84 ) => { 85 let basename = document.baseURI.replace(window.location.origin, ""); 86 const state = store.getState(); 87 const anonymousMode = state.system.anonymousMode; 88 89 let path = `${ 90 window.location.origin 91 }${basename}api/v1/buckets/${bucketName}/objects/download?prefix=${objectPath}${ 92 overrideFileName !== null && overrideFileName.trim() !== "" 93 ? `&override_file_name=${encodeURLString(overrideFileName || "")}` 94 : "" 95 }`; 96 if (versionID) { 97 path = path.concat(`&version_id=${versionID}`); 98 } 99 100 // If file is greater than 50GiB then we force browser download, if not then we use HTTP Request for Object Manager 101 if (fileSize > 53687091200) { 102 return new BrowserDownload(path, id, completeCallback, toastCallback); 103 } 104 105 let req = new XMLHttpRequest(); 106 req.open("GET", path, true); 107 if (anonymousMode) { 108 req.setRequestHeader("X-Anonymous", "1"); 109 } 110 req.addEventListener( 111 "progress", 112 function (evt) { 113 let percentComplete = Math.round((evt.loaded / fileSize) * 100); 114 if (progressCallback) { 115 progressCallback(percentComplete); 116 } 117 }, 118 false, 119 ); 120 121 req.responseType = "blob"; 122 req.onreadystatechange = () => { 123 if (req.readyState === XMLHttpRequest.DONE) { 124 // Ensure object was downloaded fully, if it's a folder we don't get the fileSize 125 let completeDownload = 126 isFolder(objectPath) || req.response.size === fileSize; 127 128 if (req.status === StatusCodes.OK && completeDownload) { 129 const rspHeader = req.getResponseHeader("Content-Disposition"); 130 131 let filename = "download"; 132 if (rspHeader) { 133 let rspHeaderDecoded = decodeURIComponent(rspHeader); 134 filename = rspHeaderDecoded.split('"')[1]; 135 } 136 137 if (completeCallback) { 138 completeCallback(); 139 } 140 141 removeTrace(id); 142 143 downloadWithLink(window.URL.createObjectURL(req.response), filename); 144 } else { 145 if (req.getResponseHeader("Content-Type") === "application/json") { 146 const rspBody: { detailedMessage?: string } = JSON.parse( 147 req.response, 148 ); 149 if (rspBody.detailedMessage) { 150 errorCallback(rspBody.detailedMessage); 151 return; 152 } 153 } 154 errorCallback(`Unexpected response, download incomplete.`); 155 } 156 } 157 }; 158 req.onerror = () => { 159 if (errorCallback) { 160 errorCallback("A network error occurred."); 161 } 162 }; 163 req.onabort = () => { 164 if (abortCallback) { 165 abortCallback(); 166 } 167 }; 168 169 return req; 170 }; 171 172 class BrowserDownload { 173 path: string; 174 id: string; 175 completeCallback: () => void; 176 toastCallback: () => void; 177 178 constructor( 179 path: string, 180 id: string, 181 completeCallback: () => void, 182 toastCallback: () => void, 183 ) { 184 this.path = path; 185 this.id = id; 186 this.completeCallback = completeCallback; 187 this.toastCallback = toastCallback; 188 } 189 190 send(): void { 191 this.toastCallback(); 192 const link = document.createElement("a"); 193 link.href = this.path; 194 document.body.appendChild(link); 195 link.click(); 196 document.body.removeChild(link); 197 this.completeCallback(); 198 removeTrace(this.id); 199 } 200 } 201 202 export type AllowedPreviews = "image" | "pdf" | "audio" | "video" | "none"; 203 export const contentTypePreview = (contentType: string): AllowedPreviews => { 204 if (contentType) { 205 const mimeObjectType = (contentType || "").toLowerCase(); 206 207 if (mimeObjectType.includes("image")) { 208 return "image"; 209 } 210 if (mimeObjectType.includes("pdf")) { 211 return "pdf"; 212 } 213 if (mimeObjectType.includes("audio")) { 214 return "audio"; 215 } 216 if (mimeObjectType.includes("video")) { 217 return "video"; 218 } 219 } 220 221 return "none"; 222 }; 223 224 // Review file extension by name & returns the type of preview browser that can be used 225 export const extensionPreview = (fileName: string): AllowedPreviews => { 226 const imageExtensions = [ 227 "jif", 228 "jfif", 229 "apng", 230 "avif", 231 "svg", 232 "webp", 233 "bmp", 234 "ico", 235 "jpg", 236 "jpe", 237 "jpeg", 238 "gif", 239 "png", 240 "heic", 241 ]; 242 const pdfExtensions = ["pdf"]; 243 const audioExtensions = ["wav", "mp3", "alac", "aiff", "dsd", "pcm"]; 244 const videoExtensions = [ 245 "mp4", 246 "avi", 247 "mpg", 248 "webm", 249 "mov", 250 "flv", 251 "mkv", 252 "wmv", 253 "avchd", 254 "mpeg-4", 255 ]; 256 257 let fileExtension = fileName.split(".").pop(); 258 259 if (!fileExtension) { 260 return "none"; 261 } 262 263 fileExtension = fileExtension.toLowerCase(); 264 265 if (imageExtensions.includes(fileExtension)) { 266 return "image"; 267 } 268 269 if (pdfExtensions.includes(fileExtension)) { 270 return "pdf"; 271 } 272 273 if (audioExtensions.includes(fileExtension)) { 274 return "audio"; 275 } 276 277 if (videoExtensions.includes(fileExtension)) { 278 return "video"; 279 } 280 281 return "none"; 282 }; 283 284 export const previewObjectType = ( 285 metaData: Record<any, any>, 286 objectName: string, 287 ) => { 288 const metaContentType = ( 289 (metaData && metaData["Content-Type"]) || 290 "" 291 ).toString(); 292 293 const extensionType = extensionPreview(objectName || ""); 294 const contentType = contentTypePreview(metaContentType); 295 296 let objectType: AllowedPreviews = extensionType; 297 298 if (extensionType === contentType) { 299 objectType = extensionType; 300 } else if (extensionType === "none" && contentType !== "none") { 301 objectType = contentType; 302 } else if (contentType === "none" && extensionType !== "none") { 303 objectType = extensionType; 304 } 305 306 return objectType; 307 }; 308 export const sortListObjects = (fieldSort: string) => { 309 switch (fieldSort) { 310 case "name": 311 return (a: BucketObjectItem, b: BucketObjectItem) => 312 a.name.localeCompare(b.name); 313 case "last_modified": 314 return (a: BucketObjectItem, b: BucketObjectItem) => 315 new Date(a.last_modified).getTime() - 316 new Date(b.last_modified).getTime(); 317 case "size": 318 return (a: BucketObjectItem, b: BucketObjectItem) => 319 (a.size || -1) - (b.size || -1); 320 } 321 }; 322 323 export const permissionItems = ( 324 bucketName: string, 325 currentPath: string, 326 permissionsArray: PermissionResource[], 327 ): BucketObjectItem[] | null => { 328 if (permissionsArray.length === 0) { 329 return null; 330 } 331 332 // We get permissions applied to the current bucket 333 const filteredPermissionsForBucket = permissionsArray.filter( 334 (permissionItem) => 335 permissionItem.resource?.endsWith(`:${bucketName}`) || 336 permissionItem.resource?.includes(`:${bucketName}/`), 337 ); 338 339 // No permissions for this bucket. we can throw the error message at this point 340 if (filteredPermissionsForBucket.length === 0) { 341 return null; 342 } 343 344 let returnElements: BucketObjectItem[] = []; 345 346 // We split current path 347 const splitCurrentPath = currentPath.split("/"); 348 349 filteredPermissionsForBucket.forEach((permissionElement) => { 350 // We review paths in resource address 351 352 // We split ARN & get the last item to check the URL 353 const splitARN = permissionElement.resource?.split(":"); 354 const urlARN = splitARN?.pop() || ""; 355 356 // We split the paths of the URL & compare against current location to see if there are more items to include. In case current level is a wildcard or is the last one, we omit this validation 357 358 const splitURLARN = urlARN.split("/"); 359 360 // splitURL has more items than bucket name, we can continue validating 361 if (splitURLARN.length > 1) { 362 splitURLARN.every((currentElementInPath, index) => { 363 // It is a wildcard element. We can store the verification as value should be included (?) 364 if (currentElementInPath === "*") { 365 return false; 366 } 367 368 // Element is not included in the path. The user is trying to browse something else. 369 if ( 370 splitCurrentPath[index] && 371 splitCurrentPath[index] !== currentElementInPath 372 ) { 373 return false; 374 } 375 376 // This element is not included by index in the current paths list. We add it so user can browse into it 377 if (!splitCurrentPath[index]) { 378 returnElements.push({ 379 name: `${currentElementInPath}/`, 380 size: 0, 381 last_modified: "", 382 version_id: "", 383 }); 384 } 385 386 return true; 387 }); 388 } 389 390 // We review prefixes in allow resources for StringEquals variant only. 391 if ( 392 permissionElement.conditionOperator === "StringEquals" || 393 permissionElement.conditionOperator === "StringLike" 394 ) { 395 permissionElement.prefixes?.forEach((prefixItem) => { 396 // Prefix Item is not empty? 397 if (prefixItem !== "") { 398 const splitItems = prefixItem.split("/"); 399 400 let pathToRouteElements: string[] = []; 401 402 // We verify if currentPath is contained in the path begin, if is not contained the user has no access to this subpath 403 const cleanCurrPath = currentPath.replace(/\/$/, ""); 404 405 if (!prefixItem.startsWith(cleanCurrPath) && currentPath !== "") { 406 return; 407 } 408 409 // For every split element we iterate and check if we can construct a URL 410 splitItems.every((splitElement, index) => { 411 if (!splitElement.includes("*") && splitElement !== "") { 412 if (splitElement !== splitCurrentPath[index]) { 413 returnElements.push({ 414 name: `${pathToRouteElements.join("/")}${ 415 pathToRouteElements.length > 0 ? "/" : "" 416 }${splitElement}/`, 417 size: 0, 418 last_modified: "", 419 version_id: "", 420 }); 421 return false; 422 } 423 if (splitElement !== "") { 424 pathToRouteElements.push(splitElement); 425 } 426 427 return true; 428 } 429 return false; 430 }); 431 } 432 }); 433 } 434 }); 435 436 // We clean duplicated name entries 437 if (returnElements.length > 0) { 438 let clElements: BucketObjectItem[] = []; 439 let keys: string[] = []; 440 441 returnElements.forEach((itm) => { 442 if (!keys.includes(itm.name)) { 443 clElements.push(itm); 444 keys.push(itm.name); 445 } 446 }); 447 448 returnElements = clElements; 449 } 450 451 return returnElements; 452 };