diff --git a/client/components/anything_picker.tsx b/client/components/anything_picker.tsx index f69751f9..a016e058 100644 --- a/client/components/anything_picker.tsx +++ b/client/components/anything_picker.tsx @@ -305,10 +305,10 @@ export function AnythingPicker({ allowNew={anchorMode ? false : allowNew} helpText={ anchorMode - ? "按 Enter 跳转到选中的锚点。如果列表为空,表示当前空间还没有锚点。" - : `按 Enter 打开选中的${openableType}` + + ? "按回车键跳转到选中的锚点。如果列表为空,表示当前空间还没有锚点。" + : `按回车键打开选中的${openableType}` + (allowNew - ? `,或按 Shift-Enter 使用当前名称新建${creatableType}。` + ? `,或按Shift + 回车键使用当前名称新建${creatableType}。` : "。") } newHint={`新建${creatableType}`} diff --git a/client/components/basic_modals.tsx b/client/components/basic_modals.tsx index 50233793..02a43801 100644 --- a/client/components/basic_modals.tsx +++ b/client/components/basic_modals.tsx @@ -40,7 +40,7 @@ export function Prompt({ />
+ {accountManaged && ( + + + 我的账号 + + )}
)}
@@ -2401,11 +2407,11 @@ export function PlainleafWorkspace({ )} {accountManaged ? ( - + - 账号管理 - 管理用户、密码与权限 + 我的账号 + 修改密码与管理 API 令牌 ) : ( @@ -2657,6 +2663,20 @@ export function PlainleafWorkspace({ )}
+ {accountManaged && ( +
+

账号与空间

+
+ + 我的账号 + 修改密码与管理 API 令牌 + + + 打开 + +
+
+ )}

高级工具

diff --git a/client/plugos/proxy_fetch.ts b/client/plugos/proxy_fetch.ts index 52ba76b6..b5fa384a 100644 --- a/client/plugos/proxy_fetch.ts +++ b/client/plugos/proxy_fetch.ts @@ -18,6 +18,7 @@ export type ProxyFetchRequest = { method?: string; headers?: Record; responseEncoding?: string; + timeout?: number; body?: Uint8Array | string | any; }; diff --git a/client/plugos/syscalls/fetch.ts b/client/plugos/syscalls/fetch.ts index cb9403c9..f0ef39d5 100644 --- a/client/plugos/syscalls/fetch.ts +++ b/client/plugos/syscalls/fetch.ts @@ -43,6 +43,7 @@ export function sandboxFetchSyscalls(client: Client): SysCallMapping { const resp = await client.httpSpacePrimitives.authenticatedFetch( buildProxyUrl(client, url), fetchOptions, + options.timeout, ); // Do sensible things with the body based on the content type // Read as ArrayBuffer first to safely handle empty responses (e.g. diff --git a/client/plugos/syscalls/space.test.ts b/client/plugos/syscalls/space.test.ts new file mode 100644 index 00000000..b8606423 --- /dev/null +++ b/client/plugos/syscalls/space.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test, vi } from "vitest"; +import { spaceReadSyscalls, spaceWriteSyscalls } from "./space.ts"; + +function callback(mapping: ReturnType, name: string) { + const entry = mapping[name]; + return typeof entry === "function" ? entry : entry.callback; +} + +describe("Base64 file syscalls", () => { + test("reads binary files as Base64 across the plug boundary", async () => { + const client = { + space: { + spacePrimitives: { + readFile: vi.fn(async () => ({ + data: new Uint8Array([0, 1, 2, 255]), + meta: {}, + })), + }, + }, + } as any; + + const read = callback(spaceReadSyscalls(client), "space.readFileBase64"); + await expect(read({}, "plug.js")).resolves.toBe("AAEC/w=="); + }); + + test("decodes Base64 only after reaching the main thread", async () => { + const writeFile = vi.fn(async (_name: string, _data: Uint8Array) => ({})); + const client = { + space: { spacePrimitives: { writeFile } }, + } as any; + + const write = callback(spaceWriteSyscalls(client), "space.writeFileBase64"); + await write({}, "plug.js", "AAEC/w=="); + + expect(writeFile).toHaveBeenCalledOnce(); + expect(writeFile.mock.calls[0][0]).toBe("plug.js"); + expect(writeFile.mock.calls[0][1]).toEqual(new Uint8Array([0, 1, 2, 255])); + }); + + test("rejects non-string Base64 payloads before writing", async () => { + const writeFile = vi.fn(async (_name: string, _data: Uint8Array) => ({})); + const client = { + space: { spacePrimitives: { writeFile } }, + } as any; + const write = callback(spaceWriteSyscalls(client), "space.writeFileBase64"); + + expect(() => write({}, "plug.js", undefined)).toThrow( + "Base64 file data must be a string", + ); + expect(writeFile).not.toHaveBeenCalled(); + }); +}); diff --git a/client/plugos/syscalls/space.ts b/client/plugos/syscalls/space.ts index 324f77ee..8b55b494 100644 --- a/client/plugos/syscalls/space.ts +++ b/client/plugos/syscalls/space.ts @@ -7,6 +7,7 @@ import type { FileMeta, PageMeta, } from "@silverbulletmd/silverbullet/type/index"; +import { base64Decode, base64Encode } from "../../../plug-api/lib/crypto.ts"; export function spaceReadSyscalls(client: Client): SysCallMapping { return { @@ -116,6 +117,13 @@ export function spaceReadSyscalls(client: Client): SysCallMapping { description: "Reads an arbitrary space file as binary data.", signatures: ["space.readFile(name)"], }, + "space.readFileBase64": { + callback: async (_ctx, name: string): Promise => + base64Encode((await client.space.spacePrimitives.readFile(name)).data), + description: + "Reads an arbitrary file as Base64 without transferring binary data across the plug worker boundary.", + signatures: ["space.readFileBase64(name)"], + }, "space.readFileWithMeta": { callback: async ( _ctx, @@ -185,6 +193,17 @@ export function spaceWriteSyscalls(editor: Client): SysCallMapping { description: "Writes an arbitrary binary file and returns its metadata.", signatures: ["space.writeFile(name, data)"], }, + "space.writeFileBase64": { + callback: (_ctx, name: string, data: string): Promise => { + if (typeof data !== "string") { + throw new TypeError("Base64 file data must be a string"); + } + return editor.space.spacePrimitives.writeFile(name, base64Decode(data)); + }, + description: + "Writes a Base64-encoded file and returns its metadata without transferring binary data across the plug worker boundary.", + signatures: ["space.writeFileBase64(name, base64Data)"], + }, "space.deleteFile": { callback: (_ctx, name: string) => editor.space.spacePrimitives.deleteFile(name), diff --git a/client/plugos/syscalls/system.ts b/client/plugos/syscalls/system.ts index d9e3610b..65c11a59 100644 --- a/client/plugos/syscalls/system.ts +++ b/client/plugos/syscalls/system.ts @@ -226,6 +226,14 @@ export function systemSyscalls( callback: () => publicVersion, description: "Returns the running SilverBullet version.", }, + "system.getCapabilities": { + callback: () => ({ + shell: client.bootConfig.shellBackend === "local", + runtimeApi: client.bootConfig.runtimeApiAvailable === true, + }), + description: + "Returns the server capabilities available to the current space.", + }, "system.getConfig": { callback: (_ctx, key: string, defaultValue: any = undefined) => client.config.get(key, defaultValue), diff --git a/client/space_lua/stdlib/net.ts b/client/space_lua/stdlib/net.ts index ee9baae6..a87e6338 100644 --- a/client/space_lua/stdlib/net.ts +++ b/client/space_lua/stdlib/net.ts @@ -31,6 +31,7 @@ export const netApi = new LuaTable({ const resp = await client.httpSpacePrimitives.authenticatedFetch( buildProxyUrl(client, url), fetchOptions, + options.timeout, ); if (resp.status !== 200) { return { diff --git a/client/spaces_ui/api.ts b/client/spaces_ui/api.ts index 7c39ba5a..8ff80ec2 100644 --- a/client/spaces_ui/api.ts +++ b/client/spaces_ui/api.ts @@ -76,6 +76,32 @@ export function getSession(): Promise<{ username: string; admin: boolean }> { return api("GET", "api/session"); } +export function getOwnAccount(): Promise<{ + username: string; + user: UserInfo; +}> { + return api("GET", "api/account"); +} + +export function changeOwnPassword( + currentPassword: string, + newPassword: string, +): Promise { + return api("POST", "api/account/password", { + currentPassword, + newPassword, + }); +} + +export async function createOwnToken(name: string): Promise { + const result = await api("POST", "api/account/tokens", { name }); + return result.token; +} + +export function deleteOwnToken(name: string): Promise { + return api("DELETE", `api/account/tokens/${encodeURIComponent(name)}`); +} + export function createUser( username: string, password: string, diff --git a/client/spaces_ui/components/AccountView.tsx b/client/spaces_ui/components/AccountView.tsx new file mode 100644 index 00000000..fd94b233 --- /dev/null +++ b/client/spaces_ui/components/AccountView.tsx @@ -0,0 +1,160 @@ +import { Alert, Badge, Button, Input } from "@silverbulletmd/silverbullet/ui"; +import { useEffect, useState } from "preact/hooks"; +import { + changeOwnPassword, + createOwnToken, + deleteOwnToken, + formatApiError, + getOwnAccount, +} from "../api.ts"; +import { spacesUrl } from "../routes.ts"; +import type { AuthState, UserInfo } from "../types.ts"; + +export function AccountView({ + auth, + onUnauthorized, +}: { + auth: Extract; + onUnauthorized: () => void; +}) { + const [user, setUser] = useState(); + const [error, setError] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [tokenName, setTokenName] = useState(""); + const [shownToken, setShownToken] = useState(); + + async function reload() { + try { + const account = await getOwnAccount(); + setUser(account.user); + setError(""); + } catch (error: any) { + if (error.unauthorized) onUnauthorized(); + else setError(formatApiError(error)); + } + } + + useEffect(() => { + void reload(); + }, []); + + async function run(action: () => Promise) { + try { + await action(); + setError(""); + } catch (error: any) { + if (error.unauthorized) onUnauthorized(); + else setError(formatApiError(error)); + } + } + + if (!user && !error) return

正在加载…

; + return ( +
+

+ {auth.username} {auth.admin ? "管理员" : "普通用户"} +

+ {error && {error}} + {auth.admin && ( +

+ + 管理全部用户 + +

+ )} +
+

修改密码

+

修改后其他设备上的登录会话将失效。

+
+ setCurrentPassword(event.currentTarget.value)} + /> + setNewPassword(event.currentTarget.value)} + /> + +
+
+
+

我的 API 令牌

+

用于外部工具访问,请像密码一样妥善保管。

+ {user && Object.keys(user.tokens).length === 0 &&

还没有令牌。

} + {user && Object.keys(user.tokens).length > 0 && ( +
    + {Object.entries(user.tokens).map(([name, token]) => ( +
  • + {name} + 创建于 {new Date(token.createdAt).toLocaleString()} + +
  • + ))} +
+ )} +
+ setTokenName(event.currentTarget.value)} + /> + +
+ {shownToken && ( +
+ 此令牌只显示一次,请立即妥善保存。 + event.currentTarget.select()} + /> + +
+ )} +
+
+ ); +} diff --git a/client/spaces_ui/components/App.tsx b/client/spaces_ui/components/App.tsx index ff54b4c4..c9ef4719 100644 --- a/client/spaces_ui/components/App.tsx +++ b/client/spaces_ui/components/App.tsx @@ -11,6 +11,7 @@ import { loginUrl, safeSpacesDestination, spacesUrl } from "../routes.ts"; import type { SpacesRoute } from "../routes.ts"; import type { AuthState } from "../types.ts"; import { Login } from "./Login.tsx"; +import { AccountView } from "./AccountView.tsx"; import { SpaceEditor } from "./SpaceEditor.tsx"; import { SpaceList } from "./SpaceList.tsx"; import { NewUser, UserDetail, UserList } from "./UsersView.tsx"; @@ -51,15 +52,20 @@ const UserDetailScreen = ({ route, auth, onUnauthorized }: ScreenProps) => ( onUnauthorized={onUnauthorized} /> ); +const AccountScreen = ({ auth, onUnauthorized }: ScreenProps) => ( + +); // Keyed on SpacesRoute["screen"], so TypeScript requires an entry for every // route variant: adding a route without deciding its admin requirement is a // compile error rather than a silently public screen. // -// `admin` is a DISPLAY decision, not a security boundary. Every screen's data -// comes from `api/admin/*`, which authorizes server-side on every request. +// `admin` is a DISPLAY decision, not a security boundary. Admin screens use +// `api/admin/*`; the account screen uses the authenticated self-service API. +// Both authorize server-side on every request. const SCREENS: Record = { spaces: { view: SpaceListScreen, admin: false }, + account: { view: AccountScreen, admin: false }, "space-new": { view: SpaceNewScreen, admin: true }, space: { view: SpaceEditScreen, admin: true }, users: { view: UserListScreen, admin: true }, @@ -129,6 +135,7 @@ export function App() { const onUnauthorized = () => location.replace(loginUrl()); const onSpacesTab = route.screen.startsWith("space"); const onUsersTab = route.screen.startsWith("user"); + const onAccountTab = route.screen === "account"; return (
@@ -157,6 +164,24 @@ export function App() { > 用户 + + 我的账号 + + + )} + {!auth.admin && ( + )}
diff --git a/client/spaces_ui/routes.test.ts b/client/spaces_ui/routes.test.ts index 9cff9ef2..1d8a81b4 100644 --- a/client/spaces_ui/routes.test.ts +++ b/client/spaces_ui/routes.test.ts @@ -59,6 +59,9 @@ test("static segments beat the space-id catch-all", async () => { expect((await load("/.spaces/users/new")).parseSpacesRoute()).toEqual({ screen: "user-new", }); + expect((await load("/.spaces/account")).parseSpacesRoute()).toEqual({ + screen: "account", + }); }); test("a bare single segment is a space id", async () => { diff --git a/client/spaces_ui/routes.ts b/client/spaces_ui/routes.ts index cf294320..f30c2442 100644 --- a/client/spaces_ui/routes.ts +++ b/client/spaces_ui/routes.ts @@ -6,6 +6,7 @@ export const SPACES_BASE = new URL(document.baseURI).pathname.replace( export type SpacesRoute = | { screen: "login"; next?: string } | { screen: "spaces" } + | { screen: "account" } | { screen: "space-new" } | { screen: "space"; id: string } | { screen: "users" } @@ -47,6 +48,9 @@ export function parseSpacesRoute(): SpacesRoute { if (segments[0] === "new" && segments.length === 1) { return { screen: "space-new" }; } + if (segments[0] === "account" && segments.length === 1) { + return { screen: "account" }; + } if (segments[0] === "users") { if (segments.length === 1) return { screen: "users" }; if (segments.length === 2 && segments[1] === "new") { diff --git a/client/styles/main.scss b/client/styles/main.scss index 4a3e4ff5..521f3df2 100644 --- a/client/styles/main.scss +++ b/client/styles/main.scss @@ -117,7 +117,8 @@ body { .sb-modal-backdrop { position: fixed; inset: 0; - z-index: 100; + // Plainleaf's mobile navigation also uses 100. Keep plug modals above it. + z-index: 300; background-color: var(--modal-backdrop-color); } @@ -141,7 +142,10 @@ body { // On narrow screens override the inline `inset` that plugs pass to // showPanel("modal", N) so the modal sits closer to the edges. It keeps // its rounded corners since (unlike .sb-modal-box) it isn't edge-to-edge. - inset: 8px !important; + top: calc(8px + env(safe-area-inset-top)) !important; + right: 8px !important; + bottom: calc(8px + env(safe-area-inset-bottom)) !important; + left: 8px !important; } } diff --git a/client/styles/plainleaf_workspace.scss b/client/styles/plainleaf_workspace.scss index 6b6d935a..0fc24ec9 100644 --- a/client/styles/plainleaf_workspace.scss +++ b/client/styles/plainleaf_workspace.scss @@ -159,7 +159,8 @@ html[data-theme="dark"] #pl-workspace-shell { .pl-primary-nav-item, .pl-sidebar-footer button, .pl-note-shortcuts button, -.pl-more-links button { +.pl-more-links button, +.pl-more-links a { display: flex; align-items: center; gap: 10px; @@ -173,6 +174,7 @@ html[data-theme="dark"] #pl-workspace-shell { cursor: pointer; font-size: 13px; text-align: left; + text-decoration: none; } .pl-primary-nav-item:hover, @@ -181,7 +183,8 @@ html[data-theme="dark"] #pl-workspace-shell { .pl-sidebar-footer button.is-active, .pl-note-shortcuts button:hover, .pl-note-shortcuts button.is-active, -.pl-more-links button:hover { +.pl-more-links button:hover, +.pl-more-links a:hover { color: var(--pl-text); background: var(--pl-green-soft); } @@ -195,7 +198,8 @@ html[data-theme="dark"] #pl-workspace-shell { .pl-primary-nav-item > svg, .pl-sidebar-footer button > svg, .pl-note-shortcuts button > svg, -.pl-more-links button > svg { +.pl-more-links button > svg, +.pl-more-links a > svg { flex: 0 0 16px; } @@ -2095,6 +2099,19 @@ button.pl-setting-row:hover { font-size: 11px; } +.pl-settings-main-row > .pl-settings-link { + display: inline-flex; + align-items: center; + height: 31px; + padding: 0 10px; + border: 1px solid var(--pl-border); + border-radius: 6px; + color: var(--pl-text); + background: var(--pl-surface); + font-size: 11px; + text-decoration: none; +} + .pl-settings-main-row > button.pl-switch-button { display: inline-flex; align-items: center; diff --git a/client/types/ui.ts b/client/types/ui.ts index 63246ed6..86ce13f8 100644 --- a/client/types/ui.ts +++ b/client/types/ui.ts @@ -190,6 +190,8 @@ export type BootConfig = { enableClientEncryption: boolean; accountManaged?: boolean; + shellBackend?: string; + runtimeApiAvailable?: boolean; disableServiceWorker?: boolean; }; diff --git a/libraries/Library/Std/Infrastructure/Github.md b/libraries/Library/Std/Infrastructure/Github.md index f9b267ed..75ca0256 100644 --- a/libraries/Library/Std/Infrastructure/Github.md +++ b/libraries/Library/Std/Infrastructure/Github.md @@ -247,7 +247,10 @@ service.define { local releaseInfo = res.body version = releaseInfo.tag_name local url = "https://github.com/" .. owner .. "/" .. repoClean .. "/releases/download/" .. version .. "/" .. path - local res = net.proxyFetch(url, {responseEncoding=data.encoding}) + local res = net.proxyFetch(url, { + responseEncoding = data.encoding, + timeout = data.timeout, + }) if res.status != 200 then print("Failed to fetch", ur, res) return nil @@ -271,7 +274,10 @@ service.define { branch = "main" end local url = "https://raw.githubusercontent.com/" .. owner .. "/" .. repo .. "/" .. branch .. "/" .. path - local res = net.proxyFetch(url) + local res = net.proxyFetch(url, { + responseEncoding = data.encoding, + timeout = data.timeout, + }) if res.status != 200 then return nil end @@ -287,7 +293,10 @@ service.define { run = function(data) local owner, repo, branch, path = data.uri:match("github%.com/([^/]+)/([^/]+)/[^/]+/([^/]+)/(.+)") local url = "https://raw.githubusercontent.com/" .. owner .. "/" .. repo .. "/" .. branch .. "/" .. path - local res = net.proxyFetch(url) + local res = net.proxyFetch(url, { + responseEncoding = data.encoding, + timeout = data.timeout, + }) if res.status != 200 then return nil end diff --git a/libraries/Library/Std/Infrastructure/URI.md b/libraries/Library/Std/Infrastructure/URI.md index 977142d7..7be6ae7e 100644 --- a/libraries/Library/Std/Infrastructure/URI.md +++ b/libraries/Library/Std/Infrastructure/URI.md @@ -19,7 +19,10 @@ service.define { selector = "net.readURI:https:*", match = {}, run = function(data) - return net.proxyFetch(data.uri).body + return net.proxyFetch(data.uri, { + responseEncoding = data.encoding, + timeout = data.timeout, + }).body end } ``` diff --git a/plug-api/lib/crypto.ts b/plug-api/lib/crypto.ts index 13c2cbd8..ae672447 100644 --- a/plug-api/lib/crypto.ts +++ b/plug-api/lib/crypto.ts @@ -12,6 +12,9 @@ export function base64Encode(buffer: Uint8Array | string): string { if (typeof buffer === "string") { buffer = new TextEncoder().encode(buffer); } + if (!(buffer instanceof Uint8Array)) { + throw new TypeError("base64Encode requires text or binary data"); + } let binary = ""; const len = buffer.byteLength; for (let i = 0; i < len; i++) { diff --git a/plug-api/lib/shortcut.ts b/plug-api/lib/shortcut.ts index dafc2c8c..372a38c0 100644 --- a/plug-api/lib/shortcut.ts +++ b/plug-api/lib/shortcut.ts @@ -3,15 +3,18 @@ export const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); export function prettifyShortcut(shortcut: string): string { - if (!isMacLike) return shortcut; - const pretty = shortcut - .replace(/Mod-/g, "⌘") - .replace(/Cmd-/g, "⌘") - .replace(/Ctrl-/g, "⌃") - .replace(/Alt-/g, "⌥") - .replace(/Shift-/g, "⇧"); - return pretty.replace( - /([⌘⌃⌥⇧])([a-z])$/, - (_, mod, key) => mod + key.toUpperCase(), - ); + let pretty = shortcut; + if (isMacLike) { + pretty = pretty + .replace(/Mod-/g, "⌘") + .replace(/Cmd-/g, "⌘") + .replace(/Ctrl-/g, "⌃") + .replace(/Alt-/g, "⌥") + .replace(/Shift-/g, "⇧") + .replace(/([⌘⌃⌥⇧])([a-z])$/, (_, mod, key) => mod + key.toUpperCase()); + } + return pretty + .replaceAll("Backspace", "退格") + .replaceAll("Escape", "退出键") + .replaceAll("Enter", "回车键"); } diff --git a/plug-api/syscalls/space.ts b/plug-api/syscalls/space.ts index b3d65c7a..3acd6401 100644 --- a/plug-api/syscalls/space.ts +++ b/plug-api/syscalls/space.ts @@ -153,6 +153,11 @@ export function readFile(name: string): Promise { return syscall("space.readFile", name); } +/** Read a file as Base64 without transferring binary data across a plug worker. */ +export function readFileBase64(name: string): Promise { + return syscall("space.readFileBase64", name); +} + /** * Reads a reference (e.g. page#header or page@20) and returns it as a string */ @@ -192,6 +197,17 @@ export function writeFile(name: string, data: Uint8Array): Promise { return syscall("space.writeFile", name, data); } +/** + * Write a Base64-encoded file to the space. This avoids browser-specific + * TypedArray serialization issues at the plug worker boundary. + */ +export function writeFileBase64( + name: string, + base64Data: string, +): Promise { + return syscall("space.writeFileBase64", name, base64Data); +} + /** * Delete a file from the space. * @param name the name of the file to delete diff --git a/plug-api/syscalls/system.ts b/plug-api/syscalls/system.ts index 880d514b..ac200572 100644 --- a/plug-api/syscalls/system.ts +++ b/plug-api/syscalls/system.ts @@ -101,6 +101,16 @@ export function getVersion(): Promise { return syscall("system.getVersion"); } +export type SystemCapabilities = { + shell: boolean; + runtimeApi: boolean; +}; + +/** Returns optional server capabilities available to the current space. */ +export function getCapabilities(): Promise { + return syscall("system.getCapabilities"); +} + export function getConfig( key: string, defaultValue: any = undefined, diff --git a/plugs/configuration-manager/configuration.ts b/plugs/configuration-manager/configuration.ts index 511fb6cc..e1893107 100644 --- a/plugs/configuration-manager/configuration.ts +++ b/plugs/configuration-manager/configuration.ts @@ -64,15 +64,23 @@ export async function openLibrariesUpdateAllRepos() { } async function openPanel(initialTab: TabId, librariesFocus?: LibrariesFocus) { - const [schemas, values, categories, commands, configText, libraries] = - await Promise.all([ - config.getSchemas(), - config.getValues(), - config.getCategories(), - system.listCommands(), - readConfigPage(), - listLibraries(), - ]); + const [ + schemas, + values, + categories, + commands, + configText, + libraries, + capabilities, + ] = await Promise.all([ + config.getSchemas(), + config.getValues(), + config.getCategories(), + system.listCommands(), + readConfigPage(), + listLibraries(), + system.getCapabilities(), + ]); let configOverrides: Record = {}; let commandOverrides: Record = {}; @@ -95,6 +103,7 @@ async function openPanel(initialTab: TabId, librariesFocus?: LibrariesFocus) { isMac, initialTab, libraries, + capabilities, librariesFocus, }); await editor.showPanel("modal", 100, html, script); diff --git a/plugs/configuration-manager/libraries.ts b/plugs/configuration-manager/libraries.ts index e0e6ef7a..3cae40fb 100644 --- a/plugs/configuration-manager/libraries.ts +++ b/plugs/configuration-manager/libraries.ts @@ -6,7 +6,7 @@ import { space, system, } from "@silverbulletmd/silverbullet/syscalls"; -import { base64Decode, hashSHA256 } from "../../plug-api/lib/crypto.ts"; +import { hashSHA256 } from "../../plug-api/lib/crypto.ts"; import type { YamlPatch } from "../../plug-api/lib/yaml.ts"; const LIBRARY_TAG = "meta/library"; @@ -21,6 +21,7 @@ export type InstalledLibrary = { hash?: string; mode?: "pull" | "push" | "sync"; files?: string[]; + missingFiles?: string[]; }; export type InstallableLibrary = { @@ -30,6 +31,7 @@ export type InstallableLibrary = { description?: string; website?: string; repositoryPage?: string; + requires?: { shell?: boolean; runtimeApi?: boolean }; }; export type RepositoryInfo = { @@ -64,13 +66,28 @@ export async function listLibraries(): Promise { space.listPlugs(), ]); - const installed: InstalledLibrary[] = installedRaw.map((lib: any) => ({ - name: lib.name, - uri: lib.share?.uri, - hash: lib.share?.hash, - mode: lib.share?.mode, - files: lib.files, - })); + const availablePlugPaths = new Set( + (allPlugs ?? []).map((file: any) => file.name as string), + ); + const installed: InstalledLibrary[] = installedRaw.map((lib: any) => { + const files: string[] = Array.isArray(lib.files) + ? lib.files.filter( + (file: unknown): file is string => typeof file === "string", + ) + : []; + const base = urlDir(lib.name); + return { + name: lib.name, + uri: lib.share?.uri, + hash: lib.share?.hash, + mode: lib.share?.mode, + files, + missingFiles: files + .filter((file) => file.endsWith(".plug.js")) + .map((file) => base + file) + .filter((path) => !availablePlugPaths.has(path)), + }; + }); const installedUris = new Set( installed.map((l) => l.uri).filter((u): u is string => !!u), @@ -85,6 +102,13 @@ export async function listLibraries(): Promise { description: lib.description, website: lib.website, repositoryPage: lib.page, + requires: + lib.requires && typeof lib.requires === "object" + ? { + shell: lib.requires.shell === true, + runtimeApi: lib.requires.runtimeApi === true, + } + : undefined, })); const repositories: RepositoryInfo[] = repositoriesRaw.map((r: any) => ({ @@ -115,21 +139,26 @@ export async function listLibraries(): Promise { async function readUriAsText(uri: string): Promise { const expr = `net.readURI(${luaString(uri)}, {encoding="text/markdown"})`; - const result = await lua.evalExpression(expr); - if (typeof result !== "string") { - throw new Error(`Could not fetch ${uri}`); + try { + const result = await lua.evalExpression(expr); + if (typeof result !== "string") throw new Error(); + return result; + } catch { + throw new Error(`无法下载扩展清单:${uri}。请检查网络后重试。`); } - return result; } -async function readUriAsBytes(uri: string): Promise { - // Ask Lua for octet-stream, then base64-encode so binary travels cleanly. - const expr = `encoding.base64Encode(net.readURI(${luaString(uri)}, {encoding="application/octet-stream"}))`; - const b64 = await lua.evalExpression(expr); - if (typeof b64 !== "string") { - throw new Error(`Could not fetch ${uri}`); +async function readUriAsBase64(uri: string): Promise { + // Keep binary as Base64 until the main thread writes it. Passing a large + // Uint8Array back through a plug worker is unreliable on iOS Safari. + const expr = `encoding.base64Encode(net.readURI(${luaString(uri)}, {encoding="application/octet-stream", timeout=120000}))`; + try { + const b64 = await lua.evalExpression(expr); + if (typeof b64 !== "string" || b64.length === 0) throw new Error(); + return b64; + } catch { + throw new Error(`无法下载扩展文件:${uri}。请检查网络后重试。`); } - return base64Decode(b64); } function luaString(s: string): string { @@ -207,7 +236,7 @@ async function installLibrary( const text = await readUriAsText(uri); const { frontmatter: remoteFm } = await extractFrontmatter(text); if (!remoteFm || !remoteFm.name) { - throw new Error("Library frontmatter missing required 'name'"); + throw new Error("扩展清单格式错误:缺少必填的 name 字段"); } const remoteHash = await contentHash(text); if (opts.currentHash && opts.currentHash === remoteHash) { @@ -215,9 +244,14 @@ async function installLibrary( } const pageName: string = remoteFm.name; - - if (!opts.allowOverwrite && (await space.pageExists(pageName))) { - throw new Error(`Page already exists: ${pageName}`); + const existingPage = (await space.pageExists(pageName)) + ? await space.readPage(pageName) + : undefined; + if (!opts.allowOverwrite && existingPage !== undefined) { + const { frontmatter: existingFm } = await extractFrontmatter(existingPage); + if (existingFm?.share?.uri !== uri) { + throw new Error(`同名页面已存在,无法安装:${pageName}`); + } } const sourceBase = urlDir(uri); @@ -229,17 +263,57 @@ async function installLibrary( mode: "pull", }); - await space.writePage(pageName, stamped); - const files: string[] = Array.isArray(remoteFm.files) ? remoteFm.files.filter((f: unknown): f is string => typeof f === "string") : []; - await Promise.all( - files.map(async (file) => { - const data = await readUriAsBytes(sourceBase + file); - await space.writeFile(targetBase + file, data); - }), + const downloadedFiles = await Promise.all( + files.map(async (file) => ({ + file, + data: await readUriAsBase64(sourceBase + file), + })), ); + + const fileSnapshots = new Map(); + try { + for (const { file, data } of downloadedFiles) { + const path = targetBase + file; + fileSnapshots.set( + path, + (await space.fileExists(path)) + ? await space.readFileBase64(path) + : undefined, + ); + await space.writeFileBase64(path, data); + } + // Write the indexed library page last so a failed file download or write + // never advertises an incomplete library as installed. + await space.writePage(pageName, stamped); + } catch (error) { + await Promise.all( + [...fileSnapshots].map(async ([path, previousData]) => { + try { + if (previousData === undefined) await space.deleteFile(path); + else await space.writeFileBase64(path, previousData); + } catch { + // Best effort: preserve the original install error. + } + }), + ); + if (existingPage === undefined) { + try { + if (await space.pageExists(pageName)) await space.deletePage(pageName); + } catch { + // Best effort: preserve the original install error. + } + } else { + try { + await space.writePage(pageName, existingPage); + } catch { + // Best effort: preserve the original install error. + } + } + throw error; + } const plugPaths = files .filter((f) => f.endsWith(".plug.js")) .map((f) => targetBase + f); @@ -252,7 +326,7 @@ async function hotLoadPlugs(plugPaths: string[]): Promise { try { await system.loadPlug(p); } catch (e: any) { - console.warn(`Failed to hot-load plug ${p}:`, e?.message || e); + console.warn(`插件 ${p} 无法立即加载:`, e?.message || e); } } } @@ -265,7 +339,7 @@ async function updateLibrary( const { frontmatter: fm } = await extractFrontmatter(text); const share = fm?.share as ShareMeta | undefined; if (!share?.uri) { - throw new Error(`No share metadata on ${name}`); + throw new Error(`无法更新“${name}”:缺少扩展来源信息`); } const result = await installLibrary(share.uri, { currentHash: force ? undefined : share.hash, @@ -276,7 +350,7 @@ async function updateLibrary( async function removeLibrary(name: string): Promise { const text = await space.readPage(name); - if (!text) throw new Error(`Could not read ${name}`); + if (!text) throw new Error(`无法读取扩展页面:${name}`); const { frontmatter: fm } = await extractFrontmatter(text); const targetBase = urlDir(name); const files: string[] = Array.isArray(fm?.files) @@ -289,7 +363,7 @@ async function removeLibrary(name: string): Promise { try { await system.unloadPlug(target); } catch (e: any) { - console.warn(`Failed to unload plug ${target}:`, e?.message || e); + console.warn(`插件 ${target} 无法立即卸载:`, e?.message || e); } } try { @@ -306,7 +380,7 @@ async function removeLibrary(name: string): Promise { async function addRepository(uri: string, targetPage: string): Promise { if (await space.pageExists(targetPage)) { - throw new Error(`${targetPage} already exists`); + throw new Error(`同名页面已存在,无法添加仓库:${targetPage}`); } const text = await readUriAsText(uri); const hash = await contentHash(text); @@ -344,6 +418,23 @@ type ActionResult = | { ok: true; data?: T } | { ok: false; error: string }; +function actionFailureMessage(kind: string, detail: string): string { + const action: Record = { + install: "安装扩展", + update: "更新扩展", + remove: "移除扩展", + installPlug: "安装插件", + removePlug: "移除插件", + updateAll: "更新全部扩展", + addRepository: "添加扩展仓库", + updateRepository: "更新扩展仓库", + removeRepository: "移除扩展仓库", + updateAllRepositories: "更新全部扩展仓库", + }; + const label = action[kind] ?? "执行扩展操作"; + return `${label}失败:${detail}`; +} + export async function librariesAction( kind: string, args: any, @@ -375,10 +466,10 @@ export async function librariesAction( case "installPlug": { const path: string = args.path; if (!path.endsWith(".plug.js")) { - throw new Error("Plug path must end with .plug.js"); + throw new Error("插件保存路径必须以 .plug.js 结尾"); } - const data = await readUriAsBytes(args.uri); - await space.writeFile(path, data); + const data = await readUriAsBase64(args.uri); + await space.writeFileBase64(path, data); await hotLoadPlugs([path]); await reloadEverything(); return { ok: true, data: { path } }; @@ -387,7 +478,7 @@ export async function librariesAction( try { await system.unloadPlug(args.path); } catch (e: any) { - console.warn(`Failed to unload plug ${args.path}:`, e?.message || e); + console.warn(`插件 ${args.path} 无法立即卸载:`, e?.message || e); } await space.deleteFile(args.path); await reloadEverything(); @@ -406,7 +497,7 @@ export async function librariesAction( allPlugPaths.push(...r.plugPaths); } } catch (e: any) { - console.warn(`Update failed for ${lib.name}:`, e.message); + console.warn(`扩展 ${lib.name} 更新失败:`, e.message); } } await hotLoadPlugs(allPlugPaths); @@ -436,17 +527,20 @@ export async function librariesAction( const changed = await updateRepository(r.name); if (changed) updates.push(r.name); } catch (e: any) { - console.warn(`Repo update failed for ${r.name}:`, e.message); + console.warn(`扩展仓库 ${r.name} 更新失败:`, e.message); } } await reloadEverything(); return { ok: true, data: { updated: updates } }; } default: - return { ok: false, error: `Unknown action: ${kind}` }; + return { ok: false, error: `不支持的扩展操作:${kind}` }; } } catch (e: any) { - return { ok: false, error: e?.message || String(e) }; + return { + ok: false, + error: actionFailureMessage(kind, e?.message || String(e)), + }; } } diff --git a/plugs/configuration-manager/ui/components/app.tsx b/plugs/configuration-manager/ui/components/app.tsx index 3be0e2ab..d8e4c1d1 100644 --- a/plugs/configuration-manager/ui/components/app.tsx +++ b/plugs/configuration-manager/ui/components/app.tsx @@ -85,7 +85,7 @@ function SaveFooter({ id="cfg-cancel" disabled={saving} onClick={close} - shortcut="esc" + shortcut="退出键" > 取消 @@ -114,7 +114,7 @@ function LibrariesFooter() { 扩展库更改会立即生效。 - diff --git a/plugs/configuration-manager/ui/components/chord_display.test.ts b/plugs/configuration-manager/ui/components/chord_display.test.ts index 69944093..3de53594 100644 --- a/plugs/configuration-manager/ui/components/chord_display.test.ts +++ b/plugs/configuration-manager/ui/components/chord_display.test.ts @@ -13,6 +13,12 @@ describe("Chinese shortcut display", () => { test("localizes empty and multi-step shortcut labels", () => { expect(render(ChordChips({ binding: "" }))).toContain("未设置"); expect(render(ChordChips({ binding: "Mod-k Mod-p" }))).toContain("然后"); + const specialKeys = render( + ChordChips({ binding: "Enter Escape Backspace" }), + ); + expect(specialKeys).toContain("回车键"); + expect(specialKeys).toContain("退出键"); + expect(specialKeys).toContain("退格"); }); test("localizes recording instructions", () => { @@ -24,8 +30,9 @@ describe("Chinese shortcut display", () => { }), ); expect(html).toContain("请按下快捷键"); - expect(html).toContain("Enter 确认"); - expect(html).toContain("Esc 取消"); + expect(html).toContain("回车确认"); + expect(html).toContain("退出键取消"); + expect(html).toContain("退格撤销"); }); test("localizes conflict command names without changing their ids", () => { diff --git a/plugs/configuration-manager/ui/components/chord_display.tsx b/plugs/configuration-manager/ui/components/chord_display.tsx index 7af3c69f..46ef31bc 100644 --- a/plugs/configuration-manager/ui/components/chord_display.tsx +++ b/plugs/configuration-manager/ui/components/chord_display.tsx @@ -138,7 +138,7 @@ export function RecordingPreview({ {info.message} ) : ( - Enter 确认 · Esc 取消 · ⌫ 撤销 + 回车确认 · 退出键取消 · 退格撤销 )} ); diff --git a/plugs/configuration-manager/ui/components/libraries_tab.test.ts b/plugs/configuration-manager/ui/components/libraries_tab.test.ts index 27723c2f..3b72f528 100644 --- a/plugs/configuration-manager/ui/components/libraries_tab.test.ts +++ b/plugs/configuration-manager/ui/components/libraries_tab.test.ts @@ -1,7 +1,10 @@ import { expect, test } from "vitest"; import { + installedLibraryNeedsRepair, + libraryCompatibility, libraryDescriptionZhCN, libraryDisplayName, + librarySourceLabel, } from "./libraries_tab.tsx"; test("uses Chinese labels without changing unknown extension names", () => { @@ -19,3 +22,51 @@ test("provides Chinese descriptions for recommended extensions", () => { "Author text", ); }); + +test("detects an installed library whose plug file is missing", () => { + expect( + installedLibraryNeedsRepair({ + name: "Library/Example", + mode: "pull", + missingFiles: ["Library/example.plug.js"], + }), + ).toBe(true); + expect( + installedLibraryNeedsRepair({ + name: "Library/Example", + mode: "pull", + missingFiles: [], + }), + ).toBe(false); +}); + +test("shows a readable source before installing", () => { + expect(librarySourceLabel("ghr:owner/repo/PLUG.md")).toBe( + "GitHub · owner/repo", + ); + expect( + librarySourceLabel("https://github.com/owner/repo/blob/main/PLUG.md"), + ).toBe("github.com"); +}); + +test("disables only extensions whose declared capabilities are unavailable", () => { + const compatible = libraryCompatibility( + { name: "Notes", page: "Notes", uri: "https://example.com/notes.md" }, + { shell: false, runtimeApi: false }, + ); + expect(compatible.unavailableReasons).toEqual([]); + + const incompatible = libraryCompatibility( + { + name: "Automation", + page: "Automation", + uri: "https://example.com/automation.md", + requires: { shell: true, runtimeApi: true }, + }, + { shell: false, runtimeApi: false }, + ); + expect(incompatible.unavailableReasons).toEqual([ + "当前空间未开启服务器命令权限", + "当前空间未启用浏览器运行时", + ]); +}); diff --git a/plugs/configuration-manager/ui/components/libraries_tab.tsx b/plugs/configuration-manager/ui/components/libraries_tab.tsx index 9ca87915..cbc0b6d8 100644 --- a/plugs/configuration-manager/ui/components/libraries_tab.tsx +++ b/plugs/configuration-manager/ui/components/libraries_tab.tsx @@ -8,6 +8,7 @@ import { import type { ComponentChildren } from "preact"; import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { ExternalLink, RefreshCw, Trash2, X } from "preact-feather"; +import type { SystemCapabilities } from "../../../../plug-api/syscalls/system.ts"; import * as editor from "../../../../plug-api/syscalls/editor.ts"; import * as markdown from "../../../../plug-api/syscalls/markdown.ts"; import { @@ -91,6 +92,52 @@ export function libraryDescriptionZhCN( return recommendedLibraryDescriptions[name] ?? description; } +export function installedLibraryNeedsRepair(lib: InstalledLibrary): boolean { + return (lib.missingFiles?.length ?? 0) > 0; +} + +export function librarySourceLabel(uri: string): string { + if (uri.startsWith("ghr:") || uri.startsWith("github:")) { + const path = uri.slice(uri.indexOf(":") + 1).split("/"); + return path.length >= 2 ? `GitHub · ${path[0]}/${path[1]}` : uri; + } + try { + return new URL(uri).hostname; + } catch { + return uri; + } +} + +export function libraryCompatibility( + lib: InstallableLibrary, + capabilities: SystemCapabilities, +): { requirements: string[]; unavailableReasons: string[] } { + const requirements: string[] = []; + const unavailableReasons: string[] = []; + if (lib.requires?.shell) { + requirements.push("服务器命令权限"); + if (!capabilities.shell) { + unavailableReasons.push("当前空间未开启服务器命令权限"); + } + } + if (lib.requires?.runtimeApi) { + requirements.push("浏览器运行时"); + if (!capabilities.runtimeApi) { + unavailableReasons.push("当前空间未启用浏览器运行时"); + } + } + return { requirements, unavailableReasons }; +} + +async function confirmLibraryInstall( + name: string, + uri: string, +): Promise { + return await editor.confirm( + `确定安装“${libraryDisplayName(name)}”吗?\n\n来源:${librarySourceLabel(uri)}\n\n第三方扩展可以读写当前空间的内容。请仅安装你信任的来源。`, + ); +} + // Prompt the user for a library/plug URI (and plug save-path when relevant) and // install it. Lives at module scope so both the button and the command-triggered // `librariesFocus` dispatch in LibrariesTab can invoke the exact same flow. @@ -104,8 +151,10 @@ async function promptInstall(libs: LibrariesEditor) { const suggested = segs[segs.length - 1] || ""; const path = (await editor.prompt("插件保存路径:", suggested))?.trim(); if (!path) return; + if (!(await confirmLibraryInstall(path, uri))) return; await libs.run(INSTALL_KEY, "available", "installPlug", { uri, path }); } else { + if (!(await confirmLibraryInstall(uri, uri))) return; await libs.run(INSTALL_KEY, "available", "install", { uri }); } } @@ -222,6 +271,7 @@ function InstalledRow({ const removeKey = `installed:remove:${lib.name}`; const updateBusy = libs.isBusy(updateKey); const removeBusy = libs.isBusy(removeKey); + const needsRepair = installedLibraryNeedsRepair(lib); if (query && !lib.name.toLowerCase().includes(query)) return null; const isBuiltin = lib.name.startsWith("Library/Std/") || !lib.uri; const isPull = !isBuiltin && lib.mode === "pull"; @@ -234,7 +284,11 @@ function InstalledRow({ {isBuiltin && 内置} {isPush && 开发模式} - {isPull && 已安装} + {isPull && ( + + {needsRepair ? "安装不完整" : "已安装"} + + )}
{lib.uri && /^https?:\/\//.test(lib.uri) && ( @@ -251,18 +305,21 @@ function InstalledRow({ {isPull && (
{displayDescription && } +
+ 来源:{librarySourceLabel(lib.uri)} +
+ {requirements.length > 0 && ( +
需要:{requirements.join("、")}
+ )} + {unavailable && ( +
+ 暂不可用:{unavailableReasons.join(";")} +
+ )} + {busy && ( +
+ 正在下载扩展文件,请勿关闭页面。大型扩展可能需要 1-2 分钟。 +
+ )} ); } diff --git a/plugs/configuration-manager/ui/config_zh_cn.test.ts b/plugs/configuration-manager/ui/config_zh_cn.test.ts index 50833158..df030ccc 100644 --- a/plugs/configuration-manager/ui/config_zh_cn.test.ts +++ b/plugs/configuration-manager/ui/config_zh_cn.test.ts @@ -26,6 +26,21 @@ describe("configuration Chinese display names", () => { expect( configurationOptionZhCN("frontmatterFolding.foldByDefault", "always"), ).toBe("总是"); + expect(configurationCategoryZhCN("Markdown Prettify")).toBe( + "Markdown 格式整理", + ); + expect( + configurationLabelZhCN( + "markdownPrettify.unwrapSoftWrappedParagraphs", + "Unwrap soft-wrapped paragraphs", + ), + ).toBe("合并手动换行的段落"); + expect( + configurationDescriptionZhCN( + "markdownPrettify.normalizeBullets", + "Normalize bullet markers", + ), + ).toContain("重新编号"); }); test("preserves configuration supplied by third-party extensions", () => { diff --git a/plugs/configuration-manager/ui/config_zh_cn.ts b/plugs/configuration-manager/ui/config_zh_cn.ts index 6f4b2fa1..26c9a5fa 100644 --- a/plugs/configuration-manager/ui/config_zh_cn.ts +++ b/plugs/configuration-manager/ui/config_zh_cn.ts @@ -41,6 +41,10 @@ const categories: Record = { label: "GitHub 集成", description: "设置写入 GitHub 仓库和 Gist 时使用的身份与凭据。", }, + "Markdown Prettify": { + label: "Markdown 格式整理", + description: "设置“Markdown:整理格式”操作使用的排版规则。", + }, }; const fields: Record = { @@ -153,6 +157,63 @@ const fields: Record = { label: "GitHub 访问令牌", description: "访问 GitHub API 所需的个人令牌。", }, + "markdownPrettify.unwrapSoftWrappedParagraphs": { + label: "合并手动换行的段落", + description: + "将段落中手动换行的多行文字合并为一行,编辑器仍会自动换行显示。", + }, + "markdownPrettify.normalizeHeadings": { + label: "统一标题格式", + description: "将 === 或 --- 形式的标题转换为使用 # 的标题。", + }, + "markdownPrettify.normalizeBullets": { + label: "统一列表格式", + description: "统一无序列表符号和有序列表格式,并按顺序重新编号。", + }, + "markdownPrettify.bulletMarker": { + label: "无序列表符号", + description: "整理格式后统一使用的无序列表符号。", + }, + "markdownPrettify.normalizeEmphasis": { + label: "统一斜体和粗体格式", + description: "统一 Markdown 中斜体和粗体使用的标记符号。", + }, + "markdownPrettify.emphasisMarker": { + label: "斜体标记符号", + description: "整理格式后斜体统一使用的符号。", + }, + "markdownPrettify.strongMarker": { + label: "粗体标记符号", + description: "整理格式后粗体统一使用的符号。", + }, + "markdownPrettify.collapseSpaceRuns": { + label: "合并连续空格", + description: "将正文中的多个连续空格合并为一个,代码内容不受影响。", + }, + "markdownPrettify.normalizeWikilinks": { + label: "整理双链格式", + description: "移除双链目标和显示文字两侧多余的空格。", + }, + "markdownPrettify.alignTables": { + label: "对齐表格", + description: "统一 Markdown 表格各列的宽度和分隔符位置。", + }, + "markdownPrettify.liftPageTagsToFrontmatter": { + label: "将页面标签移到页面属性", + description: "页面已有属性区时,将正文中的页面级标签移入 tags 字段。", + }, + "markdownPrettify.trimTrailingWhitespace": { + label: "移除行尾空格", + description: "删除每一行末尾多余的空格。", + }, + "markdownPrettify.collapseBlankLines": { + label: "合并连续空行", + description: "将三个及以上连续空行整理为一个空行。", + }, + "markdownPrettify.ensureTrailingNewline": { + label: "保留文件末尾换行", + description: "确保 Markdown 文件末尾恰好保留一个换行。", + }, }; export function configurationCategoryZhCN(name: string): string { diff --git a/plugs/configuration-manager/ui/configuration.scss b/plugs/configuration-manager/ui/configuration.scss index 849fbdec..3c8a2211 100644 --- a/plugs/configuration-manager/ui/configuration.scss +++ b/plugs/configuration-manager/ui/configuration.scss @@ -238,8 +238,10 @@ html[data-theme="dark"] .lib-icon-danger:hover { color: #ff7b75; } .lib-badge-push { background: #ffe9d6; color: #8a4400; } .lib-badge-pull { background: #e3ebff; color: #2a3c9e; } +.lib-badge-broken { background: #ffe7e5; color: #a12c24; } html[data-theme="dark"] .lib-badge-push { background: #3a2f15; color: #e6b861; } html[data-theme="dark"] .lib-badge-pull { background: #1f2a40; color: #89a0e5; } +html[data-theme="dark"] .lib-badge-broken { background: #421f1f; color: #ff9b93; } .lib-badge-builtin { background: var(--cfg-section-bg); } .lib-uri { @@ -276,6 +278,16 @@ html[data-theme="dark"] .lib-badge-pull { background: #1f2a40; color: #89a0e5; } color: var(--cfg-color-muted); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lib-install-note, +.lib-compat-warning { + margin-top: 7px; + padding: 7px 9px; + border-radius: 4px; + font-size: 12px; +} +.lib-install-note { background: var(--cfg-section-bg); color: var(--cfg-color-muted); } +.lib-compat-warning { background: #fff4d6; color: #7a5200; } +html[data-theme="dark"] .lib-compat-warning { background: #3a3018; color: #e6c46a; } .lib-footer-note { color: var(--cfg-color-muted); font-size: 13px; margin-right: auto; } diff --git a/plugs/configuration-manager/ui/schema.test.ts b/plugs/configuration-manager/ui/schema.test.ts index 2cddbced..e40d735f 100644 --- a/plugs/configuration-manager/ui/schema.test.ts +++ b/plugs/configuration-manager/ui/schema.test.ts @@ -39,6 +39,7 @@ describe("buildSchemaIndex", () => { installable: [], roguePlugs: [], }, + capabilities: { shell: false, runtimeApi: false }, }); expect(index.initialConfig["frontmatterFolding.foldByDefault"]).toBe( diff --git a/plugs/configuration-manager/ui/types.ts b/plugs/configuration-manager/ui/types.ts index cfbca6b8..15903644 100644 --- a/plugs/configuration-manager/ui/types.ts +++ b/plugs/configuration-manager/ui/types.ts @@ -1,4 +1,5 @@ import type { LibrariesViewModel } from "../libraries.ts"; +import type { SystemCapabilities } from "../../../plug-api/syscalls/system.ts"; export type CommandOverride = { key?: string | string[]; @@ -27,6 +28,7 @@ export type ConfigurationViewModel = { isMac: boolean; initialTab: TabId; libraries: LibrariesViewModel; + capabilities: SystemCapabilities; librariesFocus?: LibrariesFocus; }; diff --git a/server-common/src/types.rs b/server-common/src/types.rs index 4e147503..e238816f 100644 --- a/server-common/src/types.rs +++ b/server-common/src/types.rs @@ -29,6 +29,10 @@ pub struct BootConfig { #[serde(default)] pub account_managed: bool, pub shell_backend: String, + /// Whether this space has a live browser runtime backend. Extensions can + /// use this boot-time capability to avoid offering actions that cannot run. + #[serde(default)] + pub runtime_api_available: bool, pub disable_service_worker: bool, } diff --git a/server/src/handlers/auth.rs b/server/src/handlers/auth.rs index 172b15e3..54cbb551 100644 --- a/server/src/handlers/auth.rs +++ b/server/src/handlers/auth.rs @@ -92,14 +92,14 @@ pub async fn handle_auth_post( }; if form.username.is_empty() || form.password.is_empty() { - return json_error("Please enter a username and password"); + return json_error("请输入用户名和密码"); } if login.is_locked() { - return json_error("Too many failed attempts — please wait and try again"); + return json_error("登录失败次数过多,请稍后再试"); } if !login.authorize(&form.username, &form.password) { login.record_failure(); - return json_error("Invalid username and/or password"); + return json_error("用户名或密码错误"); } let remember = !form.remember_me.is_empty(); diff --git a/server/src/lib.rs b/server/src/lib.rs index 2dc031b4..5c2baec5 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -41,6 +41,7 @@ mod test_support { enable_client_encryption: false, account_managed: false, shell_backend: "local".into(), + runtime_api_available: false, disable_service_worker: true, }, space_folder_path: "/tmp".into(), diff --git a/server/src/multi/admin_api.rs b/server/src/multi/admin_api.rs index 8ee9bf27..ab0b31dd 100644 --- a/server/src/multi/admin_api.rs +++ b/server/src/multi/admin_api.rs @@ -158,7 +158,7 @@ fn api_error(e: ApiError) -> Response { /// so an unrelated message that happens to mention "admin" doesn't get /// mis-tagged. fn user_store_error(msg: String) -> Response { - if msg.starts_with("no such") { + if msg.starts_with("no such") || msg.starts_with("找不到") { return ( StatusCode::NOT_FOUND, Json(json!({ "errors": [{ "field": "", "message": msg }] })), @@ -167,7 +167,7 @@ fn user_store_error(msg: String) -> Response { } let field = if msg.starts_with("invalid username") || msg.starts_with("user ") { "username" - } else if msg.starts_with("token ") { + } else if msg.starts_with("token ") || msg.starts_with("令牌") { "name" } else if msg == "cannot remove the last admin" || msg == "cannot demote the last admin" { "admin" @@ -381,7 +381,7 @@ async fn handle_create_token( Json(body): Json, ) -> Response { let users = state.users.clone(); - let result = run_blocking(move || Ok(users.create_token(&user, &body.name))).await; + let result = run_blocking(move || Ok(users.create_token(&user, body.name.trim()))).await; match result { Ok(Ok(token)) => { state.manager.set_known_users(state.users.usernames()); diff --git a/server/src/multi/instance.rs b/server/src/multi/instance.rs index acfd5804..eb9ca9e1 100644 --- a/server/src/multi/instance.rs +++ b/server/src/multi/instance.rs @@ -425,6 +425,7 @@ fn try_build_state( }; let shell_enabled = config.shell.enabled && !config.read_only && !deps.shell_disabled; + let runtime_api_available = runtime.is_some(); Ok(ServerState { space, client_bundle: (deps.assets.client_bundle)(), @@ -441,6 +442,7 @@ fn try_build_state( } else { "noop".into() }, + runtime_api_available, disable_service_worker: deps.disable_service_worker, }, space_folder_path: folder_str, diff --git a/server/src/multi/space_index.rs b/server/src/multi/space_index.rs index af739889..460fa80a 100644 --- a/server/src/multi/space_index.rs +++ b/server/src/multi/space_index.rs @@ -94,20 +94,38 @@ struct LoginBody { remember_me: bool, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChangeOwnPasswordBody { + current_password: String, + new_password: String, +} + +#[derive(Deserialize)] +struct CreateOwnTokenBody { + name: String, +} + +fn account_error(status: StatusCode, message: impl Into) -> Response { + ( + status, + Json(json!({ "errors": [{ "field": "", "message": message.into() }] })), + ) + .into_response() +} + async fn handle_login( State(state): State>, headers: HeaderMap, Json(body): Json, ) -> Response { if state.login.is_locked() { - return Json( - json!({ "status": "error", "error": "Too many failed attempts — please wait" }), - ) - .into_response(); + return Json(json!({ "status": "error", "error": "登录失败次数过多,请稍后再试" })) + .into_response(); } if !state.login.authorize(&body.username, &body.password) { state.login.record_failure(); - return Json(json!({ "status": "error", "error": "Invalid username and/or password" })) + return Json(json!({ "status": "error", "error": "用户名或密码错误" })) .into_response(); } let (jwt, secs) = match state.login.issue_session(&body.username, body.remember_me) { @@ -169,6 +187,75 @@ async fn handle_session(State(state): State>, headers: Head Json(json!({ "username": username, "admin": admin })).into_response() } +async fn handle_own_account( + State(state): State>, + headers: HeaderMap, +) -> Response { + let Some(username) = current_username(&state, &headers) else { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + }; + match state.users.get(&username) { + Some(user) => Json(json!({ "username": username, "user": user })).into_response(), + None => account_error(StatusCode::NOT_FOUND, "账号不存在"), + } +} + +async fn handle_change_own_password( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let Some(username) = current_username(&state, &headers) else { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + }; + let users = state.users.clone(); + match run_blocking(move || { + Ok(users.change_own_password( + &username, + &body.current_password, + &body.new_password, + )) + }) + .await + { + Ok(Ok(())) => Json(json!({ "status": "ok" })).into_response(), + Ok(Err(error)) => account_error(StatusCode::BAD_REQUEST, error), + Err(_) => account_error(StatusCode::INTERNAL_SERVER_ERROR, "修改密码失败"), + } +} + +async fn handle_create_own_token( + State(state): State>, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let Some(username) = current_username(&state, &headers) else { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + }; + let users = state.users.clone(); + match run_blocking(move || Ok(users.create_token(&username, body.name.trim()))).await { + Ok(Ok(token)) => Json(json!({ "token": token })).into_response(), + Ok(Err(error)) => account_error(StatusCode::BAD_REQUEST, error), + Err(_) => account_error(StatusCode::INTERNAL_SERVER_ERROR, "创建令牌失败"), + } +} + +async fn handle_delete_own_token( + State(state): State>, + headers: HeaderMap, + AxumPath(token_name): AxumPath, +) -> Response { + let Some(username) = current_username(&state, &headers) else { + return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); + }; + let users = state.users.clone(); + match run_blocking(move || Ok(users.delete_token(&username, &token_name))).await { + Ok(Ok(())) => Json(json!({ "status": "ok" })).into_response(), + Ok(Err(error)) => account_error(StatusCode::BAD_REQUEST, error), + Err(_) => account_error(StatusCode::INTERNAL_SERVER_ERROR, "撤销令牌失败"), + } +} + async fn handle_list(State(state): State>, headers: HeaderMap) -> Response { let Some(username) = current_username(&state, &headers) else { return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); @@ -226,12 +313,23 @@ pub fn build_spaces_router(state: Arc, admin_api: Router) -> Ro .route("/users", get(handle_shell)) .route("/users/new", get(handle_shell)) .route("/users/{name}", get(handle_shell)) + .route("/account", get(handle_shell)) .route("/login", get(handle_shell)) // Single-segment catch-all for a space id. Static segments above win // in matchit, so `/new`, `/users` and `/login` are unaffected. .route("/{id}", get(handle_shell)) .route("/assets/{file}", get(handle_asset)) .route("/api/session", get(handle_session)) + .route("/api/account", get(handle_own_account)) + .route( + "/api/account/password", + post(handle_change_own_password), + ) + .route("/api/account/tokens", post(handle_create_own_token)) + .route( + "/api/account/tokens/{name}", + axum::routing::delete(handle_delete_own_token), + ) .route("/api/spaces", get(handle_list)) .route("/api/login", post(handle_login)) .route("/api/logout", get(handle_logout)) @@ -634,10 +732,10 @@ mod tests { .to_string() } }; - assert!(attempt("wrong").await.contains("Invalid username")); + assert!(attempt("wrong").await.contains("用户名或密码错误")); assert!(attempt("adminpw") .await - .contains("Too many failed attempts")); + .contains("登录失败次数过多")); } #[tokio::test] @@ -708,6 +806,88 @@ mod tests { assert_eq!(spaces.status(), StatusCode::OK); } + #[tokio::test] + async fn non_admin_can_manage_only_their_own_account() { + let (_dir, router) = setup(); + let cookie = login(&router, "alice", "alicepw").await; + + let account = send( + &router, + Request::builder() + .uri("/api/account") + .header("host", "localhost") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(account.status(), StatusCode::OK); + assert_eq!(json_body(account).await["username"], "alice"); + + let wrong_password = send( + &router, + Request::builder() + .method("POST") + .uri("/api/account/password") + .header("host", "localhost") + .header("cookie", &cookie) + .header("content-type", "application/json") + .body(Body::from( + json!({ "currentPassword": "wrong", "newPassword": "newalicepw" }) + .to_string(), + )) + .unwrap(), + ) + .await; + assert_eq!(wrong_password.status(), StatusCode::BAD_REQUEST); + + let empty_token = send( + &router, + Request::builder() + .method("POST") + .uri("/api/account/tokens") + .header("host", "localhost") + .header("cookie", &cookie) + .header("content-type", "application/json") + .body(Body::from(json!({ "name": " " }).to_string())) + .unwrap(), + ) + .await; + assert_eq!(empty_token.status(), StatusCode::BAD_REQUEST); + assert!(json_body(empty_token).await["errors"][0]["message"] + .as_str() + .is_some_and(|message| message.contains("令牌名称不能为空"))); + + let token = send( + &router, + Request::builder() + .method("POST") + .uri("/api/account/tokens") + .header("host", "localhost") + .header("cookie", &cookie) + .header("content-type", "application/json") + .body(Body::from(json!({ "name": "phone" }).to_string())) + .unwrap(), + ) + .await; + assert_eq!(token.status(), StatusCode::OK); + assert!(json_body(token).await["token"] + .as_str() + .is_some_and(|token| token.starts_with("sbt_"))); + + let admin_users = send( + &router, + Request::builder() + .uri("/api/admin/users") + .header("host", "localhost") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(admin_users.status(), StatusCode::FORBIDDEN); + } + #[tokio::test] async fn admin_can_reach_admin_api() { let (_dir, router) = setup(); @@ -803,6 +983,7 @@ mod tests { "/users", "/users/new", "/users/alice", + "/account", "/login", "/some-space-id", ] { @@ -820,7 +1001,7 @@ mod tests { } /// The route table pairs static single-segment routes (`/new`, `/users`, - /// `/login`) with a `/{id}` catch-all. Both currently resolve to the same + /// `/account`, `/login`) with a `/{id}` catch-all. They resolve to the same /// handler, so a same-router test cannot tell them apart — this mirrors the /// exact route strings with distinguishable handlers to pin the matchit /// priority that `build_spaces_router` relies on. If axum ever stopped diff --git a/server/src/multi/users.rs b/server/src/multi/users.rs index ba3790d1..d48c57a4 100644 --- a/server/src/multi/users.rs +++ b/server/src/multi/users.rs @@ -291,6 +291,29 @@ impl UserStore { }) } + pub fn change_own_password( + &self, + name: &str, + current_password: &str, + new_password: &str, + ) -> Result<(), String> { + if new_password.is_empty() { + return Err("新密码不能为空".into()); + } + let password_hash = crate::auth::password::hash_password(new_password)?; + self.mutate(|config| { + let entry = config + .users + .get_mut(name) + .ok_or_else(|| "账号不存在".to_string())?; + if !crate::auth::password::verify_password(current_password, &entry.password_hash) { + return Err("当前密码不正确".into()); + } + entry.password_hash = password_hash; + Ok(()) + }) + } + pub fn set_admin(&self, name: &str, admin: bool) -> Result<(), String> { self.mutate(|c| { if !admin { @@ -311,6 +334,10 @@ impl UserStore { /// Mint a named token for `user`; returns the plaintext exactly once. pub fn create_token(&self, user: &str, token_name: &str) -> Result { + let token_name = token_name.trim(); + if token_name.is_empty() { + return Err("令牌名称不能为空".into()); + } let plaintext = generate_token(); let entry = TokenEntry { token_hash: hash_token(&plaintext), @@ -322,7 +349,7 @@ impl UserStore { .get_mut(user) .ok_or_else(|| format!("no such user {user:?}"))?; if u.tokens.contains_key(token_name) { - return Err(format!("token {token_name:?} already exists")); + return Err(format!("令牌“{token_name}”已存在")); } u.tokens.insert(token_name.to_string(), entry.clone()); Ok(()) @@ -339,7 +366,7 @@ impl UserStore { u.tokens .remove(token_name) .map(|_| ()) - .ok_or_else(|| format!("no such token {token_name:?}")) + .ok_or_else(|| format!("找不到令牌“{token_name}”")) }) } @@ -447,6 +474,29 @@ mod tests { assert!(s.resolve_token(&tok).is_none()); } + #[test] + fn empty_token_name_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let s = store(dir.path()); + assert_eq!(s.create_token("zef", " ").unwrap_err(), "令牌名称不能为空"); + assert!(s.get("zef").unwrap()["tokens"].as_object().unwrap().is_empty()); + } + + #[test] + fn duplicate_and_missing_token_errors_are_localized() { + let dir = tempfile::tempdir().unwrap(); + let s = store(dir.path()); + s.create_token("zef", "phone").unwrap(); + assert_eq!( + s.create_token("zef", "phone").unwrap_err(), + "令牌“phone”已存在" + ); + assert_eq!( + s.delete_token("zef", "missing").unwrap_err(), + "找不到令牌“missing”" + ); + } + #[test] fn cannot_remove_last_admin() { let dir = tempfile::tempdir().unwrap(); @@ -469,6 +519,20 @@ mod tests { assert!(!s.session_is_current("bob", Some(&a))); } + #[test] + fn changing_own_password_requires_the_current_password() { + let dir = tempfile::tempdir().unwrap(); + let s = store(dir.path()); + assert!(s + .change_own_password("zef", "wrong", "new-password") + .is_err()); + assert!(s.verify_password("zef", "hunter22")); + s.change_own_password("zef", "hunter22", "new-password") + .unwrap(); + assert!(!s.verify_password("zef", "hunter22")); + assert!(s.verify_password("zef", "new-password")); + } + #[test] fn list_redacts_hashes() { let dir = tempfile::tempdir().unwrap();