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