mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
feat(storage): add deleteByPrefix, copy, getMetadata and prod lifecycle rules
- Add deleteByPrefix(prefix) for bulk user data deletion (account cleanup) - Add copy(sourceKey, destKey) via CopyObjectCommand for file duplication - Add getMetadata(key) via HeadObjectCommand for content-type/size/metadata - Add FileMetadata type for structured metadata responses - Add minio-init container to docker-compose.macmini.yml with bucket creation, public access policies, and lifecycle rules (matching dev compose) - 96 tests passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b0e5a9c5ff
commit
152fa5fe08
5 changed files with 191 additions and 0 deletions
|
|
@ -69,6 +69,40 @@ services:
|
|||
timeout: 20s
|
||||
retries: 3
|
||||
|
||||
# MinIO bucket initialization and lifecycle rules (runs once)
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
container_name: mana-infra-minio-init
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set myminio http://minio:9000 $${MINIO_ACCESS_KEY:-minioadmin} $${MINIO_SECRET_KEY:-minioadmin};
|
||||
mc mb --ignore-existing myminio/manacore-storage;
|
||||
mc mb --ignore-existing myminio/picture-storage;
|
||||
mc mb --ignore-existing myminio/chat-storage;
|
||||
mc mb --ignore-existing myminio/manadeck-storage;
|
||||
mc mb --ignore-existing myminio/presi-storage;
|
||||
mc mb --ignore-existing myminio/calendar-storage;
|
||||
mc mb --ignore-existing myminio/contacts-storage;
|
||||
mc mb --ignore-existing myminio/storage-storage;
|
||||
mc mb --ignore-existing myminio/inventory-storage;
|
||||
mc mb --ignore-existing myminio/mukke-storage;
|
||||
mc mb --ignore-existing myminio/planta-storage;
|
||||
mc mb --ignore-existing myminio/projectdoc-storage;
|
||||
mc mb --ignore-existing myminio/mail-storage;
|
||||
mc anonymous set download myminio/manacore-storage;
|
||||
mc anonymous set download myminio/picture-storage;
|
||||
mc anonymous set download myminio/planta-storage;
|
||||
mc anonymous set download myminio/inventory-storage;
|
||||
mc ilm rule add --expire-days 90 myminio/chat-storage --prefix 'tmp/' 2>/dev/null || true;
|
||||
mc ilm rule add --expire-days 30 myminio/calendar-storage --prefix 'tmp/' 2>/dev/null || true;
|
||||
mc ilm rule add --expire-days 7 myminio/picture-storage --prefix 'tmp/' 2>/dev/null || true;
|
||||
echo 'Buckets and lifecycle rules created successfully';
|
||||
exit 0;
|
||||
"
|
||||
|
||||
# ============================================
|
||||
# Tier 1: Core Auth Service (Port 3001)
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ vi.mock('@aws-sdk/client-s3', () => ({
|
|||
HeadObjectCommand: vi.fn(function (this: any, input: any) {
|
||||
Object.assign(this, input);
|
||||
}),
|
||||
CopyObjectCommand: vi.fn(function (this: any, input: any) {
|
||||
Object.assign(this, input);
|
||||
}),
|
||||
CreateMultipartUploadCommand: vi.fn(function (this: any, input: any) {
|
||||
Object.assign(this, input);
|
||||
}),
|
||||
|
|
@ -418,6 +421,88 @@ describe('StorageClient', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('deleteByPrefix', () => {
|
||||
it('lists and deletes all files with prefix', async () => {
|
||||
mockSend
|
||||
.mockResolvedValueOnce({
|
||||
Contents: [
|
||||
{ Key: 'users/123/a.png', Size: 100, LastModified: new Date() },
|
||||
{ Key: 'users/123/b.png', Size: 200, LastModified: new Date() },
|
||||
],
|
||||
IsTruncated: false,
|
||||
})
|
||||
.mockResolvedValue({}); // deleteMany
|
||||
|
||||
const count = await storage.deleteByPrefix('users/123/');
|
||||
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 when prefix has no files', async () => {
|
||||
mockSend.mockResolvedValue({ Contents: undefined, IsTruncated: false });
|
||||
|
||||
const count = await storage.deleteByPrefix('users/nonexistent/');
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(mockSend).toHaveBeenCalledTimes(1); // only list, no delete
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy', () => {
|
||||
it('copies a file and returns new key', async () => {
|
||||
mockSend.mockResolvedValue({ CopyObjectResult: { ETag: '"copied"' } });
|
||||
|
||||
const result = await storage.copy('old/file.png', 'new/file.png');
|
||||
|
||||
expect(result.key).toBe('new/file.png');
|
||||
expect(result.etag).toBe('"copied"');
|
||||
expect(result.url).toBe('http://localhost:9000/test-bucket/new/file.png');
|
||||
});
|
||||
|
||||
it('sends CopyObjectCommand with correct source', async () => {
|
||||
mockSend.mockResolvedValue({ CopyObjectResult: {} });
|
||||
const { CopyObjectCommand } = await import('@aws-sdk/client-s3');
|
||||
|
||||
await storage.copy('src.png', 'dst.png');
|
||||
|
||||
expect(CopyObjectCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
Bucket: 'test-bucket',
|
||||
CopySource: 'test-bucket/src.png',
|
||||
Key: 'dst.png',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetadata', () => {
|
||||
it('returns file metadata', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
ContentType: 'image/png',
|
||||
ContentLength: 4096,
|
||||
LastModified: new Date('2024-06-15'),
|
||||
ETag: '"meta-etag"',
|
||||
Metadata: { author: 'test' },
|
||||
});
|
||||
|
||||
const meta = await storage.getMetadata('file.png');
|
||||
|
||||
expect(meta.contentType).toBe('image/png');
|
||||
expect(meta.size).toBe(4096);
|
||||
expect(meta.etag).toBe('"meta-etag"');
|
||||
expect(meta.metadata).toEqual({ author: 'test' });
|
||||
});
|
||||
|
||||
it('handles missing optional fields', async () => {
|
||||
mockSend.mockResolvedValue({});
|
||||
|
||||
const meta = await storage.getMetadata('file.png');
|
||||
|
||||
expect(meta.size).toBe(0);
|
||||
expect(meta.contentType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('presigned multipart upload', () => {
|
||||
it('createMultipartUpload returns upload ID', async () => {
|
||||
mockSend.mockResolvedValue({ UploadId: 'mp-123' });
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
HeadObjectCommand,
|
||||
CopyObjectCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
|
|
@ -24,6 +25,7 @@ import type {
|
|||
MultipartUploadPart,
|
||||
UploadResult,
|
||||
FileInfo,
|
||||
FileMetadata,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
|
|
@ -370,6 +372,59 @@ export class StorageClient {
|
|||
return this.bucket.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all files matching a prefix.
|
||||
* Useful for account deletion (e.g., deleteByPrefix('users/user-123/'))
|
||||
*/
|
||||
async deleteByPrefix(prefix: string): Promise<number> {
|
||||
const files = await this.list(prefix);
|
||||
if (files.length === 0) return 0;
|
||||
|
||||
const keys = files.map((f) => f.key);
|
||||
await this.deleteMany(keys);
|
||||
return keys.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file within the same bucket.
|
||||
* For move operations, call copy() then delete() the source.
|
||||
*/
|
||||
async copy(sourceKey: string, destKey: string): Promise<UploadResult> {
|
||||
const command = new CopyObjectCommand({
|
||||
Bucket: this.bucket.name,
|
||||
CopySource: `${this.bucket.name}/${sourceKey}`,
|
||||
Key: destKey,
|
||||
});
|
||||
|
||||
const result = await this.client.send(command);
|
||||
|
||||
return {
|
||||
key: destKey,
|
||||
url: this.getPublicUrl(destKey),
|
||||
etag: result.CopyObjectResult?.ETag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata without downloading the file.
|
||||
*/
|
||||
async getMetadata(key: string): Promise<FileMetadata> {
|
||||
const command = new HeadObjectCommand({
|
||||
Bucket: this.bucket.name,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const response = await this.client.send(command);
|
||||
|
||||
return {
|
||||
contentType: response.ContentType,
|
||||
size: response.ContentLength ?? 0,
|
||||
lastModified: response.LastModified,
|
||||
etag: response.ETag,
|
||||
metadata: response.Metadata,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Presigned Multipart Upload (browser direct-upload) ──────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -62,4 +62,5 @@ export {
|
|||
type MultipartUploadPart,
|
||||
type UploadResult,
|
||||
type FileInfo,
|
||||
type FileMetadata,
|
||||
} from './types';
|
||||
|
|
|
|||
|
|
@ -86,6 +86,22 @@ export interface FileInfo {
|
|||
etag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for a stored file (from HeadObject)
|
||||
*/
|
||||
export interface FileMetadata {
|
||||
/** Content type (MIME type) */
|
||||
contentType?: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** Last modified date */
|
||||
lastModified?: Date;
|
||||
/** ETag */
|
||||
etag?: string;
|
||||
/** Custom metadata */
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart upload initialization result
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue