Files
FileHost/server/utils/access.ts
T
Yeomai 89ea2fb25d
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
feat: 新增视频床与文件床,视频支持 HLS 转码
文件床
- 新增 files/ 存储前缀与 media_type 索引列,图片、视频、文件三类资源独立
- 新增文件上传、列表、删除、批量删除接口,禁止 html/htm/svg
- 支持公开直链与 Token 访问两种模式,后台可切换,直链强制附件下载

视频床
- 新增 videos/ 存储前缀与视频上传接口,单文件上限 500MB
- 接入 ffmpeg 转码队列,并发数与单任务核心数可配,默认串行
- 五档转码质量:直接封装 / 速度 / 平衡 / 质量 / 压缩(压缩档可调码率比例)
- 码率控制避免转码后体积反超源文件,音频跟随源音轨不放大不压缩
- 转码进度实时上报,成功后删除源文件,失败时保留源文件与可用链接
- 上传时自动抽帧生成封面,视频与封面各有独立链接
- 删除视频时中断转码并清理切片、封面与源文件

媒体库
- 图库、视频库、文件库三套独立统计,含趋势、占用与来源分布
- 统一筛选、多选、批量删除、批量标签、网格/列表切换,显示方式按类型分别记忆
- 视频详情对齐图片详情,视频库支持在线播放(hls.js,兼容回退原文件)

修复
- 清理无主的切片、封面与开发期遗留的 ffmpeg 进程
- 源文件按设计删除后,索引不再被启动孤儿清理误删
- 视频统计口径与列表一致,转码中的视频不计入统计
- 转码失败时回收已写入的部分切片,避免残留
- 设置页停用检查更新(与上游版本号已不对应)

其他
- Docker 镜像安装 ffmpeg
- git remote 指向内网仓库
2026-10-17 03:14:57 +08:00

307 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { H3Event } from 'h3'
import { findUserByApiToken, getPrimaryAdminUserId } from './db'
import { getAdminSecret, getApiUploadToken } from './env'
import { createApiError } from './api-error'
import {
getCurrentUser,
type AuthUser
} from './auth'
const LEGACY_AUTH_COOKIE = 'pic_auth'
const LEGACY_SESSION_SALT = 'pic-session'
const LEGACY_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
let result = 0
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
}
return result === 0
}
export async function createLegacySessionToken(secret: string): Promise<string> {
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(LEGACY_SESSION_SALT))
return Array.from(new Uint8Array(sig), byte => byte.toString(16).padStart(2, '0')).join('')
}
export async function setLegacyAuthCookie(event: H3Event, secret: string): Promise<void> {
const token = await createLegacySessionToken(secret)
const isHttps = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).protocol === 'https:'
setCookie(event, LEGACY_AUTH_COOKIE, token, {
httpOnly: true,
secure: isHttps,
sameSite: 'lax',
path: '/',
maxAge: LEGACY_COOKIE_MAX_AGE
})
}
export function clearLegacyAuthCookie(event: H3Event): void {
deleteCookie(event, LEGACY_AUTH_COOKIE, { path: '/' })
}
export async function verifyLegacyAuth(event: H3Event): Promise<boolean> {
const adminSecret = getAdminSecret(event)
if (!adminSecret) return false
const cookie = getCookie(event, LEGACY_AUTH_COOKIE)
if (!cookie) return false
const expected = await createLegacySessionToken(adminSecret)
return timingSafeEqual(cookie, expected)
}
export async function checkAdminSecret(
input: string,
secret: string
): Promise<boolean> {
return timingSafeEqual(input, secret)
}
function extractApiToken(event: H3Event): string {
const token = getHeader(event, 'auth-token')
return token?.trim() ?? ''
}
export function verifyGlobalApiUploadToken(event: H3Event): boolean {
const expected = getApiUploadToken(event)
if (!expected) return false
const token = extractApiToken(event)
if (!token) return false
return timingSafeEqual(token, expected)
}
function resolveUserIdFromTokenValue(token: string): number | null {
const trimmed = token.trim()
if (!trimmed) return null
return findUserByApiToken(trimmed)?.id ?? null
}
/** 从 Auth-Token 请求头或表单 token 解析个人 API Token 对应用户(非全站 Token) */
export function resolveUserIdFromApiToken(
event: H3Event,
extraToken?: string
): number | null {
const headerToken = extractApiToken(event)
if (headerToken) {
const fromHeader = resolveUserIdFromTokenValue(headerToken)
if (fromHeader !== null) return fromHeader
}
if (extraToken) {
const fromForm = resolveUserIdFromTokenValue(extraToken)
if (fromForm !== null) return fromForm
}
return null
}
export function verifyApiUploadToken(event: H3Event): boolean {
return verifyGlobalApiUploadToken(event)
|| resolveUserIdFromApiToken(event) !== null
}
/** EasyImage 等图床在表单字段 token 中传 API_UPLOAD_TOKEN */
export function verifyUploadTokenValue(event: H3Event, token: string): boolean {
const expected = getApiUploadToken(event)
if (expected && token && timingSafeEqual(token, expected)) {
return true
}
return findUserByApiToken(token) !== null
}
function devBypass(event: H3Event): boolean {
const config = useRuntimeConfig(event)
return import.meta.dev && Boolean(config.devBypassAccess)
}
export async function requireUserAuth(event: H3Event): Promise<AuthUser> {
if (devBypass(event)) {
return { id: 0, username: 'dev', role: 'admin' }
}
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
}
return user
}
export async function requireAdminAuth(event: H3Event): Promise<AuthUser> {
const user = await requireUserAuth(event)
if (user.role !== 'admin') {
createApiError(event, 'FORBIDDEN', '需要管理员权限', 403)
}
return user
}
export async function requireUploadAuth(event: H3Event): Promise<void> {
if (devBypass(event)) {
return
}
if (verifyApiUploadToken(event)) {
return
}
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
}
}
/** 图库读删等管理接口:API Token 或网页登录 Session */
export async function requireApiOrAdminAuth(event: H3Event): Promise<void> {
if (devBypass(event)) {
return
}
if (verifyApiUploadToken(event)) {
return
}
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
}
}
/** 列表/搜索/删除时按用户过滤;全局 Token 视为管理员,用户 Token 仅见自己的 */
export async function getImageUserFilter(
event: H3Event
): Promise<number | 'admin'> {
if (devBypass(event)) {
return 'admin'
}
const userIdFromToken = resolveUserIdFromApiToken(event)
if (userIdFromToken !== null) {
return userIdFromToken
}
if (verifyGlobalApiUploadToken(event)) {
return 'admin'
}
const user = await requireUserAuth(event)
if (user.role === 'admin') {
return 'admin'
}
return user.id
}
export async function assertImageOwnership(
event: H3Event,
userId: number | null | undefined
): Promise<void> {
if (devBypass(event)) {
return
}
const userIdFromToken = resolveUserIdFromApiToken(event)
if (userIdFromToken !== null) {
const ownerId = userId ?? null
if (ownerId !== userIdFromToken) {
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
}
return
}
if (verifyGlobalApiUploadToken(event)) {
return
}
const user = await requireUserAuth(event)
if (user.role === 'admin') {
return
}
const ownerId = userId ?? null
if (ownerId === null || ownerId !== user.id) {
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
}
}
export function resolveActivitySource(event: H3Event): 'api' | 'web' {
return verifyApiUploadToken(event) ? 'api' : 'web'
}
function resolveUploadOwnerFromGlobalToken(
event: H3Event,
formToken?: string
): number | null {
const expected = getApiUploadToken(event)
if (!expected) return null
const headerToken = extractApiToken(event)
if (headerToken && timingSafeEqual(headerToken, expected)) {
return getPrimaryAdminUserId()
}
const trimmedForm = formToken?.trim()
if (trimmedForm && timingSafeEqual(trimmedForm, expected)) {
return getPrimaryAdminUserId()
}
return null
}
export async function getUploadUserId(
event: H3Event,
formToken?: string
): Promise<number | null> {
if (devBypass(event)) {
return null
}
const userIdFromToken = resolveUserIdFromApiToken(event, formToken)
if (userIdFromToken !== null) {
return userIdFromToken
}
const user = await getCurrentUser(event)
if (user) {
return user.id
}
const globalOwnerId = resolveUploadOwnerFromGlobalToken(event, formToken)
if (globalOwnerId !== null) {
return globalOwnerId
}
return null
}
/** 文件床直链在 Token 模式下的校验:Auth-Token 头、?token= 查询参数或已登录会话 */
export async function isFileAccessAuthorized(event: H3Event): Promise<boolean> {
if (devBypass(event)) return true
if (verifyApiUploadToken(event)) return true
const queryToken = getQuery(event).token
if (
typeof queryToken === 'string'
&& queryToken
&& verifyUploadTokenValue(event, queryToken)
) {
return true
}
const user = await getCurrentUser(event)
return Boolean(user)
}