Files
O96u 4ed4692f5e
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
release: v1.4.2
Fix batch tagging and upload token auth; single API token and tagNames-only upload; tag management sort and responsive layout.
2026-09-23 15:38:46 +08:00

289 lines
7.4 KiB
TypeScript
Raw Permalink 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
}