webspresso 0.0.94 → 0.0.96
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/.agents/ADMIN.md +225 -0
- package/.agents/AGENTS.md +72 -0
- package/.agents/ARCHITECTURE.md +119 -0
- package/.agents/AUTH.md +103 -0
- package/.agents/CLI.md +115 -0
- package/.agents/COMPRESSION.md +59 -0
- package/.agents/CONVENTIONS.md +46 -0
- package/.agents/EMAIL.md +141 -0
- package/.agents/ERRORS.md +201 -0
- package/.agents/MCP.md +103 -0
- package/.agents/ORM.md +230 -0
- package/.agents/PLUGINS.md +419 -0
- package/.agents/QUEUE.md +113 -0
- package/.agents/REALTIME.md +213 -0
- package/.agents/ROUTING.md +292 -0
- package/.agents/SERVICES.md +341 -0
- package/.agents/SHUTDOWN.md +67 -0
- package/.agents/STREAMING.md +73 -0
- package/.agents/WORKFLOW.md +27 -0
- package/.agents/internal/DOCUMENTATION_REVIEW.md +485 -0
- package/.agents/internal/TECHNICAL_REVIEW.md +428 -0
- package/.cursorrules +14 -0
- package/AGENTS.md +175 -0
- package/CLAUDE.md +54 -0
- package/README.md +18 -14
- package/bin/commands/agents-init.js +169 -0
- package/bin/commands/api.js +114 -48
- package/bin/commands/doctor.js +35 -0
- package/bin/commands/new.js +13 -2
- package/bin/commands/page.js +75 -89
- package/bin/commands/service.js +77 -0
- package/bin/webspresso.js +2 -2
- package/core/compileSchema.js +1 -1
- package/core/errors/middleware.js +22 -2
- package/core/errors/normalize.js +29 -2
- package/{utils/schemaCache.js → core/schema-cache.js} +2 -3
- package/index.d.ts +306 -0
- package/index.js +35 -1
- package/package.json +19 -6
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
- package/plugins/csrf/index.js +3 -2
- package/plugins/csv/engine.js +447 -0
- package/plugins/csv/index.js +94 -0
- package/plugins/csv/middleware.js +80 -0
- package/plugins/csv/services.js +138 -0
- package/plugins/file-manager/admin-component.js +613 -0
- package/plugins/file-manager/api-handlers.js +261 -0
- package/plugins/file-manager/core.js +601 -0
- package/plugins/file-manager/index.js +139 -0
- package/plugins/file-manager/security.js +207 -0
- package/plugins/index.js +8 -0
- package/plugins/polar/CHANGELOG.md +18 -0
- package/plugins/polar/README.md +30 -7
- package/plugins/polar/index.js +6 -1
- package/plugins/polar/src/billing-hooks.js +51 -0
- package/plugins/polar/src/config.js +2 -0
- package/plugins/polar/src/rate-limit.js +96 -28
- package/plugins/polar/src/routes.js +3 -2
- package/plugins/polar/src/urls.js +26 -2
- package/plugins/polar/src/user-resolver.js +5 -1
- package/plugins/rate-limit/index.js +5 -1
- package/plugins/xlsx/engine.js +397 -0
- package/plugins/xlsx/index.js +91 -0
- package/plugins/xlsx/middleware.js +87 -0
- package/plugins/xlsx/services.js +118 -0
- package/src/api/api-loader.js +233 -0
- package/src/api/define-api.js +35 -0
- package/src/discovery/file-route-parser.js +201 -0
- package/src/discovery/scan-routes.js +265 -0
- package/src/file-router.js +6 -0
- package/src/modules/define-module.js +99 -0
- package/src/modules/module-discovery.js +117 -0
- package/src/pages/define-page.js +36 -0
- package/src/pages/page-loader.js +214 -0
- package/src/routing/mount-discovered-routes.js +78 -0
- package/src/routing/route-table.js +191 -0
- package/src/server.js +112 -15
- package/src/services/builtins/file-manager.js +178 -0
- package/src/services/index.js +2 -0
- package/bin/commands/skill.js +0 -187
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +0 -289
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +0 -93
- package/templates/skills/webspresso-usage/SKILL.md +0 -48
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso File Manager Plugin
|
|
3
|
+
* Filesystem-first media library, directory browser and asset picker for Admin Panel.
|
|
4
|
+
* @module plugins/file-manager
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { createFileManagerApiHandlers } = require('./api-handlers');
|
|
9
|
+
const { generateFileManagerComponent } = require('./admin-component');
|
|
10
|
+
const { createFileManagerServices } = require('../../src/services/builtins/file-manager');
|
|
11
|
+
const core = require('./core');
|
|
12
|
+
const security = require('./security');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} [options]
|
|
16
|
+
* @param {string} [options.baseDir='./public/uploads'] - Filesystem directory to manage
|
|
17
|
+
* @param {string} [options.publicBasePath='/uploads'] - URL prefix for stored files
|
|
18
|
+
* @param {string} [options.apiPrefix='/files'] - API subpath under Admin API
|
|
19
|
+
* @param {string} [options.pageTitle='Dosya Yöneticisi'] - Title of admin page
|
|
20
|
+
* @param {string} [options.menuLabel='Dosya Yöneticisi'] - Sidebar menu label
|
|
21
|
+
* @param {number} [options.menuOrder=50] - Sidebar menu ordering
|
|
22
|
+
* @param {number} [options.maxUploadBytes=10485760] - Max upload size in bytes (10MB default)
|
|
23
|
+
* @param {number|null} [options.maxTotalStorageBytes] - Storage quota limit in bytes (optional)
|
|
24
|
+
* @param {string[]|null} [options.allowedExtensions] - Whitelist of allowed extensions
|
|
25
|
+
* @param {string[]|null} [options.allowedMimeTypes] - Whitelist of allowed MIME types
|
|
26
|
+
* @param {Record<string, string[]>|null} [options.permissions] - RBAC permissions map
|
|
27
|
+
* @param {Object} [options.security] - Security configurations
|
|
28
|
+
* @param {boolean} [options.security.validateMagicBytes=true] - Validate file signatures
|
|
29
|
+
* @param {boolean} [options.security.sanitizeSvg=true] - Sanitize SVG XSS payloads
|
|
30
|
+
* @param {boolean} [options.includePage=true] - Whether to register admin menu item & page
|
|
31
|
+
*/
|
|
32
|
+
function fileManagerPlugin(options = {}) {
|
|
33
|
+
const {
|
|
34
|
+
baseDir = path.join(process.cwd(), 'public', 'uploads'),
|
|
35
|
+
publicBasePath = '/uploads',
|
|
36
|
+
apiPrefix = '/files',
|
|
37
|
+
pageTitle = 'Dosya Yöneticisi',
|
|
38
|
+
menuLabel = 'Dosya Yöneticisi',
|
|
39
|
+
menuOrder = 50,
|
|
40
|
+
maxUploadBytes = 10 * 1024 * 1024,
|
|
41
|
+
maxTotalStorageBytes = null,
|
|
42
|
+
allowedExtensions = null,
|
|
43
|
+
allowedMimeTypes = null,
|
|
44
|
+
permissions = null,
|
|
45
|
+
security: securityOpts = { validateMagicBytes: true, sanitizeSvg: true },
|
|
46
|
+
includePage = true,
|
|
47
|
+
} = options;
|
|
48
|
+
|
|
49
|
+
const resolvedBaseDir = path.resolve(baseDir);
|
|
50
|
+
|
|
51
|
+
const handlers = createFileManagerApiHandlers({
|
|
52
|
+
baseDir: resolvedBaseDir,
|
|
53
|
+
publicBasePath,
|
|
54
|
+
maxUploadBytes,
|
|
55
|
+
maxTotalStorageBytes,
|
|
56
|
+
allowedExtensions,
|
|
57
|
+
allowedMimeTypes,
|
|
58
|
+
permissions,
|
|
59
|
+
security: securityOpts,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
name: 'file-manager',
|
|
64
|
+
version: '1.0.0',
|
|
65
|
+
description: 'Filesystem-first media library, directory explorer & asset picker',
|
|
66
|
+
dependencies: { 'admin-panel': '*' },
|
|
67
|
+
|
|
68
|
+
api: {
|
|
69
|
+
...core,
|
|
70
|
+
security,
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
register(ctx) {
|
|
74
|
+
// Register built-in services in app service registry if available
|
|
75
|
+
if (ctx.app && ctx.app.serviceRegistry) {
|
|
76
|
+
const services = createFileManagerServices({
|
|
77
|
+
baseDir: resolvedBaseDir,
|
|
78
|
+
publicBasePath,
|
|
79
|
+
});
|
|
80
|
+
for (const [name, def] of Object.entries(services)) {
|
|
81
|
+
if (!ctx.app.serviceRegistry.has(name)) {
|
|
82
|
+
ctx.app.serviceRegistry.register(name, def);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
onRoutesReady(ctx) {
|
|
89
|
+
const adminApi = ctx.usePlugin('admin-panel');
|
|
90
|
+
if (!adminApi) {
|
|
91
|
+
console.warn('[file-manager] admin-panel plugin not found, skipping Admin UI registration');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
adminApi.registerModule({
|
|
96
|
+
id: 'files',
|
|
97
|
+
pages: includePage
|
|
98
|
+
? [{
|
|
99
|
+
id: 'files',
|
|
100
|
+
title: pageTitle,
|
|
101
|
+
path: '/files',
|
|
102
|
+
icon: 'folder',
|
|
103
|
+
component: generateFileManagerComponent({
|
|
104
|
+
apiPrefix,
|
|
105
|
+
publicBasePath,
|
|
106
|
+
}),
|
|
107
|
+
}]
|
|
108
|
+
: [],
|
|
109
|
+
|
|
110
|
+
menu: includePage
|
|
111
|
+
? [{
|
|
112
|
+
id: 'files',
|
|
113
|
+
label: menuLabel,
|
|
114
|
+
path: '/files',
|
|
115
|
+
icon: 'folder',
|
|
116
|
+
order: menuOrder,
|
|
117
|
+
}]
|
|
118
|
+
: [],
|
|
119
|
+
|
|
120
|
+
api: {
|
|
121
|
+
prefix: apiPrefix,
|
|
122
|
+
routes: [
|
|
123
|
+
{ method: 'get', path: '', handler: handlers.list },
|
|
124
|
+
{ method: 'post', path: '/mkdir', handler: handlers.mkdir },
|
|
125
|
+
{ method: 'post', path: '/rename', handler: handlers.rename },
|
|
126
|
+
{ method: 'post', path: '/move', handler: handlers.move },
|
|
127
|
+
{ method: 'post', path: '/delete', handler: handlers.delete },
|
|
128
|
+
{ method: 'post', path: '/upload', handler: handlers.upload },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = fileManagerPlugin;
|
|
137
|
+
module.exports.fileManagerPlugin = fileManagerPlugin;
|
|
138
|
+
module.exports.core = core;
|
|
139
|
+
module.exports.security = security;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Manager Security Subsystem — Magic byte verification, dangerous extension blocking & SVG sanitization.
|
|
3
|
+
* Zero external dependencies, ultra-high performance O(1) buffer checks.
|
|
4
|
+
* @module plugins/file-manager/security
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { SecurityError, ValidationError } = require('../../core/errors');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Universal list of dangerous/executable extensions (lowercase without dot)
|
|
11
|
+
*/
|
|
12
|
+
const DANGEROUS_EXTENSIONS = new Set([
|
|
13
|
+
// Scripts & Executables
|
|
14
|
+
'php', 'phtml', 'php3', 'php4', 'php5', 'php7', 'php8', 'phps', 'phar',
|
|
15
|
+
'exe', 'dll', 'so', 'dylib', 'bin', 'elf',
|
|
16
|
+
'sh', 'bash', 'zsh', 'csh', 'ksh', 'fish', 'bat', 'cmd', 'ps1', 'psm1',
|
|
17
|
+
'vbs', 'vbe', 'wsf', 'wsh', 'scr', 'cpl', 'msc', 'hta',
|
|
18
|
+
'jar', 'war', 'ear', 'class',
|
|
19
|
+
'jsp', 'jspx', 'jsw', 'jsv', 'jspf',
|
|
20
|
+
'asp', 'aspx', 'axd', 'asx', 'ashx', 'asmx',
|
|
21
|
+
'cgi', 'pl', 'pm', 'py', 'pyc', 'pyo', 'rb',
|
|
22
|
+
// System & Config files
|
|
23
|
+
'htaccess', 'htpasswd', 'ini', 'env', 'config', 'conf', 'passwd', 'shadow',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Known magic numbers table for high-speed signature verification.
|
|
28
|
+
* Only validates when extension matches known binary format.
|
|
29
|
+
*/
|
|
30
|
+
const MAGIC_NUMBERS = {
|
|
31
|
+
// JPEG / JPG
|
|
32
|
+
jpg: (buf) => buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff,
|
|
33
|
+
jpeg: (buf) => buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff,
|
|
34
|
+
|
|
35
|
+
// PNG
|
|
36
|
+
png: (buf) =>
|
|
37
|
+
buf.length >= 8 &&
|
|
38
|
+
buf[0] === 0x89 &&
|
|
39
|
+
buf[1] === 0x50 &&
|
|
40
|
+
buf[2] === 0x4e &&
|
|
41
|
+
buf[3] === 0x47 &&
|
|
42
|
+
buf[4] === 0x0d &&
|
|
43
|
+
buf[5] === 0x0a &&
|
|
44
|
+
buf[6] === 0x1a &&
|
|
45
|
+
buf[7] === 0x0a,
|
|
46
|
+
|
|
47
|
+
// GIF
|
|
48
|
+
gif: (buf) =>
|
|
49
|
+
buf.length >= 6 &&
|
|
50
|
+
buf[0] === 0x47 &&
|
|
51
|
+
buf[1] === 0x49 &&
|
|
52
|
+
buf[2] === 0x46 &&
|
|
53
|
+
buf[3] === 0x38 &&
|
|
54
|
+
(buf[4] === 0x37 || buf[4] === 0x39) &&
|
|
55
|
+
buf[5] === 0x61,
|
|
56
|
+
|
|
57
|
+
// WebP: RIFF at 0..3 and WEBP at 8..11
|
|
58
|
+
webp: (buf) =>
|
|
59
|
+
buf.length >= 12 &&
|
|
60
|
+
buf[0] === 0x52 &&
|
|
61
|
+
buf[1] === 0x49 &&
|
|
62
|
+
buf[2] === 0x46 &&
|
|
63
|
+
buf[3] === 0x46 &&
|
|
64
|
+
buf[8] === 0x57 &&
|
|
65
|
+
buf[9] === 0x45 &&
|
|
66
|
+
buf[10] === 0x42 &&
|
|
67
|
+
buf[11] === 0x50,
|
|
68
|
+
|
|
69
|
+
// PDF: %PDF-
|
|
70
|
+
pdf: (buf) =>
|
|
71
|
+
buf.length >= 5 &&
|
|
72
|
+
buf[0] === 0x25 &&
|
|
73
|
+
buf[1] === 0x50 &&
|
|
74
|
+
buf[2] === 0x44 &&
|
|
75
|
+
buf[3] === 0x46 &&
|
|
76
|
+
buf[4] === 0x2d,
|
|
77
|
+
|
|
78
|
+
// ZIP (and DOCX/XLSX/PPTX)
|
|
79
|
+
zip: (buf) =>
|
|
80
|
+
buf.length >= 4 &&
|
|
81
|
+
buf[0] === 0x50 &&
|
|
82
|
+
buf[1] === 0x4b &&
|
|
83
|
+
(buf[2] === 0x03 || buf[2] === 0x05 || buf[2] === 0x07) &&
|
|
84
|
+
(buf[3] === 0x04 || buf[3] === 0x06 || buf[3] === 0x08),
|
|
85
|
+
docx: (buf) => MAGIC_NUMBERS.zip(buf),
|
|
86
|
+
xlsx: (buf) => MAGIC_NUMBERS.zip(buf),
|
|
87
|
+
pptx: (buf) => MAGIC_NUMBERS.zip(buf),
|
|
88
|
+
|
|
89
|
+
// MP3: ID3 or frame sync 0xFFE / 0xFFF
|
|
90
|
+
mp3: (buf) =>
|
|
91
|
+
(buf.length >= 3 && buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) ||
|
|
92
|
+
(buf.length >= 2 && buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0),
|
|
93
|
+
|
|
94
|
+
// MP4: ....ftyp at bytes 4..7
|
|
95
|
+
mp4: (buf) =>
|
|
96
|
+
buf.length >= 8 &&
|
|
97
|
+
buf[4] === 0x66 &&
|
|
98
|
+
buf[5] === 0x74 &&
|
|
99
|
+
buf[6] === 0x79 &&
|
|
100
|
+
buf[7] === 0x70,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate that a filename contains no dangerous extensions or double-extension tricks.
|
|
105
|
+
* @param {string} filename
|
|
106
|
+
* @returns {string} Clean lowercase extension
|
|
107
|
+
*/
|
|
108
|
+
function validateSafeExtension(filename) {
|
|
109
|
+
if (!filename || typeof filename !== 'string') {
|
|
110
|
+
throw new ValidationError('Filename is required');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const cleanName = filename.toLowerCase().trim();
|
|
114
|
+
const parts = cleanName.split('.').filter(Boolean);
|
|
115
|
+
|
|
116
|
+
if (parts.length === 0) {
|
|
117
|
+
throw new ValidationError('Filename must have a valid extension');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check all dotted segments against dangerous extensions to block double extensions (e.g. payload.php.jpg)
|
|
121
|
+
for (let i = 1; i < parts.length; i++) {
|
|
122
|
+
const segment = parts[i];
|
|
123
|
+
if (DANGEROUS_EXTENSIONS.has(segment)) {
|
|
124
|
+
throw new SecurityError(
|
|
125
|
+
`Dangerous extension or double-extension detected: .${segment}`,
|
|
126
|
+
400
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const finalExt = parts[parts.length - 1];
|
|
132
|
+
if (DANGEROUS_EXTENSIONS.has(finalExt)) {
|
|
133
|
+
throw new SecurityError(`File type .${finalExt} is forbidden for upload`, 403);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return finalExt;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verify that the buffer's magic bytes match the claimed extension.
|
|
141
|
+
* O(1) time complexity, minimal memory overhead.
|
|
142
|
+
* @param {Buffer} buffer
|
|
143
|
+
* @param {string} ext
|
|
144
|
+
* @returns {boolean}
|
|
145
|
+
*/
|
|
146
|
+
function validateMagicBytes(buffer, ext) {
|
|
147
|
+
if (!buffer || !Buffer.isBuffer(buffer)) {
|
|
148
|
+
throw new ValidationError('Valid buffer is required for magic byte check');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const cleanExt = (ext || '').replace(/^\./, '').toLowerCase();
|
|
152
|
+
const validator = MAGIC_NUMBERS[cleanExt];
|
|
153
|
+
|
|
154
|
+
if (validator) {
|
|
155
|
+
const isValid = validator(buffer);
|
|
156
|
+
if (!isValid) {
|
|
157
|
+
throw new SecurityError(
|
|
158
|
+
`File signature mismatch: file content does not match expected .${cleanExt} format`,
|
|
159
|
+
400
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Sanitize SVG content against Stored XSS attacks.
|
|
169
|
+
* Strips <script>, <foreignObject>, <iframe>, <embed>, <object> and on* event handlers.
|
|
170
|
+
* @param {Buffer|string} content
|
|
171
|
+
* @returns {Buffer} Sanitized SVG buffer
|
|
172
|
+
*/
|
|
173
|
+
function sanitizeSvgContent(content) {
|
|
174
|
+
const str = Buffer.isBuffer(content) ? content.toString('utf8') : String(content);
|
|
175
|
+
|
|
176
|
+
// Quick check: if no dangerous keywords, return original buffer directly for maximum speed
|
|
177
|
+
if (!/<(script|foreignobject|iframe|embed|object|meta|link|style)/i.test(str) &&
|
|
178
|
+
!/\s+on[a-z]+\s*=/i.test(str) &&
|
|
179
|
+
!/javascript:/i.test(str) &&
|
|
180
|
+
!/data:text\/html/i.test(str)) {
|
|
181
|
+
return Buffer.isBuffer(content) ? content : Buffer.from(str, 'utf8');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let sanitized = str
|
|
185
|
+
// Remove script tags and content
|
|
186
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script\s*>/gi, '')
|
|
187
|
+
// Remove dangerous containers
|
|
188
|
+
.replace(/<foreignObject\b[^<]*(?:(?!<\/foreignObject>)<[^<]*)*<\/foreignObject\s*>/gi, '')
|
|
189
|
+
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe\s*>/gi, '')
|
|
190
|
+
.replace(/<embed\b[^>]*>/gi, '')
|
|
191
|
+
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object\s*>/gi, '')
|
|
192
|
+
// Remove inline event handlers (onload, onclick, onerror, onmouseover, etc.)
|
|
193
|
+
.replace(/\s+on[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
194
|
+
// Remove javascript: and data:text/html URIs in href and xlink:href
|
|
195
|
+
.replace(/(?:href|xlink:href)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*')/gi, 'href=""')
|
|
196
|
+
.replace(/(?:href|xlink:href)\s*=\s*(?:"\s*data:text\/html[^"]*"|'\s*data:text\/html[^']*')/gi, 'href=""');
|
|
197
|
+
|
|
198
|
+
return Buffer.from(sanitized, 'utf8');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
DANGEROUS_EXTENSIONS,
|
|
203
|
+
MAGIC_NUMBERS,
|
|
204
|
+
validateSafeExtension,
|
|
205
|
+
validateMagicBytes,
|
|
206
|
+
sanitizeSvgContent,
|
|
207
|
+
};
|
package/plugins/index.js
CHANGED
|
@@ -41,6 +41,9 @@ const {
|
|
|
41
41
|
} = require('./realtime');
|
|
42
42
|
const { mcpPlugin, createMcpServer, McpServer } = require('./mcp');
|
|
43
43
|
const polarPlugin = require('./polar');
|
|
44
|
+
const fileManagerPlugin = require('./file-manager');
|
|
45
|
+
const { xlsxPlugin, xlsx } = require('./xlsx');
|
|
46
|
+
const { csvPlugin, csv } = require('./csv');
|
|
44
47
|
|
|
45
48
|
module.exports = {
|
|
46
49
|
sitemapPlugin,
|
|
@@ -83,6 +86,11 @@ module.exports = {
|
|
|
83
86
|
createMcpServer,
|
|
84
87
|
McpServer,
|
|
85
88
|
polarPlugin,
|
|
89
|
+
fileManagerPlugin,
|
|
90
|
+
xlsxPlugin,
|
|
91
|
+
xlsx,
|
|
92
|
+
csvPlugin,
|
|
93
|
+
csv,
|
|
86
94
|
};
|
|
87
95
|
|
|
88
96
|
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog — `webspresso/plugins/polar`
|
|
2
2
|
|
|
3
|
+
## 1.0.1 — Unreleased
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **package exports**: `webspresso/core/auth`, `webspresso/plugins/polar`, and `webspresso/plugins/polar/src/*` resolve without manual `/index.js` suffix
|
|
8
|
+
- **Rate limiting**: Polar limiters use `express-rate-limit` v8 `ipKeyGenerator` (fixes production startup `ValidationError`)
|
|
9
|
+
- **Webhook user lookup**: `metadata.user_id` supports nanoid/UUID string primary keys
|
|
10
|
+
- **CSP**: `polarCspDirectives()` includes `'self'` in `formAction` and `connectSrc`
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `hooks.isPaidUser({ user, knex, config })` for external tier tables (legacy `(user, knex, config)` still supported)
|
|
15
|
+
- `syncBeforeCheckout` option (default `true`)
|
|
16
|
+
- `polar.api.syncBillingForAppUser(user, knex, { hooks })` for dashboard page-load sync
|
|
17
|
+
- `mergePolarCspDirectives(existing)` for Helmet CSP union merge
|
|
18
|
+
- Production checklist: `docs/guides/polar-production-checklist.md`
|
|
19
|
+
- Integration tests: rate-limit startup, IPv6 key validation, `isPaidUser` hook
|
|
20
|
+
|
|
3
21
|
## 1.0.0 — 2026-09-10
|
|
4
22
|
|
|
5
23
|
First production-ready release.
|
package/plugins/polar/README.md
CHANGED
|
@@ -58,10 +58,19 @@ That's it — routes, webhooks, checkout, portal, and status API are auto-mounte
|
|
|
58
58
|
| Method | Path | Auth | Behavior |
|
|
59
59
|
|--------|------|------|----------|
|
|
60
60
|
| POST | `/api/v1/polar/webhook` | HMAC signature | Process Polar events |
|
|
61
|
-
| GET/POST | `/settings/billing/checkout` | session/JWT | Sync → Polar checkout redirect |
|
|
61
|
+
| GET/POST | `/settings/billing/checkout` | session/JWT | Sync → Polar checkout redirect (see POST vs GET below) |
|
|
62
62
|
| GET | `/settings/billing/portal` | session/JWT | Customer portal redirect |
|
|
63
63
|
| GET | `/api/v1/billing/status` | session/JWT | Sync + JSON billing status |
|
|
64
64
|
|
|
65
|
+
## Checkout: POST vs GET
|
|
66
|
+
|
|
67
|
+
Both `/settings/billing/checkout` methods run the same handler:
|
|
68
|
+
|
|
69
|
+
| Method | Typical use | CSP note |
|
|
70
|
+
|--------|-------------|----------|
|
|
71
|
+
| **POST** | `<form method="post" action="…">` | Requires `form-action 'self'` (included in `polarCspDirectives()`) |
|
|
72
|
+
| **GET** | `<a href="…">Upgrade</a>` | No form submission — simpler when CSP is strict |
|
|
73
|
+
|
|
65
74
|
## Template usage
|
|
66
75
|
|
|
67
76
|
```njk
|
|
@@ -81,6 +90,7 @@ That's it — routes, webhooks, checkout, portal, and status API are auto-mounte
|
|
|
81
90
|
const polar = polarPlugin({ db });
|
|
82
91
|
polar.api.verifyPolarWebhook(raw, headers, secret);
|
|
83
92
|
polar.api.syncPolarBillingForUser(user, knex);
|
|
93
|
+
polar.api.syncBillingForAppUser(user, knex, { hooks }); // dashboard page-load sync
|
|
84
94
|
polar.api.getPolarCheckoutUrl({ user, plan: 'pro_monthly', baseUrl });
|
|
85
95
|
polar.api.handlePolarWebhookEvent(event, knex);
|
|
86
96
|
polar.api.generateMigration();
|
|
@@ -118,12 +128,20 @@ polarPlugin({ db, rateLimit: { checkout: { limit: 3 } } }); // override
|
|
|
118
128
|
|
|
119
129
|
## Optional hooks
|
|
120
130
|
|
|
131
|
+
Billing columns on `users` work out of the box. If your paid tier lives elsewhere (e.g. `subscriptions.plan_tier`), use hooks:
|
|
132
|
+
|
|
121
133
|
```js
|
|
122
134
|
polarPlugin({
|
|
123
135
|
db,
|
|
136
|
+
syncBeforeCheckout: false, // skip Polar API sync before redirect (default: true)
|
|
124
137
|
hooks: {
|
|
138
|
+
/** Override paid check at checkout (default: users.tier vs tierMapping) */
|
|
139
|
+
async isPaidUser({ user, knex }) {
|
|
140
|
+
const sub = await knex('subscriptions').where('user_id', user.id).first();
|
|
141
|
+
return sub?.plan_tier === 'pro';
|
|
142
|
+
},
|
|
125
143
|
onSubscriptionChange({ user, tier, polarStatus, subscription, knex, update }) {
|
|
126
|
-
//
|
|
144
|
+
// Sync external tables when webhooks fire
|
|
127
145
|
return knex('users').where('id', user.id).update(update);
|
|
128
146
|
},
|
|
129
147
|
},
|
|
@@ -133,23 +151,28 @@ polarPlugin({
|
|
|
133
151
|
});
|
|
134
152
|
```
|
|
135
153
|
|
|
154
|
+
Checkout metadata sends `user_id` as a string (supports nanoid/UUID primary keys, not only integers).
|
|
155
|
+
|
|
136
156
|
## CSP (Helmet)
|
|
137
157
|
|
|
158
|
+
`polarCspDirectives()` always includes `'self'` so local login/register/checkout forms keep working. Polar domains are added for hosted checkout redirects.
|
|
159
|
+
|
|
138
160
|
```js
|
|
139
|
-
const {
|
|
161
|
+
const { mergePolarCspDirectives } = require('webspresso/plugins/polar/src/urls');
|
|
140
162
|
|
|
141
163
|
createApp({
|
|
142
164
|
helmet: {
|
|
143
165
|
contentSecurityPolicy: {
|
|
144
|
-
directives: {
|
|
145
|
-
|
|
146
|
-
|
|
166
|
+
directives: mergePolarCspDirectives({
|
|
167
|
+
formAction: ["'self'"],
|
|
168
|
+
scriptSrc: ["'self'"],
|
|
169
|
+
}),
|
|
147
170
|
},
|
|
148
171
|
},
|
|
149
172
|
});
|
|
150
173
|
```
|
|
151
174
|
|
|
152
|
-
Or rely on the plugin's built-in `csp` export
|
|
175
|
+
Or rely on the plugin's built-in `csp` export — the plugin manager **unions** sources per directive (does not replace your Helmet config).
|
|
153
176
|
|
|
154
177
|
## Non-goals (v1)
|
|
155
178
|
|
package/plugins/polar/index.js
CHANGED
|
@@ -12,6 +12,7 @@ const { verifyPolarWebhook, handlePolarWebhookEvent, createWebhookRawBodyMiddlew
|
|
|
12
12
|
const { getPolarCheckoutUrl } = require('./src/checkout');
|
|
13
13
|
const { getPolarPortalUrl } = require('./src/portal');
|
|
14
14
|
const { syncPolarBillingForUser } = require('./src/sync');
|
|
15
|
+
const { syncBillingForAppUser } = require('./src/billing-hooks');
|
|
15
16
|
const {
|
|
16
17
|
resolveUserFromPolarData,
|
|
17
18
|
buildCheckoutMetadata,
|
|
@@ -24,6 +25,7 @@ const {
|
|
|
24
25
|
isValidPortalRedirectUrl,
|
|
25
26
|
extractCheckoutUrlFromResponse,
|
|
26
27
|
polarCspDirectives,
|
|
28
|
+
mergePolarCspDirectives,
|
|
27
29
|
} = require('./src/urls');
|
|
28
30
|
const { generatePolarMigration } = require('./src/migration');
|
|
29
31
|
const { polarSyncMiddleware } = require('./src/middleware');
|
|
@@ -66,6 +68,8 @@ function polarPlugin(options = {}) {
|
|
|
66
68
|
getPolarCheckoutUrl: (params) => getPolarCheckoutUrl({ ...params, config: runtimeConfig }),
|
|
67
69
|
getPolarPortalUrl: (user, baseUrl) => getPolarPortalUrl(user, baseUrl, runtimeConfig),
|
|
68
70
|
syncPolarBillingForUser: (user, knex) => syncPolarBillingForUser(user, knex, runtimeConfig),
|
|
71
|
+
/** Dashboard/page-load sync — optional per-call hooks override */
|
|
72
|
+
syncBillingForAppUser: (user, knex, opts) => syncBillingForAppUser(user, knex, runtimeConfig, opts),
|
|
69
73
|
handlePolarWebhookEvent: (event, knex, hooks) => handlePolarWebhookEvent(event, knex, runtimeConfig, hooks),
|
|
70
74
|
resolveUserFromPolarData: (data, knex) => resolveUserFromPolarData(data, knex, runtimeConfig),
|
|
71
75
|
extractCheckoutUrlFromResponse: (data) => extractCheckoutUrlFromResponse(data, runtimeConfig.sandbox),
|
|
@@ -78,6 +82,7 @@ function polarPlugin(options = {}) {
|
|
|
78
82
|
generateMigration: (opts) => generatePolarMigration({ ...opts, fields: runtimeConfig.fields }),
|
|
79
83
|
polarSyncMiddleware: (opts) => polarSyncMiddleware(runtimeConfig, opts),
|
|
80
84
|
polarCspDirectives,
|
|
85
|
+
mergePolarCspDirectives,
|
|
81
86
|
createWebhookRawBodyMiddleware,
|
|
82
87
|
registerNunjucksFilters,
|
|
83
88
|
dateLabel,
|
|
@@ -89,7 +94,7 @@ function polarPlugin(options = {}) {
|
|
|
89
94
|
|
|
90
95
|
return {
|
|
91
96
|
name: 'polar',
|
|
92
|
-
version: '1.0.
|
|
97
|
+
version: '1.0.1',
|
|
93
98
|
description: 'Polar.sh subscription billing — checkout, portal, webhooks, and tier sync',
|
|
94
99
|
|
|
95
100
|
csp: polarCspDirectives(),
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { userHasProTier } = require('./user-resolver');
|
|
4
|
+
const { syncPolarBillingForUser } = require('./sync');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Invoke hooks.isPaidUser — supports ({ user, knex, config }) or legacy (user, knex, config).
|
|
8
|
+
* @param {Function} hook
|
|
9
|
+
* @param {Object} user
|
|
10
|
+
* @param {Object} knex
|
|
11
|
+
* @param {import('./config').PolarConfig} config
|
|
12
|
+
*/
|
|
13
|
+
async function invokeIsPaidUserHook(hook, user, knex, config) {
|
|
14
|
+
const ctx = { user, knex, config };
|
|
15
|
+
// Legacy (user, knex, config) when declared with 2+ params; default is ({ user, knex, config })
|
|
16
|
+
const result = hook.length >= 2
|
|
17
|
+
? await hook(user, knex, config)
|
|
18
|
+
: await hook(ctx);
|
|
19
|
+
return Boolean(result);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Paid-user check at checkout: hooks.isPaidUser override, else users.tier via userHasProTier.
|
|
24
|
+
*/
|
|
25
|
+
async function userIsPaid(user, knex, config) {
|
|
26
|
+
if (typeof config.hooks?.isPaidUser === 'function') {
|
|
27
|
+
return invokeIsPaidUserHook(config.hooks.isPaidUser, user, knex, config);
|
|
28
|
+
}
|
|
29
|
+
return userHasProTier(user, config);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* App-level billing sync for dashboard loaders — merges optional hook overrides per call.
|
|
34
|
+
* @param {Object} user
|
|
35
|
+
* @param {Object} knex
|
|
36
|
+
* @param {import('./config').PolarConfig} config
|
|
37
|
+
* @param {{ hooks?: Object }} [opts]
|
|
38
|
+
*/
|
|
39
|
+
async function syncBillingForAppUser(user, knex, config, opts = {}) {
|
|
40
|
+
const merged = {
|
|
41
|
+
...config,
|
|
42
|
+
hooks: { ...config.hooks, ...(opts.hooks || {}) },
|
|
43
|
+
};
|
|
44
|
+
return syncPolarBillingForUser(user, knex, merged);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
invokeIsPaidUserHook,
|
|
49
|
+
userIsPaid,
|
|
50
|
+
syncBillingForAppUser,
|
|
51
|
+
};
|
|
@@ -67,6 +67,8 @@ function resolvePolarConfig(options = {}) {
|
|
|
67
67
|
hooks: options.hooks || {},
|
|
68
68
|
requireAuth: options.requireAuth || null,
|
|
69
69
|
syncMiddleware: options.syncMiddleware || null,
|
|
70
|
+
/** Sync from Polar API before checkout redirect (default true). Set false to skip. */
|
|
71
|
+
syncBeforeCheckout: options.syncBeforeCheckout !== false,
|
|
70
72
|
rateLimit: options.rateLimit !== undefined ? options.rateLimit : true,
|
|
71
73
|
env,
|
|
72
74
|
accessToken: options.accessToken || env.POLAR_ACCESS_TOKEN || null,
|