@technomoron/mail-magic 1.0.16 → 1.0.23
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/CHANGES +48 -0
- package/dist/api/assets.js +19 -8
- package/dist/api/auth.js +7 -2
- package/dist/api/forms.js +480 -82
- package/dist/api/mailer.js +2 -2
- package/dist/index.js +57 -25
- package/dist/models/db.js +29 -2
- package/dist/models/form.js +23 -2
- package/dist/models/init.js +13 -3
- package/dist/models/recipient.js +65 -0
- package/dist/models/user.js +40 -3
- package/dist/server.js +19 -7
- package/dist/store/envloader.js +55 -0
- package/dist/store/store.js +10 -1
- package/dist/util.js +16 -6
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { FormAPI } from './api/forms.js';
|
|
|
4
4
|
import { MailerAPI } from './api/mailer.js';
|
|
5
5
|
import { mailApiServer } from './server.js';
|
|
6
6
|
import { mailStore } from './store/store.js';
|
|
7
|
+
const DOMAIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
7
8
|
function normalizeRoute(value, fallback = '') {
|
|
8
9
|
if (!value) {
|
|
9
10
|
return fallback;
|
|
@@ -18,12 +19,20 @@ function normalizeRoute(value, fallback = '') {
|
|
|
18
19
|
}
|
|
19
20
|
return withLeading.replace(/\/+$/, '');
|
|
20
21
|
}
|
|
22
|
+
function mergeStaticDirs(base, override) {
|
|
23
|
+
const merged = { ...base, ...(override ?? {}) };
|
|
24
|
+
if (Object.keys(merged).length === 0) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return merged;
|
|
28
|
+
}
|
|
21
29
|
function buildServerConfig(store, overrides) {
|
|
22
30
|
const env = store.env;
|
|
23
31
|
return {
|
|
24
32
|
apiHost: env.API_HOST,
|
|
25
33
|
apiPort: env.API_PORT,
|
|
26
34
|
uploadPath: store.getUploadStagingPath(),
|
|
35
|
+
uploadMax: env.UPLOAD_MAX,
|
|
27
36
|
debug: env.DEBUG,
|
|
28
37
|
apiBasePath: normalizeRoute(env.API_BASE_PATH, '/api'),
|
|
29
38
|
swaggerEnabled: env.SWAGGER_ENABLED,
|
|
@@ -36,11 +45,40 @@ export async function createMailMagicServer(overrides = {}) {
|
|
|
36
45
|
if (typeof overrides.apiBasePath === 'string') {
|
|
37
46
|
store.env.API_BASE_PATH = overrides.apiBasePath;
|
|
38
47
|
}
|
|
39
|
-
const
|
|
48
|
+
const baseStaticDirs = {};
|
|
49
|
+
let adminUiPath = null;
|
|
50
|
+
if (store.env.ADMIN_ENABLED) {
|
|
51
|
+
adminUiPath = await resolveAdminUiPath(store);
|
|
52
|
+
if (adminUiPath) {
|
|
53
|
+
baseStaticDirs['/'] = adminUiPath;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const mergedOverrides = {
|
|
57
|
+
...overrides,
|
|
58
|
+
staticDirs: mergeStaticDirs(baseStaticDirs, overrides.staticDirs)
|
|
59
|
+
};
|
|
60
|
+
const config = buildServerConfig(store, mergedOverrides);
|
|
40
61
|
const server = new mailApiServer(config, store).api(new MailerAPI()).api(new FormAPI()).api(new AssetAPI());
|
|
41
|
-
|
|
62
|
+
// Serve domain assets from a public route with traversal protection and caching.
|
|
63
|
+
const assetRoute = normalizeRoute(store.env.ASSET_ROUTE, '/asset');
|
|
64
|
+
const assetPrefix = assetRoute === '/' ? '' : assetRoute;
|
|
65
|
+
const apiBasePath = normalizeRoute(store.env.API_BASE_PATH, '/api');
|
|
66
|
+
const apiBasePrefix = apiBasePath === '/' ? '' : apiBasePath;
|
|
67
|
+
const assetHandler = createAssetHandler(server);
|
|
68
|
+
const assetMounts = new Set();
|
|
69
|
+
assetMounts.add(assetPrefix);
|
|
70
|
+
// Integration tests (and API_URL defaults) expect assets to also be reachable under the API base path.
|
|
71
|
+
if (apiBasePrefix && assetPrefix && !assetPrefix.startsWith(`${apiBasePrefix}/`)) {
|
|
72
|
+
assetMounts.add(`${apiBasePrefix}${assetPrefix}`);
|
|
73
|
+
}
|
|
74
|
+
for (const prefix of assetMounts) {
|
|
75
|
+
// Express 5 (path-to-regexp v8) requires wildcard params to be named.
|
|
76
|
+
// Use ApiServer.useExpress() so mounts under `apiBasePath` are installed on the API router
|
|
77
|
+
// (and remain reachable before the API 404 handler).
|
|
78
|
+
server.useExpress(`${prefix}/:domain/*path`, assetHandler);
|
|
79
|
+
}
|
|
42
80
|
if (store.env.ADMIN_ENABLED) {
|
|
43
|
-
await enableAdminFeatures(server, store);
|
|
81
|
+
await enableAdminFeatures(server, store, adminUiPath);
|
|
44
82
|
}
|
|
45
83
|
else {
|
|
46
84
|
store.print_debug('Admin UI/API disabled via ADMIN_ENABLED');
|
|
@@ -76,15 +114,28 @@ const isDirectExecution = (() => {
|
|
|
76
114
|
if (isDirectExecution) {
|
|
77
115
|
void bootMailMagic();
|
|
78
116
|
}
|
|
79
|
-
async function
|
|
117
|
+
async function resolveAdminUiPath(store) {
|
|
118
|
+
try {
|
|
119
|
+
const mod = (await import('@technomoron/mail-magic-admin'));
|
|
120
|
+
if (typeof mod?.resolveAdminDist === 'function') {
|
|
121
|
+
return mod.resolveAdminDist(store.env.ADMIN_APP_PATH, (message) => store.print_debug(message));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
store.print_debug(`Unable to resolve admin UI path: ${err instanceof Error ? err.message : String(err)}`);
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
async function enableAdminFeatures(server, store, adminUiPath) {
|
|
80
130
|
try {
|
|
81
131
|
const mod = (await import('@technomoron/mail-magic-admin'));
|
|
82
132
|
if (typeof mod?.registerAdmin === 'function') {
|
|
83
133
|
await mod.registerAdmin(server, {
|
|
84
134
|
apiBasePath: normalizeRoute(store.env.API_BASE_PATH, '/api'),
|
|
85
135
|
assetRoute: normalizeRoute(store.env.ASSET_ROUTE, '/asset'),
|
|
86
|
-
appPath: store.env.ADMIN_APP_PATH,
|
|
87
|
-
logger: (message) => store.print_debug(message)
|
|
136
|
+
appPath: adminUiPath ?? store.env.ADMIN_APP_PATH,
|
|
137
|
+
logger: (message) => store.print_debug(message),
|
|
138
|
+
staticFallback: Boolean(adminUiPath)
|
|
88
139
|
});
|
|
89
140
|
}
|
|
90
141
|
else if (mod?.AdminAPI) {
|
|
@@ -98,22 +149,3 @@ async function enableAdminFeatures(server, store) {
|
|
|
98
149
|
store.print_debug(`Unable to load admin module: ${err instanceof Error ? err.message : String(err)}`);
|
|
99
150
|
}
|
|
100
151
|
}
|
|
101
|
-
function mountAssetRoute(server, store) {
|
|
102
|
-
const normalizedRoute = normalizeRoute(store.env.ASSET_ROUTE, '/asset');
|
|
103
|
-
server.app.get(`${normalizedRoute}/:domain/*`, createAssetHandler(server));
|
|
104
|
-
ensureApiNotFoundLast(server);
|
|
105
|
-
}
|
|
106
|
-
function ensureApiNotFoundLast(server) {
|
|
107
|
-
const anyServer = server;
|
|
108
|
-
const handler = anyServer.apiNotFoundHandler;
|
|
109
|
-
const stack = anyServer.app?._router?.stack;
|
|
110
|
-
if (!handler || !Array.isArray(stack)) {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
const index = stack.findIndex((layer) => layer?.handle === handler);
|
|
114
|
-
if (index === -1 || index === stack.length - 1) {
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
const [layer] = stack.splice(index, 1);
|
|
118
|
-
stack.push(layer);
|
|
119
|
-
}
|
package/dist/models/db.js
CHANGED
|
@@ -2,13 +2,15 @@ import { Sequelize } from 'sequelize';
|
|
|
2
2
|
import { init_api_domain, api_domain } from './domain.js';
|
|
3
3
|
import { init_api_form, api_form } from './form.js';
|
|
4
4
|
import { importData } from './init.js';
|
|
5
|
+
import { init_api_recipient, api_recipient } from './recipient.js';
|
|
5
6
|
import { init_api_txmail, api_txmail } from './txmail.js';
|
|
6
|
-
import { init_api_user, api_user } from './user.js';
|
|
7
|
+
import { init_api_user, api_user, migrateLegacyApiTokens } from './user.js';
|
|
7
8
|
export async function init_api_db(db, store) {
|
|
8
9
|
await init_api_user(db);
|
|
9
10
|
await init_api_domain(db);
|
|
10
11
|
await init_api_txmail(db);
|
|
11
12
|
await init_api_form(db);
|
|
13
|
+
await init_api_recipient(db);
|
|
12
14
|
// User ↔ Domain
|
|
13
15
|
api_user.hasMany(api_domain, {
|
|
14
16
|
foreignKey: 'user_id',
|
|
@@ -60,11 +62,29 @@ export async function init_api_db(db, store) {
|
|
|
60
62
|
foreignKey: 'domain_id',
|
|
61
63
|
as: 'domain'
|
|
62
64
|
});
|
|
65
|
+
// Domain ↔ Recipient (form recipient allowlist)
|
|
66
|
+
api_domain.hasMany(api_recipient, {
|
|
67
|
+
foreignKey: 'domain_id',
|
|
68
|
+
as: 'recipients'
|
|
69
|
+
});
|
|
70
|
+
api_recipient.belongsTo(api_domain, {
|
|
71
|
+
foreignKey: 'domain_id',
|
|
72
|
+
as: 'domain'
|
|
73
|
+
});
|
|
63
74
|
await db.query('PRAGMA foreign_keys = OFF');
|
|
64
75
|
store.print_debug(`Force alter tables: ${store.env.DB_FORCE_SYNC}`);
|
|
65
76
|
await db.sync({ alter: true, force: store.env.DB_FORCE_SYNC });
|
|
66
77
|
await db.query('PRAGMA foreign_keys = ON');
|
|
67
78
|
await importData(store);
|
|
79
|
+
try {
|
|
80
|
+
const { migrated, cleared } = await migrateLegacyApiTokens(store.env.API_TOKEN_PEPPER);
|
|
81
|
+
if (migrated || cleared) {
|
|
82
|
+
store.print_debug(`Migrated ${migrated} legacy API token(s) and cleared ${cleared} plaintext token(s).`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
store.print_debug(`Failed to migrate legacy API tokens: ${err instanceof Error ? err.message : String(err)}`);
|
|
87
|
+
}
|
|
68
88
|
store.print_debug('API Database Initialized...');
|
|
69
89
|
}
|
|
70
90
|
export async function connect_api_db(store) {
|
|
@@ -90,7 +110,14 @@ export async function connect_api_db(store) {
|
|
|
90
110
|
dbparams.username = env.DB_USER;
|
|
91
111
|
dbparams.password = env.DB_PASS;
|
|
92
112
|
}
|
|
93
|
-
|
|
113
|
+
const debugDbParams = { ...dbparams };
|
|
114
|
+
if (typeof debugDbParams.password === 'string' && debugDbParams.password) {
|
|
115
|
+
debugDbParams.password = '<redacted>';
|
|
116
|
+
}
|
|
117
|
+
if (typeof debugDbParams.username === 'string' && debugDbParams.username) {
|
|
118
|
+
debugDbParams.username = '<redacted>';
|
|
119
|
+
}
|
|
120
|
+
store.print_debug(`Database params are:\n${JSON.stringify(debugDbParams, undefined, 2)}`);
|
|
94
121
|
const db = new Sequelize(dbparams);
|
|
95
122
|
await db.authenticate();
|
|
96
123
|
store.print_debug('API Database Connected');
|
package/dist/models/form.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import { nanoid } from 'nanoid';
|
|
2
3
|
import { Model, DataTypes } from 'sequelize';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { user_and_domain, normalizeSlug } from '../util.js';
|
|
5
6
|
export const api_form_schema = z.object({
|
|
6
7
|
form_id: z.number().int().nonnegative(),
|
|
8
|
+
form_key: z.string().min(1).nullable().optional(),
|
|
7
9
|
user_id: z.number().int().nonnegative(),
|
|
8
10
|
domain_id: z.number().int().nonnegative(),
|
|
9
11
|
locale: z.string().default(''),
|
|
@@ -15,6 +17,7 @@ export const api_form_schema = z.object({
|
|
|
15
17
|
filename: z.string().default(''),
|
|
16
18
|
slug: z.string().default(''),
|
|
17
19
|
secret: z.string().default(''),
|
|
20
|
+
captcha_required: z.boolean().default(false),
|
|
18
21
|
files: z
|
|
19
22
|
.array(z.object({
|
|
20
23
|
filename: z.string(),
|
|
@@ -33,6 +36,11 @@ export async function init_api_form(api_db) {
|
|
|
33
36
|
allowNull: false,
|
|
34
37
|
primaryKey: true
|
|
35
38
|
},
|
|
39
|
+
form_key: {
|
|
40
|
+
type: DataTypes.STRING,
|
|
41
|
+
allowNull: true,
|
|
42
|
+
defaultValue: null
|
|
43
|
+
},
|
|
36
44
|
user_id: {
|
|
37
45
|
type: DataTypes.INTEGER,
|
|
38
46
|
allowNull: false,
|
|
@@ -102,6 +110,11 @@ export async function init_api_form(api_db) {
|
|
|
102
110
|
allowNull: false,
|
|
103
111
|
defaultValue: ''
|
|
104
112
|
},
|
|
113
|
+
captcha_required: {
|
|
114
|
+
type: DataTypes.BOOLEAN,
|
|
115
|
+
allowNull: false,
|
|
116
|
+
defaultValue: false
|
|
117
|
+
},
|
|
105
118
|
files: {
|
|
106
119
|
type: DataTypes.TEXT,
|
|
107
120
|
allowNull: false,
|
|
@@ -120,6 +133,10 @@ export async function init_api_form(api_db) {
|
|
|
120
133
|
charset: 'utf8mb4',
|
|
121
134
|
collate: 'utf8mb4_unicode_ci',
|
|
122
135
|
indexes: [
|
|
136
|
+
{
|
|
137
|
+
unique: true,
|
|
138
|
+
fields: ['form_key']
|
|
139
|
+
},
|
|
123
140
|
{
|
|
124
141
|
unique: true,
|
|
125
142
|
fields: ['user_id', 'domain_id', 'locale', 'idname']
|
|
@@ -164,12 +181,16 @@ export async function upsert_form(record) {
|
|
|
164
181
|
let instance = null;
|
|
165
182
|
instance = await api_form.findByPk(record.form_id);
|
|
166
183
|
if (instance) {
|
|
184
|
+
if (!instance.form_key && !record.form_key) {
|
|
185
|
+
record.form_key = nanoid();
|
|
186
|
+
}
|
|
167
187
|
await instance.update(record);
|
|
168
188
|
}
|
|
169
189
|
else {
|
|
170
|
-
|
|
190
|
+
if (!record.form_key) {
|
|
191
|
+
record.form_key = nanoid();
|
|
192
|
+
}
|
|
171
193
|
instance = await api_form.create(record);
|
|
172
|
-
console.log(`INSTANCE IS ${instance}`);
|
|
173
194
|
}
|
|
174
195
|
if (!instance) {
|
|
175
196
|
throw new Error(`Unable to update/create form ${record.form_id}`);
|
package/dist/models/init.js
CHANGED
|
@@ -6,7 +6,7 @@ import { user_and_domain } from '../util.js';
|
|
|
6
6
|
import { api_domain, api_domain_schema } from './domain.js';
|
|
7
7
|
import { api_form_schema, upsert_form } from './form.js';
|
|
8
8
|
import { api_txmail_schema, upsert_txmail } from './txmail.js';
|
|
9
|
-
import { api_user, api_user_schema } from './user.js';
|
|
9
|
+
import { apiTokenToHmac, api_user, api_user_schema } from './user.js';
|
|
10
10
|
const init_data_schema = z.object({
|
|
11
11
|
user: z.array(api_user_schema).default([]),
|
|
12
12
|
domain: z.array(api_domain_schema).default([]),
|
|
@@ -143,8 +143,18 @@ export async function importData(store) {
|
|
|
143
143
|
if (records.user) {
|
|
144
144
|
store.print_debug('Creating user records');
|
|
145
145
|
for (const record of records.user) {
|
|
146
|
-
const { domain, ...userWithoutDomain } = record;
|
|
147
|
-
|
|
146
|
+
const { domain, token, token_hmac, ...userWithoutDomain } = record;
|
|
147
|
+
let resolvedTokenHmac;
|
|
148
|
+
if (typeof token_hmac === 'string' && token_hmac) {
|
|
149
|
+
resolvedTokenHmac = token_hmac;
|
|
150
|
+
}
|
|
151
|
+
else if (typeof token === 'string' && token) {
|
|
152
|
+
resolvedTokenHmac = apiTokenToHmac(token, store.env.API_TOKEN_PEPPER);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
throw new Error(`User ${record.user_id} is missing token or token_hmac`);
|
|
156
|
+
}
|
|
157
|
+
await api_user.upsert({ ...userWithoutDomain, token: '', token_hmac: resolvedTokenHmac, domain: null });
|
|
148
158
|
if (typeof domain === 'number') {
|
|
149
159
|
pendingUserDomains.push({ user_id: record.user_id, domain });
|
|
150
160
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Model, DataTypes } from 'sequelize';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
export const api_recipient_schema = z.object({
|
|
4
|
+
recipient_id: z.number().int().nonnegative(),
|
|
5
|
+
domain_id: z.number().int().nonnegative(),
|
|
6
|
+
// Empty string means "domain-wide"; otherwise scope to a specific form_key.
|
|
7
|
+
form_key: z.string().default(''),
|
|
8
|
+
idname: z.string().min(1),
|
|
9
|
+
email: z.string().min(1),
|
|
10
|
+
name: z.string().default('')
|
|
11
|
+
});
|
|
12
|
+
export class api_recipient extends Model {
|
|
13
|
+
}
|
|
14
|
+
export async function init_api_recipient(api_db) {
|
|
15
|
+
api_recipient.init({
|
|
16
|
+
recipient_id: {
|
|
17
|
+
type: DataTypes.INTEGER,
|
|
18
|
+
autoIncrement: true,
|
|
19
|
+
allowNull: false,
|
|
20
|
+
primaryKey: true
|
|
21
|
+
},
|
|
22
|
+
domain_id: {
|
|
23
|
+
type: DataTypes.INTEGER,
|
|
24
|
+
allowNull: false,
|
|
25
|
+
references: {
|
|
26
|
+
model: 'domain',
|
|
27
|
+
key: 'domain_id'
|
|
28
|
+
},
|
|
29
|
+
onDelete: 'CASCADE',
|
|
30
|
+
onUpdate: 'CASCADE'
|
|
31
|
+
},
|
|
32
|
+
form_key: {
|
|
33
|
+
type: DataTypes.STRING,
|
|
34
|
+
allowNull: false,
|
|
35
|
+
defaultValue: ''
|
|
36
|
+
},
|
|
37
|
+
idname: {
|
|
38
|
+
type: DataTypes.STRING,
|
|
39
|
+
allowNull: false,
|
|
40
|
+
defaultValue: ''
|
|
41
|
+
},
|
|
42
|
+
email: {
|
|
43
|
+
type: DataTypes.STRING,
|
|
44
|
+
allowNull: false,
|
|
45
|
+
defaultValue: ''
|
|
46
|
+
},
|
|
47
|
+
name: {
|
|
48
|
+
type: DataTypes.STRING,
|
|
49
|
+
allowNull: false,
|
|
50
|
+
defaultValue: ''
|
|
51
|
+
}
|
|
52
|
+
}, {
|
|
53
|
+
sequelize: api_db,
|
|
54
|
+
tableName: 'recipient',
|
|
55
|
+
charset: 'utf8mb4',
|
|
56
|
+
collate: 'utf8mb4_unicode_ci',
|
|
57
|
+
indexes: [
|
|
58
|
+
{
|
|
59
|
+
unique: true,
|
|
60
|
+
fields: ['domain_id', 'form_key', 'idname']
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
});
|
|
64
|
+
return api_recipient;
|
|
65
|
+
}
|
package/dist/models/user.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { Model, DataTypes, Op } from 'sequelize';
|
|
2
3
|
import { z } from 'zod';
|
|
3
4
|
export const api_user_schema = z.object({
|
|
4
5
|
user_id: z.number().int().nonnegative(),
|
|
5
6
|
idname: z.string().min(1),
|
|
6
|
-
token: z.string().min(1),
|
|
7
|
+
token: z.string().min(1).optional(),
|
|
8
|
+
token_hmac: z.string().min(1).optional(),
|
|
7
9
|
name: z.string().min(1),
|
|
8
10
|
email: z.string().email(),
|
|
9
11
|
domain: z.number().int().nonnegative().nullable().optional(),
|
|
@@ -11,6 +13,30 @@ export const api_user_schema = z.object({
|
|
|
11
13
|
});
|
|
12
14
|
export class api_user extends Model {
|
|
13
15
|
}
|
|
16
|
+
export function apiTokenToHmac(token, pepper) {
|
|
17
|
+
return createHmac('sha256', pepper).update(token).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
export async function migrateLegacyApiTokens(pepper) {
|
|
20
|
+
const users = await api_user.findAll({
|
|
21
|
+
where: {
|
|
22
|
+
token: {
|
|
23
|
+
[Op.ne]: ''
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
let migrated = 0;
|
|
28
|
+
let cleared = 0;
|
|
29
|
+
for (const user of users) {
|
|
30
|
+
const updates = { token: '' };
|
|
31
|
+
if (!user.token_hmac && user.token) {
|
|
32
|
+
updates.token_hmac = apiTokenToHmac(user.token, pepper);
|
|
33
|
+
migrated += 1;
|
|
34
|
+
}
|
|
35
|
+
cleared += 1;
|
|
36
|
+
await user.update(updates);
|
|
37
|
+
}
|
|
38
|
+
return { migrated, cleared };
|
|
39
|
+
}
|
|
14
40
|
export async function init_api_user(api_db) {
|
|
15
41
|
await api_user.init({
|
|
16
42
|
user_id: {
|
|
@@ -29,6 +55,11 @@ export async function init_api_user(api_db) {
|
|
|
29
55
|
allowNull: false,
|
|
30
56
|
defaultValue: ''
|
|
31
57
|
},
|
|
58
|
+
token_hmac: {
|
|
59
|
+
type: DataTypes.STRING,
|
|
60
|
+
allowNull: true,
|
|
61
|
+
defaultValue: null
|
|
62
|
+
},
|
|
32
63
|
name: {
|
|
33
64
|
type: DataTypes.STRING,
|
|
34
65
|
allowNull: false,
|
|
@@ -59,7 +90,13 @@ export async function init_api_user(api_db) {
|
|
|
59
90
|
sequelize: api_db,
|
|
60
91
|
tableName: 'user',
|
|
61
92
|
charset: 'utf8mb4',
|
|
62
|
-
collate: 'utf8mb4_unicode_ci'
|
|
93
|
+
collate: 'utf8mb4_unicode_ci',
|
|
94
|
+
indexes: [
|
|
95
|
+
{
|
|
96
|
+
unique: true,
|
|
97
|
+
fields: ['token_hmac']
|
|
98
|
+
}
|
|
99
|
+
]
|
|
63
100
|
});
|
|
64
101
|
return api_user;
|
|
65
102
|
}
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ApiServer } from '@technomoron/api-server-base';
|
|
2
|
-
import { api_user } from './models/user.js';
|
|
2
|
+
import { apiTokenToHmac, api_user } from './models/user.js';
|
|
3
3
|
export class mailApiServer extends ApiServer {
|
|
4
4
|
store;
|
|
5
5
|
storage;
|
|
@@ -9,14 +9,26 @@ export class mailApiServer extends ApiServer {
|
|
|
9
9
|
this.storage = store;
|
|
10
10
|
}
|
|
11
11
|
async getApiKey(token) {
|
|
12
|
-
this.storage.print_debug(
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
this.storage.print_debug('Looking up api key');
|
|
13
|
+
const pepper = this.storage.env.API_TOKEN_PEPPER;
|
|
14
|
+
const token_hmac = apiTokenToHmac(token, pepper);
|
|
15
|
+
const user = await api_user.findOne({ where: { token_hmac } });
|
|
16
|
+
if (user) {
|
|
17
|
+
return { uid: user.user_id };
|
|
18
|
+
}
|
|
19
|
+
// Backwards-compatible fallback for legacy databases that still store plaintext tokens.
|
|
20
|
+
const legacy = await api_user.findOne({ where: { token } });
|
|
21
|
+
if (!legacy) {
|
|
22
|
+
this.storage.print_debug('Unable to find user for api key');
|
|
16
23
|
return null;
|
|
17
24
|
}
|
|
18
|
-
|
|
19
|
-
|
|
25
|
+
try {
|
|
26
|
+
await legacy.update({ token_hmac, token: '' });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
// Don't leak token data; just surface the update failure for debugging.
|
|
30
|
+
this.storage.print_debug(`Unable to migrate legacy api token: ${err instanceof Error ? err.message : String(err)}`);
|
|
20
31
|
}
|
|
32
|
+
return { uid: legacy.user_id };
|
|
21
33
|
}
|
|
22
34
|
}
|
package/dist/store/envloader.js
CHANGED
|
@@ -91,6 +91,11 @@ export const envOptions = defineEnvOptions({
|
|
|
91
91
|
default: false,
|
|
92
92
|
type: 'boolean'
|
|
93
93
|
},
|
|
94
|
+
AUTOESCAPE_HTML: {
|
|
95
|
+
description: 'Enable Nunjucks HTML autoescape when rendering templates',
|
|
96
|
+
default: true,
|
|
97
|
+
type: 'boolean'
|
|
98
|
+
},
|
|
94
99
|
SMTP_HOST: {
|
|
95
100
|
description: 'Hostname of SMTP sending host',
|
|
96
101
|
default: 'localhost'
|
|
@@ -121,5 +126,55 @@ export const envOptions = defineEnvOptions({
|
|
|
121
126
|
UPLOAD_PATH: {
|
|
122
127
|
description: 'Path for attached files. Use {domain} to scope per domain.',
|
|
123
128
|
default: './{domain}/uploads'
|
|
129
|
+
},
|
|
130
|
+
UPLOAD_MAX: {
|
|
131
|
+
description: 'Maximum upload size per file (bytes) when uploads are enabled',
|
|
132
|
+
default: 30 * 1024 * 1024,
|
|
133
|
+
type: 'number'
|
|
134
|
+
},
|
|
135
|
+
FORM_RATE_LIMIT_WINDOW_SEC: {
|
|
136
|
+
description: 'Rate limit window for unauthenticated form submissions (seconds)',
|
|
137
|
+
default: 0,
|
|
138
|
+
type: 'number'
|
|
139
|
+
},
|
|
140
|
+
FORM_RATE_LIMIT_MAX: {
|
|
141
|
+
description: 'Max unauthenticated form submissions per client IP per window (0 disables rate limiting)',
|
|
142
|
+
default: 0,
|
|
143
|
+
type: 'number'
|
|
144
|
+
},
|
|
145
|
+
FORM_MAX_ATTACHMENTS: {
|
|
146
|
+
description: 'Max number of uploaded files accepted by /v1/form/message (-1 unlimited, 0 disables attachments)',
|
|
147
|
+
default: -1,
|
|
148
|
+
type: 'number'
|
|
149
|
+
},
|
|
150
|
+
FORM_KEEP_UPLOADS: {
|
|
151
|
+
description: 'Keep uploaded form files on disk after processing (success or failure)',
|
|
152
|
+
default: true,
|
|
153
|
+
type: 'boolean'
|
|
154
|
+
},
|
|
155
|
+
FORM_CAPTCHA_PROVIDER: {
|
|
156
|
+
description: 'CAPTCHA provider used to verify tokens for /v1/form/message',
|
|
157
|
+
options: ['turnstile', 'hcaptcha', 'recaptcha'],
|
|
158
|
+
default: 'turnstile'
|
|
159
|
+
},
|
|
160
|
+
FORM_CAPTCHA_SECRET: {
|
|
161
|
+
description: 'CAPTCHA secret used by the server to verify tokens (enables captcha checks when set)',
|
|
162
|
+
default: ''
|
|
163
|
+
},
|
|
164
|
+
FORM_CAPTCHA_REQUIRED: {
|
|
165
|
+
description: 'Require a CAPTCHA token for /v1/form/message when captcha is enabled',
|
|
166
|
+
default: false,
|
|
167
|
+
type: 'boolean'
|
|
168
|
+
},
|
|
169
|
+
API_TOKEN_PEPPER: {
|
|
170
|
+
description: 'Server-side pepper used to HMAC API tokens before DB lookup. Keep it stable to preserve existing API keys.',
|
|
171
|
+
required: true,
|
|
172
|
+
transform: (raw) => {
|
|
173
|
+
const value = String(raw ?? '').trim();
|
|
174
|
+
if (value.length < 16) {
|
|
175
|
+
throw new Error('API_TOKEN_PEPPER must be at least 16 characters');
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
124
179
|
}
|
|
125
180
|
});
|
package/dist/store/store.js
CHANGED
|
@@ -113,7 +113,16 @@ export class mailStore {
|
|
|
113
113
|
return {};
|
|
114
114
|
}
|
|
115
115
|
async init() {
|
|
116
|
-
|
|
116
|
+
// Load env config only via EnvLoader + envOptions (avoid ad-hoc `process.env` parsing here).
|
|
117
|
+
// If DEBUG is enabled, re-load with EnvLoader debug output enabled.
|
|
118
|
+
let env = await EnvLoader.createConfigProxy(envOptions, { debug: false });
|
|
119
|
+
if (env.DEBUG) {
|
|
120
|
+
env = await EnvLoader.createConfigProxy(envOptions, { debug: true });
|
|
121
|
+
}
|
|
122
|
+
this.env = env;
|
|
123
|
+
if (this.env.FORM_CAPTCHA_REQUIRED && !String(this.env.FORM_CAPTCHA_SECRET ?? '').trim()) {
|
|
124
|
+
throw new Error('FORM_CAPTCHA_SECRET must be set when FORM_CAPTCHA_REQUIRED=true');
|
|
125
|
+
}
|
|
117
126
|
EnvLoader.genTemplate(envOptions, '.env-dist');
|
|
118
127
|
const p = env.CONFIG_PATH;
|
|
119
128
|
this.configpath = path.isAbsolute(p) ? p : path.resolve(process.cwd(), p);
|
package/dist/util.js
CHANGED
|
@@ -96,22 +96,32 @@ export function decodeComponent(value) {
|
|
|
96
96
|
if (!value) {
|
|
97
97
|
return '';
|
|
98
98
|
}
|
|
99
|
+
const decoded = Array.isArray(value) ? (value[0] ?? '') : value;
|
|
100
|
+
if (!decoded) {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
99
103
|
try {
|
|
100
|
-
return decodeURIComponent(
|
|
104
|
+
return decodeURIComponent(decoded);
|
|
101
105
|
}
|
|
102
106
|
catch {
|
|
103
|
-
return
|
|
107
|
+
return decoded;
|
|
104
108
|
}
|
|
105
109
|
}
|
|
106
|
-
export function sendFileAsync(res, file) {
|
|
110
|
+
export function sendFileAsync(res, file, options) {
|
|
107
111
|
return new Promise((resolve, reject) => {
|
|
108
|
-
|
|
112
|
+
const cb = (err) => {
|
|
109
113
|
if (err) {
|
|
110
|
-
reject(err);
|
|
114
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
111
115
|
}
|
|
112
116
|
else {
|
|
113
117
|
resolve();
|
|
114
118
|
}
|
|
115
|
-
}
|
|
119
|
+
};
|
|
120
|
+
if (options !== undefined) {
|
|
121
|
+
// Express will set Cache-Control based on `maxAge` etc; callers can still override.
|
|
122
|
+
res.sendFile(file, options, cb);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
res.sendFile(file, cb);
|
|
116
126
|
});
|
|
117
127
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@technomoron/mail-magic",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.23",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,13 +42,14 @@
|
|
|
42
42
|
"url": "https://github.com/technomoron/mail-magic/issues"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@technomoron/api-server-base": "2.0.0-beta.
|
|
45
|
+
"@technomoron/api-server-base": "2.0.0-beta.18",
|
|
46
46
|
"@technomoron/env-loader": "^1.0.8",
|
|
47
47
|
"@technomoron/unyuck": "^1.0.4",
|
|
48
48
|
"bcryptjs": "^3.0.2",
|
|
49
49
|
"dotenv": "^16.4.5",
|
|
50
50
|
"email-addresses": "^5.0.0",
|
|
51
51
|
"html-to-text": "^9.0.5",
|
|
52
|
+
"nanoid": "^5.1.6",
|
|
52
53
|
"nodemailer": "^6.10.1",
|
|
53
54
|
"nunjucks": "^3.2.4",
|
|
54
55
|
"sequelize": "^6.37.7",
|
|
@@ -58,7 +59,7 @@
|
|
|
58
59
|
"zod": "^4.1.5"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
61
|
-
"@types/express": "^
|
|
62
|
+
"@types/express": "^5.0.6",
|
|
62
63
|
"@types/html-to-text": "^9.0.4",
|
|
63
64
|
"@types/nodemailer": "^6.4.19",
|
|
64
65
|
"@types/nunjucks": "^3.2.6",
|