release: v1.4.2
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

Fix batch tagging and upload token auth; single API token and tagNames-only upload; tag management sort and responsive layout.
This commit is contained in:
O96u committed 2026-09-23 15:38:46 +08:00
1 parent 6cc4f27f7d
commit 4ed4692f5e
24 files changed
+828 -370

No files matched your search

+58 -11
View File
@@ -1,5 +1,5 @@
import type { H3Event } from 'h3'
import { findUserByApiToken } from './db'
import { findUserByApiToken, getPrimaryAdminUserId } from './db'
import { getAdminSecret, getApiUploadToken } from './env'
import { createApiError } from './api-error'
import {
@@ -85,10 +85,29 @@ export function verifyGlobalApiUploadToken(event: H3Event): boolean {
return timingSafeEqual(token, expected)
}
export function resolveUserIdFromApiToken(event: H3Event): number | null {
const token = extractApiToken(event)
if (!token) return null
return findUserByApiToken(token)?.id ?? null
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 {
@@ -222,20 +241,48 @@ export function resolveActivitySource(event: H3Event): 'api' | 'web' {
return verifyApiUploadToken(event) ? 'api' : 'web'
}
export async function getUploadUserId(event: H3Event): Promise<number | null> {
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)
const userIdFromToken = resolveUserIdFromApiToken(event, formToken)
if (userIdFromToken !== null) {
return userIdFromToken
}
if (verifyGlobalApiUploadToken(event)) {
return null
const user = await getCurrentUser(event)
if (user) {
return user.id
}
const user = await getCurrentUser(event)
return user?.id ?? null
const globalOwnerId = resolveUploadOwnerFromGlobalToken(event, formToken)
if (globalOwnerId !== null) {
return globalOwnerId
}
return null
}
+8
View File
@@ -414,6 +414,14 @@ export function listAdminUserIds(): Set<number> {
return new Set(rows.map(row => row.id))
}
/** 首个管理员(按 id),用于遗留全站 Token 上传归属 */
export function getPrimaryAdminUserId(): number | null {
const row = getDb().prepare(`
SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1
`).get() as { id: number } | undefined
return row?.id ?? null
}
export function setUserAutoDeleteDays(userId: number, days: number): void {
setUserAutoDeletePolicy(userId, days)
}
+3 -6
View File
@@ -67,14 +67,11 @@ describe('parseTagNamesParam', () => {
describe('resolveUploadTagIds', () => {
it('returns empty when no tags requested', () => {
expect(resolveUploadTagIds(1, [], [])).toEqual({ tagIds: [] })
expect(resolveUploadTagIds(1, [])).toEqual({ tagIds: [] })
})
it('requires user when tags are requested', () => {
expect(resolveUploadTagIds(null, [1], [])).toMatchObject({
message: expect.stringContaining('API Token')
})
expect(resolveUploadTagIds(null, [], ['博客'])).toMatchObject({
it('requires user when tag names are requested', () => {
expect(resolveUploadTagIds(null, ['博客'])).toMatchObject({
message: expect.stringContaining('API Token')
})
})
+104 -29
View File
@@ -1,5 +1,6 @@
import { getDb } from './db'
import { getImageIndexRow } from './image-index'
import { toCanonicalImageKey } from './image-key'
export const TAG_COLOR_PRESETS = [
'#22c55e',
@@ -310,17 +311,65 @@ function assertTagsOwnedByUser(userId: number, tagIds: number[]): void {
}
}
function resolveImageOwnerUserId(key: string): number | null {
const row = getImageIndexRow(key)
return row?.user_id ?? null
function resolveIndexedImageKey(key: string): string | null {
if (getImageIndexRow(key)) return key
const canonical = toCanonicalImageKey(key)
if (canonical && getImageIndexRow(canonical)) return canonical
return null
}
/** 打标时标签归属的用户:无上传者的索引图用操作者自己的标签 */
function resolveTagOwnerUserId(
imageOwnerUserId: number | null,
actorUserId: number
): number {
return imageOwnerUserId ?? actorUserId
}
/**
* 管理员给他人图片打标时,将操作者选中的 tagId 按名称映射到图片所有者的标签。
*/
export function resolveTagIdsForImageTagging(
actorUserId: number,
tagIds: number[],
imageOwnerUserId: number | null,
isAdmin: boolean
): number[] {
const tagOwnerUserId = resolveTagOwnerUserId(imageOwnerUserId, actorUserId)
const unique = [...new Set(tagIds)]
if (!unique.length) return []
if (tagOwnerUserId === actorUserId || !isAdmin) {
assertTagsOwnedByUser(tagOwnerUserId, unique)
return unique
}
const resolved: number[] = []
for (const tagId of unique) {
const row = getTagById(tagId)
if (!row) {
throw new Error('TAG_NOT_FOUND')
}
if (row.user_id === tagOwnerUserId) {
resolved.push(tagId)
continue
}
if (row.user_id === actorUserId) {
resolved.push(findOrCreateTagByName(tagOwnerUserId, row.name).id)
continue
}
throw new Error('TAG_NOT_FOUND')
}
return [...new Set(resolved)]
}
export function assertCanTagImage(key: string, actorUserId: number, isAdmin: boolean): void {
const ownerId = resolveImageOwnerUserId(key)
if (ownerId == null) {
const indexedKey = resolveIndexedImageKey(key)
if (!indexedKey) {
throw new Error('IMAGE_NOT_FOUND')
}
if (!isAdmin && ownerId !== actorUserId) {
const ownerId = getImageIndexRow(indexedKey)!.user_id
if (!isAdmin && (ownerId === null || ownerId !== actorUserId)) {
throw new Error('FORBIDDEN')
}
}
@@ -332,19 +381,27 @@ export function addTagsToImage(
isAdmin: boolean
): TagItem[] {
ensureTagSchema()
assertCanTagImage(key, actorUserId, isAdmin)
const ownerId = resolveImageOwnerUserId(key)!
const uniqueTagIds = [...new Set(tagIds)]
assertTagsOwnedByUser(ownerId, uniqueTagIds)
const indexedKey = resolveIndexedImageKey(key)
if (!indexedKey) {
throw new Error('IMAGE_NOT_FOUND')
}
assertCanTagImage(indexedKey, actorUserId, isAdmin)
const imageOwnerUserId = getImageIndexRow(indexedKey)!.user_id
const uniqueTagIds = resolveTagIdsForImageTagging(
actorUserId,
tagIds,
imageOwnerUserId,
isAdmin
)
const insert = getDb().prepare(`
INSERT OR IGNORE INTO image_tags (image_key, tag_id) VALUES (?, ?)
`)
for (const tagId of uniqueTagIds) {
insert.run(key, tagId)
insert.run(indexedKey, tagId)
}
return getTagsForImageKeys([key]).get(key) ?? []
return getTagsForImageKeys([indexedKey]).get(indexedKey) ?? []
}
export function removeTagFromImage(
@@ -354,15 +411,27 @@ export function removeTagFromImage(
isAdmin: boolean
): TagItem[] {
ensureTagSchema()
assertCanTagImage(key, actorUserId, isAdmin)
const ownerId = resolveImageOwnerUserId(key)!
assertTagsOwnedByUser(ownerId, [tagId])
const indexedKey = resolveIndexedImageKey(key)
if (!indexedKey) {
throw new Error('IMAGE_NOT_FOUND')
}
assertCanTagImage(indexedKey, actorUserId, isAdmin)
const imageOwnerUserId = getImageIndexRow(indexedKey)!.user_id
const [resolvedTagId] = resolveTagIdsForImageTagging(
actorUserId,
[tagId],
imageOwnerUserId,
isAdmin
)
if (resolvedTagId == null) {
return getTagsForImageKeys([indexedKey]).get(indexedKey) ?? []
}
getDb().prepare(`
DELETE FROM image_tags WHERE image_key = ? AND tag_id = ?
`).run(key, tagId)
`).run(indexedKey, resolvedTagId)
return getTagsForImageKeys([key]).get(key) ?? []
return getTagsForImageKeys([indexedKey]).get(indexedKey) ?? []
}
export function setImageTags(
@@ -372,20 +441,28 @@ export function setImageTags(
isAdmin: boolean
): TagItem[] {
ensureTagSchema()
assertCanTagImage(key, actorUserId, isAdmin)
const ownerId = resolveImageOwnerUserId(key)!
const uniqueTagIds = [...new Set(tagIds)]
assertTagsOwnedByUser(ownerId, uniqueTagIds)
const indexedKey = resolveIndexedImageKey(key)
if (!indexedKey) {
throw new Error('IMAGE_NOT_FOUND')
}
assertCanTagImage(indexedKey, actorUserId, isAdmin)
const imageOwnerUserId = getImageIndexRow(indexedKey)!.user_id
const uniqueTagIds = resolveTagIdsForImageTagging(
actorUserId,
tagIds,
imageOwnerUserId,
isAdmin
)
const db = getDb()
db.exec('BEGIN')
try {
db.prepare('DELETE FROM image_tags WHERE image_key = ?').run(key)
db.prepare('DELETE FROM image_tags WHERE image_key = ?').run(indexedKey)
const insert = db.prepare(`
INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)
`)
for (const tagId of uniqueTagIds) {
insert.run(key, tagId)
insert.run(indexedKey, tagId)
}
db.exec('COMMIT')
} catch (error) {
@@ -393,7 +470,7 @@ export function setImageTags(
throw error
}
return getTagsForImageKeys([key]).get(key) ?? []
return getTagsForImageKeys([indexedKey]).get(indexedKey) ?? []
}
export function attachTagsAfterUpload(
@@ -529,22 +606,20 @@ export function resolveTagIdsFromNames(userId: number, names: readonly string[])
export function resolveUploadTagIds(
userId: number | null,
tagIds: number[],
tagNames: string[]
): { tagIds: number[] } | { message: string } {
if (!tagIds.length && !tagNames.length) {
if (!tagNames.length) {
return { tagIds: [] }
}
if (userId == null) {
return {
message: '打标签需要登录或使用绑定用户的 API Token'
message: '使用 tagNames 时请登录或通过 Auth-Token / 表单 token 提供有效 API Token'
}
}
try {
const fromNames = tagNames.length ? resolveTagIdsFromNames(userId, tagNames) : []
return { tagIds: [...new Set([...tagIds, ...fromNames])] }
return { tagIds: resolveTagIdsFromNames(userId, tagNames) }
} catch (error) {
if (error instanceof Error) {
switch (error.message) {