From 22a02be9370be28e719b46d6dc935f5336475972 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 29 Jun 2026 21:11:08 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=93=9C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C(=ED=8C=8C=EC=9D=BC=20=EC=A0=80=EC=9E=A5=EC=86=8C/?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EA=B4=80=EB=A6=AC)=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.development.example | 12 + .env.production.example | 12 + backend/.env.development.example | 9 + backend/.env.production.example | 8 + backend/package-lock.json | 574 ++++++++++++++++++ backend/package.json | 2 + backend/src/app.module.ts | 4 + .../database/numeric-column.transformer.ts | 14 + .../storage/ncp-object-storage.service.ts | 168 +++++ backend/src/common/storage/storage.module.ts | 18 + backend/src/common/storage/storage.service.ts | 26 + backend/src/common/storage/storage.types.ts | 42 ++ .../1782600000000-AddDriveTables.ts | 109 ++++ backend/src/modules/drive/drive.config.ts | 72 +++ backend/src/modules/drive/drive.controller.ts | 146 +++++ backend/src/modules/drive/drive.module.ts | 28 + backend/src/modules/drive/drive.service.ts | 524 ++++++++++++++++ .../modules/drive/dto/create-folder.dto.ts | 18 + .../modules/drive/dto/presign-upload.dto.ts | 45 ++ backend/src/modules/drive/dto/rename.dto.ts | 10 + .../drive/entities/drive-file.entity.ts | 75 +++ .../drive/entities/drive-folder.entity.ts | 50 ++ .../entities/project-folder-link.entity.ts | 50 ++ docker-compose.yml | 4 + 24 files changed, 2020 insertions(+) create mode 100644 backend/src/common/database/numeric-column.transformer.ts create mode 100644 backend/src/common/storage/ncp-object-storage.service.ts create mode 100644 backend/src/common/storage/storage.module.ts create mode 100644 backend/src/common/storage/storage.service.ts create mode 100644 backend/src/common/storage/storage.types.ts create mode 100644 backend/src/migrations/1782600000000-AddDriveTables.ts create mode 100644 backend/src/modules/drive/drive.config.ts create mode 100644 backend/src/modules/drive/drive.controller.ts create mode 100644 backend/src/modules/drive/drive.module.ts create mode 100644 backend/src/modules/drive/drive.service.ts create mode 100644 backend/src/modules/drive/dto/create-folder.dto.ts create mode 100644 backend/src/modules/drive/dto/presign-upload.dto.ts create mode 100644 backend/src/modules/drive/dto/rename.dto.ts create mode 100644 backend/src/modules/drive/entities/drive-file.entity.ts create mode 100644 backend/src/modules/drive/entities/drive-folder.entity.ts create mode 100644 backend/src/modules/drive/entities/project-folder-link.entity.ts diff --git a/.env.development.example b/.env.development.example index e6bd713..3347111 100644 --- a/.env.development.example +++ b/.env.development.example @@ -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 diff --git a/.env.production.example b/.env.production.example index 494d8cc..ad32dcf 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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 diff --git a/backend/.env.development.example b/backend/.env.development.example index 8f6566a..65f6260 100644 --- a/backend/.env.development.example +++ b/backend/.env.development.example @@ -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 diff --git a/backend/.env.production.example b/backend/.env.production.example index 06dcc9a..30164e2 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -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 diff --git a/backend/package-lock.json b/backend/package-lock.json index 9dc7bb8..308571d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 1ea1eef..37ccaf5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 4ff0f4d..293416a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ diff --git a/backend/src/common/database/numeric-column.transformer.ts b/backend/src/common/database/numeric-column.transformer.ts new file mode 100644 index 0000000..c2107c2 --- /dev/null +++ b/backend/src/common/database/numeric-column.transformer.ts @@ -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); + }, +}; diff --git a/backend/src/common/storage/ncp-object-storage.service.ts b/backend/src/common/storage/ncp-object-storage.service.ts new file mode 100644 index 0000000..3feb096 --- /dev/null +++ b/backend/src/common/storage/ncp-object-storage.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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' + ); + } +} diff --git a/backend/src/common/storage/storage.module.ts b/backend/src/common/storage/storage.module.ts new file mode 100644 index 0000000..25c7b0b --- /dev/null +++ b/backend/src/common/storage/storage.module.ts @@ -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 {} diff --git a/backend/src/common/storage/storage.service.ts b/backend/src/common/storage/storage.service.ts new file mode 100644 index 0000000..5f3c2ee --- /dev/null +++ b/backend/src/common/storage/storage.service.ts @@ -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; + + // 다운로드용 단기 presigned URL 발급(GET). 파일명/타입을 응답 헤더로 강제한다. + abstract presignDownload(input: PresignDownloadInput): Promise; + + // 객체 메타 조회 — 미존재 시 null. 업로드 완료(complete) 검증에 사용. + abstract headObject(key: string): Promise; + + // 객체 삭제(파일/미완료 업로드 정리). 미존재여도 에러 없이 통과. + abstract deleteObject(key: string): Promise; +} diff --git a/backend/src/common/storage/storage.types.ts b/backend/src/common/storage/storage.types.ts new file mode 100644 index 0000000..19ad3bf --- /dev/null +++ b/backend/src/common/storage/storage.types.ts @@ -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; + // 만료 시각(클라이언트 표시/검증용) + 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; +} diff --git a/backend/src/migrations/1782600000000-AddDriveTables.ts b/backend/src/migrations/1782600000000-AddDriveTables.ts new file mode 100644 index 0000000..11c9c28 --- /dev/null +++ b/backend/src/migrations/1782600000000-AddDriveTables.ts @@ -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 { + // ===== 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 { + // 외래키 → 테이블 순서 역순 정리 + 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"`); + } +} diff --git a/backend/src/modules/drive/drive.config.ts b/backend/src/modules/drive/drive.config.ts new file mode 100644 index 0000000..644d0c3 --- /dev/null +++ b/backend/src/modules/drive/drive.config.ts @@ -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 = new Set([ + // 이미지 + '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}`; +} diff --git a/backend/src/modules/drive/drive.controller.ts b/backend/src/modules/drive/drive.controller.ts new file mode 100644 index 0000000..08c9b80 --- /dev/null +++ b/backend/src/modules/drive/drive.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.driveService.deleteFile(user.id, fileId); + return null; + } +} diff --git a/backend/src/modules/drive/drive.module.ts b/backend/src/modules/drive/drive.module.ts new file mode 100644 index 0000000..9401411 --- /dev/null +++ b/backend/src/modules/drive/drive.module.ts @@ -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 {} diff --git a/backend/src/modules/drive/drive.service.ts b/backend/src/modules/drive/drive.service.ts new file mode 100644 index 0000000..7bf5ed9 --- /dev/null +++ b/backend/src/modules/drive/drive.service.ts @@ -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 = { + 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, + @InjectRepository(DriveFile) + private readonly fileRepo: Repository, + @InjectRepository(ProjectFolderLink) + private readonly linkRepo: Repository, + @InjectRepository(ProjectMember) + private readonly memberRepo: Repository, + private readonly storage: StorageService, + private readonly userService: UserService, + ) {} + + /* ===================== 조회 ===================== */ + + // 드라이브 목록 — folderId 없으면 내 루트, 있으면 해당 폴더(읽기 권한 필요). + async list(userId: string, folderId?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + if (folder.owner?.id === userId) return 'owner'; + // 자기 + 조상 폴더 중 하나라도 내가 멤버인 프로젝트에 연동되어 있으면 그 권한을 적용 + return this.resolveLinkedAccess(this.parseFolderPath(folder.path), userId); + } + + // 파일 접근 수준 산출 — 소유자 / 소속 폴더의 연동 권한 / 없음(루트 파일은 소유자 전용). + private async resolveFileAccess( + userId: string, + file: DriveFile, + ): Promise { + 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 { + 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 { + const user = await this.userService.findById(userId); + if (!user) { + throw new NotFoundException(); + } + return user; + } + + // 검증 실패한 업로드 폐기 — 객체 + 메타 행 제거 + private async discardFile(file: DriveFile): Promise { + await this.fileRepo.remove(file); + await this.deleteObjects([file.objectKey]); + } + + // 객체 스토리지 파일 삭제(베스트 에포트 — 실패해도 본 흐름을 막지 않음) + private async deleteObjects(keys: string[]): Promise { + 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 { + 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(), + }; + } +} diff --git a/backend/src/modules/drive/dto/create-folder.dto.ts b/backend/src/modules/drive/dto/create-folder.dto.ts new file mode 100644 index 0000000..f28dfff --- /dev/null +++ b/backend/src/modules/drive/dto/create-folder.dto.ts @@ -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; +} diff --git a/backend/src/modules/drive/dto/presign-upload.dto.ts b/backend/src/modules/drive/dto/presign-upload.dto.ts new file mode 100644 index 0000000..7b3c495 --- /dev/null +++ b/backend/src/modules/drive/dto/presign-upload.dto.ts @@ -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; +} diff --git a/backend/src/modules/drive/dto/rename.dto.ts b/backend/src/modules/drive/dto/rename.dto.ts new file mode 100644 index 0000000..02b8ca0 --- /dev/null +++ b/backend/src/modules/drive/dto/rename.dto.ts @@ -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; +} diff --git a/backend/src/modules/drive/entities/drive-file.entity.ts b/backend/src/modules/drive/entities/drive-file.entity.ts new file mode 100644 index 0000000..835d6e1 --- /dev/null +++ b/backend/src/modules/drive/entities/drive-file.entity.ts @@ -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 | 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; +} diff --git a/backend/src/modules/drive/entities/drive-folder.entity.ts b/backend/src/modules/drive/entities/drive-folder.entity.ts new file mode 100644 index 0000000..927a5a6 --- /dev/null +++ b/backend/src/modules/drive/entities/drive-folder.entity.ts @@ -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 | 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; +} diff --git a/backend/src/modules/drive/entities/project-folder-link.entity.ts b/backend/src/modules/drive/entities/project-folder-link.entity.ts new file mode 100644 index 0000000..af8aff7 --- /dev/null +++ b/backend/src/modules/drive/entities/project-folder-link.entity.ts @@ -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; + + // 연동된 드라이브 폴더 (폴더 삭제 시 연동도 함께 삭제) + @Index() + @ManyToOne(() => DriveFolder, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'folder_id' }) + folder!: Relation; + + // 연동을 수행한 사용자(폴더 소유자) — 사용자 삭제 시에도 연동은 유지(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; +} diff --git a/docker-compose.yml b/docker-compose.yml index 2f65c66..7b76f3a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 From eebdbc34f5cb293d29c5a44c43ad79c8f877b9c8 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 29 Jun 2026 21:11:59 +0900 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20git=20=EC=B6=94=EC=A0=81?= =?UTF-8?q?=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code 작업 메모(/memory)는 저장소 산출물이 아니므로 .gitignore 에 추가한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d17f507..a02cab9 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ livekit/*.local.yaml # 작업 중 공유용 임시 스크린샷(루트) 커밋 방지 /image.png + +# Claude Code 메모리 디렉터리(작업 메모) — 커밋 제외 +/memory/ From 612c4fec4c941e9f5ddc422cfc5e7aa79f090061 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 29 Jun 2026 21:11:59 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20env?= =?UTF-8?q?=20=EC=97=90=20VITE=5FLIVEKIT=5FURL=20=EB=A7=A4=ED=95=91=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker-compose 가 LIVEKIT_WS_URL → VITE_LIVEKIT_URL 로 주입함을 명시. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/.env.development.example | 1 + frontend/.env.production.example | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/.env.development.example b/frontend/.env.development.example index 3199cce..ab55572 100644 --- a/frontend/.env.development.example +++ b/frontend/.env.development.example @@ -3,5 +3,6 @@ # VITE_ 변수는 docker-compose 가 루트 .env.development 값을 build arg 로 주입한다: # VITE_API_BASE_URL ← BACKEND_API_URL # VITE_AI_TIMEOUT_MS ← AI_TIMEOUT_MS +# VITE_LIVEKIT_URL ← LIVEKIT_WS_URL # 따라서 이 파일에 별도로 작성할 값은 없다. # ========================================== diff --git a/frontend/.env.production.example b/frontend/.env.production.example index 1a11326..c2572fd 100644 --- a/frontend/.env.production.example +++ b/frontend/.env.production.example @@ -3,5 +3,6 @@ # VITE_ 변수는 docker-compose 가 루트 .env.production 값을 build arg 로 주입한다: # VITE_API_BASE_URL ← BACKEND_API_URL # VITE_AI_TIMEOUT_MS ← AI_TIMEOUT_MS +# VITE_LIVEKIT_URL ← LIVEKIT_WS_URL # 따라서 이 파일에 별도로 작성할 값은 없다. # ========================================== From 46f1875ca3e0db185b3541475ec6ff5977eb98b1 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 29 Jun 2026 21:11:59 +0900 Subject: [PATCH 4/4] =?UTF-8?q?style:=20=ED=99=94=EC=83=81=ED=9A=8C?= =?UTF-8?q?=EC=9D=98=20=EB=B0=A9=20=EC=88=98=EC=A0=95=20=EC=82=BC=ED=95=AD?= =?UTF-8?q?=20=EC=97=B0=EC=82=B0=EC=9E=90=20=ED=8F=AC=EB=A7=B7=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/modules/meeting/meeting.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/modules/meeting/meeting.service.ts b/backend/src/modules/meeting/meeting.service.ts index fec461b..a5b7738 100644 --- a/backend/src/modules/meeting/meeting.service.ts +++ b/backend/src/modules/meeting/meeting.service.ts @@ -218,7 +218,9 @@ export class MeetingService { // 이름은 표시용 라벨이라 중복 검사 없이 변경한다(코드는 불변). if (dto.name) room.name = dto.name; room.scheduledAt = dto.scheduledAt ? new Date(dto.scheduledAt) : null; - room.allowedUserIds = dto.allowedUserIds?.length ? dto.allowedUserIds : null; + room.allowedUserIds = dto.allowedUserIds?.length + ? dto.allowedUserIds + : null; await this.roomRepo.save(room); return this.toRoomResponse(room, userId); }