init
This commit is contained in:
14
server/.env.example
Normal file
14
server/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
PORT=3000
|
||||
DATABASE_URL="file:./dev.db"
|
||||
DOWNLOAD_TICKET_TTL_SEC=120
|
||||
|
||||
JWT_ACCESS_SECRET=change_this_access_secret
|
||||
JWT_REFRESH_SECRET=change_this_refresh_secret
|
||||
JWT_ACCESS_EXPIRES_IN=2h
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
GITLAB_BASE_URL=
|
||||
GITLAB_API_BASE=
|
||||
GITLAB_PROJECT_ID=
|
||||
GITLAB_TOKEN=
|
||||
GITLAB_PACKAGE_NAME_PREFIX=toolsshow
|
||||
4
server/.prettierrc
Normal file
4
server/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
98
server/README.md
Normal file
98
server/README.md
Normal file
@@ -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).
|
||||
173
server/docs/API_REFERENCE.md
Normal file
173
server/docs/API_REFERENCE.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# ToolsShow API Reference
|
||||
|
||||
Base URL: `/api/v1`
|
||||
|
||||
All JSON responses (except binary download stream) use the wrapper:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {},
|
||||
"traceId": "uuid",
|
||||
"timestamp": "2026-03-26T10:10:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Public APIs
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/health` | Liveness/readiness check |
|
||||
| GET | `/tools` | Query tools list (query/category/sort/page/pageSize) |
|
||||
| GET | `/tools/:id` | Tool detail |
|
||||
| POST | `/tools/:id/launch` | Unified launch (web/download) |
|
||||
| GET | `/downloads/:ticket` | Consume ticket and stream artifact |
|
||||
| GET | `/categories` | Category list with tool count |
|
||||
| GET | `/keywords/hot` | Hot keyword list |
|
||||
| GET | `/overview` | KPI overview |
|
||||
|
||||
### `GET /tools` query params
|
||||
|
||||
| Param | Type | Required | Default |
|
||||
|---|---|---|---|
|
||||
| `query` | string | No | - |
|
||||
| `category` | string | No | `all` |
|
||||
| `sortBy` | `popular \| latest \| rating \| name` | No | `latest` |
|
||||
| `page` | number | No | `1` |
|
||||
| `pageSize` | number (1-50) | No | `6` |
|
||||
|
||||
### `POST /tools/:id/launch` body
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "official",
|
||||
"clientVersion": "web-1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
Web mode response:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "web",
|
||||
"actionUrl": "https://example.com",
|
||||
"openIn": "new_tab"
|
||||
}
|
||||
```
|
||||
|
||||
Download mode response:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "download",
|
||||
"ticket": "dl_tk_xxx",
|
||||
"expiresInSec": 120,
|
||||
"actionUrl": "/api/v1/downloads/dl_tk_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Auth APIs
|
||||
|
||||
Auth header for protected admin APIs:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <accessToken>
|
||||
```
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/admin/auth/login` | No | Admin login |
|
||||
| POST | `/admin/auth/refresh` | No | Refresh tokens |
|
||||
| POST | `/admin/auth/logout` | Yes | Admin logout |
|
||||
| GET | `/admin/auth/me` | Yes | Current admin profile |
|
||||
|
||||
Default seeded admin account:
|
||||
|
||||
```text
|
||||
username: admin
|
||||
password: admin123456
|
||||
```
|
||||
|
||||
## Admin Tool APIs
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/admin/tools` | Query tools |
|
||||
| POST | `/admin/tools` | Create tool |
|
||||
| GET | `/admin/tools/:id` | Tool detail |
|
||||
| PATCH | `/admin/tools/:id` | Update tool |
|
||||
| PATCH | `/admin/tools/:id/status` | Update tool status |
|
||||
| PATCH | `/admin/tools/:id/access-mode` | Switch access mode |
|
||||
| DELETE | `/admin/tools/:id` | Soft delete tool |
|
||||
|
||||
### Create/Update tool payload core fields
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Tool Name",
|
||||
"categoryId": "cat_dev",
|
||||
"description": "Tool description",
|
||||
"rating": 4.5,
|
||||
"tags": ["tag_hot", "tag_free"],
|
||||
"features": ["Feature A", "Feature B"],
|
||||
"accessMode": "web",
|
||||
"openUrl": "https://example.com",
|
||||
"openInNewTab": true,
|
||||
"status": "draft"
|
||||
}
|
||||
```
|
||||
|
||||
## Admin Artifact APIs
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/admin/tools/:id/artifacts` | Upload artifact (`multipart/form-data`) |
|
||||
| GET | `/admin/tools/:id/artifacts` | List artifacts |
|
||||
| PATCH | `/admin/tools/:id/artifacts/:artifactId/latest` | Set latest artifact |
|
||||
| PATCH | `/admin/tools/:id/artifacts/:artifactId/status` | Update artifact status |
|
||||
| DELETE | `/admin/tools/:id/artifacts/:artifactId` | Soft delete artifact metadata |
|
||||
|
||||
### Upload form fields
|
||||
|
||||
| Field | Type | Required |
|
||||
|---|---|---|
|
||||
| `file` | binary | Yes |
|
||||
| `version` | string | Yes |
|
||||
| `releaseNotes` | string | No |
|
||||
| `isLatest` | boolean | No (default `true`) |
|
||||
|
||||
## Admin Audit APIs
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/admin/audit-logs` | Query admin audit logs |
|
||||
|
||||
Query params:
|
||||
|
||||
| Param | Type | Required |
|
||||
|---|---|---|
|
||||
| `action` | string | No |
|
||||
| `resourceType` | string | No |
|
||||
| `adminUserId` | string | No |
|
||||
| `page` | number | No |
|
||||
| `pageSize` | number | No |
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `1001` | validation failed |
|
||||
| `1002` | unauthorized |
|
||||
| `1003` | forbidden |
|
||||
| `1004` | resource not found |
|
||||
| `1005` | conflict |
|
||||
| `1010` | invalid credentials |
|
||||
| `1011` | token invalid/expired |
|
||||
| `1201` | GitLab upload failed |
|
||||
| `1202` | GitLab download failed |
|
||||
| `1203` | artifact not available |
|
||||
| `1204` | download ticket invalid/expired |
|
||||
| `1210` | tool access mode mismatch |
|
||||
| `1211` | web open URL not configured |
|
||||
| `1500` | internal server error |
|
||||
1095
server/docs/openapi.json
Normal file
1095
server/docs/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
35
server/eslint.config.mjs
Normal file
35
server/eslint.config.mjs
Normal file
@@ -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" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
8
server/nest-cli.json
Normal file
8
server/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
11405
server/package-lock.json
generated
Normal file
11405
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
94
server/package.json
Normal file
94
server/package.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "server",
|
||||
"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",
|
||||
"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",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "prisma db seed",
|
||||
"docs:api": "node -r ts-node/register scripts/generate-api-docs.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@types/multer": "^2.1.0",
|
||||
"argon2": "^0.44.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
"multer": "^2.1.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"prisma": "^6.16.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.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",
|
||||
"tsx": "^4.21.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": "tsx prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
231
server/prisma/migrations/20260326034442_init/migration.sql
Normal file
231
server/prisma/migrations/20260326034442_init/migration.sql
Normal file
@@ -0,0 +1,231 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "tools" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"category_id" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"rating" REAL NOT NULL DEFAULT 0,
|
||||
"download_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"open_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"access_mode" TEXT NOT NULL DEFAULT 'download',
|
||||
"open_url" TEXT,
|
||||
"open_in_new_tab" BOOLEAN NOT NULL DEFAULT true,
|
||||
"latest_artifact_id" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||
"updated_at" TEXT NOT NULL,
|
||||
"is_deleted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"modified_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "tools_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "tools_latest_artifact_id_fkey" FOREIGN KEY ("latest_artifact_id") REFERENCES "tool_artifacts" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tool_artifacts" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"tool_id" TEXT NOT NULL,
|
||||
"version" TEXT NOT NULL,
|
||||
"file_name" TEXT NOT NULL,
|
||||
"file_size_bytes" INTEGER NOT NULL,
|
||||
"sha256" TEXT NOT NULL,
|
||||
"mime_type" TEXT,
|
||||
"gitlab_project_id" INTEGER NOT NULL,
|
||||
"gitlab_package_name" TEXT NOT NULL,
|
||||
"gitlab_package_version" TEXT NOT NULL,
|
||||
"gitlab_file_path" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"release_notes" TEXT,
|
||||
"uploaded_by" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "tool_artifacts_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "tool_artifacts_uploaded_by_fkey" FOREIGN KEY ("uploaded_by") REFERENCES "admin_users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "categories" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 100,
|
||||
"is_deleted" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tags" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"is_deleted" BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tool_tags" (
|
||||
"tool_id" TEXT NOT NULL,
|
||||
"tag_id" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("tool_id", "tag_id"),
|
||||
CONSTRAINT "tool_tags_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "tool_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "tags" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tool_features" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"tool_id" TEXT NOT NULL,
|
||||
"feature_text" TEXT NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 100,
|
||||
CONSTRAINT "tool_features_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "hot_keywords" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"keyword" TEXT NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 100,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "download_tickets" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"ticket" TEXT NOT NULL,
|
||||
"tool_id" TEXT NOT NULL,
|
||||
"artifact_id" TEXT NOT NULL,
|
||||
"channel" TEXT,
|
||||
"client_version" TEXT,
|
||||
"request_ip" TEXT,
|
||||
"expires_at" DATETIME NOT NULL,
|
||||
"consumed_at" DATETIME,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "download_tickets_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "download_tickets_artifact_id_fkey" FOREIGN KEY ("artifact_id") REFERENCES "tool_artifacts" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "download_records" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tool_id" TEXT NOT NULL,
|
||||
"artifact_id" TEXT NOT NULL,
|
||||
"ticket" TEXT,
|
||||
"downloaded_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"client_ip" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"channel" TEXT,
|
||||
"client_version" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'success',
|
||||
"error_message" TEXT,
|
||||
CONSTRAINT "download_records_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "download_records_artifact_id_fkey" FOREIGN KEY ("artifact_id") REFERENCES "tool_artifacts" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "open_records" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tool_id" TEXT NOT NULL,
|
||||
"opened_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"client_ip" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"channel" TEXT,
|
||||
"client_version" TEXT,
|
||||
"referer" TEXT,
|
||||
CONSTRAINT "open_records_tool_id_fkey" FOREIGN KEY ("tool_id") REFERENCES "tools" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "admin_users" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"username" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"display_name" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"last_login_at" DATETIME,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"modified_at" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "admin_audit_logs" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"admin_user_id" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"resource_type" TEXT NOT NULL,
|
||||
"resource_id" TEXT,
|
||||
"request_method" TEXT NOT NULL,
|
||||
"request_path" TEXT NOT NULL,
|
||||
"request_body" TEXT,
|
||||
"ip" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "admin_audit_logs_admin_user_id_fkey" FOREIGN KEY ("admin_user_id") REFERENCES "admin_users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tools_slug_key" ON "tools"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tools_category_id" ON "tools"("category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tools_status" ON "tools"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tools_access_mode" ON "tools"("access_mode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tools_download_count" ON "tools"("download_count");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tools_open_count" ON "tools"("open_count");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tools_updated_at" ON "tools"("updated_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tools_rating" ON "tools"("rating");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tools_name" ON "tools"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_artifact_tool_id" ON "tool_artifacts"("tool_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_artifact_status" ON "tool_artifacts"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "uk_tool_version" ON "tool_artifacts"("tool_id", "version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "categories_name_key" ON "categories"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_tool_feature_tool_id" ON "tool_features"("tool_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hot_keywords_keyword_key" ON "hot_keywords"("keyword");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "download_tickets_ticket_key" ON "download_tickets"("ticket");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_download_tickets_tool_id" ON "download_tickets"("tool_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_download_tickets_expires_at" ON "download_tickets"("expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_download_records_tool_id" ON "download_records"("tool_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_download_records_artifact_id" ON "download_records"("artifact_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_open_records_tool_id" ON "open_records"("tool_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "admin_users_username_key" ON "admin_users"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_admin_audit_logs_user_id" ON "admin_audit_logs"("admin_user_id");
|
||||
3
server/prisma/migrations/migration_lock.toml
Normal file
3
server/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
246
server/prisma/schema.prisma
Normal file
246
server/prisma/schema.prisma
Normal file
@@ -0,0 +1,246 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum AccessMode {
|
||||
web
|
||||
download
|
||||
}
|
||||
|
||||
enum ToolStatus {
|
||||
draft
|
||||
published
|
||||
archived
|
||||
}
|
||||
|
||||
enum ArtifactStatus {
|
||||
active
|
||||
deprecated
|
||||
deleted
|
||||
}
|
||||
|
||||
enum DownloadRecordStatus {
|
||||
success
|
||||
failed
|
||||
}
|
||||
|
||||
enum AdminUserStatus {
|
||||
active
|
||||
disabled
|
||||
}
|
||||
|
||||
model Tool {
|
||||
id String @id
|
||||
name String
|
||||
slug String @unique
|
||||
categoryId String @map("category_id")
|
||||
description String
|
||||
rating Float @default(0)
|
||||
downloadCount Int @default(0) @map("download_count")
|
||||
openCount Int @default(0) @map("open_count")
|
||||
accessMode AccessMode @default(download) @map("access_mode")
|
||||
openUrl String? @map("open_url")
|
||||
openInNewTab Boolean @default(true) @map("open_in_new_tab")
|
||||
latestArtifactId String? @map("latest_artifact_id")
|
||||
status ToolStatus @default(draft)
|
||||
updatedAt String @map("updated_at")
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
modifiedAt DateTime @updatedAt @map("modified_at")
|
||||
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||
latestArtifact ToolArtifact? @relation("ToolLatestArtifact", fields: [latestArtifactId], references: [id], onDelete: SetNull)
|
||||
artifacts ToolArtifact[] @relation("ToolArtifacts")
|
||||
tags ToolTag[]
|
||||
features ToolFeature[]
|
||||
downloadTickets DownloadTicket[]
|
||||
downloadRecords DownloadRecord[]
|
||||
openRecords OpenRecord[]
|
||||
|
||||
@@index([categoryId], map: "idx_tools_category_id")
|
||||
@@index([status], map: "idx_tools_status")
|
||||
@@index([accessMode], map: "idx_tools_access_mode")
|
||||
@@index([downloadCount], map: "idx_tools_download_count")
|
||||
@@index([openCount], map: "idx_tools_open_count")
|
||||
@@index([updatedAt], map: "idx_tools_updated_at")
|
||||
@@index([rating], map: "idx_tools_rating")
|
||||
@@index([name], map: "idx_tools_name")
|
||||
@@map("tools")
|
||||
}
|
||||
|
||||
model ToolArtifact {
|
||||
id String @id
|
||||
toolId String @map("tool_id")
|
||||
version String
|
||||
fileName String @map("file_name")
|
||||
fileSizeBytes Int @map("file_size_bytes")
|
||||
sha256 String
|
||||
mimeType String? @map("mime_type")
|
||||
gitlabProjectId Int @map("gitlab_project_id")
|
||||
gitlabPackageName String @map("gitlab_package_name")
|
||||
gitlabPackageVersion String @map("gitlab_package_version")
|
||||
gitlabFilePath String @map("gitlab_file_path")
|
||||
status ArtifactStatus @default(active)
|
||||
releaseNotes String? @map("release_notes")
|
||||
uploadedBy String? @map("uploaded_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tool Tool @relation("ToolArtifacts", fields: [toolId], references: [id], onDelete: Restrict)
|
||||
latestForTool Tool[] @relation("ToolLatestArtifact")
|
||||
uploader AdminUser? @relation(fields: [uploadedBy], references: [id], onDelete: SetNull)
|
||||
downloadTickets DownloadTicket[]
|
||||
downloadRecords DownloadRecord[]
|
||||
|
||||
@@unique([toolId, version], map: "uk_tool_version")
|
||||
@@index([toolId], map: "idx_artifact_tool_id")
|
||||
@@index([status], map: "idx_artifact_status")
|
||||
@@map("tool_artifacts")
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id
|
||||
name String @unique
|
||||
sortOrder Int @default(100) @map("sort_order")
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
tools Tool[]
|
||||
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id
|
||||
name String @unique
|
||||
isDeleted Boolean @default(false) @map("is_deleted")
|
||||
tools ToolTag[]
|
||||
|
||||
@@map("tags")
|
||||
}
|
||||
|
||||
model ToolTag {
|
||||
toolId String @map("tool_id")
|
||||
tagId String @map("tag_id")
|
||||
|
||||
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([toolId, tagId])
|
||||
@@map("tool_tags")
|
||||
}
|
||||
|
||||
model ToolFeature {
|
||||
id String @id
|
||||
toolId String @map("tool_id")
|
||||
featureText String @map("feature_text")
|
||||
sortOrder Int @default(100) @map("sort_order")
|
||||
|
||||
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([toolId], map: "idx_tool_feature_tool_id")
|
||||
@@map("tool_features")
|
||||
}
|
||||
|
||||
model HotKeyword {
|
||||
id String @id
|
||||
keyword String @unique
|
||||
sortOrder Int @default(100) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
@@map("hot_keywords")
|
||||
}
|
||||
|
||||
model DownloadTicket {
|
||||
id Int @id @default(autoincrement())
|
||||
ticket String @unique
|
||||
toolId String @map("tool_id")
|
||||
artifactId String @map("artifact_id")
|
||||
channel String?
|
||||
clientVersion String? @map("client_version")
|
||||
requestIp String? @map("request_ip")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
consumedAt DateTime? @map("consumed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tool Tool @relation(fields: [toolId], references: [id], onDelete: Restrict)
|
||||
artifact ToolArtifact @relation(fields: [artifactId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([toolId], map: "idx_download_tickets_tool_id")
|
||||
@@index([expiresAt], map: "idx_download_tickets_expires_at")
|
||||
@@map("download_tickets")
|
||||
}
|
||||
|
||||
model DownloadRecord {
|
||||
id Int @id @default(autoincrement())
|
||||
toolId String @map("tool_id")
|
||||
artifactId String @map("artifact_id")
|
||||
ticket String?
|
||||
downloadedAt DateTime @default(now()) @map("downloaded_at")
|
||||
clientIp String? @map("client_ip")
|
||||
userAgent String? @map("user_agent")
|
||||
channel String?
|
||||
clientVersion String? @map("client_version")
|
||||
status DownloadRecordStatus @default(success)
|
||||
errorMessage String? @map("error_message")
|
||||
|
||||
tool Tool @relation(fields: [toolId], references: [id], onDelete: Restrict)
|
||||
artifact ToolArtifact @relation(fields: [artifactId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([toolId], map: "idx_download_records_tool_id")
|
||||
@@index([artifactId], map: "idx_download_records_artifact_id")
|
||||
@@map("download_records")
|
||||
}
|
||||
|
||||
model OpenRecord {
|
||||
id Int @id @default(autoincrement())
|
||||
toolId String @map("tool_id")
|
||||
openedAt DateTime @default(now()) @map("opened_at")
|
||||
clientIp String? @map("client_ip")
|
||||
userAgent String? @map("user_agent")
|
||||
channel String?
|
||||
clientVersion String? @map("client_version")
|
||||
referer String?
|
||||
|
||||
tool Tool @relation(fields: [toolId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([toolId], map: "idx_open_records_tool_id")
|
||||
@@map("open_records")
|
||||
}
|
||||
|
||||
model AdminUser {
|
||||
id String @id
|
||||
username String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
displayName String? @map("display_name")
|
||||
status AdminUserStatus @default(active)
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
modifiedAt DateTime @updatedAt @map("modified_at")
|
||||
|
||||
artifacts ToolArtifact[]
|
||||
auditLogs AdminAuditLog[]
|
||||
|
||||
@@map("admin_users")
|
||||
}
|
||||
|
||||
model AdminAuditLog {
|
||||
id Int @id @default(autoincrement())
|
||||
adminUserId String? @map("admin_user_id")
|
||||
action String
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String? @map("resource_id")
|
||||
requestMethod String @map("request_method")
|
||||
requestPath String @map("request_path")
|
||||
requestBody String? @map("request_body")
|
||||
ip String?
|
||||
userAgent String? @map("user_agent")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
adminUser AdminUser? @relation(fields: [adminUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([adminUserId], map: "idx_admin_audit_logs_user_id")
|
||||
@@map("admin_audit_logs")
|
||||
}
|
||||
152
server/prisma/seed.ts
Normal file
152
server/prisma/seed.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import argon2 from 'argon2';
|
||||
import { PrismaClient, AccessMode, ToolStatus, ArtifactStatus, AdminUserStatus } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
function todayDateString(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const nowDate = todayDateString();
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.toolTag.deleteMany(),
|
||||
prisma.toolFeature.deleteMany(),
|
||||
prisma.downloadRecord.deleteMany(),
|
||||
prisma.downloadTicket.deleteMany(),
|
||||
prisma.openRecord.deleteMany(),
|
||||
prisma.adminAuditLog.deleteMany(),
|
||||
prisma.toolArtifact.deleteMany(),
|
||||
prisma.tool.deleteMany(),
|
||||
prisma.tag.deleteMany(),
|
||||
prisma.category.deleteMany(),
|
||||
prisma.hotKeyword.deleteMany(),
|
||||
prisma.adminUser.deleteMany(),
|
||||
]);
|
||||
|
||||
await prisma.category.createMany({
|
||||
data: [
|
||||
{ id: 'cat_ai', name: 'AI', sortOrder: 10 },
|
||||
{ id: 'cat_dev', name: 'Developer', sortOrder: 20 },
|
||||
{ id: 'cat_ops', name: 'Operations', sortOrder: 30 },
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.tag.createMany({
|
||||
data: [
|
||||
{ id: 'tag_hot', name: 'Hot' },
|
||||
{ id: 'tag_free', name: 'Free' },
|
||||
{ id: 'tag_official', name: 'Official' },
|
||||
],
|
||||
});
|
||||
|
||||
const webToolId = 'tool_web_001';
|
||||
const downloadToolId = 'tool_dl_001';
|
||||
const artifactId = 'art_001';
|
||||
|
||||
await prisma.tool.create({
|
||||
data: {
|
||||
id: webToolId,
|
||||
name: 'OpenAI Playground',
|
||||
slug: 'openai-playground',
|
||||
categoryId: 'cat_ai',
|
||||
description: 'OpenAI web playground for prompt testing.',
|
||||
rating: 4.8,
|
||||
downloadCount: 0,
|
||||
openCount: 128,
|
||||
accessMode: AccessMode.web,
|
||||
openUrl: 'https://platform.openai.com/playground',
|
||||
openInNewTab: true,
|
||||
status: ToolStatus.published,
|
||||
updatedAt: nowDate,
|
||||
features: {
|
||||
createMany: {
|
||||
data: [
|
||||
{ id: 'feat_web_001', featureText: 'Prompt debugging', sortOrder: 10 },
|
||||
{ id: 'feat_web_002', featureText: 'Model parameter tuning', sortOrder: 20 },
|
||||
],
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
create: [{ tagId: 'tag_hot' }, { tagId: 'tag_official' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.tool.create({
|
||||
data: {
|
||||
id: downloadToolId,
|
||||
name: 'ToolsShow Desktop',
|
||||
slug: 'toolsshow-desktop',
|
||||
categoryId: 'cat_dev',
|
||||
description: 'Desktop bundle for local workflows.',
|
||||
rating: 4.6,
|
||||
downloadCount: 52,
|
||||
openCount: 0,
|
||||
accessMode: AccessMode.download,
|
||||
status: ToolStatus.published,
|
||||
updatedAt: nowDate,
|
||||
features: {
|
||||
createMany: {
|
||||
data: [
|
||||
{ id: 'feat_dl_001', featureText: 'Offline usage', sortOrder: 10 },
|
||||
{ id: 'feat_dl_002', featureText: 'Bundled plugins', sortOrder: 20 },
|
||||
],
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
create: [{ tagId: 'tag_free' }],
|
||||
},
|
||||
artifacts: {
|
||||
create: {
|
||||
id: artifactId,
|
||||
version: '1.0.0',
|
||||
fileName: 'toolsshow-desktop-1.0.0.zip',
|
||||
fileSizeBytes: 12_345_678,
|
||||
sha256: 'sample-sha256-not-real',
|
||||
mimeType: 'application/zip',
|
||||
gitlabProjectId: 0,
|
||||
gitlabPackageName: 'toolsshow/toolsshow-desktop',
|
||||
gitlabPackageVersion: '1.0.0',
|
||||
gitlabFilePath: 'storage/toolsshow-desktop-1.0.0.zip',
|
||||
status: ArtifactStatus.active,
|
||||
releaseNotes: 'Initial release',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.tool.update({
|
||||
where: { id: downloadToolId },
|
||||
data: { latestArtifactId: artifactId },
|
||||
});
|
||||
|
||||
await prisma.hotKeyword.createMany({
|
||||
data: [
|
||||
{ id: 'kw_001', keyword: 'agent', sortOrder: 10, isActive: true },
|
||||
{ id: 'kw_002', keyword: 'automation', sortOrder: 20, isActive: true },
|
||||
{ id: 'kw_003', keyword: 'open-source', sortOrder: 30, isActive: true },
|
||||
],
|
||||
});
|
||||
|
||||
const passwordHash = await argon2.hash('admin123456', { type: argon2.argon2id });
|
||||
await prisma.adminUser.create({
|
||||
data: {
|
||||
id: 'u_admin_001',
|
||||
username: 'admin',
|
||||
passwordHash,
|
||||
displayName: 'System Admin',
|
||||
status: AdminUserStatus.active,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
44
server/scripts/generate-api-docs.ts
Normal file
44
server/scripts/generate-api-docs.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
|
||||
async function generateApiDocs() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: false,
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('ToolsShow Backend API')
|
||||
.setDescription('Generated OpenAPI document for ToolsShow backend.')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
'admin-access-token',
|
||||
)
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
|
||||
const docsDir = join(process.cwd(), 'docs');
|
||||
mkdirSync(docsDir, { recursive: true });
|
||||
writeFileSync(join(docsDir, 'openapi.json'), JSON.stringify(document, null, 2), 'utf-8');
|
||||
|
||||
const prisma = app.get(PrismaService);
|
||||
await prisma.$disconnect();
|
||||
await app.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
generateApiDocs().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
38
server/src/app.module.ts
Normal file
38
server/src/app.module.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AccessModule } from './modules/access/access.module';
|
||||
import { AdminArtifactsModule } from './modules/admin-artifacts/admin-artifacts.module';
|
||||
import { AdminAuditModule } from './modules/admin-audit/admin-audit.module';
|
||||
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
|
||||
import { AdminToolsModule } from './modules/admin-tools/admin-tools.module';
|
||||
import { CategoriesModule } from './modules/categories/categories.module';
|
||||
import { DownloadsModule } from './modules/downloads/downloads.module';
|
||||
import { GitlabStorageModule } from './modules/gitlab-storage/gitlab-storage.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { KeywordsModule } from './modules/keywords/keywords.module';
|
||||
import { OverviewModule } from './modules/overview/overview.module';
|
||||
import { ToolsModule } from './modules/tools/tools.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env', '.env.example'],
|
||||
}),
|
||||
PrismaModule,
|
||||
HealthModule,
|
||||
ToolsModule,
|
||||
CategoriesModule,
|
||||
KeywordsModule,
|
||||
OverviewModule,
|
||||
AccessModule,
|
||||
GitlabStorageModule,
|
||||
DownloadsModule,
|
||||
AdminAuthModule,
|
||||
AdminToolsModule,
|
||||
AdminArtifactsModule,
|
||||
AdminAuditModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
18
server/src/common/constants/error-codes.ts
Normal file
18
server/src/common/constants/error-codes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const ERROR_CODES = {
|
||||
VALIDATION_FAILED: 1001,
|
||||
UNAUTHORIZED: 1002,
|
||||
FORBIDDEN: 1003,
|
||||
NOT_FOUND: 1004,
|
||||
CONFLICT: 1005,
|
||||
INVALID_CREDENTIALS: 1010,
|
||||
TOKEN_INVALID: 1011,
|
||||
GITLAB_UPLOAD_FAILED: 1201,
|
||||
GITLAB_DOWNLOAD_FAILED: 1202,
|
||||
ARTIFACT_NOT_AVAILABLE: 1203,
|
||||
DOWNLOAD_TICKET_INVALID: 1204,
|
||||
TOOL_ACCESS_MODE_MISMATCH: 1210,
|
||||
WEB_OPEN_URL_NOT_CONFIGURED: 1211,
|
||||
INTERNAL_SERVER_ERROR: 1500,
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
11
server/src/common/decorators/audit.decorator.ts
Normal file
11
server/src/common/decorators/audit.decorator.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const AUDIT_METADATA_KEY = 'audit:meta';
|
||||
|
||||
export interface AuditMetadata {
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceIdParam?: string;
|
||||
}
|
||||
|
||||
export const Audit = (meta: AuditMetadata) => SetMetadata(AUDIT_METADATA_KEY, meta);
|
||||
4
server/src/common/decorators/public.decorator.ts
Normal file
4
server/src/common/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
13
server/src/common/exceptions/app.exception.ts
Normal file
13
server/src/common/exceptions/app.exception.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import type { ErrorCode } from '../constants/error-codes';
|
||||
|
||||
export class AppException extends HttpException {
|
||||
constructor(
|
||||
public readonly errorCode: ErrorCode,
|
||||
message: string,
|
||||
status: HttpStatus = HttpStatus.BAD_REQUEST,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super({ message, details }, status);
|
||||
}
|
||||
}
|
||||
90
server/src/common/filters/http-exception.filter.ts
Normal file
90
server/src/common/filters/http-exception.filter.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ERROR_CODES, type ErrorCode } from '../constants/error-codes';
|
||||
import { AppException } from '../exceptions/app.exception';
|
||||
import type { RequestWithContext } from '../interfaces/request-with-context.interface';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<RequestWithContext>();
|
||||
|
||||
const traceId = request.traceId ?? 'unknown-trace-id';
|
||||
const timestamp = new Date().toISOString();
|
||||
const path = request.url;
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let code: ErrorCode = ERROR_CODES.INTERNAL_SERVER_ERROR;
|
||||
let message = 'internal server error';
|
||||
let details: unknown;
|
||||
|
||||
if (exception instanceof AppException) {
|
||||
status = exception.getStatus();
|
||||
code = exception.errorCode;
|
||||
const payload = exception.getResponse() as { message?: string; details?: unknown };
|
||||
message = payload?.message ?? exception.message;
|
||||
details = payload?.details ?? exception.details;
|
||||
} else if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
code = this.statusToCode(status);
|
||||
const payload = exception.getResponse();
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
message = payload;
|
||||
} else if (typeof payload === 'object' && payload) {
|
||||
const p = payload as { message?: string | string[]; error?: string };
|
||||
if (Array.isArray(p.message)) {
|
||||
message = p.message.join('; ');
|
||||
details = p.message;
|
||||
} else {
|
||||
message = p.message ?? p.error ?? message;
|
||||
}
|
||||
} else {
|
||||
message = exception.message;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
this.logger.error(exception.message, exception.stack);
|
||||
} else {
|
||||
this.logger.error(`Unknown exception: ${String(exception)}`);
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
code,
|
||||
message,
|
||||
data: null,
|
||||
details: details ?? null,
|
||||
traceId,
|
||||
timestamp,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
private statusToCode(status: number): ErrorCode {
|
||||
switch (status) {
|
||||
case HttpStatus.BAD_REQUEST:
|
||||
return ERROR_CODES.VALIDATION_FAILED;
|
||||
case HttpStatus.UNAUTHORIZED:
|
||||
return ERROR_CODES.UNAUTHORIZED;
|
||||
case HttpStatus.FORBIDDEN:
|
||||
return ERROR_CODES.FORBIDDEN;
|
||||
case HttpStatus.NOT_FOUND:
|
||||
return ERROR_CODES.NOT_FOUND;
|
||||
case HttpStatus.CONFLICT:
|
||||
return ERROR_CODES.CONFLICT;
|
||||
default:
|
||||
return ERROR_CODES.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
server/src/common/guards/admin-jwt.guard.ts
Normal file
5
server/src/common/guards/admin-jwt.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class AdminJwtGuard extends AuthGuard('jwt') {}
|
||||
123
server/src/common/interceptors/audit/admin-audit.interceptor.ts
Normal file
123
server/src/common/interceptors/audit/admin-audit.interceptor.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { type Observable, catchError, tap, throwError } from 'rxjs';
|
||||
import { AUDIT_METADATA_KEY, type AuditMetadata } from '../../decorators/audit.decorator';
|
||||
import type { RequestWithContext } from '../../interfaces/request-with-context.interface';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuditInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(AdminAuditInterceptor.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const meta = this.reflector.get<AuditMetadata>(AUDIT_METADATA_KEY, context.getHandler());
|
||||
if (!meta) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<RequestWithContext>();
|
||||
const method = request.method;
|
||||
const path = request.originalUrl ?? request.url;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
void this.writeAuditLog(request, meta, method, path, true);
|
||||
}),
|
||||
catchError((error) => {
|
||||
void this.writeAuditLog(request, meta, method, path, false, error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async writeAuditLog(
|
||||
request: RequestWithContext,
|
||||
meta: AuditMetadata,
|
||||
method: string,
|
||||
path: string,
|
||||
success: boolean,
|
||||
error?: unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const resourceIdParam = meta.resourceIdParam ?? 'id';
|
||||
const rawResourceId = request.params?.[resourceIdParam];
|
||||
const resourceId = Array.isArray(rawResourceId) ? rawResourceId[0] : rawResourceId;
|
||||
const body = this.maskSensitiveBody(request.body);
|
||||
|
||||
await this.prisma.adminAuditLog.create({
|
||||
data: {
|
||||
adminUserId: request.user?.sub,
|
||||
action: success ? meta.action : `${meta.action}.failed`,
|
||||
resourceType: meta.resourceType,
|
||||
resourceId: resourceId ?? null,
|
||||
requestMethod: method,
|
||||
requestPath: path,
|
||||
requestBody: body ? JSON.stringify(body) : null,
|
||||
ip: this.extractIp(request),
|
||||
userAgent: this.extractHeader(request, 'user-agent'),
|
||||
},
|
||||
});
|
||||
|
||||
if (!success && error) {
|
||||
this.logger.warn(
|
||||
`Audit error recorded for action=${meta.action}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to persist audit log for action=${meta.action}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private maskSensitiveBody(body: unknown): unknown {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return body;
|
||||
}
|
||||
|
||||
const cloned = { ...(body as Record<string, unknown>) };
|
||||
const fieldsToMask = ['password', 'accessToken', 'refreshToken', 'token'];
|
||||
|
||||
for (const field of fieldsToMask) {
|
||||
if (field in cloned) {
|
||||
cloned[field] = '***';
|
||||
}
|
||||
}
|
||||
|
||||
return cloned;
|
||||
}
|
||||
|
||||
private extractIp(request: RequestWithContext): string | undefined {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
if (Array.isArray(forwarded) && forwarded.length > 0) {
|
||||
return forwarded[0]?.split(',')[0]?.trim();
|
||||
}
|
||||
if (typeof forwarded === 'string') {
|
||||
return forwarded.split(',')[0]?.trim();
|
||||
}
|
||||
return request.ip;
|
||||
}
|
||||
|
||||
private extractHeader(request: RequestWithContext, name: string): string | undefined {
|
||||
const value = request.headers[name];
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
}
|
||||
22
server/src/common/interceptors/response.interceptor.ts
Normal file
22
server/src/common/interceptors/response.interceptor.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { map, type Observable } from 'rxjs';
|
||||
import type { RequestWithContext } from '../interfaces/request-with-context.interface';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T> implements NestInterceptor<T, unknown> {
|
||||
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const request = http.getRequest<RequestWithContext>();
|
||||
const traceId = request.traceId ?? 'unknown-trace-id';
|
||||
|
||||
return next.handle().pipe(
|
||||
map((data) => ({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: data ?? null,
|
||||
traceId,
|
||||
timestamp: new Date().toISOString(),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
export interface RequestWithContext extends Request {
|
||||
traceId?: string;
|
||||
user?: {
|
||||
sub: string;
|
||||
username: string;
|
||||
type: 'access';
|
||||
};
|
||||
}
|
||||
67
server/src/main.ts
Normal file
67
server/src/main.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
import type { RequestWithContext } from './common/interfaces/request-with-context.interface';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const prisma = app.get(PrismaService);
|
||||
|
||||
app.use((req: RequestWithContext, res, next) => {
|
||||
req.traceId = req.headers['x-trace-id']?.toString() ?? randomUUID();
|
||||
res.setHeader('x-trace-id', req.traceId);
|
||||
next();
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.useGlobalInterceptors(new ResponseInterceptor());
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('ToolsShow Backend API')
|
||||
.setDescription('NestJS backend for ToolsShow with hybrid web/download tool access.')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
'admin-access-token',
|
||||
)
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const clientDistPath = process.env.CLIENT_DIST_PATH ?? join(__dirname, '..', 'public');
|
||||
if (existsSync(clientDistPath)) {
|
||||
app.useStaticAssets(clientDistPath);
|
||||
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
expressApp.get(/^(?!\/api(?:\/|$)).*/, (_req, res) => {
|
||||
res.sendFile(join(clientDistPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.enableShutdownHooks(app);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
21
server/src/modules/access/access.controller.ts
Normal file
21
server/src/modules/access/access.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Body, Controller, Param, Post, Req } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { LaunchToolDto } from './dto/launch-tool.dto';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@ApiTags('public-launch')
|
||||
@Controller('tools')
|
||||
export class AccessController {
|
||||
constructor(private readonly accessService: AccessService) {}
|
||||
|
||||
@Post(':id/launch')
|
||||
@ApiOperation({ summary: 'Unified launch endpoint (web/download)' })
|
||||
launchTool(
|
||||
@Param('id') id: string,
|
||||
@Body() body: LaunchToolDto,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.accessService.launchTool(id, body, request);
|
||||
}
|
||||
}
|
||||
9
server/src/modules/access/access.module.ts
Normal file
9
server/src/modules/access/access.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AccessController } from './access.controller';
|
||||
import { AccessService } from './access.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccessController],
|
||||
providers: [AccessService],
|
||||
})
|
||||
export class AccessModule {}
|
||||
120
server/src/modules/access/access.service.ts
Normal file
120
server/src/modules/access/access.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ArtifactStatus, ToolStatus } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { LaunchToolDto } from './dto/launch-tool.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async launchTool(toolId: string, body: LaunchToolDto, request: RequestWithContext) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id: toolId,
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
include: {
|
||||
latestArtifact: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (tool.accessMode === 'web') {
|
||||
if (!tool.openUrl) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
|
||||
'web open url is not configured',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.openRecord.create({
|
||||
data: {
|
||||
toolId: tool.id,
|
||||
channel: body.channel,
|
||||
clientVersion: body.clientVersion,
|
||||
clientIp: this.extractIp(request),
|
||||
userAgent: request.headers['user-agent'],
|
||||
referer: this.extractHeader(request, 'referer'),
|
||||
},
|
||||
}),
|
||||
this.prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: { openCount: { increment: 1 } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
mode: 'web' as const,
|
||||
actionUrl: tool.openUrl,
|
||||
openIn: tool.openInNewTab ? 'new_tab' : 'same_tab',
|
||||
};
|
||||
}
|
||||
|
||||
if (!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
||||
'artifact not available for this download tool',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
const ttlSec = this.configService.get<number>('DOWNLOAD_TICKET_TTL_SEC', 120);
|
||||
const expiresAt = new Date(Date.now() + ttlSec * 1000);
|
||||
const ticket = `dl_tk_${randomUUID().replace(/-/g, '')}`;
|
||||
|
||||
await this.prisma.downloadTicket.create({
|
||||
data: {
|
||||
ticket,
|
||||
toolId: tool.id,
|
||||
artifactId: tool.latestArtifact.id,
|
||||
channel: body.channel,
|
||||
clientVersion: body.clientVersion,
|
||||
requestIp: this.extractIp(request),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
mode: 'download' as const,
|
||||
ticket,
|
||||
expiresInSec: ttlSec,
|
||||
actionUrl: `/api/v1/downloads/${ticket}`,
|
||||
};
|
||||
}
|
||||
|
||||
private extractIp(request: RequestWithContext): string | undefined {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
if (Array.isArray(forwarded) && forwarded.length > 0) {
|
||||
return forwarded[0]?.split(',')[0]?.trim();
|
||||
}
|
||||
if (typeof forwarded === 'string') {
|
||||
return forwarded.split(',')[0]?.trim();
|
||||
}
|
||||
return request.ip;
|
||||
}
|
||||
|
||||
private extractHeader(
|
||||
request: RequestWithContext,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const value = request.headers[name];
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
}
|
||||
16
server/src/modules/access/dto/launch-tool.dto.ts
Normal file
16
server/src/modules/access/dto/launch-tool.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class LaunchToolDto {
|
||||
@ApiPropertyOptional({ example: 'official' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
channel?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'web-1.0.0' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
clientVersion?: string;
|
||||
}
|
||||
110
server/src/modules/admin-artifacts/admin-artifacts.controller.ts
Normal file
110
server/src/modules/admin-artifacts/admin-artifacts.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { UpdateArtifactStatusDto } from './dto/update-artifact-status.dto';
|
||||
import { UploadArtifactDto } from './dto/upload-artifact.dto';
|
||||
import { AdminArtifactsService } from './admin-artifacts.service';
|
||||
|
||||
@ApiTags('admin-artifacts')
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@UseInterceptors(AdminAuditInterceptor)
|
||||
@Controller('admin/tools/:id/artifacts')
|
||||
export class AdminArtifactsController {
|
||||
constructor(private readonly adminArtifactsService: AdminArtifactsService) {}
|
||||
|
||||
@Post()
|
||||
@Audit({ action: 'artifact.upload', resourceType: 'artifact', resourceIdParam: 'id' })
|
||||
@ApiOperation({ summary: 'Upload artifact file for tool' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
version: { type: 'string', example: '1.0.0' },
|
||||
releaseNotes: { type: 'string', example: 'Initial release' },
|
||||
isLatest: { type: 'boolean', example: true },
|
||||
},
|
||||
required: ['file', 'version'],
|
||||
},
|
||||
})
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 512 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
)
|
||||
uploadArtifact(
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: UploadArtifactDto,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.adminArtifactsService.uploadArtifact(id, file, body, request.user?.sub);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List tool artifacts' })
|
||||
listArtifacts(@Param('id') id: string) {
|
||||
return this.adminArtifactsService.listToolArtifacts(id);
|
||||
}
|
||||
|
||||
@Patch(':artifactId/latest')
|
||||
@Audit({
|
||||
action: 'artifact.set_latest',
|
||||
resourceType: 'artifact',
|
||||
resourceIdParam: 'artifactId',
|
||||
})
|
||||
@ApiOperation({ summary: 'Set latest artifact' })
|
||||
setLatestArtifact(@Param('id') id: string, @Param('artifactId') artifactId: string) {
|
||||
return this.adminArtifactsService.setLatestArtifact(id, artifactId);
|
||||
}
|
||||
|
||||
@Patch(':artifactId/status')
|
||||
@Audit({
|
||||
action: 'artifact.update_status',
|
||||
resourceType: 'artifact',
|
||||
resourceIdParam: 'artifactId',
|
||||
})
|
||||
@ApiOperation({ summary: 'Update artifact status' })
|
||||
updateArtifactStatus(
|
||||
@Param('id') id: string,
|
||||
@Param('artifactId') artifactId: string,
|
||||
@Body() body: UpdateArtifactStatusDto,
|
||||
) {
|
||||
return this.adminArtifactsService.updateArtifactStatus(id, artifactId, body.status);
|
||||
}
|
||||
|
||||
@Delete(':artifactId')
|
||||
@Audit({ action: 'artifact.delete', resourceType: 'artifact', resourceIdParam: 'artifactId' })
|
||||
@ApiOperation({ summary: 'Delete artifact metadata (soft via status=deleted)' })
|
||||
deleteArtifact(@Param('id') id: string, @Param('artifactId') artifactId: string) {
|
||||
return this.adminArtifactsService.deleteArtifact(id, artifactId);
|
||||
}
|
||||
}
|
||||
12
server/src/modules/admin-artifacts/admin-artifacts.module.ts
Normal file
12
server/src/modules/admin-artifacts/admin-artifacts.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
|
||||
import { GitlabStorageModule } from '../gitlab-storage/gitlab-storage.module';
|
||||
import { AdminArtifactsController } from './admin-artifacts.controller';
|
||||
import { AdminArtifactsService } from './admin-artifacts.service';
|
||||
|
||||
@Module({
|
||||
imports: [GitlabStorageModule],
|
||||
controllers: [AdminArtifactsController],
|
||||
providers: [AdminArtifactsService, AdminAuditInterceptor],
|
||||
})
|
||||
export class AdminArtifactsModule {}
|
||||
311
server/src/modules/admin-artifacts/admin-artifacts.service.ts
Normal file
311
server/src/modules/admin-artifacts/admin-artifacts.service.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ArtifactStatus } from '@prisma/client';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { GitlabStorageService } from '../gitlab-storage/gitlab-storage.service';
|
||||
import { UploadArtifactDto } from './dto/upload-artifact.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminArtifactsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gitlabStorageService: GitlabStorageService,
|
||||
) {}
|
||||
|
||||
async uploadArtifact(toolId: string, file: Express.Multer.File | undefined, body: UploadArtifactDto, adminId?: string) {
|
||||
const tool = await this.assertDownloadModeTool(toolId);
|
||||
this.assertUploadFile(file);
|
||||
|
||||
const uploadMaxSizeMb = Number(process.env.UPLOAD_MAX_SIZE_MB ?? 512);
|
||||
if (file!.size > uploadMaxSizeMb * 1024 * 1024) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.VALIDATION_FAILED,
|
||||
`file size exceeds ${uploadMaxSizeMb}MB`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
const exists = await this.prisma.toolArtifact.findFirst({
|
||||
where: {
|
||||
toolId,
|
||||
version: body.version,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (exists) {
|
||||
throw new AppException(ERROR_CODES.CONFLICT, 'version already exists', HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
|
||||
const uploadResult = await this.gitlabStorageService.uploadArtifact({
|
||||
toolId,
|
||||
version: body.version,
|
||||
fileName: file!.originalname,
|
||||
mimeType: file!.mimetype,
|
||||
buffer: file!.buffer,
|
||||
});
|
||||
|
||||
const artifactId = this.generateBusinessId('art');
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.toolArtifact.create({
|
||||
data: {
|
||||
id: artifactId,
|
||||
toolId,
|
||||
version: body.version,
|
||||
fileName: file!.originalname,
|
||||
fileSizeBytes: file!.size,
|
||||
sha256,
|
||||
mimeType: file!.mimetype,
|
||||
gitlabProjectId: uploadResult.gitlabProjectId,
|
||||
gitlabPackageName: uploadResult.gitlabPackageName,
|
||||
gitlabPackageVersion: uploadResult.gitlabPackageVersion,
|
||||
gitlabFilePath: uploadResult.gitlabFilePath,
|
||||
releaseNotes: body.releaseNotes,
|
||||
status: ArtifactStatus.active,
|
||||
uploadedBy: adminId,
|
||||
},
|
||||
});
|
||||
|
||||
const setLatest = body.isLatest ?? true;
|
||||
if (setLatest) {
|
||||
await tx.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: {
|
||||
latestArtifactId: artifactId,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return this.getArtifactById(toolId, artifactId);
|
||||
}
|
||||
|
||||
async listToolArtifacts(toolId: string) {
|
||||
await this.assertToolExists(toolId);
|
||||
const artifacts = await this.prisma.toolArtifact.findMany({
|
||||
where: {
|
||||
toolId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
const tool = await this.prisma.tool.findUnique({
|
||||
where: {
|
||||
id: toolId,
|
||||
},
|
||||
select: {
|
||||
latestArtifactId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return artifacts.map((item) => ({
|
||||
id: item.id,
|
||||
version: item.version,
|
||||
fileName: item.fileName,
|
||||
fileSizeBytes: item.fileSizeBytes,
|
||||
sha256: item.sha256,
|
||||
mimeType: item.mimeType,
|
||||
status: item.status,
|
||||
releaseNotes: item.releaseNotes,
|
||||
isLatest: tool?.latestArtifactId === item.id,
|
||||
createdAt: item.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async setLatestArtifact(toolId: string, artifactId: string) {
|
||||
const artifact = await this.assertArtifactBelongsToTool(toolId, artifactId);
|
||||
if (artifact.status !== ArtifactStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
||||
'only active artifact can be set as latest',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.tool.update({
|
||||
where: {
|
||||
id: toolId,
|
||||
},
|
||||
data: {
|
||||
latestArtifactId: artifactId,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.getArtifactById(toolId, artifactId);
|
||||
}
|
||||
|
||||
async updateArtifactStatus(toolId: string, artifactId: string, status: ArtifactStatus) {
|
||||
const artifact = await this.assertArtifactBelongsToTool(toolId, artifactId);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.toolArtifact.update({
|
||||
where: { id: artifact.id },
|
||||
data: {
|
||||
status,
|
||||
},
|
||||
});
|
||||
|
||||
if (status !== ArtifactStatus.active) {
|
||||
const tool = await tx.tool.findUnique({
|
||||
where: { id: toolId },
|
||||
select: { latestArtifactId: true },
|
||||
});
|
||||
|
||||
if (tool?.latestArtifactId === artifactId) {
|
||||
const fallback = await tx.toolArtifact.findFirst({
|
||||
where: {
|
||||
toolId,
|
||||
id: {
|
||||
not: artifactId,
|
||||
},
|
||||
status: ArtifactStatus.active,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.tool.update({
|
||||
where: { id: toolId },
|
||||
data: {
|
||||
latestArtifactId: fallback?.id ?? null,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.getArtifactById(toolId, artifactId);
|
||||
}
|
||||
|
||||
async deleteArtifact(toolId: string, artifactId: string) {
|
||||
await this.updateArtifactStatus(toolId, artifactId, ArtifactStatus.deleted);
|
||||
return {
|
||||
success: true,
|
||||
id: artifactId,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertToolExists(toolId: string) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id: toolId,
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
private async assertDownloadModeTool(toolId: string) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id: toolId,
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
accessMode: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (tool.accessMode !== 'download') {
|
||||
throw new AppException(
|
||||
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
|
||||
'artifact upload is only allowed for download mode tool',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
private async assertArtifactBelongsToTool(toolId: string, artifactId: string) {
|
||||
await this.assertToolExists(toolId);
|
||||
const artifact = await this.prisma.toolArtifact.findFirst({
|
||||
where: {
|
||||
id: artifactId,
|
||||
toolId,
|
||||
},
|
||||
});
|
||||
if (!artifact) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'artifact not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return artifact;
|
||||
}
|
||||
|
||||
private async getArtifactById(toolId: string, artifactId: string) {
|
||||
const artifact = await this.assertArtifactBelongsToTool(toolId, artifactId);
|
||||
const tool = await this.prisma.tool.findUnique({
|
||||
where: { id: toolId },
|
||||
select: {
|
||||
latestArtifactId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: artifact.id,
|
||||
toolId: artifact.toolId,
|
||||
version: artifact.version,
|
||||
fileName: artifact.fileName,
|
||||
fileSizeBytes: artifact.fileSizeBytes,
|
||||
sha256: artifact.sha256,
|
||||
mimeType: artifact.mimeType,
|
||||
gitlabProjectId: artifact.gitlabProjectId,
|
||||
gitlabPackageName: artifact.gitlabPackageName,
|
||||
gitlabPackageVersion: artifact.gitlabPackageVersion,
|
||||
gitlabFilePath: artifact.gitlabFilePath,
|
||||
status: artifact.status,
|
||||
releaseNotes: artifact.releaseNotes,
|
||||
isLatest: tool?.latestArtifactId === artifact.id,
|
||||
createdAt: artifact.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
private assertUploadFile(file: Express.Multer.File | undefined): asserts file is Express.Multer.File {
|
||||
if (!file) {
|
||||
throw new AppException(ERROR_CODES.VALIDATION_FAILED, 'file is required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const fileName = file.originalname.toLowerCase();
|
||||
const allowedExtensions = ['.zip', '.tar.gz', '.tgz', '.exe', '.dmg', '.pkg', '.msi'];
|
||||
const isAllowed = allowedExtensions.some((ext) => fileName.endsWith(ext));
|
||||
if (!isAllowed) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.VALIDATION_FAILED,
|
||||
`file extension is not allowed: ${file.originalname}`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private generateBusinessId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
||||
}
|
||||
|
||||
private getDateOnlyString(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArtifactStatus } from '@prisma/client';
|
||||
import { IsEnum } from 'class-validator';
|
||||
|
||||
export class UpdateArtifactStatusDto {
|
||||
@ApiProperty({ enum: ArtifactStatus })
|
||||
@IsEnum(ArtifactStatus)
|
||||
status!: ArtifactStatus;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class UploadArtifactDto {
|
||||
@ApiProperty({ example: '1.0.0' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
version!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Initial release' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
releaseNotes?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
isLatest?: boolean;
|
||||
}
|
||||
19
server/src/modules/admin-audit/admin-audit.controller.ts
Normal file
19
server/src/modules/admin-audit/admin-audit.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||
import { AdminAuditQueryDto } from './dto/admin-audit-query.dto';
|
||||
import { AdminAuditService } from './admin-audit.service';
|
||||
|
||||
@ApiTags('admin-audit')
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@Controller('admin/audit-logs')
|
||||
export class AdminAuditController {
|
||||
constructor(private readonly adminAuditService: AdminAuditService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Query admin audit logs' })
|
||||
getAuditLogs(@Query() query: AdminAuditQueryDto) {
|
||||
return this.adminAuditService.getAuditLogs(query);
|
||||
}
|
||||
}
|
||||
9
server/src/modules/admin-audit/admin-audit.module.ts
Normal file
9
server/src/modules/admin-audit/admin-audit.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminAuditController } from './admin-audit.controller';
|
||||
import { AdminAuditService } from './admin-audit.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminAuditController],
|
||||
providers: [AdminAuditService],
|
||||
})
|
||||
export class AdminAuditModule {}
|
||||
74
server/src/modules/admin-audit/admin-audit.service.ts
Normal file
74
server/src/modules/admin-audit/admin-audit.service.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AdminAuditQueryDto } from './dto/admin-audit-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getAuditLogs(query: AdminAuditQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(query.pageSize ?? 20, 100);
|
||||
|
||||
const where: Prisma.AdminAuditLogWhereInput = {};
|
||||
if (query.action) {
|
||||
where.action = { contains: query.action };
|
||||
}
|
||||
if (query.resourceType) {
|
||||
where.resourceType = query.resourceType;
|
||||
}
|
||||
if (query.adminUserId) {
|
||||
where.adminUserId = query.adminUserId;
|
||||
}
|
||||
|
||||
const [total, logs] = await this.prisma.$transaction([
|
||||
this.prisma.adminAuditLog.count({ where }),
|
||||
this.prisma.adminAuditLog.findMany({
|
||||
where,
|
||||
include: {
|
||||
adminUser: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
list: logs.map((item) => ({
|
||||
id: item.id,
|
||||
action: item.action,
|
||||
resourceType: item.resourceType,
|
||||
resourceId: item.resourceId,
|
||||
requestMethod: item.requestMethod,
|
||||
requestPath: item.requestPath,
|
||||
requestBody: item.requestBody,
|
||||
ip: item.ip,
|
||||
userAgent: item.userAgent,
|
||||
createdAt: item.createdAt,
|
||||
adminUser: item.adminUser
|
||||
? {
|
||||
id: item.adminUser.id,
|
||||
username: item.adminUser.username,
|
||||
displayName: item.adminUser.displayName,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
35
server/src/modules/admin-audit/dto/admin-audit-query.dto.ts
Normal file
35
server/src/modules/admin-audit/dto/admin-audit-query.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class AdminAuditQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
action?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resourceType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adminUserId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 20, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
}
|
||||
41
server/src/modules/admin-auth/admin-auth.controller.ts
Normal file
41
server/src/modules/admin-auth/admin-auth.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
|
||||
@ApiTags('admin-auth')
|
||||
@Controller('admin/auth')
|
||||
export class AdminAuthController {
|
||||
constructor(private readonly adminAuthService: AdminAuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Admin login' })
|
||||
login(@Body() body: LoginDto) {
|
||||
return this.adminAuthService.login(body);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@ApiOperation({ summary: 'Refresh admin token' })
|
||||
refresh(@Body() body: RefreshTokenDto) {
|
||||
return this.adminAuthService.refresh(body.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@ApiOperation({ summary: 'Admin logout' })
|
||||
logout() {
|
||||
return this.adminAuthService.logout();
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@ApiOperation({ summary: 'Get current admin profile' })
|
||||
me(@Req() request: RequestWithContext) {
|
||||
return this.adminAuthService.getMe(request.user?.sub ?? '');
|
||||
}
|
||||
}
|
||||
23
server/src/modules/admin-auth/admin-auth.module.ts
Normal file
23
server/src/modules/admin-auth/admin-auth.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AdminAuthController } from './admin-auth.controller';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminJwtStrategy } from './strategies/admin-jwt.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_ACCESS_SECRET', 'change_this_access_secret'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AdminAuthController],
|
||||
providers: [AdminAuthService, AdminJwtStrategy],
|
||||
exports: [AdminAuthService],
|
||||
})
|
||||
export class AdminAuthModule {}
|
||||
189
server/src/modules/admin-auth/admin-auth.service.ts
Normal file
189
server/src/modules/admin-auth/admin-auth.service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AdminUserStatus } from '@prisma/client';
|
||||
import argon2 from 'argon2';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import type { LoginDto } from './dto/login.dto';
|
||||
import type { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async login(body: LoginDto) {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: {
|
||||
username: body.username,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || user.status !== AdminUserStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.INVALID_CREDENTIALS,
|
||||
'invalid username or password',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
const isValidPassword = await argon2.verify(user.passwordHash, body.password);
|
||||
if (!isValidPassword) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.INVALID_CREDENTIALS,
|
||||
'invalid username or password',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
await this.prisma.adminUser.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const tokens = await this.issueTokens({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
type: 'access',
|
||||
});
|
||||
|
||||
return {
|
||||
...tokens,
|
||||
profile: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName ?? user.username,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const payload = await this.verifyRefreshToken(refreshToken);
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
if (!user || user.status !== AdminUserStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.TOKEN_INVALID,
|
||||
'token invalid or expired',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
return this.issueTokens({
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
type: 'access',
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(userId: string) {
|
||||
const user = await this.prisma.adminUser.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || user.status !== AdminUserStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.UNAUTHORIZED,
|
||||
'admin user not available',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName ?? user.username,
|
||||
status: user.status,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
};
|
||||
}
|
||||
|
||||
async logout() {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async issueTokens(payload: JwtPayload) {
|
||||
const accessSecret = this.configService.get<string>(
|
||||
'JWT_ACCESS_SECRET',
|
||||
'change_this_access_secret',
|
||||
);
|
||||
const refreshSecret = this.configService.get<string>(
|
||||
'JWT_REFRESH_SECRET',
|
||||
'change_this_refresh_secret',
|
||||
);
|
||||
const accessExpiresInRaw = this.configService.get<string>('JWT_ACCESS_EXPIRES_IN', '2h');
|
||||
const refreshExpiresInRaw = this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
|
||||
const accessExpiresIn = this.parseExpiresInSeconds(accessExpiresInRaw);
|
||||
const refreshExpiresIn = this.parseExpiresInSeconds(refreshExpiresInRaw);
|
||||
|
||||
const accessToken = await this.jwtService.signAsync(payload, {
|
||||
secret: accessSecret,
|
||||
expiresIn: accessExpiresIn,
|
||||
});
|
||||
const refreshToken = await this.jwtService.signAsync(
|
||||
{
|
||||
...payload,
|
||||
type: 'refresh' as const,
|
||||
},
|
||||
{
|
||||
secret: refreshSecret,
|
||||
expiresIn: refreshExpiresIn,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: accessExpiresIn,
|
||||
};
|
||||
}
|
||||
|
||||
private async verifyRefreshToken(token: string): Promise<JwtPayload> {
|
||||
try {
|
||||
const refreshSecret = this.configService.get<string>(
|
||||
'JWT_REFRESH_SECRET',
|
||||
'change_this_refresh_secret',
|
||||
);
|
||||
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
|
||||
secret: refreshSecret,
|
||||
});
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new Error('invalid refresh token type');
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
throw new AppException(
|
||||
ERROR_CODES.TOKEN_INVALID,
|
||||
'token invalid or expired',
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private parseExpiresInSeconds(expiresIn: string): number {
|
||||
if (/^\d+$/.test(expiresIn)) {
|
||||
return Number(expiresIn);
|
||||
}
|
||||
if (expiresIn.endsWith('h')) {
|
||||
return Number(expiresIn.replace('h', '')) * 3600;
|
||||
}
|
||||
if (expiresIn.endsWith('m')) {
|
||||
return Number(expiresIn.replace('m', '')) * 60;
|
||||
}
|
||||
if (expiresIn.endsWith('d')) {
|
||||
return Number(expiresIn.replace('d', '')) * 86400;
|
||||
}
|
||||
return 7200;
|
||||
}
|
||||
}
|
||||
16
server/src/modules/admin-auth/dto/login.dto.ts
Normal file
16
server/src/modules/admin-auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'admin' })
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(64)
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ example: 'admin123456' })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
@MaxLength(128)
|
||||
password!: string;
|
||||
}
|
||||
8
server/src/modules/admin-auth/dto/refresh-token.dto.ts
Normal file
8
server/src/modules/admin-auth/dto/refresh-token.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
type: 'access' | 'refresh';
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import type { JwtPayload } from '../interfaces/jwt-payload.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AdminJwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get<string>('JWT_ACCESS_SECRET', 'change_this_access_secret'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<JwtPayload> {
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException('invalid access token');
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
67
server/src/modules/admin-tools/admin-tools.controller.ts
Normal file
67
server/src/modules/admin-tools/admin-tools.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Audit } from '../../common/decorators/audit.decorator';
|
||||
import { AdminJwtGuard } from '../../common/guards/admin-jwt.guard';
|
||||
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
|
||||
import { AdminToolsService } from './admin-tools.service';
|
||||
import { AdminToolsQueryDto } from './dto/admin-tools-query.dto';
|
||||
import { CreateToolDto } from './dto/create-tool.dto';
|
||||
import { UpdateAccessModeDto } from './dto/update-access-mode.dto';
|
||||
import { UpdateToolStatusDto } from './dto/update-tool-status.dto';
|
||||
import { UpdateToolDto } from './dto/update-tool.dto';
|
||||
|
||||
@ApiTags('admin-tools')
|
||||
@ApiBearerAuth('admin-access-token')
|
||||
@UseGuards(AdminJwtGuard)
|
||||
@UseInterceptors(AdminAuditInterceptor)
|
||||
@Controller('admin/tools')
|
||||
export class AdminToolsController {
|
||||
constructor(private readonly adminToolsService: AdminToolsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Admin query tools' })
|
||||
getTools(@Query() query: AdminToolsQueryDto) {
|
||||
return this.adminToolsService.getTools(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Audit({ action: 'tool.create', resourceType: 'tool' })
|
||||
@ApiOperation({ summary: 'Admin create tool' })
|
||||
createTool(@Body() body: CreateToolDto) {
|
||||
return this.adminToolsService.createTool(body);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Admin get tool detail' })
|
||||
getToolById(@Param('id') id: string) {
|
||||
return this.adminToolsService.getToolById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Audit({ action: 'tool.update', resourceType: 'tool' })
|
||||
@ApiOperation({ summary: 'Admin update tool' })
|
||||
updateTool(@Param('id') id: string, @Body() body: UpdateToolDto) {
|
||||
return this.adminToolsService.updateTool(id, body);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@Audit({ action: 'tool.update_status', resourceType: 'tool' })
|
||||
@ApiOperation({ summary: 'Admin update tool status' })
|
||||
updateToolStatus(@Param('id') id: string, @Body() body: UpdateToolStatusDto) {
|
||||
return this.adminToolsService.updateToolStatus(id, body.status);
|
||||
}
|
||||
|
||||
@Patch(':id/access-mode')
|
||||
@Audit({ action: 'tool.update_access_mode', resourceType: 'tool' })
|
||||
@ApiOperation({ summary: 'Admin update tool access mode' })
|
||||
updateAccessMode(@Param('id') id: string, @Body() body: UpdateAccessModeDto) {
|
||||
return this.adminToolsService.updateAccessMode(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Audit({ action: 'tool.delete', resourceType: 'tool' })
|
||||
@ApiOperation({ summary: 'Admin soft-delete tool' })
|
||||
deleteTool(@Param('id') id: string) {
|
||||
return this.adminToolsService.deleteTool(id);
|
||||
}
|
||||
}
|
||||
11
server/src/modules/admin-tools/admin-tools.module.ts
Normal file
11
server/src/modules/admin-tools/admin-tools.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminAuditInterceptor } from '../../common/interceptors/audit/admin-audit.interceptor';
|
||||
import { AdminToolsController } from './admin-tools.controller';
|
||||
import { AdminToolsService } from './admin-tools.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminToolsController],
|
||||
providers: [AdminToolsService, AdminAuditInterceptor],
|
||||
exports: [AdminToolsService],
|
||||
})
|
||||
export class AdminToolsModule {}
|
||||
484
server/src/modules/admin-tools/admin-tools.service.ts
Normal file
484
server/src/modules/admin-tools/admin-tools.service.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { AccessMode, ArtifactStatus, Prisma, ToolStatus } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AdminToolsQueryDto } from './dto/admin-tools-query.dto';
|
||||
import { CreateToolDto } from './dto/create-tool.dto';
|
||||
import { UpdateAccessModeDto } from './dto/update-access-mode.dto';
|
||||
import { UpdateToolDto } from './dto/update-tool.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminToolsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getTools(query: AdminToolsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(query.pageSize ?? 10, 50);
|
||||
|
||||
const where: Prisma.ToolWhereInput = {
|
||||
isDeleted: false,
|
||||
};
|
||||
|
||||
if (query.query) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.query } },
|
||||
{ description: { contains: query.query } },
|
||||
];
|
||||
}
|
||||
if (query.categoryId) {
|
||||
where.categoryId = query.categoryId;
|
||||
}
|
||||
if (query.status) {
|
||||
where.status = query.status;
|
||||
}
|
||||
if (query.accessMode) {
|
||||
where.accessMode = query.accessMode;
|
||||
}
|
||||
|
||||
const [total, tools] = await this.prisma.$transaction([
|
||||
this.prisma.tool.count({ where }),
|
||||
this.prisma.tool.findMany({
|
||||
where,
|
||||
include: {
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
},
|
||||
latestArtifact: true,
|
||||
},
|
||||
orderBy: {
|
||||
modifiedAt: 'desc',
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
list: tools.map((tool) => this.mapTool(tool)),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createTool(body: CreateToolDto) {
|
||||
await this.assertCategoryExists(body.categoryId);
|
||||
await this.assertTagsExist(body.tags ?? []);
|
||||
|
||||
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
|
||||
this.assertPublishInput(body.accessMode, body.openUrl, undefined);
|
||||
}
|
||||
|
||||
const toolId = this.generateBusinessId('tool');
|
||||
const slug = await this.ensureUniqueSlug(this.slugify(body.name));
|
||||
const updatedAt = this.getDateOnlyString();
|
||||
|
||||
await this.prisma.tool.create({
|
||||
data: {
|
||||
id: toolId,
|
||||
name: body.name.trim(),
|
||||
slug,
|
||||
categoryId: body.categoryId,
|
||||
description: body.description.trim(),
|
||||
rating: body.rating ?? 0,
|
||||
accessMode: body.accessMode,
|
||||
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
|
||||
openInNewTab: body.openInNewTab ?? true,
|
||||
status: body.status ?? ToolStatus.draft,
|
||||
updatedAt,
|
||||
tags:
|
||||
body.tags && body.tags.length > 0
|
||||
? {
|
||||
createMany: {
|
||||
data: body.tags.map((tagId) => ({ tagId })),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
features:
|
||||
body.features && body.features.length > 0
|
||||
? {
|
||||
createMany: {
|
||||
data: body.features.map((feature, index) => ({
|
||||
id: this.generateBusinessId('feat'),
|
||||
featureText: feature,
|
||||
sortOrder: (index + 1) * 10,
|
||||
})),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return this.getToolById(toolId);
|
||||
}
|
||||
|
||||
async getToolById(id: string) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isDeleted: false,
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
},
|
||||
latestArtifact: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return this.mapTool(tool);
|
||||
}
|
||||
|
||||
async updateTool(id: string, body: UpdateToolDto) {
|
||||
const existingTool = await this.getToolEntity(id);
|
||||
|
||||
const nextAccessMode = body.accessMode ?? existingTool.accessMode;
|
||||
const nextOpenUrl =
|
||||
body.openUrl !== undefined
|
||||
? body.openUrl
|
||||
: nextAccessMode === AccessMode.web
|
||||
? existingTool.openUrl
|
||||
: null;
|
||||
const nextStatus = body.status ?? existingTool.status;
|
||||
|
||||
if (body.categoryId) {
|
||||
await this.assertCategoryExists(body.categoryId);
|
||||
}
|
||||
if (body.tags) {
|
||||
await this.assertTagsExist(body.tags);
|
||||
}
|
||||
|
||||
this.assertModeSwitchConstraint(existingTool.status, nextAccessMode, nextOpenUrl, existingTool);
|
||||
if (nextStatus === ToolStatus.published) {
|
||||
this.assertPublishInput(nextAccessMode, nextOpenUrl ?? undefined, existingTool.latestArtifact);
|
||||
}
|
||||
|
||||
const updatedAt = this.getDateOnlyString();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.tool.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name?.trim(),
|
||||
categoryId: body.categoryId,
|
||||
description: body.description?.trim(),
|
||||
rating: body.rating,
|
||||
accessMode: body.accessMode,
|
||||
openUrl: body.openUrl,
|
||||
openInNewTab: body.openInNewTab,
|
||||
status: body.status,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (body.tags) {
|
||||
await tx.toolTag.deleteMany({ where: { toolId: id } });
|
||||
if (body.tags.length > 0) {
|
||||
await tx.toolTag.createMany({
|
||||
data: body.tags.map((tagId) => ({
|
||||
toolId: id,
|
||||
tagId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (body.features) {
|
||||
await tx.toolFeature.deleteMany({ where: { toolId: id } });
|
||||
if (body.features.length > 0) {
|
||||
await tx.toolFeature.createMany({
|
||||
data: body.features.map((feature, index) => ({
|
||||
id: this.generateBusinessId('feat'),
|
||||
toolId: id,
|
||||
featureText: feature,
|
||||
sortOrder: (index + 1) * 10,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return this.getToolById(id);
|
||||
}
|
||||
|
||||
async updateToolStatus(id: string, status: ToolStatus) {
|
||||
const tool = await this.getToolEntity(id);
|
||||
if (status === ToolStatus.published) {
|
||||
this.assertPublishInput(tool.accessMode, tool.openUrl ?? undefined, tool.latestArtifact);
|
||||
}
|
||||
|
||||
await this.prisma.tool.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.getToolById(id);
|
||||
}
|
||||
|
||||
async updateAccessMode(id: string, body: UpdateAccessModeDto) {
|
||||
const tool = await this.getToolEntity(id);
|
||||
this.assertModeSwitchConstraint(
|
||||
tool.status,
|
||||
body.accessMode,
|
||||
body.openUrl,
|
||||
tool,
|
||||
tool.accessMode !== body.accessMode,
|
||||
);
|
||||
|
||||
await this.prisma.tool.update({
|
||||
where: { id },
|
||||
data: {
|
||||
accessMode: body.accessMode,
|
||||
openUrl: body.accessMode === AccessMode.web ? body.openUrl ?? null : null,
|
||||
openInNewTab: body.openInNewTab ?? tool.openInNewTab,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.getToolById(id);
|
||||
}
|
||||
|
||||
async deleteTool(id: string) {
|
||||
await this.getToolEntity(id);
|
||||
|
||||
await this.prisma.tool.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isDeleted: true,
|
||||
status: ToolStatus.archived,
|
||||
updatedAt: this.getDateOnlyString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
private async getToolEntity(id: string) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isDeleted: false,
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
latestArtifact: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
private async assertCategoryExists(categoryId: string) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: {
|
||||
id: categoryId,
|
||||
isDeleted: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.NOT_FOUND,
|
||||
`category not found: ${categoryId}`,
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertTagsExist(tagIds: string[]) {
|
||||
if (tagIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await this.prisma.tag.count({
|
||||
where: {
|
||||
id: {
|
||||
in: tagIds,
|
||||
},
|
||||
isDeleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (count !== tagIds.length) {
|
||||
throw new AppException(ERROR_CODES.VALIDATION_FAILED, 'contains unknown tag ids');
|
||||
}
|
||||
}
|
||||
|
||||
private assertPublishInput(
|
||||
accessMode: AccessMode,
|
||||
openUrl?: string,
|
||||
latestArtifact?: { status: ArtifactStatus } | null,
|
||||
) {
|
||||
if (accessMode === AccessMode.web) {
|
||||
if (!openUrl) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
|
||||
'openUrl is required for web mode publish',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!latestArtifact || latestArtifact.status !== ArtifactStatus.active) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
||||
'download mode tool requires one active latest artifact before publish',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertModeSwitchConstraint(
|
||||
currentStatus: ToolStatus,
|
||||
targetMode: AccessMode,
|
||||
openUrl: string | null | undefined,
|
||||
tool: { latestArtifact?: { status: ArtifactStatus } | null },
|
||||
isSwitching = false,
|
||||
) {
|
||||
if (targetMode === AccessMode.web && !openUrl) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
|
||||
'openUrl is required when switching to web mode',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isSwitching &&
|
||||
targetMode === AccessMode.download &&
|
||||
currentStatus === ToolStatus.published &&
|
||||
(!tool.latestArtifact || tool.latestArtifact.status !== ArtifactStatus.active)
|
||||
) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
|
||||
'published tool cannot switch to download mode without active artifact',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapTool(
|
||||
tool: Prisma.ToolGetPayload<{
|
||||
include: {
|
||||
category: true;
|
||||
tags: { include: { tag: true } };
|
||||
features: true;
|
||||
latestArtifact: true;
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
slug: tool.slug,
|
||||
category: {
|
||||
id: tool.category.id,
|
||||
name: tool.category.name,
|
||||
},
|
||||
description: tool.description,
|
||||
rating: tool.rating,
|
||||
status: tool.status,
|
||||
accessMode: tool.accessMode,
|
||||
openUrl: tool.openUrl,
|
||||
openInNewTab: tool.openInNewTab,
|
||||
downloadCount: tool.downloadCount,
|
||||
openCount: tool.openCount,
|
||||
latestArtifact: tool.latestArtifact
|
||||
? {
|
||||
id: tool.latestArtifact.id,
|
||||
version: tool.latestArtifact.version,
|
||||
status: tool.latestArtifact.status,
|
||||
fileName: tool.latestArtifact.fileName,
|
||||
fileSizeBytes: tool.latestArtifact.fileSizeBytes,
|
||||
}
|
||||
: null,
|
||||
tags: tool.tags.map((item) => ({
|
||||
id: item.tag.id,
|
||||
name: item.tag.name,
|
||||
})),
|
||||
features: tool.features.map((item) => item.featureText),
|
||||
updatedAt: tool.updatedAt,
|
||||
createdAt: tool.createdAt,
|
||||
modifiedAt: tool.modifiedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private generateBusinessId(prefix: string): string {
|
||||
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
||||
}
|
||||
|
||||
private getDateOnlyString(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private slugify(value: string): string {
|
||||
const slug = value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.slice(0, 80);
|
||||
return slug || 'tool';
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(baseSlug: string): Promise<string> {
|
||||
let slug = baseSlug;
|
||||
let suffix = 1;
|
||||
|
||||
while (await this.prisma.tool.findUnique({ where: { slug }, select: { id: true } })) {
|
||||
slug = `${baseSlug}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
41
server/src/modules/admin-tools/dto/admin-tools-query.dto.ts
Normal file
41
server/src/modules/admin-tools/dto/admin-tools-query.dto.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AccessMode, ToolStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class AdminToolsQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
query?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ToolStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(ToolStatus)
|
||||
status?: ToolStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: AccessMode })
|
||||
@IsOptional()
|
||||
@IsEnum(AccessMode)
|
||||
accessMode?: AccessMode;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 10, maximum: 50 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
pageSize?: number;
|
||||
}
|
||||
83
server/src/modules/admin-tools/dto/create-tool.dto.ts
Normal file
83
server/src/modules/admin-tools/dto/create-tool.dto.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AccessMode, ToolStatus } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateToolDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
categoryId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
@MaxLength(2000)
|
||||
description!: string;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, maximum: 5, default: 0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(5)
|
||||
rating?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: [String], description: 'Tag ids' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
features?: string[];
|
||||
|
||||
@ApiProperty({ enum: AccessMode, default: AccessMode.download })
|
||||
@IsEnum(AccessMode)
|
||||
accessMode!: AccessMode;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
|
||||
@ValidateIf((obj: CreateToolDto) => obj.accessMode === AccessMode.web)
|
||||
@IsString()
|
||||
@IsUrl({
|
||||
require_protocol: true,
|
||||
})
|
||||
openUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
openInNewTab?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ enum: ToolStatus, default: ToolStatus.draft })
|
||||
@IsOptional()
|
||||
@IsEnum(ToolStatus)
|
||||
status?: ToolStatus;
|
||||
}
|
||||
24
server/src/modules/admin-tools/dto/update-access-mode.dto.ts
Normal file
24
server/src/modules/admin-tools/dto/update-access-mode.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { AccessMode } from '@prisma/client';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsEnum, IsOptional, IsString, IsUrl, ValidateIf } from 'class-validator';
|
||||
|
||||
export class UpdateAccessModeDto {
|
||||
@ApiProperty({ enum: AccessMode })
|
||||
@IsEnum(AccessMode)
|
||||
accessMode!: AccessMode;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when accessMode=web' })
|
||||
@ValidateIf((obj: UpdateAccessModeDto) => obj.accessMode === AccessMode.web)
|
||||
@IsString()
|
||||
@IsUrl({
|
||||
require_protocol: true,
|
||||
})
|
||||
openUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
openInNewTab?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ToolStatus } from '@prisma/client';
|
||||
import { IsEnum } from 'class-validator';
|
||||
|
||||
export class UpdateToolStatusDto {
|
||||
@ApiProperty({ enum: ToolStatus })
|
||||
@IsEnum(ToolStatus)
|
||||
status!: ToolStatus;
|
||||
}
|
||||
4
server/src/modules/admin-tools/dto/update-tool.dto.ts
Normal file
4
server/src/modules/admin-tools/dto/update-tool.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateToolDto } from './create-tool.dto';
|
||||
|
||||
export class UpdateToolDto extends PartialType(CreateToolDto) {}
|
||||
15
server/src/modules/categories/categories.controller.ts
Normal file
15
server/src/modules/categories/categories.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@ApiTags('public-categories')
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categoriesService: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get categories with tool count' })
|
||||
getCategories() {
|
||||
return this.categoriesService.getCategories();
|
||||
}
|
||||
}
|
||||
9
server/src/modules/categories/categories.module.ts
Normal file
9
server/src/modules/categories/categories.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
37
server/src/modules/categories/categories.service.ts
Normal file
37
server/src/modules/categories/categories.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ToolStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getCategories() {
|
||||
const categories = await this.prisma.category.findMany({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
include: {
|
||||
tools: {
|
||||
where: {
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return categories.map((category) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
sortOrder: category.sortOrder,
|
||||
toolCount: category.tools.length,
|
||||
}));
|
||||
}
|
||||
}
|
||||
21
server/src/modules/downloads/downloads.controller.ts
Normal file
21
server/src/modules/downloads/downloads.controller.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { DownloadsService } from './downloads.service';
|
||||
|
||||
@ApiTags('public-downloads')
|
||||
@Controller('downloads')
|
||||
export class DownloadsController {
|
||||
constructor(private readonly downloadsService: DownloadsService) {}
|
||||
|
||||
@Get(':ticket')
|
||||
@ApiOperation({ summary: 'Consume ticket and stream artifact file' })
|
||||
async consumeTicket(
|
||||
@Param('ticket') ticket: string,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
await this.downloadsService.consumeTicketAndStream(ticket, request as RequestWithContext, response);
|
||||
}
|
||||
}
|
||||
11
server/src/modules/downloads/downloads.module.ts
Normal file
11
server/src/modules/downloads/downloads.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GitlabStorageModule } from '../gitlab-storage/gitlab-storage.module';
|
||||
import { DownloadsController } from './downloads.controller';
|
||||
import { DownloadsService } from './downloads.service';
|
||||
|
||||
@Module({
|
||||
imports: [GitlabStorageModule],
|
||||
controllers: [DownloadsController],
|
||||
providers: [DownloadsService],
|
||||
})
|
||||
export class DownloadsModule {}
|
||||
144
server/src/modules/downloads/downloads.service.ts
Normal file
144
server/src/modules/downloads/downloads.service.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ArtifactStatus, DownloadRecordStatus, ToolStatus } from '@prisma/client';
|
||||
import type { Response } from 'express';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { GitlabStorageService } from '../gitlab-storage/gitlab-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class DownloadsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gitlabStorageService: GitlabStorageService,
|
||||
) {}
|
||||
|
||||
async consumeTicketAndStream(ticket: string, request: RequestWithContext, response: Response) {
|
||||
const now = new Date();
|
||||
const ticketEntity = await this.prisma.downloadTicket.findUnique({
|
||||
where: { ticket },
|
||||
include: {
|
||||
tool: true,
|
||||
artifact: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticketEntity) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.DOWNLOAD_TICKET_INVALID,
|
||||
'download ticket not found',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (ticketEntity.consumedAt || ticketEntity.expiresAt < now) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.DOWNLOAD_TICKET_INVALID,
|
||||
'download ticket expired or already consumed',
|
||||
HttpStatus.GONE,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
ticketEntity.tool.status !== ToolStatus.published ||
|
||||
ticketEntity.tool.isDeleted ||
|
||||
ticketEntity.artifact.status !== ArtifactStatus.active
|
||||
) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.ARTIFACT_NOT_AVAILABLE,
|
||||
'artifact is not available',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
let downloadStream;
|
||||
try {
|
||||
downloadStream = await this.gitlabStorageService.getArtifactStream(ticketEntity.artifact);
|
||||
} catch (error) {
|
||||
await this.prisma.downloadRecord.create({
|
||||
data: {
|
||||
toolId: ticketEntity.toolId,
|
||||
artifactId: ticketEntity.artifactId,
|
||||
ticket: ticketEntity.ticket,
|
||||
clientIp: this.extractIp(request),
|
||||
userAgent: request.headers['user-agent'],
|
||||
channel: ticketEntity.channel,
|
||||
clientVersion: ticketEntity.clientVersion,
|
||||
status: DownloadRecordStatus.failed,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const consumed = await tx.downloadTicket.updateMany({
|
||||
where: {
|
||||
ticket,
|
||||
consumedAt: null,
|
||||
expiresAt: {
|
||||
gte: now,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (consumed.count !== 1) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.DOWNLOAD_TICKET_INVALID,
|
||||
'download ticket already consumed',
|
||||
HttpStatus.GONE,
|
||||
);
|
||||
}
|
||||
|
||||
await tx.downloadRecord.create({
|
||||
data: {
|
||||
toolId: ticketEntity.toolId,
|
||||
artifactId: ticketEntity.artifactId,
|
||||
ticket: ticketEntity.ticket,
|
||||
clientIp: this.extractIp(request),
|
||||
userAgent: request.headers['user-agent'],
|
||||
channel: ticketEntity.channel,
|
||||
clientVersion: ticketEntity.clientVersion,
|
||||
status: DownloadRecordStatus.success,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.tool.update({
|
||||
where: { id: ticketEntity.toolId },
|
||||
data: {
|
||||
downloadCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
response.setHeader(
|
||||
'Content-Type',
|
||||
downloadStream.mimeType ?? 'application/octet-stream; charset=binary',
|
||||
);
|
||||
response.setHeader('Content-Length', String(downloadStream.fileSize));
|
||||
response.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(downloadStream.fileName)}"`,
|
||||
);
|
||||
|
||||
await pipeline(downloadStream.stream, response);
|
||||
}
|
||||
|
||||
private extractIp(request: RequestWithContext): string | undefined {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
if (Array.isArray(forwarded) && forwarded.length > 0) {
|
||||
return forwarded[0]?.split(',')[0]?.trim();
|
||||
}
|
||||
if (typeof forwarded === 'string') {
|
||||
return forwarded.split(',')[0]?.trim();
|
||||
}
|
||||
return request.ip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GitlabStorageService } from './gitlab-storage.service';
|
||||
|
||||
@Module({
|
||||
providers: [GitlabStorageService],
|
||||
exports: [GitlabStorageService],
|
||||
})
|
||||
export class GitlabStorageModule {}
|
||||
150
server/src/modules/gitlab-storage/gitlab-storage.service.ts
Normal file
150
server/src/modules/gitlab-storage/gitlab-storage.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { ToolArtifact } from '@prisma/client';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
|
||||
export interface ArtifactDownloadStream {
|
||||
stream: NodeJS.ReadableStream;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export interface ArtifactUploadInput {
|
||||
toolId: string;
|
||||
version: string;
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface ArtifactUploadResult {
|
||||
gitlabProjectId: number;
|
||||
gitlabPackageName: string;
|
||||
gitlabPackageVersion: string;
|
||||
gitlabFilePath: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GitlabStorageService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async getArtifactStream(artifact: ToolArtifact): Promise<ArtifactDownloadStream> {
|
||||
const gitlabApiBase = this.configService.get<string>('GITLAB_API_BASE');
|
||||
const gitlabToken = this.configService.get<string>('GITLAB_TOKEN');
|
||||
|
||||
if (gitlabApiBase && gitlabToken && artifact.gitlabProjectId > 0) {
|
||||
return this.downloadFromGitlab(artifact, gitlabApiBase, gitlabToken);
|
||||
}
|
||||
|
||||
return this.readFromLocalStorage(artifact);
|
||||
}
|
||||
|
||||
async uploadArtifact(input: ArtifactUploadInput): Promise<ArtifactUploadResult> {
|
||||
const gitlabApiBase = this.configService.get<string>('GITLAB_API_BASE');
|
||||
const gitlabToken = this.configService.get<string>('GITLAB_TOKEN');
|
||||
const projectId = Number(this.configService.get<string>('GITLAB_PROJECT_ID', '0'));
|
||||
const packagePrefix = this.configService.get<string>('GITLAB_PACKAGE_NAME_PREFIX', 'toolsshow');
|
||||
const packageName = `${packagePrefix}/${input.toolId}`;
|
||||
|
||||
if (gitlabApiBase && gitlabToken && projectId > 0) {
|
||||
const url = `${gitlabApiBase}/projects/${encodeURIComponent(
|
||||
String(projectId),
|
||||
)}/packages/generic/${encodeURIComponent(packageName)}/${encodeURIComponent(
|
||||
input.version,
|
||||
)}/${encodeURIComponent(input.fileName)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'PRIVATE-TOKEN': gitlabToken,
|
||||
'Content-Type': input.mimeType ?? 'application/octet-stream',
|
||||
},
|
||||
body: input.buffer as unknown as BodyInit,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.GITLAB_UPLOAD_FAILED,
|
||||
'failed to upload artifact to GitLab',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
gitlabProjectId: projectId,
|
||||
gitlabPackageName: packageName,
|
||||
gitlabPackageVersion: input.version,
|
||||
gitlabFilePath: `${packageName}/${input.version}/${input.fileName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const localRelativePath = `storage/uploads/${input.toolId}/${input.version}/${input.fileName}`;
|
||||
const localAbsolutePath = resolve(process.cwd(), localRelativePath);
|
||||
await mkdir(dirname(localAbsolutePath), { recursive: true });
|
||||
await writeFile(localAbsolutePath, input.buffer);
|
||||
|
||||
return {
|
||||
gitlabProjectId: 0,
|
||||
gitlabPackageName: packageName,
|
||||
gitlabPackageVersion: input.version,
|
||||
gitlabFilePath: localRelativePath.replace(/\\/g, '/'),
|
||||
};
|
||||
}
|
||||
|
||||
private async downloadFromGitlab(
|
||||
artifact: ToolArtifact,
|
||||
gitlabApiBase: string,
|
||||
gitlabToken: string,
|
||||
): Promise<ArtifactDownloadStream> {
|
||||
const url = `${gitlabApiBase}/projects/${encodeURIComponent(
|
||||
String(artifact.gitlabProjectId),
|
||||
)}/packages/generic/${encodeURIComponent(artifact.gitlabPackageName)}/${encodeURIComponent(
|
||||
artifact.gitlabPackageVersion,
|
||||
)}/${encodeURIComponent(artifact.fileName)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'PRIVATE-TOKEN': gitlabToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.GITLAB_DOWNLOAD_FAILED,
|
||||
'failed to download artifact from GitLab',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stream: Readable.fromWeb(response.body as any),
|
||||
fileName: artifact.fileName,
|
||||
mimeType: artifact.mimeType ?? undefined,
|
||||
fileSize: artifact.fileSizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
private readFromLocalStorage(artifact: ToolArtifact): ArtifactDownloadStream {
|
||||
const filePath = resolve(process.cwd(), artifact.gitlabFilePath);
|
||||
if (!existsSync(filePath)) {
|
||||
throw new AppException(
|
||||
ERROR_CODES.GITLAB_DOWNLOAD_FAILED,
|
||||
`artifact file not found: ${artifact.gitlabFilePath}`,
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stream: createReadStream(filePath),
|
||||
fileName: artifact.fileName,
|
||||
mimeType: artifact.mimeType ?? undefined,
|
||||
fileSize: artifact.fileSizeBytes,
|
||||
};
|
||||
}
|
||||
}
|
||||
14
server/src/modules/health/health.controller.ts
Normal file
14
server/src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly healthService: HealthService) {}
|
||||
|
||||
@Get()
|
||||
getHealth() {
|
||||
return this.healthService.getHealth();
|
||||
}
|
||||
}
|
||||
9
server/src/modules/health/health.module.ts
Normal file
9
server/src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
16
server/src/modules/health/health.service.ts
Normal file
16
server/src/modules/health/health.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getHealth(): Promise<{ status: 'ok'; database: 'up'; timestamp: string }> {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return {
|
||||
status: 'ok',
|
||||
database: 'up',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
15
server/src/modules/keywords/keywords.controller.ts
Normal file
15
server/src/modules/keywords/keywords.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { KeywordsService } from './keywords.service';
|
||||
|
||||
@ApiTags('public-keywords')
|
||||
@Controller('keywords')
|
||||
export class KeywordsController {
|
||||
constructor(private readonly keywordsService: KeywordsService) {}
|
||||
|
||||
@Get('hot')
|
||||
@ApiOperation({ summary: 'Get hot keywords' })
|
||||
getHotKeywords() {
|
||||
return this.keywordsService.getHotKeywords();
|
||||
}
|
||||
}
|
||||
9
server/src/modules/keywords/keywords.module.ts
Normal file
9
server/src/modules/keywords/keywords.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KeywordsController } from './keywords.controller';
|
||||
import { KeywordsService } from './keywords.service';
|
||||
|
||||
@Module({
|
||||
controllers: [KeywordsController],
|
||||
providers: [KeywordsService],
|
||||
})
|
||||
export class KeywordsModule {}
|
||||
24
server/src/modules/keywords/keywords.service.ts
Normal file
24
server/src/modules/keywords/keywords.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class KeywordsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getHotKeywords() {
|
||||
const keywords = await this.prisma.hotKeyword.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return keywords.map((item) => ({
|
||||
id: item.id,
|
||||
keyword: item.keyword,
|
||||
sortOrder: item.sortOrder,
|
||||
}));
|
||||
}
|
||||
}
|
||||
15
server/src/modules/overview/overview.controller.ts
Normal file
15
server/src/modules/overview/overview.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { OverviewService } from './overview.service';
|
||||
|
||||
@ApiTags('public-overview')
|
||||
@Controller('overview')
|
||||
export class OverviewController {
|
||||
constructor(private readonly overviewService: OverviewService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get site overview KPIs' })
|
||||
getOverview() {
|
||||
return this.overviewService.getOverview();
|
||||
}
|
||||
}
|
||||
9
server/src/modules/overview/overview.module.ts
Normal file
9
server/src/modules/overview/overview.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OverviewController } from './overview.controller';
|
||||
import { OverviewService } from './overview.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OverviewController],
|
||||
providers: [OverviewService],
|
||||
})
|
||||
export class OverviewModule {}
|
||||
41
server/src/modules/overview/overview.service.ts
Normal file
41
server/src/modules/overview/overview.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ToolStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class OverviewService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getOverview() {
|
||||
const [toolTotal, categoryTotal, counters] = await this.prisma.$transaction([
|
||||
this.prisma.tool.count({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
}),
|
||||
this.prisma.category.count({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
},
|
||||
}),
|
||||
this.prisma.tool.aggregate({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
_sum: {
|
||||
downloadCount: true,
|
||||
openCount: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
toolTotal,
|
||||
categoryTotal,
|
||||
downloadTotal: counters._sum.downloadCount ?? 0,
|
||||
openTotal: counters._sum.openCount ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
server/src/modules/tools/dto/get-tools-query.dto.ts
Normal file
43
server/src/modules/tools/dto/get-tools-query.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export enum ToolSortBy {
|
||||
popular = 'popular',
|
||||
latest = 'latest',
|
||||
rating = 'rating',
|
||||
name = 'name',
|
||||
}
|
||||
|
||||
export class GetToolsQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Search keyword' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
query?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Category id or all', default: 'all' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ToolSortBy, default: ToolSortBy.latest })
|
||||
@IsOptional()
|
||||
@IsEnum(ToolSortBy)
|
||||
sortBy?: ToolSortBy;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 6, maximum: 50 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
pageSize?: number;
|
||||
}
|
||||
22
server/src/modules/tools/tools.controller.ts
Normal file
22
server/src/modules/tools/tools.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { GetToolsQueryDto } from './dto/get-tools-query.dto';
|
||||
import { ToolsService } from './tools.service';
|
||||
|
||||
@ApiTags('public-tools')
|
||||
@Controller('tools')
|
||||
export class ToolsController {
|
||||
constructor(private readonly toolsService: ToolsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Query tools' })
|
||||
getTools(@Query() query: GetToolsQueryDto) {
|
||||
return this.toolsService.getTools(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get tool detail' })
|
||||
getToolDetail(@Param('id') id: string) {
|
||||
return this.toolsService.getToolDetail(id);
|
||||
}
|
||||
}
|
||||
10
server/src/modules/tools/tools.module.ts
Normal file
10
server/src/modules/tools/tools.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ToolsController } from './tools.controller';
|
||||
import { ToolsService } from './tools.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ToolsController],
|
||||
providers: [ToolsService],
|
||||
exports: [ToolsService],
|
||||
})
|
||||
export class ToolsModule {}
|
||||
166
server/src/modules/tools/tools.service.ts
Normal file
166
server/src/modules/tools/tools.service.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ArtifactStatus, Prisma, ToolStatus } from '@prisma/client';
|
||||
import { ERROR_CODES } from '../../common/constants/error-codes';
|
||||
import { AppException } from '../../common/exceptions/app.exception';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { GetToolsQueryDto, ToolSortBy } from './dto/get-tools-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ToolsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getTools(query: GetToolsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = Math.min(query.pageSize ?? 6, 50);
|
||||
const sortBy = query.sortBy ?? ToolSortBy.latest;
|
||||
|
||||
const where: Prisma.ToolWhereInput = {
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
};
|
||||
|
||||
if (query.category && query.category !== 'all') {
|
||||
where.categoryId = query.category;
|
||||
}
|
||||
|
||||
if (query.query) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.query } },
|
||||
{ description: { contains: query.query } },
|
||||
];
|
||||
}
|
||||
|
||||
const [total, tools] = await this.prisma.$transaction([
|
||||
this.prisma.tool.count({ where }),
|
||||
this.prisma.tool.findMany({
|
||||
where,
|
||||
include: {
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
},
|
||||
latestArtifact: true,
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: this.buildOrderBy(sortBy),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
list: tools.map((tool) => {
|
||||
const hasArtifact = Boolean(
|
||||
tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active,
|
||||
);
|
||||
|
||||
return {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
slug: tool.slug,
|
||||
category: {
|
||||
id: tool.category.id,
|
||||
name: tool.category.name,
|
||||
},
|
||||
description: tool.description,
|
||||
rating: tool.rating,
|
||||
downloadCount: tool.downloadCount,
|
||||
openCount: tool.openCount,
|
||||
latestVersion:
|
||||
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
|
||||
accessMode: tool.accessMode,
|
||||
openUrl: tool.accessMode === 'web' ? tool.openUrl : null,
|
||||
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
|
||||
tags: tool.tags.map((item) => item.tag.name),
|
||||
features: tool.features.map((item) => item.featureText),
|
||||
updatedAt: tool.updatedAt,
|
||||
};
|
||||
}),
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getToolDetail(id: string) {
|
||||
const tool = await this.prisma.tool.findFirst({
|
||||
where: {
|
||||
id,
|
||||
isDeleted: false,
|
||||
status: ToolStatus.published,
|
||||
},
|
||||
include: {
|
||||
category: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
orderBy: {
|
||||
sortOrder: 'asc',
|
||||
},
|
||||
},
|
||||
latestArtifact: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', 404);
|
||||
}
|
||||
|
||||
const detail = {
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
slug: tool.slug,
|
||||
category: {
|
||||
id: tool.category.id,
|
||||
name: tool.category.name,
|
||||
},
|
||||
description: tool.description,
|
||||
rating: tool.rating,
|
||||
downloadCount: tool.downloadCount,
|
||||
openCount: tool.openCount,
|
||||
accessMode: tool.accessMode,
|
||||
tags: tool.tags.map((item) => item.tag.name),
|
||||
features: tool.features.map((item) => item.featureText),
|
||||
updatedAt: tool.updatedAt,
|
||||
openUrl: tool.accessMode === 'web' ? tool.openUrl : null,
|
||||
latestVersion:
|
||||
tool.accessMode === 'download' && tool.latestArtifact ? tool.latestArtifact.version : null,
|
||||
fileSize:
|
||||
tool.accessMode === 'download' && tool.latestArtifact
|
||||
? tool.latestArtifact.fileSizeBytes
|
||||
: null,
|
||||
downloadReady:
|
||||
tool.accessMode === 'download'
|
||||
? Boolean(tool.latestArtifact && tool.latestArtifact.status === ArtifactStatus.active)
|
||||
: false,
|
||||
};
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
private buildOrderBy(sortBy: ToolSortBy): Prisma.ToolOrderByWithRelationInput[] {
|
||||
switch (sortBy) {
|
||||
case ToolSortBy.popular:
|
||||
return [{ downloadCount: 'desc' }, { openCount: 'desc' }, { modifiedAt: 'desc' }];
|
||||
case ToolSortBy.rating:
|
||||
return [{ rating: 'desc' }, { modifiedAt: 'desc' }];
|
||||
case ToolSortBy.name:
|
||||
return [{ name: 'asc' }];
|
||||
case ToolSortBy.latest:
|
||||
default:
|
||||
return [{ modifiedAt: 'desc' }];
|
||||
}
|
||||
}
|
||||
}
|
||||
9
server/src/prisma/prisma.module.ts
Normal file
9
server/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
16
server/src/prisma/prisma.service.ts
Normal file
16
server/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.$connect();
|
||||
await this.$queryRawUnsafe('PRAGMA journal_mode = WAL;');
|
||||
}
|
||||
|
||||
async enableShutdownHooks(app: INestApplication): Promise<void> {
|
||||
process.on('beforeExit', async () => {
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
27
server/test/app.e2e-spec.ts
Normal file
27
server/test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('Health (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/health (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/health')
|
||||
.expect(200)
|
||||
.expect(({ body }) => {
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.database).toBe('up');
|
||||
});
|
||||
});
|
||||
});
|
||||
9
server/test/jest-e2e.json
Normal file
9
server/test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
server/tsconfig.build.json
Normal file
4
server/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
server/tsconfig.json
Normal file
25
server/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user