From cf3435de43d0dd83b097e56908e4cbe93e7b0821 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sat, 24 Oct 2026 11:03:24 +0800 Subject: [PATCH] feat(desktop): upload images through a remote PicList Allow a saved PicGo or PicList address beyond loopback, and send the optional server key on both the connection check and the upload. --- README.ja.md | 2 +- README.md | 2 +- README.pl-PL.md | 2 +- README.zh-CN.md | 2 +- README.zh-TW.md | 2 +- apps/desktop/src/main/index.mjs | 5 +- apps/desktop/src/main/picgo.mjs | 129 +++++++++++++++--- apps/desktop/src/main/picgo.test.mjs | 84 +++++++++++- apps/desktop/src/preload/index.cjs | 2 +- .../components/settings/PicGoSettingsItem.tsx | 30 ++-- apps/web/src/lib/picgo.test.mjs | 31 ++++- apps/web/src/lib/picgo.ts | 29 +++- apps/web/src/vite-env.d.ts | 4 +- packages/shared/src/i18n/en-US.ts | 10 +- packages/shared/src/i18n/ja.ts | 10 +- packages/shared/src/i18n/pl.ts | 10 +- packages/shared/src/i18n/zh-CN.ts | 10 +- 17 files changed, 297 insertions(+), 67 deletions(-) diff --git a/README.ja.md b/README.ja.md index 8ed1035e..4fbeebdf 100644 --- a/README.ja.md +++ b/README.ja.md @@ -195,7 +195,7 @@ curl -fsSL https://edgeever.org/install.sh | bash ### ストレージ拡張とサードパーティ画像ホスティング - **OSS オブジェクトストレージ**:インスタンスのオーナーは **設定 → 詳細 → OSS オブジェクトストレージ** で S3 互換ストレージを設定できます。切り替えは既存添付を移さず、影響しません。 -- **デスクトップ版 PicGo 連携**:デスクトップ版ではローカルの PicGo クライアントと連携可能で、ノート内の画像を GitHub、SM.MS、又拍雲、七牛雲や自前ホストへ直接アップロードでき、セルフホスト環境のストレージと帯域消費を節約できます。 +- **デスクトップ版 PicGo 連携**:デスクトップ版は、このパソコンまたは信頼できる別のマシン(NAS など)の PicGo / PicList に挿入画像を渡し、GitHub、SM.MS、又拍雲、七牛雲や自前ホストへアップロードできます。 ### プラグインとカスタムテーマ diff --git a/README.md b/README.md index 66c6cf05..29599de3 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The instance administrator can create, disable, or reset member accounts in **Pr ### Storage Extension & Third-Party Image Hosting - **OSS Object Storage**: The instance owner can configure S3-compatible object storage under **Settings → Advanced → OSS object storage**. Changing storage does not migrate or affect existing attachments. -- **Desktop PicGo Integration**: The desktop app optionally integrates with a local PicGo client service, uploading inserted images directly to GitHub, SM.MS, Upyun, Qiniu, or custom image hosts to minimize self-hosted storage and bandwidth consumption. +- **Desktop PicGo integration**: The desktop app can hand inserted images to a PicGo or PicList server on this computer or on another machine you trust, such as a NAS. That server uploads them to GitHub, SM.MS, Upyun, Qiniu, or a custom image host. ### Plugins and Themes diff --git a/README.pl-PL.md b/README.pl-PL.md index 0b5e09e0..34cb79b4 100644 --- a/README.pl-PL.md +++ b/README.pl-PL.md @@ -196,7 +196,7 @@ Administrator może tworzyć i wyłączać konta oraz resetować ich hasła w ** ### Rozszerzenia pamięci masowej i zewnętrzny hosting obrazów - **Magazyn obiektów OSS**: Właściciel instancji może skonfigurować magazyn zgodny z S3 w **Profil → Zaawansowane → Magazyn obiektów OSS**. Zmiana magazynu nie przenosi istniejących załączników ani ich nie modyfikuje. -- **Integracja z PicGo na komputerze**: Aplikacja desktopowa opcjonalnie współpracuje z lokalną usługą PicGo, przesyłając wstawiane obrazy bezpośrednio do GitHuba, SM.MS, Upyun, Qiniu lub własnego hostingu, co ogranicza zużycie miejsca i transferu we własnym środowisku. +- **Integracja z PicGo na komputerze**: Aplikacja desktopowa może przekazać wstawiane obrazy do PicGo lub PicList na tym komputerze albo na innej zaufanej maszynie, na przykład NAS. Serwer wysyła je do GitHuba, SM.MS, Upyun, Qiniu lub własnego hostingu. ### Wtyczki i motywy diff --git a/README.zh-CN.md b/README.zh-CN.md index 3e3e61df..245854b6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -194,7 +194,7 @@ curl -fsSL https://edgeever.org/install.sh | bash ### 存储扩展与第三方图床 - **OSS 对象存储**:实例 Owner 可在**设置 → 高级设置 → OSS 对象存储**中配置兼容 S3 API 的对象存储。切换存储不会迁移或影响已有附件。 -- **桌面端 PicGo 图床**:桌面端支持可选接入本机 PicGo 客户端服务,在笔记中插入图片时可直接上传至 GitHub、SM.MS、又拍云、七牛云或自建图床,进一步减少自托管实例的存储开销与流量占用。 +- **桌面端 PicGo 图床**:桌面端可以把新插入的图片交给本机或你信任的远程 PicGo、PicList 服务(例如 NAS),再由它上传到 GitHub、SM.MS、又拍云、七牛云或自建图床。 ### 插件与个性化主题 diff --git a/README.zh-TW.md b/README.zh-TW.md index 498cf268..473307b9 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -194,7 +194,7 @@ curl -fsSL https://edgeever.org/install.sh | bash ### 儲存擴充與第三方圖床 - **OSS 物件儲存**:實例 Owner 可在**設定 → 進階設定 → OSS 物件儲存**中設定相容 S3 API 的物件儲存。切換儲存不會遷移或影響既有附件。 -- **桌面端 PicGo 圖床**:桌面端支援可選接入本機 PicGo 用戶端服務,在筆記中插入圖片時可直接上傳至 GitHub、SM.MS、又拍雲、七牛雲或自建圖床,進一步減少自託管實例的儲存開銷與頻寬占用。 +- **桌面端 PicGo 圖床**:桌面端可以把新插入的圖片交給本機或你信任的遠端 PicGo、PicList 服務(例如 NAS),再由它上傳到 GitHub、SM.MS、又拍雲、七牛雲或自建圖床。 ### 外掛與個人化主題 diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 71b0c4d7..acb1c109 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -1870,9 +1870,10 @@ const startApplication = async () => { await saveDesktopSessionToken(""); return { stored: false }; }); - ipcMain.handle("desktop:picgo-test", (event, endpoint) => { + ipcMain.handle("desktop:picgo-test", (event, input) => { if (event.sender !== mainWindow?.webContents) throw new Error("PicGo requests must come from the main window"); - return testPicGoConnection(endpoint); + const target = input && typeof input === "object" ? input : { endpoint: input, serverKey: "" }; + return testPicGoConnection(target.endpoint, { serverKey: target.serverKey }); }); // Serialize uploads and error dialogs across rapid pastes and multi-image batches. const queuePicGoUpload = createPicGoUploadQueue({ diff --git a/apps/desktop/src/main/picgo.mjs b/apps/desktop/src/main/picgo.mjs index d7b791cf..f5aa13fe 100644 --- a/apps/desktop/src/main/picgo.mjs +++ b/apps/desktop/src/main/picgo.mjs @@ -1,18 +1,108 @@ -// PicGo owns uploader credentials. This bridge only sends image bytes to its -// local HTTP server; it never reads the clipboard or writes temporary files. -export const normalizePicGoEndpoint = (value) => { - const url = new URL(value); - if (url.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) - || url.username || url.password || url.hash || url.search || url.pathname !== "/upload") { - throw new Error("Use a local PicGo address such as http://127.0.0.1:36677/upload"); +// PicGo and PicList own the image-host credentials. This bridge only posts +// image bytes to the server address the user saved. It never reads the +// clipboard or writes temporary files. +import { lookup } from "node:dns/promises"; +import { BlockList, isIP } from "node:net"; + +const ADDRESS_ERROR = "Use a PicGo or PicList upload address such as http://127.0.0.1:36677/upload"; +const BLOCKED_HOSTNAMES = new Set(["metadata.google.internal", "metadata.goog"]); + +const blockedAddresses = new BlockList(); +blockedAddresses.addSubnet("0.0.0.0", 8, "ipv4"); +blockedAddresses.addSubnet("169.254.0.0", 16, "ipv4"); +blockedAddresses.addSubnet("224.0.0.0", 4, "ipv4"); +blockedAddresses.addSubnet("240.0.0.0", 4, "ipv4"); +blockedAddresses.addAddress("100.100.100.200", "ipv4"); +blockedAddresses.addSubnet("fe80::", 10, "ipv6"); +blockedAddresses.addSubnet("ff00::", 8, "ipv6"); +blockedAddresses.addAddress("::", "ipv6"); +blockedAddresses.addAddress("fd00:ec2::254", "ipv6"); + +const canonicalHost = (hostname) => { + let host = String(hostname).toLowerCase(); + if (host.endsWith(".")) host = host.slice(0, -1); + if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); + return host; +}; + +const isLiteralHost = (host) => host.includes(":") || /^\d{1,3}(\.\d{1,3}){3}$/.test(host); + +// Link-local and cloud metadata addresses are rejected. LAN and public +// addresses stay available because a remote PicList usually lives on a NAS. +export const isBlockedPicGoAddress = (address) => { + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address); + const candidate = mapped ? mapped[1] : address; + const family = isIP(candidate); + if (family === 4) { + const canonical = candidate.split(".").map((part) => String(Number(part))).join("."); + if (canonical !== candidate) return true; + return blockedAddresses.check(candidate, "ipv4"); } + if (family === 6) return blockedAddresses.check(candidate, "ipv6"); + return true; +}; + +const assertHostShape = (hostname) => { + const host = canonicalHost(hostname); + if (!host || BLOCKED_HOSTNAMES.has(host) || /^\d+$/.test(host)) throw new Error(ADDRESS_ERROR); + if (isLiteralHost(host) && isBlockedPicGoAddress(host)) throw new Error(ADDRESS_ERROR); +}; + +export const normalizePicGoServerKey = (value) => { + if (value == null || value === "") return ""; + if (typeof value !== "string") throw new Error("PicGo server key is invalid"); + const key = value.trim(); + if (!key) return ""; + if (key.length > 512 || /[\u0000-\u0020\u007f]/.test(key)) throw new Error("PicGo server key is invalid"); + return key; +}; + +export const normalizePicGoEndpoint = (value) => { + let url; + try { url = new URL(value); } + catch { throw new Error(ADDRESS_ERROR); } + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash || url.search || url.pathname !== "/upload") { + throw new Error(ADDRESS_ERROR); + } + assertHostShape(url.hostname); return url; }; +const buildPicGoUrl = (endpoint, serverKey, pathname) => { + const url = normalizePicGoEndpoint(endpoint); + url.pathname = pathname; + const key = normalizePicGoServerKey(serverKey); + if (key) url.searchParams.set("key", key); + return url; +}; + +const assertPicGoHostAllowed = async (hostname, lookupImpl) => { + assertHostShape(hostname); + const host = canonicalHost(hostname); + if (isLiteralHost(host)) return; + let records; + try { + records = await lookupImpl(host, { all: true, verbatim: true }); + } catch { + throw new Error("PicGo host could not be resolved"); + } + if (!Array.isArray(records) || records.length === 0) throw new Error("PicGo host could not be resolved"); + for (const record of records) { + if (!record || typeof record.address !== "string" || isBlockedPicGoAddress(record.address)) throw new Error(ADDRESS_ERROR); + } +}; + const request = async (url, body, fetchImpl, timeoutMs) => { - const response = await fetchImpl(url, { - method: "POST", body, redirect: "error", signal: AbortSignal.timeout(timeoutMs), - }); + let response; + try { + response = await fetchImpl(url, { + method: "POST", body, redirect: "error", signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + const name = error && typeof error === "object" ? error.name : ""; + if (name === "TimeoutError" || name === "AbortError") throw new Error("PicGo did not respond in time"); + throw new Error("PicGo could not be reached"); + } if (!response.ok) throw new Error(`PicGo HTTP ${response.status}`); const reader = response.body?.getReader(); if (!reader) throw new Error("PicGo returned an empty response"); @@ -33,19 +123,24 @@ const request = async (url, body, fetchImpl, timeoutMs) => { const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } - return JSON.parse(new TextDecoder().decode(bytes)); + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new Error("PicGo returned an unreadable response"); + } }; -export const testPicGoConnection = async (endpoint, { fetchImpl = fetch, timeoutMs = 5000 } = {}) => { - const url = normalizePicGoEndpoint(endpoint); - url.pathname = "/heartbeat"; +export const testPicGoConnection = async (endpoint, { fetchImpl = fetch, timeoutMs = 5000, serverKey = "", lookupImpl = lookup } = {}) => { + const url = buildPicGoUrl(endpoint, serverKey, "/heartbeat"); + await assertPicGoHostAllowed(url.hostname, lookupImpl); const data = await request(url, undefined, fetchImpl, timeoutMs); - if (data?.success !== true || data.result !== "alive") throw new Error("The local service did not identify itself as PicGo"); + if (data?.success !== true || data.result !== "alive") throw new Error("The service did not identify itself as PicGo or PicList"); return true; }; -export const uploadPicGoImage = async (input, { fetchImpl = fetch, timeoutMs = 60000 } = {}) => { - const url = normalizePicGoEndpoint(input.endpoint); +export const uploadPicGoImage = async (input, { fetchImpl = fetch, timeoutMs = 60000, lookupImpl = lookup } = {}) => { + const url = buildPicGoUrl(input.endpoint, input.serverKey, "/upload"); + await assertPicGoHostAllowed(url.hostname, lookupImpl); const bytes = input.bytes; if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0 || bytes.byteLength > 50 * 1024 * 1024 || typeof input.mimeType !== "string" || !input.mimeType.startsWith("image/")) { diff --git a/apps/desktop/src/main/picgo.test.mjs b/apps/desktop/src/main/picgo.test.mjs index 0a0e3135..cb8a6392 100644 --- a/apps/desktop/src/main/picgo.test.mjs +++ b/apps/desktop/src/main/picgo.test.mjs @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { createPicGoUploadQueue, normalizePicGoEndpoint, testPicGoConnection, uploadPicGoImage } from "./picgo.mjs"; +import { createPicGoUploadQueue, isBlockedPicGoAddress, normalizePicGoEndpoint, normalizePicGoServerKey, testPicGoConnection, uploadPicGoImage } from "./picgo.mjs"; const servers = []; afterEach(() => { for (const server of servers.splice(0)) server.stop(true); }); @@ -47,11 +47,87 @@ test("rejects wrong services, failed uploads and unsafe or ambiguous URLs", asyn } }); -test("only accepts local upload endpoints and image bytes", async () => { - for (const endpoint of ["https://example.com/upload", "http://192.168.0.1/upload", "http://127.0.0.1/other", "http://user:pass@localhost/upload", "http://localhost/upload?secret=foo", "http://localhost/upload#foo"]) { +test("accepts a remote PicList address and keeps its server key on heartbeat and upload", async () => { + expect(normalizePicGoEndpoint("https://piclist.example.com/upload").origin).toBe("https://piclist.example.com"); + expect(normalizePicGoEndpoint("http://192.168.1.20:36677/upload").hostname).toBe("192.168.1.20"); + expect(normalizePicGoEndpoint("http://[::1]:36677/upload").hostname).toBe("[::1]"); + expect(normalizePicGoServerKey(" nas-key ")).toBe("nas-key"); + const requests = []; + const endpoint = serve(async (request) => { + const url = new URL(request.url); + requests.push([url.pathname, url.searchParams.get("key"), request.method]); + if (url.pathname === "/heartbeat") return Response.json({ success: true, result: "alive" }); + return Response.json({ success: true, result: ["https://images.example.com/picture.png"] }); + }); + expect(await testPicGoConnection(endpoint, { serverKey: "nas-key" })).toBe(true); + expect(await uploadPicGoImage({ ...image(endpoint), serverKey: "nas-key" })).toEqual({ url: "https://images.example.com/picture.png" }); + expect(requests).toEqual([["/heartbeat", "nas-key", "POST"], ["/upload", "nas-key", "POST"]]); +}); + +test("resolves a remote name before connecting and refuses link-local targets", async () => { + const seen = []; + await testPicGoConnection("https://piclist.example.com:8443/upload", { + serverKey: "secret", + lookupImpl: async () => [{ address: "203.0.113.10", family: 4 }], + fetchImpl: async (url, init) => { + const parsed = new URL(url); + seen.push([parsed.origin, parsed.pathname, parsed.searchParams.get("key"), init.redirect]); + return Response.json({ success: true, result: "alive" }); + }, + }); + expect(seen).toEqual([["https://piclist.example.com:8443", "/heartbeat", "secret", "error"]]); + + let fetched = false; + await expect(testPicGoConnection("https://piclist.example.com/upload", { + serverKey: "secret", + lookupImpl: async () => [{ address: "169.254.169.254", family: 4 }], + fetchImpl: () => { fetched = true; throw new Error("must not fetch"); }, + })).rejects.toThrow(); + expect(fetched).toBe(false); + await expect(uploadPicGoImage({ ...image("http://169.254.169.254/upload"), serverKey: "secret" })).rejects.toThrow(); +}); + +test("rejects unsafe addresses and does not echo the server key", async () => { + for (const endpoint of [ + "file:///upload", + "http://169.254.169.254/upload", + "http://[::ffff:169.254.169.254]/upload", + "http://0.0.0.0/upload", + "http://[fe80::1]/upload", + "http://127.0.0.1/other", + "http://user:pass@localhost/upload", + "http://localhost/upload?secret=foo", + "http://localhost/upload?key=hidden", + "http://localhost/upload#foo", + "http://2852039166/upload", + "http://0xa9fea9fe/upload", + "http://[::ffff:a9fe:a9fe]/upload", + "https://metadata.google.internal/upload", + ]) { expect(() => normalizePicGoEndpoint(endpoint)).toThrow(); } - expect(normalizePicGoEndpoint("http://[::1]:36677/upload").hostname).toBe("[::1]"); + expect(normalizePicGoEndpoint("http://2130706433/upload").hostname).toBe("127.0.0.1"); + expect(normalizePicGoEndpoint("http://127.0.0.01/upload").hostname).toBe("127.0.0.1"); + expect(normalizePicGoEndpoint("http://[::ffff:c0a8:114]/upload").hostname).toBe("[::ffff:c0a8:114]"); + expect(isBlockedPicGoAddress("192.168.1.20")).toBe(false); + expect(isBlockedPicGoAddress("10.1.2.3")).toBe(false); + expect(isBlockedPicGoAddress("::ffff:c0a8:114")).toBe(false); + expect(() => normalizePicGoServerKey("bad key")).toThrow(); + expect(() => normalizePicGoServerKey("x".repeat(513))).toThrow(); + let failure = ""; + try { + await testPicGoConnection("http://127.0.0.1:9/upload", { + serverKey: "nas-key", + fetchImpl: async (url) => { throw new Error(`connect ECONNREFUSED ${url}`); }, + }); + } catch (error) { + failure = String(error); + } + expect(failure).toContain("could not be reached"); + expect(failure.includes("nas-key")).toBe(false); +}); + +test("only accepts image bytes", async () => { const endpoint = serve(() => { throw new Error("Should not send non-images"); }); await expect(uploadPicGoImage({ ...image(endpoint), mimeType: "application/pdf" })).rejects.toThrow(); await expect(uploadPicGoImage({ ...image(endpoint), bytes: new Uint8Array() })).rejects.toThrow(); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index d949bb4c..d5fcbd6c 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -54,7 +54,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ }, setSessionToken: (value) => ipcRenderer.invoke("desktop:set-session-token", value), clearSessionToken: () => ipcRenderer.invoke("desktop:clear-session-token"), - testPicGoConnection: (endpoint) => ipcRenderer.invoke("desktop:picgo-test", endpoint), + testPicGoConnection: (input) => ipcRenderer.invoke("desktop:picgo-test", input), uploadPicGoImage: (input) => ipcRenderer.invoke("desktop:picgo-upload", input), publicNetworkFetch: (requestId, input) => ipcRenderer.invoke("desktop:public-network-fetch", requestId, input), cancelPublicNetworkFetch: async (requestId) => { ipcRenderer.send("desktop:cancel-public-network-fetch", requestId); }, diff --git a/apps/web/src/components/settings/PicGoSettingsItem.tsx b/apps/web/src/components/settings/PicGoSettingsItem.tsx index bdc7aa9e..6fcb9bb5 100644 --- a/apps/web/src/components/settings/PicGoSettingsItem.tsx +++ b/apps/web/src/components/settings/PicGoSettingsItem.tsx @@ -4,27 +4,29 @@ import { Image } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { hasPicGoBridge, readPicGoPreference, writePicGoPreference, type PicGoPreference } from "@/lib/picgo"; +import { hasPicGoBridge, readPicGoPreference, takePicGoServerKey, writePicGoPreference, type PicGoPreference } from "@/lib/picgo"; import { SETTINGS_ITEM_DESCRIPTION_CLASSNAME, SETTINGS_ITEM_TITLE_CLASSNAME } from "./settings-ui"; +const picGoTargetId = (value: PicGoPreference) => `${value.endpoint}\0${value.serverKey}`; + export const PicGoSettingsItem = () => { const { t } = useTranslation(); const [saved, setSaved] = useState(readPicGoPreference); const [draft, setDraft] = useState(saved); - const [testedEndpoint, setTestedEndpoint] = useState(null); + const [testedTarget, setTestedTarget] = useState(null); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(""); const desktopAvailable = hasPicGoBridge(); - const dirty = draft.provider !== saved.provider || draft.endpoint !== saved.endpoint; - const canSave = dirty && (draft.provider === "edgeever" || (desktopAvailable && testedEndpoint === draft.endpoint)); + const dirty = draft.provider !== saved.provider || draft.endpoint !== saved.endpoint || draft.serverKey !== saved.serverKey; + const canSave = dirty && (draft.provider === "edgeever" || (desktopAvailable && testedTarget === picGoTargetId(draft))); const testConnection = async () => { setBusy(true); - setTestedEndpoint(null); + setTestedTarget(null); setStatus(""); try { - await window.edgeeverDesktop!.testPicGoConnection(draft.endpoint); - setTestedEndpoint(draft.endpoint); + await window.edgeeverDesktop!.testPicGoConnection({ endpoint: draft.endpoint, serverKey: draft.serverKey }); + setTestedTarget(picGoTargetId(draft)); setStatus(t("settings.picgo.connected")); } catch { setStatus(t("settings.picgo.connectionFailed")); @@ -62,10 +64,18 @@ export const PicGoSettingsItem = () => {

{t("settings.picgo.description")}

{ - setDraft({ ...draft, endpoint: event.target.value.trim() }); - setTestedEndpoint(null); + const next = takePicGoServerKey(event.target.value); + setDraft({ ...draft, endpoint: next.endpoint, serverKey: next.serverKey ?? draft.serverKey }); + setTestedTarget(null); setStatus(""); - }} spellCheck={false} /> + }} spellCheck={false} inputMode="url" autoComplete="off" /> + + { + setDraft({ ...draft, serverKey: event.target.value.trim() }); + setTestedTarget(null); + setStatus(""); + }} spellCheck={false} autoComplete="new-password" maxLength={512} /> +

{t("settings.picgo.serverKeyDescription")}

{t("settings.picgo.externalImages")}

) : null} diff --git a/apps/web/src/lib/picgo.test.mjs b/apps/web/src/lib/picgo.test.mjs index 6b9dc0f1..2390cb89 100644 --- a/apps/web/src/lib/picgo.test.mjs +++ b/apps/web/src/lib/picgo.test.mjs @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { PICGO_STORAGE_KEY, DEFAULT_PICGO_ENDPOINT, readPicGoPreference, shouldUploadWithPicGo, uploadImageWithPicGo, writePicGoPreference } from "./picgo.ts"; +import { PICGO_STORAGE_KEY, DEFAULT_PICGO_ENDPOINT, readPicGoPreference, shouldUploadWithPicGo, takePicGoServerKey, uploadImageWithPicGo, writePicGoPreference } from "./picgo.ts"; const previousWindow = globalThis.window; afterEach(() => { @@ -19,17 +19,17 @@ const file = () => new File([new Uint8Array([137, 80, 78, 71])], "image.png", { test("default, corrupt settings and ordinary Web runtime retain attachment uploads", async () => { const storage = setup(); - expect(readPicGoPreference()).toEqual({ provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT }); + expect(readPicGoPreference()).toEqual({ provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" }); storage.set(PICGO_STORAGE_KEY, "corrupt"); expect(shouldUploadWithPicGo()).toBe(false); - writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT }); + writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" }); expect(shouldUploadWithPicGo()).toBe(false); expect(await uploadImageWithPicGo(file(), copy)).toBeNull(); }); test("configured but disabled PicGo never sends an image", async () => { setup(() => { throw new Error("Should not upload"); }); - writePicGoPreference({ provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT }); + writePicGoPreference({ provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" }); expect(shouldUploadWithPicGo()).toBe(false); expect(await uploadImageWithPicGo(file(), copy)).toBeNull(); }); @@ -37,11 +37,12 @@ test("configured but disabled PicGo never sends an image", async () => { test("enabled PicGo passes actual bytes and returns an external image for either editor mode", async () => { setup(async (input) => { expect(input.endpoint).toBe(DEFAULT_PICGO_ENDPOINT); + expect(input.serverKey).toBe(""); expect(input.bytes).toEqual(new Uint8Array([137, 80, 78, 71])); expect(input.copy).toEqual(copy); return { url: "https://images.example.com/image.png" }; }); - writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT }); + writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" }); expect(shouldUploadWithPicGo()).toBe(true); expect(await uploadImageWithPicGo(file(), copy)).toEqual({ kind: "image", url: "https://images.example.com/image.png", filename: "image.png", mimeType: "image/png", byteSize: 4, @@ -50,8 +51,26 @@ test("enabled PicGo passes actual bytes and returns an external image for either test("only explicit fallback returns to attachment storage; cancellation stays a failure", async () => { setup(async () => ({ fallback: true })); - writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT }); + writePicGoPreference({ provider: "picgo", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" }); expect(await uploadImageWithPicGo(file(), copy)).toBeNull(); window.edgeeverDesktop.uploadPicGoImage = async () => { throw new Error("Cancelled"); }; await expect(uploadImageWithPicGo(file(), copy)).rejects.toThrow("Cancelled"); }); + +test("keeps an older saved address and lifts a pasted PicList key out of the address", () => { + const storage = setup(); + storage.set(PICGO_STORAGE_KEY, JSON.stringify({ provider: "picgo", endpoint: "https://piclist.example.com/upload" })); + expect(readPicGoPreference()).toEqual({ provider: "picgo", endpoint: "https://piclist.example.com/upload", serverKey: "" }); + expect(takePicGoServerKey("https://piclist.example.com/upload?key=nas-key")).toEqual({ + endpoint: "https://piclist.example.com/upload", serverKey: "nas-key", + }); + expect(takePicGoServerKey("https://piclist.example.com/upload?key=nas-key&picbed=s3")).toEqual({ + endpoint: "https://piclist.example.com/upload?key=nas-key&picbed=s3", + }); + setup(async (input) => { + expect(input.serverKey).toBe("nas-key"); + return { url: "https://images.example.com/image.png" }; + }); + writePicGoPreference({ provider: "picgo", endpoint: "https://piclist.example.com/upload", serverKey: "nas-key" }); + return uploadImageWithPicGo(file(), copy); +}); diff --git a/apps/web/src/lib/picgo.ts b/apps/web/src/lib/picgo.ts index 4907266d..2e1e5bc0 100644 --- a/apps/web/src/lib/picgo.ts +++ b/apps/web/src/lib/picgo.ts @@ -1,16 +1,37 @@ export const PICGO_STORAGE_KEY = "edgeever.desktop.picgo.v1"; export const DEFAULT_PICGO_ENDPOINT = "http://127.0.0.1:36677/upload"; -export type PicGoPreference = { provider: "edgeever" | "picgo"; endpoint: string }; +export type PicGoPreference = { provider: "edgeever" | "picgo"; endpoint: string; serverKey: string }; + +const emptyPicGoPreference = (): PicGoPreference => ({ provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT, serverKey: "" }); export const readPicGoPreference = (): PicGoPreference => { - const fallback: PicGoPreference = { provider: "edgeever", endpoint: DEFAULT_PICGO_ENDPOINT }; + const fallback = emptyPicGoPreference(); try { const value = JSON.parse(window.localStorage.getItem(PICGO_STORAGE_KEY) ?? "null"); if (!value || typeof value.endpoint !== "string") return fallback; - return { provider: value.provider === "picgo" ? "picgo" : "edgeever", endpoint: value.endpoint }; + return { + provider: value.provider === "picgo" ? "picgo" : "edgeever", + endpoint: value.endpoint, + serverKey: typeof value.serverKey === "string" ? value.serverKey : "", + }; } catch { return fallback; } }; +// A pasted PicList address may carry ?key=. Keep that secret out of the address field. +export const takePicGoServerKey = (value: string): { endpoint: string; serverKey?: string } => { + const endpoint = value.trim(); + try { + const url = new URL(endpoint); + const names = [...url.searchParams.keys()]; + if (names.length === 1 && names[0] === "key" && url.searchParams.getAll("key").length === 1 && !url.hash) { + const serverKey = url.searchParams.get("key")?.trim() ?? ""; + url.search = ""; + return { endpoint: url.href, serverKey }; + } + } catch { /* The field may be an incomplete address while the user is typing. */ } + return { endpoint }; +}; + export const writePicGoPreference = (value: PicGoPreference) => { // Fail visibly when persistence is unavailable; never claim a provider changed. window.localStorage.setItem(PICGO_STORAGE_KEY, JSON.stringify(value)); @@ -26,7 +47,7 @@ export const uploadImageWithPicGo = async (file: File, copy: PicGoFailureCopy) = const preference = readPicGoPreference(); if (preference.provider !== "picgo" || !hasPicGoBridge()) return null; const result = await window.edgeeverDesktop!.uploadPicGoImage({ - endpoint: preference.endpoint, bytes: new Uint8Array(await file.arrayBuffer()), + endpoint: preference.endpoint, serverKey: preference.serverKey, bytes: new Uint8Array(await file.arrayBuffer()), filename: file.name, mimeType: file.type, copy, }); return "url" in result ? { diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 5455cec7..85bcf4bc 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -49,9 +49,9 @@ interface EdgeEverDesktopBridge { readClipboard?(): Promise<{ html: string; text: string; images: Uint8Array[] }>; setSessionToken(value: string): Promise<{ stored: boolean }>; clearSessionToken(): Promise<{ stored: false }>; - testPicGoConnection(endpoint: string): Promise; + testPicGoConnection(input: { endpoint: string; serverKey?: string }): Promise; uploadPicGoImage(input: { - endpoint: string; bytes: Uint8Array; filename: string; mimeType: string; + endpoint: string; serverKey?: string; bytes: Uint8Array; filename: string; mimeType: string; copy: import("@/lib/picgo").PicGoFailureCopy; }): Promise<{ url: string } | { fallback: true }>; publicNetworkFetch(requestId: string, input: import("@edgeever/shared").PluginPublicFetchRequest): Promise; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index 803d6f8c..68ce2d8f 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -1003,19 +1003,21 @@ export const enUS = { webPreview: "Available on desktop only.", title: "Image hosting", builtin: "Built-in image hosting (default)", - external: "External image hosting (PicGo)", + external: "External image hosting (PicGo / PicList)", endpoint: "PicGo server address", - description: "Start PicGo and enable its local server first.", + description: "Use PicGo on this computer, or a remote PicList such as one on a NAS. Keep the path as /upload.", + serverKey: "Server key", + serverKeyDescription: "Fill this in when PicList requires a key. Leave it empty when there is no key. The key is sent with each request, so use HTTPS for a remote server.", externalImages: "Images are stored on the external image host and excluded from EdgeEver backups.", test: "Test connection", testing: "Testing…", connected: "Connected to PicGo. You can now save this setting.", - connectionFailed: "Could not connect. Check the local address and that PicGo Server is enabled without authentication.", + connectionFailed: "Could not connect. Check the address and server key, and confirm PicGo or PicList is running.", save: "Save", saved: "Saved", saveFailed: "Could not save the setting.", failedTitle: "PicGo upload failed", - failedMessage: "Check that PicGo is running and its image host is configured. Retrying may upload the image again.", + failedMessage: "Check the address, server key, and that PicGo or PicList is running with an image host configured. Retrying may upload the image again.", retry: "Retry", fallback: "Save to EdgeEver", cancel: "Cancel", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index 5cb2dea1..c05bd7e4 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -992,19 +992,21 @@ export const ja = { webPreview: "デスクトップ版のみ対応。", title: "画像ホスティング", builtin: "内蔵画像ホスティング(既定)", - external: "外部画像ホスティング(PicGo)", + external: "外部画像ホスティング(PicGo / PicList)", endpoint: "PicGo サーバーアドレス", - description: "先に PicGo を起動し、ローカルサーバーを有効にしてください。", + description: "このパソコンの PicGo、または NAS などの遠隔 PicList を指定します。パスは /upload のままにしてください。", + serverKey: "サーバーキー", + serverKeyDescription: "PicList がキーを要求するときだけ入力します。不要なら空のままにします。キーはリクエストと一緒に送られるので、遠隔サーバーでは HTTPS を使ってください。", externalImages: "画像は外部ホストに保存され、EdgeEver のバックアップには含まれません。", test: "接続テスト", testing: "テスト中…", connected: "PicGo に接続しました。設定を保存できます。", - connectionFailed: "接続できません。アドレスを確認し、PicGo サーバーを認証なしで有効にしてください。", + connectionFailed: "接続できません。アドレスとキーを確認し、PicGo または PicList が起動していることを確認してください。", save: "保存", saved: "保存しました", saveFailed: "設定を保存できませんでした。", failedTitle: "PicGo のアップロードに失敗しました", - failedMessage: "PicGo の起動と画像ホストの設定を確認してください。再試行すると画像が再度アップロードされる場合があります。", + failedMessage: "アドレス、キー、PicGo または PicList の起動と画像ホストの設定を確認してください。再試行すると画像が再度アップロードされる場合があります。", retry: "再試行", fallback: "EdgeEver に保存", cancel: "キャンセル", diff --git a/packages/shared/src/i18n/pl.ts b/packages/shared/src/i18n/pl.ts index 41a86176..2f4b6188 100644 --- a/packages/shared/src/i18n/pl.ts +++ b/packages/shared/src/i18n/pl.ts @@ -992,19 +992,21 @@ export const pl = { webPreview: "Dostępne tylko w aplikacji komputerowej.", title: "Hosting obrazów", builtin: "Wbudowany hosting obrazów (domyślny)", - external: "Zewnętrzny hosting obrazów (PicGo)", + external: "Zewnętrzny hosting obrazów (PicGo / PicList)", endpoint: "Adres serwera PicGo", - description: "Najpierw uruchom PicGo i włącz lokalny serwer.", + description: "Podaj PicGo na tym komputerze albo zdalny PicList, na przykład na NAS. Ścieżka musi być /upload.", + serverKey: "Klucz serwera", + serverKeyDescription: "Wpisz tylko wtedy, gdy PicList wymaga klucza. Zostaw puste, gdy klucza nie ma. Klucz jest wysyłany z każdym żądaniem, więc dla zdalnego serwera użyj HTTPS.", externalImages: "Obrazy są przechowywane na zewnętrznym hostingu i nie są uwzględniane w kopiach zapasowych EdgeEver.", test: "Testuj połączenie", testing: "Testowanie…", connected: "Połączono z PicGo. Możesz zapisać ustawienie.", - connectionFailed: "Nie udało się połączyć. Sprawdź adres i włącz serwer PicGo bez uwierzytelniania.", + connectionFailed: "Nie udało się połączyć. Sprawdź adres i klucz oraz czy PicGo lub PicList działa.", save: "Zapisz", saved: "Zapisano", saveFailed: "Nie udało się zapisać ustawienia.", failedTitle: "Przesyłanie do PicGo nie powiodło się", - failedMessage: "Sprawdź, czy PicGo działa i hosting jest skonfigurowany. Ponowienie może przesłać obraz ponownie.", + failedMessage: "Sprawdź adres, klucz oraz czy PicGo lub PicList działa i hosting jest skonfigurowany. Ponowienie może przesłać obraz ponownie.", retry: "Ponów", fallback: "Zapisz w EdgeEver", cancel: "Anuluj", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 2be15dca..41990e3b 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -1003,19 +1003,21 @@ export const zhCN = { webPreview: "仅桌面端可用。", title: "图床", builtin: "内置图床(默认)", - external: "外链图床(PicGo)", + external: "外链图床(PicGo / PicList)", endpoint: "PicGo 服务地址", - description: "请先启动 PicGo 并开启本地服务。", + description: "可以填本机 PicGo,或 NAS 等远程 PicList。路径保持为 /upload。", + serverKey: "服务密钥", + serverKeyDescription: "PicList 开启密钥时填写,没有密钥就留空。密钥会随请求发送,远程服务请用 HTTPS。", externalImages: "图片保存在外部图床,不纳入 EdgeEver 备份。", test: "测试连接", testing: "测试中…", connected: "已连接 PicGo,可以保存设置。", - connectionFailed: "连接失败,请检查本地地址,确认 PicGo 服务已开启且未启用鉴权。", + connectionFailed: "连接失败。请检查地址和密钥,并确认 PicGo 或 PicList 已开启。", save: "保存", saved: "已保存", saveFailed: "无法保存设置。", failedTitle: "PicGo 上传失败", - failedMessage: "请检查 PicGo 是否运行及图床配置。重试可能会再次上传图片。", + failedMessage: "请检查地址、密钥,以及 PicGo 或 PicList 是否在运行、图床是否已配置。重试可能会再次上传图片。", retry: "重试", fallback: "改存 EdgeEver", cancel: "取消",