feat(storage): S3-совместимое хранилище файлов (SeaweedFS/MinIO) и миграция uploads

- storage.js: абстракция хранилища с драйверами local и s3 (AWS SDK v3),
  ключи объектов совпадают с текущими путями /uploads/<файл>, поэтому схема БД
  и URL не меняются
- docker-compose.yml: сервис s3 (SeaweedFS, том s3-data, API только на loopback),
  переменные S3_*/STORAGE_*, restart unless-stopped для app и db
- docker-compose.minio.yml: оверрайд S3-сервиса на MinIO (образ из своего зеркала)
- server.js/worker.js: чтение и запись файлов только через storage (отдача
  /uploads/*, миниатюры, share-файлы, zip-отчёты, enhance/apply/rollback,
  photo-worker), автосоздание бакета, глобальная персистенция загрузок multer
- бэкап/восстановление и scripts/backup.sh, restore.sh — через scripts/storage-sync.js
- scripts/migrate-to-s3.js: идемпотентная миграция uploads/ в бакет
  (--dry-run, --verify-only, --delete-local)
- админка: блок «Хранилище» в системной информации
- .env.example, README.md, AGENTS.md: описание драйверов, переменных и перехода на S3
This commit is contained in:
dev
2026-09-26 11:08:53 +03:00
parent 8bf54fb95d
commit ddd49707ae
16 changed files with 1563 additions and 217 deletions
+5 -2
View File
@@ -10,10 +10,13 @@ mkdir -p backups
echo ">> Экспорт БД"
docker compose exec -T db pg_dump -U app -d whereldo | gzip > backups/db.sql.gz
echo ">> Копирование фотографий"
echo ">> Экспорт файлов из хранилища"
rm -rf backups/_uploads
mkdir -p backups/_uploads
docker cp "whatido-app-1:/app/uploads/." "backups/_uploads/"
docker compose exec -T app rm -rf /tmp/whatido-export
docker compose exec -T app node scripts/storage-sync.js export /tmp/whatido-export
docker cp "whatido-app-1:/tmp/whatido-export/." "backups/_uploads/"
docker compose exec -T app rm -rf /tmp/whatido-export
rm -rf backups/_uploads/.thumbs
echo ">> Сборка архива"
+128
View File
@@ -0,0 +1,128 @@
const path = require('path');
const { createStorage } = require('../storage');
const SKIP_PREFIXES = ['.thumbs/', '.cache/'];
function parseArgs(argv) {
const opts = { dryRun: false, deleteLocal: false, verifyOnly: false, concurrency: 3, prefix: null, help: false };
for (const arg of argv.slice(2)) {
if (arg === '--dry-run') opts.dryRun = true;
else if (arg === '--delete-local') opts.deleteLocal = true;
else if (arg === '--keep-local') opts.deleteLocal = false;
else if (arg === '--verify-only') opts.verifyOnly = true;
else if (arg.startsWith('--concurrency=')) opts.concurrency = Math.max(1, parseInt(arg.split('=')[1], 10) || 1);
else if (arg.startsWith('--prefix=')) opts.prefix = arg.split('=')[1].replace(/^\/+|\/+$/g, '');
else if (arg === '--help' || arg === '-h') opts.help = true;
}
return opts;
}
function usage() {
console.log(`Миграция uploads/ -> S3 (MinIO).
Использование:
node scripts/migrate-to-s3.js [ключи]
Ключи:
--dry-run показать, что будет загружено, ничего не менять
--verify-only только проверить наличие объектов в S3
--delete-local удалить локальные файлы после успешной проверки
--keep-local оставить локальные файлы (по умолчанию)
--concurrency=N параллельных загрузок (по умолчанию 3)
--prefix=PREFIX префикс ключей внутри бакета
Переменные окружения: STORAGE_DRIVER=s3, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY.`);
}
async function runPool(items, limit, worker) {
const queue = items.slice();
const results = { ok: 0, skip: 0, fail: 0 };
const runners = Array.from({ length: Math.min(limit, queue.length) }, async () => {
while (queue.length) {
const item = queue.shift();
const res = await worker(item);
if (res === 'ok') results.ok++;
else if (res === 'skip') results.skip++;
else results.fail++;
}
});
await Promise.all(runners);
return results;
}
async function main() {
const opts = parseArgs(process.argv);
if (opts.help) return usage();
const dir = path.join(__dirname, '..', 'uploads');
const source = createStorage({ dir, driver: 'local' });
const target = createStorage({ driver: 's3', prefix: opts.prefix || undefined });
if (!target.isRemote()) {
console.error('STORAGE_DRIVER не s3: задайте STORAGE_DRIVER=s3 и переменные S3_*.');
process.exit(2);
}
if (!target.bucket) {
console.error('Не задан S3_BUCKET.');
process.exit(2);
}
const all = await source.listAll('');
const objects = all.filter(o => !SKIP_PREFIXES.some(p => o.key.startsWith(p)));
const totalBytes = objects.reduce((sum, o) => sum + (o.size || 0), 0);
if (!opts.dryRun) await target.ensureBucket();
console.log(`Локально: ${objects.length} файлов, ${(totalBytes / 1048576).toFixed(2)} МБ`);
console.log(`Бакет: ${target.bucket}${opts.prefix ? ' префикс ' + opts.prefix : ''} (${process.env.S3_ENDPOINT || 'endpoint по умолчанию'})`);
if (opts.dryRun) console.log('Режим --dry-run: изменения не применяются.');
const failures = [];
const result = await runPool(objects, opts.concurrency, async (obj) => {
const localPath = source.localFile(obj.key);
if (!localPath) {
failures.push(`${obj.key}: некорректный путь`);
return 'fail';
}
try {
const remote = await target.head(obj.key);
if (remote && remote.size === obj.size) {
if (opts.deleteLocal && !opts.dryRun) source.unlinkLocalOnly(obj.key);
return 'skip';
}
if (opts.verifyOnly) {
failures.push(`${obj.key}: нет объекта в S3`);
return 'fail';
}
if (opts.dryRun) {
console.log(` + ${obj.key} (${obj.size} Б)`);
return 'ok';
}
await target.putFile(obj.key, localPath);
const check = await target.head(obj.key);
if (!check || check.size !== obj.size) {
failures.push(`${obj.key}: размер не совпал после загрузки`);
return 'fail';
}
if (opts.deleteLocal) source.unlinkLocalOnly(obj.key);
return 'ok';
} catch (err) {
failures.push(`${obj.key}: ${err.message}`);
return 'fail';
}
});
const usage = await target.usage().catch(() => null);
console.log(`Загружено: ${result.ok}, уже было: ${result.skip}, ошибок: ${result.fail}`);
if (usage) console.log(`В бакете: ${usage.count} объектов, ${(usage.size_bytes / 1048576).toFixed(2)} МБ`);
if (failures.length) {
console.error('Ошибки:');
for (const f of failures.slice(0, 50)) console.error(' - ' + f);
if (failures.length > 50) console.error(` ... ещё ${failures.length - 50}`);
process.exit(1);
}
if (!opts.deleteLocal && !opts.dryRun) {
console.log('Локальные файлы оставлены. Удалить после проверки: node scripts/migrate-to-s3.js --delete-local');
}
}
main().catch(err => {
console.error('Миграция не выполнена:', err.message);
process.exit(1);
});
+11 -2
View File
@@ -28,8 +28,17 @@ echo ">> Восстановление БД (чистая схема)"
docker compose exec -T db psql -U app -d whereldo -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' >/dev/null
gzip -dc "$TMP/db.sql.gz" | docker compose exec -T db psql -U app -d whereldo
echo ">> Восстановление фотографий"
docker cp "$TMP/_uploads/." "whatido-app-1:/app/uploads/"
echo ">> Загрузка файлов в хранилище"
docker compose start app
for _ in $(seq 1 30); do
docker compose exec -T app true >/dev/null 2>&1 && break
sleep 1
done
docker compose exec -T app rm -rf /tmp/whatido-import
docker compose exec -T app mkdir -p /tmp/whatido-import
docker cp "$TMP/_uploads/." "whatido-app-1:/tmp/whatido-import/"
docker compose exec -T app node scripts/storage-sync.js import /tmp/whatido-import
docker compose exec -T app rm -rf /tmp/whatido-import
echo ">> Запуск приложения"
docker compose start app
+39
View File
@@ -0,0 +1,39 @@
const path = require('path');
const { createStorage } = require('../storage');
function usage() {
console.log(`Экспорт/импорт файлов хранилища (для scripts/backup.sh и scripts/restore.sh).
Использование:
node scripts/storage-sync.js export <каталог> выгрузить все объекты в каталог
node scripts/storage-sync.js import <каталог> загрузить все файлы из каталога в хранилище
Работает с текущим драйвером из переменных окружения (local — ./uploads, s3 — бакет MinIO/S3).`);
}
async function main() {
const [, , mode, dirArg] = process.argv;
if (!mode || !dirArg || mode === '--help' || mode === '-h') {
usage();
return process.exit(mode ? 0 : 2);
}
const dir = path.resolve(dirArg);
const storage = createStorage({ dir: path.join(__dirname, '..', 'uploads') });
if (mode === 'export') {
const count = await storage.downloadAll(dir);
console.log(`Экспортировано объектов: ${count} -> ${dir}`);
return;
}
if (mode === 'import') {
const count = await storage.uploadTree(dir);
console.log(`Импортировано файлов: ${count} <- ${dir}`);
return;
}
usage();
process.exit(2);
}
main().catch(err => {
console.error('storage-sync failed:', err.message);
process.exit(1);
});