@progalaxyelabs/stonescriptphp-files 1.0.0
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/LICENSE +21 -0
- package/README.md +120 -0
- package/package.json +40 -0
- package/src/auth.js +103 -0
- package/src/azure-storage.js +200 -0
- package/src/index.js +114 -0
- package/src/routes/delete.js +63 -0
- package/src/routes/download.js +77 -0
- package/src/routes/health.js +23 -0
- package/src/routes/list.js +40 -0
- package/src/routes/upload.js +78 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ProGalaxy eLabs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# @progalaxyelabs/stonescriptphp-files
|
|
2
|
+
|
|
3
|
+
Shared file server for ProGalaxy E-Labs platforms — Azure Blob Storage + JWT auth
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install express @progalaxyelabs/stonescriptphp-files
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```javascript
|
|
14
|
+
import { createFilesServer } from '@progalaxyelabs/stonescriptphp-files';
|
|
15
|
+
|
|
16
|
+
// Zero-config setup (uses environment variables)
|
|
17
|
+
createFilesServer().listen();
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Environment Variables
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
AZURE_STORAGE_CONNECTION_STRING=your_connection_string
|
|
24
|
+
AZURE_CONTAINER_NAME=platform-files # optional, defaults to 'platform-files'
|
|
25
|
+
JWT_PUBLIC_KEY=your_jwt_public_key
|
|
26
|
+
PORT=3000 # optional, defaults to 3000
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Advanced Configuration
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
import { createFilesServer } from '@progalaxyelabs/stonescriptphp-files';
|
|
33
|
+
|
|
34
|
+
const server = createFilesServer({
|
|
35
|
+
port: 3000,
|
|
36
|
+
containerName: 'my-files',
|
|
37
|
+
azureConnectionString: 'your_connection_string',
|
|
38
|
+
jwtPublicKey: 'your_public_key',
|
|
39
|
+
maxFileSize: 100 * 1024 * 1024, // 100MB
|
|
40
|
+
corsOrigins: '*' // or ['https://example.com']
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
server.listen();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## API Endpoints
|
|
47
|
+
|
|
48
|
+
### POST /upload
|
|
49
|
+
Upload a file (requires JWT authentication)
|
|
50
|
+
|
|
51
|
+
**Request:** multipart/form-data with `file` field
|
|
52
|
+
**Response:**
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"success": true,
|
|
56
|
+
"file": {
|
|
57
|
+
"id": "uuid-here",
|
|
58
|
+
"name": "filename.pdf",
|
|
59
|
+
"contentType": "application/pdf",
|
|
60
|
+
"size": 12345,
|
|
61
|
+
"uploadedAt": "2025-02-07T12:00:00.000Z"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### GET /files/:id
|
|
67
|
+
Download a file (requires JWT authentication, validates ownership)
|
|
68
|
+
|
|
69
|
+
### GET /files
|
|
70
|
+
List all files for authenticated user
|
|
71
|
+
|
|
72
|
+
### DELETE /files/:id
|
|
73
|
+
Delete a file (requires JWT authentication, validates ownership)
|
|
74
|
+
|
|
75
|
+
### GET /health
|
|
76
|
+
Health check endpoint (no authentication required)
|
|
77
|
+
|
|
78
|
+
## Composable Usage
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
import express from 'express';
|
|
82
|
+
import {
|
|
83
|
+
AzureStorageClient,
|
|
84
|
+
createAuthMiddleware,
|
|
85
|
+
createUploadRouter,
|
|
86
|
+
createDownloadRouter
|
|
87
|
+
} from '@progalaxyelabs/stonescriptphp-files';
|
|
88
|
+
|
|
89
|
+
const app = express();
|
|
90
|
+
const storage = new AzureStorageClient(connectionString, containerName);
|
|
91
|
+
const authenticate = createAuthMiddleware(publicKey);
|
|
92
|
+
|
|
93
|
+
await storage.initialize();
|
|
94
|
+
|
|
95
|
+
app.use(authenticate, createUploadRouter(storage));
|
|
96
|
+
app.use(authenticate, createDownloadRouter(storage));
|
|
97
|
+
|
|
98
|
+
app.listen(3000);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Features
|
|
102
|
+
|
|
103
|
+
- ✅ Azure Blob Storage integration
|
|
104
|
+
- ✅ JWT Bearer token authentication (RS256/ES256/HS256)
|
|
105
|
+
- ✅ File ownership validation
|
|
106
|
+
- ✅ Streaming uploads and downloads
|
|
107
|
+
- ✅ Configurable file size limits
|
|
108
|
+
- ✅ CORS support
|
|
109
|
+
- ✅ Graceful shutdown handling
|
|
110
|
+
- ✅ Health check endpoint
|
|
111
|
+
- ✅ ESM module support
|
|
112
|
+
|
|
113
|
+
## Requirements
|
|
114
|
+
|
|
115
|
+
- Node.js >=24.0.0
|
|
116
|
+
- Express ^5.0.0 (peer dependency)
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@progalaxyelabs/stonescriptphp-files",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared file server for ProGalaxy E-Labs platforms — Azure Blob Storage + JWT auth",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=24.0.0"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"express": "^5.0.0"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@azure/storage-blob": "^12.24.0",
|
|
18
|
+
"multer": "^1.4.5-lts.1",
|
|
19
|
+
"jsonwebtoken": "^9.0.2",
|
|
20
|
+
"cors": "^2.8.5",
|
|
21
|
+
"uuid": "^11.0.5"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"azure",
|
|
25
|
+
"blob-storage",
|
|
26
|
+
"file-upload",
|
|
27
|
+
"jwt",
|
|
28
|
+
"express"
|
|
29
|
+
],
|
|
30
|
+
"author": "ProGalaxy E-Labs <info@progalaxyelabs.com> (https://www.progalaxyelabs.com)",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/progalaxyelabs/stonescriptphp-files.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/progalaxyelabs/stonescriptphp-files#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/progalaxyelabs/stonescriptphp-files/issues"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JWT validation middleware factory
|
|
5
|
+
* Creates a middleware that validates JWT tokens and extracts user_id for authorization
|
|
6
|
+
*
|
|
7
|
+
* @param {string} publicKey - JWT public key for token verification (RS256/ES256/HS256)
|
|
8
|
+
* @returns {Function} Express middleware function
|
|
9
|
+
*/
|
|
10
|
+
export function createAuthMiddleware(publicKey) {
|
|
11
|
+
if (!publicKey) {
|
|
12
|
+
throw new Error('JWT public key is required');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return function authenticateJWT(req, res, next) {
|
|
16
|
+
const authHeader = req.headers.authorization;
|
|
17
|
+
|
|
18
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
19
|
+
return res.status(401).json({
|
|
20
|
+
error: 'Unauthorized',
|
|
21
|
+
message: 'Missing or invalid authorization header'
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// Verify token with public key
|
|
29
|
+
const decoded = jwt.verify(token, publicKey, {
|
|
30
|
+
algorithms: ['RS256', 'ES256', 'HS256']
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Extract user_id from token
|
|
34
|
+
// Common JWT claims: sub, user_id, userId, id
|
|
35
|
+
const userId = decoded.sub || decoded.user_id || decoded.userId || decoded.id;
|
|
36
|
+
|
|
37
|
+
if (!userId) {
|
|
38
|
+
return res.status(401).json({
|
|
39
|
+
error: 'Unauthorized',
|
|
40
|
+
message: 'Token missing user identifier'
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Attach user info to request
|
|
45
|
+
req.user = {
|
|
46
|
+
id: userId,
|
|
47
|
+
email: decoded.email,
|
|
48
|
+
roles: decoded.roles || [],
|
|
49
|
+
...decoded
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
next();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('JWT validation error:', error.message);
|
|
55
|
+
|
|
56
|
+
if (error.name === 'TokenExpiredError') {
|
|
57
|
+
return res.status(401).json({
|
|
58
|
+
error: 'Unauthorized',
|
|
59
|
+
message: 'Token expired'
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (error.name === 'JsonWebTokenError') {
|
|
64
|
+
return res.status(401).json({
|
|
65
|
+
error: 'Unauthorized',
|
|
66
|
+
message: 'Invalid token'
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return res.status(401).json({
|
|
71
|
+
error: 'Unauthorized',
|
|
72
|
+
message: 'Token validation failed'
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Optional: Role-based authorization middleware factory
|
|
80
|
+
* Use after authenticateJWT to check for specific roles
|
|
81
|
+
*/
|
|
82
|
+
export function requireRole(...allowedRoles) {
|
|
83
|
+
return (req, res, next) => {
|
|
84
|
+
if (!req.user) {
|
|
85
|
+
return res.status(401).json({
|
|
86
|
+
error: 'Unauthorized',
|
|
87
|
+
message: 'Authentication required'
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const userRoles = req.user.roles || [];
|
|
92
|
+
const hasRole = allowedRoles.some(role => userRoles.includes(role));
|
|
93
|
+
|
|
94
|
+
if (!hasRole) {
|
|
95
|
+
return res.status(403).json({
|
|
96
|
+
error: 'Forbidden',
|
|
97
|
+
message: 'Insufficient permissions'
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
next();
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { BlobServiceClient } from '@azure/storage-blob';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Azure Blob Storage operations
|
|
6
|
+
* Handles upload, download, list, and delete operations
|
|
7
|
+
*/
|
|
8
|
+
export class AzureStorageClient {
|
|
9
|
+
constructor(connectionString, containerName = 'platform-files') {
|
|
10
|
+
if (!connectionString) {
|
|
11
|
+
throw new Error('Azure Storage connection string is required');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
this.connectionString = connectionString;
|
|
15
|
+
this.containerName = containerName;
|
|
16
|
+
this.blobServiceClient = null;
|
|
17
|
+
this.containerClient = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Initialize Azure Blob Storage client
|
|
22
|
+
*/
|
|
23
|
+
async initialize() {
|
|
24
|
+
try {
|
|
25
|
+
// Create BlobServiceClient
|
|
26
|
+
this.blobServiceClient = BlobServiceClient.fromConnectionString(this.connectionString);
|
|
27
|
+
|
|
28
|
+
// Get container client
|
|
29
|
+
this.containerClient = this.blobServiceClient.getContainerClient(this.containerName);
|
|
30
|
+
|
|
31
|
+
// Create container if it doesn't exist
|
|
32
|
+
await this.containerClient.createIfNotExists({
|
|
33
|
+
access: 'private' // CRITICAL: No public access
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
console.log(`Azure Blob Storage initialized: container=${this.containerName}`);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('Failed to initialize Azure Blob Storage:', error);
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Upload file to Azure Blob Storage
|
|
45
|
+
* @param {string} userId - User ID (for namespacing)
|
|
46
|
+
* @param {Buffer} fileBuffer - File content
|
|
47
|
+
* @param {string} originalFilename - Original filename
|
|
48
|
+
* @param {string} contentType - MIME type
|
|
49
|
+
* @returns {Object} File metadata
|
|
50
|
+
*/
|
|
51
|
+
async uploadFile(userId, fileBuffer, originalFilename, contentType) {
|
|
52
|
+
if (!this.containerClient) {
|
|
53
|
+
throw new Error('Azure Storage not initialized');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Generate unique blob name: {user_id}/{uuid}.{ext}
|
|
57
|
+
const fileId = uuidv4();
|
|
58
|
+
const extension = originalFilename.split('.').pop();
|
|
59
|
+
const blobName = `${userId}/${fileId}.${extension}`;
|
|
60
|
+
|
|
61
|
+
const blockBlobClient = this.containerClient.getBlockBlobClient(blobName);
|
|
62
|
+
|
|
63
|
+
// Upload with metadata
|
|
64
|
+
await blockBlobClient.upload(fileBuffer, fileBuffer.length, {
|
|
65
|
+
blobHTTPHeaders: {
|
|
66
|
+
blobContentType: contentType
|
|
67
|
+
},
|
|
68
|
+
metadata: {
|
|
69
|
+
user_id: userId,
|
|
70
|
+
original_filename: originalFilename,
|
|
71
|
+
content_type: contentType,
|
|
72
|
+
uploaded_at: new Date().toISOString(),
|
|
73
|
+
file_id: fileId
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
fileId,
|
|
79
|
+
blobName,
|
|
80
|
+
userId,
|
|
81
|
+
originalFilename,
|
|
82
|
+
contentType,
|
|
83
|
+
size: fileBuffer.length,
|
|
84
|
+
uploadedAt: new Date().toISOString()
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Download file from Azure Blob Storage
|
|
90
|
+
* @param {string} fileId - File ID (UUID)
|
|
91
|
+
* @param {string} userId - User ID (for authorization)
|
|
92
|
+
* @returns {Object} { stream, metadata }
|
|
93
|
+
*/
|
|
94
|
+
async downloadFile(fileId, userId) {
|
|
95
|
+
if (!this.containerClient) {
|
|
96
|
+
throw new Error('Azure Storage not initialized');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// List blobs with file_id metadata to find the blob
|
|
100
|
+
const blobs = this.containerClient.listBlobsFlat({
|
|
101
|
+
prefix: `${userId}/`,
|
|
102
|
+
includeMetadata: true
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
for await (const blob of blobs) {
|
|
106
|
+
if (blob.metadata && blob.metadata.file_id === fileId) {
|
|
107
|
+
const blockBlobClient = this.containerClient.getBlockBlobClient(blob.name);
|
|
108
|
+
|
|
109
|
+
// Verify ownership
|
|
110
|
+
if (blob.metadata.user_id !== userId) {
|
|
111
|
+
throw new Error('Unauthorized: File belongs to different user');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Download blob
|
|
115
|
+
const downloadResponse = await blockBlobClient.download(0);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
stream: downloadResponse.readableStreamBody,
|
|
119
|
+
metadata: {
|
|
120
|
+
fileName: blob.metadata.original_filename,
|
|
121
|
+
contentType: blob.metadata.content_type,
|
|
122
|
+
size: blob.properties.contentLength
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
throw new Error('File not found');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* List user's files
|
|
133
|
+
* @param {string} userId - User ID
|
|
134
|
+
* @returns {Array} List of file metadata
|
|
135
|
+
*/
|
|
136
|
+
async listFiles(userId) {
|
|
137
|
+
if (!this.containerClient) {
|
|
138
|
+
throw new Error('Azure Storage not initialized');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const files = [];
|
|
142
|
+
const blobs = this.containerClient.listBlobsFlat({
|
|
143
|
+
prefix: `${userId}/`,
|
|
144
|
+
includeMetadata: true
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
for await (const blob of blobs) {
|
|
148
|
+
if (blob.metadata) {
|
|
149
|
+
files.push({
|
|
150
|
+
fileId: blob.metadata.file_id,
|
|
151
|
+
fileName: blob.metadata.original_filename,
|
|
152
|
+
contentType: blob.metadata.content_type,
|
|
153
|
+
size: blob.properties.contentLength,
|
|
154
|
+
uploadedAt: blob.metadata.uploaded_at
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return files;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Delete file from Azure Blob Storage
|
|
164
|
+
* @param {string} fileId - File ID (UUID)
|
|
165
|
+
* @param {string} userId - User ID (for authorization)
|
|
166
|
+
*/
|
|
167
|
+
async deleteFile(fileId, userId) {
|
|
168
|
+
if (!this.containerClient) {
|
|
169
|
+
throw new Error('Azure Storage not initialized');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Find blob by file_id
|
|
173
|
+
const blobs = this.containerClient.listBlobsFlat({
|
|
174
|
+
prefix: `${userId}/`,
|
|
175
|
+
includeMetadata: true
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
for await (const blob of blobs) {
|
|
179
|
+
if (blob.metadata && blob.metadata.file_id === fileId) {
|
|
180
|
+
// Verify ownership
|
|
181
|
+
if (blob.metadata.user_id !== userId) {
|
|
182
|
+
throw new Error('Unauthorized: File belongs to different user');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const blockBlobClient = this.containerClient.getBlockBlobClient(blob.name);
|
|
186
|
+
await blockBlobClient.delete();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
throw new Error('File not found');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get container client (for advanced operations)
|
|
196
|
+
*/
|
|
197
|
+
getContainerClient() {
|
|
198
|
+
return this.containerClient;
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import { AzureStorageClient } from './azure-storage.js';
|
|
4
|
+
import { createAuthMiddleware } from './auth.js';
|
|
5
|
+
import { createUploadRouter } from './routes/upload.js';
|
|
6
|
+
import { createDownloadRouter } from './routes/download.js';
|
|
7
|
+
import { createListRouter } from './routes/list.js';
|
|
8
|
+
import { createDeleteRouter } from './routes/delete.js';
|
|
9
|
+
import { createHealthRouter } from './routes/health.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a configured files server
|
|
13
|
+
* @param {Object} config - Configuration options
|
|
14
|
+
* @param {number} config.port - Server port (default: process.env.PORT || 3000)
|
|
15
|
+
* @param {string} config.containerName - Azure container name (default: process.env.AZURE_CONTAINER_NAME || 'platform-files')
|
|
16
|
+
* @param {string} config.azureConnectionString - Azure storage connection string (default: process.env.AZURE_STORAGE_CONNECTION_STRING)
|
|
17
|
+
* @param {string} config.jwtPublicKey - JWT public key for auth (default: process.env.JWT_PUBLIC_KEY)
|
|
18
|
+
* @param {number} config.maxFileSize - Max file size in bytes (default: 100MB)
|
|
19
|
+
* @param {string|string[]} config.corsOrigins - CORS allowed origins (default: '*')
|
|
20
|
+
* @returns {Object} Server object with app, storage, and listen() method
|
|
21
|
+
*/
|
|
22
|
+
export function createFilesServer(config = {}) {
|
|
23
|
+
const port = config.port || process.env.PORT || 3000;
|
|
24
|
+
const containerName = config.containerName || process.env.AZURE_CONTAINER_NAME || 'platform-files';
|
|
25
|
+
const connectionString = config.azureConnectionString || process.env.AZURE_STORAGE_CONNECTION_STRING;
|
|
26
|
+
const jwtPublicKey = config.jwtPublicKey || process.env.JWT_PUBLIC_KEY;
|
|
27
|
+
const maxFileSize = config.maxFileSize || 100 * 1024 * 1024;
|
|
28
|
+
const corsOrigins = config.corsOrigins || '*';
|
|
29
|
+
|
|
30
|
+
// Create storage client
|
|
31
|
+
const storage = new AzureStorageClient(connectionString, containerName);
|
|
32
|
+
|
|
33
|
+
// Create auth middleware
|
|
34
|
+
const authenticate = createAuthMiddleware(jwtPublicKey);
|
|
35
|
+
|
|
36
|
+
// Create Express app
|
|
37
|
+
const app = express();
|
|
38
|
+
|
|
39
|
+
// Configure CORS
|
|
40
|
+
app.use(cors({ origin: corsOrigins }));
|
|
41
|
+
|
|
42
|
+
// Body parsing middleware
|
|
43
|
+
app.use(express.json());
|
|
44
|
+
app.use(express.urlencoded({ extended: true }));
|
|
45
|
+
|
|
46
|
+
// Request logging
|
|
47
|
+
app.use((req, res, next) => {
|
|
48
|
+
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
|
|
49
|
+
next();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Routes
|
|
53
|
+
app.use(createHealthRouter());
|
|
54
|
+
app.use(authenticate, createUploadRouter(storage, maxFileSize));
|
|
55
|
+
app.use(authenticate, createDownloadRouter(storage));
|
|
56
|
+
app.use(authenticate, createListRouter(storage));
|
|
57
|
+
app.use(authenticate, createDeleteRouter(storage));
|
|
58
|
+
|
|
59
|
+
// 404 handler
|
|
60
|
+
app.use((req, res) => {
|
|
61
|
+
res.status(404).json({
|
|
62
|
+
error: 'Not Found',
|
|
63
|
+
message: 'The requested endpoint does not exist'
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Error handler
|
|
68
|
+
app.use((err, req, res, next) => {
|
|
69
|
+
console.error('Unhandled error:', err);
|
|
70
|
+
res.status(500).json({
|
|
71
|
+
error: 'Internal Server Error',
|
|
72
|
+
message: 'An unexpected error occurred'
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Server object with listen method
|
|
77
|
+
const server = {
|
|
78
|
+
app,
|
|
79
|
+
storage,
|
|
80
|
+
listen: () => {
|
|
81
|
+
return storage.initialize().then(() => {
|
|
82
|
+
const httpServer = app.listen(port, () => {
|
|
83
|
+
console.log(`Files server listening on port ${port}`);
|
|
84
|
+
console.log(`Health check: http://localhost:${port}/health`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Graceful shutdown
|
|
88
|
+
const shutdown = () => {
|
|
89
|
+
console.log('Shutting down gracefully...');
|
|
90
|
+
httpServer.close(() => {
|
|
91
|
+
console.log('Server closed');
|
|
92
|
+
process.exit(0);
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
process.on('SIGTERM', shutdown);
|
|
97
|
+
process.on('SIGINT', shutdown);
|
|
98
|
+
|
|
99
|
+
return httpServer;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return server;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Named exports for advanced/composable usage
|
|
108
|
+
export { AzureStorageClient } from './azure-storage.js';
|
|
109
|
+
export { createAuthMiddleware } from './auth.js';
|
|
110
|
+
export { createUploadRouter } from './routes/upload.js';
|
|
111
|
+
export { createDownloadRouter } from './routes/download.js';
|
|
112
|
+
export { createListRouter } from './routes/list.js';
|
|
113
|
+
export { createDeleteRouter } from './routes/delete.js';
|
|
114
|
+
export { createHealthRouter } from './routes/health.js';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Delete file router factory
|
|
5
|
+
* @param {AzureStorageClient} storage - Azure storage client instance
|
|
6
|
+
* @returns {express.Router} Express router for delete endpoint
|
|
7
|
+
*/
|
|
8
|
+
export function createDeleteRouter(storage) {
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DELETE /files/:id
|
|
13
|
+
* Delete file from Azure Blob Storage
|
|
14
|
+
* Requires: JWT authentication (applied by parent app)
|
|
15
|
+
* Validates: User owns the file
|
|
16
|
+
*/
|
|
17
|
+
router.delete('/files/:id', async (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
const fileId = req.params.id;
|
|
20
|
+
const userId = req.user.id;
|
|
21
|
+
|
|
22
|
+
// Validate file ID format (UUID)
|
|
23
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
24
|
+
if (!uuidRegex.test(fileId)) {
|
|
25
|
+
return res.status(400).json({
|
|
26
|
+
error: 'Bad Request',
|
|
27
|
+
message: 'Invalid file ID format'
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Delete file from Azure (validates ownership)
|
|
32
|
+
await storage.deleteFile(fileId, userId);
|
|
33
|
+
|
|
34
|
+
res.status(200).json({
|
|
35
|
+
success: true,
|
|
36
|
+
message: 'File deleted successfully'
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error('Delete error:', error);
|
|
40
|
+
|
|
41
|
+
if (error.message === 'File not found') {
|
|
42
|
+
return res.status(404).json({
|
|
43
|
+
error: 'Not Found',
|
|
44
|
+
message: 'File not found'
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (error.message.startsWith('Unauthorized:')) {
|
|
49
|
+
return res.status(403).json({
|
|
50
|
+
error: 'Forbidden',
|
|
51
|
+
message: error.message
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
res.status(500).json({
|
|
56
|
+
error: 'Internal Server Error',
|
|
57
|
+
message: 'Failed to delete file'
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return router;
|
|
63
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Download router factory
|
|
5
|
+
* @param {AzureStorageClient} storage - Azure storage client instance
|
|
6
|
+
* @returns {express.Router} Express router for download endpoint
|
|
7
|
+
*/
|
|
8
|
+
export function createDownloadRouter(storage) {
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /files/:id
|
|
13
|
+
* Download file from Azure Blob Storage
|
|
14
|
+
* Requires: JWT authentication (applied by parent app)
|
|
15
|
+
* Validates: User owns the file
|
|
16
|
+
*/
|
|
17
|
+
router.get('/files/:id', async (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
const fileId = req.params.id;
|
|
20
|
+
const userId = req.user.id;
|
|
21
|
+
|
|
22
|
+
// Validate file ID format (UUID)
|
|
23
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
24
|
+
if (!uuidRegex.test(fileId)) {
|
|
25
|
+
return res.status(400).json({
|
|
26
|
+
error: 'Bad Request',
|
|
27
|
+
message: 'Invalid file ID format'
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Download file from Azure (validates ownership)
|
|
32
|
+
const { stream, metadata } = await storage.downloadFile(fileId, userId);
|
|
33
|
+
|
|
34
|
+
// Set response headers
|
|
35
|
+
res.setHeader('Content-Type', metadata.contentType);
|
|
36
|
+
res.setHeader('Content-Length', metadata.size);
|
|
37
|
+
res.setHeader('Content-Disposition', `attachment; filename="${metadata.fileName}"`);
|
|
38
|
+
|
|
39
|
+
// Stream file to response
|
|
40
|
+
stream.pipe(res);
|
|
41
|
+
|
|
42
|
+
// Handle stream errors
|
|
43
|
+
stream.on('error', (error) => {
|
|
44
|
+
console.error('Stream error:', error);
|
|
45
|
+
if (!res.headersSent) {
|
|
46
|
+
res.status(500).json({
|
|
47
|
+
error: 'Internal Server Error',
|
|
48
|
+
message: 'Failed to stream file'
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error('Download error:', error);
|
|
54
|
+
|
|
55
|
+
if (error.message === 'File not found') {
|
|
56
|
+
return res.status(404).json({
|
|
57
|
+
error: 'Not Found',
|
|
58
|
+
message: 'File not found'
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (error.message.startsWith('Unauthorized:')) {
|
|
63
|
+
return res.status(403).json({
|
|
64
|
+
error: 'Forbidden',
|
|
65
|
+
message: error.message
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
res.status(500).json({
|
|
70
|
+
error: 'Internal Server Error',
|
|
71
|
+
message: 'Failed to download file'
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return router;
|
|
77
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Health check router factory
|
|
5
|
+
* @returns {express.Router} Express router for health endpoint
|
|
6
|
+
*/
|
|
7
|
+
export function createHealthRouter() {
|
|
8
|
+
const router = express.Router();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /health
|
|
12
|
+
* Public health check endpoint (no authentication required)
|
|
13
|
+
*/
|
|
14
|
+
router.get('/health', (req, res) => {
|
|
15
|
+
res.status(200).json({
|
|
16
|
+
status: 'healthy',
|
|
17
|
+
service: 'files-service',
|
|
18
|
+
timestamp: new Date().toISOString()
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return router;
|
|
23
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* List files router factory
|
|
5
|
+
* @param {AzureStorageClient} storage - Azure storage client instance
|
|
6
|
+
* @returns {express.Router} Express router for list endpoint
|
|
7
|
+
*/
|
|
8
|
+
export function createListRouter(storage) {
|
|
9
|
+
const router = express.Router();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GET /files
|
|
13
|
+
* List user's files from Azure Blob Storage
|
|
14
|
+
* Requires: JWT authentication (applied by parent app)
|
|
15
|
+
* Returns: Array of file metadata for current user
|
|
16
|
+
*/
|
|
17
|
+
router.get('/files', async (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
const userId = req.user.id;
|
|
20
|
+
|
|
21
|
+
// List files for current user
|
|
22
|
+
const files = await storage.listFiles(userId);
|
|
23
|
+
|
|
24
|
+
res.status(200).json({
|
|
25
|
+
success: true,
|
|
26
|
+
count: files.length,
|
|
27
|
+
files: files
|
|
28
|
+
});
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error('List files error:', error);
|
|
31
|
+
|
|
32
|
+
res.status(500).json({
|
|
33
|
+
error: 'Internal Server Error',
|
|
34
|
+
message: 'Failed to list files'
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return router;
|
|
40
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import multer from 'multer';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Upload router factory
|
|
6
|
+
* @param {AzureStorageClient} storage - Azure storage client instance
|
|
7
|
+
* @param {number} maxFileSize - Maximum file size in bytes (default: 100MB)
|
|
8
|
+
* @returns {express.Router} Express router for upload endpoint
|
|
9
|
+
*/
|
|
10
|
+
export function createUploadRouter(storage, maxFileSize = 100 * 1024 * 1024) {
|
|
11
|
+
const router = express.Router();
|
|
12
|
+
|
|
13
|
+
// Configure multer for memory storage (files stored in RAM before Azure upload)
|
|
14
|
+
const upload = multer({
|
|
15
|
+
storage: multer.memoryStorage(),
|
|
16
|
+
limits: {
|
|
17
|
+
fileSize: maxFileSize,
|
|
18
|
+
files: 1 // Single file upload
|
|
19
|
+
},
|
|
20
|
+
fileFilter: (req, file, cb) => {
|
|
21
|
+
// Optional: Add file type validation here
|
|
22
|
+
// For now, accept all file types
|
|
23
|
+
cb(null, true);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* POST /upload
|
|
29
|
+
* Upload file to Azure Blob Storage
|
|
30
|
+
* Requires: JWT authentication (applied by parent app)
|
|
31
|
+
* Body: multipart/form-data with 'file' field
|
|
32
|
+
*/
|
|
33
|
+
router.post('/upload', upload.single('file'), async (req, res) => {
|
|
34
|
+
try {
|
|
35
|
+
// Validate file was uploaded
|
|
36
|
+
if (!req.file) {
|
|
37
|
+
return res.status(400).json({
|
|
38
|
+
error: 'Bad Request',
|
|
39
|
+
message: 'No file uploaded'
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const userId = req.user.id;
|
|
44
|
+
const { buffer, originalname, mimetype } = req.file;
|
|
45
|
+
|
|
46
|
+
// Upload to Azure Blob Storage
|
|
47
|
+
const fileMetadata = await storage.uploadFile(userId, buffer, originalname, mimetype);
|
|
48
|
+
|
|
49
|
+
// Return success response
|
|
50
|
+
res.status(201).json({
|
|
51
|
+
success: true,
|
|
52
|
+
file: {
|
|
53
|
+
id: fileMetadata.fileId,
|
|
54
|
+
name: fileMetadata.originalFilename,
|
|
55
|
+
contentType: fileMetadata.contentType,
|
|
56
|
+
size: fileMetadata.size,
|
|
57
|
+
uploadedAt: fileMetadata.uploadedAt
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error('Upload error:', error);
|
|
62
|
+
|
|
63
|
+
if (error.code === 'LIMIT_FILE_SIZE') {
|
|
64
|
+
return res.status(413).json({
|
|
65
|
+
error: 'Payload Too Large',
|
|
66
|
+
message: `File size exceeds ${Math.round(maxFileSize / 1024 / 1024)}MB limit`
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
res.status(500).json({
|
|
71
|
+
error: 'Internal Server Error',
|
|
72
|
+
message: 'Failed to upload file'
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return router;
|
|
78
|
+
}
|