Initial AI manga platform

This commit is contained in:
www
2026-06-15 17:45:28 +08:00
commit 7a8191650f
267 changed files with 105987 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import { Body, Controller, Get, Inject, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { CurrentUser } from './current-user.decorator';
import { LoginDto, RegisterDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
import type { AuthRequestUser } from './auth.types';
@Controller('auth')
export class AuthController {
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('logout')
@UseGuards(JwtAuthGuard)
logout() {
return { logged_out: true };
}
@Get('profile')
@UseGuards(JwtAuthGuard)
profile(@CurrentUser() user: AuthRequestUser) {
return this.authService.getProfile(user);
}
}
@Controller()
export class ProfileController {
constructor(@Inject(AuthService) private readonly authService: AuthService) {}
@Get('profile')
@UseGuards(JwtAuthGuard)
profile(@CurrentUser() user: AuthRequestUser) {
return this.authService.getProfile(user);
}
}
+10
View File
@@ -0,0 +1,10 @@
export class RegisterDto {
email?: string;
password?: string;
nickname?: string;
}
export class LoginDto {
email?: string;
password?: string;
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { JwtModule, type JwtSignOptions } from '@nestjs/jwt';
import { UsersModule } from '../users/users.module';
import { AuthController, ProfileController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './jwt-auth.guard';
const jwtExpiresIn = (process.env.JWT_EXPIRES_IN ?? '7d') as JwtSignOptions['expiresIn'];
@Module({
imports: [
UsersModule,
JwtModule.register({
secret: process.env.JWT_SECRET ?? 'dev_only_change_me',
signOptions: {
expiresIn: jwtExpiresIn
}
})
],
controllers: [AuthController, ProfileController],
providers: [AuthService, JwtAuthGuard],
exports: [AuthService, JwtAuthGuard, JwtModule]
})
export class AuthModule {}
+129
View File
@@ -0,0 +1,129 @@
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthService } from './auth.service';
import type { SafeUser } from '../users/user.types';
import type { UsersService } from '../users/users.service';
const safeUser: SafeUser = {
id: '1',
email: 'user@example.com',
phone: null,
nickname: 'User',
avatar_url: null,
role: 'user',
status: 'active',
wechat_openid: null,
created_at: '2026-05-31T00:00:00.000Z'
};
function createPrismaUser(passwordHash: string) {
return {
id: 1n,
email: 'user@example.com',
phone: null,
password_hash: passwordHash,
nickname: 'User',
avatar_url: null,
role: 'user',
status: 'active',
wechat_openid: null,
last_login_at: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z')
};
}
describe('AuthService', () => {
let usersService: Pick<
UsersService,
'findByEmail' | 'findById' | 'createUser' | 'toSafeUser'
>;
let jwtService: Pick<JwtService, 'sign'>;
let service: AuthService;
beforeEach(() => {
usersService = {
findByEmail: vi.fn(),
findById: vi.fn(),
createUser: vi.fn(),
toSafeUser: vi.fn()
};
jwtService = {
sign: vi.fn(() => 'signed.jwt.token')
};
service = new AuthService(usersService as UsersService, jwtService as JwtService);
});
it('registers an active user and returns a token', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
vi.mocked(usersService.createUser).mockResolvedValue(safeUser);
const result = await service.register({
email: ' USER@example.com ',
password: 'password123',
nickname: 'User'
});
expect(usersService.findByEmail).toHaveBeenCalledWith('user@example.com');
expect(usersService.createUser).toHaveBeenCalledWith(
expect.objectContaining({
email: 'user@example.com',
nickname: 'User'
})
);
expect(result).toMatchObject({
access_token: 'signed.jwt.token',
token_type: 'Bearer',
user: safeUser
});
});
it('rejects duplicate email registration', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(createPrismaUser('hash'));
await expect(
service.register({
email: 'user@example.com',
password: 'password123'
})
).rejects.toBeInstanceOf(ConflictException);
});
it('logs in with a valid password', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
vi.mocked(usersService.createUser).mockResolvedValue(safeUser);
const registered = await service.register({
email: 'user@example.com',
password: 'password123'
});
const passwordHash = vi.mocked(usersService.createUser).mock.calls[0]?.[0]
.password_hash;
expect(registered.access_token).toBe('signed.jwt.token');
vi.mocked(usersService.findByEmail).mockResolvedValue(
createPrismaUser(passwordHash)
);
vi.mocked(usersService.toSafeUser).mockReturnValue(safeUser);
const result = await service.login({
email: 'user@example.com',
password: 'password123'
});
expect(result.user).toEqual(safeUser);
});
it('rejects invalid login credentials', async () => {
vi.mocked(usersService.findByEmail).mockResolvedValue(null);
await expect(
service.login({
email: 'user@example.com',
password: 'password123'
})
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
+110
View File
@@ -0,0 +1,110 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
UnauthorizedException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { compare, hash } from 'bcryptjs';
import { UsersService } from '../users/users.service';
import type { LoginDto, RegisterDto } from './auth.dto';
import type { AuthResult, AuthRequestUser, JwtPayload } from './auth.types';
const PASSWORD_MIN_LENGTH = 8;
@Injectable()
export class AuthService {
constructor(
@Inject(UsersService)
private readonly usersService: UsersService,
@Inject(JwtService)
private readonly jwtService: JwtService
) {}
async register(dto: RegisterDto): Promise<AuthResult> {
const email = this.normalizeEmail(dto.email);
const password = this.validatePassword(dto.password);
const existing = await this.usersService.findByEmail(email);
if (existing) {
throw new ConflictException('Email already registered');
}
const passwordHash = await hash(password, 12);
const user = await this.usersService.createUser({
email,
password_hash: passwordHash,
nickname: this.normalizeOptionalText(dto.nickname)
});
return this.createAuthResult(user);
}
async login(dto: LoginDto): Promise<AuthResult> {
const email = this.normalizeEmail(dto.email);
const password = this.validatePassword(dto.password);
const user = await this.usersService.findByEmail(email);
if (!user || user.status !== 'active') {
throw new UnauthorizedException('Invalid email or password');
}
const passwordMatches = await compare(password, user.password_hash);
if (!passwordMatches) {
throw new UnauthorizedException('Invalid email or password');
}
return this.createAuthResult(this.usersService.toSafeUser(user));
}
async getProfile(currentUser: AuthRequestUser) {
const user = await this.usersService.findById(currentUser.id);
if (!user || user.status !== 'active') {
throw new UnauthorizedException('User is unavailable');
}
return this.usersService.toSafeUser(user);
}
private createAuthResult(user: AuthResult['user']): AuthResult {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role
};
return {
access_token: this.jwtService.sign(payload),
token_type: 'Bearer',
expires_in: process.env.JWT_EXPIRES_IN ?? '7d',
user
};
}
private normalizeEmail(email: string | undefined) {
const value = email?.trim().toLowerCase();
if (!value || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new BadRequestException('Valid email is required');
}
return value;
}
private validatePassword(password: string | undefined) {
if (!password || password.length < PASSWORD_MIN_LENGTH) {
throw new BadRequestException(
`Password must be at least ${PASSWORD_MIN_LENGTH} characters`
);
}
return password;
}
private normalizeOptionalText(value: string | undefined) {
const normalized = value?.trim();
return normalized || undefined;
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { SafeUser } from '../users/user.types';
export interface JwtPayload {
sub: string;
email: string | null;
role: string;
}
export interface AuthRequestUser {
id: string;
email: string | null;
role: string;
}
export interface AuthResult {
access_token: string;
token_type: 'Bearer';
expires_in: string;
user: SafeUser;
}
@@ -0,0 +1,13 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthRequestUser } from './auth.types';
interface RequestWithUser {
user?: AuthRequestUser;
}
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<RequestWithUser>();
return request.user;
}
);
+51
View File
@@ -0,0 +1,51 @@
import { UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { describe, expect, it, vi } from 'vitest';
import { JwtAuthGuard } from './jwt-auth.guard';
function createContext(headers: Record<string, string | undefined>) {
const request = { headers };
return {
request,
context: {
switchToHttp: () => ({
getRequest: () => request
})
}
};
}
describe('JwtAuthGuard', () => {
it('attaches user payload for valid bearer tokens', async () => {
const jwtService = {
verifyAsync: vi.fn().mockResolvedValue({
sub: '1',
email: 'user@example.com',
role: 'user'
})
};
const guard = new JwtAuthGuard(jwtService as unknown as JwtService);
const { context, request } = createContext({
authorization: 'Bearer valid-token'
});
await expect(guard.canActivate(context as never)).resolves.toBe(true);
expect(request).toMatchObject({
user: {
id: '1',
email: 'user@example.com',
role: 'user'
}
});
});
it('rejects missing bearer tokens', async () => {
const guard = new JwtAuthGuard({ verifyAsync: vi.fn() } as unknown as JwtService);
const { context } = createContext({});
await expect(guard.canActivate(context as never)).rejects.toBeInstanceOf(
UnauthorizedException
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { AuthRequestUser, JwtPayload } from './auth.types';
interface AuthenticatedRequest {
headers: Record<string, string | string[] | undefined>;
user?: AuthRequestUser;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(@Inject(JwtService) private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = this.extractToken(request.headers.authorization);
if (!token) {
throw new UnauthorizedException('Missing bearer token');
}
try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token);
request.user = {
id: payload.sub,
email: payload.email,
role: payload.role
};
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractToken(authorization: string | string[] | undefined) {
const header = Array.isArray(authorization) ? authorization[0] : authorization;
if (!header) {
return null;
}
const [type, token] = header.split(' ');
return type?.toLowerCase() === 'bearer' && token ? token : null;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { ForbiddenException } from '@nestjs/common';
import type { AuthRequestUser } from './auth.types';
export const ADMIN_PERMISSIONS = [
'admin:read',
'projects:write',
'users:read',
'users:write',
'billing:read',
'billing:write',
'reviews:read',
'reviews:write',
'tasks:read',
'tasks:write',
'providers:read',
'providers:write',
'costs:read',
'settings:read',
'settings:write',
'audit:read',
'audit:export'
] as const;
export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number];
const ROLE_PERMISSIONS: Record<string, readonly AdminPermission[] | '*'> = {
admin: '*',
operator: [
'admin:read',
'projects:write',
'users:read',
'billing:read',
'reviews:read',
'reviews:write',
'tasks:read',
'tasks:write',
'providers:read',
'costs:read',
'audit:read'
],
finance: [
'admin:read',
'users:read',
'billing:read',
'billing:write',
'tasks:read',
'costs:read',
'audit:read',
'audit:export'
],
auditor: [
'admin:read',
'users:read',
'billing:read',
'reviews:read',
'tasks:read',
'providers:read',
'costs:read',
'settings:read',
'audit:read',
'audit:export'
]
};
export function permissionsForRole(role: string) {
const permissions = ROLE_PERMISSIONS[role];
return permissions === '*' ? [...ADMIN_PERMISSIONS] : [...(permissions ?? [])];
}
export function hasPermission(user: AuthRequestUser, permission: AdminPermission) {
return permissionsForRole(user.role).includes(permission);
}
export function assertPermission(user: AuthRequestUser, permission: AdminPermission) {
if (!hasPermission(user, permission)) {
throw new ForbiddenException(`Permission required: ${permission}`);
}
}