@appweaver/core 1.1.3 → 1.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appweaver/core",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Appweaver - the backend framework for AI-first development (@core)",
5
5
  "author": "Luka Matosevic",
6
6
  "license": "MIT",
@@ -53,17 +53,17 @@
53
53
  },
54
54
  "devDependencies": {
55
55
  "@appweaver/common": "^1.0.0",
56
- "@prisma/adapter-better-sqlite3": "7.8.0",
57
- "@prisma/client": "7.8.0",
56
+ "@prisma/adapter-better-sqlite3": "7.9.1",
57
+ "@prisma/client": "7.9.1",
58
58
  "bullmq": "5.79.1",
59
59
  "cron": "4.4.0",
60
60
  "ioredis": "5.11.1",
61
61
  "nodemailer": "9.0.1",
62
- "prisma": "7.8.0",
62
+ "prisma": "7.9.1",
63
63
  "rimraf": "6.1.3"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@appweaver/common": "^1.0.0",
67
- "@prisma/client": "^7.3.0"
67
+ "@prisma/client": "^7.9.0"
68
68
  }
69
69
  }
@@ -19,7 +19,7 @@ export interface PrismaClientConstructor {
19
19
  */
20
20
  new <Options extends Prisma.PrismaClientOptions = Prisma.PrismaClientOptions, LogOpts extends LogOptions<Options> = LogOptions<Options>, OmitOpts extends Prisma.PrismaClientOptions['omit'] = Options extends {
21
21
  omit: infer U;
22
- } ? U : Prisma.PrismaClientOptions['omit'], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs>(options: Prisma.Subset<Options, Prisma.PrismaClientOptions>): PrismaClient<LogOpts, OmitOpts, ExtArgs>;
22
+ } ? U : Prisma.PrismaClientOptions['omit'], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs>(options: Prisma.PrismaClientConstructorArgs<Options>): PrismaClient<LogOpts, OmitOpts, ExtArgs>;
23
23
  }
24
24
  /**
25
25
  * ## Prisma Client
@@ -36,7 +36,7 @@ export interface PrismaClientConstructor {
36
36
  *
37
37
  * Read more in our [docs](https://pris.ly/d/client).
38
38
  */
39
- export interface PrismaClient<in LogOpts extends Prisma.LogLevel = never, in out OmitOpts extends Prisma.PrismaClientOptions['omit'] = undefined, in out ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> {
39
+ export interface PrismaClient<in LogOpts extends Prisma.LogLevel = never, in out OmitOpts extends Prisma.PrismaClientOptions['omit'] = Prisma.PrismaClientOptions['omit'], in out ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> {
40
40
  [K: symbol]: {
41
41
  types: Prisma.TypeMap<ExtArgs>['other'];
42
42
  };
@@ -48,8 +48,8 @@ exports.getPrismaClientClass = getPrismaClientClass;
48
48
  const runtime = __importStar(require("@prisma/client/runtime/client"));
49
49
  const config = {
50
50
  "previewFeatures": [],
51
- "clientVersion": "7.8.0",
52
- "engineVersion": "3c6e192761c0362d496ed980de936e2f3cebcd3a",
51
+ "clientVersion": "7.9.1",
52
+ "engineVersion": "e922089b7d7502aff4249d5da3420f6fa55fc6ad",
53
53
  "activeProvider": "sqlite",
54
54
  "inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Generated by Appweaver. Please do not edit this file manually.\n\ndatasource db {\n provider = \"sqlite\"\n}\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"./client\"\n}\n\nmodel ApiKey {\n id Int @id @default(autoincrement())\n key String\n keyHash String @unique\n name String?\n description String?\n enabled Boolean @default(true)\n expiresAt DateTime?\n\n /// Audit columns\n updatedAt DateTime @updatedAt\n createdAt DateTime @default(now())\n}\n\nmodel OneTimeToken {\n id Int @id @default(autoincrement())\n tokenHash String\n purpose String\n expiresAt DateTime\n data Json\n\n /// Audit columns\n updatedAt DateTime @updatedAt\n createdAt DateTime @default(now())\n\n @@index([tokenHash, purpose])\n}\n\nmodel Permission {\n id Int @id @default(autoincrement())\n name String @unique\n\n /// Related columns\n roles Role[] @relation(\"RolePermissionsPermission\")\n\n /// Audit columns\n updatedAt DateTime @updatedAt\n createdAt DateTime @default(now())\n}\n\nmodel Role {\n id Int @id @default(autoincrement())\n name String @unique\n\n /// Related columns\n permissions Permission[] @relation(\"RolePermissionsPermission\")\n\n /// Audit columns\n updatedAt DateTime @updatedAt\n createdAt DateTime @default(now())\n}\n\nmodel Seeder {\n id String @id @default(uuid())\n checksum String\n seederName String @unique\n startedAt DateTime\n finishedAt DateTime\n logs String?\n\n @@map(\"_seeders\")\n}\n\nmodel File {\n id Int @id @default(autoincrement())\n name String @unique\n originalName String\n mimeType String\n sizeBytes Int\n checksum String\n title String?\n description String?\n resourceField String?\n resourceName String?\n resourceId Int?\n\n /// Audit columns\n updatedAt DateTime @updatedAt\n createdAt DateTime @default(now())\n\n @@index([resourceField, resourceName, resourceId])\n}\n",
55
55
  "runtimeDataModel": {
@@ -46,8 +46,8 @@ export type PrismaVersion = {
46
46
  engine: string;
47
47
  };
48
48
  /**
49
- * Prisma Client JS version: 7.8.0
50
- * Query Engine version: 3c6e192761c0362d496ed980de936e2f3cebcd3a
49
+ * Prisma Client JS version: 7.9.1
50
+ * Query Engine version: e922089b7d7502aff4249d5da3420f6fa55fc6ad
51
51
  */
52
52
  export declare const prismaVersion: PrismaVersion;
53
53
  /**
@@ -105,6 +105,19 @@ export type Enumerable<T> = T | Array<T>;
105
105
  export type Subset<T, U> = {
106
106
  [key in keyof T]: key extends keyof U ? T[key] : never;
107
107
  };
108
+ /**
109
+ * Resolved type of the argument passed to the `PrismaClient` constructor.
110
+ *
111
+ * When called without a narrower options type (the common case), this resolves
112
+ * to `PrismaClientOptions` directly, which produces a clear TypeScript error
113
+ * message (`not assignable to parameter of type 'PrismaClientOptions'`) when
114
+ * the argument is missing or incomplete. When the user supplies a narrower
115
+ * options type (e.g. via a literal), it falls back to `Subset` to keep
116
+ * filtering out unknown properties.
117
+ */
118
+ export type PrismaClientConstructorArgs<Options extends PrismaClientOptions> = [
119
+ PrismaClientOptions
120
+ ] extends [Options] ? PrismaClientOptions : Subset<Options, PrismaClientOptions>;
108
121
  /**
109
122
  * SelectSubset
110
123
  * @desc From `T` pick properties that exist in `U`. Simple version of Intersection.
@@ -127,7 +140,7 @@ type Without<T, U> = {
127
140
  * XOR is needed to have a real mutually exclusive union type
128
141
  * https://stackoverflow.com/questions/42123407/does-typescript-support-mutually-exclusive-types
129
142
  */
130
- export type XOR<T, U> = T extends object ? U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : U : T;
143
+ export type XOR<T, U> = T extends object ? U extends object ? ((Without<T, U> & U) | (Without<U, T> & T)) & object : U : T;
131
144
  /**
132
145
  * Is T a Record?
133
146
  */
@@ -854,19 +867,10 @@ export type BatchPayload = {
854
867
  export declare const defineExtension: runtime.Types.Extensions.ExtendsHook<"define", TypeMapCb, runtime.Types.Extensions.DefaultArgs>;
855
868
  export type DefaultPrismaClient = PrismaClient;
856
869
  export type ErrorFormat = 'pretty' | 'colorless' | 'minimal';
857
- export type PrismaClientOptions = ({
858
- /**
859
- * Instance of a Driver Adapter, e.g., like one provided by `@prisma/adapter-pg`.
860
- */
861
- adapter: runtime.SqlDriverAdapterFactory;
862
- accelerateUrl?: never;
863
- } | {
864
- /**
865
- * Prisma Accelerate URL allowing the client to connect through Accelerate instead of a direct database.
866
- */
867
- accelerateUrl: string;
868
- adapter?: never;
869
- }) & {
870
+ /**
871
+ * Options common to all variants of `PrismaClientOptions`, regardless of whether you connect to your database through a driver adapter or through Prisma Accelerate.
872
+ */
873
+ export interface PrismaClientBaseOptions {
870
874
  /**
871
875
  * @default "colorless"
872
876
  */
@@ -952,7 +956,54 @@ export type PrismaClientOptions = ({
952
956
  * ```
953
957
  */
954
958
  queryPlanCacheMaxSize?: number;
955
- };
959
+ }
960
+ /**
961
+ * `PrismaClient` options for connecting to your database through Prisma Accelerate instead of a driver adapter.
962
+ *
963
+ * Learn more: https://pris.ly/d/accelerate
964
+ */
965
+ export interface PrismaClientOptionsWithAccelerateUrl extends PrismaClientBaseOptions {
966
+ /**
967
+ * The Prisma Accelerate connection URL. Use this option to connect to your database through Prisma Accelerate instead of using a driver adapter to connect directly.
968
+ *
969
+ * Learn more: https://pris.ly/d/accelerate
970
+ */
971
+ accelerateUrl: string;
972
+ adapter?: never;
973
+ }
974
+ /**
975
+ * `PrismaClient` options for connecting to your database through a driver adapter. This is the common case in Prisma 7.
976
+ *
977
+ * Learn more: https://pris.ly/d/driver-adapters
978
+ */
979
+ export interface PrismaClientOptionsWithAdapter extends PrismaClientBaseOptions {
980
+ /**
981
+ * A driver adapter that PrismaClient uses to connect to your database, such as the ones provided by `@prisma/adapter-pg`, `@prisma/adapter-libsql`, `@prisma/adapter-planetscale`, etc.
982
+ *
983
+ * A driver adapter is **required** unless you connect to your database through Prisma Accelerate (in which case use `accelerateUrl` instead).
984
+ *
985
+ * Learn more: https://pris.ly/d/driver-adapters
986
+ *
987
+ * @example
988
+ * ```ts
989
+ * import { PrismaPg } from '@prisma/adapter-pg'
990
+ * import { PrismaClient } from './generated/prisma/client'
991
+ *
992
+ * const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
993
+ * const prisma = new PrismaClient({ adapter })
994
+ * ```
995
+ */
996
+ adapter: runtime.SqlDriverAdapterFactory;
997
+ accelerateUrl?: never;
998
+ }
999
+ /**
1000
+ * Options passed to the `PrismaClient` constructor.
1001
+ *
1002
+ * A driver adapter (or, alternatively, a Prisma Accelerate URL) is **required**. See {@link PrismaClientOptionsWithAdapter} and {@link PrismaClientOptionsWithAccelerateUrl} for the two variants. All other properties live in {@link PrismaClientBaseOptions} and are optional.
1003
+ *
1004
+ * Learn more about driver adapters: https://pris.ly/d/driver-adapters
1005
+ */
1006
+ export type PrismaClientOptions = PrismaClientOptionsWithAccelerateUrl | PrismaClientOptionsWithAdapter;
956
1007
  export type GlobalOmitConfig = {
957
1008
  apiKey?: Prisma.ApiKeyOmit;
958
1009
  oneTimeToken?: Prisma.OneTimeTokenOmit;
@@ -72,12 +72,12 @@ exports.Sql = runtime.Sql;
72
72
  exports.Decimal = runtime.Decimal;
73
73
  exports.getExtensionContext = runtime.Extensions.getExtensionContext;
74
74
  /**
75
- * Prisma Client JS version: 7.8.0
76
- * Query Engine version: 3c6e192761c0362d496ed980de936e2f3cebcd3a
75
+ * Prisma Client JS version: 7.9.1
76
+ * Query Engine version: e922089b7d7502aff4249d5da3420f6fa55fc6ad
77
77
  */
78
78
  exports.prismaVersion = {
79
- client: "7.8.0",
80
- engine: "3c6e192761c0362d496ed980de936e2f3cebcd3a"
79
+ client: "7.9.1",
80
+ engine: "e922089b7d7502aff4249d5da3420f6fa55fc6ad"
81
81
  };
82
82
  exports.NullTypes = {
83
83
  DbNull: runtime.NullTypes.DbNull,
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.loadResources = loadResources;
4
4
  const typebox_1 = require("@sinclair/typebox");
5
5
  const common_1 = require("@appweaver/common");
6
+ const utils_1 = require("../utils");
6
7
  /**
7
8
  * Loads application resources including models, services, policies, and routes.
8
9
  *
@@ -50,6 +51,9 @@ async function loadModels(baseDir, modelPattern) {
50
51
  }
51
52
  }
52
53
  }
54
+ // Reject file name patterns writing into a reserved storage path before any
55
+ // upload can reach the storage layer.
56
+ (0, utils_1.validateFileNamePatterns)(models);
53
57
  // Map model variants to schemas using their corresponding suffixes
54
58
  const resourceModels = {};
55
59
  for (const model of Object.values(models)) {
@@ -10,6 +10,12 @@ const promises_1 = __importDefault(require("node:fs/promises"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
11
  const node_util_1 = require("node:util");
12
12
  const node_crypto_1 = require("node:crypto");
13
+ /** Permissions of the directory holding the security keys (owner only). */
14
+ const KEYS_DIR_MODE = 0o700;
15
+ /** Permissions of the generated private key file (owner read/write only). */
16
+ const PRIVATE_KEY_MODE = 0o600;
17
+ /** Permissions of the generated public key file (owner read/write, others read). */
18
+ const PUBLIC_KEY_MODE = 0o644;
13
19
  async function ensureSecurityKeys(publicKeyPath, privateKeyPath, generateIfNotExists) {
14
20
  try {
15
21
  await promises_1.default.access(publicKeyPath, promises_1.default.constants.F_OK);
@@ -30,10 +36,22 @@ async function generateSecurityKeys(publicKeyPath, privateKeyPath) {
30
36
  publicKeyEncoding: { type: 'spki', format: 'pem' },
31
37
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
32
38
  });
33
- await promises_1.default.mkdir(node_path_1.default.dirname(publicKeyPath), { recursive: true });
34
- await promises_1.default.writeFile(publicKeyPath, publicKey, 'utf8');
35
- await promises_1.default.mkdir(node_path_1.default.dirname(privateKeyPath), { recursive: true });
36
- await promises_1.default.writeFile(privateKeyPath, privateKey, 'utf8');
39
+ await promises_1.default.mkdir(node_path_1.default.dirname(publicKeyPath), {
40
+ recursive: true,
41
+ mode: KEYS_DIR_MODE
42
+ });
43
+ await promises_1.default.writeFile(publicKeyPath, publicKey, {
44
+ encoding: 'utf8',
45
+ mode: PUBLIC_KEY_MODE
46
+ });
47
+ await promises_1.default.mkdir(node_path_1.default.dirname(privateKeyPath), {
48
+ recursive: true,
49
+ mode: KEYS_DIR_MODE
50
+ });
51
+ await promises_1.default.writeFile(privateKeyPath, privateKey, {
52
+ encoding: 'utf8',
53
+ mode: PRIVATE_KEY_MODE
54
+ });
37
55
  }
38
56
  async function loadSecurityKeys(publicKeyPath, privateKeyPath, generateIfNotExists) {
39
57
  const keysExisted = await ensureSecurityKeys(publicKeyPath, privateKeyPath, generateIfNotExists);
@@ -14,7 +14,8 @@ export declare class FileService {
14
14
  *
15
15
  * @param {string} fileName - The name of the file to search for.
16
16
  * @return {Promise<File>} A promise that resolves to the file object if found.
17
- * @throws {HttpError} Throws an error if a database error occurs or if the file is not found.
17
+ * @throws {HttpError} Throws an error if the file name is placed under a reserved storage path, if a database error
18
+ * occurs, or if the file is not found.
18
19
  */
19
20
  findByName(fileName: string): Promise<File>;
20
21
  /**
@@ -20,9 +20,11 @@ class FileService {
20
20
  *
21
21
  * @param {string} fileName - The name of the file to search for.
22
22
  * @return {Promise<File>} A promise that resolves to the file object if found.
23
- * @throws {HttpError} Throws an error if a database error occurs or if the file is not found.
23
+ * @throws {HttpError} Throws an error if the file name is placed under a reserved storage path, if a database error
24
+ * occurs, or if the file is not found.
24
25
  */
25
26
  async findByName(fileName) {
27
+ this.assertPathNotReserved(fileName);
26
28
  let file;
27
29
  try {
28
30
  file = (await this._db.client().file.findFirst({
@@ -135,6 +137,9 @@ class FileService {
135
137
  userId: identity?.id,
136
138
  userEmail: identity?.email
137
139
  });
140
+ // The generated name may come from a name pattern configured as a function,
141
+ // which cannot be validated on startup, so it is checked here as well.
142
+ this.assertPathNotReserved(generatedName);
138
143
  let nameRegenCount = 0;
139
144
  while (await this._storage.exists(generatedName)) {
140
145
  nameRegenCount++;
@@ -400,6 +405,13 @@ class FileService {
400
405
  return deletedFiles;
401
406
  }
402
407
  /** @internal */
408
+ assertPathNotReserved(fileName) {
409
+ const reservedPath = (0, common_1.findReservedStoragePath)(fileName, common_1.config.STORAGE_RESERVED_PATHS);
410
+ if (reservedPath !== null) {
411
+ throw new errors_1.HttpError(`File path '${fileName}' is not allowed`, 400);
412
+ }
413
+ }
414
+ /** @internal */
403
415
  async deleteSafe(fileName) {
404
416
  let success = true;
405
417
  try {
@@ -11,16 +11,19 @@ const promises_2 = require("node:stream/promises");
11
11
  const common_1 = require("@appweaver/common");
12
12
  class FilesystemStorage extends common_1.Storage {
13
13
  /** @internal */
14
- _dirPath = common_1.config.STORAGE_PATH;
14
+ _dirPath = node_path_1.default.resolve(common_1.config.STORAGE_PATH);
15
15
  async onInit() {
16
- const directoryExists = await this.exists('');
16
+ const directoryExists = await this.pathExists(this._dirPath);
17
17
  if (!directoryExists) {
18
18
  await promises_1.default.mkdir(this._dirPath, { recursive: true });
19
19
  common_1.logger.info(`Storage directory initialized: ${this._dirPath}`);
20
20
  }
21
21
  }
22
22
  async stream(fileName, start = 0, end) {
23
- const filePath = `${this._dirPath}/${fileName}`;
23
+ const filePath = this.resolveFilePath(fileName, 'stream');
24
+ if (!filePath) {
25
+ return null;
26
+ }
24
27
  try {
25
28
  const { size } = await promises_1.default.stat(filePath);
26
29
  const endIndex = start >= 0 && !end && end !== 0
@@ -38,10 +41,16 @@ class FilesystemStorage extends common_1.Storage {
38
41
  }
39
42
  }
40
43
  async store(fileName, data) {
41
- const filePath = await this.getFilePath(fileName, true);
44
+ const filePath = this.resolveFilePath(fileName, 'store');
45
+ if (!filePath) {
46
+ // The stream must still be consumed to prevent request from hanging.
47
+ data.resume();
48
+ return null;
49
+ }
42
50
  try {
51
+ await this.ensureDirectoryExists(filePath);
43
52
  await (0, promises_2.pipeline)(data, node_fs_1.default.createWriteStream(filePath));
44
- return fileName;
53
+ return (0, common_1.normalizeStoragePath)(fileName) ?? fileName;
45
54
  }
46
55
  catch (e) {
47
56
  common_1.logger.error(e, `Error storing file: ${filePath}`);
@@ -49,7 +58,10 @@ class FilesystemStorage extends common_1.Storage {
49
58
  }
50
59
  }
51
60
  async delete(fileName) {
52
- const filePath = await this.getFilePath(fileName);
61
+ const filePath = this.resolveFilePath(fileName, 'delete');
62
+ if (!filePath) {
63
+ return false;
64
+ }
53
65
  try {
54
66
  await promises_1.default.unlink(filePath);
55
67
  await this.removeEmptyDirectories(node_path_1.default.dirname(filePath));
@@ -61,14 +73,11 @@ class FilesystemStorage extends common_1.Storage {
61
73
  }
62
74
  }
63
75
  async exists(fileName) {
64
- const filePath = await this.getFilePath(fileName);
65
- try {
66
- await promises_1.default.access(filePath, node_fs_1.default.constants.F_OK);
67
- return true;
68
- }
69
- catch (e) {
76
+ const filePath = this.resolveFilePath(fileName, 'exists');
77
+ if (!filePath) {
70
78
  return false;
71
79
  }
80
+ return this.pathExists(filePath);
72
81
  }
73
82
  async checkHealth() {
74
83
  try {
@@ -80,14 +89,30 @@ class FilesystemStorage extends common_1.Storage {
80
89
  }
81
90
  }
82
91
  /** @internal */
83
- async getFilePath(fileName, mkdir = false) {
84
- const filePath = `${this._dirPath}/${fileName}`;
85
- if (mkdir) {
86
- await this.ensureDirectoryExists(filePath);
92
+ resolveFilePath(fileName, action) {
93
+ const filePath = (0, common_1.resolveStoragePath)(this._dirPath, fileName);
94
+ if (!filePath) {
95
+ common_1.logger.error({ fileName, action }, 'Rejected invalid storage file path');
96
+ return null;
97
+ }
98
+ const reservedPath = (0, common_1.findReservedStoragePath)(fileName, common_1.config.STORAGE_RESERVED_PATHS);
99
+ if (reservedPath !== null) {
100
+ common_1.logger.error({ fileName, action, reservedPath }, 'Rejected reserved storage file path');
101
+ return null;
87
102
  }
88
103
  return filePath;
89
104
  }
90
105
  /** @internal */
106
+ async pathExists(filePath) {
107
+ try {
108
+ await promises_1.default.access(filePath, node_fs_1.default.constants.F_OK);
109
+ return true;
110
+ }
111
+ catch (e) {
112
+ return false;
113
+ }
114
+ }
115
+ /** @internal */
91
116
  async removeEmptyDirectories(filePath) {
92
117
  const normalizedDirPath = node_path_1.default.normalize(filePath);
93
118
  if (!normalizedDirPath ||
@@ -117,10 +142,7 @@ class FilesystemStorage extends common_1.Storage {
117
142
  /** @internal */
118
143
  async ensureDirectoryExists(filePath) {
119
144
  const dirname = node_path_1.default.dirname(filePath);
120
- try {
121
- await promises_1.default.access(dirname, node_fs_1.default.constants.F_OK);
122
- }
123
- catch (e) {
145
+ if (!(await this.pathExists(dirname))) {
124
146
  await promises_1.default.mkdir(dirname, { recursive: true });
125
147
  }
126
148
  }
@@ -1,4 +1,4 @@
1
- import { FilesConfig } from '@appweaver/common';
1
+ import { FilesConfig, ResourceModel } from '@appweaver/common';
2
2
  import { File } from '../types';
3
3
  /**
4
4
  * Builds the access URL for a stored file. The `public` or `protected` path prefix is resolved from the access type
@@ -55,12 +55,31 @@ export declare function isValidMimeType(mimeType: string, mimeTypeExp?: string |
55
55
  */
56
56
  export declare function generateFileName(name: string, pattern?: string, variables?: Record<string, any>): string;
57
57
  /**
58
- * Sanitizes a given filename by removing invalid characters that are not allowed in file systems.
58
+ * Validates that no configured file name pattern writes into a path reserved by `STORAGE_RESERVED_PATHS`. Both the
59
+ * global `STORAGE_NAME_PATTERN` and the `namePattern` of every file field of the provided models are checked. Patterns
60
+ * defined as a factory function cannot be resolved before an upload, so they are validated at runtime instead.
61
+ *
62
+ * @param {Record<string, ResourceModel>} models - The loaded resource models, keyed by model name.
63
+ * @throws {Error} Throws an error naming the model, the field, and the reserved path when a pattern is reserved.
64
+ */
65
+ export declare function validateFileNamePatterns(models: Record<string, ResourceModel>): void;
66
+ /**
67
+ * Sanitizes a given filename by removing invalid characters that are not allowed in file systems. Path separators are
68
+ * preserved, since a file name may be a path relative to the storage root.
59
69
  *
60
70
  * @param fileName The original filename to sanitize.
61
71
  * @return A sanitized filename with invalid characters removed.
62
72
  */
63
73
  export declare function sanitizeFilename(fileName: string): string;
74
+ /**
75
+ * Sanitizes an untrusted value into a single file path segment. In addition to the characters removed by
76
+ * {@link sanitizeFilename}, all path separators are stripped and leading dots are removed, so that the value can never
77
+ * introduce a subdirectory, a `..` traversal or a hidden file.
78
+ *
79
+ * @param value The untrusted value to sanitize.
80
+ * @return A sanitized single path segment, or an empty string when nothing usable remains.
81
+ */
82
+ export declare function sanitizeFileSegment(value?: string | null): string;
64
83
  /**
65
84
  * Aggregates the provided files into a structured object based on the given configuration.
66
85
  *
@@ -6,7 +6,9 @@ exports.maxFileSize = maxFileSize;
6
6
  exports.sizeInBytes = sizeInBytes;
7
7
  exports.isValidMimeType = isValidMimeType;
8
8
  exports.generateFileName = generateFileName;
9
+ exports.validateFileNamePatterns = validateFileNamePatterns;
9
10
  exports.sanitizeFilename = sanitizeFilename;
11
+ exports.sanitizeFileSegment = sanitizeFileSegment;
10
12
  exports.aggregateFiles = aggregateFiles;
11
13
  const common_1 = require("@appweaver/common");
12
14
  const context_1 = require("../context");
@@ -106,25 +108,100 @@ function generateFileName(name, pattern, variables = {}) {
106
108
  nameWithoutExtension = nameParts.join('.');
107
109
  }
108
110
  const defaultPattern = common_1.config.STORAGE_NAME_PATTERN ?? '{name}-{hash}.{extension}';
111
+ // Only the pattern itself is trusted to contain directory separators, every substituted value is reduced to a
112
+ // single path segment so that an uploaded file name cannot introduce a sub-path or a traversal.
109
113
  let fileName = (0, common_1.replacePatternVariables)(pattern ?? defaultPattern, {
110
- name: nameWithoutExtension,
111
- extension,
112
- ...variables
114
+ ...sanitizeVariables(variables),
115
+ name: sanitizeFileSegment(nameWithoutExtension),
116
+ extension: sanitizeFileSegment(extension)
113
117
  });
114
118
  fileName = sanitizeFilename(fileName);
115
119
  fileName = fileName.endsWith('.')
116
120
  ? fileName.substring(0, fileName.length - 1)
117
121
  : fileName;
118
- return fileName || name;
122
+ return ((0, common_1.normalizeStoragePath)(fileName) ||
123
+ sanitizeFileSegment(name) ||
124
+ (0, common_1.generateToken)('bytes', 32));
119
125
  }
120
126
  /**
121
- * Sanitizes a given filename by removing invalid characters that are not allowed in file systems.
127
+ * Validates that no configured file name pattern writes into a path reserved by `STORAGE_RESERVED_PATHS`. Both the
128
+ * global `STORAGE_NAME_PATTERN` and the `namePattern` of every file field of the provided models are checked. Patterns
129
+ * defined as a factory function cannot be resolved before an upload, so they are validated at runtime instead.
130
+ *
131
+ * @param {Record<string, ResourceModel>} models - The loaded resource models, keyed by model name.
132
+ * @throws {Error} Throws an error naming the model, the field, and the reserved path when a pattern is reserved.
133
+ */
134
+ function validateFileNamePatterns(models) {
135
+ const reservedPaths = common_1.config.STORAGE_RESERVED_PATHS;
136
+ const globalReservedPath = (0, common_1.findReservedStoragePath)(common_1.config.STORAGE_NAME_PATTERN, reservedPaths);
137
+ if (globalReservedPath !== null) {
138
+ throw new Error(`Configured STORAGE_NAME_PATTERN '${common_1.config.STORAGE_NAME_PATTERN}' is placed under the reserved ` +
139
+ `storage path '${globalReservedPath}'`);
140
+ }
141
+ for (const model of Object.values(models)) {
142
+ for (const [field, fileConfig] of Object.entries(model.config.files ?? {})) {
143
+ if (!(0, common_1.isString)(fileConfig.namePattern)) {
144
+ continue;
145
+ }
146
+ const reservedPath = (0, common_1.findReservedStoragePath)(fileConfig.namePattern, reservedPaths);
147
+ if (reservedPath !== null) {
148
+ throw new Error(`File name pattern '${fileConfig.namePattern}' of the '${model.name}.${field}' field is placed ` +
149
+ `under the reserved storage path '${reservedPath}'`);
150
+ }
151
+ }
152
+ }
153
+ }
154
+ /**
155
+ * Sanitizes a given filename by removing invalid characters that are not allowed in file systems. Path separators are
156
+ * preserved, since a file name may be a path relative to the storage root.
122
157
  *
123
158
  * @param fileName The original filename to sanitize.
124
159
  * @return A sanitized filename with invalid characters removed.
125
160
  */
126
161
  function sanitizeFilename(fileName) {
127
- return fileName.replace(/[\\:*?"<>|]/g, '');
162
+ const invalidFilenameChars = new Set([
163
+ '\\',
164
+ ':',
165
+ '*',
166
+ '?',
167
+ '"',
168
+ '<',
169
+ '>',
170
+ '|'
171
+ ]);
172
+ return [...fileName]
173
+ .filter((char) => {
174
+ const code = char.charCodeAt(0);
175
+ // Strip control characters (including NUL) and characters invalid in file names.
176
+ return code > 31 && code !== 127 && !invalidFilenameChars.has(char);
177
+ })
178
+ .join('');
179
+ }
180
+ /**
181
+ * Sanitizes an untrusted value into a single file path segment. In addition to the characters removed by
182
+ * {@link sanitizeFilename}, all path separators are stripped and leading dots are removed, so that the value can never
183
+ * introduce a subdirectory, a `..` traversal or a hidden file.
184
+ *
185
+ * @param value The untrusted value to sanitize.
186
+ * @return A sanitized single path segment, or an empty string when nothing usable remains.
187
+ */
188
+ function sanitizeFileSegment(value) {
189
+ if (!(0, common_1.isString)(value)) {
190
+ return '';
191
+ }
192
+ // Trimming must precede the leading dot removal, otherwise a padded value such as ' .. ' keeps its dots.
193
+ return sanitizeFilename(value)
194
+ .replace(/[/\\]+/g, '')
195
+ .trim()
196
+ .replace(/^\.+/, '')
197
+ .trim();
198
+ }
199
+ /** @internal */
200
+ function sanitizeVariables(variables) {
201
+ return Object.fromEntries(Object.entries(variables).map(([key, value]) => [
202
+ key,
203
+ (0, common_1.isString)(value) ? sanitizeFileSegment(value) : value
204
+ ]));
128
205
  }
129
206
  /**
130
207
  * Aggregates the provided files into a structured object based on the given configuration.