release: PicHost v1.0.0
Publish Docker image / build-and-push (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s

Multi-user image hosting with RBAC, upload preferences, and admin settings.
This commit is contained in:
O96u committed 2026-08-22 14:36:57 +08:00
commit bc4cba7e52
115 files changed
+26508

No files matched your search

+299
View File
@@ -0,0 +1,299 @@
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
export type UserRole = 'admin' | 'user'
export interface AuthUser {
id: number
username: string
role: UserRole
}
export interface AuthStatusResponse {
initialized: boolean
allowRegistration: boolean
legacyMode: boolean
needsMigration: boolean
user: AuthUser | null
}
export function isUnauthorizedError(error: unknown): boolean {
return typeof error === 'object'
&& error !== null
&& 'statusCode' in error
&& (error as { statusCode: number }).statusCode === 401
}
export const AUTH_USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
export const AUTH_MIN_PASSWORD_LENGTH = 8
export function validateAuthInput(username: string, password: string): string | null {
const trimmed = username.trim()
if (!AUTH_USERNAME_PATTERN.test(trimmed)) {
return '用户名需为 3–32 位字母、数字或下划线'
}
if (password.length < AUTH_MIN_PASSWORD_LENGTH) {
return '密码至少 8 位'
}
return null
}
export function getFetchErrorMessage(error: unknown, fallback: string): string {
if (!error || typeof error !== 'object') return fallback
const e = error as {
data?: {
message?: string
error?: { message?: string }
data?: { error?: { message?: string } }
}
statusMessage?: string
message?: string
}
const nested = e.data?.data?.error?.message
?? e.data?.error?.message
?? e.data?.message
if (nested) return nested
if (e.statusMessage && e.statusMessage !== 'Bad Request') {
return e.statusMessage
}
return fallback
}
export type AuthActionResult = { ok: true } | { ok: false, error: string }
export function useAuth() {
const status = useState<AuthStatus>('auth-status', () => 'checking')
const user = useState<AuthUser | null>('auth-user', () => null)
const authStatus = useState<AuthStatusResponse | null>('auth-status-response', () => null)
const isChecking = computed(() => status.value === 'checking')
const isAuthenticated = computed(() => status.value === 'authenticated')
const isAdmin = computed(() => user.value?.role === 'admin')
async function fetchStatus(): Promise<AuthStatusResponse> {
const data = await $fetch<AuthStatusResponse>('/api/auth/status', {
credentials: 'include'
})
authStatus.value = data
return data
}
async function checkSession() {
status.value = 'checking'
try {
const data = await fetchStatus()
if (data.user) {
user.value = data.user
status.value = 'authenticated'
return true
}
user.value = null
status.value = 'unauthenticated'
return false
} catch {
user.value = null
status.value = 'unauthenticated'
return false
}
}
function markUnauthenticated() {
status.value = 'unauthenticated'
user.value = null
}
async function login(
credentials: { username: string, password: string }
| { secret: string }
): Promise<{ ok: boolean, needsMigration?: boolean, error?: string }> {
try {
const result = await $fetch<{
success: boolean
needsMigration?: boolean
user?: AuthUser
}>('/api/auth/login', {
method: 'POST',
credentials: 'include',
body: credentials
})
if (result.needsMigration) {
return { ok: true, needsMigration: true }
}
if (result.user) {
user.value = result.user
}
status.value = 'authenticated'
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '登录失败,请检查用户名和密码')
}
}
}
async function register(
username: string,
password: string
): Promise<AuthActionResult> {
const validationError = validateAuthInput(username, password)
if (validationError) {
return { ok: false, error: validationError }
}
try {
const result = await $fetch<{ success: boolean, user: AuthUser }>(
'/api/auth/register',
{
method: 'POST',
credentials: 'include',
body: { username: username.trim(), password }
}
)
user.value = result.user
status.value = 'authenticated'
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '注册失败,请检查输入')
}
}
}
async function setup(input: {
username: string
password: string
allowRegistration: boolean
}): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password)
if (validationError) {
return { ok: false, error: validationError }
}
try {
const result = await $fetch<{ success: boolean, user: AuthUser }>(
'/api/setup',
{
method: 'POST',
credentials: 'include',
body: {
username: input.username.trim(),
password: input.password,
allowRegistration: input.allowRegistration
}
}
)
user.value = result.user
status.value = 'authenticated'
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '初始化失败,请检查输入')
}
}
}
async function migrate(input: {
username: string
password: string
allowRegistration: boolean
}): Promise<AuthActionResult> {
const validationError = validateAuthInput(input.username, input.password)
if (validationError) {
return { ok: false, error: validationError }
}
try {
const result = await $fetch<{ success: boolean, user: AuthUser }>(
'/api/auth/migrate',
{
method: 'POST',
credentials: 'include',
body: {
username: input.username.trim(),
password: input.password,
allowRegistration: input.allowRegistration
}
}
)
user.value = result.user
status.value = 'authenticated'
await fetchStatus()
return { ok: true }
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '迁移失败,请检查输入')
}
}
}
async function changePassword(
currentPassword: string,
newPassword: string
): Promise<AuthActionResult> {
if (newPassword.length < AUTH_MIN_PASSWORD_LENGTH) {
return { ok: false, error: '新密码至少 8 位' }
}
try {
await $fetch('/api/auth/password', {
method: 'PATCH',
credentials: 'include',
body: { currentPassword, newPassword }
})
return { ok: true }
} catch (error: unknown) {
return {
ok: false,
error: getFetchErrorMessage(error, '修改密码失败')
}
}
}
async function logout() {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
} catch {
// 忽略登出失败
} finally {
status.value = 'unauthenticated'
user.value = null
authStatus.value = null
}
}
function handleAuthError(error: unknown) {
if (isUnauthorizedError(error)) {
markUnauthenticated()
}
}
return {
status,
user,
authStatus,
isChecking,
isAuthenticated,
isAdmin,
checkSession,
fetchStatus,
markUnauthenticated,
login,
register,
setup,
migrate,
changePassword,
logout,
handleAuthError,
getFetchErrorMessage
}
}
+108
View File
@@ -0,0 +1,108 @@
import type { Ref } from 'vue'
const MAX_WIDTH = 2560
const QUALITY = 0.85
export interface CompressOptions {
enabled: boolean
maxWidth?: number
quality?: number
}
function shouldCompress(file: File): boolean {
return file.type === 'image/jpeg' || file.type === 'image/png'
}
async function compressImage(
file: File,
maxWidth: number,
quality: number
): Promise<File> {
const bitmap = await createImageBitmap(file)
const scale = Math.min(1, maxWidth / bitmap.width)
const width = Math.round(bitmap.width * scale)
const height = Math.round(bitmap.height * scale)
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
bitmap.close()
return file
}
ctx.drawImage(bitmap, 0, 0, width, height)
bitmap.close()
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/webp', quality)
})
if (!blob) return file
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image'
return new File([blob], `${baseName}.webp`, { type: 'image/webp' })
}
export async function prepareFilesForUpload(
files: File[],
options: CompressOptions
): Promise<File[]> {
if (!options.enabled) return files
const maxWidth = options.maxWidth ?? MAX_WIDTH
const quality = options.quality ?? QUALITY
const prepared: File[] = []
for (const file of files) {
if (!shouldCompress(file)) {
prepared.push(file)
continue
}
try {
prepared.push(await compressImage(file, maxWidth, quality))
} catch {
prepared.push(file)
}
}
return prepared
}
export function useClipboardImage(_target?: Ref<HTMLElement | null>) {
function onPaste(event: ClipboardEvent, onFiles: (files: File[]) => void) {
const items = event.clipboardData?.items
if (!items?.length) return
const files: File[] = []
for (const item of items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) files.push(file)
}
}
if (files.length) {
event.preventDefault()
onFiles(files)
}
}
function bindPaste(onFiles: (files: File[]) => void) {
const handler = (event: ClipboardEvent) => onPaste(event, onFiles)
onMounted(() => {
window.addEventListener('paste', handler)
})
onUnmounted(() => {
window.removeEventListener('paste', handler)
})
}
return { bindPaste, onPaste }
}
+10
View File
@@ -0,0 +1,10 @@
export function useFileSize() {
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
}
return { formatFileSize }
}
+201
View File
@@ -0,0 +1,201 @@
import type {
ImageItem,
ImageListResponse
} from '~/types/image'
const PAGE_SIZE = 12
export function useImageList() {
const items = ref<ImageItem[]>([])
const page = ref(1)
const totalPages = ref(1)
const total = ref(0)
const loading = ref(false)
const folderTotal = ref<number | null>(null)
const loadingTotal = ref(false)
const searchQuery = ref('')
const activeSearch = ref('')
const activeFolder = ref('all')
function folderQueryParams(): Record<string, string> {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return {}
return { folder }
}
function matchesActiveFolder(key: string) {
const folder = activeFolder.value.trim()
if (!folder || folder === 'all') return true
return key.startsWith(`${folder}/`)
}
async function fetchTotal() {
loadingTotal.value = true
try {
const data = await $fetch<{ total: number }>('/api/images/count', {
credentials: 'include',
query: folderQueryParams()
})
folderTotal.value = data.total
} catch {
folderTotal.value = null
} finally {
loadingTotal.value = false
}
}
async function fetchList(targetPage = 1) {
loading.value = true
try {
const response = await $fetch<ImageListResponse>('/api/images', {
credentials: 'include',
query: {
limit: PAGE_SIZE,
page: targetPage,
...folderQueryParams()
}
})
items.value = response.items
page.value = response.page
totalPages.value = response.totalPages
total.value = response.total
} finally {
loading.value = false
}
}
async function fetchSearch(targetPage = 1) {
const q = activeSearch.value.trim()
if (!q) {
await fetchList(1)
return
}
loading.value = true
try {
const response = await $fetch<ImageListResponse>('/api/images/search', {
credentials: 'include',
query: {
q,
limit: PAGE_SIZE,
page: targetPage,
...folderQueryParams()
}
})
items.value = response.items
page.value = response.page
totalPages.value = response.totalPages
total.value = response.total
} finally {
loading.value = false
}
}
async function refreshList() {
activeSearch.value = ''
searchQuery.value = ''
page.value = 1
await Promise.all([fetchList(1), fetchTotal()])
}
async function initializeList(folder = 'all') {
const next = folder.trim() || 'all'
activeFolder.value = next
page.value = 1
activeSearch.value = ''
searchQuery.value = ''
await Promise.all([fetchList(1), fetchTotal()])
}
async function setActiveFolder(folder: string) {
const next = folder.trim() || 'all'
if (next === activeFolder.value) return
activeFolder.value = next
page.value = 1
if (activeSearch.value) {
await Promise.all([fetchSearch(1), fetchTotal()])
} else {
await Promise.all([fetchList(1), fetchTotal()])
}
}
async function submitSearch() {
const q = searchQuery.value.trim()
activeSearch.value = q
page.value = 1
if (!q) {
await refreshList()
return
}
await fetchSearch(1)
}
async function goToPage(targetPage: number) {
if (targetPage < 1 || targetPage > totalPages.value || loading.value) return
if (activeSearch.value) {
await fetchSearch(targetPage)
} else {
await fetchList(targetPage)
}
}
function prependItems(newItems: ImageItem[]) {
const matched = newItems.filter(item => matchesActiveFolder(item.key))
if (!matched.length || page.value !== 1) return
items.value = [...matched, ...items.value]
total.value += matched.length
if (folderTotal.value !== null) {
folderTotal.value += matched.length
}
}
function removeItems(keys: string[]) {
const keySet = new Set(keys)
const removed = items.value.filter(item => keySet.has(item.key)).length
items.value = items.value.filter(item => !keySet.has(item.key))
if (removed > 0) {
total.value = Math.max(0, total.value - removed)
}
if (folderTotal.value !== null && removed > 0) {
folderTotal.value = Math.max(0, folderTotal.value - removed)
}
}
const listSummary = computed(() => {
const folder = activeFolder.value.trim() || 'all'
const folderLabel = folder === 'all' ? '全部' : folder
if (loadingTotal.value && folderTotal.value === null) {
return `${folderLabel} · 统计中…`
}
if (activeSearch.value) {
return `${folderLabel} · 搜索「${activeSearch.value}」· 共 ${total.value} 张`
}
if (folderTotal.value === null) {
return `${folderLabel} · 共 ${total.value} 张`
}
return `${folderLabel} · 共 ${folderTotal.value} 张`
})
return {
items,
page,
totalPages,
total,
loading,
activeFolder,
searchQuery,
activeSearch,
listSummary,
fetchList,
fetchTotal,
refreshList,
initializeList,
setActiveFolder,
submitSearch,
goToPage,
prependItems,
removeItems
}
}
+120
View File
@@ -0,0 +1,120 @@
import type {
UploadProgressItem,
UploadResponse
} from '~/types/image'
import { isUnauthorizedError } from './useAuth'
import { prepareFilesForUpload } from './useClipboardImage'
const MAX_FILES = 10
export function useImageUpload() {
const toast = useToast()
const { compressEnabled, clientWebpQuality } = useUploadPreferences()
const uploading = ref(false)
const progressItems = ref<UploadProgressItem[]>([])
const lastUploadResult = ref<UploadResponse | null>(null)
async function uploadFiles(
files: File[],
folder = 'images',
options?: { notify?: boolean, includeFolder?: boolean }
) {
if (uploading.value) return
if (!files.length) {
toast.add({ title: '请选择图片', color: 'warning' })
return
}
if (files.length > MAX_FILES) {
toast.add({
title: `每次最多上传 ${MAX_FILES} 张图片`,
color: 'warning'
})
return
}
uploading.value = true
progressItems.value = files.map(file => ({
name: file.name,
status: 'pending',
progress: 0
}))
try {
const prepared = await prepareFilesForUpload(files, {
enabled: compressEnabled.value,
quality: clientWebpQuality.value / 100
})
progressItems.value = prepared.map((file, index) => ({
name: file.name,
status: 'uploading',
progress: Math.round(((index + 1) / prepared.length) * 50)
}))
const formData = new FormData()
if (options?.includeFolder !== false) {
formData.append('folder', folder.trim() || 'images')
}
for (const file of prepared) {
formData.append('files', file, file.name)
}
const response = await $fetch<UploadResponse>('/api/images/upload', {
method: 'POST',
body: formData
})
lastUploadResult.value = response
progressItems.value = prepared.map((file) => {
const failed = response.errors.find(error => error.name === file.name)
return {
name: file.name,
status: failed ? 'error' : 'success',
progress: 100,
error: failed?.error.message
}
})
if (response.items.length && options?.notify !== false) {
toast.add({
title: `成功上传 ${response.items.length} 张图片`,
color: 'success'
})
}
if (response.errors.length) {
toast.add({
title: `${response.errors.length} 张图片上传失败`,
color: 'warning'
})
}
return response
} catch (error: unknown) {
if (isUnauthorizedError(error)) {
throw error
}
const message = error instanceof Error ? error.message : '上传失败'
progressItems.value = progressItems.value.map(item => ({
...item,
status: 'error',
progress: 100,
error: message
}))
toast.add({ title: message, color: 'error' })
throw error
} finally {
uploading.value = false
}
}
return {
uploading,
progressItems,
lastUploadResult,
uploadFiles
}
}
+101
View File
@@ -0,0 +1,101 @@
const STORAGE_KEY = 'pichost.upload-preferences'
export type CopyFormat = 'url' | 'markdown' | 'html'
export interface UploadPreferences {
defaultFolder: string
compressEnabled: boolean
autoCopyMarkdown: boolean
copyFormat: CopyFormat
clientWebpQuality: number
}
const DEFAULTS: UploadPreferences = {
defaultFolder: 'images',
compressEnabled: false,
autoCopyMarkdown: false,
copyFormat: 'markdown',
clientWebpQuality: 80
}
function clampQuality(value: unknown): number {
const num = Number(value)
if (!Number.isFinite(num)) return DEFAULTS.clientWebpQuality
return Math.min(100, Math.max(1, Math.round(num)))
}
function isCopyFormat(value: unknown): value is CopyFormat {
return value === 'url' || value === 'markdown' || value === 'html'
}
function readStored(): UploadPreferences {
if (import.meta.server) return { ...DEFAULTS }
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return { ...DEFAULTS }
const parsed = JSON.parse(raw) as Partial<UploadPreferences>
return {
defaultFolder: typeof parsed.defaultFolder === 'string' && parsed.defaultFolder.trim()
? parsed.defaultFolder.trim()
: DEFAULTS.defaultFolder,
compressEnabled: parsed.compressEnabled === true,
autoCopyMarkdown: parsed.autoCopyMarkdown === true,
copyFormat: isCopyFormat(parsed.copyFormat) ? parsed.copyFormat : DEFAULTS.copyFormat,
clientWebpQuality: clampQuality(parsed.clientWebpQuality)
}
} catch {
return { ...DEFAULTS }
}
}
function writeStored(prefs: UploadPreferences) {
if (import.meta.server) return
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs))
}
export function useUploadPreferences() {
const defaultFolder = useState('upload-pref-folder', () => DEFAULTS.defaultFolder)
const compressEnabled = useState('upload-pref-compress', () => DEFAULTS.compressEnabled)
const autoCopyMarkdown = useState('upload-pref-auto-md', () => DEFAULTS.autoCopyMarkdown)
const copyFormat = useState<CopyFormat>('upload-pref-copy-format', () => DEFAULTS.copyFormat)
const clientWebpQuality = useState('upload-pref-webp-quality', () => DEFAULTS.clientWebpQuality)
const loaded = useState('upload-pref-loaded', () => false)
function loadPreferences() {
const stored = readStored()
defaultFolder.value = stored.defaultFolder
compressEnabled.value = stored.compressEnabled
autoCopyMarkdown.value = stored.autoCopyMarkdown
copyFormat.value = stored.copyFormat
clientWebpQuality.value = stored.clientWebpQuality
loaded.value = true
}
function savePreferences() {
writeStored({
defaultFolder: defaultFolder.value.trim() || DEFAULTS.defaultFolder,
compressEnabled: compressEnabled.value,
autoCopyMarkdown: autoCopyMarkdown.value,
copyFormat: copyFormat.value,
clientWebpQuality: clampQuality(clientWebpQuality.value)
})
}
watch([defaultFolder, compressEnabled, autoCopyMarkdown, copyFormat, clientWebpQuality], () => {
if (loaded.value) {
savePreferences()
}
})
return {
defaultFolder,
compressEnabled,
autoCopyMarkdown,
copyFormat,
clientWebpQuality,
loaded,
loadPreferences,
savePreferences
}
}
@@ -0,0 +1,81 @@
export function useUserAutoDeleteSettings() {
const { isAuthenticated, handleAuthError } = useAuth()
const toast = useToast()
const userSettings = ref<{ autoDeleteDays: number } | null>(null)
const enabled = ref(false)
const daysDraft = ref(30)
const hydrated = ref(false)
const saving = ref(false)
let daysSaveTimer: ReturnType<typeof setTimeout> | null = null
function apply(data: { autoDeleteDays: number }) {
hydrated.value = false
userSettings.value = data
enabled.value = data.autoDeleteDays > 0
daysDraft.value = data.autoDeleteDays > 0 ? data.autoDeleteDays : 30
nextTick(() => {
hydrated.value = true
})
}
async function load() {
try {
const data = await $fetch<{ autoDeleteDays: number }>('/api/user/settings', {
credentials: 'include'
})
apply(data)
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '加载设置失败', color: 'error' })
}
}
}
async function save() {
if (!isAuthenticated.value || !userSettings.value) return
const days = enabled.value ? daysDraft.value : 0
if (enabled.value && (days < 1 || days > 3650)) return
saving.value = true
try {
const data = await $fetch<{ autoDeleteDays: number }>('/api/user/settings', {
method: 'PATCH',
credentials: 'include',
body: { autoDeleteDays: days }
})
apply(data)
} catch (error: unknown) {
handleAuthError(error)
if (isAuthenticated.value) {
toast.add({ title: '保存设置失败', color: 'error' })
}
} finally {
saving.value = false
}
}
watch(enabled, () => {
if (!hydrated.value) return
void save()
})
watch(daysDraft, () => {
if (!hydrated.value || !enabled.value) return
if (daysSaveTimer) clearTimeout(daysSaveTimer)
daysSaveTimer = setTimeout(() => {
void save()
}, 500)
})
return {
userSettings,
enabled,
daysDraft,
saving,
load
}
}