openvibers/openvibe.media Commits
Multipart complete is idempotent: completing a session that already completed (a retry after its answer was lost) returns the ready object instead of 409, as the single-part complete does; the SDK objects client (openvibe-sdk 0.5.0) retries it. Tenant CORS allows the SDK's traceparent and X-OpenVibe-Request-Id headers next to X-Upload-Token, X-Content-SHA256 and Idempotency-Key, so a browser can send parts to presigned URLs
Changes
@@@ -203,7 +203,8 @@ client asks with `multipart: true`. Code: `server/objects/multipart.js`.`parts` list with sha256 values must match what was received. The parts are concatenated into`OBJECTS_PATH/<app>/<id>` while hashing, the parts are deleted, and the checks of `/complete`follow (expected hash, invariant, quota, content). If one of those fails, the object stays-- `uploading` with its content and can be sent again.++ `uploading` with its content and can be sent again. Completing a completed session again (a++ retry after a lost answer) returns the object.5. **Abort** (`DELETE`) deletes the parts; the object stays `uploading`. A single-part PUT is refusedwhile a session is open (409 `media.upload.multipart_active`).@@@ -342,7 +342,7 @@ function tenantCors(req, res, next) {if (origin && req.appRow && db.appAllowedOrigins(req.appRow).includes(origin)) {res.set('Access-Control-Allow-Origin', origin);res.set('Vary', 'Origin');-- res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key');++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key, traceparent, X-OpenVibe-Request-Id');res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');}next();@@@ -79,7 +79,7 @@ app.options(['/api/v1/:app/*', '/api/v2/:app/*'], (req, res) => {if (origin && appRow && db.appAllowedOrigins(appRow).includes(origin)) {res.set('Access-Control-Allow-Origin', origin);res.set('Vary', 'Origin');-- res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key');++ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key, traceparent, X-OpenVibe-Request-Id');res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');}res.sendStatus(204);@@@ -449,6 +449,12 @@ router.get('/:id/multipart/:uploadId', mpRead, (req, res) => {router.post('/:id/multipart/:uploadId/complete', mpWrite, tenantCors, async (req, res) => {let session = null;try {++ // A repeat of a complete that already succeeded (its answer was lost on the way): the object as it is.++ const already = model.resolveObject(String(req.params.id || ''), req.appId);++ const prior = already && multipart.getSession(String(req.params.uploadId || ''));++ if (prior && prior.object_id === already.id && prior.status === 'completed' && already.lifecycle_status === 'ready' && canSee(req, already)) {++ return res.json(model.objectPublic(already));++ }const obj = loadUploading(req, res);if (!obj) return;session = loadSession(req, res, obj);@@@ -190,7 +190,9 @@ const mac = (parts) => crypto.createHmac('sha256', 'test-signing-secret').updater = await call('GET', local(r.body.url), { bearer: null });assert.deepStrictEqual([r.status, sha(Buffer.from(await (await fetch(local((await call('GET', `${O}/${mp.id}/download`)).body.url))).arrayBuffer()))], [200, sha(big)]);r = await call('POST', local(up.complete_url), { bearer: null });-- assert.strictEqual(r.status, 409, 'completing twice');++ assert.deepStrictEqual([r.status, r.body.id, r.body.lifecycle_status], [200, mp.id, 'ready'], 'completing again (a lost answer) returns the object');++ r = await call('POST', `${O}/${mp.id}/complete`);++ assert.strictEqual(r.status, 200, 'and so does the single-part complete');console.log('✅ multipart: exact parts, sha256 per part, resumable after a dropped connection, assembled and verified');// Declare the size later; abort; a session for another object does not open this one; expiry.
@@ -203,7 +203,8 @@ client asks with `multipart: true`. Code: `server/objects/multipart.js`.
@@ -203,7 +203,8 @@ client asks with `multipart: true`. Code: `server/objects/multipart.js`.
`parts` list with sha256 values must match what was received. The parts are concatenated into
`parts` list with sha256 values must match what was received. The parts are concatenated into
`OBJECTS_PATH/<app>/<id>` while hashing, the parts are deleted, and the checks of `/complete`
`OBJECTS_PATH/<app>/<id>` while hashing, the parts are deleted, and the checks of `/complete`
follow (expected hash, invariant, quota, content). If one of those fails, the object stays
follow (expected hash, invariant, quota, content). If one of those fails, the object stays
- `uploading` with its content and can be sent again.
+ `uploading` with its content and can be sent again. Completing a completed session again (a
+ retry after a lost answer) returns the object.
5. **Abort** (`DELETE`) deletes the parts; the object stays `uploading`. A single-part PUT is refused
5. **Abort** (`DELETE`) deletes the parts; the object stays `uploading`. A single-part PUT is refused
while a session is open (409 `media.upload.multipart_active`).
while a session is open (409 `media.upload.multipart_active`).
@@ -342,7 +342,7 @@ function tenantCors(req, res, next) {
@@ -342,7 +342,7 @@ function tenantCors(req, res, next) {
if (origin && req.appRow && db.appAllowedOrigins(req.appRow).includes(origin)) {
if (origin && req.appRow && db.appAllowedOrigins(req.appRow).includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
res.set('Vary', 'Origin');
- res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key');
+ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key, traceparent, X-OpenVibe-Request-Id');
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
}
}
next();
next();
@@ -79,7 +79,7 @@ app.options(['/api/v1/:app/*', '/api/v2/:app/*'], (req, res) => {
@@ -79,7 +79,7 @@ app.options(['/api/v1/:app/*', '/api/v2/:app/*'], (req, res) => {
if (origin && appRow && db.appAllowedOrigins(appRow).includes(origin)) {
if (origin && appRow && db.appAllowedOrigins(appRow).includes(origin)) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
res.set('Vary', 'Origin');
- res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key');
+ res.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-Upload-Token, X-Content-SHA256, Idempotency-Key, traceparent, X-OpenVibe-Request-Id');
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
}
}
res.sendStatus(204);
res.sendStatus(204);
@@ -449,6 +449,12 @@ router.get('/:id/multipart/:uploadId', mpRead, (req, res) => {
@@ -449,6 +449,12 @@ router.get('/:id/multipart/:uploadId', mpRead, (req, res) => {
router.post('/:id/multipart/:uploadId/complete', mpWrite, tenantCors, async (req, res) => {
router.post('/:id/multipart/:uploadId/complete', mpWrite, tenantCors, async (req, res) => {
let session = null;
let session = null;
try {
try {
+ // A repeat of a complete that already succeeded (its answer was lost on the way): the object as it is.
+ const already = model.resolveObject(String(req.params.id || ''), req.appId);
+ const prior = already && multipart.getSession(String(req.params.uploadId || ''));
+ if (prior && prior.object_id === already.id && prior.status === 'completed' && already.lifecycle_status === 'ready' && canSee(req, already)) {
+ return res.json(model.objectPublic(already));
+ }
const obj = loadUploading(req, res);
const obj = loadUploading(req, res);
if (!obj) return;
if (!obj) return;
session = loadSession(req, res, obj);
session = loadSession(req, res, obj);
@@ -190,7 +190,9 @@ const mac = (parts) => crypto.createHmac('sha256', 'test-signing-secret').update
@@ -190,7 +190,9 @@ const mac = (parts) => crypto.createHmac('sha256', 'test-signing-secret').update
r = await call('GET', local(r.body.url), { bearer: null });
r = await call('GET', local(r.body.url), { bearer: null });
assert.deepStrictEqual([r.status, sha(Buffer.from(await (await fetch(local((await call('GET', `${O}/${mp.id}/download`)).body.url))).arrayBuffer()))], [200, sha(big)]);
assert.deepStrictEqual([r.status, sha(Buffer.from(await (await fetch(local((await call('GET', `${O}/${mp.id}/download`)).body.url))).arrayBuffer()))], [200, sha(big)]);
r = await call('POST', local(up.complete_url), { bearer: null });
r = await call('POST', local(up.complete_url), { bearer: null });
- assert.strictEqual(r.status, 409, 'completing twice');
+ assert.deepStrictEqual([r.status, r.body.id, r.body.lifecycle_status], [200, mp.id, 'ready'], 'completing again (a lost answer) returns the object');
+ r = await call('POST', `${O}/${mp.id}/complete`);
+ assert.strictEqual(r.status, 200, 'and so does the single-part complete');
console.log('✅ multipart: exact parts, sha256 per part, resumable after a dropped connection, assembled and verified');
console.log('✅ multipart: exact parts, sha256 per part, resumable after a dropped connection, assembled and verified');
// Declare the size later; abort; a session for another object does not open this one; expiry.
// Declare the size later; abort; a session for another object does not open this one; expiry.