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/logic/api.ts b/BillNote_extension/src/logic/api.ts index 9f935ea2..da1943b0 100644 --- a/BillNote_extension/src/logic/api.ts +++ b/BillNote_extension/src/logic/api.ts @@ -5,12 +5,14 @@ import type { Provider, ProviderCreatePayload, ProviderUpdatePayload, + ServerNote, TaskStatusResponse, TranscriberConfig, TranscriberModelsStatus, TranscriberType, WhisperModelSize, } from './types' +import { DEFAULT_BACKEND_URL } from './constants' import { settings } from './storage' interface ApiEnvelope { @@ -20,12 +22,27 @@ interface ApiEnvelope { } function backendUrl(): string { - return (settings.value?.backendUrl || 'http://localhost:8483').replace(/\/$/, '') + const raw = (settings.value?.backendUrl || DEFAULT_BACKEND_URL).trim() + let url = raw || DEFAULT_BACKEND_URL + + // Allow users to type shorthand values in the extension options: + // 3015 -> http://localhost:3015 + // localhost:3015 -> http://localhost:3015 + // http://host:3015/api -> http://host:3015 + if (/^\d+$/.test(url)) + url = `http://localhost:${url}` + else if (!/^https?:\/\//i.test(url)) + url = `http://${url}` + + url = url.replace(/\/+$/, '') + if (url.endsWith('/api')) + url = url.slice(0, -4) + return url } async function request(path: string, init?: RequestInit): Promise { const res = await fetch(`${backendUrl()}${path}`, { - headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, + headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(init?.headers || {}) }, ...init, }) if (!res.ok) @@ -41,6 +58,35 @@ async function request(path: string, init?: RequestInit): Promise { return body as T } +function authHeaders(): Record { + const token = settings.value?.authToken + return token ? { Authorization: `Bearer ${token}` } : {} +} + +function withAccessToken(url: string): string { + const token = settings.value?.authToken + if (!token) + return url + const sep = url.includes('?') ? '&' : '?' + return `${url}${sep}access_token=${encodeURIComponent(token)}` +} + +export async function getAuthStatus(): Promise<{ enabled: boolean, authenticated: boolean }> { + return request('/api/auth/status') +} + +export async function login(password: string): Promise { + const data = await request<{ token: string }>('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ password }), + }) + settings.value.authToken = data.token || '' +} + +export async function listNotes(): Promise { + return request('/api/notes') +} + export async function getProviders(): Promise { return request('/api/get_all_providers') } @@ -189,7 +235,9 @@ export async function getTaskStatus(taskId: string): Promise // 成功:{code:0, data:{status, message, task_id, result?}} // 任务失败:{code:500, msg:'xxx', data:null} // 这里手动拆,把任务失败翻译成 status:'FAILED',避免 request() 抛错让 UI 收不到状态 - const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`) + const res = await fetch(`${backendUrl()}/api/task_status/${taskId}`, { + headers: authHeaders(), + }) if (!res.ok) throw new Error(`HTTP ${res.status}`) const body = (await res.json()) as { code: number, msg: string, data: TaskStatusResponse | null } @@ -200,7 +248,7 @@ export async function getTaskStatus(taskId: string): Promise export async function ping(): Promise { try { - await getProviders() + await getAuthStatus() return true } catch { @@ -211,7 +259,10 @@ export async function ping(): Promise { // markdown 里的 /static/screenshots/xxx 是相对路径,extension 渲染时需要拼绝对地址 export function absolutizeMarkdownImages(md: string): string { const base = backendUrl() - return md.replace(/!\[([^\]]*)\]\((\/static\/[^)]+)\)/g, (_, alt, path) => `![${alt}](${base}${path})`) + return md.replace( + /!\[([^\]]*)\]\((\/static\/[^)]+)\)/g, + (_, alt, path) => `![${alt}](${withAccessToken(`${base}${path}`)})`, + ) } // backend 用 note_helper 在笔记开头插一行 '> 来源链接:URL'。侧边栏顶部已经有原片链接卡片, @@ -227,9 +278,30 @@ export function resolveImageUrl(url: string | undefined | null): string { return '' const base = backendUrl() if (url.startsWith('/')) - return `${base}${url}` + return withAccessToken(`${base}${url}`) // B 站封面、抖音封面等会做 referer 校验;走后端代理 if (/(hdslb|byteimg|kpcdn|akamaized|ytimg)\.com/i.test(url)) - return `${base}/api/image_proxy?url=${encodeURIComponent(url)}` + return withAccessToken(`${base}/api/image_proxy?url=${encodeURIComponent(url)}`) return url } + +export function serverNoteToTask(note: ServerNote): import('./types').TaskRecord { + const formData = note.form_data || {} + return { + taskId: note.task_id, + videoUrl: formData.video_url || '', + platform: (formData.platform || note.audio_meta?.platform || 'bilibili') as import('./types').Platform, + status: note.status, + message: note.message || '', + createdAt: new Date(note.created_at || Date.now()).getTime(), + updatedAt: new Date(note.updated_at || note.created_at || Date.now()).getTime(), + result: note.markdown + ? { + markdown: note.markdown, + transcript: note.transcript, + audio_meta: note.audio_meta, + } + : undefined, + title: note.audio_meta?.title, + } +} diff --git a/BillNote_extension/src/logic/constants.ts b/BillNote_extension/src/logic/constants.ts index e2dac9df..bb81c871 100644 --- a/BillNote_extension/src/logic/constants.ts +++ b/BillNote_extension/src/logic/constants.ts @@ -1,9 +1,10 @@ import type { Settings } from './types' -export const DEFAULT_BACKEND_URL = 'http://localhost:8483' +export const DEFAULT_BACKEND_URL = 'http://localhost:3015' export const DEFAULT_SETTINGS: Settings = { backendUrl: DEFAULT_BACKEND_URL, + authToken: '', providerId: '', modelName: '', quality: 'medium', diff --git a/BillNote_extension/src/logic/types.ts b/BillNote_extension/src/logic/types.ts index b729ae8a..60dd663a 100644 --- a/BillNote_extension/src/logic/types.ts +++ b/BillNote_extension/src/logic/types.ts @@ -83,6 +83,19 @@ export interface TaskRecord { title?: string } +export interface ServerNote { + id: string + task_id: string + status: TaskStatus + message?: string + created_at: string + updated_at: string + markdown: string + transcript?: unknown + audio_meta?: NoteResult['audio_meta'] + form_data?: Partial +} + // 与 backend/app/gpt/prompt_builder.py note_styles 一一对齐 export type NoteStyle = | 'minimal' | 'detailed' | 'academic' | 'tutorial' @@ -113,6 +126,7 @@ export const NOTE_FORMATS: Array<{ value: NoteFormat, label: string }> = [ export interface Settings { backendUrl: string + authToken: string providerId: string modelName: string quality: Quality diff --git a/BillNote_extension/src/options/pages/General.vue b/BillNote_extension/src/options/pages/General.vue index aa28c501..eee30949 100644 --- a/BillNote_extension/src/options/pages/General.vue +++ b/BillNote_extension/src/options/pages/General.vue @@ -1,6 +1,6 @@