From c4132be9e864c4a6c6f6f2f6e3ad8b40b4f63053 Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 00:42:02 +0800 Subject: [PATCH 01/14] try add feishu --- .gitignore | 1 + docs/cf_woker.js | 127 ++++++++++ docs/feishu-oauth-guide.md | 218 ++++++++++++++++++ src/common/backend/services/feishu/form.tsx | 115 +++++++++ src/common/backend/services/feishu/index.ts | 21 ++ .../backend/services/feishu/interface.ts | 36 +++ src/common/backend/services/feishu/service.ts | 182 +++++++++++++++ src/common/locales/data/en-US.json | 5 + src/common/locales/data/zh-CN.json | 21 +- 9 files changed, 717 insertions(+), 9 deletions(-) create mode 100644 docs/cf_woker.js create mode 100644 docs/feishu-oauth-guide.md create mode 100644 src/common/backend/services/feishu/form.tsx create mode 100644 src/common/backend/services/feishu/index.ts create mode 100644 src/common/backend/services/feishu/interface.ts create mode 100644 src/common/backend/services/feishu/service.ts diff --git a/.gitignore b/.gitignore index 245238dd..94811fdc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ webclipper.zip .now release +package-lock.json diff --git a/docs/cf_woker.js b/docs/cf_woker.js new file mode 100644 index 00000000..a0c35510 --- /dev/null +++ b/docs/cf_woker.js @@ -0,0 +1,127 @@ +/** + * Cloudflare Worker for Feishu OAuth Relay + * + * Environment Variables required: + * - APP_ID + * - APP_SECRET + */ + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const pathname = url.pathname; + + // 1. Redirect to Feishu Login + if (pathname === '/login') { + const redirectUri = `${url.origin}/callback`; + // Request permissions for Drive and Docx + const scope = 'drive:drive docx:document:user_group_read_write'; + const feishuAuthUrl = `https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=${env.APP_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`; + return Response.redirect(feishuAuthUrl, 302); + } + + // 2. Callback: Exchange Code for Token + if (pathname === '/callback') { + const code = url.searchParams.get('code'); + if (!code) return new Response('Missing code', { status: 400 }); + + try { + // Step A: Get App Access Token (Internal) + const appTokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_id: env.APP_ID, app_secret: env.APP_SECRET }) + }); + const appTokenData = await appTokenRes.json(); + if (appTokenData.code !== 0) throw new Error('Failed to get app token: ' + appTokenData.msg); + const appAccessToken = appTokenData.app_access_token; + + // Step B: Get User Access Token + const userTokenRes = await fetch('https://open.feishu.cn/open-apis/authen/v1/oidc/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${appAccessToken}` + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code: code + }) + }); + + const userTokenData = await userTokenRes.json(); + if (userTokenData.code && userTokenData.code !== 0) throw new Error(userTokenData.msg || 'Auth Failed'); + + // Display Token to User (JSON) + return new Response(JSON.stringify(userTokenData.data, null, 2), { + headers: { 'Content-Type': 'application/json' } + }); + + } catch (e) { + return new Response('Error: ' + e.message, { status: 500 }); + } + } + + // 3. Refresh Token + if (pathname === '/refresh') { + if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); + const { refresh_token } = await request.json(); + + if (!refresh_token) return new Response('Missing refresh_token', { status: 400 }); + + try { + // Step A: Get App Access Token + const appTokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_id: env.APP_ID, app_secret: env.APP_SECRET }) + }); + const appTokenData = await appTokenRes.json(); + const appAccessToken = appTokenData.app_access_token; + + // Step B: Refresh User Token + const refreshRes = await fetch('https://open.feishu.cn/open-apis/authen/v1/oidc/refresh_access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${appAccessToken}` + }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refresh_token + }) + }); + + const refreshData = await refreshRes.json(); + + // Add CORS headers so plugin can call this + return new Response(JSON.stringify(refreshData.data), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST' + } + }); + + } catch (e) { + return new Response(JSON.stringify({ error: e.message }), { + status: 500, + headers: { 'Access-Control-Allow-Origin': '*' } + }); + } + } + + // Handle OPTIONS for CORS + if (request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'Content-Type' + } + }); + } + + return new Response('Feishu OAuth Relay Worker is Running!'); + } +}; diff --git a/docs/feishu-oauth-guide.md b/docs/feishu-oauth-guide.md new file mode 100644 index 00000000..eeaf8794 --- /dev/null +++ b/docs/feishu-oauth-guide.md @@ -0,0 +1,218 @@ +# 飞书剪藏插件后端 (Cloudflare Worker) 部署指南 + +本指南将帮助你部署一个基于 Cloudflare Workers 的轻量级后端服务,用于处理飞书 (Feishu/Lark) 的 OAuth 2.0 授权流程。配合 Web Clipper 插件,你可以将网页内容直接保存到自己的飞书个人空间。 + +## 为什么需要这个 Worker? + +飞书的开放平台要求 OAuth 授权必须通过服务器端交换 Token(出于安全考虑,Client Secret 不能暴露在前端)。 +Cloudflare Workers 提供了一个免费、高性能且无需维护服务器的解决方案,非常适合个人用户托管此类鉴权服务。 + +## 准备工作 + +1. 一个 [Cloudflare](https://www.cloudflare.com/) 账号(免费版即可)。 +2. 一个 [飞书](https://www.feishu.cn/) 账号(需注册一个个人组织,免费)。 + +--- + +## 第一步:创建飞书应用 + +1. 登录 [飞书开放平台](https://open.feishu.cn/app)。 +2. 点击 **"创建企业自建应用"**。 +3. 填写应用名称(如 "Web Clipper")和描述,点击创建。 +4. 在应用详情页,左侧菜单选择 **"凭证与基础信息"**。 + * 记录下 **App ID** 和 **App Secret**,稍后会用到。 +5. 左侧菜单选择 **"开发配置" -> "安全设置"**。 + * 在 **"重定向 URL"** 中,添加你的 Worker URL(格式为 `https://<你的Worker名>.<你的子域名>.workers.dev/callback`)。 + * *注意:如果你还没部署 Worker,可以先跳过这一步,等部署完拿到 URL 后再回来填。* +6. 左侧菜单选择 **"权限管理"**。 + * 切换到 **"应用身份"** 标签页(其实这里主要是为了开通 API 能力,User Token 的权限通常是动态请求的,但建议预先配置)。 + * 搜索并开通以下权限: + * `docx:document:user_group_read_write` (查看、评论、编辑和管理云空间所有文件) + * `drive:drive:readonly` (查看云空间目录) + * `drive:drive` (查看、评论、编辑和管理云空间所有文件) +7. 左侧菜单选择 **"版本管理与发布"**。 + * 点击 **"创建版本"**。 + * 在 **"可用范围"** 中选择 **"所有员工"**。 + * 点击 **"保存并发布"**。 + +--- + +## 第二步:部署 Cloudflare Worker + +1. 登录 Cloudflare Dashboard,进入 **"Workers & Pages"**。 +2. 点击 **"Create Application"** -> **"Create Worker"**。 +3. 给 Worker 起个名字(例如 `feishu-oauth-relay`),点击 **"Deploy"**。 +4. 部署成功后,点击 **"Edit code"**。 +5. 将以下代码完整复制粘贴到编辑器中(覆盖原有代码): + +```javascript +/** + * Cloudflare Worker for Feishu OAuth Relay + * + * Environment Variables required: + * - APP_ID + * - APP_SECRET + */ + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const pathname = url.pathname; + + // 1. Redirect to Feishu Login + if (pathname === '/login') { + const redirectUri = `${url.origin}/callback`; + // Request permissions for Drive and Docx + const scope = 'drive:drive docx:document:user_group_read_write'; + const feishuAuthUrl = `https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=${env.APP_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`; + return Response.redirect(feishuAuthUrl, 302); + } + + // 2. Callback: Exchange Code for Token + if (pathname === '/callback') { + const code = url.searchParams.get('code'); + if (!code) return new Response('Missing code', { status: 400 }); + + try { + // Step A: Get App Access Token (Internal) + const appTokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_id: env.APP_ID, app_secret: env.APP_SECRET }) + }); + const appTokenData = await appTokenRes.json(); + if (appTokenData.code !== 0) throw new Error('Failed to get app token: ' + appTokenData.msg); + const appAccessToken = appTokenData.app_access_token; + + // Step B: Get User Access Token + const userTokenRes = await fetch('https://open.feishu.cn/open-apis/authen/v1/oidc/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${appAccessToken}` + }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code: code + }) + }); + + const userTokenData = await userTokenRes.json(); + if (userTokenData.code && userTokenData.code !== 0) throw new Error(userTokenData.msg || 'Auth Failed'); + + // Display Token to User (JSON) + return new Response(JSON.stringify(userTokenData.data, null, 2), { + headers: { 'Content-Type': 'application/json' } + }); + + } catch (e) { + return new Response('Error: ' + e.message, { status: 500 }); + } + } + + // 3. Refresh Token + if (pathname === '/refresh') { + if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); + const { refresh_token } = await request.json(); + + if (!refresh_token) return new Response('Missing refresh_token', { status: 400 }); + + try { + // Step A: Get App Access Token + const appTokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ app_id: env.APP_ID, app_secret: env.APP_SECRET }) + }); + const appTokenData = await appTokenRes.json(); + const appAccessToken = appTokenData.app_access_token; + + // Step B: Refresh User Token + const refreshRes = await fetch('https://open.feishu.cn/open-apis/authen/v1/oidc/refresh_access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${appAccessToken}` + }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refresh_token + }) + }); + + const refreshData = await refreshRes.json(); + + // Add CORS headers so plugin can call this + return new Response(JSON.stringify(refreshData.data), { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST' + } + }); + + } catch (e) { + return new Response(JSON.stringify({ error: e.message }), { + status: 500, + headers: { 'Access-Control-Allow-Origin': '*' } + }); + } + } + + // Handle OPTIONS for CORS + if (request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'Content-Type' + } + }); + } + + return new Response('Feishu OAuth Relay Worker is Running!'); + } +}; +``` + +6. 点击右上角的 **"Save and deploy"**。 + +--- + +## 第三步:配置环境变量 + +1. 在 Worker 编辑页面,点击左上角的 Worker 名字返回 Worker 详情页。 +2. 点击 **"Settings"** 标签页。 +3. 点击 **"Variables"**。 +4. 在 **"Environment Variables"** 部分,点击 **"Add variable"**,添加以下两个变量: + * `APP_ID`: (填入你在第一步获取的飞书 App ID) + * `APP_SECRET`: (填入你在第一步获取的飞书 App Secret) +5. 点击 **"Save and deploy"**。 + +--- + +## 第四步:完成飞书配置 + +1. 回到 Worker 详情页,找到你的 Worker URL(例如 `https://feishu-oauth-relay.yourname.workers.dev`)。 +2. 回到 [飞书开放平台](https://open.feishu.cn/app) 的应用配置页面。 +3. 进入 **"安全设置"** -> **"重定向 URL"**。 +4. 点击 **"添加重定向 URL"**,填入 `/callback`。 + * 例如:`https://feishu-oauth-relay.yourname.workers.dev/callback` +5. 点击 **"保存"**。 + +--- + +## 第五步:在 Web Clipper 插件中使用 + +1. 打开 Web Clipper 插件设置页。 +2. 选择 **"账户"** -> **"添加账户"** -> 选择 **"飞书"**。 +3. 在 **"Worker URL"** 中填入你的 Worker 链接(例如 `https://feishu-oauth-relay.yourname.workers.dev`)。 +4. 点击 **"登录飞书"** 按钮。 +5. 在弹出的窗口中完成飞书授权。 +6. 授权成功后,页面会显示一段 JSON 代码(包含 `access_token` 等信息)。 +7. **复制整段 JSON 代码**。 +8. 回到插件设置页,将 JSON 粘贴到 **"Token JSON"** 输入框中。 +9. 点击保存,完成配置! + +现在,插件将自动使用这个 Token 访问你的飞书空间,并在 Token 过期时自动通过 Worker 进行刷新。 + diff --git a/src/common/backend/services/feishu/form.tsx b/src/common/backend/services/feishu/form.tsx new file mode 100644 index 00000000..9d38b481 --- /dev/null +++ b/src/common/backend/services/feishu/form.tsx @@ -0,0 +1,115 @@ +import { Form } from '@ant-design/compatible'; +import '@ant-design/compatible/assets/index.less'; +import { Input, Button, Alert } from 'antd'; +import { FormComponentProps } from '@ant-design/compatible/lib/form'; +import React, { Component, Fragment } from 'react'; +import { FeishuBackendServiceConfig } from './interface'; +import { FormattedMessage } from 'react-intl'; + +interface FeishuFormProps { + verified?: boolean; + info?: FeishuBackendServiceConfig; +} + +export default class FeishuForm extends Component { + handleLogin = () => { + const workerUrl = this.props.form.getFieldValue('workerUrl'); + if (workerUrl) { + window.open(`${workerUrl.replace(/\/$/, '')}/login`, '_blank'); + } + }; + + render() { + const { + form: { getFieldDecorator, getFieldValue }, + info, + verified, + } = this.props; + + let initData: Partial = {}; + if (info) { + initData = info; + } + let editMode = info ? true : false; + const workerUrl = getFieldValue('workerUrl') || initData.workerUrl; + + return ( + + + + } + type="info" + /> + + + {getFieldDecorator('workerUrl', { + initialValue: initData.workerUrl, + rules: [ + { + required: true, + message: 'Worker URL is required!', + }, + { + type: 'url', + message: 'Invalid URL format', + } + ], + })()} + + + + + + {getFieldDecorator('accessToken', { + // We use a hidden logic or simple text area to paste the JSON? + // Actually, usually user copies the whole JSON. Let's provide a TextArea. + // But the service expects separated fields. + // Let's make a smart input that parses JSON on change? + // Or just simple AccessToken input if user parses it? + // JSON paste is better. + initialValue: initData.accessToken ? '********' : '', + rules: [{ required: true, message: 'Token is required' }] + })( + { + try { + const data = JSON.parse(e.target.value); + if (data.access_token && data.refresh_token) { + // Determine expiration time (current time + expires_in - buffer) + const expiresAt = Math.floor(Date.now() / 1000) + (data.expires_in || 7200); + + // Set fields silently + this.props.form.setFieldsValue({ + 'accessToken': data.access_token, + 'refreshToken': data.refresh_token, + 'expiresAt': expiresAt + }); + } + } catch (err) { + // Ignore parse error, maybe user is typing manual token + } + }} + /> + )} + + {/* Hidden fields to store parsed values */} + + {getFieldDecorator('refreshToken', { initialValue: initData.refreshToken })()} + + + {getFieldDecorator('expiresAt', { initialValue: initData.expiresAt })()} + + + ); + } +} + diff --git a/src/common/backend/services/feishu/index.ts b/src/common/backend/services/feishu/index.ts new file mode 100644 index 00000000..661432a8 --- /dev/null +++ b/src/common/backend/services/feishu/index.ts @@ -0,0 +1,21 @@ +import Service from './service'; +import Form from './form'; +import localeService from '@/common/locales'; + +export default () => { + return { + name: localeService.format({ + id: 'backend.services.feishu.name', + defaultMessage: 'Feishu (OAuth)', + }), + icon: 'feishu', + type: 'feishu', + service: Service, + form: Form, + homePage: 'https://www.feishu.cn/drive/home/', + permission: { + origins: ['https://open.feishu.cn/*', 'https://*.workers.dev/*'], + }, + }; +}; + diff --git a/src/common/backend/services/feishu/interface.ts b/src/common/backend/services/feishu/interface.ts new file mode 100644 index 00000000..e2372b54 --- /dev/null +++ b/src/common/backend/services/feishu/interface.ts @@ -0,0 +1,36 @@ +export interface FeishuBackendServiceConfig { + workerUrl: string; + accessToken?: string; + refreshToken?: string; + expiresAt?: number; // timestamp in seconds +} + +export interface FeishuUserInfoResponse { + code: number; + msg: string; + data: { + avatar_url: string; + name: string; + open_id: string; + union_id: string; + }; +} + +export interface FeishuCreateDocumentRequest { + title: string; + content: string; + repositoryId: string; +} + +export interface FeishuCompleteStatus { + href: string; + repositoryId: string; + documentId: string; +} + +export interface FeishuTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; +} + diff --git a/src/common/backend/services/feishu/service.ts b/src/common/backend/services/feishu/service.ts new file mode 100644 index 00000000..d5e4f2a6 --- /dev/null +++ b/src/common/backend/services/feishu/service.ts @@ -0,0 +1,182 @@ +import { IBasicRequestService } from '@/service/common/request'; +import { Container } from 'typedi'; +import { RequestHelper } from '@/service/request/common/request'; +import { DocumentService } from './../../index'; +import { + FeishuBackendServiceConfig, + FeishuUserInfoResponse, + FeishuCompleteStatus, + FeishuCreateDocumentRequest, + FeishuTokenResponse, +} from './interface'; +import md5 from '@web-clipper/shared/lib/md5'; +import localeService from '@/common/locales'; + +const OPEN_API = 'https://open.feishu.cn'; + +export default class FeishuDocumentService implements DocumentService { + private request: RequestHelper; + private userInfo?: any; + private config: FeishuBackendServiceConfig; + + constructor(config: FeishuBackendServiceConfig) { + this.config = config; + this.request = new RequestHelper({ + baseURL: OPEN_API, + headers: { + 'Content-Type': 'application/json', + }, + request: Container.get(IBasicRequestService), + interceptors: { + response: (response: any) => { + if (response.code !== 0) { + throw new Error(response.msg || 'Feishu API Error'); + } + return response.data; + }, + }, + }); + } + + getId = () => md5(this.config.workerUrl); + + refreshToken = async (info: FeishuBackendServiceConfig): Promise => { + if (!info.refreshToken || !info.workerUrl) { + throw new Error('Missing refresh token or worker url'); + } + const workerRefreshUrl = `${info.workerUrl.replace(/\/$/, '')}/refresh`; + + const response = await fetch(workerRefreshUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: info.refreshToken }) + }); + + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + + return { + ...info, + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Math.floor(Date.now() / 1000) + (data.expires_in || 7200) + }; + } + + private getAccessToken = async () => { + // Check expiration + if (this.config.expiresAt && Date.now() / 1000 > this.config.expiresAt - 300) { + // Token expired or about to expire (5 mins buffer) + try { + const newConfig = await this.refreshToken(this.config); + this.config = newConfig; + // Note: In a real app, we should persist this new config back to storage. + // But DocumentService interface doesn't easily support saving back config unless called by upper layer. + // However, typical usage is: upper layer checks if `refreshToken` method exists, call it, and save result. + // Current Clipper architecture might not auto-save refreshed token easily. + // We'll rely on in-memory update for this session. + // If the architecture supports `refreshToken` hook (interface line 81), it will work. + } catch (e) { + console.error('Refresh token failed', e); + throw new Error('Session expired, please login again via Worker.'); + } + } + return this.config.accessToken; + }; + + private requestWithToken = async (url: string, method: 'GET' | 'POST', data?: any) => { + const token = await this.getAccessToken(); + const headers = { + Authorization: `Bearer ${token}`, + }; + + if (method === 'GET') { + return this.request.get(url, { headers }); + } else { + return this.request.post(url, data, { headers }); + } + }; + + getUserInfo = async () => { + if (!this.userInfo) { + // Get User Info + // Endpoint: GET https://open.feishu.cn/open-apis/authen/v1/user_info + this.userInfo = await this.requestWithToken('/open-apis/authen/v1/user_info', 'GET'); + } + const { avatar_url, name, en_name } = this.userInfo; + return { + avatar: avatar_url, + name: name || en_name, + homePage: 'https://www.feishu.cn/drive/home/', + description: 'Feishu User', + }; + }; + + getRepositories = async () => { + // List "My Space" root folder children? + // User Access Token allows access to user's files. + // Let's just return a "Root" repository which represents "My Space". + // Or we can list folders in root. + + // For User Identity, we can use Explorer API + try { + const rootMeta = await this.requestWithToken('/open-apis/drive/explorer/v2/root_folder/meta', 'GET'); + return [{ + id: rootMeta.token, + name: '我的空间 (My Space)', + groupId: 'me', + groupName: 'Personal', + }]; + } catch (e) { + return [{ + id: 'root', + name: '我的空间 (My Space)', + groupId: 'me', + groupName: 'Personal', + }]; + } + }; + + createDocument = async (info: FeishuCreateDocumentRequest): Promise => { + const { title, content, repositoryId } = info; + + // 1. Create Document + // Using User Access Token, folder_token can be root token. + const createRes = await this.requestWithToken('/open-apis/docx/v1/documents', 'POST', { + folder_token: repositoryId, + title: title, + }); + + const documentId = createRes.document.document_id; + + // 2. Write Content + const blocks = [ + { + block_type: 2, // Text + text: { + elements: [ + { + text_run: { + content: content + } + } + ] + } + } + ]; + + await this.requestWithToken(`/open-apis/docx/v1/documents/${documentId}/blocks/${documentId}/children`, 'POST', { + children: blocks, + index: -1, + }); + + return { + href: `https://feishu.cn/docx/${documentId}`, + repositoryId, + documentId, + }; + }; +} + diff --git a/src/common/locales/data/en-US.json b/src/common/locales/data/en-US.json index 37e661da..12d0b076 100644 --- a/src/common/locales/data/en-US.json +++ b/src/common/locales/data/en-US.json @@ -36,6 +36,11 @@ "backend.services.dida365.name": "Dida365", "backend.services.dida365.rootGroup": "", "backend.services.dida365.unauthorizedErrorMessage": "", + "backend.services.feishu.loginAlert": "Please login to Feishu Web first. This plugin uses your browser cookie to access Feishu.", + "backend.services.feishu.form.tip": "Please create a custom app in Feishu Open Platform, enable docx and drive permissions, and share the target folder with the bot.", + "backend.services.feishu.form.tip_worker": "Please deploy the Cloudflare Worker first. Enter the Worker URL, click Login, and copy the returned JSON.", + "backend.services.feishu.form.login": "Login to Feishu", + "backend.services.feishu.name": "Feishu (OAuth)", "backend.services.flomo.login": "Unauthorized! Please Login Flomo Web.", "backend.services.github.form.GenerateNewToken": "", "backend.services.github.form.storageLocation": "", diff --git a/src/common/locales/data/zh-CN.json b/src/common/locales/data/zh-CN.json index 3b6ade14..1e20c2ed 100644 --- a/src/common/locales/data/zh-CN.json +++ b/src/common/locales/data/zh-CN.json @@ -25,18 +25,16 @@ "backend.imageHosting.wiznote.name": "为知笔记", "backend.imageHosting.wiznote.builtInRemark": "为知笔记内置图床", "backend.not.unavailable": "暂时无法剪辑此类型的页面。\n\n刷新页面可以解决。", - "backend.services.memos.name": "Memos", - "backend.services.memos.form.hostTest": "检验", + "backend.services.memos.form.hostTest": "检验", "backend.services.memos.accessToken.message": "请输入 AccessToken", "backend.services.memos.form.authentication": "请输入服务器地址", - "backend.services.memos.headerForm.tag": "请输入标签名称,多个标签用英文逗号分隔,如 tag1,tag2...", - "backend.services.memos.headerForm.visibility": "文档类型", - "backend.services.memos.headerForm.VisibilityType.private": "私人", - "backend.services.memos.headerForm.VisibilityType.public": "公开", - "backend.services.memos.headerForm.tag_error": "标签格式错误,请检查", - - "backend.services.baklib.form.hostTest": "测试", + "backend.services.memos.headerForm.tag": "请输入标签名称,多个标签用英文逗号分隔,如 tag1,tag2...", + "backend.services.memos.headerForm.visibility": "文档类型", + "backend.services.memos.headerForm.VisibilityType.private": "私人", + "backend.services.memos.headerForm.VisibilityType.public": "公开", + "backend.services.memos.headerForm.tag_error": "标签格式错误,请检查", + "backend.services.baklib.form.hostTest": "测试", "backend.services.baklib.form.authentication": "授权", "backend.services.baklib.headerForm.channel": "栏目", "backend.services.baklib.headerForm.description": "描述", @@ -50,6 +48,11 @@ "backend.services.dida365.name": "滴答清单", "backend.services.dida365.rootGroup": "根目录", "backend.services.dida365.unauthorizedErrorMessage": "授权失败,请登录网页版滴答清单。", + "backend.services.feishu.loginAlert": "授权失败,请登录网页版飞书。", + "backend.services.feishu.form.tip": "请先到飞书开放平台创建“企业自建应用”,开启 docx 和 drive 权限,并将目标文件夹分享给该应用(机器人)。", + "backend.services.feishu.form.tip_worker": "请先部署 Cloudflare Worker。输入 Worker URL,点击登录,然后复制返回的 JSON。", + "backend.services.feishu.form.login": "登录飞书", + "backend.services.feishu.name": "飞书 (OAuth)", "backend.services.flomo.login": "授权失败,请登录网页版浮墨笔记。", "backend.services.github.form.GenerateNewToken": "生成新 Token", "backend.services.github.form.storageLocation": "保存位置", From 043d17a2fca66dc2e5a9942e618c10aee770eb7d Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 00:44:37 +0800 Subject: [PATCH 02/14] update to 1.42.1 to support feishu --- README.md | 11 ++++++----- package.json | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4c1bb3f6..2666adeb 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ You can use Web Clipper to save anything on the web to anywhere. ### Support Site +- [Feishu (Lark)](https://www.feishu.cn/) (Requires [Self-Hosted Worker](docs/feishu-oauth-guide.md)) - [FlowUs](https://flowus.cn/) - [Obsidian](https://obsidian.md/) - [Github](https://github.com) @@ -55,10 +56,10 @@ ps: Because the review takes a week, the version will fall behind. ### Develop ```bash -$ git clone https://github.com/webclipper/web-clipper.git -$ cd web-clipper -$ npm i -$ npm run dev +git clone https://github.com/webclipper/web-clipper.git +cd web-clipper +npm i +npm run dev ``` - You should load the 'dist/chrome' folder in Chrome. @@ -68,7 +69,7 @@ $ npm run dev ### Test ```bash -$ npm run test +npm run test ``` ### Feedback diff --git a/package.json b/package.json index 4b891891..10d573f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "web-clipper", - "version": "1.42.0", + "version": "1.42.1", "description": "Universal open source web clipper.", "bin": { "web-clipper": "bin/index.js" From ed918aacabaea5d839cc6bed2b320e33cf8d049a Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 00:49:47 +0800 Subject: [PATCH 03/14] add package workflow --- .github/workflows/build.yml | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..0f9dd7f4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,39 @@ +name: Build Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '16.x' + + - name: Install pnpm + run: npm install -g pnpm + + - name: Install Dependencies + run: pnpm install + + - name: Build and Package + run: npm run release + + - name: Upload Chrome Artifact + uses: actions/upload-artifact@v4 + with: + name: web-clipper-chrome + path: release/web-clipper-chrome.zip + + - name: Upload Firefox Artifact + uses: actions/upload-artifact@v4 + with: + name: web-clipper-firefox + path: release/web-clipper-firefox.zip + From c468299526c8be69f453dd6f6ab5ebc3f727b98d Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 00:52:59 +0800 Subject: [PATCH 04/14] fx build workflow --- .github/workflows/build.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f9dd7f4..6b839e02 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,11 +16,8 @@ jobs: with: node-version: '16.x' - - name: Install pnpm - run: npm install -g pnpm - - name: Install Dependencies - run: pnpm install + run: npm install --force - name: Build and Package run: npm run release From 257d803c86c9c74756d0392d6cf01f4bc693ac57 Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 00:59:08 +0800 Subject: [PATCH 05/14] try create dist folder --- webpack/webpack.common.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webpack/webpack.common.js b/webpack/webpack.common.js index 2e8ccf58..d42aa8f9 100644 --- a/webpack/webpack.common.js +++ b/webpack/webpack.common.js @@ -12,7 +12,10 @@ function resolve(dir) { return path.join(__dirname, '..', dir); } -const distFiles = fs.readdirSync(resolve('dist')).filter((o) => o !== '.gitkeep'); +const distPath = resolve('dist'); +const distFiles = fs.existsSync(distPath) + ? fs.readdirSync(distPath).filter((o) => o !== '.gitkeep') + : []; module.exports = { entry: { From 8f2c6b46f19f9fd4abf86a2ac8af3c6d5fbc560b Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 01:17:12 +0800 Subject: [PATCH 06/14] update build --- .github/workflows/build.yml | 42 +++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b839e02..4f7fb552 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,15 +22,39 @@ jobs: - name: Build and Package run: npm run release - - name: Upload Chrome Artifact - uses: actions/upload-artifact@v4 - with: - name: web-clipper-chrome - path: release/web-clipper-chrome.zip + # 4. 打包 Chrome 版本 + # 进入 dist/chrome 目录进行压缩 + - name: Package Chrome Extension + run: | + if [ -d "dist/chrome" ]; then + cd dist/chrome + zip -r ../../web-clipper-chrome.zip ./* + echo "✅ Chrome version packaged." + else + echo "❌ Error: dist/chrome folder not found!" + exit 1 + fi - - name: Upload Firefox Artifact + # 5. 打包 Firefox 版本 + # 进入 dist 根目录压缩,但【排除】chrome 子文件夹 + # 这样 Firefox 包里就不会包含另一个 Chrome 包的数据 + - name: Package Firefox Extension + run: | + if [ -f "dist/manifest.json" ]; then + cd dist + zip -r ../web-clipper-firefox.zip + echo "✅ Firefox version packaged (from dist root)." + else + echo "❌ Error: dist/manifest.json not found!" + exit 1 + fi + + # 6. 上传两个 ZIP 文件 + - name: Upload Artifacts uses: actions/upload-artifact@v4 with: - name: web-clipper-firefox - path: release/web-clipper-firefox.zip - + name: web-clipper-release + path: | + web-clipper-chrome.zip + web-clipper-firefox.zip + retention-days: 5 From 3c2632d1a9291b5a8c3634d0ecccbd6c98f43c98 Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 01:17:26 +0800 Subject: [PATCH 07/14] update build.yml --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f7fb552..486b70b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,4 +57,3 @@ jobs: path: | web-clipper-chrome.zip web-clipper-firefox.zip - retention-days: 5 From d8ef9f46d295d0d1d025893592341eb81d0a7161 Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 01:21:07 +0800 Subject: [PATCH 08/14] fx build.yml --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 486b70b1..4b18a7ef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: run: | if [ -f "dist/manifest.json" ]; then cd dist - zip -r ../web-clipper-firefox.zip + zip -r ../web-clipper-firefox.zip ./* echo "✅ Firefox version packaged (from dist root)." else echo "❌ Error: dist/manifest.json not found!" From f017673b753c4304d57fe2905d84aeae2a7ce04f Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 01:28:58 +0800 Subject: [PATCH 09/14] fx build.yml --- .github/workflows/build.yml | 44 +++++++++++-------------------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4b18a7ef..cc81c9ad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,38 +22,20 @@ jobs: - name: Build and Package run: npm run release - # 4. 打包 Chrome 版本 - # 进入 dist/chrome 目录进行压缩 - - name: Package Chrome Extension - run: | - if [ -d "dist/chrome" ]; then - cd dist/chrome - zip -r ../../web-clipper-chrome.zip ./* - echo "✅ Chrome version packaged." - else - echo "❌ Error: dist/chrome folder not found!" - exit 1 - fi + - name: Upload Chrome Package + uses: actions/upload-artifact@v4 + with: + name: web-clipper-chrome # 在 GitHub 页面上显示的名字 + path: release/web-clipper-chrome.zip - # 5. 打包 Firefox 版本 - # 进入 dist 根目录压缩,但【排除】chrome 子文件夹 - # 这样 Firefox 包里就不会包含另一个 Chrome 包的数据 - - name: Package Firefox Extension - run: | - if [ -f "dist/manifest.json" ]; then - cd dist - zip -r ../web-clipper-firefox.zip ./* - echo "✅ Firefox version packaged (from dist root)." - else - echo "❌ Error: dist/manifest.json not found!" - exit 1 - fi + - name: Upload Firefox Package + uses: actions/upload-artifact@v4 + with: + name: web-clipper-firefox + path: release/web-clipper-firefox.zip - # 6. 上传两个 ZIP 文件 - - name: Upload Artifacts + - name: Upload Firefox Store Package uses: actions/upload-artifact@v4 with: - name: web-clipper-release - path: | - web-clipper-chrome.zip - web-clipper-firefox.zip + name: web-clipper-firefox-store + path: release/web-clipper-firefox-store.zip From cba83a79781e632c74d0f44d635c6ab84ce083b8 Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 12:38:06 +0800 Subject: [PATCH 10/14] fx build --- .github/workflows/build.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc81c9ad..607bf6b8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,9 +19,19 @@ jobs: - name: Install Dependencies run: npm install --force + - name: Build + run: npm run build + - name: Build and Package run: npm run release + - name: Inspect Build Output (Debug) + run: | + echo "Listing root directory:" + ls -F + echo "Listing dist directory (if exists):" + ls -F dist || echo "Dist folder not found" + - name: Upload Chrome Package uses: actions/upload-artifact@v4 with: From bfd5773f359f81b240cc263e8b408507dc799996 Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 13:00:09 +0800 Subject: [PATCH 11/14] fx feishu callback --- .github/workflows/build.yml | 4 +- docs/cf_woker.js | 2 +- docs/feishu-oauth-guide.md | 228 ++++++++---------------------------- 3 files changed, 51 insertions(+), 183 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 607bf6b8..10797c3a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: run: npm install --force - name: Build - run: npm run build + run: npm run dev - name: Build and Package run: npm run release @@ -31,7 +31,7 @@ jobs: ls -F echo "Listing dist directory (if exists):" ls -F dist || echo "Dist folder not found" - + - name: Upload Chrome Package uses: actions/upload-artifact@v4 with: diff --git a/docs/cf_woker.js b/docs/cf_woker.js index a0c35510..bc446dd7 100644 --- a/docs/cf_woker.js +++ b/docs/cf_woker.js @@ -15,7 +15,7 @@ export default { if (pathname === '/login') { const redirectUri = `${url.origin}/callback`; // Request permissions for Drive and Docx - const scope = 'drive:drive docx:document:user_group_read_write'; + const scope = 'drive:drive docx:document'; const feishuAuthUrl = `https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=${env.APP_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`; return Response.redirect(feishuAuthUrl, 302); } diff --git a/docs/feishu-oauth-guide.md b/docs/feishu-oauth-guide.md index eeaf8794..d30c6f92 100644 --- a/docs/feishu-oauth-guide.md +++ b/docs/feishu-oauth-guide.md @@ -9,210 +9,78 @@ Cloudflare Workers 提供了一个免费、高性能且无需维护服务器的 ## 准备工作 -1. 一个 [Cloudflare](https://www.cloudflare.com/) 账号(免费版即可)。 -2. 一个 [飞书](https://www.feishu.cn/) 账号(需注册一个个人组织,免费)。 +1. 一个 [Cloudflare](https://www.cloudflare.com/) 账号(免费版即可)。 +2. 一个 [飞书](https://www.feishu.cn/) 账号(需注册一个个人组织,免费)。 --- ## 第一步:创建飞书应用 -1. 登录 [飞书开放平台](https://open.feishu.cn/app)。 -2. 点击 **"创建企业自建应用"**。 -3. 填写应用名称(如 "Web Clipper")和描述,点击创建。 -4. 在应用详情页,左侧菜单选择 **"凭证与基础信息"**。 - * 记录下 **App ID** 和 **App Secret**,稍后会用到。 -5. 左侧菜单选择 **"开发配置" -> "安全设置"**。 - * 在 **"重定向 URL"** 中,添加你的 Worker URL(格式为 `https://<你的Worker名>.<你的子域名>.workers.dev/callback`)。 - * *注意:如果你还没部署 Worker,可以先跳过这一步,等部署完拿到 URL 后再回来填。* -6. 左侧菜单选择 **"权限管理"**。 - * 切换到 **"应用身份"** 标签页(其实这里主要是为了开通 API 能力,User Token 的权限通常是动态请求的,但建议预先配置)。 - * 搜索并开通以下权限: - * `docx:document:user_group_read_write` (查看、评论、编辑和管理云空间所有文件) - * `drive:drive:readonly` (查看云空间目录) - * `drive:drive` (查看、评论、编辑和管理云空间所有文件) -7. 左侧菜单选择 **"版本管理与发布"**。 - * 点击 **"创建版本"**。 - * 在 **"可用范围"** 中选择 **"所有员工"**。 - * 点击 **"保存并发布"**。 +1. 登录 [飞书开放平台](https://open.feishu.cn/app)。 +2. 点击 **"创建企业自建应用"**。 +3. 填写应用名称(如 "Web Clipper")和描述,点击创建。 +4. 在应用详情页,左侧菜单选择 **"凭证与基础信息"**。 + * 记录下 **App ID** 和 **App Secret**,稍后会用到。 +5. 左侧菜单选择 **"开发配置" -> "安全设置"**。 + * 在 **"重定向 URL"** 中,添加你的 Worker URL(格式为 `https://<你的Worker名>.<你的子域名>.workers.dev/callback`)。 + * *注意:如果你还没部署 Worker,可以先跳过这一步,等部署完拿到 URL 后再回来填。* +6. 左侧菜单选择 **"权限管理"**。 + * 切换到 **"应用身份"** 标签页(其实这里主要是为了开通 API 能力,User Token 的权限通常是动态请求的,但建议预先配置)。 + * 搜索并开通以下权限: + * `docx:document` (编辑新版文档) + * `drive:drive:readonly` (查看云空间目录) + * `drive:drive` (查看、评论、编辑和管理云空间所有文件) +7. 左侧菜单选择 **"版本管理与发布"**。 + * 点击 **"创建版本"**。 + * 在 **"可用范围"** 中选择 **"所有员工"**。 + * 点击 **"保存并发布"**。 --- ## 第二步:部署 Cloudflare Worker -1. 登录 Cloudflare Dashboard,进入 **"Workers & Pages"**。 -2. 点击 **"Create Application"** -> **"Create Worker"**。 -3. 给 Worker 起个名字(例如 `feishu-oauth-relay`),点击 **"Deploy"**。 -4. 部署成功后,点击 **"Edit code"**。 -5. 将以下代码完整复制粘贴到编辑器中(覆盖原有代码): - -```javascript -/** - * Cloudflare Worker for Feishu OAuth Relay - * - * Environment Variables required: - * - APP_ID - * - APP_SECRET - */ - -export default { - async fetch(request, env) { - const url = new URL(request.url); - const pathname = url.pathname; - - // 1. Redirect to Feishu Login - if (pathname === '/login') { - const redirectUri = `${url.origin}/callback`; - // Request permissions for Drive and Docx - const scope = 'drive:drive docx:document:user_group_read_write'; - const feishuAuthUrl = `https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=${env.APP_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`; - return Response.redirect(feishuAuthUrl, 302); - } - - // 2. Callback: Exchange Code for Token - if (pathname === '/callback') { - const code = url.searchParams.get('code'); - if (!code) return new Response('Missing code', { status: 400 }); - - try { - // Step A: Get App Access Token (Internal) - const appTokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ app_id: env.APP_ID, app_secret: env.APP_SECRET }) - }); - const appTokenData = await appTokenRes.json(); - if (appTokenData.code !== 0) throw new Error('Failed to get app token: ' + appTokenData.msg); - const appAccessToken = appTokenData.app_access_token; - - // Step B: Get User Access Token - const userTokenRes = await fetch('https://open.feishu.cn/open-apis/authen/v1/oidc/access_token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${appAccessToken}` - }, - body: JSON.stringify({ - grant_type: 'authorization_code', - code: code - }) - }); - - const userTokenData = await userTokenRes.json(); - if (userTokenData.code && userTokenData.code !== 0) throw new Error(userTokenData.msg || 'Auth Failed'); - - // Display Token to User (JSON) - return new Response(JSON.stringify(userTokenData.data, null, 2), { - headers: { 'Content-Type': 'application/json' } - }); - - } catch (e) { - return new Response('Error: ' + e.message, { status: 500 }); - } - } - - // 3. Refresh Token - if (pathname === '/refresh') { - if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405 }); - const { refresh_token } = await request.json(); - - if (!refresh_token) return new Response('Missing refresh_token', { status: 400 }); - - try { - // Step A: Get App Access Token - const appTokenRes = await fetch('https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ app_id: env.APP_ID, app_secret: env.APP_SECRET }) - }); - const appTokenData = await appTokenRes.json(); - const appAccessToken = appTokenData.app_access_token; - - // Step B: Refresh User Token - const refreshRes = await fetch('https://open.feishu.cn/open-apis/authen/v1/oidc/refresh_access_token', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${appAccessToken}` - }, - body: JSON.stringify({ - grant_type: 'refresh_token', - refresh_token: refresh_token - }) - }); - - const refreshData = await refreshRes.json(); - - // Add CORS headers so plugin can call this - return new Response(JSON.stringify(refreshData.data), { - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST' - } - }); - - } catch (e) { - return new Response(JSON.stringify({ error: e.message }), { - status: 500, - headers: { 'Access-Control-Allow-Origin': '*' } - }); - } - } - - // Handle OPTIONS for CORS - if (request.method === 'OPTIONS') { - return new Response(null, { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST', - 'Access-Control-Allow-Headers': 'Content-Type' - } - }); - } - - return new Response('Feishu OAuth Relay Worker is Running!'); - } -}; -``` - -6. 点击右上角的 **"Save and deploy"**。 +1. 登录 Cloudflare Dashboard,进入 **"Workers & Pages"**。 +2. 点击 **"Create Application"** -> **"Create Worker"**。 +3. 给 Worker 起个名字(例如 `feishu-oauth-relay`),点击 **"Deploy"**。 +4. 部署成功后,点击 **"Edit code"**。 +5. 将cf_worker.js代码完整复制粘贴到编辑器中(覆盖原有代码): +6. 点击右上角的 **"Save and deploy"**。 --- ## 第三步:配置环境变量 -1. 在 Worker 编辑页面,点击左上角的 Worker 名字返回 Worker 详情页。 -2. 点击 **"Settings"** 标签页。 -3. 点击 **"Variables"**。 -4. 在 **"Environment Variables"** 部分,点击 **"Add variable"**,添加以下两个变量: - * `APP_ID`: (填入你在第一步获取的飞书 App ID) - * `APP_SECRET`: (填入你在第一步获取的飞书 App Secret) -5. 点击 **"Save and deploy"**。 +1. 在 Worker 编辑页面,点击左上角的 Worker 名字返回 Worker 详情页。 +2. 点击 **"Settings"** 标签页。 +3. 点击 **"Variables"**。 +4. 在 **"Environment Variables"** 部分,点击 **"Add variable"**,添加以下两个变量: + * `APP_ID`: (填入你在第一步获取的飞书 App ID) + * `APP_SECRET`: (填入你在第一步获取的飞书 App Secret) +5. 点击 **"Save and deploy"**。 --- ## 第四步:完成飞书配置 -1. 回到 Worker 详情页,找到你的 Worker URL(例如 `https://feishu-oauth-relay.yourname.workers.dev`)。 -2. 回到 [飞书开放平台](https://open.feishu.cn/app) 的应用配置页面。 -3. 进入 **"安全设置"** -> **"重定向 URL"**。 -4. 点击 **"添加重定向 URL"**,填入 `/callback`。 - * 例如:`https://feishu-oauth-relay.yourname.workers.dev/callback` -5. 点击 **"保存"**。 +1. 回到 Worker 详情页,找到你的 Worker URL(例如 `https://feishu-oauth-relay.yourname.workers.dev`)。 +2. 回到 [飞书开放平台](https://open.feishu.cn/app) 的应用配置页面。 +3. 进入 **"安全设置"** -> **"重定向 URL"**。 +4. 点击 **"添加重定向 URL"**,填入 `/callback`。 + * 例如:`https://feishu-oauth-relay.yourname.workers.dev/callback` +5. 点击 **"保存"**。 --- ## 第五步:在 Web Clipper 插件中使用 -1. 打开 Web Clipper 插件设置页。 -2. 选择 **"账户"** -> **"添加账户"** -> 选择 **"飞书"**。 -3. 在 **"Worker URL"** 中填入你的 Worker 链接(例如 `https://feishu-oauth-relay.yourname.workers.dev`)。 -4. 点击 **"登录飞书"** 按钮。 -5. 在弹出的窗口中完成飞书授权。 -6. 授权成功后,页面会显示一段 JSON 代码(包含 `access_token` 等信息)。 -7. **复制整段 JSON 代码**。 -8. 回到插件设置页,将 JSON 粘贴到 **"Token JSON"** 输入框中。 -9. 点击保存,完成配置! +1. 打开 Web Clipper 插件设置页。 +2. 选择 **"账户"** -> **"添加账户"** -> 选择 **"飞书"**。 +3. 在 **"Worker URL"** 中填入你的 Worker 链接(例如 `https://feishu-oauth-relay.yourname.workers.dev`)。 +4. 点击 **"登录飞书"** 按钮。 +5. 在弹出的窗口中完成飞书授权。 +6. 授权成功后,页面会显示一段 JSON 代码(包含 `access_token` 等信息)。 +7. **复制整段 JSON 代码**。 +8. 回到插件设置页,将 JSON 粘贴到 **"Token JSON"** 输入框中。 +9. 点击保存,完成配置! 现在,插件将自动使用这个 Token 访问你的飞书空间,并在 Token 过期时自动通过 Worker 进行刷新。 - From 122c0fe58eedc9487d162fa431d38cd2039ca83b Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 13:17:31 +0800 Subject: [PATCH 12/14] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 94811fdc..5f2765f4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ webclipper.zip .now release package-lock.json +pnpm-lock.yaml From 0a77c917e22d91594810a38e5d96fe423f2fa64a Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 13:35:06 +0800 Subject: [PATCH 13/14] fx feishu document save --- src/common/backend/services/feishu/form.tsx | 70 +++--- src/common/backend/services/feishu/service.ts | 217 +++++++++++------- 2 files changed, 160 insertions(+), 127 deletions(-) diff --git a/src/common/backend/services/feishu/form.tsx b/src/common/backend/services/feishu/form.tsx index 9d38b481..0f5fc674 100644 --- a/src/common/backend/services/feishu/form.tsx +++ b/src/common/backend/services/feishu/form.tsx @@ -62,51 +62,45 @@ export default class FeishuForm extends Component)} - + - {getFieldDecorator('accessToken', { - // We use a hidden logic or simple text area to paste the JSON? - // Actually, usually user copies the whole JSON. Let's provide a TextArea. - // But the service expects separated fields. - // Let's make a smart input that parses JSON on change? - // Or just simple AccessToken input if user parses it? - // JSON paste is better. - initialValue: initData.accessToken ? '********' : '', - rules: [{ required: true, message: 'Token is required' }] - })( - { - try { - const data = JSON.parse(e.target.value); - if (data.access_token && data.refresh_token) { - // Determine expiration time (current time + expires_in - buffer) - const expiresAt = Math.floor(Date.now() / 1000) + (data.expires_in || 7200); - - // Set fields silently - this.props.form.setFieldsValue({ - 'accessToken': data.access_token, - 'refreshToken': data.refresh_token, - 'expiresAt': expiresAt - }); - } - } catch (err) { - // Ignore parse error, maybe user is typing manual token - } - }} - /> - )} + { + try { + const data = JSON.parse(e.target.value); + if (data.access_token && data.refresh_token) { + const expiresAt = Math.floor(Date.now() / 1000) + (Number(data.expires_in) || 7200); + + // Set fields silently + this.props.form.setFieldsValue({ + 'accessToken': data.access_token, + 'refreshToken': data.refresh_token, + 'expiresAt': expiresAt + }); + } + } catch (err) { + // Ignore parse error, maybe user is typing manual token + } + }} + /> {/* Hidden fields to store parsed values */} - {getFieldDecorator('refreshToken', { initialValue: initData.refreshToken })()} + {getFieldDecorator('accessToken', { + initialValue: initData.accessToken, + rules: [{ required: true, message: 'Access Token is required' }] + })()} + + + {getFieldDecorator('refreshToken', { initialValue: initData.refreshToken })()} - {getFieldDecorator('expiresAt', { initialValue: initData.expiresAt })()} + {getFieldDecorator('expiresAt', { initialValue: initData.expiresAt })()} ); diff --git a/src/common/backend/services/feishu/service.ts b/src/common/backend/services/feishu/service.ts index d5e4f2a6..832d48d0 100644 --- a/src/common/backend/services/feishu/service.ts +++ b/src/common/backend/services/feishu/service.ts @@ -1,109 +1,148 @@ -import { IBasicRequestService } from '@/service/common/request'; -import { Container } from 'typedi'; -import { RequestHelper } from '@/service/request/common/request'; import { DocumentService } from './../../index'; import { FeishuBackendServiceConfig, FeishuUserInfoResponse, FeishuCompleteStatus, FeishuCreateDocumentRequest, - FeishuTokenResponse, } from './interface'; import md5 from '@web-clipper/shared/lib/md5'; -import localeService from '@/common/locales'; const OPEN_API = 'https://open.feishu.cn'; export default class FeishuDocumentService implements DocumentService { - private request: RequestHelper; private userInfo?: any; private config: FeishuBackendServiceConfig; - + constructor(config: FeishuBackendServiceConfig) { this.config = config; - this.request = new RequestHelper({ - baseURL: OPEN_API, - headers: { - 'Content-Type': 'application/json', - }, - request: Container.get(IBasicRequestService), - interceptors: { - response: (response: any) => { - if (response.code !== 0) { - throw new Error(response.msg || 'Feishu API Error'); - } - return response.data; - }, - }, - }); } getId = () => md5(this.config.workerUrl); refreshToken = async (info: FeishuBackendServiceConfig): Promise => { - if (!info.refreshToken || !info.workerUrl) { - throw new Error('Missing refresh token or worker url'); - } - const workerRefreshUrl = `${info.workerUrl.replace(/\/$/, '')}/refresh`; - + console.log('Feishu Refresh Token Called', { + hasRefreshToken: !!info.refreshToken, + workerUrl: info.workerUrl + }); + if (!info.refreshToken || !info.workerUrl) { + throw new Error('Missing refresh token or worker url'); + } + const workerUrl = info.workerUrl.trim(); + const workerRefreshUrl = `${workerUrl.replace(/\/$/, '')}/refresh`; + console.log('Feishu Refresh URL:', workerRefreshUrl); + + try { const response = await fetch(workerRefreshUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_token: info.refreshToken }) + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: info.refreshToken }) }); - + const data = await response.json(); if (data.error) { - throw new Error(data.error); + throw new Error(data.error); } - + return { - ...info, - accessToken: data.access_token, - refreshToken: data.refresh_token, - expiresAt: Math.floor(Date.now() / 1000) + (data.expires_in || 7200) + ...info, + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Math.floor(Date.now() / 1000) + (Number(data.expires_in) || 7200) }; + } catch (e: any) { + console.error('RefreshToken Error:', e); + throw new Error(`Failed to refresh token: ${e.message}`); + } } private getAccessToken = async () => { // Check expiration - if (this.config.expiresAt && Date.now() / 1000 > this.config.expiresAt - 300) { - // Token expired or about to expire (5 mins buffer) - try { - const newConfig = await this.refreshToken(this.config); - this.config = newConfig; - // Note: In a real app, we should persist this new config back to storage. - // But DocumentService interface doesn't easily support saving back config unless called by upper layer. - // However, typical usage is: upper layer checks if `refreshToken` method exists, call it, and save result. - // Current Clipper architecture might not auto-save refreshed token easily. - // We'll rely on in-memory update for this session. - // If the architecture supports `refreshToken` hook (interface line 81), it will work. - } catch (e) { - console.error('Refresh token failed', e); - throw new Error('Session expired, please login again via Worker.'); - } + const expiresAt = Number(this.config.expiresAt); + const now = Date.now() / 1000; + const shouldRefresh = expiresAt && now > expiresAt - 300; + + console.log('Feishu GetAccessToken:', { + expiresAt, + now, + shouldRefresh, + accessToken: this.config.accessToken ? 'exists' : 'missing' + }); + + if (shouldRefresh) { + // Token expired or about to expire (5 mins buffer) + try { + const newConfig = await this.refreshToken(this.config); + this.config = newConfig; + // Note: In a real app, we should persist this new config back to storage. + // But DocumentService interface doesn't easily support saving back config unless called by upper layer. + // However, typical usage is: upper layer checks if `refreshToken` method exists, call it, and save result. + // Current Clipper architecture might not auto-save refreshed token easily. + // We'll rely on in-memory update for this session. + // If the architecture supports `refreshToken` hook (interface line 81), it will work. + } catch (e) { + console.error('Refresh token failed', e); + throw new Error('Session expired, please login again via Worker.'); + } } return this.config.accessToken; }; - private requestWithToken = async (url: string, method: 'GET' | 'POST', data?: any) => { + private requestWithToken = async (path: string, method: 'GET' | 'POST', data?: any) => { const token = await this.getAccessToken(); - const headers = { - Authorization: `Bearer ${token}`, - }; + const url = `${OPEN_API}${path}`; + // Aggressively clean token: remove all whitespace including internal newlines + const cleanToken = token.replace(/\s+/g, ''); + + console.log('Feishu Request:', { + url, + method, + tokenLength: cleanToken.length, + tokenPreview: `${cleanToken.substring(0, 10)}...${cleanToken.substring(cleanToken.length - 5)}`, + cleanToken: cleanToken + }); - if (method === 'GET') { - return this.request.get(url, { headers }); - } else { - return this.request.post(url, data, { headers }); + try { + const headers = new Headers({ + 'Authorization': `Bearer ${cleanToken}`, + }); + + if (method === 'POST') { + headers.append('Content-Type', 'application/json'); + } + + const options: RequestInit = { + method, + headers, + }; + + if (method === 'POST' && data) { + options.body = JSON.stringify(data); + } + + const response = await fetch(url, options); + const json = await response.json(); + + if (json.code !== 0) { + console.error('Feishu API Error:', json); + throw new Error(json.msg || `Feishu API Error code: ${json.code}`); + } + + return json.data as T; + } catch (e: any) { + console.error('Feishu Request Failed:', e); + // Explicitly catch Headers error + if (e.message && e.message.includes('Headers')) { + throw new Error(`Invalid Token Format: Contains illegal characters. Length: ${cleanToken.length}`); + } + throw e; } }; getUserInfo = async () => { if (!this.userInfo) { - // Get User Info - // Endpoint: GET https://open.feishu.cn/open-apis/authen/v1/user_info - this.userInfo = await this.requestWithToken('/open-apis/authen/v1/user_info', 'GET'); + // Get User Info + // Endpoint: GET https://open.feishu.cn/open-apis/authen/v1/user_info + this.userInfo = await this.requestWithToken('/open-apis/authen/v1/user_info', 'GET'); } const { avatar_url, name, en_name } = this.userInfo; return { @@ -119,57 +158,57 @@ export default class FeishuDocumentService implements DocumentService { // User Access Token allows access to user's files. // Let's just return a "Root" repository which represents "My Space". // Or we can list folders in root. - + // For User Identity, we can use Explorer API try { - const rootMeta = await this.requestWithToken('/open-apis/drive/explorer/v2/root_folder/meta', 'GET'); - return [{ - id: rootMeta.token, - name: '我的空间 (My Space)', - groupId: 'me', - groupName: 'Personal', - }]; + const rootMeta = await this.requestWithToken('/open-apis/drive/explorer/v2/root_folder/meta', 'GET'); + return [{ + id: rootMeta.token, + name: '我的空间 (My Space)', + groupId: 'me', + groupName: 'Personal', + }]; } catch (e) { - return [{ - id: 'root', - name: '我的空间 (My Space)', - groupId: 'me', - groupName: 'Personal', - }]; + return [{ + id: 'root', + name: '我的空间 (My Space)', + groupId: 'me', + groupName: 'Personal', + }]; } }; createDocument = async (info: FeishuCreateDocumentRequest): Promise => { const { title, content, repositoryId } = info; - + // 1. Create Document // Using User Access Token, folder_token can be root token. const createRes = await this.requestWithToken('/open-apis/docx/v1/documents', 'POST', { folder_token: repositoryId, title: title, }); - + const documentId = createRes.document.document_id; - + // 2. Write Content const blocks = [ { block_type: 2, // Text text: { - elements: [ - { - text_run: { - content: content - } - } - ] + elements: [ + { + text_run: { + content: content + } + } + ] } } ]; - + await this.requestWithToken(`/open-apis/docx/v1/documents/${documentId}/blocks/${documentId}/children`, 'POST', { children: blocks, - index: -1, + index: -1, }); return { From d1c0f8ffdff92a5685cfe86df328c4be76c3a015 Mon Sep 17 00:00:00 2001 From: xixiu Date: Sun, 14 Dec 2025 16:02:48 +0800 Subject: [PATCH 14/14] support feishu image upload --- src/common/backend/services/feishu/service.ts | 153 +++++++++++++++--- 1 file changed, 128 insertions(+), 25 deletions(-) diff --git a/src/common/backend/services/feishu/service.ts b/src/common/backend/services/feishu/service.ts index 832d48d0..5dc54ddf 100644 --- a/src/common/backend/services/feishu/service.ts +++ b/src/common/backend/services/feishu/service.ts @@ -87,8 +87,11 @@ export default class FeishuDocumentService implements DocumentService { return this.config.accessToken; }; - private requestWithToken = async (path: string, method: 'GET' | 'POST', data?: any) => { + private requestWithToken = async (path: string, method: 'GET' | 'POST' | 'PATCH', data?: any) => { const token = await this.getAccessToken(); + if (!token) { + throw new Error('Access token is missing. Please re-login.'); + } const url = `${OPEN_API}${path}`; // Aggressively clean token: remove all whitespace including internal newlines const cleanToken = token.replace(/\s+/g, ''); @@ -97,8 +100,7 @@ export default class FeishuDocumentService implements DocumentService { url, method, tokenLength: cleanToken.length, - tokenPreview: `${cleanToken.substring(0, 10)}...${cleanToken.substring(cleanToken.length - 5)}`, - cleanToken: cleanToken + data: data ? JSON.stringify(data) : undefined }); try { @@ -106,7 +108,7 @@ export default class FeishuDocumentService implements DocumentService { 'Authorization': `Bearer ${cleanToken}`, }); - if (method === 'POST') { + if (method === 'POST' || method === 'PATCH') { headers.append('Content-Type', 'application/json'); } @@ -115,7 +117,7 @@ export default class FeishuDocumentService implements DocumentService { headers, }; - if (method === 'POST' && data) { + if ((method === 'POST' || method === 'PATCH') && data) { options.body = JSON.stringify(data); } @@ -178,11 +180,60 @@ export default class FeishuDocumentService implements DocumentService { } }; + private uploadImage = async (url: string, parentNode: string): Promise => { + try { + console.log('Downloading image:', url); + const imgRes = await fetch(url); + if (!imgRes.ok) { + console.error('Image download failed:', imgRes.status); + return null; + } + const blob = await imgRes.blob(); + + const formData = new FormData(); + formData.append('file_name', 'image.png'); + formData.append('parent_type', 'docx_image'); + formData.append('parent_node', parentNode); + formData.append('size', String(blob.size)); + formData.append('file', blob); + + const token = await this.getAccessToken(); + const uploadRes = await fetch(`${OPEN_API}/open-apis/drive/v1/medias/upload_all`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + const json = await uploadRes.json(); + if (json.code !== 0) { + console.error('Feishu Image Upload Error:', json); + return null; + } + console.log('Image uploaded, token:', json.data.file_token); + return json.data.file_token; + } catch (e) { + console.error('Image upload exception:', e); + return null; + } + } + + private batchUpdateBlocks = async (documentId: string, updates: { blockId: string, token: string }[]) => { + if (updates.length === 0) return; + try { + const requests = updates.map(u => ({ + block_id: u.blockId, + replace_image: { token: u.token } + })); + await this.requestWithToken(`/open-apis/docx/v1/documents/${documentId}/blocks/batch_update`, 'PATCH', { requests }); + console.log(`Batch updated ${updates.length} images.`); + } catch (e) { + console.error('Failed to batch update blocks:', e); + } + } + createDocument = async (info: FeishuCreateDocumentRequest): Promise => { const { title, content, repositoryId } = info; // 1. Create Document - // Using User Access Token, folder_token can be root token. const createRes = await this.requestWithToken('/open-apis/docx/v1/documents', 'POST', { folder_token: repositoryId, title: title, @@ -190,27 +241,80 @@ export default class FeishuDocumentService implements DocumentService { const documentId = createRes.document.document_id; - // 2. Write Content - const blocks = [ - { - block_type: 2, // Text - text: { - elements: [ - { - text_run: { - content: content - } - } - ] - } - } - ]; + // 2. Parse Content into Segments + const parts = content.split(/(!\[.*?\]\(.*?\))/g); + const segments: { type: 'text' | 'image', data: string }[] = []; - await this.requestWithToken(`/open-apis/docx/v1/documents/${documentId}/blocks/${documentId}/children`, 'POST', { - children: blocks, - index: -1, + parts.forEach(part => { + const imageMatch = part.match(/!\[.*?\]\((.*?)\)/); + if (imageMatch) { + segments.push({ type: 'image', data: imageMatch[1] }); + } else if (part) { + // Split text by newlines to avoid "invalid param" for huge text blocks + const lines = part.split(/\r?\n/); + lines.forEach(line => { + segments.push({ type: 'text', data: line }); + }); + } }); + // 3. Create Blocks in Chunks + if (segments.length > 0) { + const chunkSize = 50; + for (let i = 0; i < segments.length; i += chunkSize) { + const chunk = segments.slice(i, i + chunkSize); + + // Create blocks with empty image tokens + const childrenPayload = chunk.map(seg => { + if (seg.type === 'image') { + return { + block_type: 27, + image: { token: "" } // Placeholder + }; + } else { + return { + block_type: 2, + text: { elements: [{ text_run: { content: seg.data } }] } + }; + } + }); + + const createChildrenRes = await this.requestWithToken( + `/open-apis/docx/v1/documents/${documentId}/blocks/${documentId}/children`, + 'POST', + { children: childrenPayload, index: -1 } + ); + + // 4. Process Images: Upload and Collect Updates + const createdChildren = createChildrenRes.children; + const uploadPromises: Promise<{ blockId: string, token: string | null }>[] = []; + + for (let j = 0; j < chunk.length; j++) { + if (chunk[j].type === 'image' && createdChildren[j]) { + const blockId = createdChildren[j].block_id; + const imageUrl = chunk[j].data; + + // Initiate upload + uploadPromises.push( + this.uploadImage(imageUrl, blockId) + .then(token => ({ blockId, token })) + ); + } + } + + // Wait for all uploads in this chunk + if (uploadPromises.length > 0) { + const results = await Promise.all(uploadPromises); + const updates = results + .filter(r => r.token !== null) + .map(r => ({ blockId: r.blockId, token: r.token as string })); + + // Batch update images + await this.batchUpdateBlocks(documentId, updates); + } + } + } + return { href: `https://feishu.cn/docx/${documentId}`, repositoryId, @@ -218,4 +322,3 @@ export default class FeishuDocumentService implements DocumentService { }; }; } -