mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-03 16:10:31 +00:00
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
* fix: proper shutdown * feat: enable singed uploads on oss * fix: s3 in oss * fix: thumbnails in oss
195 lines
6.3 KiB
JavaScript
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));
|
|
});
|