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.
This commit is contained in:
1 parent
f7f10e64ee
commit
cf3435de43
17 files changed
+297
-67
No files matched your search
@@ -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({
|
||||
|
||||
+112
-17
@@ -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/")) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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); },
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [testedTarget, setTestedTarget] = useState<string | null>(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 = () => {
|
||||
<p className={SETTINGS_ITEM_DESCRIPTION_CLASSNAME}>{t("settings.picgo.description")}</p>
|
||||
<label htmlFor="picgo-endpoint" className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.picgo.endpoint")}</label>
|
||||
<Input id="picgo-endpoint" value={draft.endpoint} disabled={busy} onChange={(event) => {
|
||||
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" />
|
||||
<label htmlFor="picgo-server-key" className={SETTINGS_ITEM_TITLE_CLASSNAME}>{t("settings.picgo.serverKey")}</label>
|
||||
<Input id="picgo-server-key" name="picgo-server-key" type="password" value={draft.serverKey} disabled={busy} onChange={(event) => {
|
||||
setDraft({ ...draft, serverKey: event.target.value.trim() });
|
||||
setTestedTarget(null);
|
||||
setStatus("");
|
||||
}} spellCheck={false} autoComplete="new-password" maxLength={512} />
|
||||
<p className={SETTINGS_ITEM_DESCRIPTION_CLASSNAME}>{t("settings.picgo.serverKeyDescription")}</p>
|
||||
<p className={SETTINGS_ITEM_DESCRIPTION_CLASSNAME}>{t("settings.picgo.externalImages")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 ? {
|
||||
|
||||
Vendored
+2
-2
@@ -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<boolean>;
|
||||
testPicGoConnection(input: { endpoint: string; serverKey?: string }): Promise<boolean>;
|
||||
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<import("@edgeever/shared").PluginPublicFetchResponse>;
|
||||
|
||||
Reference in new issue
Block a user