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
2 changes: 1 addition & 1 deletion src/routes/source.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,5 @@ export async function postSource({ req, env, daCtx }) {

export async function getSource({ env, daCtx, head }) {
if (!hasPermission(daCtx, daCtx.key, 'read')) return { status: 403 };
return getObject(env, daCtx, head);
return getObject(env, daCtx, head, daCtx.conditionalHeaders);
}
4 changes: 2 additions & 2 deletions src/routes/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ export async function getVersionList({ env, daCtx }) {

export async function getVersionSource({ env, daCtx, head }) {
// daCtx.key is something like
// 'f85f9b05-ae48-485b-a3b3-dd203ac5c734/1b7e005b-8602-4053-b920-8e67ad8e8dba.html
// 'f85f9b05-ae48-485b-a3b3-dd203ac5c734/1b7e005b-8602-4053-b920-8e67ad8e8dba.html'
// so we have to do the permission check when the object is returned from the metadata.

const resp = await getObjectVersion(env, daCtx, head);
const resp = await getObjectVersion(env, daCtx, head, daCtx.conditionalHeaders);
if (!hasPermission(daCtx, resp.metadata?.path, 'read')) return { status: 403 };
return resp;
}
Expand Down
106 changes: 85 additions & 21 deletions src/storage/object/get.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,39 @@ import {

import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import getS3Config from '../utils/config.js';
import { ifMatch, ifNoneMatch } from '../utils/version.js';

function buildInput({ bucket, org, key }) {
const Bucket = bucket;
return { Bucket, Key: `${org}/${key}` };
}

export default async function getObject(env, { bucket, org, key }, head = false) {
export default async function getObject(
env,
{ bucket, org, key },
head = false,
conditionalHeaders = null,
) {
const config = getS3Config(env);
const client = new S3Client(config);

// Validate conflicting conditionals - per RFC 7232, If-None-Match takes precedence for GET/HEAD
if (conditionalHeaders?.ifMatch && conditionalHeaders?.ifNoneMatch) {
// Both headers present - use If-None-Match for GET/HEAD per RFC 7232 Section 3.2
// eslint-disable-next-line no-console
console.warn('Both If-Match and If-None-Match provided, using If-None-Match per RFC 7232');
}

const input = buildInput({ bucket, org, key });
if (!head) {
// Apply conditional headers middleware for GET requests
let client;
if (conditionalHeaders?.ifNoneMatch) {
client = ifNoneMatch(config, conditionalHeaders.ifNoneMatch);
} else if (conditionalHeaders?.ifMatch) {
client = ifMatch(config, conditionalHeaders.ifMatch);
} else {
client = new S3Client(config);
}
try {
const resp = await client.send(new GetObjectCommand(input));

Expand Down Expand Up @@ -57,26 +78,69 @@ export default async function getObject(env, { bucket, org, key }, head = false)
// eslint-disable-next-line no-console
console.error('Error getting object without httpStatusCode', e);
}
return { body: '', status: e.$metadata?.httpStatusCode || 500, contentLength: 0 };
// Handle conditional request failures (304 Not Modified, 412 Precondition Failed)
const status = e.$metadata?.httpStatusCode || 500;
if (status === 304 || status === 412) {
// Include ETag in 304/412 responses per RFC 7232
return {
body: '',
status,
contentLength: 0,
etag: e.ETag || conditionalHeaders?.ifNoneMatch,
};
}
return { body: '', status, contentLength: 0 };
}
}
const url = await getSignedUrl(client, new HeadObjectCommand(input), { expiresIn: 3600 });
const resp = await fetch(url, { method: 'HEAD' });
const Metadata = {};
resp.headers.forEach((value, key2) => {
if (key2.startsWith('x-amz-meta-')) {
Metadata[key2.substring('x-amz-meta-'.length)] = value;
// HEAD request path - uses presigned URL with fetch
const client = new S3Client(config);
try {
const url = await getSignedUrl(client, new HeadObjectCommand(input), { expiresIn: 3600 });
const fetchHeaders = {};

// Add conditional headers to fetch request
if (conditionalHeaders?.ifNoneMatch) {
fetchHeaders['If-None-Match'] = conditionalHeaders.ifNoneMatch;
} else if (conditionalHeaders?.ifMatch) {
fetchHeaders['If-Match'] = conditionalHeaders.ifMatch;
}

const resp = await fetch(url, { method: 'HEAD', headers: fetchHeaders });

// Handle conditional request failures
if (resp.status === 304 || resp.status === 412) {
return {
body: '',
status: resp.status,
contentLength: 0,
etag: resp.headers.get('etag'),
};
}
});
return {
body: '',
status: resp.status,
contentType: resp.headers.get('content-type'),
contentLength: resp.headers.get('content-length'),
metadata: {
...Metadata,
LastModified: resp.headers.get('last-modified'),
},
etag: resp.headers.get('etag'),
};

const Metadata = {};
resp.headers.forEach((value, key2) => {
if (key2.startsWith('x-amz-meta-')) {
Metadata[key2.substring('x-amz-meta-'.length)] = value;
}
});
return {
body: '',
status: resp.status,
contentType: resp.headers.get('content-type'),
contentLength: resp.headers.get('content-length'),
metadata: {
...Metadata,
LastModified: resp.headers.get('last-modified'),
},
etag: resp.headers.get('etag'),
};
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error in HEAD request', e);
const status = e.$metadata?.httpStatusCode || 500;
if (status === 304 || status === 412) {
return { body: '', status, contentLength: 0 };
}
return { body: '', status, contentLength: 0 };
}
}
8 changes: 5 additions & 3 deletions src/storage/object/put.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,24 @@ export default async function putObject(env, daCtx, obj) {
const client = new S3Client(config);

const {
bucket, org, key, propsKey,
bucket, org, key, propsKey, conditionalHeaders,
} = daCtx;

const inputs = [];

let metadata = {};
let status = 201;
let etag;
if (obj) {
if (obj.data) {
const isFile = obj.data instanceof File;
const { body, type } = isFile ? await getFileBody(obj.data) : getObjectBody(obj.data);
const res = await putObjectWithVersion(env, daCtx, {
bucket, org, key, body, type,
}, false, obj.guid);
}, false, obj.guid, conditionalHeaders);
status = res.status;
metadata = res.metadata;
etag = res.etag;
}
} else {
const { body, type } = getObjectBody({});
Expand All @@ -74,6 +76,6 @@ export default async function putObject(env, daCtx, obj) {

const body = sourceRespObject(daCtx);
return {
body: JSON.stringify(body), status, contentType: 'application/json', metadata,
body: JSON.stringify(body), status, contentType: 'application/json', metadata, etag,
};
}
4 changes: 2 additions & 2 deletions src/storage/utils/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ import {
S3Client,
} from '@aws-sdk/client-s3';

export function ifNoneMatch(config) {
export function ifNoneMatch(config, value = '*') {
const client = new S3Client(config);
client.middlewareStack.add(
(next) => async (args) => {
// eslint-disable-next-line no-param-reassign
args.request.headers['If-None-Match'] = '*';
args.request.headers['If-None-Match'] = value;
return next(args);
},
{
Expand Down
4 changes: 2 additions & 2 deletions src/storage/version/get.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
*/
import getObject from '../object/get.js';

export async function getObjectVersion(env, { bucket, org, key }, head) {
return getObject(env, { bucket, org, key: `.da-versions/${key}` }, head);
export async function getObjectVersion(env, { bucket, org, key }, head, conditionalHeaders) {
return getObject(env, { bucket, org, key: `.da-versions/${key}` }, head, conditionalHeaders);
}
92 changes: 84 additions & 8 deletions src/storage/version/put.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ function buildInput({
};
}

export async function putObjectWithVersion(env, daCtx, update, body, guid) {
export async function putObjectWithVersion(
env,
daCtx,
update,
body,
guid,
clientConditionals = null,
) {
const config = getS3Config(env);
// While we are automatically storing the body once for the 'Collab Parse' changes, we never
// do a HEAD, because we may need the content. Once we don't need to do this automatic store
Expand All @@ -87,8 +94,40 @@ export async function putObjectWithVersion(env, daCtx, update, body, guid) {
const Timestamp = `${Date.now()}`;
const Path = update.key;

// Validate conflicting conditionals - both headers present is unusual for PUT
let effectiveConditionals = clientConditionals;
if (clientConditionals?.ifMatch && clientConditionals?.ifNoneMatch) {
// Per RFC 7232, If-Match should be evaluated first for PUT/POST
// If-None-Match for PUT is less common (create-only semantics)
// eslint-disable-next-line no-console
console.warn('Both If-Match and If-None-Match provided, prioritizing If-Match per RFC 7232');
// Clear If-None-Match to prevent confusion
effectiveConditionals = { ifMatch: clientConditionals.ifMatch };
}

// Handle client-provided If-Match: * (requires resource to exist)
if (effectiveConditionals?.ifMatch === '*') {
if (current.status === 404) {
return { status: 412, metadata: { id: ID } };
}
// Resource exists, proceed with update using actual ETag
// Fall through to update logic below with current.etag
}

// Handle client-provided If-None-Match: * (requires resource NOT to exist)
if (effectiveConditionals?.ifNoneMatch === '*') {
if (current.status !== 404) {
return { status: 412, metadata: { id: ID } };
}
// Resource doesn't exist, proceed with create
// Fall through to create logic below
}

if (current.status === 404) {
const client = ifNoneMatch(config);
// 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: {
Expand All @@ -98,12 +137,24 @@ export async function putObjectWithVersion(env, daCtx, update, body, guid) {
try {
const resp = await client.send(command);
return resp.$metadata.httpStatusCode === 200
? { status: 201, metadata: { id: ID } }
: { status: resp.$metadata.httpStatusCode, metadata: { id: ID } };
? { status: 201, metadata: { id: ID }, etag: resp.ETag }
: { status: resp.$metadata.httpStatusCode, metadata: { id: ID }, etag: resp.ETag };
} catch (e) {
const status = e.$metadata?.httpStatusCode || 500;
if (status === 412) {
return putObjectWithVersion(env, daCtx, update, body);
// Only retry if no client conditionals (internal operation) and under retry limit
if (!effectiveConditionals?.ifNoneMatch) {
return putObjectWithVersion(
env,
daCtx,
update,
body,
guid,
clientConditionals,
);
}
// Client conditional failed or max retries exceeded, return 412
return { status: 412, metadata: { id: ID } };
}

// eslint-disable-next-line no-console
Expand Down Expand Up @@ -153,7 +204,16 @@ export async function putObjectWithVersion(env, daCtx, update, body, guid) {
return { status: versionResp.status, metadata: { id: ID } };
}

const client = ifMatch(config, `${current.etag}`);
// 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
let matchValue;
if (effectiveConditionals?.ifMatch === '*') {
matchValue = `${current.etag}`;
} else {
matchValue = effectiveConditionals?.ifMatch || `${current.etag}`;
}
const client = ifMatch(config, matchValue);
const command = new PutObjectCommand({
...input,
Metadata: {
Expand All @@ -163,11 +223,27 @@ export async function putObjectWithVersion(env, daCtx, update, body, guid) {
try {
const resp = await client.send(command);

return { status: resp.$metadata.httpStatusCode, metadata: { id: ID } };
return {
status: resp.$metadata.httpStatusCode,
metadata: { id: ID },
etag: resp.ETag,
};
} catch (e) {
const status = e.$metadata?.httpStatusCode || 500;
if (status === 412) {
return putObjectWithVersion(env, daCtx, update, body);
// Only retry if no client conditionals (internal operation) and under retry limit
if (!effectiveConditionals?.ifMatch) {
return putObjectWithVersion(
env,
daCtx,
update,
body,
guid,
clientConditionals,
);
}
// Client conditional failed or max retries exceeded, return 412
return { status: 412, metadata: { id: ID } };
}

// eslint-disable-next-line no-console
Expand Down
8 changes: 8 additions & 0 deletions src/utils/daCtx.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export default async function getDaCtx(req, env) {
const [org, ...parts] = split;
const bucket = env.AEM_BUCKET_NAME;

// Extract conditional headers
const ifMatch = req.headers?.get('if-match') || null;
const ifNoneMatch = req.headers?.get('if-none-match') || null;

// Set base details
const daCtx = {
path: pathname,
Expand All @@ -45,6 +49,10 @@ export default async function getDaCtx(req, env) {
fullKey,
origin: new URL(req.url).origin,
method: req.method,
conditionalHeaders: {
ifMatch,
ifNoneMatch,
},
};

// Sanitize the remaining path parts
Expand Down
Loading