- storage.js: из экспорта убраны неиспользуемые publicPath, localExists и dir - server.js: убран неиспользуемый импорт mimeFor и параметр originalsDir в воркере - worker.js: убран неиспользуемый параметр originalsDir - scripts/deploy.sh: up -d --force-recreate app, чтобы пересобранный образ гарантированно применялся (compose не всегда пересоздаёт контейнер при неизменном конфиге сервиса)
544 lines
16 KiB
JavaScript
544 lines
16 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { pipeline } = require('stream/promises');
|
|
const {
|
|
S3Client,
|
|
PutObjectCommand,
|
|
GetObjectCommand,
|
|
HeadObjectCommand,
|
|
DeleteObjectCommand,
|
|
CopyObjectCommand,
|
|
ListObjectsV2Command,
|
|
HeadBucketCommand,
|
|
CreateBucketCommand,
|
|
} = require('@aws-sdk/client-s3');
|
|
|
|
const MIME_TYPES = {
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.jfif': 'image/jpeg',
|
|
'.png': 'image/png',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
'.bmp': 'image/bmp',
|
|
'.avif': 'image/avif',
|
|
'.ico': 'image/x-icon',
|
|
'.heic': 'image/heic',
|
|
'.heif': 'image/heif',
|
|
'.pdf': 'application/pdf',
|
|
'.txt': 'text/plain; charset=utf-8',
|
|
'.md': 'text/markdown; charset=utf-8',
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.htm': 'text/html; charset=utf-8',
|
|
'.zip': 'application/zip',
|
|
'.rar': 'application/vnd.rar',
|
|
'.7z': 'application/x-7z-compressed',
|
|
'.doc': 'application/msword',
|
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
};
|
|
|
|
const SAFE_SEGMENT = /^[\w,.()-]+$/;
|
|
const NON_OBJECT_PREFIXES = ['.thumbs/', '.cache/'];
|
|
|
|
function mimeFor(name) {
|
|
const m = String(name || '').toLowerCase().match(/\.[a-z0-9]+$/);
|
|
return (m && MIME_TYPES[m[0]]) || 'application/octet-stream';
|
|
}
|
|
|
|
function normalizeKey(input) {
|
|
if (typeof input !== 'string' || !input) return null;
|
|
let key = input.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
if (key.startsWith('uploads/')) key = key.slice('uploads/'.length);
|
|
if (!key || key.length > 255 || key.includes('..')) return null;
|
|
const segments = key.split('/');
|
|
if (segments.some(seg => !SAFE_SEGMENT.test(seg))) return null;
|
|
return key;
|
|
}
|
|
|
|
function isObjectKey(key) {
|
|
return !NON_OBJECT_PREFIXES.some(p => key.startsWith(p));
|
|
}
|
|
|
|
function createStorage(options = {}) {
|
|
const driver = options.driver || process.env.STORAGE_DRIVER || 'local';
|
|
const dir = path.resolve(options.dir || path.join(__dirname, 'uploads'));
|
|
const cacheDir = path.resolve(options.cacheDir || path.join(dir, '.cache'));
|
|
const localFallback = options.localFallback !== undefined
|
|
? !!options.localFallback
|
|
: process.env.STORAGE_LOCAL_FALLBACK !== '0';
|
|
const keepLocal = options.keepLocal !== undefined
|
|
? !!options.keepLocal
|
|
: process.env.STORAGE_KEEP_LOCAL === '1';
|
|
const bucket = options.bucket || process.env.S3_BUCKET || '';
|
|
const prefix = String(options.prefix !== undefined ? options.prefix : process.env.S3_PREFIX || '').replace(/^\/+|\/+$/g, '');
|
|
const cacheMaxAgeMs = Math.max(0, parseInt(options.cacheMaxAgeHours || process.env.STORAGE_CACHE_MAX_AGE_HOURS || '168', 10) || 0) * 3600 * 1000;
|
|
const remote = driver === 's3';
|
|
const client = remote
|
|
? new S3Client({
|
|
region: options.region || process.env.S3_REGION || 'us-east-1',
|
|
endpoint: options.endpoint || process.env.S3_ENDPOINT || undefined,
|
|
forcePathStyle: options.forcePathStyle !== undefined
|
|
? !!options.forcePathStyle
|
|
: process.env.S3_FORCE_PATH_STYLE !== '0',
|
|
credentials: {
|
|
accessKeyId: options.accessKey || process.env.S3_ACCESS_KEY || '',
|
|
secretAccessKey: options.secretKey || process.env.S3_SECRET_KEY || '',
|
|
},
|
|
})
|
|
: null;
|
|
|
|
function isRemote() {
|
|
return remote;
|
|
}
|
|
|
|
function objectKey(key) {
|
|
const k = normalizeKey(key);
|
|
if (!k) return null;
|
|
return prefix ? `${prefix}/${k}` : k;
|
|
}
|
|
|
|
function localFile(key) {
|
|
const k = normalizeKey(key);
|
|
if (!k) return null;
|
|
const abs = path.resolve(dir, ...k.split('/'));
|
|
if (abs !== dir && !abs.startsWith(dir + path.sep)) return null;
|
|
return abs;
|
|
}
|
|
|
|
function cacheFile(key) {
|
|
const k = normalizeKey(key);
|
|
if (!k) return null;
|
|
return path.join(cacheDir, ...k.split('/'));
|
|
}
|
|
|
|
function keyFromPath(p) {
|
|
return normalizeKey(p);
|
|
}
|
|
|
|
function localExists(key) {
|
|
const fp = localFile(key);
|
|
if (!fp) return false;
|
|
try {
|
|
return fs.statSync(fp).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function unlinkLocalOnly(key) {
|
|
for (const fp of [localFile(key), cacheFile(key)]) {
|
|
if (!fp) continue;
|
|
try {
|
|
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
function isMissingError(err) {
|
|
if (!err) return false;
|
|
const code = err.name || err.Code || err.code;
|
|
const status = err.$metadata && err.$metadata.httpStatusCode;
|
|
return code === 'NoSuchKey' || code === 'NotFound' || code === 'ENOENT' || status === 404;
|
|
}
|
|
|
|
async function put(key, body, opts = {}) {
|
|
const objKey = objectKey(key);
|
|
if (!objKey) return false;
|
|
if (!remote) {
|
|
const fp = localFile(key);
|
|
if (!fp) return false;
|
|
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
if (Buffer.isBuffer(body) || typeof body === 'string') fs.writeFileSync(fp, body);
|
|
else await pipeline(body, fs.createWriteStream(fp));
|
|
return true;
|
|
}
|
|
await client.send(new PutObjectCommand({
|
|
Bucket: bucket,
|
|
Key: objKey,
|
|
Body: body,
|
|
ContentType: opts.contentType || mimeFor(key),
|
|
ContentLength: opts.contentLength,
|
|
}));
|
|
return true;
|
|
}
|
|
|
|
async function putFile(key, localPath, opts = {}) {
|
|
const objKey = objectKey(key);
|
|
if (!objKey) return false;
|
|
let size = null;
|
|
try {
|
|
size = fs.statSync(localPath).size;
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!remote) {
|
|
const fp = localFile(key);
|
|
if (!fp) return false;
|
|
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
|
if (path.resolve(localPath) !== fp) fs.copyFileSync(localPath, fp);
|
|
return true;
|
|
}
|
|
await client.send(new PutObjectCommand({
|
|
Bucket: bucket,
|
|
Key: objKey,
|
|
Body: fs.createReadStream(localPath),
|
|
ContentLength: size,
|
|
ContentType: opts.contentType || mimeFor(key),
|
|
}));
|
|
return true;
|
|
}
|
|
|
|
async function head(key) {
|
|
const objKey = objectKey(key);
|
|
if (!objKey) return null;
|
|
if (!remote) {
|
|
const fp = localFile(key);
|
|
if (!fp) return null;
|
|
try {
|
|
const st = fs.statSync(fp);
|
|
if (!st.isFile()) return null;
|
|
return { key: normalizeKey(key), size: st.size, contentType: mimeFor(key), etag: null };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
try {
|
|
const out = await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objKey }));
|
|
return {
|
|
key: normalizeKey(key),
|
|
size: out.ContentLength || 0,
|
|
contentType: out.ContentType || mimeFor(key),
|
|
etag: out.ETag ? out.ETag.replace(/"/g, '') : null,
|
|
};
|
|
} catch (err) {
|
|
if (isMissingError(err)) return null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function exists(key) {
|
|
if (localFallback && localExists(key)) return true;
|
|
return !!(await head(key));
|
|
}
|
|
|
|
async function sizeOf(key) {
|
|
const info = await head(key);
|
|
return info ? info.size : 0;
|
|
}
|
|
|
|
async function getStream(key) {
|
|
const objKey = objectKey(key);
|
|
if (!objKey) return null;
|
|
if (!remote) {
|
|
const fp = localFile(key);
|
|
if (!fp || !fs.existsSync(fp)) return null;
|
|
return { stream: fs.createReadStream(fp), contentLength: fs.statSync(fp).size, contentType: mimeFor(key) };
|
|
}
|
|
try {
|
|
const out = await client.send(new GetObjectCommand({ Bucket: bucket, Key: objKey }));
|
|
return { stream: out.Body, contentLength: out.ContentLength || 0, contentType: out.ContentType || mimeFor(key) };
|
|
} catch (err) {
|
|
if (isMissingError(err)) return null;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function getBuffer(key) {
|
|
const fp = await localize(key);
|
|
if (fp) return fs.readFileSync(fp);
|
|
return null;
|
|
}
|
|
|
|
async function del(key) {
|
|
const k = normalizeKey(key);
|
|
if (!k) return false;
|
|
unlinkLocalOnly(k);
|
|
if (!remote) return true;
|
|
const objKey = objectKey(k);
|
|
try {
|
|
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: objKey }));
|
|
} catch (err) {
|
|
if (!isMissingError(err)) throw err;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function copyObject(srcKey, dstKey) {
|
|
const src = normalizeKey(srcKey);
|
|
const dst = normalizeKey(dstKey);
|
|
if (!src || !dst) return false;
|
|
if (!remote) {
|
|
const from = localFile(src);
|
|
const to = localFile(dst);
|
|
if (!from || !to || !fs.existsSync(from)) return false;
|
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
fs.copyFileSync(from, to);
|
|
return true;
|
|
}
|
|
try {
|
|
await client.send(new CopyObjectCommand({
|
|
Bucket: bucket,
|
|
Key: objectKey(dst),
|
|
CopySource: `${bucket}/${objectKey(src)}`,
|
|
ContentType: mimeFor(dst),
|
|
MetadataDirective: 'REPLACE',
|
|
}));
|
|
return true;
|
|
} catch (err) {
|
|
if (isMissingError(err)) return false;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function listAll(listPrefix = '') {
|
|
const out = [];
|
|
if (!remote) {
|
|
const base = listPrefix ? localFile(listPrefix) : dir;
|
|
if (!base || !fs.existsSync(base)) return out;
|
|
const walk = (abs, rel) => {
|
|
for (const name of fs.readdirSync(abs)) {
|
|
const childAbs = path.join(abs, name);
|
|
const childRel = rel ? `${rel}/${name}` : name;
|
|
let st;
|
|
try {
|
|
st = fs.statSync(childAbs);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (st.isDirectory()) walk(childAbs, childRel);
|
|
else out.push({ key: childRel, size: st.size });
|
|
}
|
|
};
|
|
walk(base, listPrefix ? normalizeKey(listPrefix) || '' : '');
|
|
return out;
|
|
}
|
|
let token = null;
|
|
const fullPrefix = prefix ? `${prefix}/${listPrefix}` : listPrefix;
|
|
do {
|
|
const res = await client.send(new ListObjectsV2Command({
|
|
Bucket: bucket,
|
|
Prefix: fullPrefix || undefined,
|
|
ContinuationToken: token || undefined,
|
|
}));
|
|
for (const item of res.Contents || []) {
|
|
const key = prefix ? String(item.Key).slice(prefix.length + 1) : String(item.Key);
|
|
out.push({ key, size: item.Size || 0 });
|
|
}
|
|
token = res.IsTruncated ? res.NextContinuationToken : null;
|
|
} while (token);
|
|
return out;
|
|
}
|
|
|
|
|
|
|
|
async function localize(key, opts = {}) {
|
|
const k = normalizeKey(key);
|
|
if (!k) return null;
|
|
if (localExists(k)) return localFile(k);
|
|
if (!remote) return null;
|
|
const cf = cacheFile(k);
|
|
if (cf && fs.existsSync(cf)) {
|
|
const info = await head(k);
|
|
if (info && info.size === fs.statSync(cf).size) return cf;
|
|
try { fs.unlinkSync(cf); } catch {}
|
|
}
|
|
const source = await getStream(k);
|
|
if (!source) return null;
|
|
fs.mkdirSync(path.dirname(cf), { recursive: true });
|
|
const tmp = `${cf}.${process.pid}.${Date.now()}.tmp`;
|
|
await pipeline(source.stream, fs.createWriteStream(tmp));
|
|
if (opts.useCache === false) return tmp;
|
|
fs.renameSync(tmp, cf);
|
|
return cf;
|
|
}
|
|
|
|
async function persist(key, localPath) {
|
|
const k = normalizeKey(key);
|
|
if (!k) return false;
|
|
const src = path.resolve(localPath);
|
|
if (!fs.existsSync(src)) return false;
|
|
if (!remote) return true;
|
|
const info = await head(k);
|
|
if (info && info.size === fs.statSync(src).size) {
|
|
if (!keepLocal) unlinkLocalOnly(k);
|
|
return true;
|
|
}
|
|
await putFile(k, src);
|
|
if (!keepLocal) {
|
|
try { fs.unlinkSync(src); } catch {}
|
|
const cf = cacheFile(k);
|
|
if (cf && fs.existsSync(cf)) {
|
|
try { fs.unlinkSync(cf); } catch {}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function streamTo(res, key, opts = {}) {
|
|
const k = normalizeKey(key);
|
|
if (!k) return false;
|
|
if (localFallback && localExists(k)) {
|
|
const fp = localFile(k);
|
|
if (opts.cacheControl) res.setHeader('Cache-Control', opts.cacheControl);
|
|
if (opts.download) {
|
|
res.download(fp, opts.name || path.basename(fp), opts.callback || (() => {}));
|
|
return true;
|
|
}
|
|
res.setHeader('Content-Type', opts.contentType || mimeFor(k));
|
|
res.sendFile(fp);
|
|
return true;
|
|
}
|
|
const source = await getStream(k);
|
|
if (!source) return false;
|
|
if (opts.cacheControl) res.setHeader('Cache-Control', opts.cacheControl);
|
|
res.setHeader('Content-Type', opts.contentType || source.contentType || mimeFor(k));
|
|
if (source.contentLength) res.setHeader('Content-Length', String(source.contentLength));
|
|
if (opts.download) {
|
|
const name = opts.name || path.basename(k);
|
|
const ascii = name.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`);
|
|
}
|
|
await pipeline(source.stream, res);
|
|
return true;
|
|
}
|
|
|
|
async function downloadAll(destDir, opts = {}) {
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
const root = path.resolve(destDir);
|
|
const objects = await listAll('');
|
|
let count = 0;
|
|
for (const obj of objects) {
|
|
if (!isObjectKey(obj.key)) continue;
|
|
const k = normalizeKey(obj.key);
|
|
if (!k) continue;
|
|
const target = path.resolve(root, ...k.split('/'));
|
|
if (!target.startsWith(root + path.sep)) continue;
|
|
const source = await getStream(k);
|
|
if (!source) continue;
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
await pipeline(source.stream, fs.createWriteStream(target));
|
|
count++;
|
|
}
|
|
if (opts.pruneThumbs !== false) {
|
|
const thumbs = path.join(root, '.thumbs');
|
|
if (fs.existsSync(thumbs)) fs.rmSync(thumbs, { recursive: true, force: true });
|
|
}
|
|
return count;
|
|
}
|
|
|
|
async function uploadTree(srcDir) {
|
|
const root = path.resolve(srcDir);
|
|
if (!fs.existsSync(root)) return 0;
|
|
let count = 0;
|
|
const walk = async (abs, rel) => {
|
|
for (const name of fs.readdirSync(abs)) {
|
|
const childAbs = path.join(abs, name);
|
|
const childRel = rel ? `${rel}/${name}` : name;
|
|
let st;
|
|
try {
|
|
st = fs.statSync(childAbs);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (st.isDirectory()) {
|
|
if (name === '.thumbs' || name === '.cache') continue;
|
|
await walk(childAbs, childRel);
|
|
continue;
|
|
}
|
|
const k = normalizeKey(childRel);
|
|
if (!k) continue;
|
|
await putFile(k, childAbs);
|
|
count++;
|
|
}
|
|
};
|
|
await walk(root, '');
|
|
return count;
|
|
}
|
|
|
|
async function ensureBucket() {
|
|
if (!remote || !bucket) return false;
|
|
try {
|
|
await client.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
return true;
|
|
} catch (err) {
|
|
const status = err && err.$metadata && err.$metadata.httpStatusCode;
|
|
const name = err && (err.name || err.Code);
|
|
if (status && status !== 404 && name !== 'NotFound' && name !== 'NoSuchBucket') throw err;
|
|
}
|
|
try {
|
|
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
return true;
|
|
} catch (err) {
|
|
const name = err && (err.name || err.Code);
|
|
if (name === 'BucketAlreadyOwnedByYou' || name === 'BucketAlreadyExists') return true;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function usage() {
|
|
const objects = (await listAll('')).filter(o => isObjectKey(o.key));
|
|
return {
|
|
count: objects.length,
|
|
size_bytes: objects.reduce((sum, o) => sum + (o.size || 0), 0),
|
|
bucket: remote ? bucket : null,
|
|
prefix: prefix || null,
|
|
};
|
|
}
|
|
|
|
function pruneCache(now = Date.now()) {
|
|
if (!cacheMaxAgeMs || !fs.existsSync(cacheDir)) return 0;
|
|
let removed = 0;
|
|
const walk = (abs) => {
|
|
for (const name of fs.readdirSync(abs)) {
|
|
const childAbs = path.join(abs, name);
|
|
let st;
|
|
try {
|
|
st = fs.statSync(childAbs);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (st.isDirectory()) {
|
|
walk(childAbs);
|
|
continue;
|
|
}
|
|
if (now - st.mtimeMs > cacheMaxAgeMs) {
|
|
try { fs.unlinkSync(childAbs); removed++; } catch {}
|
|
}
|
|
}
|
|
};
|
|
walk(cacheDir);
|
|
return removed;
|
|
}
|
|
|
|
return {
|
|
bucket,
|
|
cacheDir,
|
|
isRemote,
|
|
keyFromPath,
|
|
localFile,
|
|
unlinkLocalOnly,
|
|
normalizeKey,
|
|
put,
|
|
putFile,
|
|
head,
|
|
exists,
|
|
sizeOf,
|
|
getStream,
|
|
getBuffer,
|
|
del,
|
|
copyObject,
|
|
listAll,
|
|
localize,
|
|
persist,
|
|
streamTo,
|
|
downloadAll,
|
|
uploadTree,
|
|
ensureBucket,
|
|
usage,
|
|
pruneCache,
|
|
};
|
|
}
|
|
|
|
module.exports = { createStorage, mimeFor, normalizeKey };
|
|
|