@technomoron/mail-magic 1.0.5 → 1.0.8

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/lintconfig.cjs ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ const { execSync, spawnSync } = require('child_process');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const RELEASE_API_URL = 'https://api.github.com/repos/technomoron/vscode-eslint-defaults/releases/latest';
8
+ const INSTALLER_ASSET_NAME = 'installer.tgz';
9
+
10
+ async function fetch_json(url) {
11
+ const response = await fetch(url, {
12
+ headers: {
13
+ 'User-Agent': 'vscode-eslint-defaults-lintconfig',
14
+ Accept: 'application/vnd.github+json'
15
+ }
16
+ });
17
+
18
+ if (!response.ok) {
19
+ throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
20
+ }
21
+
22
+ return response.json();
23
+ }
24
+
25
+ async function download_asset(url, destination) {
26
+ const response = await fetch(url, {
27
+ headers: {
28
+ 'User-Agent': 'vscode-eslint-defaults-lintconfig'
29
+ }
30
+ });
31
+
32
+ if (!response.ok) {
33
+ throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
34
+ }
35
+
36
+ const buffer = Buffer.from(await response.arrayBuffer());
37
+ fs.writeFileSync(destination, buffer);
38
+ }
39
+
40
+ async function run() {
41
+ const release = await fetch_json(RELEASE_API_URL);
42
+ const assets = Array.isArray(release.assets) ? release.assets : [];
43
+ const asset = assets.find((item) => item.name === INSTALLER_ASSET_NAME);
44
+
45
+ if (!asset?.browser_download_url) {
46
+ throw new Error('Latest release does not include installer.tgz.');
47
+ }
48
+
49
+ const temp_dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lintconfig-'));
50
+ const tgz_path = path.join(temp_dir, INSTALLER_ASSET_NAME);
51
+ const args = process.argv.slice(2);
52
+ let exit_code = 0;
53
+
54
+ try {
55
+ await download_asset(asset.browser_download_url, tgz_path);
56
+ execSync(`tar -xzf "${tgz_path}" -C "${process.cwd()}"`, { stdio: 'inherit' });
57
+
58
+ const configure_path = path.join(process.cwd(), 'configure-eslint.cjs');
59
+ if (!fs.existsSync(configure_path)) {
60
+ throw new Error('configure-eslint.cjs not found after extraction.');
61
+ }
62
+
63
+ const result = spawnSync(process.execPath, [configure_path, ...args], { stdio: 'inherit' });
64
+ if (result.status !== 0) {
65
+ exit_code = result.status ?? 1;
66
+ } else {
67
+ fs.unlinkSync(configure_path);
68
+ }
69
+ } finally {
70
+ fs.rmSync(temp_dir, { recursive: true, force: true });
71
+ }
72
+
73
+ if (exit_code !== 0) {
74
+ process.exit(exit_code);
75
+ }
76
+ }
77
+
78
+ run().catch((error) => {
79
+ console.error(error.message || error);
80
+ process.exit(1);
81
+ });
package/package.json CHANGED
@@ -1,23 +1,34 @@
1
1
  {
2
2
  "name": "@technomoron/mail-magic",
3
- "version": "1.0.5",
3
+ "version": "1.0.8",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/technomoron/mail-magic.git"
9
9
  },
10
+ "pnpm": {
11
+ "onlyBuiltDependencies": [
12
+ "core-js",
13
+ "@scarf/scarf",
14
+ "esbuild",
15
+ "sqlite3"
16
+ ]
17
+ },
10
18
  "scripts": {
11
19
  "start": "node dist/index.js",
12
20
  "dev": "NODE_ENV=development nodemon --watch 'src/**/*.ts' --watch 'config/**/*.*' --watch '.env' --exec 'tsx' src/index.ts",
13
21
  "run": "NODE_ENV=production npm run start",
14
22
  "build": "tsc",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
15
25
  "scrub": "rm -rf ./node_modules/ ./dist/ pnpm-lock.yaml",
16
- "lint": "eslint --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue ./",
17
- "lintfix": "eslint --fix --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue ./",
18
- "pretty": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,mts,vue,json,css,scss,md}\"",
26
+ "lint": "eslint --no-error-on-unmatched-pattern --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue,.json ./",
27
+ "lintfix": "eslint --fix --no-error-on-unmatched-pattern --ext .js,.cjs,.mjs,.ts,.mts,.tsx,.vue,.json ./",
28
+ "pretty": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,mts,vue,json,md}\"",
19
29
  "format": "npm run lintfix && npm run pretty",
20
- "cleanbuild": "rm -rf ./dist/ && npm run lintfix && npm run format && npm run build"
30
+ "cleanbuild": "rm -rf ./dist/ && npm run format && npm run build",
31
+ "lintconfig": "node lintconfig.cjs"
21
32
  },
22
33
  "keywords": [],
23
34
  "author": "Bjørn Erik Jacobsen",
@@ -27,7 +38,7 @@
27
38
  "url": "https://github.com/technomoron/mail-magic/issues"
28
39
  },
29
40
  "dependencies": {
30
- "@technomoron/api-server-base": "^1.0.40",
41
+ "@technomoron/api-server-base": "2.0.0-beta.15",
31
42
  "@technomoron/env-loader": "^1.0.8",
32
43
  "@technomoron/unyuck": "^1.0.4",
33
44
  "bcryptjs": "^3.0.2",
@@ -45,23 +56,22 @@
45
56
  "@types/html-to-text": "^9.0.4",
46
57
  "@types/nodemailer": "^6.4.19",
47
58
  "@types/nunjucks": "^3.2.6",
48
- "@typescript-eslint/eslint-plugin": "8.44.1",
49
- "@typescript-eslint/parser": "8.44.1",
50
- "@vue/eslint-config-prettier": "10.2.0",
51
- "@vue/eslint-config-typescript": "^14.6.0",
52
- "eslint": "9.36.0",
53
- "eslint-config-prettier": "10.1.8",
54
- "eslint-import-resolver-alias": "1.1.2",
55
- "eslint-plugin-import": "2.32.0",
56
- "eslint-plugin-nuxt": "4.0.0",
57
- "eslint-plugin-prettier": "5.5.4",
58
- "eslint-plugin-vue": "^10.5.0",
59
+ "@types/supertest": "^6.0.3",
60
+ "@typescript-eslint/eslint-plugin": "^8.50.1",
61
+ "@typescript-eslint/parser": "^8.50.1",
62
+ "eslint": "^9.39.2",
63
+ "eslint-config-prettier": "^10.1.8",
64
+ "eslint-plugin-import": "^2.32.0",
59
65
  "jsonc-eslint-parser": "^2.4.1",
66
+ "mailparser": "^3.9.1",
60
67
  "nodemon": "^3.1.10",
61
- "prettier": "3.6.2",
68
+ "prettier": "^3.7.4",
69
+ "smtp-server": "^3.18.0",
70
+ "supertest": "^7.1.4",
62
71
  "tsx": "^4.20.5",
63
72
  "typescript": "^5.9.2",
64
- "vue-eslint-parser": "^10.2.0"
73
+ "vitest": "^4.0.16"
65
74
  },
66
- "homepage": "https://github.com/technomoron/mail-magic#readme"
75
+ "homepage": "https://github.com/technomoron/mail-magic#readme",
76
+ "packageManager": "pnpm@10.26.1+sha512.664074abc367d2c9324fdc18037097ce0a8f126034160f709928e9e9f95d98714347044e5c3164d65bd5da6c59c6be362b107546292a8eecb7999196e5ce58fa"
67
77
  }
@@ -0,0 +1,92 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ import { ApiError, ApiModule, ApiRoute } from '@technomoron/api-server-base';
5
+
6
+ import { mailApiServer } from '../server.js';
7
+ import { decodeComponent, sendFileAsync } from '../util.js';
8
+
9
+ import type { ApiRequest } from '@technomoron/api-server-base';
10
+
11
+ const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
12
+ const SEGMENT_PATTERN = /^[a-zA-Z0-9._-]+$/;
13
+
14
+ export class AssetAPI extends ApiModule<mailApiServer> {
15
+ private async getAsset(apiReq: ApiRequest): Promise<[number, null]> {
16
+ const domain = decodeComponent(apiReq.req.params.domain);
17
+ if (!domain || !DOMAIN_PATTERN.test(domain)) {
18
+ throw new ApiError({ code: 404, message: 'Asset not found' });
19
+ }
20
+
21
+ const rawPath = apiReq.req.params[0] ?? '';
22
+ const segments = rawPath
23
+ .split('/')
24
+ .filter(Boolean)
25
+ .map((segment: string) => decodeComponent(segment));
26
+ if (!segments.length || segments.some((segment) => !SEGMENT_PATTERN.test(segment))) {
27
+ throw new ApiError({ code: 404, message: 'Asset not found' });
28
+ }
29
+
30
+ const assetsRoot = path.join(this.server.storage.configpath, domain, 'assets');
31
+ if (!fs.existsSync(assetsRoot)) {
32
+ throw new ApiError({ code: 404, message: 'Asset not found' });
33
+ }
34
+ const resolvedRoot = fs.realpathSync(assetsRoot);
35
+ const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
36
+ const candidate = path.resolve(assetsRoot, path.join(...segments));
37
+
38
+ try {
39
+ const stats = await fs.promises.stat(candidate);
40
+ if (!stats.isFile()) {
41
+ throw new ApiError({ code: 404, message: 'Asset not found' });
42
+ }
43
+ } catch {
44
+ throw new ApiError({ code: 404, message: 'Asset not found' });
45
+ }
46
+
47
+ let realCandidate: string;
48
+ try {
49
+ realCandidate = await fs.promises.realpath(candidate);
50
+ } catch {
51
+ throw new ApiError({ code: 404, message: 'Asset not found' });
52
+ }
53
+ if (!realCandidate.startsWith(normalizedRoot)) {
54
+ throw new ApiError({ code: 404, message: 'Asset not found' });
55
+ }
56
+
57
+ const { res } = apiReq;
58
+ const originalStatus = res.status.bind(res);
59
+ const originalJson = res.json.bind(res);
60
+ res.status = ((code: number) => (res.headersSent ? res : originalStatus(code))) as typeof res.status;
61
+ res.json = ((body: unknown) => (res.headersSent ? res : originalJson(body))) as typeof res.json;
62
+
63
+ res.type(path.extname(realCandidate));
64
+ res.set('Cache-Control', 'public, max-age=300');
65
+
66
+ try {
67
+ await sendFileAsync(res, realCandidate);
68
+ } catch (err) {
69
+ this.server.storage.print_debug(
70
+ `Failed to serve asset ${domain}/${segments.join('/')}: ${err instanceof Error ? err.message : String(err)}`
71
+ );
72
+ if (!res.headersSent) {
73
+ throw new ApiError({ code: 500, message: 'Failed to stream asset' });
74
+ }
75
+ }
76
+
77
+ return [200, null];
78
+ }
79
+
80
+ override defineRoutes(): ApiRoute[] {
81
+ const route = this.server.storage.env.ASSET_ROUTE;
82
+ const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
83
+ return [
84
+ {
85
+ method: 'get',
86
+ path: `${normalizedRoute}/:domain/*`,
87
+ handler: (apiReq) => this.getAsset(apiReq),
88
+ auth: { type: 'none', req: 'any' }
89
+ }
90
+ ];
91
+ }
92
+ }
package/src/api/forms.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from 'path';
2
2
 
3
3
  import { ApiRoute, ApiRequest, ApiModule, ApiError } from '@technomoron/api-server-base';
4
+ import emailAddresses, { ParsedMailbox } from 'email-addresses';
4
5
  import nunjucks from 'nunjucks';
5
6
 
6
7
  import { api_domain } from '../models/domain.js';
@@ -12,6 +13,14 @@ import { buildRequestMeta, normalizeSlug } from '../util.js';
12
13
  import type { mailApiRequest, UploadedFile } from '../types.js';
13
14
 
14
15
  export class FormAPI extends ApiModule<mailApiServer> {
16
+ private validateEmail(email: string): string | undefined {
17
+ const parsed = emailAddresses.parseOneAddress(email);
18
+ if (parsed) {
19
+ return (parsed as ParsedMailbox).address;
20
+ }
21
+ return undefined;
22
+ }
23
+
15
24
  private async assertDomainAndUser(apireq: mailApiRequest): Promise<void> {
16
25
  const { domain, locale } = apireq.req.body;
17
26
 
@@ -26,6 +35,9 @@ export class FormAPI extends ApiModule<mailApiServer> {
26
35
  if (!dbdomain) {
27
36
  throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
28
37
  }
38
+ if (dbdomain.user_id !== user.user_id) {
39
+ throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
40
+ }
29
41
  apireq.domain = dbdomain;
30
42
  apireq.locale = locale || 'en';
31
43
  apireq.user = user;
@@ -107,7 +119,7 @@ export class FormAPI extends ApiModule<mailApiServer> {
107
119
  }
108
120
 
109
121
  private async postSendForm(apireq: ApiRequest): Promise<[number, Record<string, unknown>]> {
110
- const { formid, secret, recipient, vars = {} } = apireq.req.body;
122
+ const { formid, secret, recipient, vars = {}, replyTo, reply_to } = apireq.req.body;
111
123
 
112
124
  if (!formid) {
113
125
  throw new ApiError({ code: 404, message: 'Missing formid field in form' });
@@ -127,12 +139,27 @@ export class FormAPI extends ApiModule<mailApiServer> {
127
139
  if (recipient && !form.secret) {
128
140
  throw new ApiError({ code: 401, message: "'recipient' parameterer requires form secret to be set" });
129
141
  }
142
+ let normalizedReplyTo: string | undefined;
143
+ let normalizedRecipient: string | undefined;
144
+ const replyToValue = (replyTo || reply_to) as string | undefined;
145
+ if (replyToValue) {
146
+ normalizedReplyTo = this.validateEmail(replyToValue);
147
+ if (!normalizedReplyTo) {
148
+ throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
149
+ }
150
+ }
151
+ if (recipient) {
152
+ normalizedRecipient = this.validateEmail(String(recipient));
153
+ if (!normalizedRecipient) {
154
+ throw new ApiError({ code: 400, message: 'Invalid recipient email address' });
155
+ }
156
+ }
130
157
 
131
158
  let parsedVars: unknown = vars ?? {};
132
159
  if (typeof vars === 'string') {
133
160
  try {
134
161
  parsedVars = JSON.parse(vars);
135
- } catch (error) {
162
+ } catch {
136
163
  throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
137
164
  }
138
165
  }
@@ -172,16 +199,17 @@ export class FormAPI extends ApiModule<mailApiServer> {
172
199
 
173
200
  const mailOptions = {
174
201
  from: form.sender,
175
- to: recipient || form.recipient,
202
+ to: normalizedRecipient || form.recipient,
176
203
  subject: form.subject,
177
204
  html,
178
- attachments
205
+ attachments,
206
+ ...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {})
179
207
  };
180
208
 
181
209
  try {
182
210
  const info = await this.server.storage.transport!.sendMail(mailOptions);
183
211
  this.server.storage.print_debug('Email sent: ' + info.response);
184
- } catch (error) {
212
+ } catch (error: unknown) {
185
213
  const errorMessage = error instanceof Error ? error.message : String(error);
186
214
  this.server.storage.print_debug('Error sending email: ' + errorMessage);
187
215
  return [500, { error: `Error sending email: ${errorMessage}` }];
package/src/api/mailer.ts CHANGED
@@ -15,12 +15,12 @@ export class MailerAPI extends ApiModule<mailApiServer> {
15
15
  //
16
16
  // Validate and return the parsed email address
17
17
  //
18
- validateEmail(email: string): string | null {
18
+ validateEmail(email: string): string | undefined {
19
19
  const parsed = emailAddresses.parseOneAddress(email);
20
20
  if (parsed) {
21
21
  return (parsed as ParsedMailbox).address;
22
22
  }
23
- return null;
23
+ return undefined;
24
24
  }
25
25
 
26
26
  //
@@ -61,6 +61,9 @@ export class MailerAPI extends ApiModule<mailApiServer> {
61
61
  if (!dbdomain) {
62
62
  throw new ApiError({ code: 401, message: `Unable to look up the domain ${domain}` });
63
63
  }
64
+ if (dbdomain.user_id !== user.user_id) {
65
+ throw new ApiError({ code: 403, message: `Domain ${domain} is not owned by this user` });
66
+ }
64
67
  apireq.domain = dbdomain;
65
68
  apireq.locale = locale || 'en';
66
69
  apireq.user = user;
@@ -102,7 +105,7 @@ export class MailerAPI extends ApiModule<mailApiServer> {
102
105
  const [templateRecord, created] = await api_txmail.upsert(data, {
103
106
  returning: true
104
107
  });
105
- console.log('Template upserted:', templateRecord.name, 'Created:', created);
108
+ this.server.storage.print_debug(`Template upserted: ${templateRecord.name} (created=${created})`);
106
109
  } catch (error: unknown) {
107
110
  throw new ApiError({
108
111
  code: 500,
@@ -117,7 +120,7 @@ export class MailerAPI extends ApiModule<mailApiServer> {
117
120
  private async post_send(apireq: mailApiRequest): Promise<[number, Record<string, unknown>]> {
118
121
  await this.assert_domain_and_user(apireq);
119
122
 
120
- const { name, rcpt, domain = '', locale = '', vars = {} } = apireq.req.body;
123
+ const { name, rcpt, domain = '', locale = '', vars = {}, replyTo, reply_to, headers } = apireq.req.body;
121
124
 
122
125
  if (!name || !rcpt || !domain) {
123
126
  throw new ApiError({ code: 400, message: 'name/rcpt/domain required' });
@@ -127,7 +130,7 @@ export class MailerAPI extends ApiModule<mailApiServer> {
127
130
  if (typeof vars === 'string') {
128
131
  try {
129
132
  parsedVars = JSON.parse(vars);
130
- } catch (error) {
133
+ } catch {
131
134
  throw new ApiError({ code: 400, message: 'Invalid JSON provided in "vars"' });
132
135
  }
133
136
  }
@@ -140,7 +143,7 @@ export class MailerAPI extends ApiModule<mailApiServer> {
140
143
  throw new ApiError({ code: 400, message: 'Invalid email address(es): ' + invalid.join(',') });
141
144
  }
142
145
  let template: api_txmail | null = null;
143
- const deflocale = apireq.server.store.deflocale || '';
146
+ const deflocale = this.server.storage.deflocale || '';
144
147
  const domain_id = apireq.domain!.domain_id;
145
148
 
146
149
  try {
@@ -184,9 +187,31 @@ export class MailerAPI extends ApiModule<mailApiServer> {
184
187
  for (const file of rawFiles) {
185
188
  attachmentMap[file.fieldname] = file.originalname;
186
189
  }
187
- console.log(JSON.stringify({ vars, thevars }, undefined, 2));
190
+ this.server.storage.print_debug(`Template vars: ${JSON.stringify({ vars, thevars }, undefined, 2)}`);
188
191
 
189
192
  const meta = buildRequestMeta(apireq.req);
193
+ const replyToValue = (replyTo || reply_to) as string | undefined;
194
+ let normalizedReplyTo: string | undefined;
195
+ if (replyToValue) {
196
+ normalizedReplyTo = this.validateEmail(replyToValue);
197
+ if (!normalizedReplyTo) {
198
+ throw new ApiError({ code: 400, message: 'Invalid reply-to email address' });
199
+ }
200
+ }
201
+
202
+ let normalizedHeaders: Record<string, string> | undefined;
203
+ if (headers !== undefined) {
204
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
205
+ throw new ApiError({ code: 400, message: 'headers must be a key/value object' });
206
+ }
207
+ normalizedHeaders = {};
208
+ for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
209
+ if (typeof value !== 'string') {
210
+ throw new ApiError({ code: 400, message: `headers.${key} must be a string` });
211
+ }
212
+ normalizedHeaders[key] = value;
213
+ }
214
+ }
190
215
 
191
216
  try {
192
217
  const env = new nunjucks.Environment(null, { autoescape: false });
@@ -209,9 +234,11 @@ export class MailerAPI extends ApiModule<mailApiServer> {
209
234
  subject: template.subject || apireq.req.body.subject || '',
210
235
  html,
211
236
  text,
212
- attachments
237
+ attachments,
238
+ ...(normalizedReplyTo ? { replyTo: normalizedReplyTo } : {}),
239
+ ...(normalizedHeaders ? { headers: normalizedHeaders } : {})
213
240
  };
214
- await apireq.server.storage.transport.sendMail(sendargs);
241
+ await this.server.storage.transport!.sendMail(sendargs);
215
242
  }
216
243
  return [200, { Status: 'OK', Message: 'Emails sent successfully' }];
217
244
  } catch (error: unknown) {
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { pathToFileURL } from 'node:url';
2
2
 
3
+ import { AssetAPI } from './api/assets.js';
3
4
  import { FormAPI } from './api/forms.js';
4
5
  import { MailerAPI } from './api/mailer.js';
5
6
  import { mailApiServer } from './server.js';
@@ -22,6 +23,9 @@ function buildServerConfig(store: mailStore, overrides: MailMagicServerOptions):
22
23
  apiPort: env.API_PORT,
23
24
  uploadPath: env.UPLOAD_PATH,
24
25
  debug: env.DEBUG,
26
+ apiBasePath: '',
27
+ swaggerEnabled: env.SWAGGER_ENABLED,
28
+ swaggerPath: env.SWAGGER_PATH,
25
29
  ...overrides
26
30
  };
27
31
  }
@@ -29,7 +33,7 @@ function buildServerConfig(store: mailStore, overrides: MailMagicServerOptions):
29
33
  export async function createMailMagicServer(overrides: MailMagicServerOptions = {}): Promise<MailMagicServerBootstrap> {
30
34
  const store = await new mailStore().init();
31
35
  const config = buildServerConfig(store, overrides);
32
- const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI());
36
+ const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
33
37
 
34
38
  return { server, store, env: store.env };
35
39
  }
@@ -156,6 +156,17 @@ export async function init_api_form(api_db: Sequelize): Promise<typeof api_form>
156
156
  return api_form;
157
157
  }
158
158
 
159
+ function assertSafeRelativePath(filename: string, label: string): string {
160
+ const normalized = path.normalize(filename);
161
+ if (path.isAbsolute(normalized)) {
162
+ throw new Error(`${label} path must be relative`);
163
+ }
164
+ if (normalized.split(path.sep).includes('..')) {
165
+ throw new Error(`${label} path cannot include '..' segments`);
166
+ }
167
+ return normalized;
168
+ }
169
+
159
170
  export async function upsert_form(record: api_form_type): Promise<api_form> {
160
171
  const { user, domain } = await user_and_domain(record.domain_id);
161
172
 
@@ -179,7 +190,7 @@ export async function upsert_form(record: api_form_type): Promise<api_form> {
179
190
  if (!record.filename.endsWith('.njk')) {
180
191
  record.filename += '.njk';
181
192
  }
182
- record.filename = path.normalize(record.filename);
193
+ record.filename = assertSafeRelativePath(record.filename, 'Form filename');
183
194
 
184
195
  let instance: api_form | null = null;
185
196
  instance = await api_form.findByPk(record.form_id);
@@ -28,48 +28,46 @@ const init_data_schema = z.object({
28
28
  type InitData = z.infer<typeof init_data_schema>;
29
29
 
30
30
  /**
31
- * Resolve an asset file within ./config/<userid>/<domain>/<type>/assets
31
+ * Resolve an asset file within ./config/<domain>/assets
32
32
  */
33
- function resolveAsset(
34
- basePath: string,
35
- type: 'form-template' | 'tx-template',
36
- domainName: string,
37
- assetName: string,
38
- locale?: string | null
39
- ): string | null {
40
- const searchPaths: string[] = [];
41
-
42
- // always domain-scoped
43
- if (locale) {
44
- searchPaths.push(path.join(domainName, type, locale));
33
+ function resolveAsset(basePath: string, domainName: string, assetName: string): string | null {
34
+ const assetsRoot = path.join(basePath, domainName, 'assets');
35
+ if (!fs.existsSync(assetsRoot)) {
36
+ return null;
45
37
  }
46
- searchPaths.push(path.join(domainName, type));
47
-
48
- // no domain fallback → do not leak assets between domains
49
- // but allow locale fallbacks inside type
50
- if (locale) {
51
- searchPaths.push(path.join(type, locale));
38
+ const resolvedRoot = fs.realpathSync(assetsRoot);
39
+ const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
40
+ const candidate = path.resolve(assetsRoot, assetName);
41
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
42
+ return null;
52
43
  }
53
- searchPaths.push(type);
54
-
55
- for (const p of searchPaths) {
56
- const candidate = path.join(basePath, p, 'assets', assetName);
57
- if (fs.existsSync(candidate)) {
58
- return candidate;
59
- }
44
+ const realCandidate = fs.realpathSync(candidate);
45
+ if (!realCandidate.startsWith(normalizedRoot)) {
46
+ return null;
60
47
  }
61
- return null;
48
+ return realCandidate;
49
+ }
50
+
51
+ function buildAssetUrl(baseUrl: string, route: string, domainName: string, assetPath: string): string {
52
+ const trimmedBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
53
+ const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
54
+ const encodedDomain = encodeURIComponent(domainName);
55
+ const encodedPath = assetPath
56
+ .split('/')
57
+ .filter((segment) => segment.length > 0)
58
+ .map((segment) => encodeURIComponent(segment))
59
+ .join('/');
60
+ const trailing = encodedPath ? `/${encodedPath}` : '';
61
+ return `${trimmedBase}${normalizedRoute}/${encodedDomain}${trailing}`;
62
62
  }
63
63
 
64
64
  function extractAndReplaceAssets(
65
65
  html: string,
66
66
  opts: {
67
67
  basePath: string;
68
- type: 'form-template' | 'tx-template';
69
68
  domainName: string;
70
- locale?: string | null;
71
69
  apiUrl: string;
72
- idname: string;
70
+ assetRoute: string;
73
71
  }
74
72
  ): { html: string; assets: StoredFile[] } {
75
73
  const regex = /src=["']asset\(['"]([^'"]+)['"](?:,\s*(true|false|[01]))?\)["']/g;
@@ -77,7 +75,7 @@ function extractAndReplaceAssets(
77
75
  const assets: StoredFile[] = [];
78
76
 
79
77
  const replacedHtml = html.replace(regex, (_m, relPath: string, inlineFlag?: string) => {
80
- const fullPath = resolveAsset(opts.basePath, opts.type, opts.domainName, relPath, opts.locale ?? undefined);
78
+ const fullPath = resolveAsset(opts.basePath, opts.domainName, relPath);
81
79
  if (!fullPath) {
82
80
  throw new Error(`Missing asset "${relPath}"`);
83
81
  }
@@ -91,13 +89,18 @@ function extractAndReplaceAssets(
91
89
 
92
90
  assets.push(storedFile);
93
91
 
94
- return isInline
95
- ? `src="cid:${relPath}"`
96
- : `src="${opts.apiUrl}/image/${opts.idname}/${opts.type}/` +
97
- `${opts.domainName ? opts.domainName + '/' : ''}` +
98
- `${opts.locale ? opts.locale + '/' : ''}` +
99
- relPath +
100
- '"';
92
+ if (isInline) {
93
+ return `src="cid:${relPath}"`;
94
+ }
95
+
96
+ const domainAssetsRoot = path.join(opts.basePath, opts.domainName, 'assets');
97
+ const relativeToAssets = path.relative(domainAssetsRoot, fullPath);
98
+ if (!relativeToAssets || relativeToAssets.startsWith('..')) {
99
+ throw new Error(`Asset path escapes domain assets directory: ${fullPath}`);
100
+ }
101
+ const normalizedAssetPath = relativeToAssets.split(path.sep).join('/');
102
+ const assetUrl = buildAssetUrl(opts.apiUrl, opts.assetRoute, opts.domainName, normalizedAssetPath);
103
+ return `src="${assetUrl}"`;
101
104
  });
102
105
 
103
106
  return { html: replacedHtml, assets };
@@ -120,9 +123,11 @@ async function _load_template(
120
123
  relFile = filename.slice(prefix.length);
121
124
  }
122
125
 
123
- const absPath = path.resolve(rootDir, pathname || '', relFile);
126
+ const resolvedRoot = path.resolve(rootDir);
127
+ const normalizedRoot = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
128
+ const absPath = path.resolve(resolvedRoot, pathname || '', relFile);
124
129
 
125
- if (!absPath.startsWith(rootDir)) {
130
+ if (!absPath.startsWith(normalizedRoot)) {
126
131
  throw new Error(`Invalid template path "${filename}"`);
127
132
  }
128
133
  if (!fs.existsSync(absPath)) {
@@ -146,11 +151,9 @@ async function _load_template(
146
151
 
147
152
  const { html, assets } = extractAndReplaceAssets(merged, {
148
153
  basePath: baseConfigPath,
149
- type,
150
154
  domainName: domain.name,
151
- locale,
152
155
  apiUrl: store.env.API_URL,
153
- idname: user.idname
156
+ assetRoute: store.env.ASSET_ROUTE
154
157
  });
155
158
 
156
159
  return { html, assets };