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:
2026-06-29 21:11:08 +09:00
parent 101b1553b0
commit 22a02be937
24 changed files with 2020 additions and 0 deletions
+12
View File
@@ -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
+12
View File
@@ -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
+9
View File
@@ -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
+8
View File
@@ -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
+574
View File
@@ -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",
+2
View File
@@ -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",
+4
View File
@@ -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"`);
}
}
+72
View File
@@ -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;
}
}
+28
View File
@@ -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 {}
+524
View File
@@ -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;
}
+4
View File
@@ -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