@appweaver/data 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -2
- package/config.d.ts +6 -0
- package/config.js +22 -0
- package/database/create-client.d.ts +11 -0
- package/database/create-client.js +145 -0
- package/database/database-config.d.ts +14 -0
- package/database/database-config.js +40 -0
- package/database/database-error.d.ts +49 -0
- package/database/database-error.js +216 -0
- package/database/index.d.ts +3 -0
- package/database/index.js +19 -0
- package/database/prisma-database.d.ts +33 -0
- package/database/prisma-database.js +178 -0
- package/database/run-transaction.d.ts +21 -0
- package/database/run-transaction.js +15 -0
- package/export/export-config.d.ts +7 -0
- package/export/export-config.js +17 -0
- package/export/export-service.d.ts +22 -0
- package/export/export-service.js +188 -0
- package/export/index.d.ts +1 -0
- package/export/index.js +17 -0
- package/factory/create-model.d.ts +11 -0
- package/factory/create-model.js +479 -0
- package/factory/create-policy.d.ts +12 -0
- package/factory/create-policy.js +20 -0
- package/factory/create-service.d.ts +14 -0
- package/factory/create-service.js +116 -0
- package/index.d.ts +22 -0
- package/index.js +38 -0
- package/internal.d.ts +2 -0
- package/internal.js +6 -0
- package/module.d.ts +3 -0
- package/module.js +81 -0
- package/package.json +67 -3
- package/prisma/client/browser.d.ts +39 -0
- package/prisma/client/browser.js +56 -0
- package/prisma/client/client.d.ts +58 -0
- package/prisma/client/client.js +70 -0
- package/prisma/client/commonInputTypes.d.ts +345 -0
- package/prisma/client/commonInputTypes.js +11 -0
- package/prisma/client/enums.d.ts +1 -0
- package/prisma/client/enums.js +11 -0
- package/prisma/client/internal/class.d.ts +198 -0
- package/prisma/client/internal/class.js +85 -0
- package/prisma/client/internal/prismaNamespace.d.ts +1125 -0
- package/prisma/client/internal/prismaNamespace.js +204 -0
- package/prisma/client/internal/prismaNamespaceBrowser.d.ts +137 -0
- package/prisma/client/internal/prismaNamespaceBrowser.js +175 -0
- package/prisma/client/models/ApiKey.d.ts +1167 -0
- package/prisma/client/models/ApiKey.js +2 -0
- package/prisma/client/models/ConnectedAccount.d.ts +1094 -0
- package/prisma/client/models/ConnectedAccount.js +2 -0
- package/prisma/client/models/File.d.ts +1291 -0
- package/prisma/client/models/File.js +2 -0
- package/prisma/client/models/OneTimeToken.d.ts +1055 -0
- package/prisma/client/models/OneTimeToken.js +2 -0
- package/prisma/client/models/Permission.d.ts +1209 -0
- package/prisma/client/models/Permission.js +2 -0
- package/prisma/client/models/Role.d.ts +1209 -0
- package/prisma/client/models/Role.js +2 -0
- package/prisma/client/models/Seeder.d.ts +1023 -0
- package/prisma/client/models/Seeder.js +2 -0
- package/prisma/client/models.d.ts +8 -0
- package/prisma/client/models.js +2 -0
- package/resource/load-data-resources.d.ts +13 -0
- package/resource/load-data-resources.js +37 -0
- package/resource/model-schema.d.ts +34 -0
- package/resource/model-schema.js +54 -0
- package/resource/resource-config.d.ts +16 -0
- package/resource/resource-config.js +62 -0
- package/resource/resource-loader.d.ts +25 -0
- package/resource/resource-loader.js +138 -0
- package/resource/resource-service.d.ts +462 -0
- package/resource/resource-service.js +1042 -0
- package/resource/schemas/index.d.ts +3 -0
- package/resource/schemas/index.js +19 -0
- package/resource/schemas/resource-aggregate-schema.d.ts +53 -0
- package/resource/schemas/resource-aggregate-schema.js +130 -0
- package/resource/schemas/resource-filter-schema.d.ts +39 -0
- package/resource/schemas/resource-filter-schema.js +153 -0
- package/resource/schemas/resource-sort-schema.d.ts +38 -0
- package/resource/schemas/resource-sort-schema.js +113 -0
- package/resource/utils/aggregate-util.d.ts +113 -0
- package/resource/utils/aggregate-util.js +322 -0
- package/resource/utils/cursor-util.d.ts +60 -0
- package/resource/utils/cursor-util.js +116 -0
- package/resource/utils/date-util.d.ts +22 -0
- package/resource/utils/date-util.js +112 -0
- package/resource/utils/delete-util.d.ts +131 -0
- package/resource/utils/delete-util.js +385 -0
- package/resource/utils/filter-util.d.ts +24 -0
- package/resource/utils/filter-util.js +289 -0
- package/resource/utils/prune-util.d.ts +11 -0
- package/resource/utils/prune-util.js +56 -0
- package/resource/utils/relation-util.d.ts +75 -0
- package/resource/utils/relation-util.js +400 -0
- package/resource/utils/sort-util.d.ts +31 -0
- package/resource/utils/sort-util.js +235 -0
- package/resource/utils/text-search-util.d.ts +14 -0
- package/resource/utils/text-search-util.js +39 -0
- package/resources.d.ts +10 -0
- package/resources.js +26 -0
- package/security/auth-config.d.ts +23 -0
- package/security/auth-config.js +43 -0
- package/security/create-auth-resources.d.ts +28 -0
- package/security/create-auth-resources.js +163 -0
- package/security/helper.d.ts +109 -0
- package/security/helper.js +298 -0
- package/security/resources/api-key/model.d.ts +2 -0
- package/security/resources/api-key/model.js +67 -0
- package/security/resources/api-key/policy.d.ts +2 -0
- package/security/resources/api-key/policy.js +27 -0
- package/security/resources/api-key/service.d.ts +3 -0
- package/security/resources/api-key/service.js +34 -0
- package/security/resources/connected-account/model.d.ts +2 -0
- package/security/resources/connected-account/model.js +57 -0
- package/security/resources/connected-account/service.d.ts +2 -0
- package/security/resources/connected-account/service.js +7 -0
- package/security/resources/one-time-token/model.d.ts +2 -0
- package/security/resources/one-time-token/model.js +36 -0
- package/security/resources/permission/model.d.ts +2 -0
- package/security/resources/permission/model.js +16 -0
- package/security/resources/role/model.d.ts +2 -0
- package/security/resources/role/model.js +22 -0
- package/security/store/database-security-store.d.ts +5 -0
- package/security/store/database-security-store.js +54 -0
- package/seeder/create-seeder.d.ts +29 -0
- package/seeder/create-seeder.js +28 -0
- package/seeder/index.d.ts +2 -0
- package/seeder/index.js +18 -0
- package/seeder/resources/seeder/model.d.ts +2 -0
- package/seeder/resources/seeder/model.js +38 -0
- package/seeder/seeder.d.ts +29 -0
- package/seeder/seeder.js +197 -0
- package/storage/file-service.d.ts +133 -0
- package/storage/file-service.js +586 -0
- package/storage/image-config.d.ts +3 -0
- package/storage/image-config.js +11 -0
- package/storage/resources/file/model.d.ts +2 -0
- package/storage/resources/file/model.js +80 -0
- package/types/auth.d.ts +23 -0
- package/types/auth.js +2 -0
- package/types/generated.d.ts +366 -0
- package/types/generated.js +3 -0
- package/types/storage.d.ts +35 -0
- package/types/storage.js +2 -0
- package/utils/export-util.d.ts +8 -0
- package/utils/export-util.js +26 -0
- package/utils/file-util.d.ts +106 -0
- package/utils/file-util.js +254 -0
- package/utils/model-util.d.ts +36 -0
- package/utils/model-util.js +154 -0
- package/utils/policy-util.d.ts +18 -0
- package/utils/policy-util.js +28 -0
- package/utils/virtual-util.d.ts +11 -0
- package/utils/virtual-util.js +53 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FileService = void 0;
|
|
4
|
+
const node_stream_1 = require("node:stream");
|
|
5
|
+
const common_1 = require("@appweaver/common");
|
|
6
|
+
const core_1 = require("@appweaver/core");
|
|
7
|
+
const database_error_1 = require("../database/database-error");
|
|
8
|
+
const policy_util_1 = require("../utils/policy-util");
|
|
9
|
+
const file_util_1 = require("../utils/file-util");
|
|
10
|
+
const model_util_1 = require("../utils/model-util");
|
|
11
|
+
const internal_1 = require("@appweaver/core/internal");
|
|
12
|
+
class FileService {
|
|
13
|
+
/** @internal */
|
|
14
|
+
_db = (0, core_1.inject)(common_1.Database);
|
|
15
|
+
/** @internal */
|
|
16
|
+
_storage = (0, core_1.inject)(common_1.Storage);
|
|
17
|
+
/** @internal */
|
|
18
|
+
_cacheService = (0, core_1.inject)(core_1.CacheService);
|
|
19
|
+
/** @internal */
|
|
20
|
+
_imageProcessor = (0, core_1.inject)(common_1.ImageProcessor, false);
|
|
21
|
+
/**
|
|
22
|
+
* Searches for a file by its name from a database.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} fileName - The name of the file to search for.
|
|
25
|
+
* @return {Promise<File>} A promise that resolves to the file object if found.
|
|
26
|
+
* @throws {FileError} Throws an error if the file name is placed under a reserved storage path, if a database error
|
|
27
|
+
* occurs, or if the file is not found.
|
|
28
|
+
*/
|
|
29
|
+
async findByName(fileName) {
|
|
30
|
+
this.assertPathNotReserved(fileName);
|
|
31
|
+
let file;
|
|
32
|
+
try {
|
|
33
|
+
// The files a deleted resource keeps are retained, but no longer served
|
|
34
|
+
file = (await this._db.client().file.findFirst({
|
|
35
|
+
where: { name: fileName, ...(0, model_util_1.liveRecordFilter)('File') }
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
throw (0, database_error_1.toDatabaseError)(e, 'File');
|
|
40
|
+
}
|
|
41
|
+
if (!file) {
|
|
42
|
+
throw new common_1.FileError(common_1.ErrorCode.FileNotFound, 'File does not exist', {
|
|
43
|
+
fileName
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return file;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Streams a file from storage, handling authorization and range-based requests.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} fileName - The name of the file to stream.
|
|
52
|
+
* @param {string} [range] - An optional range header specifying the byte range to stream.
|
|
53
|
+
* @return {Promise<FileStream>} A promise that resolves to a file stream object containing the content, metadata, and
|
|
54
|
+
* range details.
|
|
55
|
+
* @throws {FileError} `FILE_NOT_FOUND` if the file does not exist.
|
|
56
|
+
* @throws {FileError} `FILE_FORBIDDEN` if user access to the file is forbidden.
|
|
57
|
+
* @throws {FileError} `FILE_STORAGE_ERROR` if an error occurs while reading the file from storage.
|
|
58
|
+
*/
|
|
59
|
+
async stream(fileName, range) {
|
|
60
|
+
const file = await this.authorizeRead(fileName);
|
|
61
|
+
const parsedRange = (0, file_util_1.parseRange)(range);
|
|
62
|
+
const start = parsedRange.start;
|
|
63
|
+
let end = parsedRange.end;
|
|
64
|
+
const fileStream = await this._storage.stream(fileName, start, end);
|
|
65
|
+
if (!fileStream) {
|
|
66
|
+
throw new common_1.FileError(common_1.ErrorCode.FileStorageError, 'Error reading file from storage');
|
|
67
|
+
}
|
|
68
|
+
if (end === undefined || end >= fileStream.size || end >= file.sizeBytes) {
|
|
69
|
+
end = (fileStream.size ?? file.sizeBytes) - 1;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
content: fileStream,
|
|
73
|
+
fileName: file.name.split('/')[0],
|
|
74
|
+
mimeType: file.mimeType,
|
|
75
|
+
start,
|
|
76
|
+
end
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Opens a file for a download, handling authorization like {@link stream}: the URL of its storage when the storage
|
|
81
|
+
* serves its files directly, otherwise the stream of the file.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} fileName - The name of the file to download.
|
|
84
|
+
* @param {string} [range] - An optional range header specifying the byte range to stream.
|
|
85
|
+
* @return {Promise<FileStream | FileRedirect>} The URL of the file, or its stream.
|
|
86
|
+
* @throws {FileError} The errors of {@link stream}.
|
|
87
|
+
*/
|
|
88
|
+
async download(fileName, range) {
|
|
89
|
+
const file = await this.authorizeRead(fileName);
|
|
90
|
+
const url = await this._storage.url(fileName, {
|
|
91
|
+
expiresIn: core_1.storageConfig.URL_TTL
|
|
92
|
+
});
|
|
93
|
+
if (url) {
|
|
94
|
+
return {
|
|
95
|
+
url,
|
|
96
|
+
fileName: file.name.split('/')[0],
|
|
97
|
+
mimeType: file.mimeType
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return this.stream(fileName, range);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Saves a file to storage, validates its properties, and associates it with a specific resource and client.
|
|
104
|
+
*
|
|
105
|
+
* @param {UploadedFile} data The multipart file object containing the file data, field name, filename, and metadata.
|
|
106
|
+
* @param {Resource} resource The resource object with which the file is being associated.
|
|
107
|
+
* @param {ResourceClient} client The database client responsible for handling the resource.
|
|
108
|
+
* @return {Promise<File>} A promise that resolves to the saved file object or rejects with an error if the operation
|
|
109
|
+
* fails.
|
|
110
|
+
* @throws {FileError} Throws an error if file validation, storage, or resource association fails.
|
|
111
|
+
*/
|
|
112
|
+
async saveFile(data, resource, client) {
|
|
113
|
+
return this.saveContent({
|
|
114
|
+
fieldName: data.fieldname,
|
|
115
|
+
fileName: data.filename,
|
|
116
|
+
mimeType: data.mimetype,
|
|
117
|
+
encoding: data.encoding,
|
|
118
|
+
stream: data.file,
|
|
119
|
+
bytesRead: () => data.file.bytesRead,
|
|
120
|
+
truncated: () => data.file.truncated
|
|
121
|
+
}, resource, client);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Saves an in-memory file to storage and associates it with a file field of a specific resource. Behaves exactly like
|
|
125
|
+
* {@link saveFile}, except the content is taken from a buffer instead of a multipart upload, which makes it usable
|
|
126
|
+
* outside a file upload request, for an avatar downloaded from an OAuth2 provider or an image the application
|
|
127
|
+
* generates itself.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} fieldName The file field of the resource the file is saved to.
|
|
130
|
+
* @param {FileBuffer} file The file content, along with its original name and media type.
|
|
131
|
+
* @param {Resource} resource The resource object with which the file is being associated.
|
|
132
|
+
* @param {ResourceClient} client The database client responsible for handling the resource.
|
|
133
|
+
* @return {Promise<File>} A promise that resolves to the saved file object or rejects with an error if the operation
|
|
134
|
+
* fails.
|
|
135
|
+
* @throws {FileError} Throws an error if file validation, storage, or resource association fails.
|
|
136
|
+
*/
|
|
137
|
+
async saveBuffer(fieldName, file, resource, client) {
|
|
138
|
+
const size = file.size ?? file.data.length;
|
|
139
|
+
return this.saveContent({
|
|
140
|
+
fieldName,
|
|
141
|
+
fileName: file.name,
|
|
142
|
+
mimeType: file.mimeType,
|
|
143
|
+
encoding: file.encoding ?? '7bit',
|
|
144
|
+
stream: node_stream_1.Readable.from(file.data),
|
|
145
|
+
bytesRead: () => size,
|
|
146
|
+
truncated: () => false
|
|
147
|
+
}, resource, client);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Saves files received as an asynchronous iterable of multipart data.
|
|
151
|
+
*
|
|
152
|
+
* @param {AsyncIterableIterator<UploadPart>} parts - The asynchronous iterable providing multipart data where each
|
|
153
|
+
* part is expected to represent a file or other data.
|
|
154
|
+
* @param {Resource} resource - The resource associated with the files being saved, providing additional contextual
|
|
155
|
+
* information.
|
|
156
|
+
* @param {ResourceClient} client - The database client responsible for handling resource-related operations, such as
|
|
157
|
+
* database interaction.
|
|
158
|
+
* @return {Promise<File[]>} A promise that resolves to an array of successfully saved files. Rejects with an error
|
|
159
|
+
* if one or more files fail to save.
|
|
160
|
+
*/
|
|
161
|
+
async saveFiles(parts, resource, client) {
|
|
162
|
+
const saveActions = [];
|
|
163
|
+
for await (const part of parts) {
|
|
164
|
+
if (part.type === 'file') {
|
|
165
|
+
saveActions.push(this.saveFile(part, resource, client));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const saveResults = await Promise.allSettled(saveActions);
|
|
169
|
+
const savedFiles = saveResults
|
|
170
|
+
.filter((r) => r.status === 'fulfilled')
|
|
171
|
+
.map((r) => r.value);
|
|
172
|
+
const errors = saveResults
|
|
173
|
+
.filter((r) => r.status === 'rejected')
|
|
174
|
+
.map((r) => r.reason);
|
|
175
|
+
if (errors.length > 0) {
|
|
176
|
+
// Delete all successfully saved files in storage and rows in a database.
|
|
177
|
+
const deleteActions = savedFiles.map((f) => this.deleteSafe(f.name));
|
|
178
|
+
await Promise.all(deleteActions);
|
|
179
|
+
const errorMessage = errors.join('\n');
|
|
180
|
+
throw new common_1.FileError(common_1.ErrorCode.FileUploadFailed, `Error while saving files: ${errorMessage}`, { errors });
|
|
181
|
+
}
|
|
182
|
+
await this._cacheService.invalidateCache(client.name, 'uploadFiles');
|
|
183
|
+
return savedFiles;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Deletes a file from the storage and database if it meets the specified conditions.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} fileName - The name of the file to be deleted.
|
|
189
|
+
* @param {string} fieldName - The field associated with the resource to which the file belongs.
|
|
190
|
+
* @param {Resource} resource - The resource that the file is associated with.
|
|
191
|
+
* @param {ResourceClient} client - The client instance representing the resource's type.
|
|
192
|
+
* @return {Promise<File>} A promise that resolves with the deleted file information upon successful deletion.
|
|
193
|
+
* @throws {FileError} If the file is not associated with the provided resource or client,
|
|
194
|
+
* if the current user does not have permission to delete the file,
|
|
195
|
+
* if there is an error removing the file from storage,
|
|
196
|
+
* or if there is an error deleting the file from the database.
|
|
197
|
+
*/
|
|
198
|
+
async deleteFile(fileName, fieldName, resource, client) {
|
|
199
|
+
const currentUser = (0, core_1.currentAuthUser)();
|
|
200
|
+
const file = await this.findByName(fileName);
|
|
201
|
+
if (file.resourceId !== String(resource.id) ||
|
|
202
|
+
file.resourceName !== client.name ||
|
|
203
|
+
file.resourceField !== fieldName) {
|
|
204
|
+
throw new common_1.FileError(common_1.ErrorCode.FileForbidden, `File does not belong to a '${client.name}' resource`, { fileName, action: 'delete' });
|
|
205
|
+
}
|
|
206
|
+
const policy = this.getFilePolicy(file.resourceName, file.resourceField);
|
|
207
|
+
if (!(0, policy_util_1.arePoliciesSkipped)() &&
|
|
208
|
+
(await policy.canDelete?.(currentUser ?? null, resource, file)) === false) {
|
|
209
|
+
throw new common_1.FileError(common_1.ErrorCode.FileForbidden, 'Deleting file is forbidden', { fileName, action: 'delete' });
|
|
210
|
+
}
|
|
211
|
+
const result = await this.deleteStored(fileName);
|
|
212
|
+
if (!result) {
|
|
213
|
+
throw new common_1.FileError(common_1.ErrorCode.FileStorageError, 'Error deleting file from storage');
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const deletedFile = (await this._db.client().file.delete({
|
|
217
|
+
where: { name: fileName }
|
|
218
|
+
}));
|
|
219
|
+
deletedFile.url = (0, file_util_1.buildFileUrl)(deletedFile);
|
|
220
|
+
common_1.logger.debug({ deletedFile }, 'File deleted');
|
|
221
|
+
return deletedFile;
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
throw (0, database_error_1.toDatabaseError)(e, 'File');
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Deletes a list of files associated with a given resource using the provided resource client.
|
|
229
|
+
*
|
|
230
|
+
* @param {Record<string, string | string[]>} fileNames - An object where the keys correspond to file fields and the
|
|
231
|
+
* values are either a string (single file name) or an array of file names to be deleted.
|
|
232
|
+
* @param {Resource} resource - The resource instance associated with the files to be deleted.
|
|
233
|
+
* @param {ResourceClient} client - The resource a client used to handle deletion operations.
|
|
234
|
+
* @return {Promise<File[]>} A promise that resolves to an array of files that were successfully deleted. Rejects with
|
|
235
|
+
* an error if no files could be deleted.
|
|
236
|
+
*/
|
|
237
|
+
async deleteFiles(fileNames, resource, client) {
|
|
238
|
+
if (Object.keys(fileNames ?? {}).length === 0) {
|
|
239
|
+
throw new common_1.FileError(common_1.ErrorCode.FileNoneProvided, 'No files for deletion are provided');
|
|
240
|
+
}
|
|
241
|
+
const deleteActions = [];
|
|
242
|
+
for (const [field, value] of Object.entries(fileNames)) {
|
|
243
|
+
const names = typeof value === 'string' ? [value] : value;
|
|
244
|
+
for (const name of names) {
|
|
245
|
+
deleteActions.push(this.deleteFile(name, field, resource, client));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const deleteResults = await Promise.allSettled(deleteActions);
|
|
249
|
+
const deletedFiles = deleteResults
|
|
250
|
+
.filter((r) => r.status === 'fulfilled')
|
|
251
|
+
.map((r) => r.value);
|
|
252
|
+
const errors = deleteResults
|
|
253
|
+
.filter((r) => r.status === 'rejected')
|
|
254
|
+
.map((r) => r.reason);
|
|
255
|
+
if (errors.length > 0 && deletedFiles.length === 0) {
|
|
256
|
+
const errorMessage = errors.join('\n');
|
|
257
|
+
throw new common_1.FileError(common_1.ErrorCode.FileDeleteFailed, `Error while deleting files: ${errorMessage}`, { errors });
|
|
258
|
+
}
|
|
259
|
+
await this._cacheService.invalidateCache(client.name, 'deleteFiles');
|
|
260
|
+
return deletedFiles;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Deletes all files associated with a resource removed from the database,
|
|
264
|
+
* for the file fields not set to `onResourceDeleted: 'keep'`.
|
|
265
|
+
*
|
|
266
|
+
* @param {string} resourceName - The resource model name.
|
|
267
|
+
* @param {ResourceId} id - The ID of the deleted resource.
|
|
268
|
+
* @return {Promise<File[]>} A promise that resolves to the list of successfully deleted files.
|
|
269
|
+
*/
|
|
270
|
+
async deleteResourceFiles(resourceName, id) {
|
|
271
|
+
return this.deleteResourcesFiles(resourceName, [id]);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Deletes all files associated with the given deleted resources of one model from the storage and the database. A
|
|
275
|
+
* resource removed from the database loses the files of every field not set to `onResourceDeleted: 'keep'`, while a
|
|
276
|
+
* soft deleted resource only loses the files of the fields set to `onResourceSoftDeleted: 'delete'`.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} resourceName - The resource model name.
|
|
279
|
+
* @param {ResourceId[]} ids - The IDs of the deleted resources.
|
|
280
|
+
* @param {boolean} [softDeleted=false] - Whether the resources were soft deleted.
|
|
281
|
+
* @return {Promise<File[]>} A promise that resolves to the list of successfully deleted files.
|
|
282
|
+
*/
|
|
283
|
+
async deleteResourcesFiles(resourceName, ids, softDeleted = false) {
|
|
284
|
+
// Owning ids are stored as text, whatever the model primary key type is
|
|
285
|
+
const resourceIds = ids.map(String);
|
|
286
|
+
const resourceModel = (0, core_1.injectModel)(resourceName, false);
|
|
287
|
+
if (!resourceModel || resourceIds.length === 0) {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
const { deleted: deleteFields } = (0, file_util_1.deletedResourceFileFields)(resourceModel.config.files, softDeleted);
|
|
291
|
+
if (deleteFields.length === 0) {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
let files;
|
|
295
|
+
try {
|
|
296
|
+
files = (await this._db.client().file.findMany({
|
|
297
|
+
where: {
|
|
298
|
+
resourceName,
|
|
299
|
+
resourceId: { in: resourceIds },
|
|
300
|
+
resourceField: { in: deleteFields }
|
|
301
|
+
}
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
common_1.logger.error({ resourceName, resourceIds, error: e }, 'Error finding resource files for deletion');
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
const deletedFiles = [];
|
|
309
|
+
for (const file of files) {
|
|
310
|
+
const success = await this.deleteSafe(file.name);
|
|
311
|
+
if (success) {
|
|
312
|
+
deletedFiles.push(file);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
common_1.logger.error({ resourceName, resourceId: file.resourceId, fileName: file.name }, 'Failed to delete file');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (deletedFiles.length > 0) {
|
|
319
|
+
common_1.logger.debug({ resourceName, resourceIds, deletedCount: deletedFiles.length }, 'Resource files deleted');
|
|
320
|
+
}
|
|
321
|
+
return deletedFiles;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Finds a file, checking that the current user may read it.
|
|
325
|
+
*
|
|
326
|
+
* @internal
|
|
327
|
+
*/
|
|
328
|
+
async authorizeRead(fileName) {
|
|
329
|
+
const identity = (0, core_1.currentAuthUser)();
|
|
330
|
+
const file = await this.findByName(fileName);
|
|
331
|
+
if (!file) {
|
|
332
|
+
throw new common_1.FileError(common_1.ErrorCode.FileNotFound, 'File does not exist', {
|
|
333
|
+
fileName
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const policy = this.getFilePolicy(file.resourceName, file.resourceField);
|
|
337
|
+
if (!identity && policy.accessType !== 'public') {
|
|
338
|
+
throw new common_1.FileError(common_1.ErrorCode.FileForbidden, 'Public file access is forbidden', { fileName, action: 'read' });
|
|
339
|
+
}
|
|
340
|
+
if (policy.accessType === 'private' && identity?.id !== file.createdById) {
|
|
341
|
+
throw new common_1.FileError(common_1.ErrorCode.FileForbidden, 'Private file access is forbidden', { fileName, action: 'read' });
|
|
342
|
+
}
|
|
343
|
+
if (policy.accessType == 'protected' &&
|
|
344
|
+
file.resourceName &&
|
|
345
|
+
file.resourceId) {
|
|
346
|
+
const resourceService = (0, core_1.injectService)(file.resourceName);
|
|
347
|
+
// The owning id is stored as text, so it is converted back to the
|
|
348
|
+
// primary key type of that model before the lookup
|
|
349
|
+
const resource = await resourceService.find((0, common_1.toResourceId)(file.resourceId, (0, core_1.injectModel)(file.resourceName, false)?.config?.id));
|
|
350
|
+
if (!(0, policy_util_1.arePoliciesSkipped)() &&
|
|
351
|
+
(await policy.canAccess?.(identity ?? null, resource, file)) === false) {
|
|
352
|
+
throw new common_1.FileError(common_1.ErrorCode.FileForbidden, 'File access is forbidden', { fileName, action: 'read' });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return file;
|
|
356
|
+
}
|
|
357
|
+
/** @internal */
|
|
358
|
+
async saveContent(data, resource, client) {
|
|
359
|
+
const identity = (0, core_1.currentAuthUser)();
|
|
360
|
+
// Checked against the model rather than the resource object, so a file field
|
|
361
|
+
// kept out of the resource output is still writable
|
|
362
|
+
const fileConfig = this.getFileConfig(client.name, data.fieldName);
|
|
363
|
+
if (!fileConfig) {
|
|
364
|
+
throw new common_1.FileError(common_1.ErrorCode.FileFieldNotFound, `File field '${data.fieldName}' does not exist on resource '${client.name}'`, { model: client.name, field: data.fieldName });
|
|
365
|
+
}
|
|
366
|
+
const policy = this.getFilePolicy(client.name, data.fieldName);
|
|
367
|
+
if (!(0, file_util_1.isValidMimeType)(data.mimeType, fileConfig.mimeType)) {
|
|
368
|
+
throw new common_1.FileError(common_1.ErrorCode.FileUnsupportedType, `Unsupported media file type: ${data.mimeType}`, { mimeType: data.mimeType });
|
|
369
|
+
}
|
|
370
|
+
if (fileConfig.array) {
|
|
371
|
+
const fileCount = await this.fileCount(data.fieldName, client.name, String(resource.id));
|
|
372
|
+
if (fileConfig.maxCount && fileConfig.maxCount < fileCount + 1) {
|
|
373
|
+
throw new common_1.FileError(common_1.ErrorCode.FileLimitExceeded, `Maximum number of files allowed: ${fileConfig.maxCount}`, { maxCount: fileConfig.maxCount });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
let pattern;
|
|
377
|
+
if ((0, common_1.isFunction)(fileConfig.namePattern)) {
|
|
378
|
+
pattern = fileConfig.namePattern({
|
|
379
|
+
fieldName: data.fieldName,
|
|
380
|
+
fileName: data.fileName,
|
|
381
|
+
encoding: data.encoding,
|
|
382
|
+
mimeType: data.mimeType,
|
|
383
|
+
bytesRead: data.bytesRead(),
|
|
384
|
+
truncated: data.truncated()
|
|
385
|
+
}, resource);
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
pattern = fileConfig.namePattern;
|
|
389
|
+
}
|
|
390
|
+
let generatedName = (0, file_util_1.generateFileName)(data.fileName, pattern, {
|
|
391
|
+
resourceField: data.fieldName,
|
|
392
|
+
resourceName: client.name,
|
|
393
|
+
resourceId: resource.id,
|
|
394
|
+
userId: identity?.id,
|
|
395
|
+
userEmail: identity?.email
|
|
396
|
+
});
|
|
397
|
+
// The generated name may come from a name pattern configured as a function,
|
|
398
|
+
// which cannot be validated on startup, so it is checked here as well.
|
|
399
|
+
this.assertPathNotReserved(generatedName);
|
|
400
|
+
let nameRegenCount = 0;
|
|
401
|
+
while (await this._storage.exists(generatedName)) {
|
|
402
|
+
nameRegenCount++;
|
|
403
|
+
if (nameRegenCount === 10) {
|
|
404
|
+
throw new common_1.FileError(common_1.ErrorCode.FileStorageError, 'Unable to generate unique file name');
|
|
405
|
+
}
|
|
406
|
+
const hash = (0, common_1.generateToken)('bytes');
|
|
407
|
+
const nameParts = generatedName.split('.');
|
|
408
|
+
if (nameParts.length > 1) {
|
|
409
|
+
const ext = nameParts.pop();
|
|
410
|
+
const base = nameParts.join('.');
|
|
411
|
+
generatedName = `${base}-${hash}.${ext}`;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
generatedName = `${nameParts[0]}-${hash}`;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const createFile = {
|
|
418
|
+
name: generatedName,
|
|
419
|
+
originalName: data.fileName,
|
|
420
|
+
mimeType: data.mimeType,
|
|
421
|
+
sizeBytes: data.bytesRead(),
|
|
422
|
+
resourceField: data.fieldName,
|
|
423
|
+
resourceName: client.name,
|
|
424
|
+
resourceId: String(resource.id),
|
|
425
|
+
createdById: identity?.id
|
|
426
|
+
};
|
|
427
|
+
if (!(0, policy_util_1.arePoliciesSkipped)() &&
|
|
428
|
+
(await policy.canCreate?.(identity ?? null, resource, createFile)) ===
|
|
429
|
+
false) {
|
|
430
|
+
throw new common_1.FileError(common_1.ErrorCode.FileForbidden, 'Creating file is forbidden', { action: 'create' });
|
|
431
|
+
}
|
|
432
|
+
let fileStream = data.stream;
|
|
433
|
+
if (this._imageProcessor?.supports(data.mimeType)) {
|
|
434
|
+
fileStream = this._imageProcessor.process(data.stream, data.mimeType, fileConfig.image);
|
|
435
|
+
}
|
|
436
|
+
// Tee the stream so the checksum and the stored size are calculated over the
|
|
437
|
+
// exact bytes written to storage in a single pass, without buffering the file.
|
|
438
|
+
let storedBytes = 0;
|
|
439
|
+
const storageStream = new node_stream_1.Transform({
|
|
440
|
+
transform(chunk, _encoding, callback) {
|
|
441
|
+
storedBytes += chunk.length;
|
|
442
|
+
callback(null, chunk);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
const checksumStream = new node_stream_1.PassThrough();
|
|
446
|
+
fileStream.pipe(storageStream);
|
|
447
|
+
fileStream.pipe(checksumStream);
|
|
448
|
+
// pipe() does not forward source errors to destinations, so both branches
|
|
449
|
+
// must be destroyed manually to avoid hanging on a failed upload stream.
|
|
450
|
+
fileStream.on('error', (e) => {
|
|
451
|
+
common_1.logger.error(e, 'Error calculating file checksum');
|
|
452
|
+
storageStream.destroy(e);
|
|
453
|
+
checksumStream.destroy(e);
|
|
454
|
+
});
|
|
455
|
+
const [fileName, checksum] = await Promise.all([
|
|
456
|
+
this._storage.store(generatedName, storageStream, {
|
|
457
|
+
contentType: data.mimeType
|
|
458
|
+
}),
|
|
459
|
+
(0, common_1.makeHash)(checksumStream)
|
|
460
|
+
]);
|
|
461
|
+
if (!fileName) {
|
|
462
|
+
throw new common_1.FileError(common_1.ErrorCode.FileStorageError, 'Error saving file to storage');
|
|
463
|
+
}
|
|
464
|
+
(0, internal_1.afterRollback)(() => this._storage.delete(fileName));
|
|
465
|
+
createFile.name = fileName;
|
|
466
|
+
createFile.checksum = checksum;
|
|
467
|
+
// The size of the content in storage, which is the processed one for images
|
|
468
|
+
createFile.sizeBytes = storedBytes;
|
|
469
|
+
// The size limit applies to the received content, whose read size and
|
|
470
|
+
// truncation flag are only known once the stream has been consumed.
|
|
471
|
+
const maxSizeBytes = (0, file_util_1.sizeInBytes)(fileConfig.maxSize);
|
|
472
|
+
if (data.truncated() ||
|
|
473
|
+
(maxSizeBytes > 0 && data.bytesRead() > maxSizeBytes)) {
|
|
474
|
+
await this._storage.delete(fileName);
|
|
475
|
+
throw new common_1.FileError(common_1.ErrorCode.FileTooLarge, `File size exceeded limit of ${maxSizeBytes} bytes`, { maxSizeBytes });
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
// Check if a resource already has a file for a single file property and
|
|
479
|
+
// delete it after successfully creating the new one.
|
|
480
|
+
let existingFile = null;
|
|
481
|
+
if (!fileConfig.array) {
|
|
482
|
+
const resourceWithFile = await client.findFirst({
|
|
483
|
+
where: { id: resource.id },
|
|
484
|
+
include: { [data.fieldName]: true }
|
|
485
|
+
});
|
|
486
|
+
existingFile = resourceWithFile[data.fieldName];
|
|
487
|
+
}
|
|
488
|
+
const result = await client.update({
|
|
489
|
+
where: { id: resource.id },
|
|
490
|
+
data: {
|
|
491
|
+
[data.fieldName]: {
|
|
492
|
+
create: createFile
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
include: { [data.fieldName]: true }
|
|
496
|
+
});
|
|
497
|
+
if (existingFile !== null) {
|
|
498
|
+
await this.deleteSafe(existingFile.name);
|
|
499
|
+
}
|
|
500
|
+
let file = result[data.fieldName];
|
|
501
|
+
if ((0, common_1.isArray)(result[data.fieldName])) {
|
|
502
|
+
file = result[data.fieldName].find((f) => f.name === createFile.name);
|
|
503
|
+
}
|
|
504
|
+
file.url = (0, file_util_1.buildFileUrl)(file);
|
|
505
|
+
common_1.logger.debug({ file }, 'File saved');
|
|
506
|
+
return file;
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
await this.deleteSafe(fileName);
|
|
510
|
+
throw (0, database_error_1.toDatabaseError)(e, 'File');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/** @internal */
|
|
514
|
+
assertPathNotReserved(fileName) {
|
|
515
|
+
const reservedPath = (0, common_1.findReservedStoragePath)(fileName, core_1.storageConfig.RESERVED_PATHS);
|
|
516
|
+
if (reservedPath !== null) {
|
|
517
|
+
throw new common_1.FileError(common_1.ErrorCode.FileInvalidPath, `File path '${fileName}' is not allowed`, { fileName });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Removes a file from storage, or once the current transaction commits, so a
|
|
522
|
+
* rollback cannot leave a database row pointing at a removed file.
|
|
523
|
+
*
|
|
524
|
+
* @internal
|
|
525
|
+
*/
|
|
526
|
+
async deleteStored(fileName) {
|
|
527
|
+
if ((0, internal_1.currentTransaction)()) {
|
|
528
|
+
(0, internal_1.afterCommit)(() => this._storage.delete(fileName));
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
return this._storage.delete(fileName);
|
|
532
|
+
}
|
|
533
|
+
/** @internal */
|
|
534
|
+
async deleteSafe(fileName) {
|
|
535
|
+
let success = true;
|
|
536
|
+
try {
|
|
537
|
+
const result = await this.deleteStored(fileName);
|
|
538
|
+
if (!result) {
|
|
539
|
+
success = false;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
success = false;
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
await this._db.client().file.delete({
|
|
547
|
+
where: { name: fileName }
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
success = false;
|
|
552
|
+
}
|
|
553
|
+
return success;
|
|
554
|
+
}
|
|
555
|
+
/** @internal */
|
|
556
|
+
async fileCount(resourceField, resourceName, resourceId) {
|
|
557
|
+
try {
|
|
558
|
+
return await this._db.client().file.count({
|
|
559
|
+
where: { resourceField, resourceName, resourceId }
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
catch (e) {
|
|
563
|
+
throw (0, database_error_1.toDatabaseError)(e, 'File');
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/** Returns undefined when the model has no such file field configured.
|
|
567
|
+
* @internal */
|
|
568
|
+
getFileConfig(resourceName, resourceField) {
|
|
569
|
+
if (!resourceName || !resourceField) {
|
|
570
|
+
return undefined;
|
|
571
|
+
}
|
|
572
|
+
const config = (0, core_1.injectModel)(resourceName, false)?.config.files?.[resourceField];
|
|
573
|
+
return config ? { ...config } : undefined;
|
|
574
|
+
}
|
|
575
|
+
/** @internal */
|
|
576
|
+
getFilePolicy(resourceName, resourceField) {
|
|
577
|
+
if (!resourceName || !resourceField) {
|
|
578
|
+
return {};
|
|
579
|
+
}
|
|
580
|
+
const policy = (0, core_1.injectPolicy)(resourceName, false)?.files?.[resourceField] ?? {};
|
|
581
|
+
// Without an access type, a file is protected, which also runs the custom
|
|
582
|
+
// access check and the read restrictions of the owning resource
|
|
583
|
+
return { accessType: 'protected', ...policy };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
exports.FileService = FileService;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.imageConfig = void 0;
|
|
4
|
+
const typebox_1 = require("@sinclair/typebox");
|
|
5
|
+
const common_1 = require("@appweaver/common");
|
|
6
|
+
exports.imageConfig = (0, common_1.defineConfig)('image', {
|
|
7
|
+
/** Image processor transforming the uploaded images: `auto` (`sharp` when its module is enabled, otherwise none,
|
|
8
|
+
* keeping the images as they are uploaded), `sharp`, `none`, or the module path of an implementation. Default:
|
|
9
|
+
* `'auto'`. */
|
|
10
|
+
PROVIDER: typebox_1.Type.String({ default: 'auto' })
|
|
11
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const create_model_1 = require("../../../factory/create-model");
|
|
4
|
+
const file_util_1 = require("../../../utils/file-util");
|
|
5
|
+
exports.default = (0, create_model_1.createModel)({
|
|
6
|
+
name: 'File',
|
|
7
|
+
// The files a deleted resource keeps are retained for audit, but no longer
|
|
8
|
+
// served, by marking their rows deleted together with the resource
|
|
9
|
+
softDelete: true,
|
|
10
|
+
scalars: {
|
|
11
|
+
name: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
maxLength: 1023,
|
|
14
|
+
unique: true,
|
|
15
|
+
example: 'image_123.png'
|
|
16
|
+
},
|
|
17
|
+
originalName: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
maxLength: 255,
|
|
20
|
+
example: 'image.png'
|
|
21
|
+
},
|
|
22
|
+
mimeType: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
maxLength: 127
|
|
25
|
+
},
|
|
26
|
+
sizeBytes: {
|
|
27
|
+
type: 'int',
|
|
28
|
+
minimum: 0,
|
|
29
|
+
example: 1024
|
|
30
|
+
},
|
|
31
|
+
checksum: {
|
|
32
|
+
type: 'string',
|
|
33
|
+
maxLength: 127,
|
|
34
|
+
example: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'
|
|
35
|
+
},
|
|
36
|
+
title: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
maxLength: 511,
|
|
39
|
+
required: false
|
|
40
|
+
},
|
|
41
|
+
description: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
maxLength: 4095,
|
|
44
|
+
required: false
|
|
45
|
+
},
|
|
46
|
+
resourceField: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
maxLength: 255,
|
|
49
|
+
required: false,
|
|
50
|
+
hidden: true
|
|
51
|
+
},
|
|
52
|
+
resourceName: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
maxLength: 255,
|
|
55
|
+
required: false,
|
|
56
|
+
hidden: true
|
|
57
|
+
},
|
|
58
|
+
// Polymorphic reference without a foreign key, stored as text so a file can
|
|
59
|
+
// belong to a resource with either primary key type
|
|
60
|
+
resourceId: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
maxLength: 36,
|
|
63
|
+
required: false,
|
|
64
|
+
hidden: true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
virtual: {
|
|
68
|
+
url: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
input: {
|
|
71
|
+
type: 'none'
|
|
72
|
+
},
|
|
73
|
+
output: {
|
|
74
|
+
value: (file) => (0, file_util_1.buildFileUrl)(file)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
// Leads with the exact match columns, so the prefix also serves lookups
|
|
79
|
+
index: [['resourceName', 'resourceId', 'resourceField']]
|
|
80
|
+
});
|