diff --git a/src/app/App.tsx b/src/app/App.tsx index 94b1428eb..c79119d88 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -15,7 +15,13 @@ import { useLoading, useRouter, useT } from "~/hooks" import { setSettings } from "~/store" import { setArchiveExtensions } from "~/store/archive" import { Resp } from "~/types" -import { base_path, bus, handleRespWithoutAuthAndNotify, r } from "~/utils" +import { + base_path, + bus, + handleRespWithoutAuthAndNotify, + initPluginEngine, + r, +} from "~/utils" import { MustUser, UserOrGuest } from "./MustUser" import "./index.css" import { globalStyles } from "./theme" @@ -29,6 +35,7 @@ const Test = lazy(() => import("~/pages/test")) const App: Component = () => { const t = useT() globalStyles() + initPluginEngine() const isRouting = useIsRouting() const { to, pathname } = useRouter() const onTo = (path: string) => { diff --git a/src/components/Base.tsx b/src/components/Base.tsx index 30c996b88..4ded6da04 100644 --- a/src/components/Base.tsx +++ b/src/components/Base.tsx @@ -32,12 +32,14 @@ import { import { AiOutlineFullscreen, AiOutlineFullscreenExit } from "solid-icons/ai" import { BsFullscreen, BsFullscreenExit } from "solid-icons/bs" import { useT } from "~/hooks" +import { Face404 } from "./Face404" export const Error = (props: { msg: string disableColor?: boolean h?: string actions?: JSXElement + show404Face?: boolean }) => { const merged = mergeProps( { @@ -52,7 +54,16 @@ export const Error = (props: { px="$4" py="$6" bgColor={useColorModeValue("white", "$neutral3")()} + css={{ + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "$4", + }} > + + + { + const color = useColorModeValue("#292d33", "#d6d9de")() + return ( + + ) +} diff --git a/src/components/index.ts b/src/components/index.ts index 598a9a29e..6e79ead76 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -11,6 +11,7 @@ export * from "./ImageWithError" export * from "./Markdown" export * from "./ModalInput" export * from "./ModalTwoInput" +export * from "./Face404" export * from "./Base" export * from "./Paginator" export * from "./icons" diff --git a/src/lang/en/entry.ts b/src/lang/en/entry.ts index 427430650..a320684d9 100644 --- a/src/lang/en/entry.ts +++ b/src/lang/en/entry.ts @@ -8,6 +8,7 @@ import init from "./init.json" import login from "./login.json" import manage from "./manage.json" import metas from "./metas.json" +import plugins from "./plugins.json" import settings_other from "./settings_other.json" import settings from "./settings.json" import shares from "./shares.json" @@ -26,6 +27,7 @@ export const dict = { login, manage, metas, + plugins, settings_other, settings, shares, diff --git a/src/lang/en/manage.json b/src/lang/en/manage.json index 5829c6ee7..ea94fad02 100644 --- a/src/lang/en/manage.json +++ b/src/lang/en/manage.json @@ -27,7 +27,8 @@ "ldap": "LDAP", "s3": "S3", "ftp": "FTP", - "traffic": "Traffic" + "traffic": "Traffic", + "plugins": "Plugins" }, "title": "OpenList Management", "not_admin": "You are not an admin user, please login with an admin account.", diff --git a/src/lang/en/plugins.json b/src/lang/en/plugins.json new file mode 100644 index 000000000..397c1be02 --- /dev/null +++ b/src/lang/en/plugins.json @@ -0,0 +1,69 @@ +{ + "title": "Plugins", + "name": "Plugin Name", + "type": "Type", + "version": "Version", + "author": "Author", + "status_col": "Status", + "empty_desc": "No description provided", + "subtitle": "Manage and configure OpenList extensions with ZIP file upload and third-party URL installation", + "search_placeholder": "Search plugins by name, ID, description, or permissions...", + "filter_type": "Filter by type", + "types": { + "all": "All Types", + "ui": "UI & Widgets", + "preview": "Previewers", + "tool": "Tools", + "theme": "Themes & Effects", + "integration": "Integrations", + "system": "System Core" + }, + "status": { + "enabled": "Enabled", + "disabled": "Disabled", + "builtin": "Built-in", + "custom": "Custom", + "high_privilege": "High Privilege" + }, + "actions": { + "install": "Install Plugin", + "reinstall": "Reinstall", + "uninstall": "Uninstall", + "config": "Settings", + "view_details": "Details", + "enable": "Enable", + "disable": "Disable", + "export": "Export Config", + "import": "Import Config", + "refresh": "Refresh", + "save": "Save Settings", + "cancel": "Cancel", + "install_url": "Install via URL" + }, + "install_modal": { + "install_type": "Installation Source", + "zip_tab": "Upload ZIP Package", + "url_tab": "Install via URL", + "zip_file_label": "Plugin ZIP Package", + "zip_drop_title": "Click to select or drag & drop plugin ZIP file here", + "zip_drop_help": "Supports open-source plugin archives containing plugin.json / manifest.json", + "url_label": "Plugin Manifest URL", + "url_placeholder": "e.g.: https://raw.githubusercontent.com/.../plugin.json", + "url_help": "Supports open-source plugin JSON manifest links (GitHub Raw, jsDelivr, unpkg, etc.)", + "high_privilege_label": "Grant System High Privilege (Root / Admin Privileges)", + "high_privilege_help": "Allows direct access to low-level filesystem APIs, storage driver reloading, full site settings read/write, and raw HTTP requests", + "fetch_and_install": "Fetch and Install" + }, + "config_modal": { + "title": "Plugin Configuration", + "no_config": "No configuration parameters required for this plugin.", + "save_success": "Plugin configuration saved successfully" + }, + "delete_confirm": "Are you sure you want to uninstall and remove this plugin? This cannot be undone.", + "import_modal": { + "title": "Import Plugins Backup", + "paste_json": "Paste plugins JSON data here", + "import_success": "Plugins imported successfully" + }, + "empty_installed": "No plugins installed yet. Click 'Install Plugin' to upload a ZIP package or install from URL." +} diff --git a/src/lang/en/shares.json b/src/lang/en/shares.json index 812ff5ba4..4849b40ff 100644 --- a/src/lang/en/shares.json +++ b/src/lang/en/shares.json @@ -37,6 +37,7 @@ "back": "Folders at Bottom" }, "input_password": "Please enter Share Code", + "share_gone": "This share has expired or been cancelled", "copy_msg": "Copy link", "no_permission_tip": "To create a share, you need \"Share\" permission." } diff --git a/src/pages/home/Obj.tsx b/src/pages/home/Obj.tsx index 0a96c634c..105703836 100644 --- a/src/pages/home/Obj.tsx +++ b/src/pages/home/Obj.tsx @@ -93,6 +93,7 @@ export const Obj = () => { => { + // Normalize root path: pathDir returns "" for root, so use "/" to avoid + // "path and file_name are required" errors. + const dirPath = pathDir(uploadPath) || "/" + + let md5 = "" + if (rapid) { + setUpload("status", "hashing") + try { + const hashes = await calculateHash(file, (p) => setUpload("progress", p)) + md5 = hashes.md5 + } catch { + // Hash failure degrades to normal chunked upload without rapid upload + md5 = "" + } + } + + setUpload("status", "uploading") + const createSession = (): Promise> => + r.post( + "/fs/upload/create", + { + path: dirPath, + file_name: file.name, + size: file.size, + md5, + }, + { + headers: { + Password: password(), + Overwrite: overwrite.toString(), + }, + }, + ) as unknown as Promise> + + let createResp = await createSession() + if (createResp.code !== 200) { + throw new Error(createResp.message) + } + let info = createResp.data + // 189Cloud requires the complete MD5 during init. Hash in the browser and + // retry session creation; the Worker still only receives one chunk at a time. + if (info?.requiresMd5 && !md5) { + setUpload("status", "hashing") + const hashes = await calculateHash(file, (p) => { + setUpload("progress", p | 0) + }) + md5 = hashes.md5 + setUpload("status", "uploading") + createResp = await createSession() + if (createResp.code !== 200) { + throw new Error(createResp.message) + } + info = createResp.data + } + // Storage does not support session chunked upload -> fall back to stream upload + if (!info) { + return await StreamUpload( + uploadPath, + file, + setUpload, + asTask, + overwrite, + false, + ) + } + // Rapid upload hit: file already exists, done + if (info.reuse) { + return + } + + const { session, partCount, chunkSize } = info + const totalParts: number = partCount + + let oldTimestamp = new Date().valueOf() + let oldLoaded = 0 + const partMd5s: string[] = [] + + // Upload parts sequentially (preserves part order and session consistency) + for (let i = 1; i <= totalParts; i++) { + const start = (i - 1) * chunkSize + const end = Math.min(start + chunkSize, file.size) + const chunk = file.slice(start, end) + const partResp = (await r.put("/fs/upload/part", chunk, { + headers: { + "X-Upload-Session": session, + "X-Part-Number": String(i), + "Upload-Path": encodeURIComponent(dirPath), + "Content-Type": "application/octet-stream", + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const loaded = start + progressEvent.loaded + const complete = (loaded / file.size) * 100 + setUpload("progress", complete | 0) + const timestamp = new Date().valueOf() + const duration = (timestamp - oldTimestamp) / 1000 + if (duration > 1) { + const speed = (loaded - oldLoaded) / duration + setUpload("speed", speed) + oldTimestamp = timestamp + oldLoaded = loaded + } + } + }, + })) as unknown as Resp + if (partResp.code !== 200) { + throw new Error( + `[Part ${i}/${totalParts}] ${partResp.message || "upload failed"}`, + ) + } + if (partResp.data?.partMd5) partMd5s[i - 1] = partResp.data.partMd5 + } + + setUpload("status", "backending") + const completeResp = (await r.post("/fs/upload/complete", { + path: dirPath, + session, + partMd5s, + })) as unknown as Resp + if (completeResp.code !== 200) { + throw new Error(completeResp.message) + } + return +} diff --git a/src/pages/home/uploads/multipart.ts b/src/pages/home/uploads/multipart.ts index bb1c57d86..3103e59ba 100644 --- a/src/pages/home/uploads/multipart.ts +++ b/src/pages/home/uploads/multipart.ts @@ -122,6 +122,11 @@ export const MultipartUpload: Upload = async ( throw new Error(initResp!.message) } const session = initResp!.data + // 后端不支持分片(存储驱动无会话上传能力)时返回 data:null, + // 回退到流式上传,保证任意存储都能上传 + if (!session) { + return StreamUpload(uploadPath, file, setUpload, false, overwrite, rapid) + } const uploadId = session.upload_id const chunkSize = session.chunk_size const total = session.total_chunks diff --git a/src/pages/home/uploads/uploads.ts b/src/pages/home/uploads/uploads.ts index 6fcacd51b..f348fa018 100644 --- a/src/pages/home/uploads/uploads.ts +++ b/src/pages/home/uploads/uploads.ts @@ -12,6 +12,10 @@ type Uploader = { } // All upload methods +// +// 分片上传统一采用官方 multipart 协议(/fs/multipart/*),GO / TS 后端共用 +// 同一 API 契约;后端实现可不同(TS 内部桥接到会话分片)。后端对不支持分片 +// 的存储返回 data:null,前端 multipart 自动回退到流式上传。 const AllUploads: Uploader[] = [ { name: "Multipart", diff --git a/src/pages/manage/SideMenu.tsx b/src/pages/manage/SideMenu.tsx index 542039f77..41e97c8d5 100644 --- a/src/pages/manage/SideMenu.tsx +++ b/src/pages/manage/SideMenu.tsx @@ -8,6 +8,7 @@ import { me } from "~/store" import { AnchorWithBase } from "~/components" import { Link } from "@solidjs/router" import { hoverColor, joinBase } from "~/utils" +import { BackendKind, getBackendKind } from "~/utils/backend" import { IconTypes } from "solid-icons" export interface SideMenuItemProps { @@ -18,10 +19,13 @@ export interface SideMenuItemProps { role?: number external?: true refresh?: true + /** Restrict this item to specific backends (e.g. ["go"] or ["ts-worker"]). */ + backend?: BackendKind[] } const SideMenuItem = (props: SideMenuItemProps) => { const ifShow = createMemo(() => { + if (props.backend && !props.backend.includes(getBackendKind())) return false if (!UserMethods.is_admin(me())) { if (props.role === undefined) return false else if (props.role === UserRole.GENERAL && !UserMethods.is_general(me())) diff --git a/src/pages/manage/index.tsx b/src/pages/manage/index.tsx index 0ed8064a8..5547f8b93 100644 --- a/src/pages/manage/index.tsx +++ b/src/pages/manage/index.tsx @@ -5,12 +5,16 @@ import { Header } from "./Header" import { SideMenu } from "./SideMenu" import { side_menu_items } from "./sidemenu_items" import { Route, Routes } from "@solidjs/router" -import { For, Suspense } from "solid-js" +import { createMemo, For, Suspense } from "solid-js" import { routes } from "./routes" +import { getBackendKind } from "~/utils/backend" const Manage = () => { const t = useT() useTitle(() => t("manage.title")) + const visibleRoutes = createMemo(() => + routes.filter((r) => !r.backend || r.backend.includes(getBackendKind())), + ) return ( { overflowY="auto" > - + {(route) => { return }} diff --git a/src/pages/manage/plugins/AddPlugin.tsx b/src/pages/manage/plugins/AddPlugin.tsx new file mode 100644 index 000000000..f2832d1bf --- /dev/null +++ b/src/pages/manage/plugins/AddPlugin.tsx @@ -0,0 +1,355 @@ +import { + Badge, + Box, + Button, + FormControl, + FormHelperText, + FormLabel, + Heading, + HStack, + Icon, + Input, + Select, + SelectContent, + SelectIcon, + SelectListbox, + SelectOption, + SelectOptionIndicator, + SelectOptionText, + SelectTrigger, + SelectValue, + Switch as HopeSwitch, + Text, + useColorModeValue, + VStack, +} from "@hope-ui/solid" +import { createSignal, Show } from "solid-js" +import { FiUploadCloud } from "solid-icons/fi" +import { FaSolidPuzzlePiece } from "solid-icons/fa" +import { useManageTitle, useRouter, useT } from "~/hooks" +import { PluginItem, Resp } from "~/types" +import { handleResp, notify, parsePluginZip, pluginEngine, r } from "~/utils" + +const AddPlugin = () => { + const t = useT() + const { back, to } = useRouter() + useManageTitle("plugins.actions.install") + + const [loading, setLoading] = createSignal(false) + const [installMethod, setInstallMethod] = createSignal<"zip" | "url">("zip") + + // ZIP upload state + const [zipFile, setZipFile] = createSignal(null) + const [zipParsedManifest, setZipParsedManifest] = + createSignal | null>(null) + const [isDragging, setIsDragging] = createSignal(false) + + // URL state + const [manifestUrl, setManifestUrl] = createSignal("") + + // High privilege state + const [highPrivilege, setHighPrivilege] = createSignal(false) + + const borderColor = useColorModeValue("$neutral4", "$neutral5") + const dropBg = useColorModeValue("$neutral1", "$neutral2") + + const handleFileChange = async (file: File) => { + if (!file.name.toLowerCase().endsWith(".zip")) { + notify.error("Please select a .zip plugin archive") + return + } + setZipFile(file) + setLoading(true) + try { + const extracted = await parsePluginZip(file) + setZipParsedManifest(extracted.manifest) + if (extracted.manifest.high_privilege) { + setHighPrivilege(true) + } + notify.success("ZIP plugin package parsed successfully") + } catch (err: any) { + notify.error("ZIP parse failed: " + err.message) + setZipFile(null) + setZipParsedManifest(null) + } finally { + setLoading(false) + } + } + + const handleInstall = async () => { + setLoading(true) + try { + if (installMethod() === "zip") { + const manifest = zipParsedManifest() + if (!manifest || !manifest.id) { + notify.warning("Please upload a valid plugin ZIP file first") + return + } + const resp: Resp = await r.post("/admin/plugin/install", { + ...manifest, + high_privilege: highPrivilege(), + enabled: true, + }) + handleResp(resp, (installed) => { + if (installed.enabled) { + pluginEngine.loadPlugin(installed) + } + notify.success(t("global.add") + " " + t("global.success")) + to("/@manage/plugins") + }) + } else { + const url = manifestUrl().trim() + if (!url) { + notify.warning("Please enter the plugin manifest URL") + return + } + const resp: Resp = await r.post("/admin/plugin/install", { + manifest_url: url, + high_privilege: highPrivilege(), + }) + handleResp(resp, (installed) => { + if (installed.enabled) { + pluginEngine.loadPlugin(installed) + } + notify.success(t("global.add") + " " + t("global.success")) + to("/@manage/plugins") + }) + } + } finally { + setLoading(false) + } + } + + return ( + + {t("plugins.actions.install")} + + {/* 1. Install Method Select */} + + + {t("plugins.install_modal.install_type")} + + + + + {/* 2. ZIP Mode */} + + + {t("plugins.install_modal.url_label")} + + setManifestUrl(e.target.value)} + /> + + {t("plugins.install_modal.url_help")} + + + } + > + + {t("plugins.install_modal.zip_file_label")} + { + e.preventDefault() + setIsDragging(true) + }} + onDragLeave={() => setIsDragging(false)} + onDrop={(e: DragEvent) => { + e.preventDefault() + setIsDragging(false) + if (e.dataTransfer?.files?.[0]) { + handleFileChange(e.dataTransfer.files[0]) + } + }} + onClick={() => { + const input = document.createElement("input") + input.type = "file" + input.accept = ".zip" + input.onchange = (e: any) => { + if (e.target.files?.[0]) { + handleFileChange(e.target.files[0]) + } + } + input.click() + }} + > + + + + {zipFile() + ? zipFile()?.name + : t("plugins.install_modal.zip_drop_title")} + + + {t("plugins.install_modal.zip_drop_help")} + + + + + + {/* Extracted Preview */} + + + + + } + > + plugin icon + + + + + + {zipParsedManifest()?.name} + + + v{zipParsedManifest()?.version} + + + {zipParsedManifest()?.id} + + + + {zipParsedManifest()?.description || + "ZIP plugin package parsed successfully"} + + + + + + + + {/* 3. High Privilege Option */} + + + {t("plugins.install_modal.high_privilege_label")} + + + setHighPrivilege(e.target.checked)} + /> + + Root Access + + + + {t("plugins.install_modal.high_privilege_help")} + + + + {/* 4. Operations */} + + + + + + ) +} + +export default AddPlugin diff --git a/src/pages/manage/plugins/ConfigPlugin.tsx b/src/pages/manage/plugins/ConfigPlugin.tsx new file mode 100644 index 000000000..7356f6b82 --- /dev/null +++ b/src/pages/manage/plugins/ConfigPlugin.tsx @@ -0,0 +1,361 @@ +import { + Badge, + Box, + Button, + FormControl, + FormHelperText, + FormLabel, + Heading, + HStack, + Icon, + Input, + Select, + SelectContent, + SelectIcon, + SelectListbox, + SelectOption, + SelectOptionIndicator, + SelectOptionText, + SelectTrigger, + SelectValue, + Switch as HopeSwitch, + Text, + Textarea, + VStack, +} from "@hope-ui/solid" +import { createSignal, For, onMount, Show } from "solid-js" +import { FaSolidPuzzlePiece } from "solid-icons/fa" +import { useFetch, useManageTitle, useRouter, useT } from "~/hooks" +import { PluginConfigField, PluginItem, Resp } from "~/types" +import { handleResp, notify, pluginEngine, r } from "~/utils" +import { MaybeLoading } from "~/components" + +const ConfigPlugin = () => { + const t = useT() + const { params, back, to } = useRouter() + const id = decodeURIComponent(params.id || "") + useManageTitle("plugins.config_modal.title") + + const [plugin, setPlugin] = createSignal(null) + const [formValues, setFormValues] = createSignal>({}) + const [highPrivilege, setHighPrivilege] = createSignal(false) + const [enabled, setEnabled] = createSignal(true) + const [saving, setSaving] = createSignal(false) + + const [fetchLoading, fetchPlugin] = useFetch( + (): Promise> => + r.get(`/admin/plugin/get?id=${encodeURIComponent(id)}`), + ) + + const initData = async () => { + const resp = await fetchPlugin() + handleResp( + resp, + (data) => { + setPlugin(data) + setHighPrivilege(!!data.high_privilege) + setEnabled(!!data.enabled) + const initial: Record = {} + if (data.config_schema) { + data.config_schema.forEach((field) => { + if ( + data.config_values && + data.config_values[field.key] !== undefined + ) { + initial[field.key] = data.config_values[field.key] + } else if (field.defaultValue !== undefined) { + initial[field.key] = field.defaultValue + } + }) + } + setFormValues(initial) + }, + (msg) => { + notify.error("Plugin not found: " + msg) + back() + }, + ) + } + + onMount(() => { + if (id) { + initData() + } + }) + + const handleFieldChange = (key: string, value: any) => { + setFormValues((prev) => ({ + ...prev, + [key]: value, + })) + } + + const handleSave = async () => { + const p = plugin() + if (!p) return + setSaving(true) + try { + const resp: Resp = await r.post("/admin/plugin/update", { + id: p.id, + config_values: formValues(), + high_privilege: highPrivilege(), + enabled: enabled(), + }) + handleResp(resp, (updated) => { + if (updated.enabled) { + pluginEngine.loadPlugin(updated) + } else { + pluginEngine.unloadPlugin(updated.id) + } + notify.success(t("global.save_success")) + to("/@manage/plugins") + }) + } finally { + setSaving(false) + } + } + + const renderFieldInput = (field: PluginConfigField) => { + const value = () => formValues()[field.key] + + switch (field.type) { + case "string": + return ( + handleFieldChange(field.key, e.target.value)} + /> + ) + case "number": + return ( + + handleFieldChange(field.key, parseFloat(e.target.value) || 0) + } + /> + ) + case "bool": + return ( + + + handleFieldChange(field.key, e.target.checked) + } + /> + + {t(`global.${value() ? "enable" : "disable"}`)} + + + ) + case "select": + return ( + + ) + case "text": + return ( +