小程序修正

This commit is contained in:
tianmo committed 2026-09-29 20:51:39 +08:00
1 parent 5a92bab835
commit 6ae4c109ce
12 files changed
+232 -62

No files matched your search

@@ -2,6 +2,8 @@ import { Inject, Injectable } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';
import type { Response } from 'express';
import { APP_CONFIG, type AppConfig } from '../../config/config';
@@ -23,9 +25,45 @@ const REWRITABLE = /\.(ts|m3u8|key|mp4|jpg|jpeg|png|webp)(\?.*)?$/i;
@Injectable()
export class MediaService {
private readonly root: string;
private readonly upstream: string;
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {
this.root = path.resolve(process.cwd(), cfg.DATA_ROOT);
this.upstream = (cfg.MEDIA_UPSTREAM ?? '').replace(/\/+$/, '');
}
/**
* 媒体文件不一定在本机。
* 配了 MEDIA_UPSTREAM 就走上游(例如另一台服务器经隧道映射到本机的端口),
* 否则读 DATA_ROOT 下的本地文件。
*/
private upstreamUrl(relativePath: string): string {
const encoded = relativePath.split('/').map(encodeURIComponent).join('/');
return `${this.upstream}/${encoded}`;
}
/** 取文本内容(播放列表需要整体读入后重写)。 */
private async readText(relativePath: string): Promise<string | null> {
if (this.upstream) {
const res = await fetch(this.upstreamUrl(relativePath));
if (!res.ok) return null;
return res.text();
}
const abs = this.resolve(relativePath);
return abs ? readFile(abs, 'utf8') : null;
}
private notFound(res: Response, detail = '资源不存在'): void {
res.status(404).json({ code: 1001, message: detail, data: null });
}
/**
* 路径合法性检查(纵深防御)。
* 签名本身已经能挡住伪造请求,这里再拦一道目录穿越,
* 避免上游服务器被 `../` 探到数据目录之外。
*/
private isSafePath(relativePath: string): boolean {
return relativePath.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
}
private sign(relativePath: string, expiresAt: number): string {
@@ -95,28 +133,47 @@ export class MediaService {
/** 把文件写入响应。播放列表会先重写。 */
async send(res: Response, relativePath: string): Promise<void> {
const abs = this.resolve(relativePath);
if (!abs) {
res.status(404).json({ code: 1001, message: '资源不存在', data: null });
if (!this.isSafePath(relativePath)) {
this.notFound(res, '非法路径');
return;
}
const ext = path.extname(abs).toLowerCase();
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
res.setHeader('Cache-Control', 'private, max-age=300');
const ext = path.extname(relativePath).toLowerCase();
const mime = MIME[ext] ?? 'application/octet-stream';
// 播放列表:必须整体读入,因为要把签名透传到每一行分片引用上
if (ext === '.m3u8') {
const content = await readFile(abs, 'utf8');
const content = await this.readText(relativePath);
if (content === null) {
this.notFound(res);
return;
}
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'private, max-age=300');
res.send(this.rewritePlaylist(content, relativePath));
return;
}
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(abs);
stream.on('error', reject);
stream.on('end', resolve);
stream.pipe(res);
});
// 其余资源(分片、密钥、封面图):按字节透传,不整块读入内存
if (this.upstream) {
const upstreamRes = await fetch(this.upstreamUrl(relativePath));
if (!upstreamRes.ok || !upstreamRes.body) {
this.notFound(res, `上游返回 ${upstreamRes.status}`);
return;
}
res.setHeader('Content-Type', upstreamRes.headers.get('content-type') ?? mime);
res.setHeader('Cache-Control', 'private, max-age=300');
await pipeline(Readable.fromWeb(upstreamRes.body as Parameters<typeof Readable.fromWeb>[0]), res);
return;
}
const abs = this.resolve(relativePath);
if (!abs) {
this.notFound(res);
return;
}
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'private, max-age=300');
await pipeline(createReadStream(abs), res);
}
}
@@ -34,7 +34,10 @@ export class UsersService {
if (input.nickname !== undefined) {
const nickname = input.nickname.trim();
if (nickname.length === 0) throw AppError.badRequest('昵称不能为空');
if (nickname.length > 8) throw AppError.badRequest('昵称最多 8 个字符');
// 上限取 20:旧数据里自动生成的昵称是「用户 + 8 位随机」共 10 字符,
// 而小程序手输框限制 8 字符。服务端要容纳两者,取一个有余量的值。
// 数据库列是 VARCHAR(32),不会溢出。
if (nickname.length > 20) throw AppError.badRequest('昵称最多 20 个字符');
data.nickname = nickname;
}
@@ -80,4 +83,3 @@ export class UsersService {
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 50;
}
}