@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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -2
  3. package/config.d.ts +6 -0
  4. package/config.js +22 -0
  5. package/database/create-client.d.ts +11 -0
  6. package/database/create-client.js +145 -0
  7. package/database/database-config.d.ts +14 -0
  8. package/database/database-config.js +40 -0
  9. package/database/database-error.d.ts +49 -0
  10. package/database/database-error.js +216 -0
  11. package/database/index.d.ts +3 -0
  12. package/database/index.js +19 -0
  13. package/database/prisma-database.d.ts +33 -0
  14. package/database/prisma-database.js +178 -0
  15. package/database/run-transaction.d.ts +21 -0
  16. package/database/run-transaction.js +15 -0
  17. package/export/export-config.d.ts +7 -0
  18. package/export/export-config.js +17 -0
  19. package/export/export-service.d.ts +22 -0
  20. package/export/export-service.js +188 -0
  21. package/export/index.d.ts +1 -0
  22. package/export/index.js +17 -0
  23. package/factory/create-model.d.ts +11 -0
  24. package/factory/create-model.js +479 -0
  25. package/factory/create-policy.d.ts +12 -0
  26. package/factory/create-policy.js +20 -0
  27. package/factory/create-service.d.ts +14 -0
  28. package/factory/create-service.js +116 -0
  29. package/index.d.ts +22 -0
  30. package/index.js +38 -0
  31. package/internal.d.ts +2 -0
  32. package/internal.js +6 -0
  33. package/module.d.ts +3 -0
  34. package/module.js +81 -0
  35. package/package.json +67 -3
  36. package/prisma/client/browser.d.ts +39 -0
  37. package/prisma/client/browser.js +56 -0
  38. package/prisma/client/client.d.ts +58 -0
  39. package/prisma/client/client.js +70 -0
  40. package/prisma/client/commonInputTypes.d.ts +345 -0
  41. package/prisma/client/commonInputTypes.js +11 -0
  42. package/prisma/client/enums.d.ts +1 -0
  43. package/prisma/client/enums.js +11 -0
  44. package/prisma/client/internal/class.d.ts +198 -0
  45. package/prisma/client/internal/class.js +85 -0
  46. package/prisma/client/internal/prismaNamespace.d.ts +1125 -0
  47. package/prisma/client/internal/prismaNamespace.js +204 -0
  48. package/prisma/client/internal/prismaNamespaceBrowser.d.ts +137 -0
  49. package/prisma/client/internal/prismaNamespaceBrowser.js +175 -0
  50. package/prisma/client/models/ApiKey.d.ts +1167 -0
  51. package/prisma/client/models/ApiKey.js +2 -0
  52. package/prisma/client/models/ConnectedAccount.d.ts +1094 -0
  53. package/prisma/client/models/ConnectedAccount.js +2 -0
  54. package/prisma/client/models/File.d.ts +1291 -0
  55. package/prisma/client/models/File.js +2 -0
  56. package/prisma/client/models/OneTimeToken.d.ts +1055 -0
  57. package/prisma/client/models/OneTimeToken.js +2 -0
  58. package/prisma/client/models/Permission.d.ts +1209 -0
  59. package/prisma/client/models/Permission.js +2 -0
  60. package/prisma/client/models/Role.d.ts +1209 -0
  61. package/prisma/client/models/Role.js +2 -0
  62. package/prisma/client/models/Seeder.d.ts +1023 -0
  63. package/prisma/client/models/Seeder.js +2 -0
  64. package/prisma/client/models.d.ts +8 -0
  65. package/prisma/client/models.js +2 -0
  66. package/resource/load-data-resources.d.ts +13 -0
  67. package/resource/load-data-resources.js +37 -0
  68. package/resource/model-schema.d.ts +34 -0
  69. package/resource/model-schema.js +54 -0
  70. package/resource/resource-config.d.ts +16 -0
  71. package/resource/resource-config.js +62 -0
  72. package/resource/resource-loader.d.ts +25 -0
  73. package/resource/resource-loader.js +138 -0
  74. package/resource/resource-service.d.ts +462 -0
  75. package/resource/resource-service.js +1042 -0
  76. package/resource/schemas/index.d.ts +3 -0
  77. package/resource/schemas/index.js +19 -0
  78. package/resource/schemas/resource-aggregate-schema.d.ts +53 -0
  79. package/resource/schemas/resource-aggregate-schema.js +130 -0
  80. package/resource/schemas/resource-filter-schema.d.ts +39 -0
  81. package/resource/schemas/resource-filter-schema.js +153 -0
  82. package/resource/schemas/resource-sort-schema.d.ts +38 -0
  83. package/resource/schemas/resource-sort-schema.js +113 -0
  84. package/resource/utils/aggregate-util.d.ts +113 -0
  85. package/resource/utils/aggregate-util.js +322 -0
  86. package/resource/utils/cursor-util.d.ts +60 -0
  87. package/resource/utils/cursor-util.js +116 -0
  88. package/resource/utils/date-util.d.ts +22 -0
  89. package/resource/utils/date-util.js +112 -0
  90. package/resource/utils/delete-util.d.ts +131 -0
  91. package/resource/utils/delete-util.js +385 -0
  92. package/resource/utils/filter-util.d.ts +24 -0
  93. package/resource/utils/filter-util.js +289 -0
  94. package/resource/utils/prune-util.d.ts +11 -0
  95. package/resource/utils/prune-util.js +56 -0
  96. package/resource/utils/relation-util.d.ts +75 -0
  97. package/resource/utils/relation-util.js +400 -0
  98. package/resource/utils/sort-util.d.ts +31 -0
  99. package/resource/utils/sort-util.js +235 -0
  100. package/resource/utils/text-search-util.d.ts +14 -0
  101. package/resource/utils/text-search-util.js +39 -0
  102. package/resources.d.ts +10 -0
  103. package/resources.js +26 -0
  104. package/security/auth-config.d.ts +23 -0
  105. package/security/auth-config.js +43 -0
  106. package/security/create-auth-resources.d.ts +28 -0
  107. package/security/create-auth-resources.js +163 -0
  108. package/security/helper.d.ts +109 -0
  109. package/security/helper.js +298 -0
  110. package/security/resources/api-key/model.d.ts +2 -0
  111. package/security/resources/api-key/model.js +67 -0
  112. package/security/resources/api-key/policy.d.ts +2 -0
  113. package/security/resources/api-key/policy.js +27 -0
  114. package/security/resources/api-key/service.d.ts +3 -0
  115. package/security/resources/api-key/service.js +34 -0
  116. package/security/resources/connected-account/model.d.ts +2 -0
  117. package/security/resources/connected-account/model.js +57 -0
  118. package/security/resources/connected-account/service.d.ts +2 -0
  119. package/security/resources/connected-account/service.js +7 -0
  120. package/security/resources/one-time-token/model.d.ts +2 -0
  121. package/security/resources/one-time-token/model.js +36 -0
  122. package/security/resources/permission/model.d.ts +2 -0
  123. package/security/resources/permission/model.js +16 -0
  124. package/security/resources/role/model.d.ts +2 -0
  125. package/security/resources/role/model.js +22 -0
  126. package/security/store/database-security-store.d.ts +5 -0
  127. package/security/store/database-security-store.js +54 -0
  128. package/seeder/create-seeder.d.ts +29 -0
  129. package/seeder/create-seeder.js +28 -0
  130. package/seeder/index.d.ts +2 -0
  131. package/seeder/index.js +18 -0
  132. package/seeder/resources/seeder/model.d.ts +2 -0
  133. package/seeder/resources/seeder/model.js +38 -0
  134. package/seeder/seeder.d.ts +29 -0
  135. package/seeder/seeder.js +197 -0
  136. package/storage/file-service.d.ts +133 -0
  137. package/storage/file-service.js +586 -0
  138. package/storage/image-config.d.ts +3 -0
  139. package/storage/image-config.js +11 -0
  140. package/storage/resources/file/model.d.ts +2 -0
  141. package/storage/resources/file/model.js +80 -0
  142. package/types/auth.d.ts +23 -0
  143. package/types/auth.js +2 -0
  144. package/types/generated.d.ts +366 -0
  145. package/types/generated.js +3 -0
  146. package/types/storage.d.ts +35 -0
  147. package/types/storage.js +2 -0
  148. package/utils/export-util.d.ts +8 -0
  149. package/utils/export-util.js +26 -0
  150. package/utils/file-util.d.ts +106 -0
  151. package/utils/file-util.js +254 -0
  152. package/utils/model-util.d.ts +36 -0
  153. package/utils/model-util.js +154 -0
  154. package/utils/policy-util.d.ts +18 -0
  155. package/utils/policy-util.js +28 -0
  156. package/utils/virtual-util.d.ts +11 -0
  157. 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,3 @@
1
+ export declare const imageConfig: Readonly<{
2
+ PROVIDER: string;
3
+ }>;
@@ -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,2 @@
1
+ declare const _default: import("@appweaver/common").ResourceModel;
2
+ export default _default;
@@ -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
+ });