feat: 드라이브(파일 저장소/폴더 관리) 기능 추가
NCP Object Storage(S3 호환) 기반 파일 업로드/폴더 관리 기능을 백엔드에 추가한다. - 스토리지 계층(common/storage): presign 기반 업로드/다운로드 URL 발급 래퍼 - 드라이브 모듈: 폴더 CRUD, 파일 presign 업로드→complete→다운로드, 이름변경/삭제 - 엔티티 3종: drive_folders(폴더 트리), drive_files(객체 메타), project_folder_links(프로젝트 공유) - 마이그레이션 AddDriveTables: 3개 테이블 + 인덱스/외래키 생성 - stale pending 파일 정리 @Cron(매시) 추가 - env/docker-compose: OBJECT_STORAGE_* 주입(비시크릿은 루트, ACCESS/SECRET 키는 backend env) - 의존성: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,3 +68,15 @@ LIVEKIT_API_KEY=APIxxxxxxxxxxxx
|
||||
LIVEKIT_API_SECRET=your-livekit-cloud-api-secret
|
||||
LIVEKIT_WS_URL=wss://your-project.livekit.cloud
|
||||
LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage / S3 호환)
|
||||
# ==========================================
|
||||
# 파일 업로드/저장용 오브젝트 스토리지. NCP 콘솔에서 버킷 생성 후 값을 채운다.
|
||||
# 비시크릿(endpoint/region/bucket)만 루트에 두고, ACCESS/SECRET 키는 backend/.env 에 둔다.
|
||||
# OBJECT_STORAGE_ENDPOINT : NCP 리전 엔드포인트(서울: https://kr.object.ncloudstorage.com)
|
||||
# OBJECT_STORAGE_REGION : 리전명(NCP: kr-standard)
|
||||
# OBJECT_STORAGE_BUCKET : 버킷명(환경별로 분리 권장, 예: comrelay-dev)
|
||||
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
|
||||
OBJECT_STORAGE_REGION=kr-standard
|
||||
OBJECT_STORAGE_BUCKET=comrelay-dev
|
||||
|
||||
@@ -71,3 +71,15 @@ LIVEKIT_API_KEY=change-me-livekit-key
|
||||
LIVEKIT_API_SECRET=change-me-livekit-secret-min-32-characters
|
||||
LIVEKIT_WS_URL=wss://livekit.example.com
|
||||
LIVEKIT_URL=http://livekit:7880
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage / S3 호환)
|
||||
# ==========================================
|
||||
# 운영 버킷은 개발과 분리한다(예: comrelay-prod). 비시크릿만 루트에 두고
|
||||
# ACCESS/SECRET 키는 backend/.env.production 에 둔다.
|
||||
# OBJECT_STORAGE_ENDPOINT : NCP 리전 엔드포인트(서울: https://kr.object.ncloudstorage.com)
|
||||
# OBJECT_STORAGE_REGION : 리전명(NCP: kr-standard)
|
||||
# OBJECT_STORAGE_BUCKET : 운영 버킷명
|
||||
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
|
||||
OBJECT_STORAGE_REGION=kr-standard
|
||||
OBJECT_STORAGE_BUCKET=comrelay-prod
|
||||
|
||||
@@ -42,3 +42,12 @@ GOOGLE_CALLBACK_URL=http://localhost:3100/api/auth/google/callback
|
||||
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
|
||||
CLAUDE_API_KEY=change-me-claude-api-key
|
||||
CLAUDE_MODEL=claude-sonnet-4-6
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage) — 시크릿
|
||||
# ==========================================
|
||||
# NCP 콘솔 → 마이페이지 → 인증키 관리(또는 서브계정)에서 발급한 키.
|
||||
# endpoint/region/bucket 은 루트 .env 에서 docker-compose 가 주입한다.
|
||||
# 미설정 시 드라이브 기능만 비활성화되고 앱은 정상 부팅된다.
|
||||
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
|
||||
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
|
||||
|
||||
@@ -42,3 +42,11 @@ GOOGLE_CALLBACK_URL=https://your-domain.com/api/auth/google/callback
|
||||
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
|
||||
CLAUDE_API_KEY=change-me-claude-api-key
|
||||
CLAUDE_MODEL=claude-sonnet-4-6
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage) — 시크릿
|
||||
# ==========================================
|
||||
# NCP 콘솔에서 발급한 운영용 키. endpoint/region/bucket 은 루트 .env 가 주입.
|
||||
# 운영은 권한 최소화를 위해 해당 버킷에만 접근 가능한 서브계정 키 사용 권장.
|
||||
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
|
||||
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
|
||||
|
||||
Generated
+574
@@ -9,6 +9,8 @@
|
||||
"version": "0.0.1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1075.0",
|
||||
"@keyv/redis": "^5.1.6",
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
@@ -273,6 +275,452 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/crc32": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
|
||||
"integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/util": "^5.2.0",
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/crc32c": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz",
|
||||
"integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/util": "^5.2.0",
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/sha1-browser": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz",
|
||||
"integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/supports-web-crypto": "^5.2.0",
|
||||
"@aws-crypto/util": "^5.2.0",
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"@aws-sdk/util-locate-window": "^3.0.0",
|
||||
"@smithy/util-utf8": "^2.0.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/sha256-browser": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
|
||||
"integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
|
||||
"license": "Apache-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.222.0",
|
||||
"@aws-sdk/util-locate-window": "^3.0.0",
|
||||
"@smithy/util-utf8": "^2.0.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/sha256-js": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
|
||||
"integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/util": "^5.2.0",
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/supports-web-crypto": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
|
||||
"integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/util": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
|
||||
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"@smithy/util-utf8": "^2.0.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/checksums": {
|
||||
"version": "3.1000.8",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.8.tgz",
|
||||
"integrity": "sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/crc32": "5.2.0",
|
||||
"@aws-crypto/crc32c": "5.2.0",
|
||||
"@aws-crypto/util": "5.2.0",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-s3": {
|
||||
"version": "3.1075.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1075.0.tgz",
|
||||
"integrity": "sha512-h1A6nIl1YX6Y45enGsTK7ef3ZrOnBiQJ1qF5R2K/nMWfsu6A9mc2Y5T66nxerABzyjjyyvign3MrzafnFoQKmA==",
|
||||
"license": "Apache-2.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.23",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.58",
|
||||
"@aws-sdk/middleware-flexible-checksums": "^3.974.33",
|
||||
"@aws-sdk/middleware-sdk-s3": "^3.972.54",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/fetch-http-handler": "^5.4.6",
|
||||
"@smithy/node-http-handler": "^4.7.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.974.23",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz",
|
||||
"integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@aws-sdk/xml-builder": "^3.972.31",
|
||||
"@aws/lambda-invoke-store": "^0.2.2",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/signature-v4": "^5.4.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"bowser": "^2.11.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.49",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz",
|
||||
"integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.51",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz",
|
||||
"integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/fetch-http-handler": "^5.4.6",
|
||||
"@smithy/node-http-handler": "^4.7.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.972.56",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz",
|
||||
"integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.49",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.51",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.55",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.49",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.55",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.55",
|
||||
"@aws-sdk/nested-clients": "^3.997.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/credential-provider-imds": "^4.3.7",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.55",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz",
|
||||
"integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/nested-clients": "^3.997.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.58",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz",
|
||||
"integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.49",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.51",
|
||||
"@aws-sdk/credential-provider-ini": "^3.972.56",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.49",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.55",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.55",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/credential-provider-imds": "^4.3.7",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.49",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz",
|
||||
"integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.972.55",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz",
|
||||
"integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/nested-clients": "^3.997.23",
|
||||
"@aws-sdk/token-providers": "3.1074.0",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.55",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz",
|
||||
"integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/nested-clients": "^3.997.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-flexible-checksums": {
|
||||
"version": "3.974.33",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.33.tgz",
|
||||
"integrity": "sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/checksums": "^3.1000.8",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||
"version": "3.972.54",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.54.tgz",
|
||||
"integrity": "sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.23",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz",
|
||||
"integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-browser": "5.2.0",
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/fetch-http-handler": "^5.4.6",
|
||||
"@smithy/node-http-handler": "^4.7.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/s3-request-presigner": {
|
||||
"version": "3.1075.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1075.0.tgz",
|
||||
"integrity": "sha512-++ftTvAGZSTuzFVHEPk8lLi7mybBD8PzJ9USWBvwnE4kSrXOyqYVJ5Ixd06xUEWS/xsrhpkI07mzCLGIxrRymA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.35",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz",
|
||||
"integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/signature-v4": "^5.4.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1074.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz",
|
||||
"integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/nested-clients": "^3.997.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.973.13",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz",
|
||||
"integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/util-locate-window": {
|
||||
"version": "3.965.8",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz",
|
||||
"integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.31",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz",
|
||||
"integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz",
|
||||
"integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -3581,6 +4029,126 @@
|
||||
"@sinonjs/commons": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz",
|
||||
"integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/crc32": "5.2.0",
|
||||
"@smithy/types": "^4.15.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/credential-provider-imds": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz",
|
||||
"integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.26.0",
|
||||
"@smithy/types": "^4.15.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/fetch-http-handler": {
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.2.tgz",
|
||||
"integrity": "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.26.0",
|
||||
"@smithy/types": "^4.15.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/is-array-buffer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
|
||||
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz",
|
||||
"integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.26.0",
|
||||
"@smithy/types": "^4.15.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/signature-v4": {
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.2.tgz",
|
||||
"integrity": "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.26.0",
|
||||
"@smithy/types": "^4.15.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz",
|
||||
"integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-buffer-from": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
|
||||
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/is-array-buffer": "^2.2.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-utf8": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
|
||||
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/util-buffer-from": "^2.2.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@so-ric/colorspace": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
|
||||
@@ -5991,6 +6559,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/boxen": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz",
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d ./src/data-source.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1075.0",
|
||||
"@keyv/redis": "^5.1.6",
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { StorageModule } from './common/storage/storage.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
@@ -20,6 +21,7 @@ import { AiModule } from './modules/ai/ai.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
import { ScheduleModule } from './modules/schedule/schedule.module';
|
||||
import { MeetingModule } from './modules/meeting/meeting.module';
|
||||
import { DriveModule } from './modules/drive/drive.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -92,6 +94,7 @@ import { MeetingModule } from './modules/meeting/meeting.module';
|
||||
};
|
||||
},
|
||||
}),
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
UserModule,
|
||||
AuthModule,
|
||||
@@ -102,6 +105,7 @@ import { MeetingModule } from './modules/meeting/meeting.module';
|
||||
ProjectModule,
|
||||
ScheduleModule,
|
||||
MeetingModule,
|
||||
DriveModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ValueTransformer } from 'typeorm';
|
||||
|
||||
// bigint 컬럼 변환기 — TypeORM 은 bigint 를 문자열로 반환하므로 number 로 복원한다.
|
||||
// (파일 크기 등 Number.MAX_SAFE_INTEGER 이내 값에 사용)
|
||||
export const numericColumnTransformer: ValueTransformer = {
|
||||
// 저장: number → DB(bigint). null/undefined 는 그대로.
|
||||
to(value: number | null | undefined): number | null | undefined {
|
||||
return value;
|
||||
},
|
||||
// 조회: DB 문자열 → number. null 은 그대로.
|
||||
from(value: string | null): number | null {
|
||||
return value === null ? null : Number(value);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { StorageService } from './storage.service';
|
||||
import type {
|
||||
ObjectHead,
|
||||
PresignDownloadInput,
|
||||
PresignUploadInput,
|
||||
PresignedUpload,
|
||||
} from './storage.types';
|
||||
|
||||
// presigned URL 기본 유효 시간(초)
|
||||
const DEFAULT_UPLOAD_EXPIRES = 300; // 업로드: 5분
|
||||
const DEFAULT_DOWNLOAD_EXPIRES = 60; // 다운로드: 1분(단기 노출)
|
||||
|
||||
// NCP Object Storage 구현체 — S3 호환 API 를 그대로 사용한다.
|
||||
// 환경변수(SSOT: 루트 .env → docker-compose 주입 / 시크릿은 backend 자체 env):
|
||||
// OBJECT_STORAGE_ENDPOINT 예) https://kr.object.ncloudstorage.com
|
||||
// OBJECT_STORAGE_REGION 예) kr-standard
|
||||
// OBJECT_STORAGE_BUCKET 버킷명
|
||||
// OBJECT_STORAGE_ACCESS_KEY 서브계정 Access Key (시크릿)
|
||||
// OBJECT_STORAGE_SECRET_KEY 서브계정 Secret Key (시크릿)
|
||||
@Injectable()
|
||||
export class NcpObjectStorageService extends StorageService {
|
||||
private readonly logger = new Logger(NcpObjectStorageService.name);
|
||||
// 설정 누락 시에도 앱은 부팅되며(드라이브 외 기능 정상), 실제 사용 시점에 실패한다.
|
||||
private readonly client: S3Client | null;
|
||||
private readonly bucketName: string;
|
||||
private readonly missing: string[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const endpoint = process.env.OBJECT_STORAGE_ENDPOINT;
|
||||
const region = process.env.OBJECT_STORAGE_REGION || 'kr-standard';
|
||||
const bucket = process.env.OBJECT_STORAGE_BUCKET;
|
||||
const accessKeyId = process.env.OBJECT_STORAGE_ACCESS_KEY;
|
||||
const secretAccessKey = process.env.OBJECT_STORAGE_SECRET_KEY;
|
||||
|
||||
this.bucketName = bucket || '';
|
||||
this.missing = [
|
||||
['OBJECT_STORAGE_ENDPOINT', endpoint],
|
||||
['OBJECT_STORAGE_BUCKET', bucket],
|
||||
['OBJECT_STORAGE_ACCESS_KEY', accessKeyId],
|
||||
['OBJECT_STORAGE_SECRET_KEY', secretAccessKey],
|
||||
]
|
||||
.filter(([, v]) => !v)
|
||||
.map(([k]) => k as string);
|
||||
|
||||
if (this.missing.length > 0) {
|
||||
// 부팅은 통과시키되 경고를 남긴다 — 드라이브 API 호출 시 ensureConfigured() 가 막는다.
|
||||
this.client = null;
|
||||
this.logger.warn(
|
||||
`오브젝트 스토리지 미설정(드라이브 비활성): ${this.missing.join(', ')} 누락`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = new S3Client({
|
||||
endpoint,
|
||||
region,
|
||||
// NCP/MinIO 등 S3 호환 스토리지는 path-style 접근이 필요(가상호스트 도메인 미지원)
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: accessKeyId as string,
|
||||
secretAccessKey: secretAccessKey as string,
|
||||
},
|
||||
});
|
||||
this.logger.log(
|
||||
`오브젝트 스토리지 초기화: endpoint=${endpoint}, bucket=${this.bucketName}`,
|
||||
);
|
||||
}
|
||||
|
||||
get bucket(): string {
|
||||
return this.bucketName;
|
||||
}
|
||||
|
||||
// 설정이 완료되어 실제 호출 가능한지 보장 — 미설정 시 명확한 에러로 막는다.
|
||||
private ensureConfigured(): S3Client {
|
||||
if (!this.client) {
|
||||
throw new Error(
|
||||
`오브젝트 스토리지 설정 누락: ${this.missing.join(', ')} 를 환경변수로 지정해 주세요.`,
|
||||
);
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async presignUpload(input: PresignUploadInput): Promise<PresignedUpload> {
|
||||
const client = this.ensureConfigured();
|
||||
const expiresIn = input.expiresInSeconds ?? DEFAULT_UPLOAD_EXPIRES;
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: input.key,
|
||||
// ContentType 을 서명에 포함 → 클라이언트는 동일 Content-Type 헤더로만 업로드 가능
|
||||
ContentType: input.contentType,
|
||||
});
|
||||
const url = await getSignedUrl(client, command, { expiresIn });
|
||||
return {
|
||||
url,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': input.contentType },
|
||||
expiresAt: new Date(Date.now() + expiresIn * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
async presignDownload(input: PresignDownloadInput): Promise<string> {
|
||||
const client = this.ensureConfigured();
|
||||
const expiresIn = input.expiresInSeconds ?? DEFAULT_DOWNLOAD_EXPIRES;
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: input.key,
|
||||
// 다운로드 강제 + 원본 파일명 복원(비ASCII 안전) + 타입 고정
|
||||
ResponseContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(
|
||||
input.filename,
|
||||
)}`,
|
||||
ResponseContentType: input.contentType,
|
||||
});
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
async headObject(key: string): Promise<ObjectHead | null> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
const res = await client.send(
|
||||
new HeadObjectCommand({ Bucket: this.bucketName, Key: key }),
|
||||
);
|
||||
return {
|
||||
size: res.ContentLength ?? 0,
|
||||
contentType: res.ContentType ?? null,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
// 미존재(404/NotFound)는 정상 흐름 → null. 그 외 오류는 상위로 전파.
|
||||
if (this.isNotFound(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
await client.send(
|
||||
new DeleteObjectCommand({ Bucket: this.bucketName, Key: key }),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
// 이미 없으면 멱등 처리(삭제 목적은 달성). 그 외 오류는 전파.
|
||||
if (this.isNotFound(err)) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// AWS SDK v3 의 미존재 응답 판별(상태코드 404 또는 NotFound 계열 name)
|
||||
private isNotFound(err: unknown): boolean {
|
||||
const e = err as {
|
||||
name?: string;
|
||||
$metadata?: { httpStatusCode?: number };
|
||||
};
|
||||
return (
|
||||
e?.$metadata?.httpStatusCode === 404 ||
|
||||
e?.name === 'NotFound' ||
|
||||
e?.name === 'NoSuchKey'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
import { NcpObjectStorageService } from './ncp-object-storage.service';
|
||||
|
||||
// 스토리지 모듈 — StorageService 토큰을 NCP Object Storage 구현체에 바인딩한다.
|
||||
// @Global 로 등록해 어느 모듈에서든 StorageService 를 주입받을 수 있다.
|
||||
// (구현체 교체 시 useClass 만 변경하면 됨 — 예: 로컬 디스크/MinIO)
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: StorageService,
|
||||
useClass: NcpObjectStorageService,
|
||||
},
|
||||
],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type {
|
||||
ObjectHead,
|
||||
PresignDownloadInput,
|
||||
PresignUploadInput,
|
||||
PresignedUpload,
|
||||
} from './storage.types';
|
||||
|
||||
// 오브젝트 스토리지 추상 인터페이스.
|
||||
// abstract class 를 DI 토큰으로 사용한다(NestJS 권장 패턴) — 구현체는 storage.module 에서 주입.
|
||||
// 바이너리는 백엔드를 거치지 않고 클라이언트가 presigned URL 로 직접 송수신한다.
|
||||
export abstract class StorageService {
|
||||
// 현재 사용 중인 버킷 이름(메타데이터에 함께 저장해 추후 버킷 이전 대비)
|
||||
abstract get bucket(): string;
|
||||
|
||||
// 업로드용 presigned URL 발급(PUT). contentType 이 서명에 포함된다.
|
||||
abstract presignUpload(input: PresignUploadInput): Promise<PresignedUpload>;
|
||||
|
||||
// 다운로드용 단기 presigned URL 발급(GET). 파일명/타입을 응답 헤더로 강제한다.
|
||||
abstract presignDownload(input: PresignDownloadInput): Promise<string>;
|
||||
|
||||
// 객체 메타 조회 — 미존재 시 null. 업로드 완료(complete) 검증에 사용.
|
||||
abstract headObject(key: string): Promise<ObjectHead | null>;
|
||||
|
||||
// 객체 삭제(파일/미완료 업로드 정리). 미존재여도 에러 없이 통과.
|
||||
abstract deleteObject(key: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 스토리지 추상화 공용 타입 — 구현체(NCP Object Storage 등)와 호출부가 공유한다.
|
||||
|
||||
// Presigned 업로드 발급 입력
|
||||
export interface PresignUploadInput {
|
||||
// 버킷 내 객체 키 (예: development/drive/{userId}/{uuid}-name.pdf)
|
||||
key: string;
|
||||
// 업로드 시 강제할 MIME 타입 — 서명에 포함되어 클라이언트가 다른 타입으로 못 올림
|
||||
contentType: string;
|
||||
// URL 유효 시간(초). 미지정 시 구현체 기본값
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
// Presigned 업로드 발급 결과
|
||||
export interface PresignedUpload {
|
||||
// 클라이언트가 직접 PUT 할 URL
|
||||
url: string;
|
||||
// HTTP 메서드 (PUT 고정)
|
||||
method: 'PUT';
|
||||
// 업로드 요청 시 반드시 포함해야 하는 헤더(서명 대상) — 예: { 'Content-Type': '...' }
|
||||
headers: Record<string, string>;
|
||||
// 만료 시각(클라이언트 표시/검증용)
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
// Presigned 다운로드 발급 입력
|
||||
export interface PresignDownloadInput {
|
||||
key: string;
|
||||
// 다운로드 시 표시할 원본 파일명(Content-Disposition 에 반영)
|
||||
filename: string;
|
||||
// 응답 Content-Type
|
||||
contentType: string;
|
||||
// URL 유효 시간(초). 미지정 시 구현체 기본값
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
// HEAD 조회 결과 — 업로드 완료 검증(실제 size/type 재확인)에 사용
|
||||
export interface ObjectHead {
|
||||
// 실제 저장된 바이트 크기
|
||||
size: number;
|
||||
// 실제 저장된 MIME 타입(없을 수 있음)
|
||||
contentType: string | null;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 드라이브 기능 — drive_folders / drive_files / project_folder_links 테이블 추가.
|
||||
// - drive_folders: 사용자 소유 폴더 트리(adjacency list + materialized path)
|
||||
// - drive_files: 오브젝트 스토리지 객체 메타(presign 업로드, status 로 완료 관리)
|
||||
// - project_folder_links: 폴더를 프로젝트에 연동(공유), 권한(read/read-write)
|
||||
export class AddDriveTables1782600000000 implements MigrationInterface {
|
||||
name = 'AddDriveTables1782600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ===== drive_folders =====
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "drive_folders" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "path" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "parent_id" uuid, CONSTRAINT "PK_drive_folders" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_folders_owner" ON "drive_folders" ("owner_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_folders_parent" ON "drive_folders" ("parent_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_folders_path" ON "drive_folders" ("path")`,
|
||||
);
|
||||
|
||||
// ===== drive_files =====
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "drive_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "bucket" character varying NOT NULL, "object_key" character varying NOT NULL, "size" bigint NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'pending', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "folder_id" uuid, CONSTRAINT "PK_drive_files" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_drive_files_object_key" ON "drive_files" ("object_key")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_files_owner" ON "drive_files" ("owner_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_files_folder" ON "drive_files" ("folder_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_files_status" ON "drive_files" ("status")`,
|
||||
);
|
||||
|
||||
// ===== project_folder_links =====
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "project_folder_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "permission" character varying NOT NULL DEFAULT 'read', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "folder_id" uuid, "linked_by_id" uuid, CONSTRAINT "PK_project_folder_links" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_project_folder_links_project" ON "project_folder_links" ("project_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_project_folder_links_folder" ON "project_folder_links" ("folder_id")`,
|
||||
);
|
||||
// 한 폴더는 한 프로젝트에 한 번만 연동
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "UQ_project_folder_link" UNIQUE ("project_id", "folder_id")`,
|
||||
);
|
||||
|
||||
// ===== 외래키 =====
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_parent" FOREIGN KEY ("parent_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_linked_by" FOREIGN KEY ("linked_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 외래키 → 테이블 순서 역순 정리
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_linked_by"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_folder"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_project"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_folder"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_owner"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_parent"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_owner"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`DROP TABLE "project_folder_links"`);
|
||||
await queryRunner.query(`DROP TABLE "drive_files"`);
|
||||
await queryRunner.query(`DROP TABLE "drive_folders"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { extname } from 'path';
|
||||
import type { AttachmentKind } from '../task/entities/attachment.entity';
|
||||
|
||||
// 드라이브 파일 1건 최대 크기(기본 200MB) — env 로 조정 가능.
|
||||
// presigned PUT 은 크기를 서명으로 강제하지 못하므로, 최종 검증은 complete 단계의 HEAD 로 한다.
|
||||
export const DRIVE_MAX_FILE_SIZE = (() => {
|
||||
const mb = Number(process.env.DRIVE_MAX_FILE_MB);
|
||||
return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 200 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
// 허용 MIME 화이트리스트 — 실행/스크립트(text/html, image/svg+xml 등)는 차단(심층 방어).
|
||||
// 문서 첨부(upload.config) 기준에 드라이브 용도의 미디어 타입을 추가했다.
|
||||
export const DRIVE_ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
// 이미지
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
// 문서
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'text/markdown',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/json',
|
||||
// 압축
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-rar-compressed',
|
||||
// 미디어(드라이브 용도)
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/quicktime',
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
]);
|
||||
|
||||
// 허용 MIME 여부
|
||||
export function isAllowedMime(mime: string): boolean {
|
||||
return DRIVE_ALLOWED_MIME_TYPES.has(mime);
|
||||
}
|
||||
|
||||
// MIME/확장자로 아이콘 종류 파생(첨부와 동일 분류 재사용)
|
||||
export function deriveKind(mime: string, name: string): AttachmentKind {
|
||||
const lower = name.toLowerCase();
|
||||
if (mime.startsWith('image/')) return 'img';
|
||||
if (mime === 'application/pdf' || lower.endsWith('.pdf')) return 'pdf';
|
||||
if (
|
||||
mime.includes('zip') ||
|
||||
mime.includes('compressed') ||
|
||||
lower.endsWith('.zip')
|
||||
) {
|
||||
return 'zip';
|
||||
}
|
||||
return 'doc';
|
||||
}
|
||||
|
||||
// 버킷 객체 키 생성 — {APP_ENV}/drive/{userId}/{uuid}{ext}
|
||||
// 원본 파일명은 DB(name)에만 보관하고, 키는 충돌/인코딩 안전을 위해 uuid 기반으로 만든다.
|
||||
export function buildObjectKey(userId: string, filename: string): string {
|
||||
const env = process.env.APP_ENV || process.env.NODE_ENV || 'development';
|
||||
const ext = extname(filename).toLowerCase().slice(0, 16); // 비정상적으로 긴 확장자 방어
|
||||
return `${env}/drive/${userId}/${randomUUID()}${ext}`;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import {
|
||||
DriveService,
|
||||
type DriveFileResponse,
|
||||
type DriveFolderResponse,
|
||||
type DriveListResponse,
|
||||
type PresignUploadResponse,
|
||||
} from './drive.service';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { PresignUploadDto } from './dto/presign-upload.dto';
|
||||
import { RenameDto } from './dto/rename.dto';
|
||||
|
||||
// 드라이브 컨트롤러 — 프로젝트와 독립된 개인 드라이브. 모든 엔드포인트 인증 필요.
|
||||
@ApiTags('Drive')
|
||||
@Controller('drive')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DriveController {
|
||||
constructor(private readonly driveService: DriveService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '드라이브 목록(폴더 미지정 시 루트)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
@ApiResponse({ status: 403, description: '접근 권한 없음 (AUTH_003)' })
|
||||
list(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Query('folderId') folderId?: string,
|
||||
): Promise<DriveListResponse> {
|
||||
return this.driveService.list(user.id, folderId);
|
||||
}
|
||||
|
||||
/* ===================== 폴더 ===================== */
|
||||
|
||||
@Post('folders')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '폴더 생성' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
createFolder(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateFolderDto,
|
||||
): Promise<DriveFolderResponse> {
|
||||
return this.driveService.createFolder(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch('folders/:folderId')
|
||||
@ApiOperation({ summary: '폴더 이름 변경' })
|
||||
@ApiResponse({ status: 200, description: '변경 성공' })
|
||||
renameFolder(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('folderId', ParseUUIDPipe) folderId: string,
|
||||
@Body() dto: RenameDto,
|
||||
): Promise<DriveFolderResponse> {
|
||||
return this.driveService.renameFolder(user.id, folderId, dto.name);
|
||||
}
|
||||
|
||||
@Delete('folders/:folderId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '폴더 삭제(하위 폴더·파일 포함)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async deleteFolder(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('folderId', ParseUUIDPipe) folderId: string,
|
||||
): Promise<null> {
|
||||
await this.driveService.deleteFolder(user.id, folderId);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ===================== 파일 ===================== */
|
||||
|
||||
@Post('files/presign')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '업로드용 presigned URL 발급(+pending 메타 생성)' })
|
||||
@ApiResponse({ status: 201, description: '발급 성공' })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '허용되지 않는 형식/크기 (VAL_001)',
|
||||
})
|
||||
presignUpload(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: PresignUploadDto,
|
||||
): Promise<PresignUploadResponse> {
|
||||
return this.driveService.presignUpload(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('files/:fileId/complete')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '업로드 확정(HEAD 검증 후 active 전환)' })
|
||||
@ApiResponse({ status: 200, description: '확정 성공' })
|
||||
@ApiResponse({ status: 400, description: '업로드 미완료/검증 실패' })
|
||||
completeUpload(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
): Promise<DriveFileResponse> {
|
||||
return this.driveService.completeUpload(user.id, fileId);
|
||||
}
|
||||
|
||||
@Get('files/:fileId/download-url')
|
||||
@ApiOperation({ summary: '다운로드용 단기 presigned URL 발급' })
|
||||
@ApiResponse({ status: 200, description: '발급 성공' })
|
||||
@ApiResponse({ status: 404, description: '파일 없음/미확정 (RES_001)' })
|
||||
getDownloadUrl(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
): Promise<{ url: string }> {
|
||||
return this.driveService.getDownloadUrl(user.id, fileId);
|
||||
}
|
||||
|
||||
@Patch('files/:fileId')
|
||||
@ApiOperation({ summary: '파일 이름 변경' })
|
||||
@ApiResponse({ status: 200, description: '변경 성공' })
|
||||
renameFile(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@Body() dto: RenameDto,
|
||||
): Promise<DriveFileResponse> {
|
||||
return this.driveService.renameFile(user.id, fileId, dto.name);
|
||||
}
|
||||
|
||||
@Delete('files/:fileId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '파일 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async deleteFile(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
): Promise<null> {
|
||||
await this.driveService.deleteFile(user.id, fileId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { DriveFolder } from './entities/drive-folder.entity';
|
||||
import { DriveFile } from './entities/drive-file.entity';
|
||||
import { ProjectFolderLink } from './entities/project-folder-link.entity';
|
||||
import { ProjectMember } from '../project/entities/project-member.entity';
|
||||
import { DriveService } from './drive.service';
|
||||
import { DriveController } from './drive.controller';
|
||||
|
||||
// 드라이브 모듈 — 프로젝트와 독립된 파일 저장소.
|
||||
// 저장소(StorageService)는 전역 StorageModule 에서 주입된다.
|
||||
// ProjectMember 는 폴더-프로젝트 연동 시 멤버십(권한) 판정을 위해 forFeature 로 등록한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
DriveFolder,
|
||||
DriveFile,
|
||||
ProjectFolderLink,
|
||||
ProjectMember,
|
||||
]),
|
||||
UserModule,
|
||||
],
|
||||
controllers: [DriveController],
|
||||
providers: [DriveService],
|
||||
exports: [DriveService],
|
||||
})
|
||||
export class DriveModule {}
|
||||
@@ -0,0 +1,524 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { In, IsNull, LessThan, Repository } from 'typeorm';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { StorageService } from '../../common/storage/storage.service';
|
||||
import type { PresignedUpload } from '../../common/storage/storage.types';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { ProjectMember } from '../project/entities/project-member.entity';
|
||||
import { DriveFolder } from './entities/drive-folder.entity';
|
||||
import { DriveFile } from './entities/drive-file.entity';
|
||||
import { ProjectFolderLink } from './entities/project-folder-link.entity';
|
||||
import type { AttachmentKind } from '../task/entities/attachment.entity';
|
||||
import type { DriveFileStatus } from './entities/drive-file.entity';
|
||||
import {
|
||||
DRIVE_MAX_FILE_SIZE,
|
||||
buildObjectKey,
|
||||
deriveKind,
|
||||
isAllowedMime,
|
||||
} from './drive.config';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { PresignUploadDto } from './dto/presign-upload.dto';
|
||||
|
||||
// 폴더/파일에 대한 접근 수준 — 높을수록 권한이 강하다.
|
||||
export type DriveAccess = 'owner' | 'write' | 'read';
|
||||
const ACCESS_RANK: Record<DriveAccess, number> = {
|
||||
read: 1,
|
||||
write: 2,
|
||||
owner: 3,
|
||||
};
|
||||
|
||||
// --- 응답 형태 ---
|
||||
export interface DriveFolderResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
owner: PublicUser | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DriveFileResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
kind: AttachmentKind;
|
||||
status: DriveFileStatus;
|
||||
folderId: string | null;
|
||||
owner: PublicUser | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DriveListResponse {
|
||||
// 현재 폴더(루트면 null) + 루트→현재 경로(브레드크럼)
|
||||
current: DriveFolderResponse | null;
|
||||
breadcrumb: { id: string; name: string }[];
|
||||
folders: DriveFolderResponse[];
|
||||
files: DriveFileResponse[];
|
||||
}
|
||||
|
||||
export interface PresignUploadResponse {
|
||||
fileId: string;
|
||||
file: DriveFileResponse;
|
||||
upload: PresignedUpload;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DriveService {
|
||||
private readonly logger = new Logger(DriveService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DriveFolder)
|
||||
private readonly folderRepo: Repository<DriveFolder>,
|
||||
@InjectRepository(DriveFile)
|
||||
private readonly fileRepo: Repository<DriveFile>,
|
||||
@InjectRepository(ProjectFolderLink)
|
||||
private readonly linkRepo: Repository<ProjectFolderLink>,
|
||||
@InjectRepository(ProjectMember)
|
||||
private readonly memberRepo: Repository<ProjectMember>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
/* ===================== 조회 ===================== */
|
||||
|
||||
// 드라이브 목록 — folderId 없으면 내 루트, 있으면 해당 폴더(읽기 권한 필요).
|
||||
async list(userId: string, folderId?: string): Promise<DriveListResponse> {
|
||||
if (!folderId) {
|
||||
// 루트 — 내 소유의 최상위 폴더/파일만
|
||||
const [folders, files] = await Promise.all([
|
||||
this.folderRepo.find({
|
||||
where: { owner: { id: userId }, parent: IsNull() },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.fileRepo.find({
|
||||
where: { owner: { id: userId }, folder: IsNull(), status: 'active' },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
current: null,
|
||||
breadcrumb: [],
|
||||
folders: folders.map((f) => this.toFolderResponse(f, null)),
|
||||
files: files.map((f) => this.toFileResponse(f, null)),
|
||||
};
|
||||
}
|
||||
|
||||
const folder = await this.getFolderForAccess(userId, folderId, 'read');
|
||||
const [folders, files, breadcrumb] = await Promise.all([
|
||||
this.folderRepo.find({
|
||||
where: { parent: { id: folder.id } },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.fileRepo.find({
|
||||
where: { folder: { id: folder.id }, status: 'active' },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.buildBreadcrumb(folder),
|
||||
]);
|
||||
return {
|
||||
current: this.toFolderResponse(folder, folder.parent?.id ?? null),
|
||||
breadcrumb,
|
||||
folders: folders.map((f) => this.toFolderResponse(f, folder.id)),
|
||||
files: files.map((f) => this.toFileResponse(f, folder.id)),
|
||||
};
|
||||
}
|
||||
|
||||
/* ===================== 폴더 CRUD ===================== */
|
||||
|
||||
// 폴더 생성 — 루트면 내 드라이브, 하위면 상위 폴더에 쓰기 권한 필요.
|
||||
async createFolder(
|
||||
userId: string,
|
||||
dto: CreateFolderDto,
|
||||
): Promise<DriveFolderResponse> {
|
||||
let parent: DriveFolder | null = null;
|
||||
if (dto.parentId) {
|
||||
parent = await this.getFolderForAccess(userId, dto.parentId, 'write');
|
||||
}
|
||||
const owner = await this.getUserOrThrow(userId);
|
||||
// 1차 저장으로 id 확보 → materialized path 구성 후 재저장
|
||||
const created = await this.folderRepo.save(
|
||||
this.folderRepo.create({
|
||||
owner,
|
||||
parent: parent ? { id: parent.id } : null,
|
||||
name: dto.name,
|
||||
path: '',
|
||||
}),
|
||||
);
|
||||
created.path = `${parent ? parent.path : ''}${created.id}/`;
|
||||
await this.folderRepo.save(created);
|
||||
return this.toFolderResponse({ ...created, owner }, parent?.id ?? null);
|
||||
}
|
||||
|
||||
// 폴더 이름 변경 — 쓰기 권한 필요.
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
folderId: string,
|
||||
name: string,
|
||||
): Promise<DriveFolderResponse> {
|
||||
const folder = await this.getFolderForAccess(userId, folderId, 'write');
|
||||
folder.name = name;
|
||||
await this.folderRepo.save(folder);
|
||||
return this.toFolderResponse(folder, folder.parent?.id ?? null);
|
||||
}
|
||||
|
||||
// 폴더 삭제 — 쓰기 권한 필요. 하위 트리의 객체 스토리지 파일까지 정리.
|
||||
async deleteFolder(userId: string, folderId: string): Promise<void> {
|
||||
const folder = await this.getFolderForAccess(userId, folderId, 'write');
|
||||
// 자기 + 하위 폴더에 속한 모든 파일의 객체 키 수집(행은 FK CASCADE 로 삭제됨)
|
||||
const descendantFiles = await this.fileRepo
|
||||
.createQueryBuilder('f')
|
||||
.innerJoin('f.folder', 'fd')
|
||||
.where('fd.path LIKE :prefix', { prefix: `${folder.path}%` })
|
||||
.getMany();
|
||||
await this.folderRepo.remove(folder);
|
||||
await this.deleteObjects(descendantFiles.map((f) => f.objectKey));
|
||||
}
|
||||
|
||||
/* ===================== 파일 업로드(Presigned) ===================== */
|
||||
|
||||
// ① 업로드용 presigned URL 발급 + pending 메타 생성.
|
||||
async presignUpload(
|
||||
userId: string,
|
||||
dto: PresignUploadDto,
|
||||
): Promise<PresignUploadResponse> {
|
||||
if (!isAllowedMime(dto.contentType)) {
|
||||
throw new BusinessException(
|
||||
'VAL_001',
|
||||
'업로드할 수 없는 파일 형식입니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
let folder: DriveFolder | null = null;
|
||||
if (dto.folderId) {
|
||||
folder = await this.getFolderForAccess(userId, dto.folderId, 'write');
|
||||
}
|
||||
const owner = await this.getUserOrThrow(userId);
|
||||
const objectKey = buildObjectKey(userId, dto.filename);
|
||||
const saved = await this.fileRepo.save(
|
||||
this.fileRepo.create({
|
||||
owner,
|
||||
folder: folder ? { id: folder.id } : null,
|
||||
name: dto.filename,
|
||||
bucket: this.storage.bucket,
|
||||
objectKey,
|
||||
size: dto.size,
|
||||
mime: dto.contentType,
|
||||
kind: deriveKind(dto.contentType, dto.filename),
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
const upload = await this.storage.presignUpload({
|
||||
key: objectKey,
|
||||
contentType: dto.contentType,
|
||||
});
|
||||
return {
|
||||
fileId: saved.id,
|
||||
file: this.toFileResponse({ ...saved, owner }, folder?.id ?? null),
|
||||
upload,
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 업로드 확정 — HEAD 로 실제 크기/타입 재검증 후 active 전환. 발급자 본인만 호출.
|
||||
async completeUpload(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
): Promise<DriveFileResponse> {
|
||||
const file = await this.fileRepo.findOne({
|
||||
where: { id: fileId },
|
||||
relations: { owner: true, folder: true },
|
||||
});
|
||||
if (!file) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (file.owner?.id !== userId) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
if (file.status === 'active') {
|
||||
// 멱등 — 이미 확정된 경우 현재 상태 반환
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
const head = await this.storage.headObject(file.objectKey);
|
||||
if (!head) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'업로드가 완료되지 않았습니다. 파일 전송 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
// 실제 저장된 객체 기준으로 검증(클라이언트 신고값을 신뢰하지 않음)
|
||||
if (head.size > DRIVE_MAX_FILE_SIZE) {
|
||||
await this.discardFile(file);
|
||||
throw new BusinessException(
|
||||
'VAL_001',
|
||||
'허용 최대 파일 크기를 초과했습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
if (head.contentType && !isAllowedMime(head.contentType)) {
|
||||
await this.discardFile(file);
|
||||
throw new BusinessException(
|
||||
'VAL_001',
|
||||
'업로드할 수 없는 파일 형식입니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
file.size = head.size;
|
||||
file.status = 'active';
|
||||
await this.fileRepo.save(file);
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
/* ===================== 파일 CRUD ===================== */
|
||||
|
||||
// 파일 이름 변경 — 쓰기 권한 필요.
|
||||
async renameFile(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
name: string,
|
||||
): Promise<DriveFileResponse> {
|
||||
const file = await this.getFileForAccess(userId, fileId, 'write');
|
||||
file.name = name;
|
||||
await this.fileRepo.save(file);
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
// 파일 삭제 — 쓰기 권한 필요. 객체 스토리지 파일도 정리.
|
||||
async deleteFile(userId: string, fileId: string): Promise<void> {
|
||||
const file = await this.getFileForAccess(userId, fileId, 'write');
|
||||
await this.fileRepo.remove(file);
|
||||
await this.deleteObjects([file.objectKey]);
|
||||
}
|
||||
|
||||
// 다운로드용 단기 presigned GET URL 발급 — 읽기 권한 필요.
|
||||
async getDownloadUrl(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
): Promise<{ url: string }> {
|
||||
const file = await this.getFileForAccess(userId, fileId, 'read');
|
||||
if (file.status !== 'active') {
|
||||
// 아직 업로드 확정 전 — 다운로드 불가
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const url = await this.storage.presignDownload({
|
||||
key: file.objectKey,
|
||||
filename: file.name,
|
||||
contentType: file.mime,
|
||||
});
|
||||
return { url };
|
||||
}
|
||||
|
||||
/* ===================== 권한 해석 ===================== */
|
||||
|
||||
// 폴더를 로딩하고 요구 접근 수준을 만족하는지 검사(불충분 시 403, 미존재 404).
|
||||
private async getFolderForAccess(
|
||||
userId: string,
|
||||
folderId: string,
|
||||
required: DriveAccess,
|
||||
): Promise<DriveFolder> {
|
||||
const folder = await this.folderRepo.findOne({
|
||||
where: { id: folderId },
|
||||
relations: { owner: true, parent: true },
|
||||
});
|
||||
if (!folder) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const access = await this.resolveFolderAccess(userId, folder);
|
||||
this.assertAccess(access, required);
|
||||
return folder;
|
||||
}
|
||||
|
||||
// 파일을 로딩하고 요구 접근 수준을 만족하는지 검사.
|
||||
private async getFileForAccess(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
required: DriveAccess,
|
||||
): Promise<DriveFile> {
|
||||
const file = await this.fileRepo.findOne({
|
||||
where: { id: fileId },
|
||||
relations: { owner: true, folder: true },
|
||||
});
|
||||
if (!file) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const access = await this.resolveFileAccess(userId, file);
|
||||
this.assertAccess(access, required);
|
||||
return file;
|
||||
}
|
||||
|
||||
// 폴더 접근 수준 산출 — 소유자 / 연동(쓰기·읽기) / 없음.
|
||||
private async resolveFolderAccess(
|
||||
userId: string,
|
||||
folder: DriveFolder,
|
||||
): Promise<DriveAccess | null> {
|
||||
if (folder.owner?.id === userId) return 'owner';
|
||||
// 자기 + 조상 폴더 중 하나라도 내가 멤버인 프로젝트에 연동되어 있으면 그 권한을 적용
|
||||
return this.resolveLinkedAccess(this.parseFolderPath(folder.path), userId);
|
||||
}
|
||||
|
||||
// 파일 접근 수준 산출 — 소유자 / 소속 폴더의 연동 권한 / 없음(루트 파일은 소유자 전용).
|
||||
private async resolveFileAccess(
|
||||
userId: string,
|
||||
file: DriveFile,
|
||||
): Promise<DriveAccess | null> {
|
||||
if (file.owner?.id === userId) return 'owner';
|
||||
if (!file.folder) return null;
|
||||
return this.resolveLinkedAccess(
|
||||
this.parseFolderPath(file.folder.path),
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
// 폴더 id 집합(자기+조상)에 대해, 내가 멤버인 프로젝트로의 연동 권한 중 최상위를 반환.
|
||||
private async resolveLinkedAccess(
|
||||
folderIds: string[],
|
||||
userId: string,
|
||||
): Promise<DriveAccess | null> {
|
||||
if (folderIds.length === 0) return null;
|
||||
const links = await this.linkRepo.find({
|
||||
where: { folder: { id: In(folderIds) } },
|
||||
relations: { project: true },
|
||||
});
|
||||
if (links.length === 0) return null;
|
||||
const projectIds = [...new Set(links.map((l) => l.project.id))];
|
||||
const memberships = await this.memberRepo.find({
|
||||
where: { project: { id: In(projectIds) }, user: { id: userId } },
|
||||
relations: { project: true },
|
||||
});
|
||||
const memberProjectIds = new Set(memberships.map((m) => m.project.id));
|
||||
const accessible = links.filter((l) => memberProjectIds.has(l.project.id));
|
||||
if (accessible.length === 0) return null;
|
||||
return accessible.some((l) => l.permission === 'read-write')
|
||||
? 'write'
|
||||
: 'read';
|
||||
}
|
||||
|
||||
// 접근 수준이 요구치 미만이면 403
|
||||
private assertAccess(
|
||||
access: DriveAccess | null,
|
||||
required: DriveAccess,
|
||||
): void {
|
||||
if (!access || ACCESS_RANK[access] < ACCESS_RANK[required]) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
// "rootId/childId/selfId/" → ["rootId","childId","selfId"] (자기 포함 + 조상)
|
||||
private parseFolderPath(path: string): string[] {
|
||||
return path.split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
// 브레드크럼(루트→현재) — path 의 폴더들을 이름과 함께 순서대로 반환
|
||||
private async buildBreadcrumb(
|
||||
folder: DriveFolder,
|
||||
): Promise<{ id: string; name: string }[]> {
|
||||
const ids = this.parseFolderPath(folder.path);
|
||||
if (ids.length === 0) return [];
|
||||
const rows = await this.folderRepo.find({
|
||||
where: { id: In(ids) },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const byId = new Map(rows.map((r) => [r.id, r.name]));
|
||||
return ids
|
||||
.filter((id) => byId.has(id))
|
||||
.map((id) => ({ id, name: byId.get(id) as string }));
|
||||
}
|
||||
|
||||
/* ===================== 내부 유틸 ===================== */
|
||||
|
||||
private async getUserOrThrow(userId: string): Promise<User> {
|
||||
const user = await this.userService.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// 검증 실패한 업로드 폐기 — 객체 + 메타 행 제거
|
||||
private async discardFile(file: DriveFile): Promise<void> {
|
||||
await this.fileRepo.remove(file);
|
||||
await this.deleteObjects([file.objectKey]);
|
||||
}
|
||||
|
||||
// 객체 스토리지 파일 삭제(베스트 에포트 — 실패해도 본 흐름을 막지 않음)
|
||||
private async deleteObjects(keys: string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
try {
|
||||
await this.storage.deleteObject(key);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`객체 삭제 실패(무시): ${key} — ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 미완료(pending) 업로드 정리 — 1시간 이상 확정되지 않은 행/객체 제거
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
async cleanupStalePending(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const stale = await this.fileRepo.find({
|
||||
where: { status: 'pending', createdAt: LessThan(cutoff) },
|
||||
});
|
||||
if (stale.length === 0) return;
|
||||
await this.deleteObjects(stale.map((f) => f.objectKey));
|
||||
await this.fileRepo.remove(stale);
|
||||
this.logger.log(`미완료 업로드 ${stale.length}건 정리`);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`미완료 업로드 정리 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 매핑 ===================== */
|
||||
|
||||
private toFolderResponse(
|
||||
folder: DriveFolder,
|
||||
parentId: string | null,
|
||||
): DriveFolderResponse {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId,
|
||||
owner: folder.owner ? UserService.toPublic(folder.owner) : null,
|
||||
createdAt: folder.createdAt.toISOString(),
|
||||
updatedAt: folder.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toFileResponse(
|
||||
file: DriveFile,
|
||||
folderId: string | null,
|
||||
): DriveFileResponse {
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime: file.mime,
|
||||
kind: file.kind,
|
||||
status: file.status,
|
||||
folderId,
|
||||
owner: file.owner ? UserService.toPublic(file.owner) : null,
|
||||
createdAt: file.createdAt.toISOString(),
|
||||
updatedAt: file.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsOptional, IsString, IsUUID, Length } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
// 폴더 생성 요청
|
||||
export class CreateFolderDto {
|
||||
@ApiProperty({ description: '폴더명', example: '디자인 시안' })
|
||||
@IsString()
|
||||
@Length(1, 255, { message: '폴더명은 1~255자로 입력해 주세요.' })
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '상위 폴더 ID (생략 시 드라이브 루트)',
|
||||
format: 'uuid',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4', { message: '올바른 상위 폴더 ID가 아닙니다.' })
|
||||
parentId?: string;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { DRIVE_MAX_FILE_SIZE } from '../drive.config';
|
||||
|
||||
// 업로드용 presigned URL 발급 요청
|
||||
export class PresignUploadDto {
|
||||
@ApiProperty({ description: '원본 파일명', example: '기획서.pdf' })
|
||||
@IsString()
|
||||
@Length(1, 255, { message: '파일명은 1~255자로 입력해 주세요.' })
|
||||
filename!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'MIME 타입(업로드 시 동일 Content-Type 으로 전송해야 함)',
|
||||
example: 'application/pdf',
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 255)
|
||||
contentType!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '파일 크기(바이트)',
|
||||
example: 1048576,
|
||||
maximum: DRIVE_MAX_FILE_SIZE,
|
||||
})
|
||||
@IsInt({ message: '파일 크기가 올바르지 않습니다.' })
|
||||
@Min(1, { message: '빈 파일은 업로드할 수 없습니다.' })
|
||||
@Max(DRIVE_MAX_FILE_SIZE, { message: '허용 최대 파일 크기를 초과했습니다.' })
|
||||
size!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '업로드 대상 폴더 ID (생략 시 드라이브 루트)',
|
||||
format: 'uuid',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4', { message: '올바른 폴더 ID가 아닙니다.' })
|
||||
folderId?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, Length } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 폴더/파일 이름 변경 요청 (공용)
|
||||
export class RenameDto {
|
||||
@ApiProperty({ description: '변경할 이름', example: '최종_시안.pdf' })
|
||||
@IsString()
|
||||
@Length(1, 255, { message: '이름은 1~255자로 입력해 주세요.' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { DriveFolder } from './drive-folder.entity';
|
||||
import { numericColumnTransformer } from '../../../common/database/numeric-column.transformer';
|
||||
import type { AttachmentKind } from '../../task/entities/attachment.entity';
|
||||
|
||||
// 드라이브 파일 업로드 상태
|
||||
// pending: presign 발급됨(아직 실제 업로드/검증 전) / active: 업로드 완료·검증 통과
|
||||
export type DriveFileStatus = 'pending' | 'active';
|
||||
|
||||
// 드라이브 파일 — 실제 바이너리는 오브젝트 스토리지에 저장하고 메타데이터만 보관.
|
||||
// 업로드는 presigned URL 직접 전송 후 complete 로 확정한다.
|
||||
@Entity('drive_files')
|
||||
export class DriveFile {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소유자 (사용자 삭제 시 파일 메타도 함께 삭제 — 객체 정리는 서비스가 담당)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User;
|
||||
|
||||
// 소속 폴더 (null = 드라이브 루트). 폴더 삭제 시 파일 메타도 함께 삭제(CASCADE).
|
||||
@Index()
|
||||
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'folder_id' })
|
||||
folder!: Relation<DriveFolder> | null;
|
||||
|
||||
// 원본 파일명(다운로드 시 표시)
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 저장 버킷 — 추후 버킷 이전 대비해 메타에 함께 보관
|
||||
@Column()
|
||||
bucket!: string;
|
||||
|
||||
// 버킷 내 객체 키(고유). 예: development/drive/{userId}/{uuid}-name.pdf
|
||||
@Index({ unique: true })
|
||||
@Column({ name: 'object_key' })
|
||||
objectKey!: string;
|
||||
|
||||
// 파일 크기(바이트) — 대용량 대비 bigint(문자열) → number 변환
|
||||
@Column({ type: 'bigint', transformer: numericColumnTransformer })
|
||||
size!: number;
|
||||
|
||||
// MIME 타입
|
||||
@Column()
|
||||
mime!: string;
|
||||
|
||||
// 아이콘 종류(MIME/확장자에서 파생) — 업무/프로젝트 첨부와 동일 분류 재사용
|
||||
@Column({ type: 'varchar' })
|
||||
kind!: AttachmentKind;
|
||||
|
||||
// 업로드 상태 — pending 은 미완료(목록/다운로드 비노출), 정리 크론 대상
|
||||
@Index()
|
||||
@Column({ type: 'varchar', default: 'pending' })
|
||||
status!: DriveFileStatus;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 드라이브 폴더 — 사용자 소유의 폴더 트리(adjacency list).
|
||||
// 프로젝트와 독립적이며, ProjectFolderLink 로 프로젝트에 연동(공유)할 수 있다.
|
||||
@Entity('drive_folders')
|
||||
export class DriveFolder {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소유자 (사용자 삭제 시 폴더도 함께 삭제)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User;
|
||||
|
||||
// 상위 폴더 (null = 드라이브 루트). 상위 삭제 시 하위도 함께 삭제(CASCADE).
|
||||
// Relation<> 래퍼: 자기참조 순환 회피
|
||||
@Index()
|
||||
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'parent_id' })
|
||||
parent!: Relation<DriveFolder> | null;
|
||||
|
||||
// 폴더명
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 머터리얼라이즈드 패스 — 루트부터 자기 자신까지의 폴더 id 를 '/' 로 이어 저장(자기 포함, 끝에 '/').
|
||||
// 예: "rootId/childId/" . 조상 연동 여부 판정과 하위 트리 조회(LIKE)에 사용한다.
|
||||
// (parent_id 가 트리의 SSOT 이고, path 는 빠른 조회를 위한 파생값)
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
path!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Project } from '../../project/entities/project.entity';
|
||||
import { DriveFolder } from './drive-folder.entity';
|
||||
|
||||
// 연동 권한 — read: 보기/다운로드만, read-write: 프로젝트 멤버도 업로드 가능
|
||||
export type DriveLinkPermission = 'read' | 'read-write';
|
||||
|
||||
// 프로젝트-폴더 연동 — 드라이브 폴더를 프로젝트에 공유한다.
|
||||
// 연동된 폴더와 그 하위 트리 전체가 해당 프로젝트 멤버에게 노출된다.
|
||||
@Entity('project_folder_links')
|
||||
@Unique('UQ_project_folder_link', ['project', 'folder'])
|
||||
export class ProjectFolderLink {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 대상 프로젝트 (프로젝트 삭제 시 연동 해제)
|
||||
@Index()
|
||||
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project!: Relation<Project>;
|
||||
|
||||
// 연동된 드라이브 폴더 (폴더 삭제 시 연동도 함께 삭제)
|
||||
@Index()
|
||||
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'folder_id' })
|
||||
folder!: Relation<DriveFolder>;
|
||||
|
||||
// 연동을 수행한 사용자(폴더 소유자) — 사용자 삭제 시에도 연동은 유지(SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'linked_by_id' })
|
||||
linkedBy!: User | null;
|
||||
|
||||
// 연동 권한 — 기본 read(안전)
|
||||
@Column({ type: 'varchar', default: 'read' })
|
||||
permission!: DriveLinkPermission;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -68,6 +68,10 @@ services:
|
||||
- LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
|
||||
- LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
|
||||
- LIVEKIT_URL=${LIVEKIT_URL}
|
||||
# 드라이브(NCP Object Storage) — 비시크릿만 루트에서 주입(ACCESS/SECRET 키는 backend env_file)
|
||||
- OBJECT_STORAGE_ENDPOINT=${OBJECT_STORAGE_ENDPOINT}
|
||||
- OBJECT_STORAGE_REGION=${OBJECT_STORAGE_REGION}
|
||||
- OBJECT_STORAGE_BUCKET=${OBJECT_STORAGE_BUCKET}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
Reference in New Issue
Block a user