This commit is contained in:
dlandy
2026-03-27 10:18:26 +08:00
commit 40be11adbf
116 changed files with 26138 additions and 0 deletions

14
server/.env.example Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
server/README.md Normal file
View 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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](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).

View 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

File diff suppressed because it is too large Load Diff

35
server/eslint.config.mjs Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

94
server/package.json Normal file
View 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"
}
}

View 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");

View 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
View 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
View 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();
});

View 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
View 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 {}

View 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];

View 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);

View File

@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View 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);
}
}

View 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;
}
}
}

View File

@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class AdminJwtGuard extends AuthGuard('jwt') {}

View 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;
}
}

View 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(),
})),
);
}
}

View File

@@ -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
View 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();

View 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);
}
}

View 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 {}

View 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;
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View 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);
}
}

View 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 {}

View 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),
},
};
}
}

View 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;
}

View 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 ?? '');
}
}

View 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 {}

View 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;
}
}

View 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;
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@ApiProperty()
@IsString()
refreshToken!: string;
}

View File

@@ -0,0 +1,5 @@
export interface JwtPayload {
sub: string;
username: string;
type: 'access' | 'refresh';
}

View File

@@ -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;
}
}

View 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);
}
}

View 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 {}

View 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;
}
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateToolDto } from './create-tool.dto';
export class UpdateToolDto extends PartialType(CreateToolDto) {}

View 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();
}
}

View 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 {}

View 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,
}));
}
}

View 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);
}
}

View 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 {}

View 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;
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { GitlabStorageService } from './gitlab-storage.service';
@Module({
providers: [GitlabStorageService],
exports: [GitlabStorageService],
})
export class GitlabStorageModule {}

View 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,
};
}
}

View 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();
}
}

View 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 {}

View 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(),
};
}
}

View 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();
}
}

View 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 {}

View 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,
}));
}
}

View 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();
}
}

View 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 {}

View 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,
};
}
}

View 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;
}

View 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);
}
}

View 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 {}

View 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' }];
}
}
}

View 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 {}

View 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();
});
}
}

View 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');
});
});
});

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
server/tsconfig.json Normal file
View 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
}
}