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

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