164 lines
5.7 KiB
TypeScript
164 lines
5.7 KiB
TypeScript
import {
|
|
Controller,
|
|
Injectable,
|
|
Get,
|
|
Post,
|
|
Query,
|
|
Req,
|
|
Res,
|
|
Param,
|
|
Body,
|
|
UploadedFile,
|
|
UseInterceptors,
|
|
BadRequestException,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
import { memoryStorage } from 'multer';
|
|
import { Response } from 'express';
|
|
import sharp from 'sharp';
|
|
import { createHash } from 'node:crypto';
|
|
import { z } from 'zod';
|
|
import { Database } from './database';
|
|
import { UserRequest } from './auth';
|
|
|
|
export const iconName = z.string().trim().min(1).max(100);
|
|
export const iconHash = (data: Buffer) => createHash('sha256').update(data).digest('hex');
|
|
export async function normalizeIcon(data: Buffer, preserveWhite = false) {
|
|
if (!data.length || data.length > 2 * 1024 * 1024)
|
|
throw new BadRequestException('图标不能超过 2 MB');
|
|
try {
|
|
const image = sharp(data, { limitInputPixels: 16000000, animated: false });
|
|
const meta = await image.metadata();
|
|
if (!['png', 'jpeg', 'webp'].includes(meta.format || '') || (meta.pages || 1) > 1)
|
|
throw Error();
|
|
const resized = await image
|
|
.rotate()
|
|
.resize(256, 256, { fit: 'inside', withoutEnlargement: true })
|
|
.ensureAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
// Neutral white logo backgrounds become transparent; coloured marks remain unchanged.
|
|
const { data: pixels, info } = resized;
|
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
const low = Math.min(pixels[i], pixels[i + 1], pixels[i + 2]);
|
|
const high = Math.max(pixels[i], pixels[i + 1], pixels[i + 2]);
|
|
if (!preserveWhite && low >= 245 && high - low <= 8) pixels[i + 3] = 0;
|
|
}
|
|
return await sharp(pixels, { raw: { width: info.width, height: info.height, channels: 4 } })
|
|
.png()
|
|
.toBuffer();
|
|
} catch {
|
|
throw new BadRequestException('请选择有效的静态 PNG、JPG 或 WebP 图片');
|
|
}
|
|
}
|
|
export async function validateStoredIcon(image: string, hash: string) {
|
|
const data = Buffer.from(image, 'base64');
|
|
if (data.toString('base64') !== image || iconHash(data) !== hash)
|
|
throw new BadRequestException('图标内容或校验值无效');
|
|
await normalizeIcon(data);
|
|
const meta = await sharp(data).metadata();
|
|
if (meta.format !== 'png' || !meta.width || !meta.height || meta.width > 256 || meta.height > 256)
|
|
throw new BadRequestException('备份图标必须是规范的 PNG');
|
|
return data;
|
|
}
|
|
@Injectable()
|
|
export class IconsService {
|
|
constructor(private db: Database) {}
|
|
async requireVisible(userId: string, id?: string | null) {
|
|
if (
|
|
id &&
|
|
!(await this.db.icon.findFirst({
|
|
where: { id, OR: [{ shared: true }, { ownerId: userId }] },
|
|
select: { id: true },
|
|
}))
|
|
)
|
|
throw new BadRequestException('图标不存在或无权使用');
|
|
}
|
|
}
|
|
|
|
@Injectable()
|
|
export class IconsBusinessService {
|
|
constructor(private db: Database) {}
|
|
async list(r: UserRequest, q = '', page = '1') {
|
|
const query = z.string().trim().max(100).parse(q);
|
|
const index = z.coerce.number().int().min(1).max(100000).parse(page);
|
|
const where = { OR: [{ shared: true }, { ownerId: r.userId }], name: { contains: query } };
|
|
const [items, total] = await this.db.$transaction([
|
|
this.db.icon.findMany({
|
|
where,
|
|
select: { id: true, name: true, shared: true, source: true },
|
|
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
|
skip: (index - 1) * 60,
|
|
take: 60,
|
|
}),
|
|
this.db.icon.count({ where }),
|
|
]);
|
|
return { items, total, page: index };
|
|
}
|
|
async image(r: UserRequest, id: string, res: Response) {
|
|
const icon = await this.db.icon.findFirst({
|
|
where: { id, OR: [{ shared: true }, { ownerId: r.userId }] },
|
|
});
|
|
if (!icon) throw new NotFoundException('图标不存在');
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
// Uploads, built-in seeding and imports already validate stored PNG data.
|
|
// Preserve essential white artwork rather than applying the cutout twice.
|
|
res.send(Buffer.from(icon.data));
|
|
}
|
|
|
|
async upload(r: UserRequest, raw: unknown, file?: Express.Multer.File) {
|
|
const v = z
|
|
.object({
|
|
name: iconName,
|
|
shared: z.enum(['true', 'false']).default('false'),
|
|
confirmed: z.literal('true').optional(),
|
|
})
|
|
.strict()
|
|
.parse(raw);
|
|
const shared = v.shared === 'true';
|
|
if (shared && (!/\p{Script=Han}/u.test(v.name) || v.confirmed !== 'true'))
|
|
throw new BadRequestException('共享图标须填写中文名称并明确确认公开给所有用户');
|
|
if (!file) throw new BadRequestException('请选择图标文件');
|
|
const data = await normalizeIcon(file.buffer),
|
|
hash = iconHash(data);
|
|
const icon = await this.db.icon.upsert({
|
|
where: { ownerId_hash_shared: { ownerId: r.userId, hash, shared } },
|
|
create: { name: v.name, ownerId: r.userId, shared, hash, data },
|
|
update: {},
|
|
select: { id: true, name: true, shared: true, source: true },
|
|
});
|
|
return icon;
|
|
}
|
|
}
|
|
|
|
@Controller('api/icons')
|
|
export class IconsController {
|
|
constructor(private service: IconsBusinessService) {}
|
|
@Get() async list(@Req() r: UserRequest, @Query('q') q = '', @Query('page') page = '1') {
|
|
return this.service.list(r, q, page);
|
|
}
|
|
@Get(':id/image') async image(
|
|
@Req() r: UserRequest,
|
|
@Param('id') id: string,
|
|
@Res() res: Response,
|
|
) {
|
|
return this.service.image(r, id, res);
|
|
}
|
|
@Post('upload')
|
|
@UseInterceptors(
|
|
FileInterceptor('file', {
|
|
storage: memoryStorage(),
|
|
limits: { fileSize: 2 * 1024 * 1024, files: 1, fields: 3 },
|
|
}),
|
|
)
|
|
async upload(
|
|
@Req() r: UserRequest,
|
|
@Body() raw: unknown,
|
|
@UploadedFile() file?: Express.Multer.File,
|
|
) {
|
|
return this.service.upload(r, raw, file);
|
|
}
|
|
}
|