21700: Install Bundler system-wide in Rails postinst
[arvados.git] / services / workbench2 / src / services / collection-service / collection-service.ts
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import { CollectionResource, defaultCollectionSelectedFields } from "models/collection";
6 import { AxiosInstance, AxiosResponse } from "axios";
7 import { CollectionFile, CollectionDirectory } from "models/collection-file";
8 import { WebDAV } from "common/webdav";
9 import { AuthService } from "../auth-service/auth-service";
10 import { extractFilesData } from "./collection-service-files-response";
11 import { TrashableResourceService } from "services/common-service/trashable-resource-service";
12 import { ApiActions } from "services/api/api-actions";
13 import { Session } from "models/session";
14 import { CommonService } from "services/common-service/common-service";
15 import { snakeCase } from "lodash";
16 import { CommonResourceServiceError } from "services/common-service/common-resource-service";
17
18 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
19 type CollectionPartialUpdateOrCreate =
20     | (Partial<CollectionResource> & Pick<CollectionResource, "uuid">)
21     | (Partial<CollectionResource> & Pick<CollectionResource, "ownerUuid">);
22
23 type ReplaceFilesPayload = {
24     collection: Partial<CollectionResource>;
25     replace_files: {[key: string]: string};
26 }
27
28 export const emptyCollectionPdh = "d41d8cd98f00b204e9800998ecf8427e+0";
29 export const SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE = "Source and destination cannot be the same";
30
31 export class CollectionService extends TrashableResourceService<CollectionResource> {
32     constructor(serverApi: AxiosInstance, private keepWebdavClient: WebDAV, private authService: AuthService, actions: ApiActions) {
33         super(serverApi, "collections", actions, [
34             "fileCount",
35             "fileSizeTotal",
36             "replicationConfirmed",
37             "replicationConfirmedAt",
38             "storageClassesConfirmed",
39             "storageClassesConfirmedAt",
40             "unsignedManifestText",
41             "version",
42         ]);
43     }
44
45     async get(uuid: string, showErrors?: boolean, select?: string[], session?: Session) {
46         super.validateUuid(uuid);
47         const selectParam = select || defaultCollectionSelectedFields;
48         return super.get(uuid, showErrors, selectParam, session);
49     }
50
51     create(data?: Partial<CollectionResource>, showErrors?: boolean) {
52         return super.create({ ...data, preserveVersion: true }, showErrors);
53     }
54
55     update(uuid: string, data: Partial<CollectionResource>, showErrors?: boolean) {
56         const select = [...Object.keys(data), "version", "modifiedAt"];
57         return super.update(uuid, { ...data, preserveVersion: true }, showErrors, select);
58     }
59
60     async files(uuid: string) {
61         try {
62             const request = await this.keepWebdavClient.propfind(`c=${uuid}`);
63             if (request.responseXML != null) {
64                 return extractFilesData(request.responseXML);
65             }
66         } catch (e) {
67             return Promise.reject(e);
68         }
69         return Promise.reject();
70     }
71
72     private combineFilePath(parts: string[]) {
73         return parts.reduce((path, part) => {
74             // Trim leading and trailing slashes
75             const trimmedPart = part.split("/").filter(Boolean).join("/");
76             if (trimmedPart.length) {
77                 const separator = path.endsWith("/") ? "" : "/";
78                 return `${path}${separator}${trimmedPart}`;
79             } else {
80                 return path;
81             }
82         }, "/");
83     }
84
85     private replaceFiles(data: CollectionPartialUpdateOrCreate, fileMap: {}, showErrors?: boolean) {
86         const payload: ReplaceFilesPayload = {
87             collection: {
88                 preserve_version: true,
89                 ...CommonService.mapKeys(snakeCase)(data),
90                 // Don't send uuid in payload when creating
91                 uuid: undefined,
92             },
93             replace_files: fileMap,
94         };
95         if (data.uuid) {
96             return CommonService.defaultResponse(
97                 this.serverApi.put<ReplaceFilesPayload, AxiosResponse<CollectionResource>>(`/${this.resourceType}/${data.uuid}`, payload),
98                 this.actions,
99                 true, // mapKeys
100                 showErrors
101             );
102         } else {
103             return CommonService.defaultResponse(
104                 this.serverApi.post<ReplaceFilesPayload, AxiosResponse<CollectionResource>>(`/${this.resourceType}`, payload),
105                 this.actions,
106                 true, // mapKeys
107                 showErrors
108             );
109         }
110     }
111
112     async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = "") {
113         if (collectionUuid === "" || files.length === 0) {
114             return;
115         }
116         // files have to be uploaded sequentially
117         for (let idx = 0; idx < files.length; idx++) {
118             await this.uploadFile(collectionUuid, files[idx], idx, onProgress, targetLocation);
119         }
120         await this.update(collectionUuid, { preserveVersion: true });
121     }
122
123     async renameFile(collectionUuid: string, collectionPdh: string, oldPath: string, newPath: string) {
124         return this.replaceFiles(
125             { uuid: collectionUuid },
126             {
127                 [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
128                 [this.combineFilePath([oldPath])]: "",
129             }
130         );
131     }
132
133     extendFileURL = (file: CollectionDirectory | CollectionFile) => {
134         const baseUrl = this.keepWebdavClient.getBaseUrl().endsWith("/")
135             ? this.keepWebdavClient.getBaseUrl().slice(0, -1)
136             : this.keepWebdavClient.getBaseUrl();
137         const apiToken = this.authService.getApiToken();
138         const encodedApiToken = apiToken ? encodeURI(apiToken) : "";
139         const userApiToken = `/t=${encodedApiToken}/`;
140         const splittedPrevFileUrl = file.url.split("/");
141         const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join("/")}`;
142         return {
143             ...file,
144             url,
145         };
146     };
147
148     async getFileContents(file: CollectionFile) {
149         return (await this.keepWebdavClient.get(`c=${file.id}`)).response;
150     }
151
152     private async uploadFile(
153         collectionUuid: string,
154         file: File,
155         fileId: number,
156         onProgress: UploadProgress = () => {
157             return;
158         },
159         targetLocation: string = ""
160     ) {
161         const fileURL = `c=${targetLocation !== "" ? targetLocation : collectionUuid}/${file.name}`.replace("//", "/");
162         const requestConfig = {
163             headers: {
164                 "Content-Type": "text/octet-stream",
165             },
166             onUploadProgress: (e: ProgressEvent) => {
167                 onProgress(fileId, e.loaded, e.total, Date.now());
168             },
169         };
170         return this.keepWebdavClient.upload(fileURL, [file], requestConfig);
171     }
172
173     deleteFiles(collectionUuid: string, files: string[], showErrors?: boolean) {
174         const optimizedFiles = files
175             .sort((a, b) => a.length - b.length)
176             .reduce((acc, currentPath) => {
177                 const parentPathFound = acc.find(parentPath => currentPath.indexOf(`${parentPath}/`) > -1);
178
179                 if (!parentPathFound) {
180                     return [...acc, currentPath];
181                 }
182
183                 return acc;
184             }, []);
185
186         const fileMap = optimizedFiles.reduce((obj, filePath) => {
187             return {
188                 ...obj,
189                 [this.combineFilePath([filePath])]: "",
190             };
191         }, {});
192
193         return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
194     }
195
196     copyFiles(
197         sourcePdh: string,
198         files: string[],
199         destinationCollection: CollectionPartialUpdateOrCreate,
200         destinationPath: string,
201         showErrors?: boolean
202     ) {
203         const fileMap = files.reduce((obj, sourceFile) => {
204             const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
205             return {
206                 ...obj,
207                 [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`,
208             };
209         }, {});
210
211         return this.replaceFiles(destinationCollection, fileMap, showErrors);
212     }
213
214     moveFiles(
215         sourceUuid: string,
216         sourcePdh: string,
217         files: string[],
218         destinationCollection: CollectionPartialUpdateOrCreate,
219         destinationPath: string,
220         showErrors?: boolean
221     ) {
222         if (sourceUuid === destinationCollection.uuid) {
223             let errors: CommonResourceServiceError[] = [];
224             const fileMap = files.reduce((obj, sourceFile) => {
225                 const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
226                 const fileDestinationPath = this.combineFilePath([destinationPath, fileBasename]);
227                 const fileSourcePath = this.combineFilePath([sourceFile]);
228                 const fileSourceUri = `${sourcePdh}${fileSourcePath}`;
229
230                 if (fileDestinationPath !== fileSourcePath) {
231                     return {
232                         ...obj,
233                         [fileDestinationPath]: fileSourceUri,
234                         [fileSourcePath]: "",
235                     };
236                 } else {
237                     errors.push(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME);
238                     return obj;
239                 }
240             }, {});
241
242             if (errors.length === 0) {
243                 return this.replaceFiles({ uuid: sourceUuid }, fileMap, showErrors);
244             } else {
245                 return Promise.reject({ errors });
246             }
247         } else {
248             return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors).then(() => {
249                 return this.deleteFiles(sourceUuid, files, showErrors);
250             });
251         }
252     }
253
254     createDirectory(collectionUuid: string, path: string, showErrors?: boolean) {
255         const fileMap = { [this.combineFilePath([path])]: emptyCollectionPdh };
256
257         return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
258     }
259 }