Files
puter/extensions/thumbnails/thumbnailBucketStore.js
Daniel Salazar 95cbbc5de6
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
Notify HeyPuter / notify (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test-backend (24.x) (push) Has been cancelled
test / API tests (node env, api-test) (24.x) (push) Has been cancelled
test / puterjs (node env, vitest) (24.x) (push) Has been cancelled
feat: enable singed uploads on oss and fix broken migration (#2765)
* fix: proper shutdown

* feat: enable singed uploads on oss

* fix: s3 in oss

* fix: thumbnails in oss
2026-04-03 07:29:22 -07:00

195 lines
6.3 KiB
JavaScript

const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { Context } = extension.import('core');
const /**@type {any}*/ svc_fs = extension.import('service:filesystem');
const {
NodeUIDSelector,
} = extension.import('core').fs.selectors;
const extensionBucketInfo = global_config.services?.thumbnails?.bucket;
const client = extensionBucketInfo?.endpoint && extensionBucketInfo?.credentials ? new S3Client({
region: 'auto',
endpoint: extensionBucketInfo.endpoint,
credentials: extensionBucketInfo.credentials,
}) : extension.import('data').s3ClientProvider.get();
const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
const thumbnailBucketName = extensionBucketInfo?.name || 'puter-local';
const extensionBucketEndpoint = extensionBucketInfo?.endpoint || 'http://127.0.0.1:4566/puter-local/';
// A not-user-input-safe base64 data url parser.
function base64ParseDataUrl (dataURL) {
dataURL = dataURL.slice(5);
const mimeType = dataURL.split(';')[0];
const data = Buffer.from(dataURL.split(',')[1], 'base64');
return { mimeType, data };
}
function estimateDataUrlSize (dataURL) {
const commaIndex = dataURL.indexOf(',');
const base64 = commaIndex === -1 ? dataURL : dataURL.slice(commaIndex + 1);
return Math.ceil(base64.length * 3 / 4);
}
extension.on('thumbnail.created', async (event) => {
const url = event.url;
if ( typeof url !== 'string' || !url.startsWith('data:') ) {
return;
}
if ( estimateDataUrlSize(url) > MAX_THUMBNAIL_BYTES ) {
event.url = null;
return;
}
const key = crypto.randomUUID();
// Inject in the s3 internal URL in place of the data URL before the operation goes to DB
event.url = `s3://${thumbnailBucketName}/${key}`;
// Parse base64 URL created from thumbnail service
const { mimeType, data } = base64ParseDataUrl(url);
// Upload thumbnail
const params = {
Bucket: thumbnailBucketName,
Key: key,
Body: data,
ContentType: mimeType,
};
await client.send(new PutObjectCommand(params));
});
extension.on('thumbnail.upload.prepare', async (event) => {
if ( !event || !Array.isArray(event.items) ) {
return;
}
for ( const item of event.items ) {
if ( !item || typeof item !== 'object' ) {
throw new Error('thumbnail.upload.prepare item is invalid');
}
const contentType = typeof item.contentType === 'string'
? item.contentType.trim()
: '';
if ( ! contentType ) {
continue;
}
if ( item.size !== undefined ) {
const size = Number(item.size);
if ( !Number.isFinite(size) || size < 0 ) {
continue;
}
if ( size > MAX_THUMBNAIL_BYTES ) {
continue;
}
}
const key = crypto.randomUUID();
const bucket = thumbnailBucketName;
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
});
const uploadUrl = await getSignedUrl(client, command, { expiresIn: 900 });
item.uploadUrl = uploadUrl;
item.thumbnailUrl = `s3://${bucket}/${key}`;
}
});
let in_progress_thumbs = {};
extension.on('thumbnail.read', async (/**@type {any}*/entry) => {
if ( entry.thumbnail && entry.thumbnail.startsWith('s3://') ) {
// Parse s3 URL
const [bucket, key] = entry.thumbnail.slice(5).split('/');
// Get signed url and inject it into the thumbnail read event
entry.thumbnail = await getSignedUrl(
client,
new GetObjectCommand({ Bucket: bucket, Key: key }),
{ expiresIn: 604800 },
);
} else if ( entry.thumbnail.startsWith('https') && entry.thumbnail.includes(new URL(extensionBucketEndpoint).hostname) ) {
// Remove after migration
let [bucket, key] = new URL(entry.thumbnail).pathname.slice(1).split('/');
// Get signed url and inject it into the thumbnail read event
entry.thumbnail = await getSignedUrl(
client,
new GetObjectCommand({ Bucket: bucket, Key: key }),
{ expiresIn: 604800 },
);
} else if ( entry.thumbnail.startsWith('data') && Context.get('req') && !in_progress_thumbs[entry.uuid] ) {
in_progress_thumbs[entry.uuid] = true;
const newNode = await svc_fs.node(new NodeUIDSelector(entry.uuid));
const key = crypto.randomUUID();
const { mimeType, data } = base64ParseDataUrl(entry.thumbnail);
const newUrl = `s3://${thumbnailBucketName}/${key}`;
// Upload thumbnail
const params = {
Bucket: thumbnailBucketName,
Key: key,
Body: data,
ContentType: mimeType,
};
(async () => {
await client.send(new PutObjectCommand(params));
await newNode.provider.update_thumbnail({
context: Context.get(),
node: newNode,
thumbnail: newUrl,
});
delete in_progress_thumbs[entry.uuid];
})();
}
});
extension.on('fs.remove.node', async ({ target }) => {
let thumbnailUrl;
if ( ! target.thumbnail ) {
// Stat the entry since we weren't given a thumbnail
const controls = {
log: target.log,
provide_selector: selector => {
target.selector = selector;
},
};
const newTarget = await target.provider.stat({
selector: target.selector,
options: { thumbnail: true },
node: target,
controls,
});
// There is REALLY just no thumbnail
if ( ! newTarget.thumbnail )
{
return;
}
thumbnailUrl = newTarget.thumbnail;
} else {
// We were immediately given a thumbnail
thumbnailUrl = target.thumbnail;
}
// Not an S3 thumbnail, likely older format like data URL
if ( !thumbnailUrl || !thumbnailUrl.startsWith('s3://') )
{
return;
}
const [bucket, key] = thumbnailUrl.slice(5).split('/');
// Delete thumbnail from S3
const params = {
Bucket: bucket,
Key: key,
};
await client.send(new DeleteObjectCommand(params));
});