init studio launcher backend
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
coverage/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
FROM node:22-bookworm-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY prisma ./prisma
|
||||||
|
RUN npm run prisma:generate
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
COPY tsconfig*.json ./
|
||||||
|
COPY nest-cli.json ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
|
||||||
|
EXPOSE 4300
|
||||||
|
CMD ["node", "dist/src/main.js"]
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||||
|
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||||
|
|
||||||
|
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||||
|
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||||
|
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||||
|
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||||
|
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||||
|
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||||
|
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||||
|
</p>
|
||||||
|
<!--[](https://opencollective.com/nest#backer)
|
||||||
|
[](https://opencollective.com/nest#sponsor)-->
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compile and run the project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# development
|
||||||
|
$ npm run start
|
||||||
|
|
||||||
|
# watch mode
|
||||||
|
$ npm run start:dev
|
||||||
|
|
||||||
|
# production mode
|
||||||
|
$ npm run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# unit tests
|
||||||
|
$ npm run test
|
||||||
|
|
||||||
|
# e2e tests
|
||||||
|
$ npm run test:e2e
|
||||||
|
|
||||||
|
# test coverage
|
||||||
|
$ npm run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||||
|
|
||||||
|
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install -g @nestjs/mau
|
||||||
|
$ mau deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
Check out a few resources that may come in handy when working with NestJS:
|
||||||
|
|
||||||
|
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||||
|
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||||
|
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||||
|
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||||
|
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||||
|
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||||
|
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||||
|
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||||
|
|
||||||
|
## Stay in touch
|
||||||
|
|
||||||
|
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||||
|
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||||
|
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: autonomy-studio-launcher-postgres
|
||||||
|
command: postgres -c timezone=Asia/Seoul
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-launcher}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-launcher}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-future1234!@}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5435}:5432"
|
||||||
|
volumes:
|
||||||
|
- launcher_postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-launcher} -d ${POSTGRES_DB:-launcher}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
launcher_postgres_data:
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
sourceType: 'commonjs',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
|
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10404
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"name": "atnmy-stdo-v2-be",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:deploy": "prisma migrate deploy",
|
||||||
|
"db:seed": "ts-node prisma/seed.ts",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.1.24",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
|
"@nestjs/core": "^11.1.24",
|
||||||
|
"@nestjs/platform-express": "^11.1.24",
|
||||||
|
"@nestjs/serve-static": "^5.0.5",
|
||||||
|
"@prisma/client": "^6.19.3",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@nestjs/cli": "^11.0.21",
|
||||||
|
"@nestjs/schematics": "^11.1.0",
|
||||||
|
"@nestjs/testing": "^11.1.24",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/supertest": "^7.0.0",
|
||||||
|
"eslint": "^9.18.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.2",
|
||||||
|
"globals": "^17.0.0",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.2.5",
|
||||||
|
"ts-loader": "^9.5.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.20.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE "launcher_notices" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"link_url" TEXT,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"starts_at" TIMESTAMP(3),
|
||||||
|
"ends_at" TIMESTAMP(3),
|
||||||
|
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "launcher_notices_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "launcher_notices_is_active_priority_idx" ON "launcher_notices"("is_active", "priority");
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "launcher_notices" ADD COLUMN "app_key" TEXT NOT NULL DEFAULT 'login';
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS "launcher_notices_is_active_priority_idx";
|
||||||
|
CREATE INDEX "launcher_notices_app_key_is_active_priority_idx" ON "launcher_notices"("app_key", "is_active", "priority");
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE "launcher_app_versions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"app_key" TEXT NOT NULL,
|
||||||
|
"display_name" TEXT NOT NULL,
|
||||||
|
"latest_version" TEXT NOT NULL,
|
||||||
|
"minimum_supported_version" TEXT,
|
||||||
|
"download_url" TEXT,
|
||||||
|
"release_notes" TEXT,
|
||||||
|
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "launcher_app_versions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "launcher_app_versions_app_key_key" ON "launcher_app_versions"("app_key");
|
||||||
|
CREATE INDEX "launcher_app_versions_is_enabled_idx" ON "launcher_app_versions"("is_enabled");
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE "launcher_apps" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"app_key" TEXT NOT NULL,
|
||||||
|
"display_name" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"subtitle" TEXT,
|
||||||
|
"detail_url" TEXT,
|
||||||
|
"video_path" TEXT,
|
||||||
|
"icon_key" TEXT NOT NULL,
|
||||||
|
"launch_url" TEXT,
|
||||||
|
"dev_app_path" TEXT,
|
||||||
|
"exec_path" TEXT,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "launcher_apps_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "launcher_apps_app_key_key" ON "launcher_apps"("app_key");
|
||||||
|
CREATE INDEX "launcher_apps_is_enabled_sort_order_idx" ON "launcher_apps"("is_enabled", "sort_order");
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
COMMENT ON TABLE "launcher_notices" IS '런처 로그인 화면 및 앱별 화면에 노출할 공지사항';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."id" IS '런처 공지 고유 ID';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."app_key" IS '공지 대상 앱 식별자. login은 로그인 화면 공지';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."title" IS '공지 제목';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."body" IS '공지 본문';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."link_url" IS '자세히보기 링크 URL';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."is_active" IS '공지 활성 여부';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."starts_at" IS '공지 노출 시작 일시';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."ends_at" IS '공지 노출 종료 일시';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."priority" IS '공지 우선순위. 값이 높을수록 우선 노출';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."created_at" IS '공지 생성 일시';
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."updated_at" IS '공지 수정 일시';
|
||||||
|
|
||||||
|
COMMENT ON TABLE "launcher_app_versions" IS '런처 및 앱별 최신 버전, 다운로드 URL, 릴리즈 노트 관리';
|
||||||
|
COMMENT ON COLUMN "launcher_app_versions"."id" IS '앱 버전 레코드 고유 ID';
|
||||||
|
COMMENT ON COLUMN "launcher_app_versions"."app_key" IS '버전 대상 앱 식별자';
|
||||||
|
COMMENT ON COLUMN "launcher_app_versions"."display_name" IS '버전 대상 앱 표시명';
|
||||||
|
COMMENT ON COLUMN "launcher_app_versions"."latest_version" IS '파일서버에 배포된 최신 버전';
|
||||||
|
COMMENT ON COLUMN "launcher_app_versions"."minimum_supported_version" IS '강제 업데이트 판단용 최소 지원 버전';
|
||||||
|
COMMENT ON COLUMN "launcher_app_versions"."download_url" IS '설치 또는 업데이트 파일 다운로드 URL';
|
||||||
|
COMMENT ON COLUMN "launcher_app_versions"."release_notes" IS '업데이트 안내 문구 또는 릴리즈 노트';
|
||||||
|
COMMENT ON COLUMN "launcher_app_versions"."is_enabled" IS '버전 정보 사용 여부';
|
||||||
|
COMMENT ON COLUMN "launcher_app_versions"."created_at" IS '버전 정보 생성 일시';
|
||||||
|
COMMENT ON COLUMN "launcher_app_versions"."updated_at" IS '버전 정보 수정 일시';
|
||||||
|
|
||||||
|
COMMENT ON TABLE "launcher_apps" IS '런처에 표시할 앱 카드와 실행 정보를 관리하는 앱 마스터';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."id" IS '런처 앱 고유 ID';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."app_key" IS '런처 앱 식별자';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."display_name" IS '런처에 표시할 앱 이름';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."description" IS '앱 소개 본문';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."subtitle" IS '앱 소개 보조 문구';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."detail_url" IS '자세히보기 링크 URL';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."video_path" IS '앱 소개 영상 경로 또는 URL';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."icon_key" IS '프론트엔드 아이콘 매핑 키';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."launch_url" IS '웹 앱 실행 URL';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."dev_app_path" IS '개발 환경에서 실행할 로컬 앱 경로';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."exec_path" IS '설치 후 실행할 파일 경로 또는 파일명';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."sort_order" IS '런처 앱 표시 순서';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."is_enabled" IS '런처 앱 노출 여부';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."created_at" IS '런처 앱 생성 일시';
|
||||||
|
COMMENT ON COLUMN "launcher_apps"."updated_at" IS '런처 앱 수정 일시';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
UPDATE "launcher_notices"
|
||||||
|
SET "id" = gen_random_uuid()::text
|
||||||
|
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
|
||||||
|
|
||||||
|
UPDATE "launcher_app_versions"
|
||||||
|
SET "id" = gen_random_uuid()::text
|
||||||
|
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
|
||||||
|
|
||||||
|
UPDATE "launcher_apps"
|
||||||
|
SET "id" = gen_random_uuid()::text
|
||||||
|
WHERE "id" !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$';
|
||||||
|
|
||||||
|
ALTER TABLE "launcher_notices"
|
||||||
|
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
|
||||||
|
|
||||||
|
ALTER TABLE "launcher_app_versions"
|
||||||
|
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
|
||||||
|
|
||||||
|
ALTER TABLE "launcher_apps"
|
||||||
|
ALTER COLUMN "id" TYPE uuid USING "id"::uuid;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
CREATE TABLE "notifications" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"app_key" TEXT NOT NULL DEFAULT 'all',
|
||||||
|
"audience" TEXT NOT NULL DEFAULT 'all',
|
||||||
|
"category" TEXT NOT NULL DEFAULT 'notice',
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"link_url" TEXT,
|
||||||
|
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"starts_at" TIMESTAMP(3),
|
||||||
|
"ends_at" TIMESTAMP(3),
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "notification_reads" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"notification_id" uuid NOT NULL,
|
||||||
|
"auth_user_id" uuid NOT NULL,
|
||||||
|
"read_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "notification_reads_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "notifications_app_key_audience_is_active_priority_idx" ON "notifications"("app_key", "audience", "is_active", "priority");
|
||||||
|
CREATE INDEX "notifications_is_active_created_at_idx" ON "notifications"("is_active", "created_at");
|
||||||
|
CREATE UNIQUE INDEX "notification_reads_notification_id_auth_user_id_key" ON "notification_reads"("notification_id", "auth_user_id");
|
||||||
|
CREATE INDEX "notification_reads_auth_user_id_idx" ON "notification_reads"("auth_user_id");
|
||||||
|
|
||||||
|
ALTER TABLE "notification_reads" ADD CONSTRAINT "notification_reads_notification_id_fkey" FOREIGN KEY ("notification_id") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE "notifications" IS '런처와 모든 앱이 공통으로 사용하는 알림 메시지';
|
||||||
|
COMMENT ON COLUMN "notifications"."id" IS '알림 고유 ID';
|
||||||
|
COMMENT ON COLUMN "notifications"."app_key" IS '알림 대상 앱 식별자. all이면 전체 앱 대상';
|
||||||
|
COMMENT ON COLUMN "notifications"."audience" IS '알림 대상 사용자 그룹. all, user, student, instructor, admin 등을 사용';
|
||||||
|
COMMENT ON COLUMN "notifications"."category" IS '알림 분류';
|
||||||
|
COMMENT ON COLUMN "notifications"."title" IS '알림 제목';
|
||||||
|
COMMENT ON COLUMN "notifications"."body" IS '알림 본문';
|
||||||
|
COMMENT ON COLUMN "notifications"."link_url" IS '알림 클릭 시 이동할 링크 URL';
|
||||||
|
COMMENT ON COLUMN "notifications"."priority" IS '알림 우선순위. 값이 높을수록 우선 노출';
|
||||||
|
COMMENT ON COLUMN "notifications"."is_active" IS '알림 활성 여부';
|
||||||
|
COMMENT ON COLUMN "notifications"."starts_at" IS '알림 노출 시작 일시';
|
||||||
|
COMMENT ON COLUMN "notifications"."ends_at" IS '알림 노출 종료 일시';
|
||||||
|
COMMENT ON COLUMN "notifications"."created_at" IS '알림 생성 일시';
|
||||||
|
COMMENT ON COLUMN "notifications"."updated_at" IS '알림 수정 일시';
|
||||||
|
|
||||||
|
COMMENT ON TABLE "notification_reads" IS '사용자별 공통 알림 읽음 처리';
|
||||||
|
COMMENT ON COLUMN "notification_reads"."id" IS '알림 읽음 레코드 고유 ID';
|
||||||
|
COMMENT ON COLUMN "notification_reads"."notification_id" IS '알림 ID';
|
||||||
|
COMMENT ON COLUMN "notification_reads"."auth_user_id" IS '중앙 auth DB users.id';
|
||||||
|
COMMENT ON COLUMN "notification_reads"."read_at" IS '알림 읽음 일시';
|
||||||
|
|
||||||
|
INSERT INTO "notifications" ("app_key", "audience", "category", "title", "body", "priority", "created_at", "updated_at")
|
||||||
|
VALUES
|
||||||
|
('LMS', 'instructor', 'assignment', '신규 과제 검토 요청', '자율주행 인지 실습 과정에 제출된 과제 3건의 검토가 필요합니다.', 100, '2026-06-02T00:00:00.000Z', CURRENT_TIMESTAMP),
|
||||||
|
('LMS', 'instructor', 'schedule', '라이브 세션 일정 안내', '오늘 15:00에 SLAM 기초 라이브 세션이 예정되어 있습니다.', 90, '2026-06-01T00:00:00.000Z', CURRENT_TIMESTAMP),
|
||||||
|
('LMS', 'instructor', 'system', '시스템 점검 안내', '6월 5일 02:00부터 03:00까지 LMS 점검이 진행됩니다.', 80, '2026-05-31T00:00:00.000Z', CURRENT_TIMESTAMP),
|
||||||
|
('LMS', 'student', 'assignment', '과제 제출 마감 안내', '인지 실습 리포트 제출 마감이 오늘 18:00까지입니다.', 100, '2026-06-02T00:00:00.000Z', CURRENT_TIMESTAMP),
|
||||||
|
('LMS', 'student', 'course', '신규 강의 공개', '자율주행 센서 융합 기초 강의가 새로 공개되었습니다.', 90, '2026-06-01T00:00:00.000Z', CURRENT_TIMESTAMP),
|
||||||
|
('LMS', 'student', 'schedule', '실습 세션 안내', '내일 10:00에 ROS2 실습 세션이 예정되어 있습니다.', 80, '2026-05-31T00:00:00.000Z', CURRENT_TIMESTAMP);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
UPDATE "launcher_apps"
|
||||||
|
SET "dev_app_path" = './apps/ads-ide/ads-ide-fe',
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP
|
||||||
|
WHERE "app_key" = 'ADS IDE';
|
||||||
|
|
||||||
|
UPDATE "launcher_apps"
|
||||||
|
SET "dev_app_path" = './apps/lms/lms-fe',
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP
|
||||||
|
WHERE "app_key" = 'LMS';
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
CREATE TABLE "launcher_user_apps" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"auth_user_id" UUID NOT NULL,
|
||||||
|
"app_id" UUID NOT NULL,
|
||||||
|
"can_view" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"can_launch" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"assigned_by" UUID,
|
||||||
|
"assigned_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "launcher_user_apps_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "launcher_user_apps_auth_user_id_app_id_key" ON "launcher_user_apps"("auth_user_id", "app_id");
|
||||||
|
CREATE INDEX "launcher_user_apps_app_id_idx" ON "launcher_user_apps"("app_id");
|
||||||
|
CREATE INDEX "launcher_user_apps_auth_user_id_can_view_can_launch_idx" ON "launcher_user_apps"("auth_user_id", "can_view", "can_launch");
|
||||||
|
|
||||||
|
ALTER TABLE "launcher_user_apps"
|
||||||
|
ADD CONSTRAINT "launcher_user_apps_app_id_fkey"
|
||||||
|
FOREIGN KEY ("app_id") REFERENCES "launcher_apps"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE "launcher_user_apps" IS '사용자별 런처 앱 접근 권한. auth 사용자 UUID 기준으로 앱 표시 및 실행 가능 여부를 관리한다.';
|
||||||
|
COMMENT ON COLUMN "launcher_user_apps"."id" IS '사용자 앱 권한 고유 UUID';
|
||||||
|
COMMENT ON COLUMN "launcher_user_apps"."auth_user_id" IS 'auth DB users.id UUID';
|
||||||
|
COMMENT ON COLUMN "launcher_user_apps"."app_id" IS '권한이 부여된 런처 앱 UUID';
|
||||||
|
COMMENT ON COLUMN "launcher_user_apps"."can_view" IS '런처 앱 목록 및 상세 화면 표시 가능 여부';
|
||||||
|
COMMENT ON COLUMN "launcher_user_apps"."can_launch" IS '앱 설치, 업데이트, 실행 가능 여부';
|
||||||
|
COMMENT ON COLUMN "launcher_user_apps"."assigned_by" IS '권한을 부여한 관리자 auth 사용자 UUID';
|
||||||
|
COMMENT ON COLUMN "launcher_user_apps"."assigned_at" IS '권한 부여 일시';
|
||||||
|
COMMENT ON COLUMN "launcher_user_apps"."created_at" IS '권한 레코드 생성 일시';
|
||||||
|
COMMENT ON COLUMN "launcher_user_apps"."updated_at" IS '권한 레코드 수정 일시';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
CREATE TABLE "common_codes" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"group_key" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "common_codes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "common_codes_group_key_code_key" ON "common_codes"("group_key", "code");
|
||||||
|
CREATE INDEX "common_codes_group_key_is_active_sort_order_idx" ON "common_codes"("group_key", "is_active", "sort_order");
|
||||||
|
|
||||||
|
COMMENT ON TABLE "common_codes" IS '공통 코드 마스터. 알림 카테고리 등 화면 표시명과 코드값을 관리한다.';
|
||||||
|
COMMENT ON COLUMN "common_codes"."id" IS '공통 코드 고유 UUID';
|
||||||
|
COMMENT ON COLUMN "common_codes"."group_key" IS '공통 코드 그룹 키';
|
||||||
|
COMMENT ON COLUMN "common_codes"."code" IS '업무에서 사용하는 코드값';
|
||||||
|
COMMENT ON COLUMN "common_codes"."label" IS '화면에 표시할 코드명';
|
||||||
|
COMMENT ON COLUMN "common_codes"."description" IS '공통 코드 설명';
|
||||||
|
COMMENT ON COLUMN "common_codes"."sort_order" IS '같은 그룹 내 표시 순서';
|
||||||
|
COMMENT ON COLUMN "common_codes"."is_active" IS '공통 코드 사용 여부';
|
||||||
|
COMMENT ON COLUMN "common_codes"."created_at" IS '공통 코드 생성 일시';
|
||||||
|
COMMENT ON COLUMN "common_codes"."updated_at" IS '공통 코드 수정 일시';
|
||||||
|
|
||||||
|
INSERT INTO "common_codes" ("group_key", "code", "label", "description", "sort_order")
|
||||||
|
VALUES
|
||||||
|
('NOTIFICATION_CATEGORY', 'assignment', '과제', '과제 및 제출 관련 알림', 10),
|
||||||
|
('NOTIFICATION_CATEGORY', 'course', '강의', '강의 및 커리큘럼 관련 알림', 20),
|
||||||
|
('NOTIFICATION_CATEGORY', 'notice', '공지', '일반 공지 알림', 30),
|
||||||
|
('NOTIFICATION_CATEGORY', 'update', '업데이트', '업데이트 및 배포 알림', 40),
|
||||||
|
('NOTIFICATION_CATEGORY', 'system', '시스템', '시스템 운영 알림', 50)
|
||||||
|
ON CONFLICT ("group_key", "code") DO UPDATE
|
||||||
|
SET "label" = EXCLUDED."label",
|
||||||
|
"description" = EXCLUDED."description",
|
||||||
|
"sort_order" = EXCLUDED."sort_order",
|
||||||
|
"is_active" = true,
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
CREATE TABLE "download_files" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"app_key" TEXT NOT NULL,
|
||||||
|
"context_key" TEXT NOT NULL DEFAULT 'general',
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"file_name" TEXT NOT NULL,
|
||||||
|
"source_url" TEXT NOT NULL,
|
||||||
|
"content_type" TEXT,
|
||||||
|
"file_size_bytes" BIGINT,
|
||||||
|
"uploaded_by_app_key" TEXT,
|
||||||
|
"uploaded_by_auth_id" uuid,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "download_files_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "download_events" (
|
||||||
|
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"file_id" uuid NOT NULL,
|
||||||
|
"auth_user_id" uuid,
|
||||||
|
"app_key" TEXT NOT NULL,
|
||||||
|
"user_agent" TEXT,
|
||||||
|
"ip_address" TEXT,
|
||||||
|
"downloaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "download_events_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "download_files_app_key_context_key_is_active_sort_order_idx" ON "download_files"("app_key", "context_key", "is_active", "sort_order");
|
||||||
|
CREATE INDEX "download_files_uploaded_by_app_key_idx" ON "download_files"("uploaded_by_app_key");
|
||||||
|
CREATE INDEX "download_events_file_id_downloaded_at_idx" ON "download_events"("file_id", "downloaded_at");
|
||||||
|
CREATE INDEX "download_events_auth_user_id_downloaded_at_idx" ON "download_events"("auth_user_id", "downloaded_at");
|
||||||
|
CREATE INDEX "download_events_app_key_downloaded_at_idx" ON "download_events"("app_key", "downloaded_at");
|
||||||
|
|
||||||
|
ALTER TABLE "download_events"
|
||||||
|
ADD CONSTRAINT "download_events_file_id_fkey"
|
||||||
|
FOREIGN KEY ("file_id") REFERENCES "download_files"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE "download_files" IS '스튜디오 앱들이 공통 다운로드 API로 제공하는 파일 메타데이터';
|
||||||
|
COMMENT ON COLUMN "download_files"."app_key" IS '파일이 노출될 앱 구분 키';
|
||||||
|
COMMENT ON COLUMN "download_files"."context_key" IS '앱 내부 화면 또는 영역 구분 키';
|
||||||
|
COMMENT ON COLUMN "download_files"."title" IS '사용자에게 표시할 파일 제목';
|
||||||
|
COMMENT ON COLUMN "download_files"."description" IS '파일 설명';
|
||||||
|
COMMENT ON COLUMN "download_files"."file_name" IS '다운로드 시 사용할 파일명';
|
||||||
|
COMMENT ON COLUMN "download_files"."source_url" IS '실제 원본 파일 URL';
|
||||||
|
COMMENT ON COLUMN "download_files"."content_type" IS '다운로드 응답에 사용할 MIME 타입';
|
||||||
|
COMMENT ON COLUMN "download_files"."file_size_bytes" IS '파일 크기 바이트';
|
||||||
|
COMMENT ON COLUMN "download_files"."uploaded_by_app_key" IS '파일을 등록한 앱 구분 키';
|
||||||
|
COMMENT ON COLUMN "download_files"."uploaded_by_auth_id" IS '파일을 등록한 중앙 auth 사용자 ID';
|
||||||
|
COMMENT ON COLUMN "download_files"."sort_order" IS '목록 표시 순서';
|
||||||
|
COMMENT ON COLUMN "download_files"."is_active" IS '다운로드 노출 여부';
|
||||||
|
|
||||||
|
COMMENT ON TABLE "download_events" IS '공통 다운로드 API를 통한 파일 다운로드 이력';
|
||||||
|
COMMENT ON COLUMN "download_events"."file_id" IS '다운로드한 파일 ID';
|
||||||
|
COMMENT ON COLUMN "download_events"."auth_user_id" IS '다운로드한 중앙 auth 사용자 ID';
|
||||||
|
COMMENT ON COLUMN "download_events"."app_key" IS '다운로드가 발생한 앱 구분 키';
|
||||||
|
COMMENT ON COLUMN "download_events"."user_agent" IS '요청 User-Agent';
|
||||||
|
COMMENT ON COLUMN "download_events"."ip_address" IS '요청 IP 주소';
|
||||||
|
COMMENT ON COLUMN "download_events"."downloaded_at" IS '다운로드 발생 시각';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
INSERT INTO "common_codes" ("group_key", "code", "label", "description", "sort_order")
|
||||||
|
VALUES ('NOTIFICATION_CATEGORY', 'download', '다운로드', '파일 다운로드 관련 알림', 60)
|
||||||
|
ON CONFLICT ("group_key", "code") DO UPDATE SET
|
||||||
|
"label" = EXCLUDED."label",
|
||||||
|
"description" = EXCLUDED."description",
|
||||||
|
"sort_order" = EXCLUDED."sort_order",
|
||||||
|
"is_active" = true,
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
column_record record;
|
||||||
|
BEGIN
|
||||||
|
FOR column_record IN
|
||||||
|
SELECT table_schema, table_name, column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND data_type = 'timestamp without time zone'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format(
|
||||||
|
'ALTER TABLE %I.%I ALTER COLUMN %I TYPE timestamptz USING %I AT TIME ZONE ''UTC''',
|
||||||
|
column_record.table_schema,
|
||||||
|
column_record.table_name,
|
||||||
|
column_record.column_name,
|
||||||
|
column_record.column_name
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
CREATE TABLE "provisioned_devices" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"account_email" TEXT NOT NULL,
|
||||||
|
"auth_user_id" UUID,
|
||||||
|
"hostname" TEXT NOT NULL,
|
||||||
|
"tailscale_hostname" TEXT NOT NULL,
|
||||||
|
"ssh_user" TEXT,
|
||||||
|
"usage_type" TEXT NOT NULL DEFAULT 'personal',
|
||||||
|
"os" TEXT,
|
||||||
|
"arch" TEXT,
|
||||||
|
"kernel" TEXT,
|
||||||
|
"distro" TEXT,
|
||||||
|
"device" JSONB,
|
||||||
|
"installed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"last_provisioned_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "provisioned_devices_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "provisioned_devices_account_email_tailscale_hostname_key"
|
||||||
|
ON "provisioned_devices"("account_email", "tailscale_hostname");
|
||||||
|
|
||||||
|
CREATE INDEX "provisioned_devices_account_email_idx"
|
||||||
|
ON "provisioned_devices"("account_email");
|
||||||
|
|
||||||
|
CREATE INDEX "provisioned_devices_tailscale_hostname_idx"
|
||||||
|
ON "provisioned_devices"("tailscale_hostname");
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE "launcher_ssh_keys" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"account_email" TEXT NOT NULL,
|
||||||
|
"auth_user_id" UUID,
|
||||||
|
"public_key" TEXT NOT NULL,
|
||||||
|
"public_key_hash" TEXT NOT NULL,
|
||||||
|
"key_type" TEXT,
|
||||||
|
"label" TEXT,
|
||||||
|
"last_provisioned_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "launcher_ssh_keys_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "launcher_ssh_keys_account_email_public_key_hash_key"
|
||||||
|
ON "launcher_ssh_keys"("account_email", "public_key_hash");
|
||||||
|
|
||||||
|
CREATE INDEX "launcher_ssh_keys_account_email_idx"
|
||||||
|
ON "launcher_ssh_keys"("account_email");
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
COMMENT ON TABLE "provisioned_devices" IS '에이전트/런처 설치 시 계정과 장비를 프로비저닝한 이력을 저장한다.';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."id" IS '프로비저닝 장비 이력 ID';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."account_email" IS '설치 또는 로그인 프로비저닝을 요청한 계정 이메일';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."auth_user_id" IS '인증 서버의 사용자 ID';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."hostname" IS '설치 장비에서 감지한 원본 호스트명';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."tailscale_hostname" IS 'Headscale/Tailscale에 등록하는 노드 호스트명';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."ssh_user" IS '원격 SSH 접속에 사용할 장비 OS 사용자명';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."usage_type" IS '프로비저닝 용도. personal, class, launcher-client 등';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."os" IS '장비 운영체제 계열';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."arch" IS '장비 CPU 아키텍처';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."kernel" IS '장비 커널 버전';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."distro" IS '장비 배포판/OS 상세 정보';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."device" IS '설치 요청에서 전달된 장비 정보 원본 JSON';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."installed_at" IS '최초 설치 이력 생성 시각';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."last_provisioned_at" IS '마지막 프로비저닝 요청 시각';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."created_at" IS '레코드 생성 시각';
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."updated_at" IS '레코드 수정 시각';
|
||||||
|
|
||||||
|
COMMENT ON TABLE "launcher_ssh_keys" IS '런처가 생성한 사용자/PC별 SSH public key를 저장한다. private key는 로컬 PC에만 존재한다.';
|
||||||
|
COMMENT ON COLUMN "launcher_ssh_keys"."id" IS '런처 SSH public key ID';
|
||||||
|
COMMENT ON COLUMN "launcher_ssh_keys"."account_email" IS 'public key를 등록한 사용자 이메일';
|
||||||
|
COMMENT ON COLUMN "launcher_ssh_keys"."auth_user_id" IS '인증 서버의 사용자 ID';
|
||||||
|
COMMENT ON COLUMN "launcher_ssh_keys"."public_key" IS '장비 authorized_keys에 배포할 SSH public key';
|
||||||
|
COMMENT ON COLUMN "launcher_ssh_keys"."public_key_hash" IS '중복 등록 방지를 위한 public key SHA-256 해시';
|
||||||
|
COMMENT ON COLUMN "launcher_ssh_keys"."key_type" IS 'SSH key 타입. 예: ssh-ed25519';
|
||||||
|
COMMENT ON COLUMN "launcher_ssh_keys"."label" IS '키 출처 또는 용도 라벨';
|
||||||
|
COMMENT ON COLUMN "launcher_ssh_keys"."last_provisioned_at" IS '마지막 키 등록/갱신 시각';
|
||||||
|
COMMENT ON COLUMN "launcher_ssh_keys"."created_at" IS '레코드 생성 시각';
|
||||||
|
COMMENT ON COLUMN "launcher_ssh_keys"."updated_at" IS '레코드 수정 시각';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "provisioned_devices"
|
||||||
|
ADD COLUMN "workspace_root" TEXT;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN "provisioned_devices"."workspace_root" IS '에이전트 설치 시 입력한 원격 워크스페이스 루트 경로';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE "launcher_notices"
|
||||||
|
ALTER COLUMN "app_key" SET DEFAULT 'Launcher';
|
||||||
|
|
||||||
|
UPDATE "launcher_notices"
|
||||||
|
SET "app_key" = 'Launcher'
|
||||||
|
WHERE "app_key" IN ('login', 'launcher');
|
||||||
|
|
||||||
|
COMMENT ON COLUMN "launcher_notices"."app_key" IS '공지 대상 앱 식별자. Launcher는 런처 로그인 화면 공지';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
UPDATE "launcher_notices"
|
||||||
|
SET "priority" = 0;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
ALTER TABLE "launcher_app_versions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "repository" TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS "branch" TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS "commit_sha" TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "launcher_common_settings" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"is_secret" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "launcher_common_settings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "launcher_common_settings_key_key"
|
||||||
|
ON "launcher_common_settings"("key");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "launcher_common_settings_type_is_active_idx"
|
||||||
|
ON "launcher_common_settings"("type", "is_active");
|
||||||
|
|
||||||
|
COMMENT ON TABLE "launcher_common_settings" IS '런처 공통 연결 설정과 플랫폼 연동 설정';
|
||||||
|
COMMENT ON COLUMN "launcher_common_settings"."key" IS '공통 설정 식별 키';
|
||||||
|
COMMENT ON COLUMN "launcher_common_settings"."name" IS '공통 설정 표시 이름';
|
||||||
|
COMMENT ON COLUMN "launcher_common_settings"."type" IS '공통 설정 유형';
|
||||||
|
COMMENT ON COLUMN "launcher_common_settings"."value" IS '공통 설정 값';
|
||||||
|
COMMENT ON COLUMN "launcher_common_settings"."description" IS '공통 설정 설명';
|
||||||
|
COMMENT ON COLUMN "launcher_common_settings"."is_active" IS '공통 설정 활성 여부';
|
||||||
|
COMMENT ON COLUMN "launcher_common_settings"."is_secret" IS '민감 정보 여부';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LauncherNotice {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
appKey String @default("Launcher") @map("app_key")
|
||||||
|
title String
|
||||||
|
body String
|
||||||
|
linkUrl String? @map("link_url")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
|
||||||
|
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
|
||||||
|
priority Int @default(0)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@index([appKey, isActive, priority])
|
||||||
|
@@map("launcher_notices")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LauncherAppVersion {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
appKey String @unique @map("app_key")
|
||||||
|
displayName String @map("display_name")
|
||||||
|
latestVersion String @map("latest_version")
|
||||||
|
minimumSupportedVersion String? @map("minimum_supported_version")
|
||||||
|
downloadUrl String? @map("download_url")
|
||||||
|
releaseNotes String? @map("release_notes")
|
||||||
|
repository String?
|
||||||
|
branch String?
|
||||||
|
commitSha String? @map("commit_sha")
|
||||||
|
isEnabled Boolean @default(true) @map("is_enabled")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@index([isEnabled])
|
||||||
|
@@map("launcher_app_versions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LauncherCommonSetting {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
key String @unique
|
||||||
|
name String
|
||||||
|
type String
|
||||||
|
value String
|
||||||
|
description String?
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
isSecret Boolean @default(false) @map("is_secret")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@index([type, isActive])
|
||||||
|
@@map("launcher_common_settings")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LauncherApp {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
appKey String @unique @map("app_key")
|
||||||
|
displayName String @map("display_name")
|
||||||
|
description String
|
||||||
|
subtitle String?
|
||||||
|
detailUrl String? @map("detail_url")
|
||||||
|
videoPath String? @map("video_path")
|
||||||
|
iconKey String @map("icon_key")
|
||||||
|
launchUrl String? @map("launch_url")
|
||||||
|
devAppPath String? @map("dev_app_path")
|
||||||
|
execPath String? @map("exec_path")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isEnabled Boolean @default(true) @map("is_enabled")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
userApps LauncherUserApp[]
|
||||||
|
|
||||||
|
@@index([isEnabled, sortOrder])
|
||||||
|
@@map("launcher_apps")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LauncherUserApp {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
authUserId String @map("auth_user_id") @db.Uuid
|
||||||
|
appId String @map("app_id") @db.Uuid
|
||||||
|
canView Boolean @default(true) @map("can_view")
|
||||||
|
canLaunch Boolean @default(true) @map("can_launch")
|
||||||
|
assignedById String? @map("assigned_by") @db.Uuid
|
||||||
|
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
app LauncherApp @relation(fields: [appId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([authUserId, appId])
|
||||||
|
@@index([appId])
|
||||||
|
@@index([authUserId, canView, canLaunch])
|
||||||
|
@@map("launcher_user_apps")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
appKey String @default("all") @map("app_key")
|
||||||
|
audience String @default("all")
|
||||||
|
category String @default("notice")
|
||||||
|
title String
|
||||||
|
body String
|
||||||
|
linkUrl String? @map("link_url")
|
||||||
|
priority Int @default(0)
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
|
||||||
|
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
reads NotificationRead[]
|
||||||
|
|
||||||
|
@@index([appKey, audience, isActive, priority])
|
||||||
|
@@index([isActive, createdAt])
|
||||||
|
@@map("notifications")
|
||||||
|
}
|
||||||
|
|
||||||
|
model NotificationRead {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
notificationId String @map("notification_id") @db.Uuid
|
||||||
|
authUserId String @map("auth_user_id") @db.Uuid
|
||||||
|
readAt DateTime @default(now()) @map("read_at") @db.Timestamptz(3)
|
||||||
|
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([notificationId, authUserId])
|
||||||
|
@@index([authUserId])
|
||||||
|
@@map("notification_reads")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CommonCode {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
groupKey String @map("group_key")
|
||||||
|
code String
|
||||||
|
label String
|
||||||
|
description String?
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@unique([groupKey, code])
|
||||||
|
@@index([groupKey, isActive, sortOrder])
|
||||||
|
@@map("common_codes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DownloadFile {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
appKey String @map("app_key")
|
||||||
|
contextKey String @default("general") @map("context_key")
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
fileName String @map("file_name")
|
||||||
|
sourceUrl String @map("source_url")
|
||||||
|
contentType String? @map("content_type")
|
||||||
|
fileSizeBytes BigInt? @map("file_size_bytes")
|
||||||
|
uploadedByAppKey String? @map("uploaded_by_app_key")
|
||||||
|
uploadedByAuthId String? @map("uploaded_by_auth_id") @db.Uuid
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
events DownloadEvent[]
|
||||||
|
|
||||||
|
@@index([appKey, contextKey, isActive, sortOrder])
|
||||||
|
@@index([uploadedByAppKey])
|
||||||
|
@@map("download_files")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DownloadEvent {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
fileId String @map("file_id") @db.Uuid
|
||||||
|
authUserId String? @map("auth_user_id") @db.Uuid
|
||||||
|
appKey String @map("app_key")
|
||||||
|
userAgent String? @map("user_agent")
|
||||||
|
ipAddress String? @map("ip_address")
|
||||||
|
downloadedAt DateTime @default(now()) @map("downloaded_at") @db.Timestamptz(3)
|
||||||
|
file DownloadFile @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([fileId, downloadedAt])
|
||||||
|
@@index([authUserId, downloadedAt])
|
||||||
|
@@index([appKey, downloadedAt])
|
||||||
|
@@map("download_events")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProvisionedDevice {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
accountEmail String @map("account_email")
|
||||||
|
authUserId String? @map("auth_user_id") @db.Uuid
|
||||||
|
hostname String
|
||||||
|
tailscaleHostname String @map("tailscale_hostname")
|
||||||
|
sshUser String? @map("ssh_user")
|
||||||
|
workspaceRoot String? @map("workspace_root")
|
||||||
|
usageType String @default("personal") @map("usage_type")
|
||||||
|
os String?
|
||||||
|
arch String?
|
||||||
|
kernel String?
|
||||||
|
distro String?
|
||||||
|
device Json?
|
||||||
|
installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3)
|
||||||
|
lastProvisionedAt DateTime @default(now()) @map("last_provisioned_at") @db.Timestamptz(3)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@unique([accountEmail, tailscaleHostname])
|
||||||
|
@@index([accountEmail])
|
||||||
|
@@index([tailscaleHostname])
|
||||||
|
@@map("provisioned_devices")
|
||||||
|
}
|
||||||
|
|
||||||
|
model LauncherSshKey {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
accountEmail String @map("account_email")
|
||||||
|
authUserId String? @map("auth_user_id") @db.Uuid
|
||||||
|
publicKey String @map("public_key")
|
||||||
|
publicKeyHash String @map("public_key_hash")
|
||||||
|
keyType String? @map("key_type")
|
||||||
|
label String?
|
||||||
|
lastProvisionedAt DateTime @default(now()) @map("last_provisioned_at") @db.Timestamptz(3)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@unique([accountEmail, publicKeyHash])
|
||||||
|
@@index([accountEmail])
|
||||||
|
@@map("launcher_ssh_keys")
|
||||||
|
}
|
||||||
+383
@@ -0,0 +1,383 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const appNotices = [
|
||||||
|
{
|
||||||
|
id: 'app-notice-ads-ide-v1-1-0',
|
||||||
|
appKey: 'ADS IDE',
|
||||||
|
title: 'v1.0.1 업데이트 안내',
|
||||||
|
body: 'ADS IDE 신규 기능 및 성능 개선 사항이 포함된 업데이트가 배포되었습니다.',
|
||||||
|
linkUrl: 'https://www.futuredrive.net/solutions/astudio',
|
||||||
|
createdAt: new Date('2025-04-23T00:00:00.000Z'),
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-notice-ads-ide-maintenance-2025-04-10',
|
||||||
|
appKey: 'ADS IDE',
|
||||||
|
title: '정기 점검 안내',
|
||||||
|
body: '4월 15일 02:00 ~ 04:00 서버 정기 점검이 진행됩니다.',
|
||||||
|
linkUrl: 'https://www.futuredrive.net/solutions/astudio',
|
||||||
|
createdAt: new Date('2025-04-10T00:00:00.000Z'),
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-notice-data-management-retention-2025-04-18',
|
||||||
|
appKey: 'Data Management',
|
||||||
|
title: '데이터 보존 정책 변경 안내',
|
||||||
|
body: '5월 1일부터 로그 데이터 보존 기간이 90일로 변경됩니다.',
|
||||||
|
createdAt: new Date('2025-04-18T00:00:00.000Z'),
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-notice-fms-v2-3-1',
|
||||||
|
appKey: 'FMS',
|
||||||
|
title: 'FMS v2.3.1 배포',
|
||||||
|
body: '차량 관제 대시보드 UI 개선 및 실시간 알림 기능이 추가되었습니다.',
|
||||||
|
createdAt: new Date('2025-04-20T00:00:00.000Z'),
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-notice-ads-education-curriculum-2025-04-15',
|
||||||
|
appKey: 'ADS Education',
|
||||||
|
title: '신규 커리큘럼 추가',
|
||||||
|
body: '자율주행 센서 융합 과정이 새롭게 추가되었습니다.',
|
||||||
|
createdAt: new Date('2025-04-15T00:00:00.000Z'),
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-notice-calibration-lidar-2025-04-21',
|
||||||
|
appKey: 'Calibration',
|
||||||
|
title: 'LiDAR 캘리브레이션 개선',
|
||||||
|
body: '다중 LiDAR 동시 캘리브레이션 지원이 추가되었습니다.',
|
||||||
|
createdAt: new Date('2025-04-21T00:00:00.000Z'),
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'app-notice-hdmap-builder-format-2025-04-12',
|
||||||
|
appKey: 'HDMAP Builder',
|
||||||
|
title: '지도 포맷 업데이트',
|
||||||
|
body: 'OpenDRIVE 1.7 포맷 지원이 추가되었습니다.',
|
||||||
|
createdAt: new Date('2025-04-12T00:00:00.000Z'),
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const appVersions = [
|
||||||
|
{
|
||||||
|
appKey: 'launcher',
|
||||||
|
displayName: 'Autonomy Studio Launcher',
|
||||||
|
latestVersion: '1.0.1',
|
||||||
|
minimumSupportedVersion: '1.0.0',
|
||||||
|
downloadUrl: 'https://testfile.autonomystudio.net/media/platform/autonomy-studio-launcher-1.0.1.deb',
|
||||||
|
releaseNotes: '런처 업데이트 확인 및 앱별 설치 상태 판단 기능이 추가되었습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'ADS IDE',
|
||||||
|
displayName: 'ADS IDE',
|
||||||
|
latestVersion: '1.1.0',
|
||||||
|
minimumSupportedVersion: '0.1.0',
|
||||||
|
downloadUrl: 'https://testfile.autonomystudio.net/media/platform/ADS-IDE-1.1.0.AppImage',
|
||||||
|
releaseNotes: 'ADS IDE 신규 기능 및 성능 개선 사항이 포함되어 있습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'LMS',
|
||||||
|
displayName: 'LMS',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
minimumSupportedVersion: '1.0.0',
|
||||||
|
downloadUrl: 'https://autonomystudio.example.com/downloads/lms',
|
||||||
|
releaseNotes: '학습 관리 시스템 최초 배포 버전입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'Data Management',
|
||||||
|
displayName: 'Data Management',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
minimumSupportedVersion: '1.0.0',
|
||||||
|
downloadUrl: 'https://autonomystudio.example.com/downloads/data-management',
|
||||||
|
releaseNotes: '데이터 관리 앱 최초 배포 버전입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'HMI',
|
||||||
|
displayName: 'HMI',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
minimumSupportedVersion: '1.0.0',
|
||||||
|
downloadUrl: 'https://autonomystudio.example.com/downloads/hmi',
|
||||||
|
releaseNotes: 'HMI 앱 최초 배포 버전입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'FMS',
|
||||||
|
displayName: 'FMS',
|
||||||
|
latestVersion: '2.3.1',
|
||||||
|
minimumSupportedVersion: '2.0.0',
|
||||||
|
downloadUrl: 'https://autonomystudio.example.com/downloads/fms',
|
||||||
|
releaseNotes: '차량 관제 대시보드 UI 개선 및 실시간 알림 기능이 추가되었습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'ADS Education',
|
||||||
|
displayName: 'ADS Education',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
minimumSupportedVersion: '1.0.0',
|
||||||
|
downloadUrl: 'https://autonomystudio.example.com/downloads/ads-education',
|
||||||
|
releaseNotes: '자율주행 교육 앱 최초 배포 버전입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'AI Benchmark',
|
||||||
|
displayName: 'AI Benchmark',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
minimumSupportedVersion: '1.0.0',
|
||||||
|
downloadUrl: 'https://autonomystudio.example.com/downloads/ai-benchmark',
|
||||||
|
releaseNotes: 'AI Benchmark 앱 최초 배포 버전입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'Calibration',
|
||||||
|
displayName: 'Calibration',
|
||||||
|
latestVersion: '1.0.4',
|
||||||
|
minimumSupportedVersion: '1.0.0',
|
||||||
|
downloadUrl: 'https://autonomystudio.example.com/downloads/calibration',
|
||||||
|
releaseNotes: '다중 LiDAR 동시 캘리브레이션 지원이 추가되었습니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'IP Repository',
|
||||||
|
displayName: 'IP Repository',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
minimumSupportedVersion: '1.0.0',
|
||||||
|
downloadUrl: 'https://autonomystudio.example.com/downloads/ip-repository',
|
||||||
|
releaseNotes: 'IP Repository 앱 최초 배포 버전입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'HDMAP Builder',
|
||||||
|
displayName: 'HDMAP Builder',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
minimumSupportedVersion: '1.0.0',
|
||||||
|
downloadUrl: 'https://autonomystudio.example.com/downloads/hdmap-builder',
|
||||||
|
releaseNotes: 'OpenDRIVE 1.7 포맷 지원이 추가되었습니다.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const launcherApps = [
|
||||||
|
{
|
||||||
|
appKey: 'ADS IDE',
|
||||||
|
displayName: 'ADS IDE',
|
||||||
|
description: '클라우드 기반에서 자율주행 소프트웨어를 설계·빌드·실행까지 빠르게 수행할 수 있는 통합 개발 플랫폼으로, 오픈소스 ADS 기반의 유연한 구조를 통해 기능 수정과 배포가 가능하며 다양한 실차 및 시뮬레이션 환경을 지원합니다.',
|
||||||
|
subtitle: '자율주행 모빌리티 소프트웨어\n통합 개발 환경 (IDE)',
|
||||||
|
detailUrl: 'https://www.futuredrive.net/solutions/astudio',
|
||||||
|
videoPath: './video/ads_ide.mp4',
|
||||||
|
iconKey: 'code',
|
||||||
|
launchUrl: 'http://localhost:5175',
|
||||||
|
devAppPath: './apps/ads-ide/ads-ide-fe',
|
||||||
|
execPath: './ADS-IDE-1.1.0.AppImage',
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'LMS',
|
||||||
|
displayName: 'LMS',
|
||||||
|
description: '자율주행 교육 과정, 실습 콘텐츠, 사용자 학습 이력을 통합 관리하는 학습 관리 시스템입니다.',
|
||||||
|
subtitle: '자율주행 교육 콘텐츠\n학습 관리 시스템',
|
||||||
|
detailUrl: null,
|
||||||
|
videoPath: './video/lms.mp4',
|
||||||
|
iconKey: 'graduation',
|
||||||
|
launchUrl: 'http://localhost:5176',
|
||||||
|
devAppPath: './apps/lms/lms-fe',
|
||||||
|
execPath: './LMS-1.0.0.AppImage',
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'Data Management',
|
||||||
|
displayName: 'Data Management',
|
||||||
|
description: '주행 데이터 수집 · 저장 · 분석 플랫폼',
|
||||||
|
subtitle: null,
|
||||||
|
detailUrl: null,
|
||||||
|
videoPath: './video/data_management.mp4',
|
||||||
|
iconKey: 'bar-chart',
|
||||||
|
launchUrl: null,
|
||||||
|
devAppPath: null,
|
||||||
|
execPath: null,
|
||||||
|
sortOrder: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'HMI',
|
||||||
|
displayName: 'HMI',
|
||||||
|
description: '차량 인터페이스 모니터링 및 제어',
|
||||||
|
subtitle: null,
|
||||||
|
detailUrl: null,
|
||||||
|
videoPath: './video/hmi.mp4',
|
||||||
|
iconKey: 'monitor',
|
||||||
|
launchUrl: null,
|
||||||
|
devAppPath: null,
|
||||||
|
execPath: null,
|
||||||
|
sortOrder: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'FMS',
|
||||||
|
displayName: 'FMS',
|
||||||
|
description: '플릿 운영 및 차량 관제 시스템',
|
||||||
|
subtitle: null,
|
||||||
|
detailUrl: null,
|
||||||
|
videoPath: './video/fms.mp4',
|
||||||
|
iconKey: 'truck',
|
||||||
|
launchUrl: null,
|
||||||
|
devAppPath: null,
|
||||||
|
execPath: null,
|
||||||
|
sortOrder: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'ADS Education',
|
||||||
|
displayName: 'ADS Education',
|
||||||
|
description: '자율주행 기술 학습 및 교육 콘텐츠',
|
||||||
|
subtitle: null,
|
||||||
|
detailUrl: null,
|
||||||
|
videoPath: './video/ads_education.mp4',
|
||||||
|
iconKey: 'graduation',
|
||||||
|
launchUrl: null,
|
||||||
|
devAppPath: null,
|
||||||
|
execPath: null,
|
||||||
|
sortOrder: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'AI Benchmark',
|
||||||
|
displayName: 'AI Benchmark',
|
||||||
|
description: 'AI 모델 성능 평가 및 비교 분석',
|
||||||
|
subtitle: null,
|
||||||
|
detailUrl: null,
|
||||||
|
videoPath: './video/ai_benchmark.mp4',
|
||||||
|
iconKey: 'activity',
|
||||||
|
launchUrl: null,
|
||||||
|
devAppPath: null,
|
||||||
|
execPath: null,
|
||||||
|
sortOrder: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'Calibration',
|
||||||
|
displayName: 'Calibration',
|
||||||
|
description: '센서 캘리브레이션 및 설정 도구',
|
||||||
|
subtitle: null,
|
||||||
|
detailUrl: null,
|
||||||
|
videoPath: './video/calibration.mp4',
|
||||||
|
iconKey: 'sliders',
|
||||||
|
launchUrl: null,
|
||||||
|
devAppPath: null,
|
||||||
|
execPath: null,
|
||||||
|
sortOrder: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'IP Repository',
|
||||||
|
displayName: 'IP Repository',
|
||||||
|
description: '지식재산 및 소프트웨어 자산 저장소',
|
||||||
|
subtitle: null,
|
||||||
|
detailUrl: null,
|
||||||
|
videoPath: './video/ip_repository.mp4',
|
||||||
|
iconKey: 'shopping-cart',
|
||||||
|
launchUrl: null,
|
||||||
|
devAppPath: null,
|
||||||
|
execPath: null,
|
||||||
|
sortOrder: 90,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
appKey: 'HDMAP Builder',
|
||||||
|
displayName: 'HDMAP Builder',
|
||||||
|
description: '고정밀 지도 생성 및 편집 도구',
|
||||||
|
subtitle: null,
|
||||||
|
detailUrl: null,
|
||||||
|
videoPath: './video/hdmap_builder.mp4',
|
||||||
|
iconKey: 'map',
|
||||||
|
launchUrl: null,
|
||||||
|
devAppPath: null,
|
||||||
|
execPath: null,
|
||||||
|
sortOrder: 100,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
await prisma.launcherNotice.upsert({
|
||||||
|
where: { id: 'launcher-notice-v1-1-0' },
|
||||||
|
create: {
|
||||||
|
id: 'launcher-notice-v1-1-0',
|
||||||
|
appKey: 'Launcher',
|
||||||
|
title: '공지',
|
||||||
|
body: 'v1.0.1 업데이트가 배포되었습니다. ADS IDE 신규 기능 및 성능 개선 사항이 포함되어 있습니다.',
|
||||||
|
isActive: true,
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
title: '공지',
|
||||||
|
body: 'v1.0.1 업데이트가 배포되었습니다. ADS IDE 신규 기능 및 성능 개선 사항이 포함되어 있습니다.',
|
||||||
|
appKey: 'Launcher',
|
||||||
|
isActive: true,
|
||||||
|
priority: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const notice of appNotices) {
|
||||||
|
await prisma.launcherNotice.upsert({
|
||||||
|
where: { id: notice.id },
|
||||||
|
create: {
|
||||||
|
...notice,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
appKey: notice.appKey,
|
||||||
|
title: notice.title,
|
||||||
|
body: notice.body,
|
||||||
|
linkUrl: notice.linkUrl ?? null,
|
||||||
|
createdAt: notice.createdAt,
|
||||||
|
isActive: true,
|
||||||
|
priority: notice.priority,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const appVersion of appVersions) {
|
||||||
|
await prisma.launcherAppVersion.upsert({
|
||||||
|
where: { appKey: appVersion.appKey },
|
||||||
|
create: {
|
||||||
|
...appVersion,
|
||||||
|
isEnabled: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
displayName: appVersion.displayName,
|
||||||
|
latestVersion: appVersion.latestVersion,
|
||||||
|
minimumSupportedVersion: appVersion.minimumSupportedVersion,
|
||||||
|
downloadUrl: appVersion.downloadUrl,
|
||||||
|
releaseNotes: appVersion.releaseNotes,
|
||||||
|
isEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const launcherApp of launcherApps) {
|
||||||
|
await prisma.launcherApp.upsert({
|
||||||
|
where: { appKey: launcherApp.appKey },
|
||||||
|
create: {
|
||||||
|
...launcherApp,
|
||||||
|
isEnabled: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
displayName: launcherApp.displayName,
|
||||||
|
description: launcherApp.description,
|
||||||
|
subtitle: launcherApp.subtitle,
|
||||||
|
detailUrl: launcherApp.detailUrl,
|
||||||
|
videoPath: launcherApp.videoPath,
|
||||||
|
iconKey: launcherApp.iconKey,
|
||||||
|
launchUrl: launcherApp.launchUrl,
|
||||||
|
devAppPath: launcherApp.devAppPath,
|
||||||
|
execPath: launcherApp.execPath,
|
||||||
|
sortOrder: launcherApp.sortOrder,
|
||||||
|
isEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeded launcher notice: launcher-notice-v1-1-0');
|
||||||
|
console.log(`Seeded app notices: ${appNotices.length}`);
|
||||||
|
console.log(`Seeded app versions: ${appVersions.length}`);
|
||||||
|
console.log(`Seeded launcher apps: ${launcherApps.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Autonomy Studio</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Noto+Sans+KR:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-deep: #0a0b0e;
|
||||||
|
--bg-sidebar: #0d0f14;
|
||||||
|
--bg-main: #0f1117;
|
||||||
|
--border: rgba(255,255,255,0.06);
|
||||||
|
--accent: #3b7ffa;
|
||||||
|
--accent-glow: rgba(59,127,250,0.35);
|
||||||
|
--text-primary: #e8eaf0;
|
||||||
|
--text-secondary: rgba(200,205,220,0.65);
|
||||||
|
--text-muted: rgba(160,168,185,0.45);
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: #000;
|
||||||
|
font-family: 'Noto Sans KR', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Window Shell ── */
|
||||||
|
.window {
|
||||||
|
width: 100vw; height: 100vh;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Title Bar ── */
|
||||||
|
.titlebar {
|
||||||
|
height: 38px;
|
||||||
|
background: #080a0f;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 16px 0 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.titlebar-title {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: rgba(180,188,210,0.7);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.titlebar-logo {
|
||||||
|
height: 18px;
|
||||||
|
width: auto;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.window-controls {
|
||||||
|
display: flex; gap: 8px; align-items: center;
|
||||||
|
}
|
||||||
|
.wc {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.2s;
|
||||||
|
}
|
||||||
|
.wc:hover { filter: brightness(1.3); }
|
||||||
|
.wc-min { background: #3a3d45; }
|
||||||
|
.wc-max { background: #3a3d45; }
|
||||||
|
.wc-close { background: #3a3d45; }
|
||||||
|
.wc-min::after { content: '─'; font-size: 8px; line-height: 14px; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.4); }
|
||||||
|
.wc-max::after { content: '□'; font-size: 8px; line-height: 14px; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.4); }
|
||||||
|
.wc-close::after { content: '✕'; font-size: 8px; line-height: 14px; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.4); }
|
||||||
|
|
||||||
|
/* ── App Body ── */
|
||||||
|
.app-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar ── */
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding: 8px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.sidebar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute; right: 0; top: 0; bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: linear-gradient(to bottom, transparent, rgba(59,127,250,0.15) 40%, rgba(59,127,250,0.15) 60%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 13px 24px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
background: rgba(59,127,250,0.07);
|
||||||
|
}
|
||||||
|
.nav-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; left: 0; top: 8px; bottom: 8px;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
box-shadow: 0 0 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main Content ── */
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-main);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle background gradient */
|
||||||
|
.main::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 65% 50%, rgba(20,25,40,0.9) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 50% 50% at 20% 50%, rgba(15,30,60,0.3) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Top Bar ── */
|
||||||
|
.topbar { display: none; }
|
||||||
|
|
||||||
|
.user-profile {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.user-profile:hover { background: rgba(255,255,255,0.04); }
|
||||||
|
.avatar {
|
||||||
|
width: 34px; height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #2c4faa, #1a3580);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 13px; font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border: 1px solid rgba(59,127,250,0.4);
|
||||||
|
box-shadow: 0 0 12px rgba(59,127,250,0.2);
|
||||||
|
}
|
||||||
|
.user-info { display: flex; flex-direction: column; }
|
||||||
|
.user-name {
|
||||||
|
font-size: 13px; font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.user-email {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero Content ── */
|
||||||
|
.hero {
|
||||||
|
flex: 1;
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 80px 0 80px 52px; align-items: flex-start;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Car image area - atmospheric background */
|
||||||
|
.car-area {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.car-bg {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 90% 70% at 60% 55%, rgba(25,35,70,0.15) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
/* Car silhouette using CSS art + SVG simulation */
|
||||||
|
.car-image {
|
||||||
|
position: absolute; right: -20px; bottom: 40px;
|
||||||
|
width: 680px; height: 380px;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 60% at 50% 50%, rgba(30,40,70,0.2) 0%, transparent 70%);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.car-image img {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: brightness(1.5) contrast(1.05);
|
||||||
|
opacity: 0;
|
||||||
|
animation: carFadeIn 1.2s 0.6s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fog/atmosphere layers */
|
||||||
|
.fog-layer {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 70% 50% at 55% 60%, rgba(15,25,55,0.25) 0%, transparent 65%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 70% 70%, rgba(10,15,40,0.3) 0%, transparent 55%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left gradient fade for text readability */
|
||||||
|
.left-fade {
|
||||||
|
position: absolute; left: 0; top: 0; bottom: 0;
|
||||||
|
width: 45%;
|
||||||
|
background: linear-gradient(to right, var(--bg-main) 30%, rgba(15,17,23,0.7) 60%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Floor reflection */
|
||||||
|
.floor-reflection {
|
||||||
|
position: absolute; bottom: 0; left: 0; right: 0;
|
||||||
|
height: 120px;
|
||||||
|
background: linear-gradient(to top, rgba(8,10,18,0.8) 0%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text Content ── */
|
||||||
|
.text-content {
|
||||||
|
position: relative; z-index: 10;
|
||||||
|
max-width: 480px;
|
||||||
|
animation: textReveal 1s 0.2s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 52px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-shadow: 0 0 60px rgba(59,127,250,0.1);
|
||||||
|
}
|
||||||
|
.brand-title span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: rgba(200,210,230,0.85);
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 40px; height: 2px;
|
||||||
|
background: linear-gradient(to right, var(--accent), transparent);
|
||||||
|
margin-bottom: 22px;
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 10px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 2.0;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Version ── */
|
||||||
|
.version {
|
||||||
|
position: absolute; bottom: 16px; right: 24px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Animations ── */
|
||||||
|
@keyframes textReveal {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes carFadeIn {
|
||||||
|
from { opacity: 0; transform: translateX(30px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.sidebar-footer .user-profile {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.sidebar-footer .user-profile:hover {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.sidebar-footer .user-name { font-size: 13px; }
|
||||||
|
.sidebar-footer .user-email { font-size: 11px; }
|
||||||
|
|
||||||
|
/* Blue accent line at top of sidebar active item area */
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px 24px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.sidebar-logo {
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Update Button ── */
|
||||||
|
.update-btn-wrap {
|
||||||
|
position: absolute;
|
||||||
|
top: 88px;
|
||||||
|
right: 82px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.update-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
background: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'Noto Sans KR', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
box-shadow: 0 4px 20px rgba(59,127,250,0.35);
|
||||||
|
}
|
||||||
|
.update-btn:hover {
|
||||||
|
background: #5592ff;
|
||||||
|
box-shadow: 0 6px 28px rgba(59,127,250,0.55);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.update-btn:active { transform: translateY(0); }
|
||||||
|
.update-btn svg {
|
||||||
|
width: 14px; height: 14px;
|
||||||
|
stroke: #fff;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Car Video ── */
|
||||||
|
.car-video {
|
||||||
|
position: absolute;
|
||||||
|
right: 0; bottom: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center bottom;
|
||||||
|
opacity: 0;
|
||||||
|
animation: carFadeIn 1.4s 0.5s ease forwards;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Natural shadow vignette over video */
|
||||||
|
.car-video-shadow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(to right, var(--bg-main) 0%, var(--bg-main) 14%, rgba(15,17,23,0.45) 32%, transparent 52%),
|
||||||
|
linear-gradient(to top, rgba(5,7,12,0.9) 0%, transparent 45%),
|
||||||
|
radial-gradient(ellipse 100% 100% at 80% 40%, transparent 40%, rgba(5,7,12,0.5) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="window">
|
||||||
|
<!-- Title Bar -->
|
||||||
|
<div class="titlebar">
|
||||||
|
<div style="display:flex;align-items:center;">
|
||||||
|
<img src="https://autonomystudio.net/static/logo/autonomy.png" class="titlebar-logo" alt="logo">
|
||||||
|
<span class="titlebar-title">Autonomy Studio</span>
|
||||||
|
</div>
|
||||||
|
<div class="window-controls">
|
||||||
|
<div class="wc wc-min"></div>
|
||||||
|
<div class="wc wc-max"></div>
|
||||||
|
<div class="wc wc-close"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App Body -->
|
||||||
|
<div class="app-body">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="sidebar-logo"></div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item active">ADS IDE</div>
|
||||||
|
<div class="nav-item">Data Management</div>
|
||||||
|
<div class="nav-item">HMI</div>
|
||||||
|
<div class="nav-item">FMS</div>
|
||||||
|
<div class="nav-item">ADS Education</div>
|
||||||
|
<div class="nav-item">AI Benchmark</div>
|
||||||
|
<div class="nav-item">Calibration</div>
|
||||||
|
<div class="nav-item">IP Repository</div>
|
||||||
|
<div class="nav-item">HDMAP Builder</div>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-profile">
|
||||||
|
<div class="avatar">C</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="user-name">changhoon yoo</span>
|
||||||
|
<span class="user-email">chyoo@futuredrive.net</span>
|
||||||
|
</div>
|
||||||
|
<span class="chevron">▾</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Update Button -->
|
||||||
|
<div class="update-btn-wrap">
|
||||||
|
<button class="update-btn">
|
||||||
|
업데이트
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="hero">
|
||||||
|
<!-- Car visual area -->
|
||||||
|
<div class="car-area">
|
||||||
|
<div class="car-bg"></div>
|
||||||
|
<video class="car-video" src="car_move.mp4" autoplay loop muted playsinline></video>
|
||||||
|
<div class="car-video-shadow"></div>
|
||||||
|
<div class="floor-reflection"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text Content -->
|
||||||
|
<div class="text-content">
|
||||||
|
<h1 class="brand-title">Autonomy<br><span>Studio</span></h1>
|
||||||
|
<p class="subtitle">클라우드 기반 자율주행 모빌리티<br>소프트웨어 개발, 검증 및 운영 환경</p>
|
||||||
|
<div class="divider"></div>
|
||||||
|
<p class="description">
|
||||||
|
충분히 검증된 오픈소스 기반의 레퍼런스<br>
|
||||||
|
자율주행 소프트웨어(ADS)를 통해 실차 수준에서 다양한 시뮬레이션과 검증,<br>
|
||||||
|
AI 기능 성능 평가까지 가능한 통합 DevOps 플랫폼을 제공합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version -->
|
||||||
|
<div class="version">v1.0.0</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Nav item click interaction
|
||||||
|
document.querySelectorAll('.nav-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Window control interactions
|
||||||
|
document.querySelector('.wc-close').addEventListener('click', () => {
|
||||||
|
document.querySelector('.window').style.opacity = '0';
|
||||||
|
document.querySelector('.window').style.transform = 'scale(0.97)';
|
||||||
|
document.querySelector('.window').style.transition = 'opacity 0.3s, transform 0.3s';
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector('.window').style.opacity = '1';
|
||||||
|
document.querySelector('.window').style.transform = 'scale(1)';
|
||||||
|
}, 600);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
describe('AppController', () => {
|
||||||
|
let appController: AppController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const app: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('root', () => {
|
||||||
|
it('should return "Hello World!"', () => {
|
||||||
|
expect(appController.getHello()).toBe('Hello World!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
constructor(private readonly appService: AppService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getHello(): string {
|
||||||
|
return this.appService.getHello();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { AppService } from './app.service';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { AppsModule } from './apps/apps.module';
|
||||||
|
import { NoticesModule } from './notices/notices.module';
|
||||||
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
|
import { DownloadsModule } from './downloads/downloads.module';
|
||||||
|
import { ProvisioningModule } from './provisioning/provisioning.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
}),
|
||||||
|
ServeStaticModule.forRoot({
|
||||||
|
rootPath: join(__dirname, '..', 'public'),
|
||||||
|
}),
|
||||||
|
AuthModule,
|
||||||
|
NoticesModule,
|
||||||
|
NotificationsModule,
|
||||||
|
DownloadsModule,
|
||||||
|
ProvisioningModule,
|
||||||
|
AppsModule,
|
||||||
|
],
|
||||||
|
controllers: [AppController],
|
||||||
|
providers: [AppService],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppService {
|
||||||
|
getHello(): string {
|
||||||
|
return 'Hello World!';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query, Req } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { AppsService } from './apps.service';
|
||||||
|
import { GrantAppPermissionDto } from './dto/grant-app-permission.dto';
|
||||||
|
|
||||||
|
@Controller('api/apps')
|
||||||
|
export class AppsController {
|
||||||
|
constructor(private readonly appsService: AppsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
getApps(@Req() request: Request) {
|
||||||
|
return this.appsService.getApps(this.getBearerToken(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':appKey/update')
|
||||||
|
getUpdateStatus(
|
||||||
|
@Param('appKey') appKey: string,
|
||||||
|
@Req() request: Request,
|
||||||
|
@Query('currentVersion') currentVersion?: string,
|
||||||
|
@Query('installed') installed?: string,
|
||||||
|
) {
|
||||||
|
return this.appsService.getUpdateStatus(appKey, {
|
||||||
|
currentVersion,
|
||||||
|
installed: installed === 'true',
|
||||||
|
accessToken: this.getBearerToken(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':appKey/users/:authUserId/permission')
|
||||||
|
grantAppPermission(
|
||||||
|
@Param('appKey') appKey: string,
|
||||||
|
@Param('authUserId') authUserId: string,
|
||||||
|
@Body() body: GrantAppPermissionDto,
|
||||||
|
@Req() request: Request,
|
||||||
|
) {
|
||||||
|
return this.appsService.grantAppPermission(
|
||||||
|
appKey,
|
||||||
|
authUserId,
|
||||||
|
body,
|
||||||
|
this.getBearerToken(request),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBearerToken(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { AppsController } from './apps.controller';
|
||||||
|
import { AppsService } from './apps.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [AppsController],
|
||||||
|
providers: [AppsService],
|
||||||
|
})
|
||||||
|
export class AppsModule {}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { GrantAppPermissionDto } from './dto/grant-app-permission.dto';
|
||||||
|
|
||||||
|
type UpdateAction = 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE';
|
||||||
|
type UpdateStatus = 'NOT_INSTALLED' | 'UPDATE_AVAILABLE' | 'READY' | 'DISABLED' | 'UNKNOWN_APP';
|
||||||
|
|
||||||
|
type UpdateStatusInput = {
|
||||||
|
currentVersion?: string;
|
||||||
|
installed: boolean;
|
||||||
|
accessToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthMeResponse = {
|
||||||
|
user?: {
|
||||||
|
id?: string;
|
||||||
|
role?: string;
|
||||||
|
roles?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const APP_PERMISSION_ADMIN_ROLES = new Set(['ADMIN']);
|
||||||
|
|
||||||
|
function normalizeVersion(version?: string): string | undefined {
|
||||||
|
return version?.trim().replace(/^v/i, '') || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersions(left?: string, right?: string): number {
|
||||||
|
const a = normalizeVersion(left);
|
||||||
|
const b = normalizeVersion(right);
|
||||||
|
if (!a && !b) return 0;
|
||||||
|
if (!a) return -1;
|
||||||
|
if (!b) return 1;
|
||||||
|
|
||||||
|
const leftParts = a.split(/[.-]/).map((part) => Number.parseInt(part, 10));
|
||||||
|
const rightParts = b.split(/[.-]/).map((part) => Number.parseInt(part, 10));
|
||||||
|
const length = Math.max(leftParts.length, rightParts.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const leftValue = Number.isNaN(leftParts[index]) ? 0 : leftParts[index] ?? 0;
|
||||||
|
const rightValue = Number.isNaN(rightParts[index]) ? 0 : rightParts[index] ?? 0;
|
||||||
|
if (leftValue > rightValue) return 1;
|
||||||
|
if (leftValue < rightValue) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AppsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getApps(accessToken?: string) {
|
||||||
|
const authUserId = await this.getAuthUserId(accessToken);
|
||||||
|
const apps = await this.prisma.launcherApp.findMany({
|
||||||
|
where: { isEnabled: true },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
|
||||||
|
select: {
|
||||||
|
appKey: true,
|
||||||
|
displayName: true,
|
||||||
|
description: true,
|
||||||
|
subtitle: true,
|
||||||
|
detailUrl: true,
|
||||||
|
videoPath: true,
|
||||||
|
iconKey: true,
|
||||||
|
launchUrl: true,
|
||||||
|
devAppPath: true,
|
||||||
|
execPath: true,
|
||||||
|
sortOrder: true,
|
||||||
|
userApps: authUserId
|
||||||
|
? {
|
||||||
|
where: { authUserId },
|
||||||
|
select: {
|
||||||
|
canView: true,
|
||||||
|
canLaunch: true,
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
apps: apps.map((app) => {
|
||||||
|
const permission = app.userApps?.[0];
|
||||||
|
const canView = permission?.canView ?? false;
|
||||||
|
return {
|
||||||
|
appKey: app.appKey,
|
||||||
|
displayName: app.displayName,
|
||||||
|
description: app.description,
|
||||||
|
subtitle: app.subtitle,
|
||||||
|
detailUrl: app.detailUrl,
|
||||||
|
videoPath: app.videoPath,
|
||||||
|
iconKey: app.iconKey,
|
||||||
|
launchUrl: app.launchUrl,
|
||||||
|
devAppPath: app.devAppPath,
|
||||||
|
execPath: app.execPath,
|
||||||
|
sortOrder: app.sortOrder,
|
||||||
|
canView,
|
||||||
|
canLaunch: canView && (permission?.canLaunch ?? false),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUpdateStatus(appKey: string, input: UpdateStatusInput) {
|
||||||
|
const isLauncherUpdate = appKey.toLowerCase() === 'launcher';
|
||||||
|
const authUserId = isLauncherUpdate ? null : await this.getAuthUserId(input.accessToken);
|
||||||
|
const canLaunch = isLauncherUpdate
|
||||||
|
? true
|
||||||
|
: authUserId
|
||||||
|
? await this.canLaunchApp(authUserId, appKey)
|
||||||
|
: false;
|
||||||
|
const appVersion = await this.prisma.launcherAppVersion.findUnique({
|
||||||
|
where: { appKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!appVersion) {
|
||||||
|
return {
|
||||||
|
appKey,
|
||||||
|
installed: input.installed,
|
||||||
|
localVersion: input.currentVersion ?? null,
|
||||||
|
latestVersion: null,
|
||||||
|
status: 'UNKNOWN_APP' satisfies UpdateStatus,
|
||||||
|
action: 'UNAVAILABLE' satisfies UpdateAction,
|
||||||
|
updateRequired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appVersion.isEnabled) {
|
||||||
|
return {
|
||||||
|
appKey,
|
||||||
|
displayName: appVersion.displayName,
|
||||||
|
installed: input.installed,
|
||||||
|
localVersion: input.currentVersion ?? null,
|
||||||
|
latestVersion: appVersion.latestVersion,
|
||||||
|
status: 'DISABLED' satisfies UpdateStatus,
|
||||||
|
action: 'UNAVAILABLE' satisfies UpdateAction,
|
||||||
|
updateRequired: false,
|
||||||
|
downloadUrl: appVersion.downloadUrl,
|
||||||
|
releaseNotes: appVersion.releaseNotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canLaunch) {
|
||||||
|
return {
|
||||||
|
appKey,
|
||||||
|
displayName: appVersion.displayName,
|
||||||
|
installed: input.installed,
|
||||||
|
localVersion: input.currentVersion ?? null,
|
||||||
|
latestVersion: appVersion.latestVersion,
|
||||||
|
status: 'DISABLED' satisfies UpdateStatus,
|
||||||
|
action: 'UNAVAILABLE' satisfies UpdateAction,
|
||||||
|
updateRequired: false,
|
||||||
|
downloadUrl: appVersion.downloadUrl,
|
||||||
|
releaseNotes: appVersion.releaseNotes,
|
||||||
|
canLaunch: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const installed = input.installed;
|
||||||
|
const localVersion = normalizeVersion(input.currentVersion);
|
||||||
|
const hasUpdate = installed && compareVersions(localVersion, appVersion.latestVersion) < 0;
|
||||||
|
|
||||||
|
const status: UpdateStatus = !installed
|
||||||
|
? 'NOT_INSTALLED'
|
||||||
|
: hasUpdate
|
||||||
|
? 'UPDATE_AVAILABLE'
|
||||||
|
: 'READY';
|
||||||
|
|
||||||
|
const action: UpdateAction = status === 'NOT_INSTALLED'
|
||||||
|
? 'INSTALL'
|
||||||
|
: status === 'UPDATE_AVAILABLE'
|
||||||
|
? 'UPDATE'
|
||||||
|
: 'LAUNCH';
|
||||||
|
|
||||||
|
return {
|
||||||
|
appKey,
|
||||||
|
displayName: appVersion.displayName,
|
||||||
|
installed,
|
||||||
|
localVersion: localVersion ?? null,
|
||||||
|
latestVersion: appVersion.latestVersion,
|
||||||
|
minimumSupportedVersion: appVersion.minimumSupportedVersion,
|
||||||
|
status,
|
||||||
|
action,
|
||||||
|
updateRequired: status === 'UPDATE_AVAILABLE',
|
||||||
|
downloadUrl: appVersion.downloadUrl,
|
||||||
|
releaseNotes: appVersion.releaseNotes,
|
||||||
|
canLaunch: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async grantAppPermission(
|
||||||
|
appKey: string,
|
||||||
|
authUserId: string,
|
||||||
|
input: GrantAppPermissionDto,
|
||||||
|
accessToken?: string,
|
||||||
|
) {
|
||||||
|
const actor = await this.requireAppPermissionAdmin(accessToken);
|
||||||
|
const app = await this.prisma.launcherApp.findUnique({
|
||||||
|
where: { appKey },
|
||||||
|
select: { id: true, appKey: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) throw new NotFoundException('App not found');
|
||||||
|
|
||||||
|
const permission = await this.prisma.launcherUserApp.upsert({
|
||||||
|
where: {
|
||||||
|
authUserId_appId: {
|
||||||
|
authUserId,
|
||||||
|
appId: app.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
canView: input.canView ?? true,
|
||||||
|
canLaunch: input.canLaunch ?? true,
|
||||||
|
assignedById: actor.id,
|
||||||
|
assignedAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
authUserId,
|
||||||
|
appId: app.id,
|
||||||
|
canView: input.canView ?? true,
|
||||||
|
canLaunch: input.canLaunch ?? true,
|
||||||
|
assignedById: actor.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
authUserId: true,
|
||||||
|
canView: true,
|
||||||
|
canLaunch: true,
|
||||||
|
assignedAt: true,
|
||||||
|
app: {
|
||||||
|
select: {
|
||||||
|
appKey: true,
|
||||||
|
displayName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { permission };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthUserId(accessToken?: string): Promise<string | null> {
|
||||||
|
if (!accessToken) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.authService.me(accessToken) as { user?: { id?: string } };
|
||||||
|
return response.user?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireAppPermissionAdmin(accessToken?: string) {
|
||||||
|
if (!accessToken) throw new UnauthorizedException('Bearer token required');
|
||||||
|
|
||||||
|
const response = await this.authService.me(accessToken) as AuthMeResponse;
|
||||||
|
const user = response.user;
|
||||||
|
const roles = new Set(
|
||||||
|
[user?.role, ...(user?.roles ?? [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((role) => String(role).toUpperCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user?.id || ![...APP_PERMISSION_ADMIN_ROLES].some((role) => roles.has(role))) {
|
||||||
|
throw new ForbiddenException('App permission admin role required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async canLaunchApp(authUserId: string, appKey: string): Promise<boolean> {
|
||||||
|
const permission = await this.prisma.launcherUserApp.findFirst({
|
||||||
|
where: {
|
||||||
|
authUserId,
|
||||||
|
canView: true,
|
||||||
|
canLaunch: true,
|
||||||
|
app: {
|
||||||
|
appKey,
|
||||||
|
isEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return Boolean(permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class GrantAppPermissionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
canView?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
canLaunch?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Post, Put, Req, Res } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
|
import { HandoffDto } from './dto/handoff.dto';
|
||||||
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
|
||||||
|
@Controller('api/auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
login(
|
||||||
|
@Body() loginDto: LoginDto,
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
return this.authService.login(
|
||||||
|
loginDto.email,
|
||||||
|
loginDto.password,
|
||||||
|
request.headers.cookie,
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
refresh(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
return this.authService.refresh(request.headers.cookie, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
logout(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
return this.authService.logout(request.headers.cookie, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
me(@Req() request: Request) {
|
||||||
|
return this.authService.me(this.getBearerToken(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('password/change')
|
||||||
|
changePassword(@Req() request: Request, @Body() body: ChangePasswordDto) {
|
||||||
|
return this.authService.changePassword(
|
||||||
|
this.getBearerToken(request),
|
||||||
|
body.currentPassword,
|
||||||
|
body.newPassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('profile/avatar')
|
||||||
|
async getProfileAvatar(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.authService.getProfileAvatar(
|
||||||
|
this.getBearerToken(request),
|
||||||
|
);
|
||||||
|
response.setHeader('Content-Type', result.contentType);
|
||||||
|
response.setHeader('Cache-Control', 'no-store');
|
||||||
|
response.send(result.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('profile/avatar')
|
||||||
|
async updateProfileAvatar(@Req() request: Request) {
|
||||||
|
const body = Buffer.isBuffer(request.body)
|
||||||
|
? request.body
|
||||||
|
: Buffer.from([]);
|
||||||
|
await this.authService.updateProfileAvatar(
|
||||||
|
this.getBearerToken(request),
|
||||||
|
request.headers['content-type'],
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('profile/avatar')
|
||||||
|
deleteProfileAvatar(@Req() request: Request) {
|
||||||
|
return this.authService.deleteProfileAvatar(this.getBearerToken(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('handoff')
|
||||||
|
handoff(@Req() request: Request, @Body() body: HandoffDto) {
|
||||||
|
return this.authService.handoff(
|
||||||
|
this.getBearerToken(request),
|
||||||
|
body.audienceApp ?? 'platform-ide',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBearerToken(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
BadGatewayException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
role: string;
|
||||||
|
roles?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthTokenResponse = {
|
||||||
|
accessToken: string;
|
||||||
|
accessTokenExpiresIn?: number;
|
||||||
|
user: AuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
private readonly authServerUrl: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.authServerUrl = this.configService
|
||||||
|
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
cookieHeader: string | undefined,
|
||||||
|
response: Response,
|
||||||
|
) {
|
||||||
|
const authResponse = await this.request<AuthTokenResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
cookieHeader,
|
||||||
|
response,
|
||||||
|
unauthorizedMessage: 'Invalid email or password',
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.frontendTokenResponse(authResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(cookieHeader: string | undefined, response: Response) {
|
||||||
|
const authResponse = await this.request<AuthTokenResponse>(
|
||||||
|
'/auth/refresh',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
cookieHeader,
|
||||||
|
response,
|
||||||
|
unauthorizedMessage: 'Invalid session',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.frontendTokenResponse(authResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(cookieHeader: string | undefined, response: Response) {
|
||||||
|
await this.request('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
cookieHeader,
|
||||||
|
response,
|
||||||
|
ignoreAuthErrors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(accessToken: string | undefined) {
|
||||||
|
return this.request('/auth/me', {
|
||||||
|
method: 'GET',
|
||||||
|
accessToken,
|
||||||
|
unauthorizedMessage: 'Invalid bearer token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handoff(accessToken: string | undefined, audienceApp: string) {
|
||||||
|
return this.request('/auth/handoff', {
|
||||||
|
method: 'POST',
|
||||||
|
accessToken,
|
||||||
|
body: { audienceApp },
|
||||||
|
unauthorizedMessage: 'Invalid bearer token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(
|
||||||
|
accessToken: string | undefined,
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
) {
|
||||||
|
return this.request('/auth/password/change', {
|
||||||
|
method: 'POST',
|
||||||
|
accessToken,
|
||||||
|
body: { currentPassword, newPassword },
|
||||||
|
unauthorizedMessage: 'Invalid bearer token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfileAvatar(accessToken: string | undefined) {
|
||||||
|
return this.binaryRequest('/auth/profile/avatar', {
|
||||||
|
method: 'GET',
|
||||||
|
accessToken,
|
||||||
|
unauthorizedMessage: 'Invalid bearer token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfileAvatar(
|
||||||
|
accessToken: string | undefined,
|
||||||
|
contentType: string | undefined,
|
||||||
|
body: Buffer,
|
||||||
|
) {
|
||||||
|
return this.binaryRequest('/auth/profile/avatar', {
|
||||||
|
method: 'PUT',
|
||||||
|
accessToken,
|
||||||
|
contentType,
|
||||||
|
body,
|
||||||
|
unauthorizedMessage: 'Invalid bearer token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProfileAvatar(accessToken: string | undefined) {
|
||||||
|
await this.binaryRequest('/auth/profile/avatar', {
|
||||||
|
method: 'DELETE',
|
||||||
|
accessToken,
|
||||||
|
unauthorizedMessage: 'Invalid bearer token',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private frontendTokenResponse(authResponse: AuthTokenResponse) {
|
||||||
|
return {
|
||||||
|
accessToken: authResponse.accessToken,
|
||||||
|
accessTokenExpiresIn: authResponse.accessTokenExpiresIn,
|
||||||
|
tokenType: 'Bearer',
|
||||||
|
user: authResponse.user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
method: 'GET' | 'POST';
|
||||||
|
body?: unknown;
|
||||||
|
accessToken?: string;
|
||||||
|
cookieHeader?: string;
|
||||||
|
response?: Response;
|
||||||
|
unauthorizedMessage?: string;
|
||||||
|
ignoreAuthErrors?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (options.body) headers['content-type'] = 'application/json';
|
||||||
|
if (options.accessToken) {
|
||||||
|
headers.authorization = `Bearer ${options.accessToken}`;
|
||||||
|
}
|
||||||
|
if (options.cookieHeader) {
|
||||||
|
headers.cookie = options.cookieHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
let authResponse: globalThis.Response;
|
||||||
|
try {
|
||||||
|
authResponse = await fetch(`${this.authServerUrl}${path}`, {
|
||||||
|
method: options.method,
|
||||||
|
headers,
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new BadGatewayException('Auth server is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.forwardSetCookie(authResponse, options.response);
|
||||||
|
|
||||||
|
const payload: unknown = await authResponse.json().catch((): null => null);
|
||||||
|
|
||||||
|
if (authResponse.status === 401) {
|
||||||
|
if (options.ignoreAuthErrors) return { ok: true } as T;
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
options.unauthorizedMessage ?? 'Unauthorized',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authResponse.status === 400) {
|
||||||
|
throw new BadRequestException(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authResponse.ok) {
|
||||||
|
throw new BadGatewayException({
|
||||||
|
message: 'Auth server request failed',
|
||||||
|
statusCode: authResponse.status,
|
||||||
|
error: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async binaryRequest(
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
method: 'GET' | 'PUT' | 'DELETE';
|
||||||
|
accessToken?: string;
|
||||||
|
contentType?: string;
|
||||||
|
body?: Buffer;
|
||||||
|
unauthorizedMessage?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (options.accessToken) headers.authorization = `Bearer ${options.accessToken}`;
|
||||||
|
if (options.contentType) headers['content-type'] = options.contentType;
|
||||||
|
|
||||||
|
const requestBody = options.body
|
||||||
|
? new Uint8Array(options.body).buffer
|
||||||
|
: undefined;
|
||||||
|
let authResponse: globalThis.Response;
|
||||||
|
try {
|
||||||
|
authResponse = await fetch(`${this.authServerUrl}${path}`, {
|
||||||
|
method: options.method,
|
||||||
|
headers,
|
||||||
|
body: requestBody,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new BadGatewayException('Auth server is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authResponse.status === 401) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
options.unauthorizedMessage ?? 'Unauthorized',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authResponse.ok) {
|
||||||
|
const payload: unknown = await authResponse.json().catch((): null => null);
|
||||||
|
if (authResponse.status === 404) {
|
||||||
|
throw new NotFoundException(payload);
|
||||||
|
}
|
||||||
|
if ([400, 413, 415].includes(authResponse.status)) {
|
||||||
|
throw new BadRequestException(payload);
|
||||||
|
}
|
||||||
|
throw new BadGatewayException({
|
||||||
|
message: 'Auth server request failed',
|
||||||
|
statusCode: authResponse.status,
|
||||||
|
error: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType: authResponse.headers.get('content-type') ?? 'application/octet-stream',
|
||||||
|
body: Buffer.from(await authResponse.arrayBuffer()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private forwardSetCookie(
|
||||||
|
authResponse: globalThis.Response,
|
||||||
|
response?: Response,
|
||||||
|
) {
|
||||||
|
if (!response) return;
|
||||||
|
|
||||||
|
const cookies = this.getSetCookieHeaders(authResponse.headers);
|
||||||
|
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
response.append('Set-Cookie', cookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSetCookieHeaders(headers: Headers): string[] {
|
||||||
|
const maybeHeaders = headers as unknown as {
|
||||||
|
getSetCookie?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof maybeHeaders.getSetCookie === 'function') {
|
||||||
|
const cookies = maybeHeaders.getSetCookie.call(headers);
|
||||||
|
return Array.isArray(cookies)
|
||||||
|
? cookies.filter(
|
||||||
|
(cookie): cookie is string => typeof cookie === 'string',
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.splitSetCookieHeader(headers.get('set-cookie'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private splitSetCookieHeader(header: string | null): string[] {
|
||||||
|
if (!header) return [];
|
||||||
|
return header
|
||||||
|
.split(/,(?=\s*[^;,=]+=[^;,]+)/)
|
||||||
|
.map((cookie) => cookie.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class ChangePasswordDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
currentPassword: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class HandoffDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(120)
|
||||||
|
audienceApp?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
import { CreateDownloadFileDto } from './dto/create-download-file.dto';
|
||||||
|
import { DownloadsService } from './downloads.service';
|
||||||
|
|
||||||
|
@Controller('api/downloads')
|
||||||
|
export class DownloadsController {
|
||||||
|
constructor(private readonly downloadsService: DownloadsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query('appKey') appKey = 'all',
|
||||||
|
@Query('contextKey') contextKey = 'general',
|
||||||
|
) {
|
||||||
|
return this.downloadsService.list(appKey, contextKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() body: CreateDownloadFileDto, @Req() request: Request) {
|
||||||
|
return this.downloadsService.create(body, this.getBearerToken(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/file')
|
||||||
|
async download(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Req() request: Request,
|
||||||
|
@Res() response: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.downloadsService.download(id, {
|
||||||
|
accessToken: this.getBearerToken(request),
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
ipAddress: request.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.setHeader('Content-Type', result.contentType);
|
||||||
|
response.setHeader('Content-Disposition', result.contentDisposition);
|
||||||
|
response.setHeader('Cache-Control', 'no-store');
|
||||||
|
if (result.contentLength) response.setHeader('Content-Length', result.contentLength);
|
||||||
|
response.send(result.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBearerToken(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { DownloadsController } from './downloads.controller';
|
||||||
|
import { DownloadsService } from './downloads.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, PrismaModule],
|
||||||
|
controllers: [DownloadsController],
|
||||||
|
providers: [DownloadsService],
|
||||||
|
})
|
||||||
|
export class DownloadsModule {}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateDownloadFileDto } from './dto/create-download-file.dto';
|
||||||
|
|
||||||
|
type AuthMeResponse = {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type DownloadRequestMeta = {
|
||||||
|
accessToken?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DownloadsService {
|
||||||
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(appKey: string, contextKey: string) {
|
||||||
|
const files = await this.prisma.downloadFile.findMany({
|
||||||
|
where: {
|
||||||
|
appKey: appKey.trim() || 'all',
|
||||||
|
contextKey: contextKey.trim() || 'general',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
files: files.map((file) => ({
|
||||||
|
id: file.id,
|
||||||
|
appKey: file.appKey,
|
||||||
|
contextKey: file.contextKey,
|
||||||
|
title: file.title,
|
||||||
|
description: file.description,
|
||||||
|
fileName: file.fileName,
|
||||||
|
contentType: file.contentType ?? inferContentType(file.fileName, file.sourceUrl),
|
||||||
|
fileSizeBytes: file.fileSizeBytes?.toString() ?? null,
|
||||||
|
uploadedByAppKey: file.uploadedByAppKey,
|
||||||
|
sortOrder: file.sortOrder,
|
||||||
|
downloadUrl: `/api/downloads/${file.id}/file`,
|
||||||
|
createdAt: file.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateDownloadFileDto, accessToken: string | undefined) {
|
||||||
|
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
|
||||||
|
|
||||||
|
const authUserId = await this.getAuthUserId(accessToken);
|
||||||
|
const contentType = input.contentType?.trim() || inferContentType(input.fileName, input.sourceUrl);
|
||||||
|
const fileSizeBytes = await this.readRemoteFileSize(input.sourceUrl).catch(() => null);
|
||||||
|
|
||||||
|
const file = await this.prisma.downloadFile.create({
|
||||||
|
data: {
|
||||||
|
appKey: input.appKey.trim(),
|
||||||
|
contextKey: input.contextKey?.trim() || 'general',
|
||||||
|
title: input.title.trim(),
|
||||||
|
description: input.description?.trim() || null,
|
||||||
|
fileName: input.fileName.trim(),
|
||||||
|
sourceUrl: input.sourceUrl.trim(),
|
||||||
|
contentType,
|
||||||
|
fileSizeBytes,
|
||||||
|
uploadedByAppKey: input.uploadedByAppKey?.trim() || input.appKey.trim(),
|
||||||
|
uploadedByAuthId: authUserId,
|
||||||
|
sortOrder: input.sortOrder ?? 0,
|
||||||
|
isActive: input.isActive ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: {
|
||||||
|
...file,
|
||||||
|
fileSizeBytes: file.fileSizeBytes?.toString() ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(id: string, meta: DownloadRequestMeta) {
|
||||||
|
const file = await this.prisma.downloadFile.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!file) throw new NotFoundException('Download file not found');
|
||||||
|
|
||||||
|
const remoteResponse = await fetch(file.sourceUrl).catch(() => null);
|
||||||
|
if (!remoteResponse) throw new BadGatewayException('Download source is unavailable');
|
||||||
|
|
||||||
|
if (!remoteResponse.ok) {
|
||||||
|
throw new BadGatewayException({
|
||||||
|
message: 'Download source request failed',
|
||||||
|
statusCode: remoteResponse.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUserId = meta.accessToken
|
||||||
|
? await this.getAuthUserId(meta.accessToken).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await this.prisma.downloadEvent.create({
|
||||||
|
data: {
|
||||||
|
fileId: file.id,
|
||||||
|
authUserId,
|
||||||
|
appKey: file.appKey,
|
||||||
|
userAgent: meta.userAgent ?? null,
|
||||||
|
ipAddress: meta.ipAddress ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
file.contentType ||
|
||||||
|
inferContentType(file.fileName, file.sourceUrl) ||
|
||||||
|
remoteResponse.headers.get('content-type') ||
|
||||||
|
'application/octet-stream';
|
||||||
|
const contentLength = remoteResponse.headers.get('content-length') ?? file.fileSizeBytes?.toString() ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType,
|
||||||
|
contentDisposition: buildContentDisposition(file.fileName),
|
||||||
|
contentLength,
|
||||||
|
body: Buffer.from(await remoteResponse.arrayBuffer()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthUserId(accessToken: string): Promise<string> {
|
||||||
|
const response = (await this.authService.me(accessToken)) as AuthMeResponse;
|
||||||
|
if (!response.user?.id) throw new UnauthorizedException('Invalid bearer token');
|
||||||
|
return response.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readRemoteFileSize(sourceUrl: string): Promise<bigint | null> {
|
||||||
|
const response = await fetch(sourceUrl, { method: 'HEAD' });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const contentLength = response.headers.get('content-length');
|
||||||
|
return contentLength && /^\d+$/.test(contentLength) ? BigInt(contentLength) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferContentType(fileName: string, sourceUrl: string): string | null {
|
||||||
|
const path = `${fileName || sourceUrl}`.split('?')[0]?.toLowerCase() ?? '';
|
||||||
|
if (path.endsWith('.pdf')) return 'application/pdf';
|
||||||
|
if (path.endsWith('.zip')) return 'application/zip';
|
||||||
|
if (path.endsWith('.json')) return 'application/json';
|
||||||
|
if (path.endsWith('.txt')) return 'text/plain; charset=utf-8';
|
||||||
|
if (path.endsWith('.png')) return 'image/png';
|
||||||
|
if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg';
|
||||||
|
if (path.endsWith('.webp')) return 'image/webp';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContentDisposition(fileName: string): string {
|
||||||
|
const fallback = fileName.replace(/[^\w. -]/g, '_') || 'download';
|
||||||
|
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateDownloadFileDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
appKey: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
contextKey?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
fileName: string;
|
||||||
|
|
||||||
|
@IsUrl({ require_protocol: true })
|
||||||
|
@MaxLength(2000)
|
||||||
|
sourceUrl: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
contentType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
uploadedByAppKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { raw } from 'express';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
const configService = app.get(ConfigService);
|
||||||
|
const port = configService.get<number>('PORT', 4200);
|
||||||
|
const corsOrigin = configService.get<string>('CORS_ORIGIN', '*');
|
||||||
|
const configuredOrigins = corsOrigin
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const isLocalDevelopmentOrigin = (origin: string): boolean => {
|
||||||
|
try {
|
||||||
|
const url = new URL(origin);
|
||||||
|
return ['localhost', '127.0.0.1'].includes(url.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
if (!origin || corsOrigin === '*' || configuredOrigins.includes(origin) || isLocalDevelopmentOrigin(origin)) {
|
||||||
|
callback(null, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(new Error(`CORS origin not allowed: ${origin}`));
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
app.use('/api/auth/profile/avatar', raw({ type: ['image/webp', 'image/png', 'image/jpeg'], limit: '80kb' }));
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.listen(port);
|
||||||
|
}
|
||||||
|
void bootstrap();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
|
import { NoticesService } from './notices.service';
|
||||||
|
|
||||||
|
@Controller('api/notices')
|
||||||
|
export class NoticesController {
|
||||||
|
constructor(private readonly noticesService: NoticesService) {}
|
||||||
|
|
||||||
|
@Get('login')
|
||||||
|
getLoginNotice() {
|
||||||
|
return this.noticesService.getLoginNotice();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('apps/:appKey')
|
||||||
|
getAppNotices(@Param('appKey') appKey: string) {
|
||||||
|
return this.noticesService.getAppNotices(appKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { NoticesController } from './notices.controller';
|
||||||
|
import { NoticesService } from './notices.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [NoticesController],
|
||||||
|
providers: [NoticesService],
|
||||||
|
})
|
||||||
|
export class NoticesModule {}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NoticesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getLoginNotice() {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const notice = await this.prisma.launcherNotice.findFirst({
|
||||||
|
where: {
|
||||||
|
appKey: 'Launcher',
|
||||||
|
isActive: true,
|
||||||
|
OR: [
|
||||||
|
{ startsAt: null, endsAt: null },
|
||||||
|
{ startsAt: { lte: now }, endsAt: null },
|
||||||
|
{ startsAt: null, endsAt: { gte: now } },
|
||||||
|
{ startsAt: { lte: now }, endsAt: { gte: now } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
body: true,
|
||||||
|
linkUrl: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { notice };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAppNotices(appKey: string) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const notices = await this.prisma.launcherNotice.findMany({
|
||||||
|
where: {
|
||||||
|
appKey,
|
||||||
|
isActive: true,
|
||||||
|
OR: [
|
||||||
|
{ startsAt: null, endsAt: null },
|
||||||
|
{ startsAt: { lte: now }, endsAt: null },
|
||||||
|
{ startsAt: null, endsAt: { gte: now } },
|
||||||
|
{ startsAt: { lte: now }, endsAt: { gte: now } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
body: true,
|
||||||
|
linkUrl: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
notices: notices.map((notice) => ({
|
||||||
|
...notice,
|
||||||
|
date: formatNoticeDate(notice.createdAt),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNoticeDate(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateNotificationDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
appKey: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
audience?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
|
body: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUrl({ require_protocol: true })
|
||||||
|
@MaxLength(2000)
|
||||||
|
linkUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Query, Req } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
import { CreateNotificationDto } from './dto/create-notification.dto';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
type ReadBody = {
|
||||||
|
notificationIds?: string[];
|
||||||
|
appKey?: string;
|
||||||
|
audience?: string;
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Controller('api/notifications')
|
||||||
|
export class NotificationsController {
|
||||||
|
constructor(private readonly notificationsService: NotificationsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query('appKey') appKey = 'all',
|
||||||
|
@Query('audience') audience = 'all',
|
||||||
|
@Query('page') page = '1',
|
||||||
|
@Query('pageSize') pageSize = '50',
|
||||||
|
@Query('query') query = '',
|
||||||
|
@Query('read') read = 'all',
|
||||||
|
@Req() request: Request,
|
||||||
|
) {
|
||||||
|
return this.notificationsService.list({
|
||||||
|
appKey,
|
||||||
|
audience,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
query,
|
||||||
|
read,
|
||||||
|
accessToken: this.getBearerToken(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() body: CreateNotificationDto, @Req() request: Request) {
|
||||||
|
return this.notificationsService.create(body, this.getBearerToken(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/read')
|
||||||
|
markRead(@Param('id') id: string, @Req() request: Request) {
|
||||||
|
return this.notificationsService.markRead([id], this.getBearerToken(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('read-all')
|
||||||
|
markReadAll(@Body() body: ReadBody, @Req() request: Request) {
|
||||||
|
return this.notificationsService.markReadAll(body, this.getBearerToken(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBearerToken(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { NotificationsController } from './notifications.controller';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, PrismaModule],
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
providers: [NotificationsService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateNotificationDto } from './dto/create-notification.dto';
|
||||||
|
|
||||||
|
type AuthMeResponse = {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListInput = {
|
||||||
|
appKey: string;
|
||||||
|
audience: string;
|
||||||
|
page?: string;
|
||||||
|
pageSize?: string;
|
||||||
|
query?: string;
|
||||||
|
read?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarkAllReadInput = {
|
||||||
|
notificationIds?: string[];
|
||||||
|
appKey?: string;
|
||||||
|
audience?: string;
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NOTIFICATION_CATEGORY_GROUP = 'NOTIFICATION_CATEGORY';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsService {
|
||||||
|
private readonly authServerUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
this.authServerUrl = this.configService
|
||||||
|
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(input: ListInput) {
|
||||||
|
const now = new Date();
|
||||||
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
const appKey = input.appKey.trim() || 'all';
|
||||||
|
const audience = input.audience.trim() || 'all';
|
||||||
|
const page = Math.max(1, Number.parseInt(input.page ?? '1', 10) || 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Number.parseInt(input.pageSize ?? '50', 10) || 50));
|
||||||
|
const query = input.query?.trim() ?? '';
|
||||||
|
const readFilter = input.read === 'read' || input.read === 'unread' ? input.read : 'all';
|
||||||
|
const authUserId = input.accessToken ? await this.getAuthUserId(input.accessToken).catch(() => null) : null;
|
||||||
|
|
||||||
|
const notifications = await this.prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
appKey: { in: ['all', appKey] },
|
||||||
|
audience: { in: ['all', audience] },
|
||||||
|
createdAt: {
|
||||||
|
gte: oneDayAgo,
|
||||||
|
lte: now,
|
||||||
|
},
|
||||||
|
...(query
|
||||||
|
? {
|
||||||
|
OR: [{ title: { contains: query } }, { body: { contains: query } }, { category: { contains: query } }],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ startsAt: null, endsAt: null },
|
||||||
|
{ startsAt: { lte: now }, endsAt: null },
|
||||||
|
{ startsAt: null, endsAt: { gte: now } },
|
||||||
|
{ startsAt: { lte: now }, endsAt: { gte: now } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
appKey: true,
|
||||||
|
audience: true,
|
||||||
|
category: true,
|
||||||
|
title: true,
|
||||||
|
body: true,
|
||||||
|
linkUrl: true,
|
||||||
|
createdAt: true,
|
||||||
|
reads: authUserId
|
||||||
|
? {
|
||||||
|
where: { authUserId },
|
||||||
|
select: { readAt: true },
|
||||||
|
take: 1,
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const categoryCodes = [...new Set(notifications.map((notification) => notification.category))];
|
||||||
|
const commonCodes = categoryCodes.length
|
||||||
|
? await this.prisma.commonCode.findMany({
|
||||||
|
where: {
|
||||||
|
groupKey: NOTIFICATION_CATEGORY_GROUP,
|
||||||
|
code: { in: categoryCodes },
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
code: true,
|
||||||
|
label: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const categoryLabelByCode = new Map(commonCodes.map((code) => [code.code, code.label]));
|
||||||
|
|
||||||
|
const mappedNotifications = notifications
|
||||||
|
.map((notification) => ({
|
||||||
|
id: notification.id,
|
||||||
|
appKey: notification.appKey,
|
||||||
|
audience: notification.audience,
|
||||||
|
category: notification.category,
|
||||||
|
categoryLabel: categoryLabelByCode.get(notification.category) ?? notification.category,
|
||||||
|
title: notification.title,
|
||||||
|
body: notification.body,
|
||||||
|
linkUrl: notification.linkUrl,
|
||||||
|
createdAt: notification.createdAt.toISOString(),
|
||||||
|
date: formatNotificationDate(notification.createdAt),
|
||||||
|
read: authUserId ? notification.reads.length > 0 : false,
|
||||||
|
}))
|
||||||
|
.filter((notification) => {
|
||||||
|
if (readFilter === 'read') return notification.read;
|
||||||
|
if (readFilter === 'unread') return !notification.read;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.read !== right.read) return left.read ? 1 : -1;
|
||||||
|
return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = mappedNotifications.length;
|
||||||
|
const unreadCount = mappedNotifications.filter((notification) => !notification.read).length;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: mappedNotifications.slice(start, start + pageSize),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
unreadCount,
|
||||||
|
retentionHours: 24,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateNotificationDto, accessToken: string | undefined) {
|
||||||
|
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
|
||||||
|
await this.getAuthUserId(accessToken);
|
||||||
|
|
||||||
|
const notification = await this.prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
appKey: input.appKey.trim(),
|
||||||
|
audience: input.audience?.trim() || 'all',
|
||||||
|
category: input.category?.trim() || 'notice',
|
||||||
|
title: input.title.trim(),
|
||||||
|
body: input.body.trim(),
|
||||||
|
linkUrl: input.linkUrl?.trim() || null,
|
||||||
|
priority: input.priority ?? 0,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
notification: {
|
||||||
|
id: notification.id,
|
||||||
|
appKey: notification.appKey,
|
||||||
|
audience: notification.audience,
|
||||||
|
category: notification.category,
|
||||||
|
title: notification.title,
|
||||||
|
body: notification.body,
|
||||||
|
linkUrl: notification.linkUrl,
|
||||||
|
createdAt: notification.createdAt.toISOString(),
|
||||||
|
date: formatNotificationDate(notification.createdAt),
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(notificationIds: string[], accessToken: string | undefined) {
|
||||||
|
if (!notificationIds.length) return { ok: true, readCount: 0 };
|
||||||
|
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
|
||||||
|
|
||||||
|
const authUserId = await this.getAuthUserId(accessToken);
|
||||||
|
const ids = [...new Set(notificationIds)];
|
||||||
|
|
||||||
|
await this.prisma.$transaction(
|
||||||
|
ids.map((notificationId) =>
|
||||||
|
this.prisma.notificationRead.upsert({
|
||||||
|
where: {
|
||||||
|
notificationId_authUserId: {
|
||||||
|
notificationId,
|
||||||
|
authUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
notificationId,
|
||||||
|
authUserId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
readAt: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ok: true, readCount: ids.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async markReadAll(input: MarkAllReadInput, accessToken: string | undefined) {
|
||||||
|
if (!accessToken) throw new UnauthorizedException('Missing bearer token');
|
||||||
|
|
||||||
|
if (input.notificationIds?.length) {
|
||||||
|
return this.markRead(input.notificationIds, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
const appKey = input.appKey?.trim() || 'all';
|
||||||
|
const audience = input.audience?.trim() || 'all';
|
||||||
|
const query = input.query?.trim() ?? '';
|
||||||
|
|
||||||
|
const notifications = await this.prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
appKey: { in: ['all', appKey] },
|
||||||
|
audience: { in: ['all', audience] },
|
||||||
|
createdAt: {
|
||||||
|
gte: oneDayAgo,
|
||||||
|
lte: now,
|
||||||
|
},
|
||||||
|
...(query
|
||||||
|
? {
|
||||||
|
OR: [{ title: { contains: query } }, { body: { contains: query } }, { category: { contains: query } }],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ startsAt: null, endsAt: null },
|
||||||
|
{ startsAt: { lte: now }, endsAt: null },
|
||||||
|
{ startsAt: null, endsAt: { gte: now } },
|
||||||
|
{ startsAt: { lte: now }, endsAt: { gte: now } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.markRead(
|
||||||
|
notifications.map((notification) => notification.id),
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthUserId(accessToken: string): Promise<string> {
|
||||||
|
const response = await fetch(`${this.authServerUrl}/auth/me`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new UnauthorizedException('Invalid bearer token');
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as AuthMeResponse | null;
|
||||||
|
if (!payload?.user?.id) throw new UnauthorizedException('Invalid bearer token');
|
||||||
|
|
||||||
|
return payload.user.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNotificationDate(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}.${month}.${day}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class ProvisioningDeviceDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
hostname?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
os?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
arch?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
kernel?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
distro?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
usageType?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
sshUser?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
workspaceRoot?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
publicSshKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateProvisioningDto {
|
||||||
|
@IsEmail()
|
||||||
|
account: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
hostname: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
os?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
arch?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
kernel?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
distro?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
usageType?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
sshUser?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
workspaceRoot?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
publicSshKey?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => ProvisioningDeviceDto)
|
||||||
|
@IsOptional()
|
||||||
|
device?: ProvisioningDeviceDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||||
|
import { ProvisioningService } from './provisioning.service';
|
||||||
|
import type { ProvisioningResponse } from './provisioning.service';
|
||||||
|
|
||||||
|
@Controller('api/provision')
|
||||||
|
export class ProvisioningController {
|
||||||
|
constructor(
|
||||||
|
private readonly provisioningService: ProvisioningService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
createProvisioning(
|
||||||
|
@Body() createProvisioningDto: CreateProvisioningDto,
|
||||||
|
): Promise<ProvisioningResponse> {
|
||||||
|
return this.provisioningService.createProvisioning(createProvisioningDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ssh-keys/lookup')
|
||||||
|
listSshKeys(
|
||||||
|
@Headers('x-lms-gateway-token') token: string | undefined,
|
||||||
|
@Body() body: { emails?: string[] },
|
||||||
|
) {
|
||||||
|
const expectedToken =
|
||||||
|
this.configService.get<string>('LMS_GATEWAY_TOKEN') ??
|
||||||
|
this.configService.get<string>('PLATFORM_GATEWAY_TOKEN') ??
|
||||||
|
'';
|
||||||
|
if (!expectedToken || token !== expectedToken) {
|
||||||
|
throw new UnauthorizedException('invalid_gateway_token');
|
||||||
|
}
|
||||||
|
return this.provisioningService.listLauncherSshKeys(body.emails ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { ProvisioningController } from './provisioning.controller';
|
||||||
|
import { ProvisioningService } from './provisioning.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [ProvisioningController],
|
||||||
|
providers: [ProvisioningService],
|
||||||
|
})
|
||||||
|
export class ProvisioningModule {}
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||||
|
|
||||||
|
export type ProvisioningResponse = {
|
||||||
|
installTailscale: boolean;
|
||||||
|
tailscaleAuthKey: string;
|
||||||
|
tailscaleLoginServer: string;
|
||||||
|
tailscaleHostname: string;
|
||||||
|
tailscaleExtraArgs: string;
|
||||||
|
provisioningMode: 'personal' | 'class';
|
||||||
|
agentGatewayToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthLoginResponse = {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string | null;
|
||||||
|
role?: string;
|
||||||
|
roles?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProvisioningService {
|
||||||
|
private readonly authServerUrl: string;
|
||||||
|
private readonly headscaleBaseUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
|
this.authServerUrl = this.configService
|
||||||
|
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
this.headscaleBaseUrl = this.configService
|
||||||
|
.get<string>(
|
||||||
|
'HEADSCALE_BASE_URL',
|
||||||
|
this.configService.get<string>(
|
||||||
|
'TAILSCALE_LOGIN_SERVER',
|
||||||
|
'https://atnmy-hs.duckdns.org',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProvisioning(
|
||||||
|
provisioningDto: CreateProvisioningDto,
|
||||||
|
): Promise<ProvisioningResponse> {
|
||||||
|
const authUser = await this.authenticateAccount(
|
||||||
|
provisioningDto.account,
|
||||||
|
provisioningDto.password,
|
||||||
|
);
|
||||||
|
const headscaleUser = this.toHeadscaleUserName(authUser.email);
|
||||||
|
const provisioningMode = this.normalizeProvisioningMode(
|
||||||
|
provisioningDto.usageType ?? provisioningDto.device?.usageType,
|
||||||
|
);
|
||||||
|
if (provisioningMode === 'class' && !this.isInstructor(authUser)) {
|
||||||
|
throw new ForbiddenException('Instructor role required for class device');
|
||||||
|
}
|
||||||
|
const aclTags =
|
||||||
|
provisioningMode === 'class' ? this.getClassDeviceAclTags() : [];
|
||||||
|
const tailscaleHostname = this.toTailscaleHostname(
|
||||||
|
provisioningDto.hostname || 'agent',
|
||||||
|
headscaleUser,
|
||||||
|
);
|
||||||
|
await this.recordProvisionedDevice(
|
||||||
|
provisioningDto,
|
||||||
|
authUser,
|
||||||
|
tailscaleHostname,
|
||||||
|
provisioningMode,
|
||||||
|
);
|
||||||
|
await this.recordLauncherSshKey(provisioningDto, authUser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
installTailscale: true,
|
||||||
|
tailscaleAuthKey: await this.createTailscaleAuthKey(
|
||||||
|
headscaleUser,
|
||||||
|
aclTags,
|
||||||
|
),
|
||||||
|
tailscaleLoginServer: this.configService.get<string>(
|
||||||
|
'TAILSCALE_LOGIN_SERVER',
|
||||||
|
this.headscaleBaseUrl,
|
||||||
|
),
|
||||||
|
tailscaleHostname,
|
||||||
|
tailscaleExtraArgs: this.configService.get<string>(
|
||||||
|
'TAILSCALE_EXTRA_ARGS',
|
||||||
|
'--accept-routes',
|
||||||
|
),
|
||||||
|
provisioningMode,
|
||||||
|
agentGatewayToken: this.configService.get<string>(
|
||||||
|
'AGENT_GATEWAY_TOKEN',
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listLauncherSshKeys(emails: string[]) {
|
||||||
|
const normalizedEmails = Array.from(
|
||||||
|
new Set(
|
||||||
|
emails
|
||||||
|
.map((email) => email.trim().toLowerCase())
|
||||||
|
.filter((email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!normalizedEmails.length) return { keysByEmail: {} };
|
||||||
|
|
||||||
|
const keys = await this.prisma.launcherSshKey.findMany({
|
||||||
|
where: { accountEmail: { in: normalizedEmails } },
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
accountEmail: true,
|
||||||
|
publicKey: true,
|
||||||
|
keyType: true,
|
||||||
|
label: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const keysByEmail: Record<string, Array<{
|
||||||
|
publicKey: string;
|
||||||
|
keyType: string | null;
|
||||||
|
label: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
}>> = {};
|
||||||
|
for (const key of keys) {
|
||||||
|
const bucket = keysByEmail[key.accountEmail] ?? [];
|
||||||
|
bucket.push({
|
||||||
|
publicKey: key.publicKey,
|
||||||
|
keyType: key.keyType,
|
||||||
|
label: key.label,
|
||||||
|
updatedAt: key.updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
keysByEmail[key.accountEmail] = bucket;
|
||||||
|
}
|
||||||
|
return { keysByEmail };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordLauncherSshKey(
|
||||||
|
provisioningDto: CreateProvisioningDto,
|
||||||
|
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||||
|
) {
|
||||||
|
const publicKey = this.normalizePublicSshKey(
|
||||||
|
provisioningDto.publicSshKey || provisioningDto.device?.publicSshKey,
|
||||||
|
);
|
||||||
|
if (!publicKey) return;
|
||||||
|
|
||||||
|
const publicKeyHash = createHash('sha256').update(publicKey).digest('hex');
|
||||||
|
const keyType = publicKey.split(/\s+/, 1)[0] || null;
|
||||||
|
await this.prisma.launcherSshKey.upsert({
|
||||||
|
where: {
|
||||||
|
accountEmail_publicKeyHash: {
|
||||||
|
accountEmail: authUser.email,
|
||||||
|
publicKeyHash,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
accountEmail: authUser.email,
|
||||||
|
authUserId: authUser.id,
|
||||||
|
publicKey,
|
||||||
|
publicKeyHash,
|
||||||
|
keyType,
|
||||||
|
label: 'launcher',
|
||||||
|
lastProvisionedAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
authUserId: authUser.id,
|
||||||
|
publicKey,
|
||||||
|
keyType,
|
||||||
|
label: 'launcher',
|
||||||
|
lastProvisionedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordProvisionedDevice(
|
||||||
|
provisioningDto: CreateProvisioningDto,
|
||||||
|
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||||
|
tailscaleHostname: string,
|
||||||
|
provisioningMode: 'personal' | 'class',
|
||||||
|
) {
|
||||||
|
const hostname = this.cleanString(
|
||||||
|
provisioningDto.hostname || provisioningDto.device?.hostname || 'agent',
|
||||||
|
) ?? 'agent';
|
||||||
|
const sshUser = this.cleanString(
|
||||||
|
provisioningDto.sshUser || provisioningDto.device?.sshUser,
|
||||||
|
);
|
||||||
|
const workspaceRoot = this.cleanString(
|
||||||
|
provisioningDto.workspaceRoot || provisioningDto.device?.workspaceRoot,
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
await this.prisma.provisionedDevice.upsert({
|
||||||
|
where: {
|
||||||
|
accountEmail_tailscaleHostname: {
|
||||||
|
accountEmail: authUser.email,
|
||||||
|
tailscaleHostname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
accountEmail: authUser.email,
|
||||||
|
authUserId: authUser.id,
|
||||||
|
hostname,
|
||||||
|
tailscaleHostname,
|
||||||
|
sshUser,
|
||||||
|
workspaceRoot,
|
||||||
|
usageType: provisioningMode,
|
||||||
|
os: this.cleanString(provisioningDto.os || provisioningDto.device?.os),
|
||||||
|
arch: this.cleanString(
|
||||||
|
provisioningDto.arch || provisioningDto.device?.arch,
|
||||||
|
),
|
||||||
|
kernel: this.cleanString(
|
||||||
|
provisioningDto.kernel || provisioningDto.device?.kernel,
|
||||||
|
),
|
||||||
|
distro: this.cleanString(
|
||||||
|
provisioningDto.distro || provisioningDto.device?.distro,
|
||||||
|
),
|
||||||
|
device: this.cleanJson(provisioningDto.device),
|
||||||
|
installedAt: now,
|
||||||
|
lastProvisionedAt: now,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
authUserId: authUser.id,
|
||||||
|
hostname,
|
||||||
|
sshUser,
|
||||||
|
workspaceRoot,
|
||||||
|
usageType: provisioningMode,
|
||||||
|
os: this.cleanString(provisioningDto.os || provisioningDto.device?.os),
|
||||||
|
arch: this.cleanString(
|
||||||
|
provisioningDto.arch || provisioningDto.device?.arch,
|
||||||
|
),
|
||||||
|
kernel: this.cleanString(
|
||||||
|
provisioningDto.kernel || provisioningDto.device?.kernel,
|
||||||
|
),
|
||||||
|
distro: this.cleanString(
|
||||||
|
provisioningDto.distro || provisioningDto.device?.distro,
|
||||||
|
),
|
||||||
|
device: this.cleanJson(provisioningDto.device),
|
||||||
|
lastProvisionedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanString(value?: string | null) {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePublicSshKey(value?: string | null) {
|
||||||
|
const normalized = value?.trim().replace(/\s+/g, ' ');
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (!/^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanJson(value: unknown): Prisma.InputJsonValue | undefined {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async authenticateAccount(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<NonNullable<AuthLoginResponse['user']>> {
|
||||||
|
let response: globalThis.Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${this.authServerUrl}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new BadGatewayException('Auth server is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| AuthLoginResponse
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new UnauthorizedException('Invalid account or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload?.user?.email) {
|
||||||
|
throw new BadGatewayException({
|
||||||
|
message: 'Auth server request failed',
|
||||||
|
statusCode: response.status,
|
||||||
|
error: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createTailscaleAuthKey(
|
||||||
|
headscaleUser: string,
|
||||||
|
aclTags: string[],
|
||||||
|
): Promise<string> {
|
||||||
|
const headscaleApiKey = this.configService.get<string>('HEADSCALE_API_KEY');
|
||||||
|
if (!headscaleApiKey) {
|
||||||
|
throw new BadGatewayException('HEADSCALE_API_KEY is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const headscaleUserId = await this.ensureHeadscaleUser(
|
||||||
|
headscaleUser,
|
||||||
|
headscaleApiKey,
|
||||||
|
);
|
||||||
|
return this.createHeadscalePreAuthKey(
|
||||||
|
headscaleUserId,
|
||||||
|
headscaleApiKey,
|
||||||
|
aclTags,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureHeadscaleUser(
|
||||||
|
userName: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const response = await this.headscaleRequest('/api/v1/user', apiKey, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name: userName },
|
||||||
|
});
|
||||||
|
const payload = await this.readResponsePayload(response);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 409 || this.isAlreadyExistsResponse(payload)) {
|
||||||
|
return this.findHeadscaleUserId(userName, apiKey);
|
||||||
|
}
|
||||||
|
throw new BadGatewayException(
|
||||||
|
this.formatHeadscaleError(
|
||||||
|
'Headscale user provisioning failed',
|
||||||
|
response.status,
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdUserId = this.findUserId(payload, userName);
|
||||||
|
return createdUserId ?? this.findHeadscaleUserId(userName, apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createHeadscalePreAuthKey(
|
||||||
|
userId: number,
|
||||||
|
apiKey: string,
|
||||||
|
aclTags: string[],
|
||||||
|
): Promise<string> {
|
||||||
|
const expiresInSeconds = this.configService.get<number>(
|
||||||
|
'TAILSCALE_PREAUTH_KEY_EXPIRATION_SECONDS',
|
||||||
|
60 * 60 * 24,
|
||||||
|
);
|
||||||
|
const expiration = new Date(
|
||||||
|
Date.now() + expiresInSeconds * 1000,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
const { response, payload } = await this.createHeadscalePreAuthKeyRequest(
|
||||||
|
userId,
|
||||||
|
apiKey,
|
||||||
|
expiration,
|
||||||
|
aclTags,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (this.isAlreadyExistsResponse(payload)) {
|
||||||
|
const retry = await this.createHeadscalePreAuthKeyRequest(
|
||||||
|
userId,
|
||||||
|
apiKey,
|
||||||
|
new Date(Date.now() + (expiresInSeconds + 30) * 1000).toISOString(),
|
||||||
|
aclTags,
|
||||||
|
);
|
||||||
|
if (retry.response.ok) {
|
||||||
|
const retryKey = this.findAuthKey(retry.payload);
|
||||||
|
if (retryKey) return retryKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BadGatewayException(
|
||||||
|
this.formatHeadscaleError(
|
||||||
|
'Headscale pre-auth key provisioning failed',
|
||||||
|
response.status,
|
||||||
|
payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.findAuthKey(payload);
|
||||||
|
if (!key) {
|
||||||
|
throw new BadGatewayException('Headscale response did not include a key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createHeadscalePreAuthKeyRequest(
|
||||||
|
userId: number,
|
||||||
|
apiKey: string,
|
||||||
|
expiration: string,
|
||||||
|
aclTags: string[],
|
||||||
|
) {
|
||||||
|
const response = await this.headscaleRequest('/api/v1/preauthkey', apiKey, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
user: userId,
|
||||||
|
reusable: false,
|
||||||
|
ephemeral: false,
|
||||||
|
expiration,
|
||||||
|
aclTags,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
response,
|
||||||
|
payload: await this.readResponsePayload(response),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async headscaleRequest(
|
||||||
|
path: string,
|
||||||
|
apiKey: string,
|
||||||
|
options: { method: 'GET' | 'POST'; body?: unknown },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await fetch(`${this.headscaleBaseUrl}${path}`, {
|
||||||
|
method: options.method,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${apiKey}`,
|
||||||
|
...(options.body ? { 'content-type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new BadGatewayException('Headscale server is unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readResponsePayload(response: Response): Promise<unknown> {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
if (!text) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatHeadscaleError(
|
||||||
|
message: string,
|
||||||
|
statusCode: number,
|
||||||
|
payload: unknown,
|
||||||
|
): string {
|
||||||
|
const detail = this.formatPayloadMessage(payload);
|
||||||
|
return detail
|
||||||
|
? `${message}: HTTP ${statusCode} ${detail}`
|
||||||
|
: `${message}: HTTP ${statusCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatPayloadMessage(payload: unknown): string {
|
||||||
|
if (!payload) return '';
|
||||||
|
if (typeof payload === 'string') return payload;
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return payload
|
||||||
|
.map((item) => this.formatPayloadMessage(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
if (typeof payload !== 'object') return String(payload);
|
||||||
|
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
for (const key of ['message', 'error', 'details']) {
|
||||||
|
const message = this.formatPayloadMessage(record[key]);
|
||||||
|
if (message) return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findHeadscaleUserId(
|
||||||
|
userName: string,
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const listResponse = await this.headscaleRequest('/api/v1/user', apiKey, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
const listPayload = (await listResponse.json().catch(() => null)) as unknown;
|
||||||
|
const listedUserId = this.findUserId(listPayload, userName);
|
||||||
|
if (listedUserId) return listedUserId;
|
||||||
|
|
||||||
|
const detailResponse = await this.headscaleRequest(
|
||||||
|
`/api/v1/user/${encodeURIComponent(userName)}`,
|
||||||
|
apiKey,
|
||||||
|
{ method: 'GET' },
|
||||||
|
);
|
||||||
|
const detailPayload = (await detailResponse
|
||||||
|
.json()
|
||||||
|
.catch(() => null)) as unknown;
|
||||||
|
const detailUserId = this.findUserId(detailPayload, userName);
|
||||||
|
if (detailUserId) return detailUserId;
|
||||||
|
|
||||||
|
throw new BadGatewayException({
|
||||||
|
message: 'Headscale user id was not found',
|
||||||
|
error: { user: userName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private findAuthKey(payload: unknown): string | null {
|
||||||
|
if (!payload || typeof payload !== 'object') return null;
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
if (typeof record.key === 'string') return record.key;
|
||||||
|
|
||||||
|
for (const value of Object.values(record)) {
|
||||||
|
const nestedKey = this.findAuthKey(value);
|
||||||
|
if (nestedKey) return nestedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAlreadyExistsResponse(payload: unknown) {
|
||||||
|
const serialized =
|
||||||
|
typeof payload === 'string' ? payload : JSON.stringify(payload ?? {});
|
||||||
|
return /already exists|exists|duplicate|unique constraint/i.test(
|
||||||
|
serialized,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findUserId(payload: unknown, userName: string): number | null {
|
||||||
|
if (!payload || typeof payload !== 'object') return null;
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
for (const item of payload) {
|
||||||
|
const userId = this.findUserId(item, userName);
|
||||||
|
if (userId) return userId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = payload as Record<string, unknown>;
|
||||||
|
const recordNames = ['name', 'username', 'email']
|
||||||
|
.map((key) => (typeof record[key] === 'string' ? String(record[key]) : ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
const normalizedRecordNames = recordNames.map((name) =>
|
||||||
|
this.toHeadscaleUserName(name),
|
||||||
|
);
|
||||||
|
const normalizedUserName = this.toHeadscaleUserName(userName);
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedRecordNames.includes(normalizedUserName) &&
|
||||||
|
(typeof record.id === 'number' || typeof record.id === 'string')
|
||||||
|
) {
|
||||||
|
const parsedId = Number(record.id);
|
||||||
|
return Number.isFinite(parsedId) && parsedId > 0 ? parsedId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of Object.values(record)) {
|
||||||
|
const userId = this.findUserId(value, userName);
|
||||||
|
if (userId) return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toHeadscaleUserName(email: string) {
|
||||||
|
return email
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/@/g, '-at-')
|
||||||
|
.replace(/[^a-z0-9-]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 63);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeProvisioningMode(value?: string | null): 'personal' | 'class' {
|
||||||
|
const normalized = String(value ?? '').trim().toLowerCase();
|
||||||
|
return ['class', 'classroom', 'education', 'educational', 'training', 'use for class'].includes(normalized)
|
||||||
|
? 'class'
|
||||||
|
: 'personal';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isInstructor(user: NonNullable<AuthLoginResponse['user']>) {
|
||||||
|
const roles = new Set(
|
||||||
|
[user.role, ...(user.roles ?? [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((role) => String(role).toUpperCase()),
|
||||||
|
);
|
||||||
|
return ['INSTRUCTOR', 'LMS_ADMIN', 'ADMIN'].some((role) =>
|
||||||
|
roles.has(role),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClassDeviceAclTags() {
|
||||||
|
return this.configService
|
||||||
|
.get<string>('HEADSCALE_CLASS_DEVICE_TAGS', '')
|
||||||
|
.split(',')
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((tag) => (tag.startsWith('tag:') ? tag : `tag:${tag}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toTailscaleHostname(hostname: string, headscaleUser: string) {
|
||||||
|
const normalizedHost = hostname
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
return `${headscaleUser}-${normalizedHost || 'agent'}`.slice(0, 63);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('AppController (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"incremental": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user