@es-labs/jslib 0.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/CHANGELOG.md +4 -0
- package/README.md +42 -0
- package/__test__/services.test.js +32 -0
- package/auth/index.js +226 -0
- package/auth/keyv.js +23 -0
- package/auth/knex.js +29 -0
- package/auth/redis.js +23 -0
- package/comms/email.js +123 -0
- package/comms/nexmo.js +44 -0
- package/comms/telegram.js +43 -0
- package/comms/telegram2/inbound.js +314 -0
- package/comms/telegram2/outbound.js +574 -0
- package/comms/webpush.js +60 -0
- package/config.js +37 -0
- package/express/controller/auth/oauth.js +39 -0
- package/express/controller/auth/oidc.js +87 -0
- package/express/controller/auth/own.js +100 -0
- package/express/controller/auth/saml.js +74 -0
- package/express/upload.js +48 -0
- package/index.js +1 -0
- package/iso/README.md +4 -0
- package/iso/__tests__/csv-utils.spec.js +128 -0
- package/iso/__tests__/datetime.spec.js +101 -0
- package/iso/__tests__/fetch.spec.js +270 -0
- package/iso/csv-utils.js +206 -0
- package/iso/datetime.js +103 -0
- package/iso/fetch.js +129 -0
- package/iso/fetch2.js +180 -0
- package/iso/log-filter.js +17 -0
- package/iso/sleep.js +6 -0
- package/iso/ws.js +63 -0
- package/node/oss-files/oss-uploader-client-fetch.js +258 -0
- package/node/oss-files/oss-uploader-client-fetch.md +31 -0
- package/node/oss-files/oss-uploader-client.js +219 -0
- package/node/oss-files/oss-uploader-server.js +199 -0
- package/node/oss-files/oss-uploader-usage.js +121 -0
- package/node/oss-files/oss-uploader-usage.md +34 -0
- package/node/oss-files/s3-uploader-client.js +217 -0
- package/node/oss-files/s3-uploader-server.js +123 -0
- package/node/oss-files/s3-uploader-usage.js +77 -0
- package/node/oss-files/s3-uploader-usage.md +34 -0
- package/package.json +53 -0
- package/packageInfo.js +9 -0
- package/services/ali.js +279 -0
- package/services/aws.js +194 -0
- package/services/db/__tests__/keyv.spec.js +31 -0
- package/services/db/keyv.js +14 -0
- package/services/db/knex.js +67 -0
- package/services/db/redis.js +51 -0
- package/services/index.js +57 -0
- package/services/mq/README.md +8 -0
- package/services/websocket.js +139 -0
- package/t4t/README.md +1 -0
- package/traps.js +20 -0
- package/utils/__tests__/aes.spec.js +52 -0
- package/utils/aes.js +23 -0
- package/web/UI.md +71 -0
- package/web/bwc-autocomplete.js +211 -0
- package/web/bwc-combobox.js +343 -0
- package/web/bwc-fileupload.js +87 -0
- package/web/bwc-loading-overlay.js +54 -0
- package/web/bwc-t4t-form.js +511 -0
- package/web/bwc-table.js +756 -0
- package/web/fetch.js +129 -0
- package/web/i18n.js +24 -0
- package/web/idle.js +49 -0
- package/web/parse-jwt.js +15 -0
- package/web/pwa.js +84 -0
- package/web/sign-pad.js +164 -0
- package/web/t4t-fe.js +164 -0
- package/web/util.js +126 -0
- package/web/web-cam.js +182 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alibaba Cloud OSS Large File Uploader (Client-Side)
|
|
3
|
+
* Uses the S3-compatible API via pre-signed URLs generated by your backend.
|
|
4
|
+
*
|
|
5
|
+
* OSS S3-compatibility docs:
|
|
6
|
+
* https://www.alibabacloud.com/help/en/oss/developer-reference/compatibility-with-amazon-s3
|
|
7
|
+
*
|
|
8
|
+
* Key OSS-specific differences from AWS S3:
|
|
9
|
+
* - Endpoint format: https://<bucket>.<region>.aliyuncs.com (path-style not needed)
|
|
10
|
+
* - Minimum multipart part size: 100KB (S3 is 5MB) — we still use 10MB for reliability
|
|
11
|
+
* - Max parts: 10,000 (same as S3)
|
|
12
|
+
* - ETag is returned without quotes in some OSS responses — we normalize this below
|
|
13
|
+
* - CORS must expose the ETag header explicitly on the OSS bucket
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* const uploader = new OSSUploader({ signEndpoint: '/api/oss/sign' });
|
|
17
|
+
* const result = await uploader.upload(file, { onProgress: pct => console.log(pct + '%') });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB per part
|
|
21
|
+
const MULTIPART_THRESHOLD = 5 * 1024 * 1024; // Use multipart above 5MB
|
|
22
|
+
|
|
23
|
+
class OSSUploader {
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} options
|
|
26
|
+
* @param {string} options.signEndpoint - Your backend endpoint for signed URL generation
|
|
27
|
+
* @param {number} [options.chunkSize] - Bytes per part (default 10MB, min 100KB for OSS)
|
|
28
|
+
* @param {number} [options.maxConcurrent]- Parallel part uploads (default 3)
|
|
29
|
+
*/
|
|
30
|
+
constructor(options = {}) {
|
|
31
|
+
if (!options.signEndpoint) throw new Error('signEndpoint is required');
|
|
32
|
+
this.signEndpoint = options.signEndpoint;
|
|
33
|
+
this.chunkSize = options.chunkSize || CHUNK_SIZE;
|
|
34
|
+
this.maxConcurrent = options.maxConcurrent || 3;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Upload a File or Blob to Alibaba OSS.
|
|
41
|
+
*
|
|
42
|
+
* @param {File|Blob} file
|
|
43
|
+
* @param {object} [opts]
|
|
44
|
+
* @param {string} [opts.key] - OSS object key (defaults to file.name)
|
|
45
|
+
* @param {Function} [opts.onProgress] - Callback with integer 0–100
|
|
46
|
+
* @param {AbortSignal} [opts.signal] - AbortController signal to cancel
|
|
47
|
+
* @returns {Promise<{ key: string, location: string }>}
|
|
48
|
+
*/
|
|
49
|
+
async upload(file, opts = {}) {
|
|
50
|
+
const key = opts.key || file.name;
|
|
51
|
+
const onProgress = opts.onProgress || (() => {});
|
|
52
|
+
const signal = opts.signal || null;
|
|
53
|
+
|
|
54
|
+
if (file.size <= MULTIPART_THRESHOLD) {
|
|
55
|
+
return this._singleUpload(file, key, onProgress, signal);
|
|
56
|
+
}
|
|
57
|
+
return this._multipartUpload(file, key, onProgress, signal);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Single-Part Upload (≤5MB) ───────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
async _singleUpload(file, key, onProgress, signal) {
|
|
63
|
+
onProgress(0);
|
|
64
|
+
|
|
65
|
+
const { signedUrl, location } = await this._callBackend({
|
|
66
|
+
type: 'single',
|
|
67
|
+
key,
|
|
68
|
+
contentType: file.type || 'application/octet-stream',
|
|
69
|
+
size: file.size,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await this._putBlob(signedUrl, file, file.type, (loaded) => {
|
|
73
|
+
onProgress(Math.round((loaded / file.size) * 100));
|
|
74
|
+
}, signal);
|
|
75
|
+
|
|
76
|
+
onProgress(100);
|
|
77
|
+
return { key, location };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Multipart Upload (>5MB) ─────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
async _multipartUpload(file, key, onProgress, signal) {
|
|
83
|
+
// Step 1 — Initiate: backend calls CreateMultipartUpload, returns uploadId
|
|
84
|
+
const { uploadId } = await this._callBackend({
|
|
85
|
+
type: 'initiate',
|
|
86
|
+
key,
|
|
87
|
+
contentType: file.type || 'application/octet-stream',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const chunks = this._splitFile(file);
|
|
91
|
+
const partProgress = new Array(chunks.length).fill(0);
|
|
92
|
+
|
|
93
|
+
const reportProgress = () => {
|
|
94
|
+
const uploaded = partProgress.reduce((s, v) => s + v, 0);
|
|
95
|
+
onProgress(Math.round((uploaded / file.size) * 100));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Step 2 — Upload parts concurrently
|
|
99
|
+
const completedParts = [];
|
|
100
|
+
const queue = chunks.map((chunk, i) => ({ chunk, partNumber: i + 1, index: i }));
|
|
101
|
+
|
|
102
|
+
const worker = async () => {
|
|
103
|
+
while (queue.length > 0) {
|
|
104
|
+
const { chunk, partNumber, index } = queue.shift();
|
|
105
|
+
|
|
106
|
+
if (signal?.aborted) throw new DOMException('Upload aborted', 'AbortError');
|
|
107
|
+
|
|
108
|
+
// Get a signed URL for this specific part
|
|
109
|
+
const { signedUrl } = await this._callBackend({
|
|
110
|
+
type: 'part',
|
|
111
|
+
key,
|
|
112
|
+
uploadId,
|
|
113
|
+
partNumber,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// PUT the chunk; OSS returns ETag in response header
|
|
117
|
+
const rawETag = await this._putBlob(
|
|
118
|
+
signedUrl,
|
|
119
|
+
chunk,
|
|
120
|
+
file.type || 'application/octet-stream',
|
|
121
|
+
(loaded) => { partProgress[index] = loaded; reportProgress(); },
|
|
122
|
+
signal,
|
|
123
|
+
/* returnETag= */ true,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// OSS sometimes returns ETag without surrounding quotes — normalise
|
|
127
|
+
const etag = rawETag?.replace(/"/g, '') ? `"${rawETag.replace(/"/g, '')}"` : rawETag;
|
|
128
|
+
|
|
129
|
+
completedParts.push({ PartNumber: partNumber, ETag: etag });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const workers = Array.from({ length: this.maxConcurrent }, worker);
|
|
134
|
+
try {
|
|
135
|
+
await Promise.all(workers);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Best-effort abort to avoid orphaned multipart uploads costing storage
|
|
138
|
+
await this._callBackend({ type: 'abort', key, uploadId }).catch(() => {});
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Step 3 — Complete: parts must be sorted by PartNumber
|
|
143
|
+
completedParts.sort((a, b) => a.PartNumber - b.PartNumber);
|
|
144
|
+
|
|
145
|
+
const { location } = await this._callBackend({
|
|
146
|
+
type: 'complete',
|
|
147
|
+
key,
|
|
148
|
+
uploadId,
|
|
149
|
+
parts: completedParts,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
onProgress(100);
|
|
153
|
+
return { key, location };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
_splitFile(file) {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
for (let offset = 0; offset < file.size; offset += this.chunkSize) {
|
|
161
|
+
chunks.push(file.slice(offset, offset + this.chunkSize));
|
|
162
|
+
}
|
|
163
|
+
return chunks;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async _callBackend(payload) {
|
|
167
|
+
const res = await fetch(this.signEndpoint, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: JSON.stringify(payload),
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
const text = await res.text();
|
|
174
|
+
throw new Error(`Backend error (${res.status}): ${text}`);
|
|
175
|
+
}
|
|
176
|
+
return res.json();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* PUT a Blob to a pre-signed OSS URL via XHR (for upload progress events).
|
|
181
|
+
*
|
|
182
|
+
* OSS S3-compatible PUT requires:
|
|
183
|
+
* - Content-Type header must match the value used when signing
|
|
184
|
+
* - Do NOT send Content-MD5 unless you included it in the signed headers
|
|
185
|
+
*
|
|
186
|
+
* @returns {Promise<string|undefined>} ETag value when returnETag=true
|
|
187
|
+
*/
|
|
188
|
+
_putBlob(signedUrl, blob, contentType, onProgress, signal, returnETag = false) {
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
const xhr = new XMLHttpRequest();
|
|
191
|
+
xhr.open('PUT', signedUrl);
|
|
192
|
+
|
|
193
|
+
// Content-Type must match what was signed on the backend
|
|
194
|
+
if (contentType) xhr.setRequestHeader('Content-Type', contentType);
|
|
195
|
+
|
|
196
|
+
xhr.upload.addEventListener('progress', (e) => {
|
|
197
|
+
if (e.lengthComputable) onProgress(e.loaded);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
xhr.addEventListener('load', () => {
|
|
201
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
202
|
+
resolve(returnETag ? xhr.getResponseHeader('ETag') : undefined);
|
|
203
|
+
} else {
|
|
204
|
+
// OSS returns XML error bodies — surface them for easier debugging
|
|
205
|
+
reject(new Error(`OSS PUT failed (HTTP ${xhr.status}): ${xhr.responseText}`));
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
xhr.addEventListener('error', () => reject(new Error('Network error during OSS upload')));
|
|
210
|
+
xhr.addEventListener('abort', () => reject(new DOMException('Upload aborted', 'AbortError')));
|
|
211
|
+
|
|
212
|
+
if (signal) signal.addEventListener('abort', () => xhr.abort(), { once: true });
|
|
213
|
+
|
|
214
|
+
xhr.send(blob);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export default OSSUploader;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alibaba Cloud OSS — Signed URL Backend (Node.js / Express)
|
|
3
|
+
* Uses the AWS SDK v3 pointed at OSS's S3-compatible endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Install:
|
|
6
|
+
* npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner express
|
|
7
|
+
*
|
|
8
|
+
* Required env vars:
|
|
9
|
+
* OSS_REGION e.g. oss-ap-southeast-1
|
|
10
|
+
* OSS_BUCKET your bucket name
|
|
11
|
+
* OSS_ACCESS_KEY_ID Alibaba RAM user AccessKey ID
|
|
12
|
+
* OSS_ACCESS_KEY_SECRET
|
|
13
|
+
*
|
|
14
|
+
* OSS S3-compatible endpoint format:
|
|
15
|
+
* https://<region>.aliyuncs.com (global/internal)
|
|
16
|
+
* https://<region>.aliyuncs.com (use forcePathStyle: false — virtual-hosted default)
|
|
17
|
+
*
|
|
18
|
+
* Compatibility notes:
|
|
19
|
+
* - OSS supports Signature V4 (same as S3) via the S3-compatible API
|
|
20
|
+
* - Use forcePathStyle: false (virtual-hosted style) — OSS prefers bucket in hostname
|
|
21
|
+
* - OSS presigned URLs use x-oss-* headers, but the S3 SDK generates x-amz-* — this is
|
|
22
|
+
* fine because OSS accepts both via the S3-compat layer
|
|
23
|
+
* - ETag exposure in CORS must be configured on the OSS bucket (see notes at bottom)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import express from 'express';
|
|
27
|
+
import {
|
|
28
|
+
S3Client,
|
|
29
|
+
PutObjectCommand,
|
|
30
|
+
CreateMultipartUploadCommand,
|
|
31
|
+
UploadPartCommand,
|
|
32
|
+
CompleteMultipartUploadCommand,
|
|
33
|
+
AbortMultipartUploadCommand,
|
|
34
|
+
} from '@aws-sdk/client-s3';
|
|
35
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
36
|
+
|
|
37
|
+
const app = express();
|
|
38
|
+
app.use(express.json());
|
|
39
|
+
|
|
40
|
+
// ─── OSS Client via S3-compatible endpoint ────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const REGION = process.env.OSS_REGION; // e.g. 'oss-ap-southeast-1'
|
|
43
|
+
const BUCKET = process.env.OSS_BUCKET;
|
|
44
|
+
const URL_TTL = 3600; // signed URL valid for 1 hour
|
|
45
|
+
|
|
46
|
+
if (!REGION || !BUCKET) {
|
|
47
|
+
throw new Error('OSS_REGION and OSS_BUCKET env vars are required');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ossClient = new S3Client({
|
|
51
|
+
// OSS S3-compatible endpoint — region subdomain, no bucket prefix here
|
|
52
|
+
endpoint: `https://${REGION}.aliyuncs.com`,
|
|
53
|
+
|
|
54
|
+
region: REGION,
|
|
55
|
+
|
|
56
|
+
// Virtual-hosted style: bucket is placed in the hostname (recommended for OSS)
|
|
57
|
+
// e.g. https://my-bucket.oss-ap-southeast-1.aliyuncs.com/object-key
|
|
58
|
+
forcePathStyle: false,
|
|
59
|
+
|
|
60
|
+
credentials: {
|
|
61
|
+
accessKeyId: process.env.OSS_ACCESS_KEY_ID,
|
|
62
|
+
secretAccessKey: process.env.OSS_ACCESS_KEY_SECRET,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ─── Single endpoint handling all upload lifecycle phases ─────────────────────
|
|
67
|
+
// POST /api/oss/sign
|
|
68
|
+
// Body: { type, key, contentType?, size?, uploadId?, partNumber?, parts? }
|
|
69
|
+
|
|
70
|
+
app.post('/api/oss/sign', async (req, res) => {
|
|
71
|
+
const { type, key, contentType, size, uploadId, partNumber, parts } = req.body;
|
|
72
|
+
|
|
73
|
+
if (!key) return res.status(400).json({ error: 'key is required' });
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
switch (type) {
|
|
77
|
+
|
|
78
|
+
// ── 1. Single PUT (≤5MB) ───────────────────────────────────────────────
|
|
79
|
+
case 'single': {
|
|
80
|
+
const cmd = new PutObjectCommand({
|
|
81
|
+
Bucket: BUCKET,
|
|
82
|
+
Key: key,
|
|
83
|
+
ContentType: contentType || 'application/octet-stream',
|
|
84
|
+
ContentLength: size,
|
|
85
|
+
});
|
|
86
|
+
const signedUrl = await getSignedUrl(ossClient, cmd, { expiresIn: URL_TTL });
|
|
87
|
+
|
|
88
|
+
// OSS virtual-hosted URL for the completed object
|
|
89
|
+
const location = `https://${BUCKET}.${REGION}.aliyuncs.com/${key}`;
|
|
90
|
+
return res.json({ signedUrl, location });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── 2. Initiate multipart ──────────────────────────────────────────────
|
|
94
|
+
case 'initiate': {
|
|
95
|
+
const cmd = new CreateMultipartUploadCommand({
|
|
96
|
+
Bucket: BUCKET,
|
|
97
|
+
Key: key,
|
|
98
|
+
ContentType: contentType || 'application/octet-stream',
|
|
99
|
+
});
|
|
100
|
+
const response = await ossClient.send(cmd);
|
|
101
|
+
return res.json({ uploadId: response.UploadId });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── 3. Sign a part URL ─────────────────────────────────────────────────
|
|
105
|
+
case 'part': {
|
|
106
|
+
if (!uploadId || !partNumber) {
|
|
107
|
+
return res.status(400).json({ error: 'uploadId and partNumber are required' });
|
|
108
|
+
}
|
|
109
|
+
const cmd = new UploadPartCommand({
|
|
110
|
+
Bucket: BUCKET,
|
|
111
|
+
Key: key,
|
|
112
|
+
UploadId: uploadId,
|
|
113
|
+
PartNumber: Number(partNumber),
|
|
114
|
+
});
|
|
115
|
+
const signedUrl = await getSignedUrl(ossClient, cmd, { expiresIn: URL_TTL });
|
|
116
|
+
return res.json({ signedUrl });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── 4. Complete multipart ──────────────────────────────────────────────
|
|
120
|
+
case 'complete': {
|
|
121
|
+
if (!uploadId || !Array.isArray(parts) || parts.length === 0) {
|
|
122
|
+
return res.status(400).json({ error: 'uploadId and parts[] are required' });
|
|
123
|
+
}
|
|
124
|
+
const cmd = new CompleteMultipartUploadCommand({
|
|
125
|
+
Bucket: BUCKET,
|
|
126
|
+
Key: key,
|
|
127
|
+
UploadId: uploadId,
|
|
128
|
+
MultipartUpload: {
|
|
129
|
+
// OSS is strict: parts must be sorted by PartNumber
|
|
130
|
+
Parts: parts
|
|
131
|
+
.slice()
|
|
132
|
+
.sort((a, b) => a.PartNumber - b.PartNumber)
|
|
133
|
+
.map(({ PartNumber, ETag }) => ({ PartNumber, ETag })),
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
const response = await ossClient.send(cmd);
|
|
137
|
+
const location = response.Location ||
|
|
138
|
+
`https://${BUCKET}.${REGION}.aliyuncs.com/${key}`;
|
|
139
|
+
return res.json({ location });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── 5. Abort multipart (clean up on cancel/error) ──────────────────────
|
|
143
|
+
case 'abort': {
|
|
144
|
+
if (!uploadId) return res.status(400).json({ error: 'uploadId is required' });
|
|
145
|
+
const cmd = new AbortMultipartUploadCommand({
|
|
146
|
+
Bucket: BUCKET,
|
|
147
|
+
Key: key,
|
|
148
|
+
UploadId: uploadId,
|
|
149
|
+
});
|
|
150
|
+
await ossClient.send(cmd);
|
|
151
|
+
return res.json({ success: true });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
default:
|
|
155
|
+
return res.status(400).json({ error: `Unknown type: "${type}"` });
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error('[OSS sign error]', err);
|
|
159
|
+
res.status(500).json({ error: err.message });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
app.listen(3000, () => console.log('OSS sign server running on http://localhost:3000'));
|
|
164
|
+
export default app;
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* ─── OSS Bucket CORS Configuration (required) ────────────────────────────────
|
|
168
|
+
*
|
|
169
|
+
* In the Alibaba Cloud OSS console → Bucket → Data Security → CORS:
|
|
170
|
+
*
|
|
171
|
+
* Rule:
|
|
172
|
+
* Allowed Origins: https://your-frontend-domain.com (or * for dev)
|
|
173
|
+
* Allowed Methods: PUT
|
|
174
|
+
* Allowed Headers: *
|
|
175
|
+
* Exposed Headers: ETag ← CRITICAL for multipart complete
|
|
176
|
+
* Max Age (seconds): 3600
|
|
177
|
+
*
|
|
178
|
+
* Without exposing ETag, the browser cannot read it from the PUT response
|
|
179
|
+
* and multipart complete will fail.
|
|
180
|
+
*
|
|
181
|
+
* ─── OSS RAM Policy (minimum permissions for the signing user) ───────────────
|
|
182
|
+
*
|
|
183
|
+
* {
|
|
184
|
+
* "Statement": [{
|
|
185
|
+
* "Effect": "Allow",
|
|
186
|
+
* "Action": [
|
|
187
|
+
* "oss:PutObject",
|
|
188
|
+
* "oss:InitiateMultipartUpload",
|
|
189
|
+
* "oss:UploadPart",
|
|
190
|
+
* "oss:CompleteMultipartUpload",
|
|
191
|
+
* "oss:AbortMultipartUpload"
|
|
192
|
+
* ],
|
|
193
|
+
* "Resource": [
|
|
194
|
+
* "acs:oss:*:*:your-bucket-name",
|
|
195
|
+
* "acs:oss:*:*:your-bucket-name/*"
|
|
196
|
+
* ]
|
|
197
|
+
* }]
|
|
198
|
+
* }
|
|
199
|
+
*/
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alibaba OSS Uploader — Usage Examples
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// ─── 1. Vanilla JS ────────────────────────────────────────────────────────────
|
|
6
|
+
import OSSUploader from './oss-uploader-client.js';
|
|
7
|
+
|
|
8
|
+
const uploader = new OSSUploader({
|
|
9
|
+
signEndpoint: '/api/oss/sign',
|
|
10
|
+
chunkSize: 10 * 1024 * 1024, // 10MB
|
|
11
|
+
maxConcurrent: 3,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Basic upload
|
|
15
|
+
async function uploadFile(file) {
|
|
16
|
+
try {
|
|
17
|
+
const result = await uploader.upload(file, {
|
|
18
|
+
key: `uploads/${Date.now()}-${file.name}`,
|
|
19
|
+
onProgress: (pct) => {
|
|
20
|
+
console.log(`${pct}%`);
|
|
21
|
+
document.getElementById('progress').value = pct;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
console.log('Done:', result.location);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error('Upload failed:', err.message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// With cancel support
|
|
31
|
+
let controller = null;
|
|
32
|
+
|
|
33
|
+
async function uploadWithCancel(file) {
|
|
34
|
+
controller = new AbortController();
|
|
35
|
+
try {
|
|
36
|
+
const result = await uploader.upload(file, {
|
|
37
|
+
key: `uploads/${file.name}`,
|
|
38
|
+
onProgress: (pct) => console.log(`${pct}%`),
|
|
39
|
+
signal: controller.signal,
|
|
40
|
+
});
|
|
41
|
+
console.log('Uploaded to:', result.location);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.name === 'AbortError') {
|
|
44
|
+
console.log('Upload cancelled');
|
|
45
|
+
// The client already called /api/oss/sign with type=abort to clean up OSS
|
|
46
|
+
} else {
|
|
47
|
+
console.error(err);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
document.getElementById('fileInput').addEventListener('change', (e) => {
|
|
53
|
+
if (e.target.files[0]) uploadWithCancel(e.target.files[0]);
|
|
54
|
+
});
|
|
55
|
+
document.getElementById('cancelBtn').addEventListener('click', () => controller?.abort());
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
// ─── 2. React component ───────────────────────────────────────────────────────
|
|
59
|
+
/*
|
|
60
|
+
import { useState, useRef } from 'react';
|
|
61
|
+
import OSSUploader from './oss-uploader-client.js';
|
|
62
|
+
|
|
63
|
+
const uploader = new OSSUploader({ signEndpoint: '/api/oss/sign' });
|
|
64
|
+
|
|
65
|
+
export function OSSFileUpload() {
|
|
66
|
+
const [progress, setProgress] = useState(0);
|
|
67
|
+
const [status, setStatus] = useState('idle'); // idle | uploading | done | error
|
|
68
|
+
const [location, setLocation] = useState('');
|
|
69
|
+
const controllerRef = useRef(null);
|
|
70
|
+
|
|
71
|
+
const handleFileChange = async (e) => {
|
|
72
|
+
const file = e.target.files[0];
|
|
73
|
+
if (!file) return;
|
|
74
|
+
|
|
75
|
+
controllerRef.current = new AbortController();
|
|
76
|
+
setStatus('uploading');
|
|
77
|
+
setProgress(0);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const result = await uploader.upload(file, {
|
|
81
|
+
key: `uploads/${Date.now()}-${file.name}`,
|
|
82
|
+
onProgress: setProgress,
|
|
83
|
+
signal: controllerRef.current.signal,
|
|
84
|
+
});
|
|
85
|
+
setStatus('done');
|
|
86
|
+
setLocation(result.location);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err.name === 'AbortError') {
|
|
89
|
+
setStatus('idle');
|
|
90
|
+
} else {
|
|
91
|
+
setStatus('error');
|
|
92
|
+
console.error(err);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div>
|
|
99
|
+
<input type="file" onChange={handleFileChange} disabled={status === 'uploading'} />
|
|
100
|
+
{status === 'uploading' && (
|
|
101
|
+
<>
|
|
102
|
+
<progress value={progress} max={100} />
|
|
103
|
+
<span>{progress}%</span>
|
|
104
|
+
<button onClick={() => controllerRef.current?.abort()}>Cancel</button>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
107
|
+
{status === 'done' && <p>Uploaded: <a href={location}>{location}</a></p>}
|
|
108
|
+
{status === 'error' && <p style={{ color: 'red' }}>Upload failed</p>}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
Minimal HTML (vanilla):
|
|
117
|
+
|
|
118
|
+
<input type="file" id="fileInput" />
|
|
119
|
+
<progress id="progress" max="100" value="0"></progress>
|
|
120
|
+
<button id="cancelBtn">Cancel</button>
|
|
121
|
+
*/
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Here's a comprehensive JavaScript solution for reading and uploading large files to S3 using signed URLs, with multipart upload support for reliability:Here's a breakdown of what's in each file:
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
**`s3-uploader-client.js`** — Browser-side `S3Uploader` class
|
|
6
|
+
- Files **≤ 5MB** → single `PUT` with a signed URL
|
|
7
|
+
- Files **> 5MB** → S3 multipart upload (10MB chunks by default)
|
|
8
|
+
- Concurrent part uploads (default: 3 in parallel) for speed
|
|
9
|
+
- `XMLHttpRequest`-based for accurate `upload.progress` events
|
|
10
|
+
- `AbortController` support to cancel mid-upload
|
|
11
|
+
|
|
12
|
+
**`s3-uploader-server.js`** — Node.js/Express backend
|
|
13
|
+
- Single `/api/s3/sign` endpoint handles all 5 phases: `single`, `initiate`, `part`, `complete`, `abort`
|
|
14
|
+
- Uses the AWS SDK v3 (`@aws-sdk/client-s3` + `@aws-sdk/s3-request-presigner`)
|
|
15
|
+
- Your AWS credentials never touch the browser
|
|
16
|
+
|
|
17
|
+
**`s3-uploader-usage.js`** — Integration examples with cancel support
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
**Quick setup:**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Backend
|
|
25
|
+
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner express
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Set env vars: `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET_NAME`
|
|
29
|
+
|
|
30
|
+
**S3 CORS config** you'll also need on your bucket:
|
|
31
|
+
```json
|
|
32
|
+
[{ "AllowedOrigins": ["*"], "AllowedMethods": ["PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"] }]
|
|
33
|
+
```
|
|
34
|
+
The `ETag` exposure is critical — S3 multipart complete requires the ETags from each part.
|