417 lines
10 KiB
JavaScript
417 lines
10 KiB
JavaScript
/**
|
|||
|
|
* mnote-web vault API client (background only).
|
||
|
|
* Token / secrets never enter content scripts.
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @typedef {object} VaultConfig
|
||
|
|
* @property {string} baseUrl
|
||
|
|
* @property {string} rootUri
|
||
|
|
* @property {string} [token]
|
||
|
|
* @property {string} [userId]
|
||
|
|
* @property {string} [email]
|
||
|
|
*/
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {string} baseUrl
|
||
|
|
* @returns {string}
|
||
|
|
*/
|
||
|
|
export function normalizeBaseUrl(baseUrl) {
|
||
|
|
const raw = String(baseUrl || "").trim().replace(/\/+$/, "");
|
||
|
|
if (!raw) throw new Error("baseUrl 不能为空");
|
||
|
|
let u;
|
||
|
|
try {
|
||
|
|
u = new URL(raw);
|
||
|
|
} catch {
|
||
|
|
throw new Error("baseUrl 不是合法 URL");
|
||
|
|
}
|
||
|
|
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
||
|
|
throw new Error("baseUrl 仅支持 http/https");
|
||
|
|
}
|
||
|
|
return u.origin + (u.pathname === "/" ? "" : u.pathname.replace(/\/+$/, ""));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {string} baseUrl
|
||
|
|
* @param {string} path
|
||
|
|
* @param {object} [opts]
|
||
|
|
* @param {string} [opts.token]
|
||
|
|
* @param {string} [opts.method]
|
||
|
|
* @param {object} [opts.body]
|
||
|
|
* @param {boolean} [opts.credentials]
|
||
|
|
*/
|
||
|
|
export async function vaultFetch(baseUrl, path, opts = {}) {
|
||
|
|
const base = normalizeBaseUrl(baseUrl);
|
||
|
|
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||
|
|
/** @type {Record<string, string>} */
|
||
|
|
const headers = {
|
||
|
|
Accept: "application/json",
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
};
|
||
|
|
if (opts.token) {
|
||
|
|
headers.Authorization = `Bearer ${opts.token}`;
|
||
|
|
}
|
||
|
|
const init = {
|
||
|
|
method: opts.method || (opts.body ? "POST" : "GET"),
|
||
|
|
headers,
|
||
|
|
credentials: opts.credentials ? "include" : "omit",
|
||
|
|
};
|
||
|
|
if (opts.body !== undefined) {
|
||
|
|
init.body = JSON.stringify(opts.body);
|
||
|
|
}
|
||
|
|
let res;
|
||
|
|
try {
|
||
|
|
res = await fetch(url, init);
|
||
|
|
} catch (e) {
|
||
|
|
const err = new Error(
|
||
|
|
`Failed to fetch ${url}: ${e?.message || e}(若是扩展请求,先确认已授权该 origin 的 host 权限)`
|
||
|
|
);
|
||
|
|
err.cause = e;
|
||
|
|
err.code = "network_error";
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
let data = null;
|
||
|
|
const text = await res.text();
|
||
|
|
try {
|
||
|
|
data = text ? JSON.parse(text) : null;
|
||
|
|
} catch {
|
||
|
|
data = { raw: text };
|
||
|
|
}
|
||
|
|
if (!res.ok) {
|
||
|
|
const code = data?.code || data?.error || `http_${res.status}`;
|
||
|
|
const msg =
|
||
|
|
data?.message || data?.error || text || `${res.status} ${res.statusText}`;
|
||
|
|
const err = new Error(String(msg));
|
||
|
|
err.code = code;
|
||
|
|
err.status = res.status;
|
||
|
|
err.body = data;
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
return data;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Auth sign-in (sets session cookies when credentials:include).
|
||
|
|
* @param {string} baseUrl
|
||
|
|
* @param {{ email: string, password: string }} creds
|
||
|
|
*/
|
||
|
|
export async function signIn(baseUrl, creds) {
|
||
|
|
const email = String(creds.email || "").trim();
|
||
|
|
const password = String(creds.password || "");
|
||
|
|
const name = email.includes("@") ? email.split("@")[0] : email;
|
||
|
|
// 与 mnote-web /auth 页、scripts/mnote-vault-cli.js 对齐
|
||
|
|
return vaultFetch(baseUrl, "/api/auth", {
|
||
|
|
method: "POST",
|
||
|
|
credentials: true,
|
||
|
|
body: {
|
||
|
|
action: "auth:signIn",
|
||
|
|
args: {
|
||
|
|
provider: "password",
|
||
|
|
params: {
|
||
|
|
password,
|
||
|
|
flow: "signIn",
|
||
|
|
account: email,
|
||
|
|
email,
|
||
|
|
name,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Issue E2 extension token (requires session cookie from signIn).
|
||
|
|
* @param {string} baseUrl
|
||
|
|
* @param {object} [opts]
|
||
|
|
*/
|
||
|
|
export async function issueExtensionToken(baseUrl, opts = {}) {
|
||
|
|
return vaultFetch(baseUrl, "/api/vault/extension/token", {
|
||
|
|
method: "POST",
|
||
|
|
credentials: true,
|
||
|
|
body: {
|
||
|
|
clientId: "chrome-extension",
|
||
|
|
extensionId: opts.extensionId || null,
|
||
|
|
ttlHours: opts.ttlHours ?? 168,
|
||
|
|
email: opts.email || null,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 当前登录账号的默认 local_folder 工作区(每用户固定 my-space)。
|
||
|
|
* POST /api/local-folder/workspaces/default — 不存在则创建。
|
||
|
|
* @param {string} baseUrl
|
||
|
|
* @param {{ token?: string, credentials?: boolean }} [opts]
|
||
|
|
* @returns {Promise<{ rootUri: string, rootPath?: string }>}
|
||
|
|
*/
|
||
|
|
export async function ensureDefaultWorkspace(baseUrl, opts = {}) {
|
||
|
|
const data = await vaultFetch(
|
||
|
|
baseUrl,
|
||
|
|
"/api/local-folder/workspaces/default",
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
credentials: opts.credentials !== false && !opts.token,
|
||
|
|
token: opts.token,
|
||
|
|
body: {},
|
||
|
|
}
|
||
|
|
);
|
||
|
|
const ws = data?.workspace || data?.result?.workspace || data?.result || data;
|
||
|
|
const rootUri = String(ws?.rootUri || "").trim();
|
||
|
|
if (!rootUri.startsWith("file://")) {
|
||
|
|
throw new Error(
|
||
|
|
"无法解析默认工作区 rootUri(请确认账号已登录且 mnote-web 可用)"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
rootUri,
|
||
|
|
rootPath: ws?.rootPath ? String(ws.rootPath) : undefined,
|
||
|
|
workspaceId: ws?.manifest?.workspaceId || ws?.workspaceId,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {VaultConfig} cfg
|
||
|
|
*/
|
||
|
|
export async function whoami(cfg) {
|
||
|
|
return vaultFetch(cfg.baseUrl, "/api/auth/whoami", {
|
||
|
|
method: "GET",
|
||
|
|
token: cfg.token,
|
||
|
|
credentials: !cfg.token,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {VaultConfig} cfg
|
||
|
|
*/
|
||
|
|
export async function listVault(cfg) {
|
||
|
|
const q = new URLSearchParams({
|
||
|
|
rootUri: cfg.rootUri,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
});
|
||
|
|
return vaultFetch(cfg.baseUrl, `/api/vault/list?${q}`, {
|
||
|
|
method: "GET",
|
||
|
|
token: cfg.token,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {VaultConfig} cfg
|
||
|
|
* @param {object} item
|
||
|
|
*/
|
||
|
|
export async function createItem(cfg, item) {
|
||
|
|
return vaultFetch(cfg.baseUrl, "/api/vault/items", {
|
||
|
|
method: "POST",
|
||
|
|
token: cfg.token,
|
||
|
|
body: {
|
||
|
|
rootUri: cfg.rootUri,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
...item,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {VaultConfig} cfg
|
||
|
|
* @param {string} id
|
||
|
|
* @param {object} patch
|
||
|
|
*/
|
||
|
|
export async function updateItem(cfg, id, patch) {
|
||
|
|
return vaultFetch(cfg.baseUrl, `/api/vault/items/${encodeURIComponent(id)}`, {
|
||
|
|
method: "PATCH",
|
||
|
|
token: cfg.token,
|
||
|
|
body: {
|
||
|
|
rootUri: cfg.rootUri,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
...patch,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {VaultConfig} cfg
|
||
|
|
* @param {string} id
|
||
|
|
*/
|
||
|
|
export async function getItem(cfg, id) {
|
||
|
|
const q = new URLSearchParams({
|
||
|
|
rootUri: cfg.rootUri,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
});
|
||
|
|
return vaultFetch(
|
||
|
|
cfg.baseUrl,
|
||
|
|
`/api/vault/items/${encodeURIComponent(id)}?${q}`,
|
||
|
|
{
|
||
|
|
method: "GET",
|
||
|
|
token: cfg.token,
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Reveal a secret field (password etc.) for dedupe / autofill decisions.
|
||
|
|
* @param {VaultConfig} cfg
|
||
|
|
* @param {string} id
|
||
|
|
* @param {{ field?: string, accountId?: string, secretId?: string }} [opts]
|
||
|
|
*/
|
||
|
|
export async function revealField(cfg, id, opts = {}) {
|
||
|
|
return vaultFetch(
|
||
|
|
cfg.baseUrl,
|
||
|
|
`/api/vault/items/${encodeURIComponent(id)}/reveal`,
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
token: cfg.token,
|
||
|
|
body: {
|
||
|
|
rootUri: cfg.rootUri,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
field: opts.field || "password",
|
||
|
|
accountId: opts.accountId,
|
||
|
|
secretId: opts.secretId,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* PUT session file for credential/account.
|
||
|
|
* @param {VaultConfig} cfg
|
||
|
|
* @param {string} id
|
||
|
|
* @param {object} session
|
||
|
|
*/
|
||
|
|
export async function putSession(cfg, id, session) {
|
||
|
|
return vaultFetch(
|
||
|
|
cfg.baseUrl,
|
||
|
|
`/api/vault/items/${encodeURIComponent(id)}/session`,
|
||
|
|
{
|
||
|
|
method: "PUT",
|
||
|
|
token: cfg.token,
|
||
|
|
body: {
|
||
|
|
rootUri: cfg.rootUri,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
source: "chrome_extension",
|
||
|
|
...session,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {VaultConfig} cfg
|
||
|
|
* @param {string} id
|
||
|
|
* @param {object} [opts]
|
||
|
|
*/
|
||
|
|
export async function shareToAi(cfg, id, opts = {}) {
|
||
|
|
return vaultFetch(
|
||
|
|
cfg.baseUrl,
|
||
|
|
`/api/vault/items/${encodeURIComponent(id)}/share-to-ai`,
|
||
|
|
{
|
||
|
|
method: "POST",
|
||
|
|
token: cfg.token,
|
||
|
|
body: {
|
||
|
|
rootUri: cfg.rootUri,
|
||
|
|
sourceKind: "local_folder",
|
||
|
|
...opts,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 从 list 条目收集唯一 folderPath(含父路径前缀)。
|
||
|
|
* @param {Array} items
|
||
|
|
* @returns {string[]}
|
||
|
|
*/
|
||
|
|
export function collectFolderPaths(items) {
|
||
|
|
/** @type {Record<string, true>} */
|
||
|
|
const set = {};
|
||
|
|
const list = Array.isArray(items) ? items : [];
|
||
|
|
for (const it of list) {
|
||
|
|
const fp = String(it?.folderPath || "").trim();
|
||
|
|
if (!fp) continue;
|
||
|
|
set[fp] = true;
|
||
|
|
const parts = fp.split("/").filter(Boolean);
|
||
|
|
for (let i = 1; i < parts.length; i++) {
|
||
|
|
set[parts.slice(0, i).join("/")] = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return Object.keys(set).sort((a, b) => a.localeCompare(b, "zh"));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Match list items by origin (client-side filter).
|
||
|
|
* @param {Array} items
|
||
|
|
* @param {string} pageUrl
|
||
|
|
*/
|
||
|
|
export function matchItemsByUrl(items, pageUrl) {
|
||
|
|
let origin = "";
|
||
|
|
let hostname = "";
|
||
|
|
try {
|
||
|
|
const u = new URL(pageUrl);
|
||
|
|
origin = u.origin;
|
||
|
|
hostname = u.hostname || "";
|
||
|
|
} catch {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
const list = Array.isArray(items) ? items : [];
|
||
|
|
const hostOf = (raw) => {
|
||
|
|
try {
|
||
|
|
return new URL(String(raw)).hostname || "";
|
||
|
|
} catch {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const sameSite = (a, b) => {
|
||
|
|
if (!a || !b) return false;
|
||
|
|
if (a === b) return true;
|
||
|
|
return a.endsWith(`.${b}`) || b.endsWith(`.${a}`);
|
||
|
|
};
|
||
|
|
return list
|
||
|
|
.filter((it) => {
|
||
|
|
if (it && it.status && String(it.status).toLowerCase() === "deleted") {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
const urls = [];
|
||
|
|
if (it.url) urls.push(String(it.url));
|
||
|
|
if (Array.isArray(it.urls)) urls.push(...it.urls.map(String));
|
||
|
|
return urls.some((u) => {
|
||
|
|
try {
|
||
|
|
const ou = new URL(u);
|
||
|
|
if (ou.origin === origin) return true;
|
||
|
|
return sameSite(ou.hostname, hostname);
|
||
|
|
} catch {
|
||
|
|
const bare = origin.replace(/^https?:\/\//, "");
|
||
|
|
return String(u).includes(bare) || sameSite(hostOf(u), hostname);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
})
|
||
|
|
.sort((a, b) =>
|
||
|
|
String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Find a list item that matches page origin + username (best effort).
|
||
|
|
* @param {Array} items
|
||
|
|
* @param {string} pageUrl
|
||
|
|
* @param {string} [username]
|
||
|
|
*/
|
||
|
|
export function findMatchingCredential(items, pageUrl, username) {
|
||
|
|
const matched = matchItemsByUrl(items, pageUrl);
|
||
|
|
if (!matched.length) return null;
|
||
|
|
const want = String(username || "")
|
||
|
|
.trim()
|
||
|
|
.toLowerCase();
|
||
|
|
if (!want) return matched[0];
|
||
|
|
const byUser = matched.find((it) => {
|
||
|
|
const candidates = [
|
||
|
|
it.username,
|
||
|
|
it.email,
|
||
|
|
...(Array.isArray(it.accounts)
|
||
|
|
? it.accounts.flatMap((a) => [a?.username, a?.email])
|
||
|
|
: []),
|
||
|
|
]
|
||
|
|
.filter(Boolean)
|
||
|
|
.map((s) => String(s).trim().toLowerCase());
|
||
|
|
return candidates.includes(want);
|
||
|
|
});
|
||
|
|
return byUser || matched[0];
|
||
|
|
}
|