Merge remote-tracking branch 'origin/dev' into marketplace2
This commit is contained in:
@@ -27,6 +27,12 @@ PG_HOST_PORT=5532
|
||||
COOPBACK_HOST_PORT=2998
|
||||
DESKTOP_HOST_PORT=2999
|
||||
OPENSEARCH_HOST_PORT=9200
|
||||
MINIO_HOST_PORT=9000
|
||||
MINIO_CONSOLE_HOST_PORT=9001
|
||||
|
||||
# MinIO root credentials (только для локального dev; на prod подменяются плейбуком).
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
|
||||
# URL для скриптов и кода, запускаемого с хоста (boot, networks.sh,
|
||||
# preactivate.sh, health.ts). Должны соответствовать host-портам выше.
|
||||
|
||||
@@ -84,3 +84,16 @@ OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
WHISPER_MODEL=whisper-1
|
||||
WHISPER_LANGUAGE=ru
|
||||
|
||||
# FILE STORAGE (MinIO в dev compose; на prod подменяется плейбуком).
|
||||
# Endpoint S3-совместимого бэкенда. В dev compose service name = minio.
|
||||
# Для запуска контроллера на хосте (вне docker) — http://127.0.0.1:9000.
|
||||
MINIO_ENDPOINT=http://minio:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
# Имя физического бакета. По умолчанию `coop-${COOPNAME}`, можно перекрыть.
|
||||
MINIO_BUCKET=
|
||||
# HMAC-секрет для подписи URL отдачи. Если пусто — берётся SERVER_SECRET.
|
||||
FILE_STORAGE_SIGNING_SECRET=
|
||||
# База публичного URL контроллера для read-URL. Если пусто — берётся BACKEND_URL.
|
||||
FILE_STORAGE_PUBLIC_BASE_URL=
|
||||
|
||||
@@ -8,6 +8,9 @@ module.exports = {
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
restoreMocks: true,
|
||||
// Интеграционные тесты против внешних сервисов (MinIO и т.п.) — не часть штатного `jest`-прогона.
|
||||
// Запускаются явно через `npm run test:integration:file-storage`.
|
||||
testPathIgnorePatterns: ['/node_modules/', '\\.integration\\.spec\\.ts$'],
|
||||
coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.ts', 'tests'],
|
||||
coverageReporters: ['text', 'lcov', 'clover', 'html'],
|
||||
moduleNameMapper: {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"dev": "nodemon --watch src --watch migrations --ext ts,js,env --exec 'ts-node -r tsconfig-paths/register' src/index.ts",
|
||||
"test": "jest -i --testPathPattern=tests/unit",
|
||||
"test:integration": "echo 'Integration tests run via @coopenomics/boot' && exit 0",
|
||||
"test:integration:file-storage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testRegex 'tests/file-storage/.*\\.integration\\.spec\\.ts$' --testPathIgnorePatterns='/node_modules/' --testTimeout=60000 -i",
|
||||
"test:watch": "jest -i --watchAll",
|
||||
"coverage": "jest -i --coverage",
|
||||
"coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls",
|
||||
@@ -71,6 +72,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
"@aws-sdk/client-s3": "^3.1035.0",
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
@@ -193,6 +195,7 @@
|
||||
"@graphql-codegen/typescript": "^4.1.1",
|
||||
"@graphql-codegen/typescript-apollo-client-helpers": "^3.0.0",
|
||||
"@graphql-codegen/typescript-operations": "^4.3.1",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@redocly/cli": "^1.18.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.26",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { EventsInfrastructureModule } from './infrastructure/events/events.modul
|
||||
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
|
||||
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
|
||||
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
|
||||
import { FileStorageInfrastructureModule } from './infrastructure/file-storage';
|
||||
|
||||
// Domain modules
|
||||
import { AccountDomainModule } from './domain/account/account-domain.module';
|
||||
@@ -104,6 +105,14 @@ import { MarketplaceCardsModule } from './extensions/marketplace-cards/marketpla
|
||||
EventsInfrastructureModule,
|
||||
FreeDecisionInfrastructureModule,
|
||||
DecisionTrackingInfrastructureModule,
|
||||
FileStorageInfrastructureModule.forRoot({
|
||||
endpoint: config.file_storage.endpoint,
|
||||
accessKey: config.file_storage.access_key,
|
||||
secretKey: config.file_storage.secret_key,
|
||||
bucket: config.file_storage.bucket,
|
||||
signingSecret: config.file_storage.signing_secret,
|
||||
publicBaseUrl: config.file_storage.public_base_url,
|
||||
}),
|
||||
// Domain modules
|
||||
AuthDomainModule,
|
||||
RegistrationDomainModule,
|
||||
|
||||
@@ -170,6 +170,26 @@ const envVarsSchema = z.object({
|
||||
OPENAI_BASE_URL: z.string().optional().describe('Базовый URL для Whisper API (через chatcoop-proxy nginx)'),
|
||||
WHISPER_MODEL: z.string().default('whisper-1').describe('Модель Whisper для STT'),
|
||||
WHISPER_LANGUAGE: z.string().default('ru').describe('Язык для Whisper STT'),
|
||||
|
||||
// Файловое хранилище (MinIO в dev/контуре кооператива; S3 в проде по плану E59-N)
|
||||
MINIO_ENDPOINT: z
|
||||
.string()
|
||||
.default('http://minio:9000')
|
||||
.describe('Endpoint S3-совместимого бэкенда; в compose — service name'),
|
||||
MINIO_ACCESS_KEY: z.string().default('minioadmin').describe('Access-key для MinIO/S3'),
|
||||
MINIO_SECRET_KEY: z.string().default('minioadmin').describe('Secret-key для MinIO/S3'),
|
||||
MINIO_BUCKET: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Имя физического бакета; по умолчанию `coop-${COOPNAME}`'),
|
||||
FILE_STORAGE_SIGNING_SECRET: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('HMAC-секрет для подписи read-URL; пусто — берётся SERVER_SECRET'),
|
||||
FILE_STORAGE_PUBLIC_BASE_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
|
||||
});
|
||||
|
||||
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
|
||||
@@ -293,4 +313,12 @@ export default {
|
||||
whisper_model: envVars.data.WHISPER_MODEL,
|
||||
whisper_language: envVars.data.WHISPER_LANGUAGE,
|
||||
},
|
||||
file_storage: {
|
||||
endpoint: envVars.data.MINIO_ENDPOINT,
|
||||
access_key: envVars.data.MINIO_ACCESS_KEY,
|
||||
secret_key: envVars.data.MINIO_SECRET_KEY,
|
||||
bucket: envVars.data.MINIO_BUCKET || `coop-${envVars.data.COOPNAME}`,
|
||||
signing_secret: envVars.data.FILE_STORAGE_SIGNING_SECRET || envVars.data.SERVER_SECRET,
|
||||
public_base_url: envVars.data.FILE_STORAGE_PUBLIC_BASE_URL || envVars.data.BACKEND_URL,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# FileStorage
|
||||
|
||||
Универсальное файловое хранилище контура кооператива (MinIO в v1, готовность к нативному S3).
|
||||
Подробности и архитектура — req **«SPEC: файловое хранилище v1»** в blago.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Расширению нужно положить и потом отдать пайщику бинарный файл — изображение, PDF, в перспективе
|
||||
любой другой blob. Не катить свой S3-клиент и не плодить ad-hoc эндпоинты.
|
||||
|
||||
## Минимальный пример
|
||||
|
||||
### 1. Сервис расширения декларирует свой бакет
|
||||
|
||||
```ts
|
||||
// extensions/stol-zakazov/order-images.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
InjectBucket,
|
||||
UseBucket,
|
||||
type InterFileStorageBucket,
|
||||
} from '~/infrastructure/file-storage';
|
||||
import type { FileUpload } from 'graphql-upload';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
@UseBucket({
|
||||
name: 'stol-zakazov:images',
|
||||
maxBytes: 10 * MB,
|
||||
allowedMime: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
metadataSchema: { ownerId: 'required', orderId: 'required' },
|
||||
defaultUrlTtlSeconds: 600,
|
||||
})
|
||||
@Injectable()
|
||||
export class OrderImagesService {
|
||||
constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
|
||||
|
||||
async attach(orderId: string, ownerId: string, upload: FileUpload): Promise<string> {
|
||||
const key = `orders/${orderId}/main.${ext(upload.mimetype)}`;
|
||||
await this.bucket.put(key, upload.createReadStream(), {
|
||||
contentType: upload.mimetype,
|
||||
metadata: { ownerId, orderId },
|
||||
});
|
||||
return key;
|
||||
}
|
||||
|
||||
async displayUrl(key: string): Promise<string> {
|
||||
return this.bucket.getReadUrl(key);
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
await this.bucket.delete(key); // идемпотентно
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Конвенция:** один бакет — один сервис. Нужен второй бакет — сделай второй сервис.
|
||||
|
||||
### 2. Модуль расширения регистрирует сервис в `forFeature`
|
||||
|
||||
```ts
|
||||
// extensions/stol-zakazov/stol-zakazov.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
|
||||
import { OrderImagesService } from './order-images.service';
|
||||
|
||||
@Module({
|
||||
imports: [FileStorageInfrastructureModule.forFeature([OrderImagesService])],
|
||||
providers: [OrderImagesService],
|
||||
exports: [OrderImagesService],
|
||||
})
|
||||
export class StolZakazovModule {}
|
||||
```
|
||||
|
||||
`forFeature` читает `@UseBucket` метадату каждого класса и провайдит ему конкретный
|
||||
`InterFileStorageBucket` (с уже подмешанным префиксом). Корневой `forRoot(...)` уже подключён
|
||||
в `app.module.ts`.
|
||||
|
||||
### 3. GraphQL — обычная мутация с Upload и поле URL в ответе
|
||||
|
||||
```graphql
|
||||
type Mutation {
|
||||
createOrderItem(input: CreateOrderItemInput!): OrderItem!
|
||||
}
|
||||
|
||||
input CreateOrderItemInput {
|
||||
title: String!
|
||||
image: Upload!
|
||||
}
|
||||
|
||||
type OrderItem {
|
||||
id: ID!
|
||||
title: String!
|
||||
imageUrl: String! # резолвер: bucket.getReadUrl(key) после ACL-проверки
|
||||
}
|
||||
```
|
||||
|
||||
UI ходит обычным `<img src={imageUrl}>`. Никакого нового REST-слоя для GraphQL-клиента —
|
||||
служебная ручка `/api/storage/...` живёт сама и видит только подписанные URL.
|
||||
|
||||
## Операции
|
||||
|
||||
| Метод | Что делает | Гарантии |
|
||||
|---|---|---|
|
||||
| `put(key, body, opts)` | Загружает объект | Перезапись по тому же ключу, валидация `maxBytes`/`allowedMime`/`metadataSchema` |
|
||||
| `getReadUrl(key, opts?)` | Короткоживущий URL чтения | HMAC-signed URL контроллера; TTL по умолчанию из `defaultUrlTtlSeconds` спеки |
|
||||
| `delete(key)` | Удалить | Идемпотентно (нет объекта = успех) |
|
||||
| `head(key)` | Метаданные без скачивания | Бросает `InterFileStorageObjectNotFoundError` если нет |
|
||||
|
||||
`body` принимает `Uint8Array` или `ReadableStream<Uint8Array>` (graphql-upload `createReadStream()`
|
||||
также подходит — duck-typed, проверяется наличие `getReader()` или конвертируется адаптером).
|
||||
|
||||
## Ошибки
|
||||
|
||||
Всё — наследники `InterFileStorageError`. Доменный код мапит на свои коды:
|
||||
|
||||
- `InterFileStorageObjectNotFoundError` — нет объекта (`head`)
|
||||
- `InterFileStorageObjectTooLargeError` — превышен `maxBytes`
|
||||
- `InterFileStorageMimeRejectedError` — `contentType` не в `allowedMime`
|
||||
- `InterFileStorageMetadataValidationError` — нет required-метаданного
|
||||
- `InterFileStorageBackendUnavailableError` — недоступен MinIO/S3
|
||||
- `InterFileStorageBucketNotConfiguredError` — невалидная спека бакета
|
||||
|
||||
## Конфигурация (env)
|
||||
|
||||
| Переменная | Default | Заметка |
|
||||
|---|---|---|
|
||||
| `MINIO_ENDPOINT` | `http://minio:9000` | Compose service name; для запуска контроллера на хосте — `http://127.0.0.1:9000` |
|
||||
| `MINIO_ACCESS_KEY` | `minioadmin` | Только dev; на prod подменяется плейбуком |
|
||||
| `MINIO_SECRET_KEY` | `minioadmin` | То же |
|
||||
| `MINIO_BUCKET` | `coop-${COOPNAME}` | Физический бакет, идемпотентно создаётся на bootstrap |
|
||||
| `FILE_STORAGE_SIGNING_SECRET` | `SERVER_SECRET` | HMAC-секрет; на prod — отдельный |
|
||||
| `FILE_STORAGE_PUBLIC_BASE_URL` | `BACKEND_URL` | База URL для read-ссылок |
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
# Юнит (моки)
|
||||
cd components/controller && npx jest src/infrastructure/file-storage/ -i
|
||||
|
||||
# Интеграционные против реального MinIO
|
||||
docker compose up -d minio # из mono-ai-3 root
|
||||
cd components/controller
|
||||
npm run test:integration:file-storage
|
||||
```
|
||||
|
||||
Тесты пропускаются автоматически (с диагностическим warning), если MinIO недоступен на
|
||||
`FILE_STORAGE_TEST_MINIO_ENDPOINT` (default `http://localhost:9000`).
|
||||
|
||||
## Что в коробке (файлы модуля)
|
||||
|
||||
- `file-storage.config.ts` — `FileStorageInfrastructureOptions` + токен `FILE_STORAGE_OPTIONS`
|
||||
- `bucket-registry.ts` — singleton, накапливает `BucketSpec` от декораторов
|
||||
- `use-bucket.decorator.ts` — `@UseBucket`/`@InjectBucket` + `bucketTokenFor`
|
||||
- `minio-file-storage.adapter.ts` — реализация `InterFileStoragePort` поверх `@aws-sdk/client-s3`,
|
||||
валидация спеки, `OnApplicationBootstrap` с `ensureBucketExists`
|
||||
- `signing.ts` — `signReadUrl` / `verifyReadUrl` (HMAC-SHA256, constant-time)
|
||||
- `file-storage-http.controller.ts` — `GET /api/storage/:bucket/:key` с проверкой подписи
|
||||
- `file-storage-infrastructure.module.ts` — `FileStorageModule.forRoot(...) / forFeature(consumers)`
|
||||
|
||||
## Не входит в v1
|
||||
|
||||
- `getWriteUrl()` (presigned PUT)
|
||||
- `list(prefix)`, `copy(src, dst)`
|
||||
- Lifecycle/retention на уровне порта
|
||||
- Привязка URL к userId (отдельный режим, если появится особо приватный контент)
|
||||
- Антивирусное сканирование
|
||||
|
||||
См. §11 спеки.
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
|
||||
import { BucketRegistry } from './bucket-registry';
|
||||
|
||||
describe('BucketRegistry', () => {
|
||||
beforeEach(() => {
|
||||
BucketRegistry._resetForTests();
|
||||
});
|
||||
|
||||
const spec = (name: string): InterFileStorageBucketSpec => ({
|
||||
name,
|
||||
maxBytes: 1024,
|
||||
allowedMime: ['image/jpeg'],
|
||||
});
|
||||
|
||||
it('add + get возвращает зарегистрированную спеку', () => {
|
||||
class A {}
|
||||
BucketRegistry.add(A, spec('ext-a:images'));
|
||||
expect(BucketRegistry.get(A)?.name).toBe('ext-a:images');
|
||||
});
|
||||
|
||||
it('повторная регистрация того же класса под тем же именем — no-op (идемпотентно)', () => {
|
||||
class A {}
|
||||
BucketRegistry.add(A, spec('ext-a:images'));
|
||||
expect(() => BucketRegistry.add(A, spec('ext-a:images'))).not.toThrow();
|
||||
});
|
||||
|
||||
it('повторная регистрация того же класса под другим именем — выбрасывает', () => {
|
||||
class A {}
|
||||
BucketRegistry.add(A, spec('ext-a:images'));
|
||||
expect(() => BucketRegistry.add(A, spec('ext-a:other'))).toThrow(/уже зарегистрирован/);
|
||||
});
|
||||
|
||||
it('list возвращает все зарегистрированные пары', () => {
|
||||
class A {}
|
||||
class B {}
|
||||
BucketRegistry.add(A, spec('ext-a:images'));
|
||||
BucketRegistry.add(B, spec('ext-b:receipts'));
|
||||
const list = BucketRegistry.list();
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list.map((r) => r.spec.name).sort()).toEqual(['ext-a:images', 'ext-b:receipts']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
|
||||
|
||||
/**
|
||||
* Глобальный реестр бакетов, наполняется декоратором `@UseBucket` на этапе загрузки модулей.
|
||||
* Используется `FileStorageInfrastructureModule.forFeature(...)` для wire-up per-class providers
|
||||
* и `MinioFileStorageAdapter.onApplicationBootstrap` для логирования / диагностики.
|
||||
*/
|
||||
export interface RegisteredBucket {
|
||||
readonly cls: { readonly name: string };
|
||||
readonly spec: InterFileStorageBucketSpec;
|
||||
}
|
||||
|
||||
const REGISTERED = new Map<{ name: string }, InterFileStorageBucketSpec>();
|
||||
|
||||
export const BucketRegistry = {
|
||||
add(cls: { name: string }, spec: InterFileStorageBucketSpec): void {
|
||||
const existing = REGISTERED.get(cls);
|
||||
if (existing && existing.name !== spec.name) {
|
||||
throw new Error(
|
||||
`BucketRegistry: класс ${cls.name} уже зарегистрирован под бакетом '${existing.name}', ` +
|
||||
`повторная регистрация под '${spec.name}' запрещена`,
|
||||
);
|
||||
}
|
||||
REGISTERED.set(cls, spec);
|
||||
},
|
||||
|
||||
get(cls: { name: string }): InterFileStorageBucketSpec | undefined {
|
||||
return REGISTERED.get(cls);
|
||||
},
|
||||
|
||||
list(): readonly RegisteredBucket[] {
|
||||
return Array.from(REGISTERED.entries()).map(([cls, spec]) => ({ cls, spec }));
|
||||
},
|
||||
|
||||
/** Только для тестов: очистка реестра между сценариями. */
|
||||
_resetForTests(): void {
|
||||
REGISTERED.clear();
|
||||
},
|
||||
};
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import 'reflect-metadata';
|
||||
import { Readable } from 'stream';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
InterFileStorageBackendUnavailableError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
} from '@coopenomics/inter';
|
||||
import {
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
} from './file-storage.config';
|
||||
import { FileStorageHttpController } from './file-storage-http.controller';
|
||||
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
|
||||
import { signReadUrl } from './signing';
|
||||
|
||||
const OPTS: FileStorageInfrastructureOptions = {
|
||||
endpoint: 'http://minio:9000',
|
||||
accessKey: 'AKIA-test',
|
||||
secretKey: 'sk-test',
|
||||
bucket: 'coop-test',
|
||||
signingSecret: 'signing-secret-32-bytes-aaaaaaaa',
|
||||
publicBaseUrl: 'https://test.example.org',
|
||||
};
|
||||
|
||||
interface AdapterStub {
|
||||
fetchObjectForReadProxy: jest.Mock;
|
||||
}
|
||||
|
||||
async function makeApp(stub: AdapterStub): Promise<INestApplication> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [FileStorageHttpController],
|
||||
providers: [
|
||||
{ provide: FILE_STORAGE_OPTIONS, useValue: OPTS },
|
||||
{ provide: MinioFileStorageAdapter, useValue: stub },
|
||||
],
|
||||
}).compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeStream(bytes: Buffer): Readable {
|
||||
return Readable.from([bytes]);
|
||||
}
|
||||
|
||||
function makeStub(): AdapterStub {
|
||||
return { fetchObjectForReadProxy: jest.fn() };
|
||||
}
|
||||
|
||||
function urlFor(bucket: string, key: string, ttlSeconds: number): { path: string; exp: number; sig: string } {
|
||||
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
|
||||
const sig = signReadUrl({ bucket, key, expUnix: exp, secret: OPTS.signingSecret });
|
||||
const encodedKey = key.split('/').map(encodeURIComponent).join('/');
|
||||
return { path: `/api/storage/${encodeURIComponent(bucket)}/${encodedKey}`, exp, sig };
|
||||
}
|
||||
|
||||
describe('FileStorageHttpController', () => {
|
||||
let app: INestApplication;
|
||||
let stub: AdapterStub;
|
||||
|
||||
beforeEach(() => {
|
||||
stub = makeStub();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
it('200 + байты при валидной подписи', async () => {
|
||||
app = await makeApp(stub);
|
||||
const payload = Buffer.from('hello world');
|
||||
stub.fetchObjectForReadProxy.mockResolvedValueOnce({
|
||||
stream: makeStream(payload),
|
||||
size: payload.length,
|
||||
contentType: 'image/jpeg',
|
||||
lastModified: new Date(),
|
||||
});
|
||||
|
||||
const u = urlFor('orders-images', 'orders/42/main.jpg', 60);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(u.path)
|
||||
.query({ exp: u.exp, sig: u.sig })
|
||||
.buffer(true)
|
||||
.parse((response, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (c) => chunks.push(c));
|
||||
response.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
expect(res.headers['content-length']).toBe(String(payload.length));
|
||||
expect(res.headers['cache-control']).toMatch(/^private, max-age=\d+$/);
|
||||
expect((res.body as Buffer).equals(payload)).toBe(true);
|
||||
|
||||
expect(stub.fetchObjectForReadProxy).toHaveBeenCalledWith('orders-images/orders/42/main.jpg');
|
||||
});
|
||||
|
||||
it('403 при неверной подписи', async () => {
|
||||
app = await makeApp(stub);
|
||||
const u = urlFor('orders-images', 'a.jpg', 60);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(u.path)
|
||||
.query({ exp: u.exp, sig: 'a'.repeat(64) });
|
||||
expect(res.status).toBe(403);
|
||||
expect(stub.fetchObjectForReadProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 при истёкшем exp', async () => {
|
||||
app = await makeApp(stub);
|
||||
const exp = Math.floor(Date.now() / 1000) - 1;
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a.jpg', expUnix: exp, secret: OPTS.signingSecret });
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/storage/orders-images/a.jpg')
|
||||
.query({ exp, sig });
|
||||
expect(res.status).toBe(403);
|
||||
expect(stub.fetchObjectForReadProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 при отсутствии exp/sig', async () => {
|
||||
app = await makeApp(stub);
|
||||
const res = await request(app.getHttpServer()).get('/api/storage/orders-images/a.jpg');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 при подмене bucket в URL (HMAC связан с bucket)', async () => {
|
||||
app = await makeApp(stub);
|
||||
const exp = Math.floor(Date.now() / 1000) + 60;
|
||||
const sig = signReadUrl({
|
||||
bucket: 'orders-images',
|
||||
key: 'a.jpg',
|
||||
expUnix: exp,
|
||||
secret: OPTS.signingSecret,
|
||||
});
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/storage/expenses-receipts/a.jpg')
|
||||
.query({ exp, sig });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 при подмене key в URL (HMAC связан с key)', async () => {
|
||||
app = await makeApp(stub);
|
||||
const exp = Math.floor(Date.now() / 1000) + 60;
|
||||
const sig = signReadUrl({
|
||||
bucket: 'orders-images',
|
||||
key: 'a.jpg',
|
||||
expUnix: exp,
|
||||
secret: OPTS.signingSecret,
|
||||
});
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/storage/orders-images/b.jpg')
|
||||
.query({ exp, sig });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('404 при ObjectNotFoundError от адаптера', async () => {
|
||||
app = await makeApp(stub);
|
||||
stub.fetchObjectForReadProxy.mockRejectedValueOnce(
|
||||
new InterFileStorageObjectNotFoundError('missing'),
|
||||
);
|
||||
const u = urlFor('orders-images', 'missing.jpg', 60);
|
||||
const res = await request(app.getHttpServer()).get(u.path).query({ exp: u.exp, sig: u.sig });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('502 при BackendUnavailableError от адаптера', async () => {
|
||||
app = await makeApp(stub);
|
||||
stub.fetchObjectForReadProxy.mockRejectedValueOnce(
|
||||
new InterFileStorageBackendUnavailableError('backend down'),
|
||||
);
|
||||
const u = urlFor('orders-images', 'x.jpg', 60);
|
||||
const res = await request(app.getHttpServer()).get(u.path).query({ exp: u.exp, sig: u.sig });
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it('правильно парсит составной ключ с / и спецсимволами', async () => {
|
||||
app = await makeApp(stub);
|
||||
const payload = Buffer.from('xy');
|
||||
stub.fetchObjectForReadProxy.mockResolvedValueOnce({
|
||||
stream: makeStream(payload),
|
||||
size: payload.length,
|
||||
contentType: 'application/pdf',
|
||||
lastModified: new Date(),
|
||||
});
|
||||
|
||||
const key = 'reports/q1 2026/r 1.pdf';
|
||||
const u = urlFor('expenses-receipts', key, 60);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(u.path)
|
||||
.query({ exp: u.exp, sig: u.sig });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(stub.fetchObjectForReadProxy).toHaveBeenCalledWith(`expenses-receipts/${key}`);
|
||||
});
|
||||
});
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
Param,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
InterFileStorageBackendUnavailableError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
} from '@coopenomics/inter';
|
||||
import {
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
} from './file-storage.config';
|
||||
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
|
||||
import { verifyReadUrl } from './signing';
|
||||
|
||||
/**
|
||||
* Служебная ручка отдачи объектов по HMAC-signed URL.
|
||||
*
|
||||
* Формат URL формирует `MinioFileStorageAdapter.getReadUrl`:
|
||||
* `<publicBaseUrl>/api/storage/:bucket/:key?exp=<unix-ts>&sig=<hmac-hex>`
|
||||
*
|
||||
* - `:bucket` — публичная форма имени бакета (`<extension>-<purpose>`).
|
||||
* - `:key` — caller-defined ключ; может содержать `/`, кодируется посегментно.
|
||||
* - `exp` — unix-timestamp истечения; `now < exp` обязательно.
|
||||
* - `sig` — `HMAC-SHA256(secret, "<bucket>\n<key>\n<exp>")`, hex; constant-time сверка.
|
||||
*
|
||||
* ACL принимается ДО получения URL — на стороне доменного резолвера. Любой держатель URL до
|
||||
* истечения TTL может скачать. Утечка → утечка контента до `exp`.
|
||||
*/
|
||||
@Controller('api/storage')
|
||||
export class FileStorageHttpController {
|
||||
private readonly logger = new Logger(FileStorageHttpController.name);
|
||||
|
||||
constructor(
|
||||
@Inject(FILE_STORAGE_OPTIONS)
|
||||
private readonly opts: FileStorageInfrastructureOptions,
|
||||
private readonly adapter: MinioFileStorageAdapter,
|
||||
) {}
|
||||
|
||||
@Get(':bucket/*')
|
||||
async serve(
|
||||
@Param('bucket') bucketRaw: string,
|
||||
@Query('exp') expRaw: string | undefined,
|
||||
@Query('sig') sig: string | undefined,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const bucket = safeDecodeURIComponent(bucketRaw);
|
||||
const restRaw = (req.params as Record<string, string>)['0'] ?? '';
|
||||
const key = decodeKeyFromPath(restRaw);
|
||||
const exp = Number(expRaw);
|
||||
|
||||
if (!Number.isFinite(exp) || !sig || !bucket || !key) {
|
||||
res.status(HttpStatus.FORBIDDEN).end();
|
||||
return;
|
||||
}
|
||||
const nowUnix = Math.floor(Date.now() / 1000);
|
||||
if (nowUnix >= exp) {
|
||||
res.status(HttpStatus.FORBIDDEN).end();
|
||||
return;
|
||||
}
|
||||
const ok = verifyReadUrl({
|
||||
bucket,
|
||||
key,
|
||||
expUnix: exp,
|
||||
sig,
|
||||
secret: this.opts.signingSecret,
|
||||
});
|
||||
if (!ok) {
|
||||
res.status(HttpStatus.FORBIDDEN).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const physicalKey = `${bucket}/${key}`;
|
||||
|
||||
let obj;
|
||||
try {
|
||||
obj = await this.adapter.fetchObjectForReadProxy(physicalKey);
|
||||
} catch (e) {
|
||||
if (e instanceof InterFileStorageObjectNotFoundError) {
|
||||
res.status(HttpStatus.NOT_FOUND).end();
|
||||
return;
|
||||
}
|
||||
if (e instanceof InterFileStorageBackendUnavailableError) {
|
||||
this.logger.warn(`backend недоступен на ${physicalKey}: ${(e as Error).message}`);
|
||||
res.status(HttpStatus.BAD_GATEWAY).end();
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, exp - nowUnix);
|
||||
res.setHeader('Content-Type', obj.contentType);
|
||||
if (obj.size > 0) {
|
||||
res.setHeader('Content-Length', String(obj.size));
|
||||
}
|
||||
res.setHeader('Cache-Control', `private, max-age=${remaining}`);
|
||||
res.status(HttpStatus.OK);
|
||||
|
||||
obj.stream.on('error', (err) => {
|
||||
this.logger.warn(`stream error на ${physicalKey}: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(HttpStatus.BAD_GATEWAY).end();
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
obj.stream.pipe(res);
|
||||
}
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(s: string): string {
|
||||
try {
|
||||
return decodeURIComponent(s);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function decodeKeyFromPath(rest: string): string {
|
||||
if (!rest) return '';
|
||||
return rest.split('/').map(safeDecodeURIComponent).join('/');
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { DynamicModule, Global, Module, type Type } from '@nestjs/common';
|
||||
import { INTER_FILE_STORAGE, type InterFileStoragePort } from '@coopenomics/inter';
|
||||
import { BucketRegistry } from './bucket-registry';
|
||||
import {
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
} from './file-storage.config';
|
||||
import { FileStorageHttpController } from './file-storage-http.controller';
|
||||
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
|
||||
import { bucketTokenFor } from './use-bucket.decorator';
|
||||
|
||||
/**
|
||||
* Динамический модуль файлового хранилища.
|
||||
*
|
||||
* - `forRoot(options)` — провайдит адаптер `InterFileStoragePort` (токен `INTER_FILE_STORAGE`)
|
||||
* и стартует `OnApplicationBootstrap` хук с `ensureBucketExists`. Глобальный — токен
|
||||
* доступен `forFeature`-ам без явного импорта.
|
||||
* - `forFeature(consumers)` — для каждого `@UseBucket`-класса регистрирует фабрику
|
||||
* `bucketTokenFor(class)`, которая отдаёт `InterFileStorageBucket`. Импортируется в модуле
|
||||
* расширения, где живут эти сервисы.
|
||||
*/
|
||||
@Global()
|
||||
@Module({})
|
||||
export class FileStorageInfrastructureModule {
|
||||
static forRoot(options: FileStorageInfrastructureOptions): DynamicModule {
|
||||
return {
|
||||
module: FileStorageInfrastructureModule,
|
||||
controllers: [FileStorageHttpController],
|
||||
providers: [
|
||||
{ provide: FILE_STORAGE_OPTIONS, useValue: options },
|
||||
MinioFileStorageAdapter,
|
||||
{ provide: INTER_FILE_STORAGE, useExisting: MinioFileStorageAdapter },
|
||||
],
|
||||
exports: [INTER_FILE_STORAGE, MinioFileStorageAdapter],
|
||||
};
|
||||
}
|
||||
|
||||
static forFeature(consumers: ReadonlyArray<Type<unknown>>): DynamicModule {
|
||||
const providers = consumers.map((cls) => {
|
||||
const spec = BucketRegistry.get(cls);
|
||||
if (!spec) {
|
||||
throw new Error(
|
||||
`FileStorageInfrastructureModule.forFeature: класс ${cls.name} не помечен @UseBucket`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
provide: bucketTokenFor(cls),
|
||||
useFactory: (port: InterFileStoragePort) => port.getBucket(spec),
|
||||
inject: [INTER_FILE_STORAGE],
|
||||
};
|
||||
});
|
||||
return {
|
||||
module: FileStorageInfrastructureModule,
|
||||
providers,
|
||||
exports: providers.map((p) => p.provide),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Конфигурация подключения к MinIO/S3 и подписания read-URL.
|
||||
* Передаётся в `FileStorageInfrastructureModule.forRoot(options)`.
|
||||
*/
|
||||
export interface FileStorageInfrastructureOptions {
|
||||
/** URL S3-совместимого бэкенда. Для MinIO — `http://minio:9000`. */
|
||||
endpoint: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
/** Имя физического бакета на сервере (например, `coop-voskhod`). */
|
||||
bucket: string;
|
||||
/** Секрет для HMAC-подписи read-URL служебной ручки контроллера. */
|
||||
signingSecret: string;
|
||||
/** База публичного URL контроллера (например, `https://voskhod.example.org`). */
|
||||
publicBaseUrl: string;
|
||||
/** AWS region. Для MinIO любое непустое значение. По умолчанию `us-east-1`. */
|
||||
region?: string;
|
||||
/** Path-style addressing (true для MinIO, false для AWS S3). По умолчанию true. */
|
||||
forcePathStyle?: boolean;
|
||||
/** TTL по умолчанию для read-URL (сек). Перекрывается `BucketSpec.defaultUrlTtlSeconds`. По умолчанию 600. */
|
||||
defaultUrlTtlSeconds?: number;
|
||||
}
|
||||
|
||||
export const FILE_STORAGE_OPTIONS = Symbol.for('FileStorage.Options');
|
||||
|
||||
export const FILE_STORAGE_DEFAULT_URL_TTL_SECONDS = 600;
|
||||
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
FILE_STORAGE_DEFAULT_URL_TTL_SECONDS,
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
} from './file-storage.config';
|
||||
export { BucketRegistry, type RegisteredBucket } from './bucket-registry';
|
||||
export { InjectBucket, UseBucket, bucketTokenFor } from './use-bucket.decorator';
|
||||
export {
|
||||
MinioFileStorageAdapter,
|
||||
type FileStorageObjectStream,
|
||||
} from './minio-file-storage.adapter';
|
||||
export { FileStorageHttpController } from './file-storage-http.controller';
|
||||
export { FileStorageInfrastructureModule } from './file-storage-infrastructure.module';
|
||||
export { signReadUrl, verifyReadUrl } from './signing';
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {
|
||||
InterFileStorageBackendUnavailableError,
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
InterFileStorageMetadataValidationError,
|
||||
InterFileStorageMimeRejectedError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
InterFileStorageObjectTooLargeError,
|
||||
type InterFileStorageBucketSpec,
|
||||
} from '@coopenomics/inter';
|
||||
import type { FileStorageInfrastructureOptions } from './file-storage.config';
|
||||
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
|
||||
import { verifyReadUrl } from './signing';
|
||||
|
||||
const BASE_OPTS: FileStorageInfrastructureOptions = {
|
||||
endpoint: 'http://minio:9000',
|
||||
accessKey: 'AKIA-test',
|
||||
secretKey: 'sk-test',
|
||||
bucket: 'coop-test',
|
||||
signingSecret: 'signing-secret-32-bytes-aaaaaaaa',
|
||||
publicBaseUrl: 'https://test.example.org',
|
||||
};
|
||||
|
||||
const SPEC: InterFileStorageBucketSpec = {
|
||||
name: 'orders:images',
|
||||
maxBytes: 1024,
|
||||
allowedMime: ['image/jpeg', 'image/png'],
|
||||
metadataSchema: {
|
||||
ownerId: 'required',
|
||||
altText: 'optional',
|
||||
},
|
||||
};
|
||||
|
||||
function makeAdapter(opts: FileStorageInfrastructureOptions = BASE_OPTS): {
|
||||
adapter: MinioFileStorageAdapter;
|
||||
send: jest.Mock;
|
||||
} {
|
||||
const adapter = new MinioFileStorageAdapter(opts);
|
||||
const send = jest.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(adapter as any).s3 = { send };
|
||||
return { adapter, send };
|
||||
}
|
||||
|
||||
describe('MinioFileStorageAdapter — getBucket валидация спеки', () => {
|
||||
it('отклоняет name без двоеточия', () => {
|
||||
const { adapter } = makeAdapter();
|
||||
expect(() => adapter.getBucket({ ...SPEC, name: 'no-colon' })).toThrow(
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
);
|
||||
});
|
||||
|
||||
it('отклоняет неположительный maxBytes', () => {
|
||||
const { adapter } = makeAdapter();
|
||||
expect(() => adapter.getBucket({ ...SPEC, maxBytes: 0 })).toThrow(
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
);
|
||||
});
|
||||
|
||||
it('отклоняет пустой allowedMime', () => {
|
||||
const { adapter } = makeAdapter();
|
||||
expect(() => adapter.getBucket({ ...SPEC, allowedMime: [] })).toThrow(
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioFileStorageAdapter — put', () => {
|
||||
it('успешный put передаёт верные Bucket/Key/ContentType/Metadata в PutObjectCommand', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockResolvedValueOnce({ ETag: '"abc123"' });
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
|
||||
const body = new Uint8Array([1, 2, 3, 4]);
|
||||
const result = await bucket.put('orders/42/main.jpg', body, {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u-1' },
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
const cmd = send.mock.calls[0][0] as PutObjectCommand;
|
||||
expect(cmd).toBeInstanceOf(PutObjectCommand);
|
||||
expect(cmd.input.Bucket).toBe('coop-test');
|
||||
expect(cmd.input.Key).toBe('orders-images/orders/42/main.jpg');
|
||||
expect(cmd.input.ContentType).toBe('image/jpeg');
|
||||
expect(cmd.input.ContentLength).toBe(4);
|
||||
expect(cmd.input.Metadata).toEqual({ ownerId: 'u-1' });
|
||||
|
||||
expect(result.key).toBe('orders/42/main.jpg');
|
||||
expect(result.etag).toBe('abc123');
|
||||
expect(result.size).toBe(4);
|
||||
});
|
||||
|
||||
it('отклоняет put с неподходящим MIME без обращения к S3', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(
|
||||
bucket.put('orders/x/main.exe', new Uint8Array([0]), {
|
||||
contentType: 'application/octet-stream',
|
||||
metadata: { ownerId: 'u-1' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InterFileStorageMimeRejectedError);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('отклоняет put сверх maxBytes (Uint8Array) без обращения к S3', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const body = new Uint8Array(SPEC.maxBytes + 1);
|
||||
await expect(
|
||||
bucket.put('big.jpg', body, { contentType: 'image/jpeg', metadata: { ownerId: 'u-1' } }),
|
||||
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('отклоняет put сверх maxBytes (ReadableStream) до отправки', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
|
||||
const chunks: Uint8Array[] = [
|
||||
new Uint8Array(SPEC.maxBytes - 100),
|
||||
new Uint8Array(200), // overshoot at second chunk
|
||||
];
|
||||
const stream = makeWebStream(chunks) as unknown as ReadableStream<Uint8Array>;
|
||||
|
||||
await expect(
|
||||
bucket.put('big-stream.jpg', stream, {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u-1' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('отклоняет put без обязательного metadata-поля', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(
|
||||
bucket.put('x.jpg', new Uint8Array([1]), { contentType: 'image/jpeg', metadata: {} }),
|
||||
).rejects.toBeInstanceOf(InterFileStorageMetadataValidationError);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('путь успешного потока — стрим читается, размер контролируется, S3 получает Buffer', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockResolvedValueOnce({ ETag: '"e1"' });
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
|
||||
const stream = makeWebStream([
|
||||
new Uint8Array([10, 20]),
|
||||
new Uint8Array([30]),
|
||||
]) as unknown as ReadableStream<Uint8Array>;
|
||||
const r = await bucket.put('s.jpg', stream, {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u' },
|
||||
});
|
||||
expect(r.size).toBe(3);
|
||||
const cmd = send.mock.calls[0][0] as PutObjectCommand;
|
||||
const body = cmd.input.Body as Buffer;
|
||||
expect(body.length).toBe(3);
|
||||
expect(body[0]).toBe(10);
|
||||
expect(body[2]).toBe(30);
|
||||
});
|
||||
|
||||
it('оборачивает backend-ошибку put в BackendUnavailableError', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockRejectedValueOnce(new Error('connection refused'));
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
|
||||
await expect(
|
||||
bucket.put('x.jpg', new Uint8Array([1]), {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InterFileStorageBackendUnavailableError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioFileStorageAdapter — head', () => {
|
||||
it('возвращает метаданные при успешном HEAD', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const lastModified = new Date('2026-04-23T10:00:00Z');
|
||||
send.mockResolvedValueOnce({
|
||||
ContentLength: 42,
|
||||
ETag: '"deadbeef"',
|
||||
ContentType: 'image/png',
|
||||
LastModified: lastModified,
|
||||
Metadata: { ownerid: 'u-1' },
|
||||
});
|
||||
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const r = await bucket.head('a/b.png');
|
||||
|
||||
expect((send.mock.calls[0][0] as HeadObjectCommand).input.Key).toBe('orders-images/a/b.png');
|
||||
expect(r.size).toBe(42);
|
||||
expect(r.etag).toBe('deadbeef');
|
||||
expect(r.contentType).toBe('image/png');
|
||||
expect(r.lastModified).toEqual(lastModified);
|
||||
expect(r.metadata.ownerid).toBe('u-1');
|
||||
});
|
||||
|
||||
it('бросает ObjectNotFoundError при S3-ошибке NotFound', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const err = Object.assign(new Error('not found'), {
|
||||
name: 'NotFound',
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
});
|
||||
send.mockRejectedValueOnce(err);
|
||||
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(bucket.head('missing.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageObjectNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('бросает ObjectNotFoundError при ошибке NoSuchKey', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const err = Object.assign(new Error('no such key'), { name: 'NoSuchKey' });
|
||||
send.mockRejectedValueOnce(err);
|
||||
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(bucket.head('missing.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageObjectNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('оборачивает прочие backend-ошибки в BackendUnavailableError', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockRejectedValueOnce(new Error('timeout'));
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(bucket.head('x.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageBackendUnavailableError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioFileStorageAdapter — delete', () => {
|
||||
it('успешный delete вызывает DeleteObjectCommand с правильным Key', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockResolvedValueOnce({});
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await bucket.delete('a/b.jpg');
|
||||
const cmd = send.mock.calls[0][0] as DeleteObjectCommand;
|
||||
expect(cmd).toBeInstanceOf(DeleteObjectCommand);
|
||||
expect(cmd.input.Bucket).toBe('coop-test');
|
||||
expect(cmd.input.Key).toBe('orders-images/a/b.jpg');
|
||||
});
|
||||
|
||||
it('оборачивает backend-ошибку delete в BackendUnavailableError', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockRejectedValueOnce(new Error('500'));
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(bucket.delete('x.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageBackendUnavailableError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioFileStorageAdapter — getReadUrl', () => {
|
||||
it('возвращает URL верной структуры с подписью, проходящей verifyReadUrl', async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
|
||||
const url = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 60 });
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin).toBe('https://test.example.org');
|
||||
expect(parsed.pathname).toBe('/api/storage/orders-images/orders/42/main.jpg');
|
||||
|
||||
const exp = Number(parsed.searchParams.get('exp'));
|
||||
const sig = parsed.searchParams.get('sig')!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(exp).toBeGreaterThan(now);
|
||||
expect(exp).toBeLessThanOrEqual(now + 61);
|
||||
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
const ok = verifyReadUrl({
|
||||
bucket: 'orders-images',
|
||||
key: 'orders/42/main.jpg',
|
||||
expUnix: exp,
|
||||
sig,
|
||||
secret: BASE_OPTS.signingSecret,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it('берёт TTL из defaultUrlTtlSeconds бакета, если не передан', async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
const bucket = adapter.getBucket({ ...SPEC, defaultUrlTtlSeconds: 30 });
|
||||
const url = await bucket.getReadUrl('x.jpg');
|
||||
const exp = Number(new URL(url).searchParams.get('exp'));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(exp - now).toBeGreaterThanOrEqual(28);
|
||||
expect(exp - now).toBeLessThanOrEqual(31);
|
||||
});
|
||||
|
||||
it('экранирует ключ посегментно, сохраняя слэши как разделители', async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const url = await bucket.getReadUrl('a b/c?d=e/f.jpg');
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.pathname).toBe('/api/storage/orders-images/a%20b/c%3Fd%3De/f.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioFileStorageAdapter — onApplicationBootstrap', () => {
|
||||
it('пропускает CreateBucket, если HeadBucket прошёл', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockResolvedValueOnce({}); // HeadBucket OK
|
||||
await adapter.onApplicationBootstrap();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
|
||||
});
|
||||
|
||||
it('создаёт бакет, если HeadBucket вернул 404', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const notFound = Object.assign(new Error('not found'), {
|
||||
name: 'NotFound',
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
});
|
||||
send.mockRejectedValueOnce(notFound).mockResolvedValueOnce({});
|
||||
|
||||
await adapter.onApplicationBootstrap();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
|
||||
expect(send.mock.calls[1][0]).toBeInstanceOf(CreateBucketCommand);
|
||||
});
|
||||
|
||||
it('бросает BackendUnavailableError, если HeadBucket упал не на 404', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockRejectedValueOnce(new Error('connection refused'));
|
||||
await expect(adapter.onApplicationBootstrap()).rejects.toBeInstanceOf(
|
||||
InterFileStorageBackendUnavailableError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeWebStream(chunks: Uint8Array[]): {
|
||||
getReader: () => { read: () => Promise<{ value?: Uint8Array; done: boolean }>; cancel: () => Promise<void> };
|
||||
} {
|
||||
let i = 0;
|
||||
return {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (i >= chunks.length) return { value: undefined, done: true };
|
||||
const value = chunks[i++];
|
||||
return { value, done: false };
|
||||
},
|
||||
async cancel() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {
|
||||
InterFileStorageBackendUnavailableError,
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
InterFileStorageMetadataValidationError,
|
||||
InterFileStorageMimeRejectedError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
InterFileStorageObjectTooLargeError,
|
||||
type InterFileStorageBody,
|
||||
type InterFileStorageBucket,
|
||||
type InterFileStorageBucketSpec,
|
||||
type InterFileStorageGetReadUrlOptions,
|
||||
type InterFileStorageObjectMetadata,
|
||||
type InterFileStoragePort,
|
||||
type InterFileStoragePutOptions,
|
||||
type InterFileStoragePutResult,
|
||||
} from '@coopenomics/inter';
|
||||
import { BucketRegistry } from './bucket-registry';
|
||||
import {
|
||||
FILE_STORAGE_DEFAULT_URL_TTL_SECONDS,
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
} from './file-storage.config';
|
||||
import { signReadUrl } from './signing';
|
||||
|
||||
/**
|
||||
* Адаптер `InterFileStoragePort` поверх MinIO/S3. Реализация одна и та же для обоих —
|
||||
* различается только `endpoint` и `forcePathStyle`. На bootstrap идемпотентно создаёт
|
||||
* физический бакет, дальше отдаёт `InterFileStorageBucket`-хэндлы по спекам.
|
||||
*
|
||||
* URL чтения формируется на собственный домен контроллера (служебная ручка `/api/storage/...`),
|
||||
* подписывается HMAC-SHA256 секретом из конфига. При переходе на нативный S3 здесь же
|
||||
* можно начать возвращать настоящие S3 presigned URL — контракт строки не меняется.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MinioFileStorageAdapter implements InterFileStoragePort, OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(MinioFileStorageAdapter.name);
|
||||
private readonly s3: S3Client;
|
||||
|
||||
constructor(
|
||||
@Inject(FILE_STORAGE_OPTIONS)
|
||||
private readonly opts: FileStorageInfrastructureOptions,
|
||||
) {
|
||||
this.s3 = new S3Client({
|
||||
endpoint: opts.endpoint,
|
||||
region: opts.region ?? 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: opts.accessKey,
|
||||
secretAccessKey: opts.secretKey,
|
||||
},
|
||||
forcePathStyle: opts.forcePathStyle ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.ensureBucketExists();
|
||||
const registered = BucketRegistry.list();
|
||||
this.logger.log(
|
||||
`File storage готов. Физический бакет '${this.opts.bucket}', эндпоинт '${this.opts.endpoint}'. ` +
|
||||
`Зарегистрировано бакетов через @UseBucket: ${registered.length}` +
|
||||
(registered.length > 0
|
||||
? ` (${registered.map((r) => `${r.cls.name}→${r.spec.name}`).join(', ')})`
|
||||
: ''),
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureBucketExists(): Promise<void> {
|
||||
try {
|
||||
await this.s3.send(new HeadBucketCommand({ Bucket: this.opts.bucket }));
|
||||
return;
|
||||
} catch (e) {
|
||||
if (!isNotFound(e)) {
|
||||
throw new InterFileStorageBackendUnavailableError(
|
||||
`HeadBucket(${this.opts.bucket}) не удался: ${getMessage(e)}`,
|
||||
{ cause: asError(e) },
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.s3.send(new CreateBucketCommand({ Bucket: this.opts.bucket }));
|
||||
this.logger.log(`Создан физический бакет '${this.opts.bucket}'`);
|
||||
} catch (e) {
|
||||
throw new InterFileStorageBackendUnavailableError(
|
||||
`CreateBucket(${this.opts.bucket}) не удался: ${getMessage(e)}`,
|
||||
{ cause: asError(e) },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getBucket(spec: InterFileStorageBucketSpec): InterFileStorageBucket {
|
||||
validateSpec(spec);
|
||||
return new MinioBucketHandle(this.s3, this.opts, spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Внутренний fetch для HTTP-ручки `/api/storage/:bucket/:key` (E59-4).
|
||||
* `physicalKey` — это уже собранный ключ S3-объекта вида `<extension>-<purpose>/<caller-key>`.
|
||||
* @throws InterFileStorageObjectNotFoundError, InterFileStorageBackendUnavailableError
|
||||
*/
|
||||
async fetchObjectForReadProxy(physicalKey: string): Promise<FileStorageObjectStream> {
|
||||
try {
|
||||
const r = await this.s3.send(
|
||||
new GetObjectCommand({ Bucket: this.opts.bucket, Key: physicalKey }),
|
||||
);
|
||||
return {
|
||||
stream: r.Body as NodeJS.ReadableStream,
|
||||
size: r.ContentLength ?? 0,
|
||||
contentType: r.ContentType ?? 'application/octet-stream',
|
||||
lastModified: r.LastModified ?? new Date(0),
|
||||
};
|
||||
} catch (e) {
|
||||
if (isNotFound(e)) {
|
||||
throw new InterFileStorageObjectNotFoundError(`Объект '${physicalKey}' не найден`);
|
||||
}
|
||||
throw wrapBackendError(e, `getObject '${physicalKey}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MinioBucketHandle implements InterFileStorageBucket {
|
||||
private readonly publicBucketName: string;
|
||||
private readonly defaultTtlSeconds: number;
|
||||
|
||||
constructor(
|
||||
private readonly s3: S3Client,
|
||||
private readonly opts: FileStorageInfrastructureOptions,
|
||||
private readonly spec: InterFileStorageBucketSpec,
|
||||
) {
|
||||
this.publicBucketName = spec.name.replace(/:/g, '-');
|
||||
this.defaultTtlSeconds =
|
||||
spec.defaultUrlTtlSeconds ?? opts.defaultUrlTtlSeconds ?? FILE_STORAGE_DEFAULT_URL_TTL_SECONDS;
|
||||
}
|
||||
|
||||
private physicalKey(key: string): string {
|
||||
return `${this.publicBucketName}/${key}`;
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
body: InterFileStorageBody,
|
||||
opts: InterFileStoragePutOptions,
|
||||
): Promise<InterFileStoragePutResult> {
|
||||
if (!this.spec.allowedMime.includes(opts.contentType)) {
|
||||
throw new InterFileStorageMimeRejectedError(
|
||||
`MIME '${opts.contentType}' не входит в allowedMime бакета '${this.spec.name}'`,
|
||||
);
|
||||
}
|
||||
this.validateMetadata(opts.metadata);
|
||||
const buffer = await materializeAndCheckSize(body, this.spec.maxBytes, this.spec.name);
|
||||
|
||||
try {
|
||||
const r = await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.opts.bucket,
|
||||
Key: this.physicalKey(key),
|
||||
Body: buffer,
|
||||
ContentType: opts.contentType,
|
||||
ContentLength: buffer.byteLength,
|
||||
Metadata: opts.metadata as Record<string, string> | undefined,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
key,
|
||||
etag: stripQuotes(r.ETag) ?? '',
|
||||
size: buffer.byteLength,
|
||||
};
|
||||
} catch (e) {
|
||||
throw wrapBackendError(e, `put '${this.spec.name}/${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async getReadUrl(key: string, opts?: InterFileStorageGetReadUrlOptions): Promise<string> {
|
||||
const ttl = opts?.ttlSeconds ?? this.defaultTtlSeconds;
|
||||
const exp = Math.floor(Date.now() / 1000) + ttl;
|
||||
const sig = signReadUrl({
|
||||
bucket: this.publicBucketName,
|
||||
key,
|
||||
expUnix: exp,
|
||||
secret: this.opts.signingSecret,
|
||||
});
|
||||
const base = this.opts.publicBaseUrl.replace(/\/+$/, '');
|
||||
return `${base}/api/storage/${encodeURIComponent(this.publicBucketName)}/${encodeKeyForPath(key)}?exp=${exp}&sig=${sig}`;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
await this.s3.send(
|
||||
new DeleteObjectCommand({ Bucket: this.opts.bucket, Key: this.physicalKey(key) }),
|
||||
);
|
||||
} catch (e) {
|
||||
// S3 DeleteObject идемпотентен — отсутствие объекта не ошибка. Сюда попадают только
|
||||
// реальные backend-фейлы.
|
||||
throw wrapBackendError(e, `delete '${this.spec.name}/${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async head(key: string): Promise<InterFileStorageObjectMetadata> {
|
||||
try {
|
||||
const r = await this.s3.send(
|
||||
new HeadObjectCommand({ Bucket: this.opts.bucket, Key: this.physicalKey(key) }),
|
||||
);
|
||||
return {
|
||||
size: r.ContentLength ?? 0,
|
||||
etag: stripQuotes(r.ETag) ?? '',
|
||||
contentType: r.ContentType ?? 'application/octet-stream',
|
||||
lastModified: r.LastModified ?? new Date(0),
|
||||
metadata: r.Metadata ?? {},
|
||||
};
|
||||
} catch (e) {
|
||||
if (isNotFound(e)) {
|
||||
throw new InterFileStorageObjectNotFoundError(
|
||||
`Объект '${this.spec.name}/${key}' не найден`,
|
||||
);
|
||||
}
|
||||
throw wrapBackendError(e, `head '${this.spec.name}/${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateMetadata(metadata?: Readonly<Record<string, string>>): void {
|
||||
if (!this.spec.metadataSchema) return;
|
||||
for (const [field, kind] of Object.entries(this.spec.metadataSchema)) {
|
||||
if (kind !== 'required') continue;
|
||||
const value = metadata?.[field];
|
||||
if (!value) {
|
||||
throw new InterFileStorageMetadataValidationError(
|
||||
`Метаданное '${field}' обязательно для бакета '${this.spec.name}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат внутреннего fetch для HTTP-ручки `/api/storage/...`.
|
||||
* Не часть публичного `InterFileStoragePort` — только для proxy-стрима внутри контроллера.
|
||||
*/
|
||||
export interface FileStorageObjectStream {
|
||||
stream: NodeJS.ReadableStream;
|
||||
size: number;
|
||||
contentType: string;
|
||||
lastModified: Date;
|
||||
}
|
||||
|
||||
function validateSpec(spec: InterFileStorageBucketSpec): void {
|
||||
if (!spec.name || !spec.name.includes(':')) {
|
||||
throw new InterFileStorageBucketNotConfiguredError(
|
||||
`BucketSpec.name должен иметь формат '<extension>:<purpose>', получено '${spec.name}'`,
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {
|
||||
throw new InterFileStorageBucketNotConfiguredError(
|
||||
`BucketSpec.maxBytes должно быть положительным конечным числом, получено ${spec.maxBytes}`,
|
||||
);
|
||||
}
|
||||
if (!spec.allowedMime || spec.allowedMime.length === 0) {
|
||||
throw new InterFileStorageBucketNotConfiguredError(
|
||||
`BucketSpec.allowedMime должен содержать хотя бы один MIME-тип`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function materializeAndCheckSize(
|
||||
body: InterFileStorageBody,
|
||||
maxBytes: number,
|
||||
specName: string,
|
||||
): Promise<Buffer> {
|
||||
if (body instanceof Uint8Array) {
|
||||
if (body.byteLength > maxBytes) {
|
||||
throw new InterFileStorageObjectTooLargeError(
|
||||
`Размер ${body.byteLength} байт превышает лимит ${maxBytes} бакета '${specName}'`,
|
||||
);
|
||||
}
|
||||
return Buffer.from(body);
|
||||
}
|
||||
// Web ReadableStream<Uint8Array> — duck-typed, чтобы не зависеть от глобального типа `ReadableStream`.
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
'getReader' in body &&
|
||||
typeof (body as { getReader: unknown }).getReader === 'function'
|
||||
) {
|
||||
const reader = (body as { getReader: () => StreamReader }).getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value && value.byteLength > 0) {
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
// Освобождаем reader, чтобы не оставлять стрим висящим.
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new InterFileStorageObjectTooLargeError(
|
||||
`Размер тела превышает лимит ${maxBytes} бакета '${specName}'`,
|
||||
);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
throw new InterFileStorageBackendUnavailableError(
|
||||
`Неподдерживаемый тип тела для бакета '${specName}'`,
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamReader {
|
||||
read(): Promise<{ value?: Uint8Array; done: boolean }>;
|
||||
cancel(): Promise<void>;
|
||||
}
|
||||
|
||||
function isNotFound(e: unknown): boolean {
|
||||
if (typeof e !== 'object' || e === null) return false;
|
||||
const ex = e as {
|
||||
name?: string;
|
||||
Code?: string;
|
||||
$metadata?: { httpStatusCode?: number };
|
||||
};
|
||||
if (ex.name === 'NotFound' || ex.name === 'NoSuchKey' || ex.name === 'NoSuchBucket') return true;
|
||||
if (ex.Code === 'NotFound' || ex.Code === 'NoSuchKey' || ex.Code === 'NoSuchBucket') return true;
|
||||
if (ex.$metadata?.httpStatusCode === 404) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function wrapBackendError(e: unknown, op: string): InterFileStorageBackendUnavailableError {
|
||||
return new InterFileStorageBackendUnavailableError(`${op}: ${getMessage(e)}`, {
|
||||
cause: asError(e),
|
||||
});
|
||||
}
|
||||
|
||||
function getMessage(e: unknown): string {
|
||||
if (e instanceof Error) return e.message;
|
||||
if (typeof e === 'string') return e;
|
||||
return 'unknown error';
|
||||
}
|
||||
|
||||
function asError(e: unknown): Error | undefined {
|
||||
return e instanceof Error ? e : undefined;
|
||||
}
|
||||
|
||||
function stripQuotes(s: string | undefined): string | undefined {
|
||||
if (!s) return s;
|
||||
return s.replace(/^"|"$/g, '');
|
||||
}
|
||||
|
||||
function encodeKeyForPath(key: string): string {
|
||||
// '/' — разделитель сегментов URL, остальное per-segment encodeURIComponent.
|
||||
return key.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { signReadUrl, verifyReadUrl } from './signing';
|
||||
|
||||
describe('signing', () => {
|
||||
const secret = 'test-secret-32-bytes-aaaaaaaaaaaa';
|
||||
|
||||
it('подпись детерминирована для одинакового набора параметров', () => {
|
||||
const a = signReadUrl({ bucket: 'b1', key: 'k1', expUnix: 1000, secret });
|
||||
const b = signReadUrl({ bucket: 'b1', key: 'k1', expUnix: 1000, secret });
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('verify проходит на корректную подпись', () => {
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
|
||||
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, sig, secret })).toBe(true);
|
||||
});
|
||||
|
||||
it('verify отклоняет подмену bucket', () => {
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
|
||||
expect(verifyReadUrl({ bucket: 'expenses-receipts', key: 'a/b.jpg', expUnix: 9999, sig, secret })).toBe(false);
|
||||
});
|
||||
|
||||
it('verify отклоняет подмену key', () => {
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
|
||||
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/c.jpg', expUnix: 9999, sig, secret })).toBe(false);
|
||||
});
|
||||
|
||||
it('verify отклоняет подмену exp', () => {
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
|
||||
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 10000, sig, secret })).toBe(false);
|
||||
});
|
||||
|
||||
it('verify отклоняет подмену секрета', () => {
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
|
||||
expect(
|
||||
verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, sig, secret: 'other' }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('verify отклоняет подпись неверной длины без выброса исключения', () => {
|
||||
expect(
|
||||
verifyReadUrl({ bucket: 'b', key: 'k', expUnix: 1, sig: 'abcd', secret }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('verify отклоняет не-hex без выброса исключения', () => {
|
||||
const fake = 'z'.repeat(64);
|
||||
expect(
|
||||
verifyReadUrl({ bucket: 'b', key: 'k', expUnix: 1, sig: fake, secret }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
/**
|
||||
* HMAC-SHA256(secret, "<bucket>\n<key>\n<exp>") в hex.
|
||||
* Используется и адаптером (выдача URL), и HTTP-ручкой (валидация).
|
||||
*/
|
||||
export function signReadUrl(args: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expUnix: number;
|
||||
secret: string;
|
||||
}): string {
|
||||
const h = createHmac('sha256', args.secret);
|
||||
h.update(`${args.bucket}\n${args.key}\n${args.expUnix}`);
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time сверка подписи. Возвращает false на любую структурную ошибку
|
||||
* (несовпадение длин, не-hex), а не выбрасывает — чтобы не различать каналы side-channel-ом.
|
||||
*/
|
||||
export function verifyReadUrl(args: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expUnix: number;
|
||||
sig: string;
|
||||
secret: string;
|
||||
}): boolean {
|
||||
const expected = signReadUrl(args);
|
||||
if (expected.length !== args.sig.length) return false;
|
||||
let expectedBuf: Buffer;
|
||||
let actualBuf: Buffer;
|
||||
try {
|
||||
expectedBuf = Buffer.from(expected, 'hex');
|
||||
actualBuf = Buffer.from(args.sig, 'hex');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (expectedBuf.length !== actualBuf.length) return false;
|
||||
return timingSafeEqual(expectedBuf, actualBuf);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'reflect-metadata';
|
||||
import { BucketRegistry } from './bucket-registry';
|
||||
import { UseBucket, bucketTokenFor } from './use-bucket.decorator';
|
||||
|
||||
describe('UseBucket decorator', () => {
|
||||
beforeEach(() => {
|
||||
BucketRegistry._resetForTests();
|
||||
});
|
||||
|
||||
it('регистрирует спеку класса в BucketRegistry', () => {
|
||||
@UseBucket({ name: 'demo:images', maxBytes: 1024, allowedMime: ['image/png'] })
|
||||
class DemoService {}
|
||||
|
||||
const got = BucketRegistry.get(DemoService);
|
||||
expect(got).toBeDefined();
|
||||
expect(got?.name).toBe('demo:images');
|
||||
expect(got?.maxBytes).toBe(1024);
|
||||
expect(got?.allowedMime).toEqual(['image/png']);
|
||||
});
|
||||
|
||||
it('bucketTokenFor стабилен и зависит только от имени класса', () => {
|
||||
class FooService {}
|
||||
expect(bucketTokenFor(FooService)).toBe('__InterFileStorageBucket:FooService');
|
||||
expect(bucketTokenFor(FooService)).toBe(bucketTokenFor(FooService));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
|
||||
import { BucketRegistry } from './bucket-registry';
|
||||
|
||||
/**
|
||||
* DI-токен per-class бакета, под которым `forFeature` регистрирует фабрику `BucketHandle`.
|
||||
* Конструктор `@InjectBucket()` инжектится по этому токену.
|
||||
*/
|
||||
export function bucketTokenFor(cls: { name: string }): string {
|
||||
return `__InterFileStorageBucket:${cls.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Класс-декоратор: декларирует бакет, в который пишет данный сервис.
|
||||
* Конвенция — один бакет на один сервис; повторная регистрация под другим именем запрещена.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* @UseBucket({ name: 'stol-zakazov:images', maxBytes: 10*MB, allowedMime: ['image/jpeg'] })
|
||||
* @Injectable()
|
||||
* export class OrderImagesService {
|
||||
* constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function UseBucket(spec: InterFileStorageBucketSpec): ClassDecorator {
|
||||
return (target) => {
|
||||
BucketRegistry.add(target as unknown as { name: string }, spec);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Параметр-декоратор: инжектит `InterFileStorageBucket` для текущего класса.
|
||||
* Резолвится в DI-токен `bucketTokenFor(<ClassName>)`, который провайдится через
|
||||
* `FileStorageInfrastructureModule.forFeature([ThisClass])`.
|
||||
*/
|
||||
export function InjectBucket(): ParameterDecorator {
|
||||
return (target, propertyKey, parameterIndex) => {
|
||||
const cls = target as unknown as { name: string };
|
||||
Inject(bucketTokenFor(cls))(target, propertyKey, parameterIndex);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# File-storage integration tests
|
||||
|
||||
Тесты против реального MinIO. По умолчанию ожидают MinIO на `http://localhost:9100` с
|
||||
кредами `testuser` / `testpassword` (см. `docker-compose.yml` рядом).
|
||||
|
||||
## Как запустить
|
||||
|
||||
```bash
|
||||
# 1. Поднять MinIO
|
||||
docker compose -f components/controller/tests/file-storage/docker-compose.yml up -d
|
||||
# дождаться healthy
|
||||
|
||||
# 2. Запустить интеграционные тесты
|
||||
cd components/controller
|
||||
npm run test:integration:file-storage
|
||||
|
||||
# 3. После работы
|
||||
docker compose -f components/controller/tests/file-storage/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
## Переопределение через env
|
||||
|
||||
| Переменная | Default | Что делает |
|
||||
|---|---|---|
|
||||
| `FILE_STORAGE_TEST_MINIO_ENDPOINT` | `http://localhost:9100` | URL MinIO |
|
||||
| `FILE_STORAGE_TEST_MINIO_ACCESS_KEY` | `testuser` | Access key |
|
||||
| `FILE_STORAGE_TEST_MINIO_SECRET_KEY` | `testpassword` | Secret key |
|
||||
| `FILE_STORAGE_TEST_BUCKET` | `coop-test-{timestamp}` | Имя физического бакета (уникально per-run) |
|
||||
|
||||
Если MinIO недоступен на `endpoint`, suite пропускает тесты (`it.skip`) с диагностическим сообщением.
|
||||
@@ -0,0 +1,19 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
container_name: file-storage-test-minio
|
||||
command: server /data --console-address ":9101"
|
||||
environment:
|
||||
MINIO_ROOT_USER: testuser
|
||||
MINIO_ROOT_PASSWORD: testpassword
|
||||
ports:
|
||||
# Уникальные порты, чтобы не конфликтовать с prod-MinIO (9000) и другими инстансами.
|
||||
- '9100:9000'
|
||||
- '9101:9101'
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
@@ -0,0 +1,280 @@
|
||||
import 'reflect-metadata';
|
||||
import { execSync } from 'child_process';
|
||||
import { Readable } from 'stream';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
InterFileStorageMetadataValidationError,
|
||||
InterFileStorageMimeRejectedError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
InterFileStorageObjectTooLargeError,
|
||||
type InterFileStorageBucketSpec,
|
||||
} from '@coopenomics/inter';
|
||||
import {
|
||||
FileStorageHttpController,
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
MinioFileStorageAdapter,
|
||||
signReadUrl,
|
||||
} from '../../src/infrastructure/file-storage';
|
||||
|
||||
const ENDPOINT = process.env.FILE_STORAGE_TEST_MINIO_ENDPOINT ?? 'http://localhost:9100';
|
||||
const ACCESS_KEY = process.env.FILE_STORAGE_TEST_MINIO_ACCESS_KEY ?? 'testuser';
|
||||
const SECRET_KEY = process.env.FILE_STORAGE_TEST_MINIO_SECRET_KEY ?? 'testpassword';
|
||||
const BUCKET = process.env.FILE_STORAGE_TEST_BUCKET ?? `coop-test-${Date.now()}`;
|
||||
const SIGNING_SECRET = 'integration-signing-secret-aaaaaaaaaaaaaaaa';
|
||||
const PUBLIC_BASE_URL = 'https://test.example.org';
|
||||
|
||||
const SPEC: InterFileStorageBucketSpec = {
|
||||
name: 'orders:images',
|
||||
maxBytes: 10 * 1024,
|
||||
allowedMime: ['image/jpeg', 'image/png', 'application/pdf'],
|
||||
metadataSchema: {
|
||||
ownerId: 'required',
|
||||
altText: 'optional',
|
||||
},
|
||||
defaultUrlTtlSeconds: 60,
|
||||
};
|
||||
|
||||
/** Sync-проверка доступности MinIO. Решение `it` vs `it.skip` принимается до describe(). */
|
||||
function probeMinioSync(): boolean {
|
||||
try {
|
||||
execSync(`curl -fsS --max-time 2 ${ENDPOINT}/minio/health/live`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const minioReachable = probeMinioSync();
|
||||
if (!minioReachable) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[integration] MinIO недоступен на ${ENDPOINT} — тесты пропущены. ` +
|
||||
`Подними: docker compose -f tests/file-storage/docker-compose.yml up -d`,
|
||||
);
|
||||
}
|
||||
|
||||
const itLive = (name: string, fn: jest.ProvidesCallback, timeout?: number) =>
|
||||
(minioReachable ? it : it.skip)(name, fn, timeout);
|
||||
|
||||
describe('file-storage integration (real MinIO)', () => {
|
||||
let adapter: MinioFileStorageAdapter;
|
||||
let app: INestApplication;
|
||||
const opts: FileStorageInfrastructureOptions = {
|
||||
endpoint: ENDPOINT,
|
||||
accessKey: ACCESS_KEY,
|
||||
secretKey: SECRET_KEY,
|
||||
bucket: BUCKET,
|
||||
signingSecret: SIGNING_SECRET,
|
||||
publicBaseUrl: PUBLIC_BASE_URL,
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!minioReachable) return;
|
||||
adapter = new MinioFileStorageAdapter(opts);
|
||||
await adapter.onApplicationBootstrap();
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [FileStorageHttpController],
|
||||
providers: [
|
||||
{ provide: FILE_STORAGE_OPTIONS, useValue: opts },
|
||||
{ provide: MinioFileStorageAdapter, useValue: adapter },
|
||||
],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
itLive('put → head → getReadUrl → GET → bytes равны (image)', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const payload = Buffer.from(jpegFixture());
|
||||
await bucket.put('orders/42/main.jpg', payload, {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u-1', altText: 'demo' },
|
||||
});
|
||||
|
||||
const meta = await bucket.head('orders/42/main.jpg');
|
||||
expect(meta.size).toBe(payload.length);
|
||||
expect(meta.contentType).toBe('image/jpeg');
|
||||
expect(meta.metadata.ownerid ?? meta.metadata.ownerId).toBe('u-1');
|
||||
|
||||
const url = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 30 });
|
||||
const parsed = new URL(url);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(parsed.pathname)
|
||||
.query({ exp: parsed.searchParams.get('exp')!, sig: parsed.searchParams.get('sig')! })
|
||||
.buffer(true)
|
||||
.parse((response, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (c) => chunks.push(c));
|
||||
response.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
expect((res.body as Buffer).equals(payload)).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
itLive('put PDF среднего размера через стрим, читается обратно идентично', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const payload = Buffer.alloc(8 * 1024); // 8 KiB
|
||||
for (let i = 0; i < payload.length; i++) payload[i] = i % 251;
|
||||
const stream = bufferToWebStream(payload);
|
||||
await bucket.put('reports/r-1/r1.pdf', stream, {
|
||||
contentType: 'application/pdf',
|
||||
metadata: { ownerId: 'u-2' },
|
||||
});
|
||||
|
||||
const meta = await bucket.head('reports/r-1/r1.pdf');
|
||||
expect(meta.size).toBe(payload.length);
|
||||
|
||||
const url = await bucket.getReadUrl('reports/r-1/r1.pdf', { ttlSeconds: 30 });
|
||||
const parsed = new URL(url);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(parsed.pathname)
|
||||
.query({ exp: parsed.searchParams.get('exp')!, sig: parsed.searchParams.get('sig')! })
|
||||
.buffer(true)
|
||||
.parse((response, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (c) => chunks.push(c));
|
||||
response.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as Buffer).equals(payload)).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
itLive('delete идемпотентен (повтор на отсутствующий — успех)', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await bucket.put('to-delete.jpg', Buffer.from([1, 2, 3]), {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u' },
|
||||
});
|
||||
await bucket.delete('to-delete.jpg');
|
||||
await expect(bucket.delete('to-delete.jpg')).resolves.toBeUndefined();
|
||||
await expect(bucket.delete('never-existed.jpg')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
itLive('head отсутствующего → ObjectNotFoundError', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(bucket.head('does-not-exist.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageObjectNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
itLive('превышение maxBytes → ObjectTooLargeError, объект не появился', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const tooBig = Buffer.alloc(SPEC.maxBytes + 1);
|
||||
await expect(
|
||||
bucket.put('too-big.jpg', tooBig, { contentType: 'image/jpeg', metadata: { ownerId: 'u' } }),
|
||||
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
|
||||
await expect(bucket.head('too-big.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageObjectNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
itLive('запрещённый MIME → MimeRejectedError, объект не появился', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(
|
||||
bucket.put('bad.exe', Buffer.from([0]), {
|
||||
contentType: 'application/octet-stream',
|
||||
metadata: { ownerId: 'u' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InterFileStorageMimeRejectedError);
|
||||
await expect(bucket.head('bad.exe')).rejects.toBeInstanceOf(
|
||||
InterFileStorageObjectNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
itLive('отсутствие required-метаданного → MetadataValidationError', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(
|
||||
bucket.put('no-meta.jpg', Buffer.from([0]), { contentType: 'image/jpeg', metadata: {} }),
|
||||
).rejects.toBeInstanceOf(InterFileStorageMetadataValidationError);
|
||||
});
|
||||
|
||||
itLive('HMAC-роут: 200 → 403 (exp) → 403 (sig) → 404', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const payload = Buffer.from('end-to-end');
|
||||
await bucket.put('hmac-test/x.jpg', payload, {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u' },
|
||||
});
|
||||
|
||||
// 200
|
||||
const okUrl = await bucket.getReadUrl('hmac-test/x.jpg', { ttlSeconds: 30 });
|
||||
const okParsed = new URL(okUrl);
|
||||
const ok = await request(app.getHttpServer())
|
||||
.get(okParsed.pathname)
|
||||
.query({ exp: okParsed.searchParams.get('exp')!, sig: okParsed.searchParams.get('sig')! });
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
// 403 — истёкший exp
|
||||
const pastExp = Math.floor(Date.now() / 1000) - 1;
|
||||
const pastSig = signReadUrl({
|
||||
bucket: 'orders-images',
|
||||
key: 'hmac-test/x.jpg',
|
||||
expUnix: pastExp,
|
||||
secret: SIGNING_SECRET,
|
||||
});
|
||||
const expired = await request(app.getHttpServer())
|
||||
.get('/api/storage/orders-images/hmac-test/x.jpg')
|
||||
.query({ exp: pastExp, sig: pastSig });
|
||||
expect(expired.status).toBe(403);
|
||||
|
||||
// 403 — чужой sig
|
||||
const futureExp = Math.floor(Date.now() / 1000) + 60;
|
||||
const wrongSig = await request(app.getHttpServer())
|
||||
.get('/api/storage/orders-images/hmac-test/x.jpg')
|
||||
.query({ exp: futureExp, sig: 'a'.repeat(64) });
|
||||
expect(wrongSig.status).toBe(403);
|
||||
|
||||
// 404 — несуществующий ключ, но валидная подпись
|
||||
const missingKey = 'hmac-test/missing.jpg';
|
||||
const missingSig = signReadUrl({
|
||||
bucket: 'orders-images',
|
||||
key: missingKey,
|
||||
expUnix: futureExp,
|
||||
secret: SIGNING_SECRET,
|
||||
});
|
||||
const notFound = await request(app.getHttpServer())
|
||||
.get(`/api/storage/orders-images/${missingKey}`)
|
||||
.query({ exp: futureExp, sig: missingSig });
|
||||
expect(notFound.status).toBe(404);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function jpegFixture(): Uint8Array {
|
||||
// Минимальный валидный JPEG header + EOI; для проверки трактовать как опаковые байты.
|
||||
return new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0xff, 0xd9]);
|
||||
}
|
||||
|
||||
function bufferToWebStream(buf: Buffer): ReadableStream<Uint8Array> {
|
||||
// Простая реализация без зависимости от node:stream/web.
|
||||
let sent = false;
|
||||
return {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (sent) return { value: undefined, done: true };
|
||||
sent = true;
|
||||
return { value: new Uint8Array(buf), done: false };
|
||||
},
|
||||
async cancel() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as ReadableStream<Uint8Array>;
|
||||
}
|
||||
|
||||
// suppress unused-import warning for Readable in case parser flags
|
||||
void Readable;
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Универсальное файловое хранилище контура кооператива.
|
||||
* Реализация — адаптер `MinioFileStorageAdapter` поверх MinIO/S3 SDK; токен `INTER_FILE_STORAGE`
|
||||
* регистрируется в composition root контроллера. Расширения декларируют свои бакеты декоратором
|
||||
* `@UseBucket(spec)` и получают типизированный `InterFileStorageBucket` через DI.
|
||||
*
|
||||
* Per-bucket изоляция: каждый бакет принадлежит одному расширению, физический путь объектов —
|
||||
* `<extension>-<purpose>/<caller-defined-key>` внутри одного MinIO/S3-бакета на кооператив.
|
||||
*
|
||||
* Подробнее — req «SPEC: файловое хранилище v1».
|
||||
*/
|
||||
|
||||
/** Тело загрузки. ReadableStream — для больших файлов через graphql-upload, Uint8Array — для мелочей. */
|
||||
export type InterFileStorageBody = ReadableStream<Uint8Array> | Uint8Array;
|
||||
|
||||
/**
|
||||
* Декларация бакета. Передаётся в `@UseBucket` и далее в `getBucket` порта.
|
||||
* Поля валидации (`maxBytes`, `allowedMime`, `metadataSchema`) проверяются адаптером на каждом `put`.
|
||||
*/
|
||||
export interface InterFileStorageBucketSpec {
|
||||
/** Логическое имя в формате `<extension>:<purpose>`. */
|
||||
name: string;
|
||||
/** Жёсткий лимит размера на один put (байт). */
|
||||
maxBytes: number;
|
||||
/** Allowlist MIME-типов. Адаптер сравнивает с `InterFileStoragePutOptions.contentType`. */
|
||||
allowedMime: readonly string[];
|
||||
/** Схема пользовательских метаданных. Адаптер валидирует на put и возвращает в head. */
|
||||
metadataSchema?: Readonly<Record<string, 'required' | 'optional'>>;
|
||||
/** TTL по умолчанию для `getReadUrl` (сек). Default — на усмотрение адаптера (рекомендуется 600). */
|
||||
defaultUrlTtlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface InterFileStoragePutOptions {
|
||||
/** MIME-тип содержимого. Должен быть в `allowedMime` бакета. */
|
||||
contentType: string;
|
||||
/** Custom user-metadata. Должна соответствовать `metadataSchema` бакета. */
|
||||
metadata?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface InterFileStoragePutResult {
|
||||
key: string;
|
||||
etag: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface InterFileStorageGetReadUrlOptions {
|
||||
/** Перекрывает `defaultUrlTtlSeconds` бакета. */
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface InterFileStorageObjectMetadata {
|
||||
size: number;
|
||||
etag: string;
|
||||
contentType: string;
|
||||
lastModified: Date;
|
||||
metadata: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Хэндл одного зарегистрированного бакета. Расширение получает его через DI и работает только с ним.
|
||||
* Префикс `<extension>-<purpose>` инкапсулирован адаптером — наружу ключи остаются caller-defined.
|
||||
*/
|
||||
export interface InterFileStorageBucket {
|
||||
/**
|
||||
* Загружает объект под caller-defined ключом. Перезапись по тому же ключу — overwrite, как в S3.
|
||||
* @throws InterFileStorageObjectTooLargeError — размер превышает `maxBytes` бакета.
|
||||
* @throws InterFileStorageMimeRejectedError — `contentType` не входит в `allowedMime`.
|
||||
* @throws InterFileStorageMetadataValidationError — отсутствует обязательное поле `metadataSchema`.
|
||||
* @throws InterFileStorageBackendUnavailableError — недоступен MinIO/S3.
|
||||
*/
|
||||
put(
|
||||
key: string,
|
||||
body: InterFileStorageBody,
|
||||
opts: InterFileStoragePutOptions,
|
||||
): Promise<InterFileStoragePutResult>;
|
||||
|
||||
/**
|
||||
* Возвращает короткоживущий URL для скачивания объекта.
|
||||
* В v1 (MinIO) — HMAC-signed URL служебной ручки контроллера; при переходе на нативный S3 —
|
||||
* настоящий S3 presigned URL. Контракт строки не меняется.
|
||||
* ACL принимается доменом ДО вызова: получив URL, любой держатель может скачать до истечения TTL.
|
||||
*/
|
||||
getReadUrl(
|
||||
key: string,
|
||||
opts?: InterFileStorageGetReadUrlOptions,
|
||||
): Promise<string>;
|
||||
|
||||
/**
|
||||
* Идемпотентное удаление: отсутствие объекта — успех (как DELETE в S3).
|
||||
* @throws InterFileStorageBackendUnavailableError — недоступен MinIO/S3.
|
||||
*/
|
||||
delete(key: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Метаданные объекта без скачивания тела.
|
||||
* @throws InterFileStorageObjectNotFoundError — объекта нет.
|
||||
* @throws InterFileStorageBackendUnavailableError — недоступен MinIO/S3.
|
||||
*/
|
||||
head(key: string): Promise<InterFileStorageObjectMetadata>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Корневой порт. Получается через `@Inject(INTER_FILE_STORAGE)` в реестре бакетов контроллера;
|
||||
* прикладной код ходит не в порт напрямую, а в `InterFileStorageBucket` от `@UseBucket`.
|
||||
*/
|
||||
export interface InterFileStoragePort {
|
||||
/**
|
||||
* Возвращает типизированный хэндл для данной спеки.
|
||||
* Синхронно — обеспечение существования физического бакета у бэкенда происходит отдельно
|
||||
* (хук `OnApplicationBootstrap` адаптера, идемпотентно).
|
||||
*
|
||||
* @throws InterFileStorageBucketNotConfiguredError — спека не валидна.
|
||||
*/
|
||||
getBucket(spec: InterFileStorageBucketSpec): InterFileStorageBucket;
|
||||
}
|
||||
|
||||
// ─── Ошибки ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Базовый класс ошибок порта. Доменный код ловит конкретные подклассы. */
|
||||
export class InterFileStorageError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class InterFileStorageObjectNotFoundError extends InterFileStorageError {}
|
||||
export class InterFileStorageObjectTooLargeError extends InterFileStorageError {}
|
||||
export class InterFileStorageMimeRejectedError extends InterFileStorageError {}
|
||||
export class InterFileStorageMetadataValidationError extends InterFileStorageError {}
|
||||
export class InterFileStorageBackendUnavailableError extends InterFileStorageError {}
|
||||
export class InterFileStorageBucketNotConfiguredError extends InterFileStorageError {}
|
||||
@@ -1,11 +1,33 @@
|
||||
export {
|
||||
INTER_CHATCOOP_CALENDAR,
|
||||
INTER_COOP_CALENDAR_EVENT_NOTIFICATION,
|
||||
INTER_FILE_STORAGE,
|
||||
INTER_MATRIX_ROOM_MESSAGING,
|
||||
INTER_PROJECT_CAPITAL_CLEARANCE,
|
||||
INTER_PROJECT_COMMUNICATION_ARTIFACTS,
|
||||
} from './tokens';
|
||||
|
||||
export {
|
||||
InterFileStorageBackendUnavailableError,
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
InterFileStorageError,
|
||||
InterFileStorageMetadataValidationError,
|
||||
InterFileStorageMimeRejectedError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
InterFileStorageObjectTooLargeError,
|
||||
} from './file-storage.port';
|
||||
|
||||
export type {
|
||||
InterFileStorageBody,
|
||||
InterFileStorageBucket,
|
||||
InterFileStorageBucketSpec,
|
||||
InterFileStorageGetReadUrlOptions,
|
||||
InterFileStorageObjectMetadata,
|
||||
InterFileStoragePort,
|
||||
InterFileStoragePutOptions,
|
||||
InterFileStoragePutResult,
|
||||
} from './file-storage.port';
|
||||
|
||||
export type {
|
||||
InterCompletedCallTranscriptionHead,
|
||||
InterProjectCommunicationArtifactsPort,
|
||||
|
||||
@@ -16,3 +16,6 @@ export const INTER_COOP_CALENDAR_EVENT_NOTIFICATION = Symbol.for('InterCoopCalen
|
||||
|
||||
/** Capital: username с подтверждённым допуском к проекту (appendix confirmed). */
|
||||
export const INTER_PROJECT_CAPITAL_CLEARANCE = Symbol.for('InterProjectCapitalClearance');
|
||||
|
||||
/** FileStorage: универсальное файловое хранилище контура кооператива (MinIO в v1, S3 в v2). */
|
||||
export const INTER_FILE_STORAGE = Symbol.for('InterFileStorage');
|
||||
|
||||
@@ -114,7 +114,26 @@ services:
|
||||
- "127.0.0.1:${OPENSEARCH_HOST_PORT:-9200}:9200"
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
ports:
|
||||
- "127.0.0.1:${MINIO_HOST_PORT:-9000}:9000"
|
||||
- "127.0.0.1:${MINIO_CONSOLE_HOST_PORT:-9001}:9001"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
postgres_data:
|
||||
opensearch_data:
|
||||
minio_data:
|
||||
|
||||
Generated
+670
-1
@@ -259,6 +259,9 @@ importers:
|
||||
'@a2seven/yoo-checkout':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1035.0
|
||||
version: 3.1046.0
|
||||
'@coopenomics/factory':
|
||||
specifier: workspace:*
|
||||
version: link:../factory
|
||||
@@ -620,6 +623,9 @@ importers:
|
||||
'@graphql-codegen/typescript-operations':
|
||||
specifier: ^4.3.1
|
||||
version: 4.6.1(graphql@16.13.2)
|
||||
'@nestjs/testing':
|
||||
specifier: ^11.1.19
|
||||
version: 11.1.21(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)
|
||||
'@redocly/cli':
|
||||
specifier: ^1.18.0
|
||||
version: 1.34.11(ajv@8.18.0)(encoding@0.1.13)
|
||||
@@ -1768,6 +1774,161 @@ packages:
|
||||
peerDependencies:
|
||||
graphql: '*'
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@aws-crypto/crc32c@5.2.0':
|
||||
resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==}
|
||||
|
||||
'@aws-crypto/sha1-browser@5.2.0':
|
||||
resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==}
|
||||
|
||||
'@aws-crypto/sha256-browser@5.2.0':
|
||||
resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==}
|
||||
|
||||
'@aws-crypto/sha256-js@5.2.0':
|
||||
resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@aws-crypto/supports-web-crypto@5.2.0':
|
||||
resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==}
|
||||
|
||||
'@aws-crypto/util@5.2.0':
|
||||
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
|
||||
|
||||
'@aws-sdk/client-s3@3.1046.0':
|
||||
resolution: {integrity: sha512-Cwl2SPm0CLczqNLEW2AZR9G/W+Jap85K7uUVOTRHzG3pErVeYRKOnbzsbAqGHnSYBdaxZ9a58YVaWj67P8tF4w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/core@3.974.9':
|
||||
resolution: {integrity: sha512-bXxosFunr+v/kqNb99r1NRkrVBha7CG036fRSpWGbC1A/e363XFQN6wcZMx7MYTdRr1tYwNnkrWX2xc1rT3BCQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/crc64-nvme@3.972.8':
|
||||
resolution: {integrity: sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.35':
|
||||
resolution: {integrity: sha512-WkFQ8BedszVomhh/Zzs8WwnE/XBmTqZjoQVB8u/4zH6kZCjouXZpPpb93gD8m0EZmzAl7dxHE/y+yDpuKzNCMw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.37':
|
||||
resolution: {integrity: sha512-ylx0ZJTU+2eNcvXQ69VNR3TVSYa/ibpvdK717/NxqR9aXRMn2QRWZaiI8aa5yY/fOWZ5mknSmxGaVxxtdwv3EA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.972.39':
|
||||
resolution: {integrity: sha512-QhRSrdkk+Gq0AFIylpiI0N6lcJqFYV9Jtr4Luz5FpYOYbjJSfyTG6iLhnK/UPIgN1Jnon8WAmSC//16XYGvwkA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.39':
|
||||
resolution: {integrity: sha512-1hU0NtC04QbFIuoBuF4aQ2A97GsSE5/A0ZJpDijwexsBREIQ4KPRYl3v/FfKCPBYsaTeGjkOFx5nLhWHY24LOw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.40':
|
||||
resolution: {integrity: sha512-ZgrQaGkpyTlVSCCsffzijVg+KgftTAWYvI5Otc36J/4jNiHb+7MmBiJIR0a5AHLvifC92PiYHt5pijP0dswd1w==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.35':
|
||||
resolution: {integrity: sha512-hNj1rAwZWT1vfz54BwH8FUWxZuqStrM25Q5LEIwn2erHPMRVAjLlpZqEbCEEqS99eEEOhdeetnS0WeNa3iYeEQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.972.39':
|
||||
resolution: {integrity: sha512-mwIPNPldyCZkvHozb6E0X/vuQLN1UCjcA6MwUf1gaO7EwghCmuNZXatq0L3zptKFvPC4Nds7+WFUkifI1XmbSw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.39':
|
||||
resolution: {integrity: sha512-b9HT8CnpyPVn1hU14Q7ihjwSPlRzToYmRYJxRd5jNHEZ43lrIhoLaTT8JmfQQt5j5M8rTX1iN1X8mvu0SM1dXA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.972.11':
|
||||
resolution: {integrity: sha512-AhVDn+qObNacklqmBABnFa3YfVk08CzksuuecL/x+lo95dZxXuAkqJZLUsAEKQ3EiDd5E9wTUBjh0cSogmKMYA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-expect-continue@3.972.11':
|
||||
resolution: {integrity: sha512-xpobcctR1AHSrvkiArgTyLffn78Lt9unPMpa/yic9RKn+bOf/5M55UIM6RaPL5xKzI06/GSsTDywTWvzEAbyyw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-flexible-checksums@3.974.17':
|
||||
resolution: {integrity: sha512-Js24a6sdH9SU5DI5++nlQJayCuOweiiTjnCcAsY75/JtaXF+xysDQ6nRBYx6pUPNY22viRYmdDTFZDaA9AF46g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-host-header@3.972.11':
|
||||
resolution: {integrity: sha512-CBC6+tVYaOJo7QXgN1zJ4Ba2f3/Cpy4eRViYFimXW/O5Mn8hBmgXXzHu4vy4ubT80YWnp8aCFygr7dTOa14yQg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-location-constraint@3.972.10':
|
||||
resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-logger@3.972.10':
|
||||
resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-recursion-detection@3.972.12':
|
||||
resolution: {integrity: sha512-5eltYxKB4MfdQv7/VhWxRbAVQKow5dz9votRFigTYrWJHMQXwLMltlbk7KFWSZh5NDBySfmjT7Jv/DWfYCmDng==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.38':
|
||||
resolution: {integrity: sha512-Yuv3urkJtd1/b3kIURzHwihc1SV6n1t+uiXffOD2OpylZ7+4/QnO2W73yhLZzK1Z762BaqwQ3IVRqAHWzNbQ4A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-ssec@3.972.10':
|
||||
resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/middleware-user-agent@3.972.39':
|
||||
resolution: {integrity: sha512-MlNSvNsSVlMKKWaCzA0GP1nS4Cuq3WCXUN1vmMvd+Ctztib5kmRcpmTtKx9kikN8szAc+gcdp7uqJJervV2nQg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.7':
|
||||
resolution: {integrity: sha512-jT2AXOODobQfTYGC2SChMSnZ/voIcRV/LHlY1suyhY1bdgP/voKkhEg8Ci1jiGQ4lBiaso5BEAV3ZWWpPTfmYA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/region-config-resolver@3.972.14':
|
||||
resolution: {integrity: sha512-VuLXVmm7+lKVxqFcOItPkXhjbJ02iUfxkxheRu41SfWf6/xrZup2A2SwHZos/LeQGu3SBHeqTQht80Uo3ienPA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.26':
|
||||
resolution: {integrity: sha512-2N62veqdMZBCwQUHsbhtnaovOFjOa5Dn3dAD1nRqFTUXR4QmirT3HZnfus/L1DS08Vm5CkoKmL0iMVt6YbqEag==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.1046.0':
|
||||
resolution: {integrity: sha512-9je8nZt+ntB8IjhpGNayU/AkBgvq/f4aFO2bH1LSNC5JX6K8zY4LUnr/ymqunePrwq+B5OVBpL7ILjYzMFSZAw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/types@3.973.8':
|
||||
resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/util-endpoints@3.996.9':
|
||||
resolution: {integrity: sha512-ibx8Vd73rCTHekNGeXX8cpGWoBKbNAlwKHL3yjSxxttu5QnNDaSAM7/0MFYDjU31/F4lyrPoQcGirT0ew61xcg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/util-locate-window@3.965.5':
|
||||
resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/util-user-agent-browser@3.972.11':
|
||||
resolution: {integrity: sha512-kq3RS6XQtHMrLFShbkem6h+8fxazB3jEIsbMC6aaSInOciRGE+eGAqTgJ+obO7Euo/pjM8thVqLiLISEH9X9DA==}
|
||||
|
||||
'@aws-sdk/util-user-agent-node@3.973.25':
|
||||
resolution: {integrity: sha512-066hKH/0nvV7x4ofV/iK9kz8r/qNfcR6rzuEOFqI2vQL/fcTTsDAbTw0jmXkyMzANK8ltQdALj19ns3zuOJiUw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
aws-crt: '>=1.0.0'
|
||||
peerDependenciesMeta:
|
||||
aws-crt:
|
||||
optional: true
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.23':
|
||||
resolution: {integrity: sha512-A0YmgYFv+hTI9c17Ntvd2hSehm9bmJfkb+ggADBwVKA8H/3+Jx94SzR2qOB9bAA9WFeDqnfz9PKKQ+D+YAKomA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws/lambda-invoke-store@0.2.4':
|
||||
resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@babel/code-frame@7.12.11':
|
||||
resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==}
|
||||
|
||||
@@ -5192,6 +5353,19 @@ packages:
|
||||
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||
|
||||
'@nestjs/testing@11.1.21':
|
||||
resolution: {integrity: sha512-RhzaUFxr6/bpXWjKIzr7p2eHKMFMLwPgsxJNFcCf2CkkT3UEjW+KRGb7E2JY+fh+ck3zAdvQJrzATDnSsVlFZw==}
|
||||
peerDependencies:
|
||||
'@nestjs/common': ^11.0.0
|
||||
'@nestjs/core': ^11.0.0
|
||||
'@nestjs/microservices': ^11.0.0
|
||||
'@nestjs/platform-express': ^11.0.0
|
||||
peerDependenciesMeta:
|
||||
'@nestjs/microservices':
|
||||
optional: true
|
||||
'@nestjs/platform-express':
|
||||
optional: true
|
||||
|
||||
'@nestjs/throttler@6.5.0':
|
||||
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
|
||||
peerDependencies:
|
||||
@@ -5216,6 +5390,9 @@ packages:
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@nodable/entities@2.1.0':
|
||||
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -6587,6 +6764,42 @@ packages:
|
||||
'@sinonjs/fake-timers@10.3.0':
|
||||
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
|
||||
|
||||
'@smithy/core@3.24.2':
|
||||
resolution: {integrity: sha512-IKS7qX59fAGCYBmt5JChcDswQDupZqT2Yn2ZBA3UgTlsjRNNkQzZobbn95xoAAdtTyJmBiJB3Y02qR3rgy3Zog==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/credential-provider-imds@4.3.2':
|
||||
resolution: {integrity: sha512-iYr9ekBjmZ+FwkiHEopqGscBbl78X62cq3p5Dd0eC+gNd7fybNZFQQdDuOQjTVmFymleuA8YRWZnuXWZ8B3kKA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/fetch-http-handler@5.4.2':
|
||||
resolution: {integrity: sha512-3wF40g8OOCA5BnwQUvwtzZqYBbWWftDjpAlWIUo6Yld3ZzJaMAKqg7MWQBPjE8oLaqvZQUE7tVGlZPsae6A4bQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/is-array-buffer@2.2.0':
|
||||
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/node-http-handler@4.7.2':
|
||||
resolution: {integrity: sha512-EdksTZ8UXYxGUgQ4mpIKrHoaj9WVGsp66TpZuixLAz1Jex8YDLnS4RH9ktGED5aOpN0OJlEtrsC9IGt76go1eA==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/signature-v4@5.4.2':
|
||||
resolution: {integrity: sha512-1km1OjdLRFuITWpCPofjFqzZ+tbeWuB72ZhcYjbjkCxZ21tTPfIs4GUxRrelMyKMLxLghGD58RENnXorU/O8cw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/types@4.14.1':
|
||||
resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@smithy/util-buffer-from@2.2.0':
|
||||
resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@smithy/util-utf8@2.3.0':
|
||||
resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@so-ric/colorspace@1.1.6':
|
||||
resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==}
|
||||
|
||||
@@ -8484,6 +8697,9 @@ packages:
|
||||
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
bowser@2.14.1:
|
||||
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||
|
||||
bpmn-js@18.13.2:
|
||||
resolution: {integrity: sha512-OSsCknc5J7lnLFZxyK9hET4dxCV+k44mvI3QmbWkgbmBwPsNgV9CzI6FS8aIV5uea4rgBGS+Xscs0+wuDZQKNA==}
|
||||
|
||||
@@ -10962,10 +11178,17 @@ packages:
|
||||
fast-xml-builder@1.1.4:
|
||||
resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==}
|
||||
|
||||
fast-xml-builder@1.2.0:
|
||||
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
|
||||
|
||||
fast-xml-parser@5.5.9:
|
||||
resolution: {integrity: sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==}
|
||||
hasBin: true
|
||||
|
||||
fast-xml-parser@5.7.2:
|
||||
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
|
||||
hasBin: true
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
@@ -14787,6 +15010,10 @@ packages:
|
||||
resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
path-expression-matcher@1.5.0:
|
||||
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
path-intersection@4.1.0:
|
||||
resolution: {integrity: sha512-urUP6WvhnxbHPdHYl6L7Yrc6+1ny6uOFKPCzPxTSUSYGHG0o94RmI7SvMMaScNAM5RtTf08bg4skc6/kjfne3A==}
|
||||
engines: {node: '>= 14.20'}
|
||||
@@ -16947,6 +17174,9 @@ packages:
|
||||
strnum@2.2.2:
|
||||
resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==}
|
||||
|
||||
strnum@2.3.0:
|
||||
resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==}
|
||||
|
||||
strtok3@10.3.5:
|
||||
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -17919,6 +18149,7 @@ packages:
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@11.0.3:
|
||||
@@ -17931,15 +18162,17 @@ packages:
|
||||
|
||||
uuid@3.4.0:
|
||||
resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==}
|
||||
deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@9.0.1:
|
||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
v8-compile-cache-lib@3.0.1:
|
||||
@@ -18704,6 +18937,10 @@ packages:
|
||||
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
xml-naming@0.1.0:
|
||||
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
xml-utils@1.10.2:
|
||||
resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==}
|
||||
|
||||
@@ -19395,6 +19632,360 @@ snapshots:
|
||||
immutable: 5.1.5
|
||||
invariant: 2.2.4
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/crc32c@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/sha1-browser@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/supports-web-crypto': 5.2.0
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@aws-sdk/util-locate-window': 3.965.5
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/sha256-browser@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-crypto/supports-web-crypto': 5.2.0
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@aws-sdk/util-locate-window': 3.965.5
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/sha256-js@5.2.0':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/supports-web-crypto@5.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-crypto/util@5.2.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-s3@3.1046.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha1-browser': 5.2.0
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/credential-provider-node': 3.972.40
|
||||
'@aws-sdk/middleware-bucket-endpoint': 3.972.11
|
||||
'@aws-sdk/middleware-expect-continue': 3.972.11
|
||||
'@aws-sdk/middleware-flexible-checksums': 3.974.17
|
||||
'@aws-sdk/middleware-host-header': 3.972.11
|
||||
'@aws-sdk/middleware-location-constraint': 3.972.10
|
||||
'@aws-sdk/middleware-logger': 3.972.10
|
||||
'@aws-sdk/middleware-recursion-detection': 3.972.12
|
||||
'@aws-sdk/middleware-sdk-s3': 3.972.38
|
||||
'@aws-sdk/middleware-ssec': 3.972.10
|
||||
'@aws-sdk/middleware-user-agent': 3.972.39
|
||||
'@aws-sdk/region-config-resolver': 3.972.14
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.26
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@aws-sdk/util-endpoints': 3.996.9
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.11
|
||||
'@aws-sdk/util-user-agent-node': 3.973.25
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/fetch-http-handler': 5.4.2
|
||||
'@smithy/node-http-handler': 4.7.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/core@3.974.9':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@aws-sdk/xml-builder': 3.972.23
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/signature-v4': 5.4.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/crc64-nvme@3.972.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-env@3.972.35':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-http@3.972.37':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/fetch-http-handler': 5.4.2
|
||||
'@smithy/node-http-handler': 4.7.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-ini@3.972.39':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/credential-provider-env': 3.972.35
|
||||
'@aws-sdk/credential-provider-http': 3.972.37
|
||||
'@aws-sdk/credential-provider-login': 3.972.39
|
||||
'@aws-sdk/credential-provider-process': 3.972.35
|
||||
'@aws-sdk/credential-provider-sso': 3.972.39
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.39
|
||||
'@aws-sdk/nested-clients': 3.997.7
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/credential-provider-imds': 4.3.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/credential-provider-login@3.972.39':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/nested-clients': 3.997.7
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/credential-provider-node@3.972.40':
|
||||
dependencies:
|
||||
'@aws-sdk/credential-provider-env': 3.972.35
|
||||
'@aws-sdk/credential-provider-http': 3.972.37
|
||||
'@aws-sdk/credential-provider-ini': 3.972.39
|
||||
'@aws-sdk/credential-provider-process': 3.972.35
|
||||
'@aws-sdk/credential-provider-sso': 3.972.39
|
||||
'@aws-sdk/credential-provider-web-identity': 3.972.39
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/credential-provider-imds': 4.3.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/credential-provider-process@3.972.35':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/credential-provider-sso@3.972.39':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/nested-clients': 3.997.7
|
||||
'@aws-sdk/token-providers': 3.1046.0
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/credential-provider-web-identity@3.972.39':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/nested-clients': 3.997.7
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/middleware-bucket-endpoint@3.972.11':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-expect-continue@3.972.11':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-flexible-checksums@3.974.17':
|
||||
dependencies:
|
||||
'@aws-crypto/crc32': 5.2.0
|
||||
'@aws-crypto/crc32c': 5.2.0
|
||||
'@aws-crypto/util': 5.2.0
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/crc64-nvme': 3.972.8
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-host-header@3.972.11':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-location-constraint@3.972.10':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-logger@3.972.10':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-recursion-detection@3.972.12':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@aws/lambda-invoke-store': 0.2.4
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-sdk-s3@3.972.38':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.26
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/signature-v4': 5.4.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-ssec@3.972.10':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/middleware-user-agent@3.972.39':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@aws-sdk/util-endpoints': 3.996.9
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/nested-clients@3.997.7':
|
||||
dependencies:
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
'@aws-crypto/sha256-js': 5.2.0
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/middleware-host-header': 3.972.11
|
||||
'@aws-sdk/middleware-logger': 3.972.10
|
||||
'@aws-sdk/middleware-recursion-detection': 3.972.12
|
||||
'@aws-sdk/middleware-user-agent': 3.972.39
|
||||
'@aws-sdk/region-config-resolver': 3.972.14
|
||||
'@aws-sdk/signature-v4-multi-region': 3.996.26
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@aws-sdk/util-endpoints': 3.996.9
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.11
|
||||
'@aws-sdk/util-user-agent-node': 3.973.25
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/fetch-http-handler': 5.4.2
|
||||
'@smithy/node-http-handler': 4.7.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/region-config-resolver@3.972.14':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.996.26':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/signature-v4': 5.4.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/token-providers@3.1046.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.974.9
|
||||
'@aws-sdk/nested-clients': 3.997.7
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
transitivePeerDependencies:
|
||||
- aws-crt
|
||||
|
||||
'@aws-sdk/types@3.973.8':
|
||||
dependencies:
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-endpoints@3.996.9':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-locate-window@3.965.5':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-user-agent-browser@3.972.11':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/types': 4.14.1
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-user-agent-node@3.973.25':
|
||||
dependencies:
|
||||
'@aws-sdk/middleware-user-agent': 3.972.39
|
||||
'@aws-sdk/types': 3.973.8
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/xml-builder@3.972.23':
|
||||
dependencies:
|
||||
'@nodable/entities': 2.1.0
|
||||
'@smithy/types': 4.14.1
|
||||
fast-xml-parser: 5.7.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws/lambda-invoke-store@0.2.4': {}
|
||||
|
||||
'@babel/code-frame@7.12.11':
|
||||
dependencies:
|
||||
'@babel/highlight': 7.25.9
|
||||
@@ -23580,6 +24171,14 @@ snapshots:
|
||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
cron: 4.4.0
|
||||
|
||||
'@nestjs/testing@11.1.21(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)
|
||||
|
||||
'@nestjs/throttler@6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.1.14)':
|
||||
dependencies:
|
||||
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
@@ -23599,6 +24198,8 @@ snapshots:
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodable/entities@2.1.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -25267,6 +25868,54 @@ snapshots:
|
||||
dependencies:
|
||||
'@sinonjs/commons': 3.0.1
|
||||
|
||||
'@smithy/core@3.24.2':
|
||||
dependencies:
|
||||
'@aws-crypto/crc32': 5.2.0
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/credential-provider-imds@4.3.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/fetch-http-handler@5.4.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/is-array-buffer@2.2.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/node-http-handler@4.7.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/signature-v4@5.4.2':
|
||||
dependencies:
|
||||
'@smithy/core': 3.24.2
|
||||
'@smithy/types': 4.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/types@4.14.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-buffer-from@2.2.0':
|
||||
dependencies:
|
||||
'@smithy/is-array-buffer': 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@smithy/util-utf8@2.3.0':
|
||||
dependencies:
|
||||
'@smithy/util-buffer-from': 2.2.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@so-ric/colorspace@1.1.6':
|
||||
dependencies:
|
||||
color: 5.0.3
|
||||
@@ -27817,6 +28466,8 @@ snapshots:
|
||||
boolean@3.2.0:
|
||||
optional: true
|
||||
|
||||
bowser@2.14.1: {}
|
||||
|
||||
bpmn-js@18.13.2:
|
||||
dependencies:
|
||||
bpmn-moddle: 10.0.0
|
||||
@@ -31469,12 +32120,24 @@ snapshots:
|
||||
dependencies:
|
||||
path-expression-matcher: 1.2.0
|
||||
|
||||
fast-xml-builder@1.2.0:
|
||||
dependencies:
|
||||
path-expression-matcher: 1.5.0
|
||||
xml-naming: 0.1.0
|
||||
|
||||
fast-xml-parser@5.5.9:
|
||||
dependencies:
|
||||
fast-xml-builder: 1.1.4
|
||||
path-expression-matcher: 1.2.0
|
||||
strnum: 2.2.2
|
||||
|
||||
fast-xml-parser@5.7.2:
|
||||
dependencies:
|
||||
'@nodable/entities': 2.1.0
|
||||
fast-xml-builder: 1.2.0
|
||||
path-expression-matcher: 1.5.0
|
||||
strnum: 2.3.0
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -36218,6 +36881,8 @@ snapshots:
|
||||
|
||||
path-expression-matcher@1.2.0: {}
|
||||
|
||||
path-expression-matcher@1.5.0: {}
|
||||
|
||||
path-intersection@4.1.0: {}
|
||||
|
||||
path-is-absolute@1.0.1: {}
|
||||
@@ -38803,6 +39468,8 @@ snapshots:
|
||||
|
||||
strnum@2.2.2: {}
|
||||
|
||||
strnum@2.3.0: {}
|
||||
|
||||
strtok3@10.3.5:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
@@ -40726,6 +41393,8 @@ snapshots:
|
||||
|
||||
xml-name-validator@4.0.0: {}
|
||||
|
||||
xml-naming@0.1.0: {}
|
||||
|
||||
xml-utils@1.10.2: {}
|
||||
|
||||
xmlbuilder2@4.0.3:
|
||||
|
||||
Reference in New Issue
Block a user