json-rest-api 0.1.1 → 1.0.1
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/.claude/settings.local.json +16 -0
- package/LICENSE +674 -20
- package/README.md +728 -120
- package/RELATIONSHIPS.md +1013 -0
- package/TODO_rest-api-plugin-extras.js +142 -0
- package/connectors_ideas/express-plugin.js +120 -0
- package/connectors_ideas/fastify.js +219 -0
- package/connectors_ideas/http-plugin.js +138 -0
- package/connectors_ideas/koa-plugin.js +113 -0
- package/docs/fileUploads.md +722 -0
- package/docs/mini-guides/file-uploads-complete.md +159 -0
- package/examples/.gitkeep +0 -0
- package/examples/express-example.js +262 -0
- package/examples/express-plugin.js +366 -0
- package/examples/file-upload-example.js +205 -0
- package/examples/filename-strategies.js +140 -0
- package/examples/knex-basic.js +66 -0
- package/index.js +28 -202
- package/lib/payload-validators.js +685 -0
- package/lib/rest-api-errors.js +58 -0
- package/package.json +37 -20
- package/payloadValdators.js +686 -0
- package/plugins/core/connectors/express-plugin.js +449 -0
- package/plugins/core/connectors/http-plugin.js +450 -0
- package/plugins/core/file-handling-plugin.js +338 -0
- package/plugins/core/lib/busboy-detector.js +167 -0
- package/plugins/core/lib/formidable-detector.js +130 -0
- package/plugins/core/rest-api-knex-plugin.js +200 -0
- package/plugins/core/rest-api-plugin.js +1419 -0
- package/plugins/storage/local-storage.js +218 -0
- package/plugins/storage/s3-storage.js +125 -0
- package/test-examples/package-lock.json +906 -0
- package/test-examples/package.json +15 -0
- package/test-examples/test-basic-api.js +11 -0
- package/test-examples/test-books-resource.js +116 -0
- package/test-examples/test-complete-file-upload.js +140 -0
- package/test-examples/test-debug.js +28 -0
- package/test-examples/test-express-plugin.js +177 -0
- package/test-examples/test-express-simple.js +28 -0
- package/test-examples/test-express-with-import.js +71 -0
- package/test-examples/test-file-upload.js +132 -0
- package/test-examples/test-http-plugin.js +159 -0
- package/test-examples/test-programmatic-api.js +188 -0
- package/test-examples/test-rest-api-plugin.js +13 -0
- package/.jshintrc +0 -48
- package/.npmignore +0 -15
- package/examples/client.js +0 -18
- package/examples/client2.js +0 -27
- package/examples/server.js +0 -23
- package/test.js +0 -101
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local File Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* Production-ready local storage with secure filename handling
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { promises as fs } from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
|
|
11
|
+
export class LocalStorage {
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
this.directory = path.resolve(options.directory || './uploads');
|
|
14
|
+
this.baseUrl = options.baseUrl || '/uploads';
|
|
15
|
+
this.nameStrategy = options.nameStrategy || 'hash'; // 'hash', 'timestamp', 'original', 'custom'
|
|
16
|
+
this.nameGenerator = options.nameGenerator; // Custom function
|
|
17
|
+
this.preserveExtension = options.preserveExtension !== false;
|
|
18
|
+
this.allowedExtensions = options.allowedExtensions; // Whitelist
|
|
19
|
+
this.maxFilenameLength = options.maxFilenameLength || 255;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate a safe filename
|
|
24
|
+
*/
|
|
25
|
+
async generateFilename(file) {
|
|
26
|
+
let basename, extension;
|
|
27
|
+
|
|
28
|
+
// Extract and validate extension
|
|
29
|
+
const originalExt = path.extname(file.filename).toLowerCase();
|
|
30
|
+
if (this.preserveExtension && originalExt) {
|
|
31
|
+
// Validate extension against whitelist if provided
|
|
32
|
+
if (this.allowedExtensions && !this.allowedExtensions.includes(originalExt)) {
|
|
33
|
+
throw new Error(`File extension '${originalExt}' is not allowed`);
|
|
34
|
+
}
|
|
35
|
+
extension = originalExt;
|
|
36
|
+
} else {
|
|
37
|
+
// Determine extension from MIME type for safety
|
|
38
|
+
extension = this.getExtensionFromMimeType(file.mimetype);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Generate basename based on strategy
|
|
42
|
+
switch (this.nameStrategy) {
|
|
43
|
+
case 'hash':
|
|
44
|
+
// Cryptographically secure random name
|
|
45
|
+
basename = crypto.randomBytes(16).toString('hex');
|
|
46
|
+
break;
|
|
47
|
+
|
|
48
|
+
case 'timestamp':
|
|
49
|
+
// Timestamp + random suffix to prevent collisions
|
|
50
|
+
basename = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
51
|
+
break;
|
|
52
|
+
|
|
53
|
+
case 'original':
|
|
54
|
+
// Sanitized original filename
|
|
55
|
+
basename = this.sanitizeFilename(
|
|
56
|
+
path.basename(file.filename, originalExt)
|
|
57
|
+
);
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case 'custom':
|
|
61
|
+
if (!this.nameGenerator) {
|
|
62
|
+
throw new Error('nameGenerator function required for custom strategy');
|
|
63
|
+
}
|
|
64
|
+
basename = await this.nameGenerator(file);
|
|
65
|
+
break;
|
|
66
|
+
|
|
67
|
+
default:
|
|
68
|
+
throw new Error(`Unknown name strategy: ${this.nameStrategy}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Combine basename and extension
|
|
72
|
+
let filename = extension ? `${basename}${extension}` : basename;
|
|
73
|
+
|
|
74
|
+
// Ensure filename doesn't exceed max length
|
|
75
|
+
if (filename.length > this.maxFilenameLength) {
|
|
76
|
+
// Truncate basename to fit
|
|
77
|
+
const maxBasename = this.maxFilenameLength - (extension?.length || 0);
|
|
78
|
+
basename = basename.substring(0, maxBasename);
|
|
79
|
+
filename = extension ? `${basename}${extension}` : basename;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Handle duplicates
|
|
83
|
+
filename = await this.ensureUnique(filename);
|
|
84
|
+
|
|
85
|
+
return filename;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sanitize filename for safe storage
|
|
90
|
+
*/
|
|
91
|
+
sanitizeFilename(filename) {
|
|
92
|
+
return filename
|
|
93
|
+
// Remove path traversal attempts
|
|
94
|
+
.replace(/\.\./g, '')
|
|
95
|
+
.replace(/[\/\\]/g, '')
|
|
96
|
+
// Remove control characters and special chars
|
|
97
|
+
.replace(/[^\w\s.-]/g, '_')
|
|
98
|
+
// Remove leading/trailing dots and spaces
|
|
99
|
+
.replace(/^[\s.]+|[\s.]+$/g, '')
|
|
100
|
+
// Collapse multiple underscores
|
|
101
|
+
.replace(/_+/g, '_')
|
|
102
|
+
// Default if empty
|
|
103
|
+
|| 'unnamed';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ensure filename is unique
|
|
108
|
+
*/
|
|
109
|
+
async ensureUnique(filename) {
|
|
110
|
+
const filepath = path.join(this.directory, filename);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await fs.access(filepath);
|
|
114
|
+
// File exists, generate unique name
|
|
115
|
+
const ext = path.extname(filename);
|
|
116
|
+
const base = path.basename(filename, ext);
|
|
117
|
+
let counter = 1;
|
|
118
|
+
let newFilename;
|
|
119
|
+
|
|
120
|
+
do {
|
|
121
|
+
newFilename = `${base}_${counter}${ext}`;
|
|
122
|
+
counter++;
|
|
123
|
+
} while (await this.fileExists(path.join(this.directory, newFilename)));
|
|
124
|
+
|
|
125
|
+
return newFilename;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
// File doesn't exist, name is unique
|
|
128
|
+
return filename;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if file exists
|
|
134
|
+
*/
|
|
135
|
+
async fileExists(filepath) {
|
|
136
|
+
try {
|
|
137
|
+
await fs.access(filepath);
|
|
138
|
+
return true;
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get safe extension from MIME type
|
|
146
|
+
*/
|
|
147
|
+
getExtensionFromMimeType(mimetype) {
|
|
148
|
+
// Safe mappings only
|
|
149
|
+
const mimeToExt = {
|
|
150
|
+
'image/jpeg': '.jpg',
|
|
151
|
+
'image/png': '.png',
|
|
152
|
+
'image/gif': '.gif',
|
|
153
|
+
'image/webp': '.webp',
|
|
154
|
+
'application/pdf': '.pdf',
|
|
155
|
+
'text/plain': '.txt',
|
|
156
|
+
'application/json': '.json',
|
|
157
|
+
'video/mp4': '.mp4',
|
|
158
|
+
'audio/mpeg': '.mp3',
|
|
159
|
+
'application/zip': '.zip'
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return mimeToExt[mimetype] || '.bin';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Upload file with secure filename
|
|
167
|
+
*/
|
|
168
|
+
async upload(file) {
|
|
169
|
+
// Ensure directory exists
|
|
170
|
+
await fs.mkdir(this.directory, { recursive: true });
|
|
171
|
+
|
|
172
|
+
// Generate secure filename
|
|
173
|
+
const filename = await this.generateFilename(file);
|
|
174
|
+
const filepath = path.join(this.directory, filename);
|
|
175
|
+
|
|
176
|
+
// Ensure we're not writing outside our directory (defense in depth)
|
|
177
|
+
const resolvedPath = path.resolve(filepath);
|
|
178
|
+
const resolvedDir = path.resolve(this.directory);
|
|
179
|
+
if (!resolvedPath.startsWith(resolvedDir)) {
|
|
180
|
+
throw new Error('Invalid file path');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Write file
|
|
184
|
+
if (file.data) {
|
|
185
|
+
await fs.writeFile(filepath, file.data);
|
|
186
|
+
} else if (file.filepath) {
|
|
187
|
+
await fs.rename(file.filepath, filepath);
|
|
188
|
+
} else {
|
|
189
|
+
throw new Error('File has no data or filepath');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Return public URL
|
|
193
|
+
return `${this.baseUrl}/${filename}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Delete a file
|
|
198
|
+
*/
|
|
199
|
+
async delete(url) {
|
|
200
|
+
const filename = path.basename(url);
|
|
201
|
+
const filepath = path.join(this.directory, filename);
|
|
202
|
+
|
|
203
|
+
// Security check
|
|
204
|
+
const resolvedPath = path.resolve(filepath);
|
|
205
|
+
const resolvedDir = path.resolve(this.directory);
|
|
206
|
+
if (!resolvedPath.startsWith(resolvedDir)) {
|
|
207
|
+
throw new Error('Invalid file path');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await fs.unlink(filepath);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error.code !== 'ENOENT') {
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S3 Storage Adapter
|
|
3
|
+
*
|
|
4
|
+
* Stores uploaded files in Amazon S3 or S3-compatible storage.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```javascript
|
|
8
|
+
* import { S3Storage } from 'jsonrestapi/lib/storage/s3-storage.js';
|
|
9
|
+
*
|
|
10
|
+
* const storage = new S3Storage({
|
|
11
|
+
* bucket: 'my-uploads',
|
|
12
|
+
* region: 'us-east-1',
|
|
13
|
+
* accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
14
|
+
* secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
15
|
+
* prefix: 'uploads/' // optional path prefix
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* const schema = {
|
|
19
|
+
* image: { type: 'file', storage }
|
|
20
|
+
* };
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import crypto from 'crypto';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
|
|
27
|
+
export class S3Storage {
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.bucket = options.bucket;
|
|
30
|
+
this.region = options.region || 'us-east-1';
|
|
31
|
+
this.prefix = options.prefix || '';
|
|
32
|
+
this.acl = options.acl || 'public-read';
|
|
33
|
+
|
|
34
|
+
if (!this.bucket) {
|
|
35
|
+
throw new Error('S3Storage requires a bucket name');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Note: In a real implementation, you would initialize the AWS SDK here
|
|
39
|
+
// For this example, we'll just mock the behavior
|
|
40
|
+
this.mockMode = options.mockMode !== false; // Default to mock mode
|
|
41
|
+
|
|
42
|
+
if (!this.mockMode) {
|
|
43
|
+
// Real S3 initialization would go here
|
|
44
|
+
// const { S3Client } = require('@aws-sdk/client-s3');
|
|
45
|
+
// this.s3 = new S3Client({ region: this.region });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Upload a file to S3
|
|
51
|
+
* @param {Object} file - File object from detector
|
|
52
|
+
* @returns {Promise<string>} URL of the uploaded file
|
|
53
|
+
*/
|
|
54
|
+
async upload(file) {
|
|
55
|
+
// Generate S3 key
|
|
56
|
+
const ext = path.extname(file.filename);
|
|
57
|
+
const hash = crypto.randomBytes(16).toString('hex');
|
|
58
|
+
const key = `${this.prefix}${hash}${ext}`;
|
|
59
|
+
|
|
60
|
+
if (this.mockMode) {
|
|
61
|
+
// Mock mode - just return a fake URL
|
|
62
|
+
const url = `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`;
|
|
63
|
+
|
|
64
|
+
// Simulate upload delay
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
66
|
+
|
|
67
|
+
return url;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Real S3 upload would go here
|
|
71
|
+
/*
|
|
72
|
+
const { PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
73
|
+
|
|
74
|
+
const command = new PutObjectCommand({
|
|
75
|
+
Bucket: this.bucket,
|
|
76
|
+
Key: key,
|
|
77
|
+
Body: file.data,
|
|
78
|
+
ContentType: file.mimetype,
|
|
79
|
+
ACL: this.acl,
|
|
80
|
+
Metadata: {
|
|
81
|
+
originalName: file.filename
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await this.s3.send(command);
|
|
86
|
+
|
|
87
|
+
// Return public URL
|
|
88
|
+
if (this.acl === 'public-read') {
|
|
89
|
+
return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}`;
|
|
90
|
+
} else {
|
|
91
|
+
// For private files, you might return a signed URL
|
|
92
|
+
return await this.getSignedUrl(key);
|
|
93
|
+
}
|
|
94
|
+
*/
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Delete a file from S3
|
|
99
|
+
* @param {string} url - URL returned by upload()
|
|
100
|
+
* @returns {Promise<void>}
|
|
101
|
+
*/
|
|
102
|
+
async delete(url) {
|
|
103
|
+
// Extract key from URL
|
|
104
|
+
const urlObj = new URL(url);
|
|
105
|
+
const key = urlObj.pathname.substring(1); // Remove leading /
|
|
106
|
+
|
|
107
|
+
if (this.mockMode) {
|
|
108
|
+
// Mock mode - just simulate deletion
|
|
109
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Real S3 delete would go here
|
|
114
|
+
/*
|
|
115
|
+
const { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
|
116
|
+
|
|
117
|
+
const command = new DeleteObjectCommand({
|
|
118
|
+
Bucket: this.bucket,
|
|
119
|
+
Key: key
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await this.s3.send(command);
|
|
123
|
+
*/
|
|
124
|
+
}
|
|
125
|
+
}
|