小程序修正
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user