Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

100 changes: 61 additions & 39 deletions src/storage/version/put.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ export async function putVersion(config, {
}
}

function shouldCreateVersion(contentType) {
// Only create versions for HTML and JSON files
if (!contentType) return false;
const type = contentType.toLowerCase();
return type.startsWith('text/html') || type.startsWith('application/json');
}

function buildInput({
bucket, org, key, body, type, contentLength,
}) {
Expand Down Expand Up @@ -88,6 +95,10 @@ export async function putObjectWithVersion(
return { status: 409, metadata: { id: ID } };
}

// Only create versions for HTML and JSON files
const contentType = update.type || current.contentType;
const createVersion = shouldCreateVersion(contentType);

const Version = current.metadata?.version || crypto.randomUUID();
const Users = JSON.stringify(getUsersForMetadata(daCtx.users));
const input = buildInput(update);
Expand Down Expand Up @@ -124,15 +135,20 @@ export async function putObjectWithVersion(
}

if (current.status === 404) {
const metadata = {
ID, Users, Timestamp, Path,
};
// Only include Version metadata for files that support versioning
if (createVersion) {
metadata.Version = Version;
}
// Use client conditional if provided, otherwise use internal If-None-Match: *
const client = effectiveConditionals?.ifNoneMatch
? ifNoneMatch(config, effectiveConditionals.ifNoneMatch)
: ifNoneMatch(config);
const command = new PutObjectCommand({
...input,
Metadata: {
ID, Version, Users, Timestamp, Path,
},
Metadata: metadata,
});
try {
const resp = await client.send(command);
Expand Down Expand Up @@ -164,46 +180,54 @@ export async function putObjectWithVersion(
}

const pps = current.metadata?.preparsingstore || '0';

// Store the body if preparsingstore is not defined, so a once-off store
let storeBody = !body && pps === '0';
const Preparsingstore = storeBody ? Timestamp : pps;
let Preparsingstore = storeBody ? Timestamp : pps;
let Label = storeBody ? 'Collab Parse' : update.label;

if (daCtx.method === 'PUT'
&& daCtx.ext === 'html'
&& current.contentLength > EMPTY_DOC_SIZE
&& (!update.body || update.body.size <= EMPTY_DOC_SIZE)) {
// we are about to empty the document body
// this should almost never happen but it does in some unexpectedcases
// we want then to store a version of the full document as a Restore Point
// eslint-disable-next-line no-console
console.warn(`Empty body, creating a restore point (${current.contentLength} / ${update.body?.size})`);
storeBody = true;
Label = 'Restore Point';
}
if (createVersion) {
if (daCtx.method === 'PUT'
&& daCtx.ext === 'html'
&& current.contentLength > EMPTY_DOC_SIZE
&& (!update.body || update.body.size <= EMPTY_DOC_SIZE)) {
// we are about to empty the document body
// this should almost never happen but it does in some unexpectedcases
// we want then to store a version of the full document as a Restore Point
// eslint-disable-next-line no-console
console.warn(`Empty body, creating a restore point (${current.contentLength} / ${update.body?.size})`);
storeBody = true;
Label = 'Restore Point';
Preparsingstore = Timestamp;
}

const versionResp = await putVersion(config, {
Bucket: input.Bucket,
Org: daCtx.org,
Body: (body || storeBody ? current.body : ''),
ContentLength: (body || storeBody ? current.contentLength : undefined),
ContentType: current.contentType,
ID,
Version,
Ext: daCtx.ext,
Metadata: {
Users: current.metadata?.users || JSON.stringify([{ email: 'anonymous' }]),
Timestamp: current.metadata?.timestamp || Timestamp,
Path: current.metadata?.path || Path,
Label,
},
});
const versionResp = await putVersion(config, {
Bucket: input.Bucket,
Org: daCtx.org,
Body: (body || storeBody ? current.body : ''),
ContentLength: (body || storeBody ? current.contentLength : undefined),
ContentType: current.contentType,
ID,
Version,
Ext: daCtx.ext,
Metadata: {
Users: current.metadata?.users || JSON.stringify([{ email: 'anonymous' }]),
Timestamp: current.metadata?.timestamp || Timestamp,
Path: current.metadata?.path || Path,
Label,
},
});

if (versionResp.status !== 200 && versionResp.status !== 412) {
return { status: versionResp.status, metadata: { id: ID } };
if (versionResp.status !== 200 && versionResp.status !== 412) {
return { status: versionResp.status, metadata: { id: ID } };
}
}

const metadata = {
ID, Users, Timestamp, Path, Preparsingstore,
};
// Only include Version metadata for files that support versioning
if (createVersion) {
metadata.Version = crypto.randomUUID();
}
// Use client-provided If-Match if available, otherwise use current ETag
// Special case: If client sent If-Match:*, we already validated existence above,
// so now use the actual ETag for proper version control
Expand All @@ -216,9 +240,7 @@ export async function putObjectWithVersion(
const client = ifMatch(config, matchValue);
const command = new PutObjectCommand({
...input,
Metadata: {
ID, Version: crypto.randomUUID(), Users, Timestamp, Path, Preparsingstore,
},
Metadata: metadata,
});
try {
const resp = await client.send(command);
Expand Down
108 changes: 108 additions & 0 deletions test/routes/source.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,4 +334,112 @@ describe('Source Route', () => {
assert.strictEqual('some data', putCalled[0].o.data);
assert.strictEqual(202, resp.status);
});

it('Test postSource with binary image file via FormData', async () => {
const putCalled = [];
const putCall = (e, c, o) => {
putCalled.push({ e, c, o });
return { status: 201, metadata: { id: 'image-guid-123' } };
};

const ctx = { key: 'images/photo.jpg', ext: 'jpg', isFile: true };
const hasPermission = () => true;

const { postSource } = await esmock('../../src/routes/source.js', {
'../../src/storage/object/put.js': {
default: putCall,
},
'../../src/utils/auth.js': {
hasPermission,
},
});

// Create a binary image file (JPEG signature)
const jpegData = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46]);
const imageFile = new File([jpegData], 'photo.jpg', { type: 'image/jpeg' });

const body = new FormData();
body.append('data', imageFile);
body.append('guid', 'image-guid-123');

const opts = { body, method: 'POST' };
const req = new Request('https://da.live/source/org/repo/images/photo.jpg', opts);

const resp = await postSource({ req, env: {}, daCtx: ctx });
assert.strictEqual(1, putCalled.length);
assert.strictEqual('image-guid-123', putCalled[0].o.guid);
assert(putCalled[0].o.data instanceof File);
assert.strictEqual('image/jpeg', putCalled[0].o.data.type);
assert.strictEqual(201, resp.status);
});

it('Test postSource with PNG image via FormData', async () => {
const putCalled = [];
const putCall = (e, c, o) => {
putCalled.push({ e, c, o });
return { status: 201, metadata: { id: 'png-guid-456' } };
};

const ctx = { key: 'images/graphic.png', ext: 'png', isFile: true };
const hasPermission = () => true;

const { postSource } = await esmock('../../src/routes/source.js', {
'../../src/storage/object/put.js': {
default: putCall,
},
'../../src/utils/auth.js': {
hasPermission,
},
});

const pngData = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
const pngFile = new File([pngData], 'graphic.png', { type: 'image/png' });

const body = new FormData();
body.append('data', pngFile);

const opts = { body, method: 'POST' };
const req = new Request('https://da.live/source/org/repo/images/graphic.png', opts);

const resp = await postSource({ req, env: {}, daCtx: ctx });
assert.strictEqual(1, putCalled.length);
assert(putCalled[0].o.data instanceof File);
assert.strictEqual('image/png', putCalled[0].o.data.type);
assert.strictEqual(201, resp.status);
});

it('Test postSource with PDF via FormData', async () => {
const putCalled = [];
const putCall = (e, c, o) => {
putCalled.push({ e, c, o });
return { status: 201, metadata: { id: 'pdf-guid-abc' } };
};

const ctx = { key: 'docs/report.pdf', ext: 'pdf', isFile: true };
const hasPermission = () => true;

const { postSource } = await esmock('../../src/routes/source.js', {
'../../src/storage/object/put.js': {
default: putCall,
},
'../../src/utils/auth.js': {
hasPermission,
},
});

const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]);
const pdfFile = new File([pdfData], 'report.pdf', { type: 'application/pdf' });

const body = new FormData();
body.append('data', pdfFile);

const opts = { body, method: 'POST' };
const req = new Request('https://da.live/source/org/repo/docs/report.pdf', opts);

const resp = await postSource({ req, env: {}, daCtx: ctx });
assert.strictEqual(1, putCalled.length);
assert(putCalled[0].o.data instanceof File);
assert.strictEqual('application/pdf', putCalled[0].o.data.type);
assert.strictEqual(201, resp.status);
});
});
Loading