diff --git a/.env.example b/.env.example index 3589634a..1bb489de 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,15 @@ NOTE_OUTPUT_DIR=note_results IMAGE_BASE_URL=/static/screenshots DATA_DIR=data +# 自托管访问鉴权(默认关闭) +# 公网部署建议开启。开启后 Web 端会显示访问密码登录页,浏览器插件需要在设置页登录。 +BILINOTE_AUTH_ENABLED=false +# BILINOTE_AUTH_PASSWORD=please-change-me +# 可选:固定 token 签名密钥;不填时会从访问密码派生,改密码会让旧登录失效。 +# BILINOTE_AUTH_SECRET= +# 登录有效期(天) +BILINOTE_AUTH_TOKEN_EXPIRE_DAYS=30 + # FFMPEG 配置(Docker 镜像已内置 ffmpeg,留空即可;自建/桌面端可填绝对路径) FFMPEG_BIN_PATH= diff --git a/BillNote_extension/src/background/main.ts b/BillNote_extension/src/background/main.ts index 7883e316..d8925f3a 100644 --- a/BillNote_extension/src/background/main.ts +++ b/BillNote_extension/src/background/main.ts @@ -74,7 +74,10 @@ async function startTask(url: string, title?: string): Promise<{ ok: boolean, ta try { const res = await fetch(`${backend}/api/generate_note`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(settings.authToken ? { Authorization: `Bearer ${settings.authToken}` } : {}), + }, body: JSON.stringify({ video_url: url, platform, diff --git a/BillNote_extension/src/components/MarkdownView.vue b/BillNote_extension/src/components/MarkdownView.vue index a492703f..bace8db5 100644 --- a/BillNote_extension/src/components/MarkdownView.vue +++ b/BillNote_extension/src/components/MarkdownView.vue @@ -1,13 +1,98 @@ @@ -340,9 +417,47 @@ onUnmounted(() => { {{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.title || t.videoUrl }} {{ t.status }} + + +
+
+
+ 确认删除这个任务? +
+
+ {{ pendingDeleteTitle }} +
+
+ + +
+
+
diff --git a/BillNote_extension/src/sidepanel/Sidepanel.vue b/BillNote_extension/src/sidepanel/Sidepanel.vue index 155625b7..e30b130f 100644 --- a/BillNote_extension/src/sidepanel/Sidepanel.vue +++ b/BillNote_extension/src/sidepanel/Sidepanel.vue @@ -1,7 +1,7 @@ @@ -148,6 +216,13 @@ onUnmounted(() => { {{ (t.result?.audio_meta as { title?: string } | undefined)?.title || t.title || t.videoUrl }} {{ STAGE_LABELS[t.status] || t.status }} + @@ -190,6 +265,13 @@ onUnmounted(() => { v-else class="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 shrink-0 animate-pulse" >{{ STAGE_LABELS[activeTask.status] || activeTask.status }} + @@ -253,6 +335,37 @@ onUnmounted(() => { + +
+
+
+ 确认删除这个任务? +
+
+ {{ pendingDeleteTitle }} +
+
+ + +
+
+
diff --git a/BillNote_frontend/src/App.tsx b/BillNote_frontend/src/App.tsx index a8a24816..e7e608b9 100644 --- a/BillNote_frontend/src/App.tsx +++ b/BillNote_frontend/src/App.tsx @@ -9,6 +9,7 @@ import StartupBanner from '@/components/SystemDiagnostic/StartupBanner' import BackendHealthIndicator from '@/components/BackendHealth/BackendHealthIndicator' import Index from '@/pages/Index.tsx' import { HomePage } from './pages/HomePage/Home.tsx' +import AuthGate from '@/components/AuthGate' // 非首屏页面使用 React.lazy 按需加载 const Onboarding = lazy(() => import('@/pages/Onboarding')) @@ -31,40 +32,19 @@ const DownloaderForm = lazy(() => import('@/components/Form/DownloaderForm/Form. const TranscriberPage = lazy(() => import('@/pages/SettingPage/transcriber.tsx')) const NotFoundPage = lazy(() => import('@/pages/NotFoundPage')) -function App() { +function MainApplication() { useTaskPolling(3000) // 每 3 秒轮询一次 - const { loading, initialized, failed, lastError, retry } = useCheckBackend() - // 在后端初始化完成后执行系统检查 useEffect(() => { - if (initialized) { - systemCheck() - } - }, [initialized]) - - // 如果后端还未初始化,显示初始化对话框(loading 或 failed 都展示,由 dialog 内部决定渲染哪一态) - if (!initialized) { - return ( - <> - - - - ) - } + systemCheck() + }, []) // 桌面端使用 HashRouter 避免刷新 404;Web 端继续使用 BrowserRouter const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window const Router = isTauri ? HashRouter : BrowserRouter - // 后端已初始化,渲染主应用 return ( <> - 加载中…}> @@ -95,4 +75,33 @@ function App() { ) } +function App() { + const { loading, initialized, failed, lastError, retry } = useCheckBackend() + + // 如果后端还未初始化,显示初始化对话框(loading 或 failed 都展示,由 dialog 内部决定渲染哪一态) + if (!initialized) { + return ( + <> + + + + ) + } + + // 后端已初始化,先过自托管鉴权,再渲染主应用 + return ( + <> + + + + + + ) +} + export default App diff --git a/BillNote_frontend/src/components/AuthGate.tsx b/BillNote_frontend/src/components/AuthGate.tsx new file mode 100644 index 00000000..868200cf --- /dev/null +++ b/BillNote_frontend/src/components/AuthGate.tsx @@ -0,0 +1,92 @@ +import type React from 'react' +import { FormEvent, useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { getAuthStatus, login, setAuthToken } from '@/services/auth' + +type AuthState = 'checking' | 'disabled' | 'authenticated' | 'login' + +export default function AuthGate({ children }: { children: React.ReactNode }) { + const [state, setState] = useState('checking') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState(false) + + const check = async () => { + try { + const status = await getAuthStatus() + if (!status.enabled) { + setState('disabled') + } else if (status.authenticated) { + setState('authenticated') + } else { + setAuthToken('') + setState('login') + } + } catch { + setState('login') + } + } + + useEffect(() => { + check() + const onExpired = () => { + setAuthToken('') + setState('login') + } + window.addEventListener('bilinote-auth-expired', onExpired) + return () => window.removeEventListener('bilinote-auth-expired', onExpired) + }, []) + + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError('') + setSubmitting(true) + try { + await login(password) + setPassword('') + setState('authenticated') + } catch (err: any) { + setError(err?.msg || '登录失败,请检查访问密码') + } finally { + setSubmitting(false) + } + } + + if (state === 'checking') { + return
检查访问权限…
+ } + + if (state === 'login') { + return ( +
+
+
+
BiliNote
+

此自托管实例已开启访问密码

+
+ + setPassword(e.target.value)} + placeholder="请输入 BILINOTE_AUTH_PASSWORD" + /> + {error &&

{error}

} + +

+ 用于自托管单用户访问保护;登录后同一后端的笔记历史会跨设备同步。 +

+
+
+ ) + } + + return <>{children} +} diff --git a/BillNote_frontend/src/hooks/useTaskPolling.ts b/BillNote_frontend/src/hooks/useTaskPolling.ts index 58e406f4..baeff3ab 100644 --- a/BillNote_frontend/src/hooks/useTaskPolling.ts +++ b/BillNote_frontend/src/hooks/useTaskPolling.ts @@ -6,8 +6,6 @@ import toast from 'react-hot-toast' export const useTaskPolling = (interval = 3000) => { const tasks = useTaskStore(state => state.tasks) const updateTaskContent = useTaskStore(state => state.updateTaskContent) - const updateTaskStatus = useTaskStore(state => state.updateTaskStatus) - const removeTask = useTaskStore(state => state.removeTask) const tasksRef = useRef(tasks) diff --git a/BillNote_frontend/src/pages/HomePage/Home.tsx b/BillNote_frontend/src/pages/HomePage/Home.tsx index d1902399..a89cde67 100644 --- a/BillNote_frontend/src/pages/HomePage/Home.tsx +++ b/BillNote_frontend/src/pages/HomePage/Home.tsx @@ -8,6 +8,7 @@ type ViewStatus = 'idle' | 'loading' | 'success' | 'failed' export const HomePage: FC = () => { const tasks = useTaskStore(state => state.tasks) const currentTaskId = useTaskStore(state => state.currentTaskId) + const syncNotes = useTaskStore(state => state.syncNotes) const currentTask = tasks.find(t => t.id === currentTaskId) @@ -15,6 +16,16 @@ export const HomePage: FC = () => { const content = currentTask?.markdown || '' + useEffect(() => { + syncNotes().catch(err => { + console.warn('同步后端笔记失败:', err) + }) + const timer = window.setInterval(() => { + syncNotes().catch(err => console.warn('同步后端笔记失败:', err)) + }, 30_000) + return () => window.clearInterval(timer) + }, [syncNotes]) + useEffect(() => { if (!currentTask) { setStatus('idle') diff --git a/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx b/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx index c34a0e54..c28568d0 100644 --- a/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/MarkdownViewer.tsx @@ -25,6 +25,7 @@ import TranscriptViewer from '@/pages/HomePage/components/transcriptViewer.tsx' import MarkmapEditor from '@/pages/HomePage/components/MarkmapComponent.tsx' import ChatPanel from '@/pages/HomePage/components/ChatPanel.tsx' import VideoBanner from '@/pages/HomePage/components/VideoBanner.tsx' +import { withAuthTokenQuery } from '@/services/auth' interface VersionNote { ver_id: string @@ -183,7 +184,7 @@ function createMarkdownComponents(baseURL: string) { if (src.startsWith('/')) { src = baseURL + src } - props.src = src + props.src = withAuthTokenQuery(src) return (
diff --git a/BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx b/BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx index d11e8523..548bf9d7 100644 --- a/BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/NoteHistory.tsx @@ -13,8 +13,9 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip.tsx' -import LazyImage from "@/components/LazyImage.tsx"; -import {FC, useState, useEffect, useMemo} from 'react' +import LazyImage from '@/components/LazyImage.tsx' +import { FC, useState, useEffect, useMemo } from 'react' +import { withAuthTokenQuery } from '@/services/auth' interface NoteHistoryProps { onSelect: (taskId: string) => void @@ -98,15 +99,14 @@ const NoteHistory: FC = ({ onSelect, selectedId }) => { className="h-10 w-12 rounded-md object-cover" /> ) : ( - + )} {/* 标题 + 状态 */} diff --git a/BillNote_frontend/src/pages/HomePage/components/VideoBanner.tsx b/BillNote_frontend/src/pages/HomePage/components/VideoBanner.tsx index 6acd3a96..08f3adc2 100644 --- a/BillNote_frontend/src/pages/HomePage/components/VideoBanner.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/VideoBanner.tsx @@ -1,5 +1,6 @@ import { ExternalLink } from 'lucide-react' import type { AudioMeta } from '@/store/taskStore' +import { withAuthTokenQuery } from '@/services/auth' interface VideoBannerProps { audioMeta?: AudioMeta @@ -20,9 +21,7 @@ export default function VideoBanner({ audioMeta, videoUrl }: VideoBannerProps) { const rawCover = audioMeta.cover_url // 通过后端代理加载封面,避免跨域/Referrer 限制 const apiBase = String(import.meta.env.VITE_API_BASE_URL || 'api').replace(/\/$/, '') - const coverUrl = rawCover - ? `${apiBase}/image_proxy?url=${encodeURIComponent(rawCover)}` - : '' + const coverUrl = rawCover ? withAuthTokenQuery(`${apiBase}/image_proxy?url=${encodeURIComponent(rawCover)}`) : '' const title = audioMeta.title const uploader = audioMeta.raw_info?.uploader || '' const platform = platformLabel[audioMeta.platform] || audioMeta.platform || '' diff --git a/BillNote_frontend/src/services/auth.ts b/BillNote_frontend/src/services/auth.ts new file mode 100644 index 00000000..5a563e74 --- /dev/null +++ b/BillNote_frontend/src/services/auth.ts @@ -0,0 +1,40 @@ +import request from '@/utils/request' + +export const AUTH_TOKEN_KEY = 'bilinote-auth-token' + +export interface AuthStatus { + enabled: boolean + authenticated: boolean +} + +export const getAuthToken = () => localStorage.getItem(AUTH_TOKEN_KEY) || '' + +export const setAuthToken = (token: string) => { + if (token) localStorage.setItem(AUTH_TOKEN_KEY, token) + else localStorage.removeItem(AUTH_TOKEN_KEY) +} + +export const withAuthTokenQuery = (url: string) => { + const token = getAuthToken() + if (!token) return url + const sep = url.includes('?') ? '&' : '?' + return `${url}${sep}access_token=${encodeURIComponent(token)}` +} + +export const getAuthStatus = async (): Promise => { + return await request.get('/auth/status', { suppressToast: true }) +} + +export const login = async (password: string): Promise<{ token: string; enabled: boolean }> => { + const res = await request.post('/auth/login', { password }, { suppressToast: true }) + if (res?.token) setAuthToken(res.token) + return res +} + +export const logout = async () => { + try { + await request.post('/auth/logout', {}, { suppressToast: true }) + } finally { + setAuthToken('') + } +} diff --git a/BillNote_frontend/src/services/note.ts b/BillNote_frontend/src/services/note.ts index 722bd92b..30840b3b 100644 --- a/BillNote_frontend/src/services/note.ts +++ b/BillNote_frontend/src/services/note.ts @@ -1,6 +1,19 @@ import request from '@/utils/request' import toast from 'react-hot-toast' +export interface ServerNote { + id: string + task_id: string + status: string + message?: string + created_at: string + updated_at: string + markdown: string + transcript: any + audio_meta: any + form_data: any +} + export const generateNote = async (data: { video_url: string platform: string @@ -41,11 +54,12 @@ export const generateNote = async (data: { } } -export const delete_task = async ({ video_id, platform }) => { +export const delete_task = async ({ video_id, platform, task_id }) => { try { const data = { video_id, platform, + task_id, } const res = await request.post('/delete_task', data) @@ -59,6 +73,10 @@ export const delete_task = async ({ video_id, platform }) => { } } +export const listNotes = async (): Promise => { + return await request.get('/notes') +} + export const get_task_status = async (task_id: string) => { try { // 成功提示 diff --git a/BillNote_frontend/src/store/taskStore/index.ts b/BillNote_frontend/src/store/taskStore/index.ts index f511f78f..2f415336 100644 --- a/BillNote_frontend/src/store/taskStore/index.ts +++ b/BillNote_frontend/src/store/taskStore/index.ts @@ -1,12 +1,21 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' -import { delete_task, generateNote } from '@/services/note.ts' +import { delete_task, generateNote, listNotes, type ServerNote } from '@/services/note.ts' import { v4 as uuidv4 } from 'uuid' import toast from 'react-hot-toast' import { get, set, del } from 'idb-keyval' -export type TaskStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILD' +export type TaskStatus = + | 'PENDING' + | 'PARSING' + | 'DOWNLOADING' + | 'TRANSCRIBING' + | 'SUMMARIZING' + | 'FORMATTING' + | 'SAVING' + | 'SUCCESS' + | 'FAILED' export interface AudioMeta { cover_url: string @@ -44,6 +53,7 @@ export interface Task { transcript: Transcript status: TaskStatus audioMeta: AudioMeta + platform: string createdAt: string formData: { video_url: string @@ -53,19 +63,89 @@ export interface Task { quality: string model_name: string provider_id: string + style?: string + extras?: string + format?: string[] + video_understanding?: boolean + video_interval?: number + grid_size?: number[] } } interface TaskStore { tasks: Task[] currentTaskId: string | null - addPendingTask: (taskId: string, platform: string) => void + addPendingTask: (taskId: string, platform: string, formData: any) => void updateTaskContent: (id: string, data: Partial>) => void removeTask: (id: string) => void clearTasks: () => void + syncNotes: () => Promise setCurrentTask: (taskId: string | null) => void getCurrentTask: () => Task | null - retryTask: (id: string) => void + retryTask: (id: string, payload?: any) => void +} + +export const isArtifactTaskId = (id?: string | null) => + /_(audio|markdown|request|transcript)$/.test(id || '') + +const defaultTranscript = (): Transcript => ({ + full_text: '', + language: '', + raw: null, + segments: [], +}) + +const defaultAudioMeta = (): AudioMeta => ({ + cover_url: '', + duration: 0, + file_path: '', + platform: '', + raw_info: null, + title: '', + video_id: '', +}) + +const normalizeServerTask = (note: ServerNote, existing?: Task): Task => { + const formData = { + video_url: '', + platform: note.audio_meta?.platform || note.form_data?.platform || '', + quality: 'medium', + model_name: '', + provider_id: '', + style: 'minimal', + format: [], + screenshot: false, + link: false, + extras: '', + video_understanding: false, + video_interval: 6, + grid_size: [2, 2], + ...(note.form_data || {}), + } + + const serverMarkdown: Markdown[] | string = note.markdown + ? [{ + ver_id: `${note.task_id}-server`, + content: note.markdown, + style: formData.style || '', + model_name: formData.model_name || '', + created_at: note.updated_at || note.created_at, + }] + : '' + + return { + id: note.task_id, + status: note.status as TaskStatus, + markdown: + existing?.status === 'SUCCESS' && Array.isArray(existing.markdown) && existing.markdown.length > 0 + ? existing.markdown + : serverMarkdown, + transcript: note.transcript || defaultTranscript(), + audioMeta: { ...defaultAudioMeta(), ...(note.audio_meta || {}) }, + platform: formData.platform || note.audio_meta?.platform || '', + createdAt: note.created_at || new Date().toISOString(), + formData, + } } export const useTaskStore = create()( @@ -217,12 +297,40 @@ export const useTaskStore = create()( await delete_task({ video_id: task.audioMeta.video_id, platform: task.platform, + task_id: task.id, }) } }, clearTasks: () => set({ tasks: [], currentTaskId: null }), + syncNotes: async () => { + const notes = (await listNotes()).filter(note => !isArtifactTaskId(note.task_id)) + set(state => { + const existingMap = new Map(state.tasks.map(task => [task.id, task])) + const synced = notes.map(note => normalizeServerTask(note, existingMap.get(note.task_id))) + const syncedIds = new Set(synced.map(task => task.id)) + const localOnly = state.tasks.filter( + task => + !isArtifactTaskId(task.id) && + !syncedIds.has(task.id) && + task.status !== 'SUCCESS' && + task.status !== 'FAILED' + ) + const tasks = [...synced, ...localOnly].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + const currentTaskId = state.currentTaskId && tasks.some(task => task.id === state.currentTaskId) + ? state.currentTaskId + : tasks[0]?.id || null + + return { + tasks, + currentTaskId, + } + }) + }, + setCurrentTask: taskId => set({ currentTaskId: taskId }), }), { diff --git a/BillNote_frontend/src/utils/request.ts b/BillNote_frontend/src/utils/request.ts index c44693c1..787ac2c1 100644 --- a/BillNote_frontend/src/utils/request.ts +++ b/BillNote_frontend/src/utils/request.ts @@ -1,6 +1,8 @@ import axios, { AxiosInstance, AxiosResponse } from 'axios'; import toast from 'react-hot-toast' +const AUTH_TOKEN_KEY = 'bilinote-auth-token' + // 统一响应类型 export interface IResponse { code: number; @@ -25,8 +27,18 @@ const baseURL = import.meta.env.VITE_API_BASE_URL; const request: AxiosInstance = axios.create({ baseURL: baseURL || '/api', timeout: 10000, + withCredentials: true, }); +request.interceptors.request.use(config => { + const token = localStorage.getItem(AUTH_TOKEN_KEY) + if (token) { + config.headers = config.headers || {} + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + // 响应拦截器 request.interceptors.response.use( (response: AxiosResponse) => { @@ -50,6 +62,9 @@ request.interceptors.response.use( if (res) { // 如果后端有返回错误信息,则显示后端信息 if (!suppress) toast.error(res.msg || '服务器错误,请稍后再试'); + if (error?.response?.status === 401) { + window.dispatchEvent(new CustomEvent('bilinote-auth-expired')) + } return Promise.reject(res); } else { // 没有响应数据(如网络中断),显示通用网络错误 diff --git a/Dockerfile.complete b/Dockerfile.complete index 3102be46..e28dfc51 100644 --- a/Dockerfile.complete +++ b/Dockerfile.complete @@ -104,7 +104,7 @@ nodaemon=true user=root logfile=/var/log/supervisor/supervisord.log pidfile=/var/run/supervisord.pid -environment=BACKEND_PORT="8483",BACKEND_HOST="0.0.0.0",TRANSCRIBER_TYPE="fast-whisper",WHISPER_MODEL_SIZE="tiny",FFMPEG_BIN_PATH="",HF_ENDPOINT="https://hf-mirror.com",STATIC="/static",OUT_DIR="./static/screenshots",DATA_DIR="data",NOTE_OUTPUT_DIR="data/note_results",DATABASE_URL="sqlite:////app/backend/data/bili_note.db",IMAGE_BASE_URL="/static/screenshots",ENV="production",GROQ_TRANSCRIBER_MODEL="whisper-large-v3-turbo" +environment=BACKEND_PORT="8483",BACKEND_HOST="0.0.0.0",TRANSCRIBER_TYPE="fast-whisper",WHISPER_MODEL_SIZE="tiny",FFMPEG_BIN_PATH="",HF_ENDPOINT="https://hf-mirror.com",STATIC="/static",OUT_DIR="./static/screenshots",DATA_DIR="data",NOTE_OUTPUT_DIR="data/note_results",DATABASE_URL="sqlite:////app/backend/data/bili_note.db",IMAGE_BASE_URL="/static/screenshots",ENV="production",GROQ_TRANSCRIBER_MODEL="whisper-large-v3-turbo",BILINOTE_AUTH_ENABLED="false",BILINOTE_AUTH_PASSWORD="",BILINOTE_AUTH_SECRET="",BILINOTE_AUTH_TOKEN_EXPIRE_DAYS="30" [program:nginx] command=nginx -g "daemon off;" @@ -120,7 +120,7 @@ stdout_logfile=/var/log/supervisor/backend.log stderr_logfile=/var/log/supervisor/backend.log autorestart=true priority=20 -environment=BACKEND_PORT="%(ENV_BACKEND_PORT)s",BACKEND_HOST="%(ENV_BACKEND_HOST)s",TRANSCRIBER_TYPE="%(ENV_TRANSCRIBER_TYPE)s",WHISPER_MODEL_SIZE="%(ENV_WHISPER_MODEL_SIZE)s",FFMPEG_BIN_PATH="%(ENV_FFMPEG_BIN_PATH)s",HF_ENDPOINT="%(ENV_HF_ENDPOINT)s",STATIC="%(ENV_STATIC)s",OUT_DIR="%(ENV_OUT_DIR)s",DATA_DIR="%(ENV_DATA_DIR)s",NOTE_OUTPUT_DIR="%(ENV_NOTE_OUTPUT_DIR)s",DATABASE_URL="%(ENV_DATABASE_URL)s",IMAGE_BASE_URL="%(ENV_IMAGE_BASE_URL)s",ENV="%(ENV_ENV)s",GROQ_TRANSCRIBER_MODEL="%(ENV_GROQ_TRANSCRIBER_MODEL)s" +environment=BACKEND_PORT="%(ENV_BACKEND_PORT)s",BACKEND_HOST="%(ENV_BACKEND_HOST)s",TRANSCRIBER_TYPE="%(ENV_TRANSCRIBER_TYPE)s",WHISPER_MODEL_SIZE="%(ENV_WHISPER_MODEL_SIZE)s",FFMPEG_BIN_PATH="%(ENV_FFMPEG_BIN_PATH)s",HF_ENDPOINT="%(ENV_HF_ENDPOINT)s",STATIC="%(ENV_STATIC)s",OUT_DIR="%(ENV_OUT_DIR)s",DATA_DIR="%(ENV_DATA_DIR)s",NOTE_OUTPUT_DIR="%(ENV_NOTE_OUTPUT_DIR)s",DATABASE_URL="%(ENV_DATABASE_URL)s",IMAGE_BASE_URL="%(ENV_IMAGE_BASE_URL)s",ENV="%(ENV_ENV)s",GROQ_TRANSCRIBER_MODEL="%(ENV_GROQ_TRANSCRIBER_MODEL)s",BILINOTE_AUTH_ENABLED="%(ENV_BILINOTE_AUTH_ENABLED)s",BILINOTE_AUTH_PASSWORD="%(ENV_BILINOTE_AUTH_PASSWORD)s",BILINOTE_AUTH_SECRET="%(ENV_BILINOTE_AUTH_SECRET)s",BILINOTE_AUTH_TOKEN_EXPIRE_DAYS="%(ENV_BILINOTE_AUTH_TOKEN_EXPIRE_DAYS)s" EOF # 修改 nginx 配置以使用本地 backend diff --git a/README.md b/README.md index 21c300ee..f9866ef8 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ docker run -d -p 80:80 \ > ⚠️ **不要**用 `-v 卷名:/app/backend` 挂整个后端目录——命名卷会用首次启动时的镜像内容固化,之后 `docker pull` 升级也会被旧代码盖住,导致「升级不生效」。只挂上面这些数据子目录即可。 +公网部署建议开启自托管访问密码:`-e BILINOTE_AUTH_ENABLED=true -e BILINOTE_AUTH_PASSWORD=请改成强密码`。开启后 Web 端与浏览器插件都需要登录,同一后端的笔记历史会跨设备同步。 + 访问:`http://localhost` 也可以使用 docker-compose 本地构建: @@ -229,21 +231,33 @@ docker logs -f bilinote-backend 注意:**LLM API key 不要写 `.env`**,从前端「模型供应商」页面录入,会保存到 SQLite 数据库并持久化。 -**3. 数据存在哪?删容器会丢吗?** +**3. 公网部署如何开启访问密码?** + +自托管实例默认不启用鉴权,公网部署建议在 `.env` 中开启单用户访问保护: + +```env +BILINOTE_AUTH_ENABLED=true +BILINOTE_AUTH_PASSWORD=请改成强密码 +``` + +开启后 Web 端会显示访问密码登录页;浏览器插件需要在「插件设置 → 通用 → 访问鉴权」里输入同一个密码登录。登录后,同一后端里的 `note_results` 笔记历史会同步到不同设备的 Web / 插件历史列表。 + +**4. 数据存在哪?删容器会丢吗?** `docker-compose` 用的是 `./backend:/app` 绑挂,下面这些文件都在宿主机的 `./backend/` 目录里、删容器不会丢: - `./backend/bili_note.db` —— SQLite 库(含 LLM 供应商配置、笔记历史) +- `./backend/note_results/` —— 生成的 Markdown 笔记、转写结果、任务状态 - `./backend/config/transcriber.json` —— 转写器运行时配置 - `./backend/static/screenshots/` —— 视频截图 - `./backend/uploads/` —— 上传的本地视频 要彻底重置就 `docker-compose down && rm backend/bili_note.db backend/config/transcriber.json`。 -**4. 前端打开是空白页 / 报 502** +**5. 前端打开是空白页 / 报 502** 通常是 nginx 起来了但 backend 还没 healthy。`docker ps` 看 backend 容器 STATUS 是不是 `(healthy)`;若长期 `(unhealthy)`,按问题 1 排查后端日志。 -**5. 不要用 `restart: on-failure:N`** +**6. 不要用 `restart: on-failure:N`** 如果你 fork 后改过 compose 文件、把 restart 策略改成了 `on-failure:3`:任何 3 次连续崩溃都会让容器永远不再启动,之后改 `.env` 也没用。本项目自带的 compose 已经统一用 `unless-stopped`。 diff --git a/backend/.env.example b/backend/.env.example index 0b37d5fc..fd9e9858 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,13 @@ STATIC=/static # 外部访问路径(URL 前缀) OUT_DIR=./static/screenshots # 本地输出目录 IMAGE_BASE_URL=/static/screenshots # 图片访问 URL DATA_DIR=data + +# 自托管访问鉴权(默认关闭) +BILINOTE_AUTH_ENABLED=false +# BILINOTE_AUTH_PASSWORD=please-change-me +# BILINOTE_AUTH_SECRET= +BILINOTE_AUTH_TOKEN_EXPIRE_DAYS=30 + # transcriber 相关配置 TRANSCRIBER_TYPE=fast-whisper # fast-whisper/bcut/kuaishou -WHISPER_MODEL_SIZE=base \ No newline at end of file +WHISPER_MODEL_SIZE=base diff --git a/backend/app/__init__.py b/backend/app/__init__.py index f97ca9a3..64f754cc 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,11 +1,12 @@ from fastapi import FastAPI -from .routers import note, provider, model, config, chat +from .routers import note, provider, model, config, chat, auth def create_app(lifespan) -> FastAPI: app = FastAPI(title="BiliNote",lifespan=lifespan) + app.include_router(auth.router, prefix="/api") app.include_router(note.router, prefix="/api") app.include_router(provider.router, prefix="/api") app.include_router(model.router,prefix="/api") diff --git a/backend/app/db/video_task_dao.py b/backend/app/db/video_task_dao.py index 42cd4359..064eb504 100644 --- a/backend/app/db/video_task_dao.py +++ b/backend/app/db/video_task_dao.py @@ -58,4 +58,21 @@ def delete_task_by_video(video_id: str, platform: str): except Exception as e: logger.error(f"Failed to delete task by video: {e}") finally: - db.close() \ No newline at end of file + db.close() + + +def delete_task_by_task_id(task_id: str): + db = next(get_db()) + try: + task = db.query(VideoTask).filter_by(task_id=task_id).first() + if task: + db.delete(task) + db.commit() + logger.info(f"Task deleted by task_id: {task_id}") + return 1 + return 0 + except Exception as e: + logger.error(f"Failed to delete task by task_id: {e}") + return 0 + finally: + db.close() diff --git a/backend/app/middlewares/__init__.py b/backend/app/middlewares/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/app/middlewares/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/middlewares/auth.py b/backend/app/middlewares/auth.py new file mode 100644 index 00000000..559e189f --- /dev/null +++ b/backend/app/middlewares/auth.py @@ -0,0 +1,40 @@ +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from app.services.auth import is_auth_enabled, is_request_authenticated + + +PUBLIC_PATHS = { + "/api/auth/status", + "/api/auth/login", + "/api/auth/logout", + "/api/sys_health", + "/api/sys_check", +} + + +class AuthMiddleware(BaseHTTPMiddleware): + """Optional single-user auth gate for self-hosted deployments. + + The middleware is intentionally disabled by default. When enabled it protects + both API routes and backend-served assets such as /static/screenshots so a + public deployment is not left writable/readable without a password. + """ + + async def dispatch(self, request: Request, call_next): + if request.method == "OPTIONS" or not is_auth_enabled(): + return await call_next(request) + + path = request.url.path + if path in PUBLIC_PATHS: + return await call_next(request) + + if is_request_authenticated(request): + return await call_next(request) + + return JSONResponse( + status_code=401, + content={"code": 401, "msg": "未登录或登录已过期", "data": None}, + ) + diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 00000000..d7b244e3 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Request +from pydantic import BaseModel + +from app.services.auth import ( + AUTH_COOKIE_NAME, + check_password, + create_access_token, + get_token_max_age_seconds, + is_auth_enabled, + is_request_authenticated, +) +from app.utils.response import ResponseWrapper as R + +router = APIRouter() + + +class LoginRequest(BaseModel): + password: str + + +@router.get("/auth/status") +def auth_status(request: Request): + enabled = is_auth_enabled() + return R.success(data={ + "enabled": enabled, + "authenticated": True if not enabled else is_request_authenticated(request), + }) + + +@router.post("/auth/login") +def login(data: LoginRequest): + if not is_auth_enabled(): + return R.success(data={"token": "", "enabled": False}) + + if not check_password(data.password): + return R.error(msg="访问密码不正确", code=401) + + token = create_access_token() + max_age = get_token_max_age_seconds() + res = R.success(data={"token": token, "enabled": True, "expires_in": max_age}) + res.set_cookie( + key=AUTH_COOKIE_NAME, + value=token, + max_age=max_age, + httponly=True, + samesite="lax", + ) + return res + + +@router.post("/auth/logout") +def logout(): + res = R.success() + res.delete_cookie(AUTH_COOKIE_NAME) + return res diff --git a/backend/app/routers/note.py b/backend/app/routers/note.py index 80033c87..5ef86d8d 100644 --- a/backend/app/routers/note.py +++ b/backend/app/routers/note.py @@ -8,7 +8,6 @@ from fastapi import APIRouter, HTTPException, BackgroundTasks, UploadFile, File from pydantic import BaseModel, validator, field_validator -from dataclasses import asdict from app.db.video_task_dao import get_task_by_video from app.enmus.exception import NoteErrorEnum @@ -23,6 +22,13 @@ from fastapi.responses import StreamingResponse import httpx from app.enmus.task_status_enums import TaskStatus +from app.db.video_task_dao import delete_task_by_task_id +from app.services.note_storage import ( + delete_note_artifacts, + list_notes, + persist_task_request, + save_note_result, +) # from app.services.downloader import download_raw_audio # from app.services.whisperer import transcribe_audio @@ -31,8 +37,9 @@ class RecordRequest(BaseModel): - video_id: str - platform: str + video_id: str = "" + platform: str = "" + task_id: Optional[str] = None class VideoRequest(BaseModel): @@ -72,10 +79,8 @@ def validate_supported_url(cls, v): UPLOAD_DIR = "uploads" -def save_note_to_file(task_id: str, note): - os.makedirs(NOTE_OUTPUT_DIR, exist_ok=True) - with open(os.path.join(NOTE_OUTPUT_DIR, f"{task_id}.json"), "w", encoding="utf-8") as f: - json.dump(asdict(note), f, ensure_ascii=False, indent=2) +def save_note_to_file(task_id: str, note, form_data: Optional[dict] = None): + save_note_result(task_id, note, form_data=form_data) def _persist_prefetched_transcript(task_id: str, transcript: dict) -> None: @@ -115,7 +120,7 @@ def _persist_prefetched_transcript(task_id: str, transcript: dict) -> None: def run_note_task(task_id: str, video_url: str, platform: str, quality: DownloadQuality, link: bool = False, screenshot: bool = False, model_name: str = None, provider_id: str = None, _format: list = None, style: str = None, extras: str = None, video_understanding: bool = False, - video_interval=0, grid_size=[] + video_interval=0, grid_size=[], form_data: Optional[dict] = None ): if not model_name or not provider_id: @@ -145,7 +150,7 @@ def _execute_note_task(): if not note or not note.markdown: logger.warning(f"任务 {task_id} 执行失败,跳过保存") return - save_note_to_file(task_id, note) + save_note_to_file(task_id, note, form_data=form_data) # 自动建立向量索引(用于 AI 问答),失败不影响笔记生成 try: @@ -158,13 +163,26 @@ def _execute_note_task(): @router.post('/delete_task') def delete_task(data: RecordRequest): try: - # TODO: 待持久化完成 - # NoteGenerator().delete_note(video_id=data.video_id, platform=data.platform) + if data.task_id: + delete_note_artifacts(data.task_id) + delete_task_by_task_id(data.task_id) + elif data.video_id and data.platform: + # 兼容旧前端:没有 task_id 时只删除 DB 中 video/platform 记录 + NoteGenerator().delete_note(video_id=data.video_id, platform=data.platform) return R.success(msg='删除成功') except Exception as e: return R.error(msg=e) +@router.get("/notes") +def get_notes(): + try: + return R.success(data=list_notes()) + except Exception as e: + logger.error(f"获取笔记列表失败: {e}", exc_info=True) + return R.error(msg=e) + + @router.post("/upload") async def upload(file: UploadFile = File(...)): os.makedirs(UPLOAD_DIR, exist_ok=True) @@ -216,6 +234,12 @@ def generate_note(data: VideoRequest, background_tasks: BackgroundTasks): # 正常新建任务 task_id = str(uuid.uuid4()) + request_meta = data.model_dump(mode="json", exclude={"prefetched_transcript"}) + try: + persist_task_request(task_id, request_meta, video_id=video_id) + except Exception as e: + logger.warning(f"写入任务请求元数据失败 (task_id={task_id}): {e}") + # 统一先写入 PENDING,表示已进入队列等待串行执行 NoteGenerator()._update_status(task_id, TaskStatus.PENDING) @@ -228,7 +252,8 @@ def generate_note(data: VideoRequest, background_tasks: BackgroundTasks): background_tasks.add_task(run_note_task, task_id, data.video_url, data.platform, data.quality, data.link, data.screenshot, data.model_name, data.provider_id, data.format, data.style, - data.extras, data.video_understanding, data.video_interval, data.grid_size) + data.extras, data.video_understanding, data.video_interval, data.grid_size, + request_meta) return R.success({"task_id": task_id}) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 00000000..499ed5e5 --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,116 @@ +import base64 +import hashlib +import hmac +import json +import os +import time +from typing import Any, Optional + +from fastapi import Request + + +AUTH_COOKIE_NAME = "bilinote_auth" +AUTH_SUBJECT = "self-host" + + +def _truthy(value: str | None) -> bool: + return str(value or "").strip().lower() in {"1", "true", "yes", "on"} + + +def is_auth_enabled() -> bool: + """Whether the optional self-host single-user auth gate is enabled.""" + return _truthy(os.getenv("BILINOTE_AUTH_ENABLED")) + + +def get_auth_password() -> str: + return os.getenv("BILINOTE_AUTH_PASSWORD", "") + + +def get_token_max_age_seconds() -> int: + days = int(os.getenv("BILINOTE_AUTH_TOKEN_EXPIRE_DAYS", "30") or "30") + return max(days, 1) * 24 * 60 * 60 + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(data: str) -> bytes: + padding = "=" * (-len(data) % 4) + return base64.urlsafe_b64decode((data + padding).encode("ascii")) + + +def _get_secret() -> bytes: + """Return the HMAC secret. + + A dedicated BILINOTE_AUTH_SECRET keeps tokens valid when the password is + rotated. If it is not configured we derive a deterministic secret from the + self-host password, which is sufficient for the single-user deployment gate. + """ + configured = os.getenv("BILINOTE_AUTH_SECRET") + if configured: + return configured.encode("utf-8") + password = get_auth_password() + return hashlib.sha256(f"bilinote-auth:{password}".encode("utf-8")).digest() + + +def check_password(password: str) -> bool: + expected = get_auth_password() + if not expected: + return False + return hmac.compare_digest(password or "", expected) + + +def create_access_token() -> str: + now = int(time.time()) + payload = { + "sub": AUTH_SUBJECT, + "iat": now, + "exp": now + get_token_max_age_seconds(), + } + body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + sig = hmac.new(_get_secret(), body.encode("ascii"), hashlib.sha256).digest() + return f"{body}.{_b64url_encode(sig)}" + + +def verify_access_token(token: str | None) -> Optional[dict[str, Any]]: + if not token or "." not in token: + return None + try: + body, sig = token.split(".", 1) + expected = hmac.new(_get_secret(), body.encode("ascii"), hashlib.sha256).digest() + provided = _b64url_decode(sig) + if not hmac.compare_digest(provided, expected): + return None + payload = json.loads(_b64url_decode(body).decode("utf-8")) + if payload.get("sub") != AUTH_SUBJECT: + return None + if int(payload.get("exp", 0)) < int(time.time()): + return None + return payload + except Exception: + return None + + +def extract_token(request: Request) -> Optional[str]: + auth = request.headers.get("authorization") or request.headers.get("Authorization") + if auth and auth.lower().startswith("bearer "): + return auth.split(" ", 1)[1].strip() + + cookie_token = request.cookies.get(AUTH_COOKIE_NAME) + if cookie_token: + return cookie_token + + # Used by extension-rendered images where cannot attach an + # Authorization header. Keep it optional and only accept signed tokens. + query_token = request.query_params.get("access_token") + if query_token: + return query_token + return None + + +def is_request_authenticated(request: Request) -> bool: + if not is_auth_enabled(): + return True + return verify_access_token(extract_token(request)) is not None + diff --git a/backend/app/services/note_storage.py b/backend/app/services/note_storage.py new file mode 100644 index 00000000..949c1773 --- /dev/null +++ b/backend/app/services/note_storage.py @@ -0,0 +1,212 @@ +import json +import os +import re +from dataclasses import asdict, is_dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from app.services.vector_store import VectorStoreManager +from app.utils.logger import get_logger + +logger = get_logger(__name__) + +NOTE_OUTPUT_DIR = Path(os.getenv("NOTE_OUTPUT_DIR", "note_results")) + +TASK_ID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) +TASK_ARTIFACT_ID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + r"(?:_(?:audio|markdown|request|transcript))?$" +) +TASK_ARTIFACT_SUFFIXES = ("_audio", "_markdown", "_request", "_transcript") + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _read_json(path: Path) -> dict[str, Any] | None: + try: + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + except Exception as e: + logger.warning(f"读取 JSON 失败: {path}, {e}") + return None + + +def _write_json(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(path) + + +def _base_task_id_from_artifact(stem: str) -> str | None: + if TASK_ID_RE.match(stem): + return stem + + for suffix in TASK_ARTIFACT_SUFFIXES: + if stem.endswith(suffix): + candidate = stem[:-len(suffix)] + if TASK_ID_RE.match(candidate): + return candidate + + return None + + +def _jsonable(value: Any) -> Any: + if is_dataclass(value): + return asdict(value) + if isinstance(value, dict): + return {k: _jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(v) for v in value] + return value + + +def persist_task_request(task_id: str, form_data: dict[str, Any], video_id: str | None = None) -> None: + """Persist request metadata so pending tasks and generated notes can sync to other devices.""" + payload = { + "task_id": task_id, + "video_id": video_id, + "platform": form_data.get("platform"), + "form_data": form_data, + "created_at": _now_iso(), + "updated_at": _now_iso(), + } + _write_json(NOTE_OUTPUT_DIR / f"{task_id}_request.json", payload) + + +def save_note_result(task_id: str, note: Any, form_data: dict[str, Any] | None = None) -> dict[str, Any]: + request_meta = _read_json(NOTE_OUTPUT_DIR / f"{task_id}_request.json") or {} + note_payload = _jsonable(note) + payload = { + **note_payload, + "task_id": task_id, + "created_at": request_meta.get("created_at") or _now_iso(), + "updated_at": _now_iso(), + } + if form_data or request_meta.get("form_data"): + payload["form_data"] = form_data or request_meta.get("form_data") + + _write_json(NOTE_OUTPUT_DIR / f"{task_id}.json", payload) + return payload + + +def _infer_form_data(task_id: str, payload: dict[str, Any]) -> dict[str, Any]: + request_meta = _read_json(NOTE_OUTPUT_DIR / f"{task_id}_request.json") or {} + if isinstance(payload.get("form_data"), dict): + return payload["form_data"] + if isinstance(request_meta.get("form_data"), dict): + return request_meta["form_data"] + + audio_meta = payload.get("audio_meta") or {} + raw_info = audio_meta.get("raw_info") or {} + return { + "video_url": raw_info.get("webpage_url") or raw_info.get("original_url") or "", + "platform": audio_meta.get("platform") or request_meta.get("platform") or "", + "quality": "medium", + "model_name": "", + "provider_id": "", + "style": "minimal", + "format": [], + "screenshot": False, + "link": False, + "extras": "", + "video_understanding": False, + "video_interval": 6, + "grid_size": [2, 2], + } + + +def _status_for_task(task_id: str, has_result: bool) -> tuple[str, str]: + status = _read_json(NOTE_OUTPUT_DIR / f"{task_id}.status.json") + if status: + return status.get("status") or ("SUCCESS" if has_result else "PENDING"), status.get("message") or "" + return ("SUCCESS" if has_result else "PENDING"), "" + + +def _audio_meta_for_task(task_id: str, payload: dict[str, Any]) -> dict[str, Any]: + audio_meta = payload.get("audio_meta") + if isinstance(audio_meta, dict): + return audio_meta + audio = _read_json(NOTE_OUTPUT_DIR / f"{task_id}_audio.json") or {} + return audio if isinstance(audio, dict) else {} + + +def list_notes() -> list[dict[str, Any]]: + NOTE_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + task_ids: set[str] = set() + for path in NOTE_OUTPUT_DIR.glob("*.json"): + name = path.name + if name.endswith(".status.json"): + # 旧逻辑会把 _markdown.status.json 误当成一个真实任务, + # 导致网页端出现删不掉的“等待中”伪任务。这里只同步真实 task_id。 + task_id = _base_task_id_from_artifact(name[:-len(".status.json")]) + if task_id and TASK_ID_RE.match(task_id): + task_ids.add(task_id) + continue + stem = path.stem + task_id = _base_task_id_from_artifact(stem) + if task_id: + task_ids.add(task_id) + + notes = [] + for task_id in task_ids: + result_path = NOTE_OUTPUT_DIR / f"{task_id}.json" + payload = _read_json(result_path) or {} + has_result = result_path.exists() and bool(payload) + status, message = _status_for_task(task_id, has_result) + request_meta = _read_json(NOTE_OUTPUT_DIR / f"{task_id}_request.json") or {} + + mtime = result_path.stat().st_mtime if result_path.exists() else ( + (NOTE_OUTPUT_DIR / f"{task_id}.status.json").stat().st_mtime + if (NOTE_OUTPUT_DIR / f"{task_id}.status.json").exists() + else 0 + ) + updated_at = payload.get("updated_at") or request_meta.get("updated_at") or ( + datetime.fromtimestamp(mtime, timezone.utc).isoformat() if mtime else _now_iso() + ) + created_at = payload.get("created_at") or request_meta.get("created_at") or updated_at + + notes.append({ + "task_id": task_id, + "id": task_id, + "status": status, + "message": message, + "created_at": created_at, + "updated_at": updated_at, + "markdown": payload.get("markdown") or "", + "transcript": payload.get("transcript") or {"full_text": "", "language": "", "raw": None, "segments": []}, + "audio_meta": _audio_meta_for_task(task_id, payload), + "form_data": _infer_form_data(task_id, payload), + }) + + return sorted(notes, key=lambda item: item.get("updated_at") or "", reverse=True) + + +def delete_note_artifacts(task_id: str) -> int: + """Delete persisted note artifacts for one task. Returns deleted file count.""" + deleted = 0 + if not TASK_ARTIFACT_ID_RE.match(task_id or ""): + return 0 + + for path in NOTE_OUTPUT_DIR.glob(f"{task_id}*"): + if path.is_file(): + try: + path.unlink() + deleted += 1 + except Exception as e: + logger.warning(f"删除笔记文件失败: {path}, {e}") + + try: + VectorStoreManager().delete_index(task_id) + except Exception as e: + logger.warning(f"删除向量索引失败: {task_id}, {e}") + + return deleted + diff --git a/backend/main.py b/backend/main.py index 9f21a64f..7cc7fa90 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,7 @@ from app.services.transcriber_config_manager import TranscriberConfigManager from events import register_handler from ffmpeg_helper import ensure_ffmpeg_or_raise +from app.middlewares.auth import AuthMiddleware logger = get_logger(__name__) load_dotenv() @@ -69,6 +70,10 @@ async def lifespan(app: FastAPI): app = create_app(lifespan=lifespan) +# Optional self-host auth must be added before CORS so CORS can still decorate +# 401 responses returned by the auth gate. +app.add_middleware(AuthMiddleware) + # 允许的源:本地 web 端 + Tauri 桌面端 + 浏览器扩展(chrome/edge/firefox) # 用 regex 是因为 chrome-extension:// 的 id 在每次开发版加载时不固定 # Tauri 2 不同平台 webview origin 不一样,必须全列: @@ -109,4 +114,4 @@ async def lifespan(app: FastAPI): port = int(os.getenv("BACKEND_PORT", 8483)) host = os.getenv("BACKEND_HOST", "0.0.0.0") logger.info(f"Starting server on {host}:{port}") - uvicorn.run(app, host=host, port=port, reload=False) \ No newline at end of file + uvicorn.run(app, host=host, port=port, reload=False)