Add extracting and validating the plugin

This commit is contained in:
tutman96 2025-01-01 22:34:59 +00:00
parent 377c3e89c0
commit 0a772005dc
8 changed files with 518 additions and 12 deletions

View File

@ -0,0 +1,54 @@
package plugin
import (
"encoding/json"
"fmt"
"os"
)
const databaseFile = pluginsFolder + "/plugins.json"
var pluginDatabase = PluginDatabase{}
func init() {
if err := pluginDatabase.Load(); err != nil {
fmt.Printf("failed to load plugin database: %v\n", err)
}
}
func (d *PluginDatabase) Load() error {
file, err := os.Open(databaseFile)
if os.IsNotExist(err) {
d.Plugins = make(map[string]PluginInstall)
return nil
}
if err != nil {
return fmt.Errorf("failed to open plugin database: %v", err)
}
defer file.Close()
if err := json.NewDecoder(file).Decode(d); err != nil {
return fmt.Errorf("failed to decode plugin database: %v", err)
}
return nil
}
func (d *PluginDatabase) Save() error {
d.saveMutex.Lock()
defer d.saveMutex.Unlock()
file, err := os.Create(databaseFile)
if err != nil {
return fmt.Errorf("failed to create plugin database: %v", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(d); err != nil {
return fmt.Errorf("failed to encode plugin database: %v", err)
}
return nil
}

View File

@ -0,0 +1,83 @@
package plugin
import (
"archive/tar"
"compress/gzip"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"github.com/google/uuid"
)
const pluginsExtractsFolder = pluginsFolder + "/extracts"
func init() {
_ = os.MkdirAll(pluginsExtractsFolder, 0755)
}
func extractPlugin(filePath string) (*string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file for extraction: %v", err)
}
defer file.Close()
var reader io.Reader = file
// TODO: there's probably a better way of doing this without relying on the file extension
if strings.HasSuffix(filePath, ".gz") {
gzipReader, err := gzip.NewReader(file)
if err != nil {
return nil, fmt.Errorf("failed to create gzip reader: %v", err)
}
defer gzipReader.Close()
reader = gzipReader
}
destinationFolder := path.Join(pluginsExtractsFolder, uuid.New().String())
if err := os.MkdirAll(destinationFolder, 0755); err != nil {
return nil, fmt.Errorf("failed to create extracts folder: %v", err)
}
tarReader := tar.NewReader(reader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("failed to read tar header: %v", err)
}
// Prevent path traversal attacks
targetPath := filepath.Join(destinationFolder, header.Name)
if !strings.HasPrefix(targetPath, filepath.Clean(destinationFolder)+string(os.PathSeparator)) {
return nil, fmt.Errorf("tar file contains illegal path: %s", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
return nil, fmt.Errorf("failed to create directory: %v", err)
}
case tar.TypeReg:
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode))
if err != nil {
return nil, fmt.Errorf("failed to create file: %v", err)
}
defer file.Close()
if _, err := io.Copy(file, tarReader); err != nil {
return nil, fmt.Errorf("failed to extract file: %v", err)
}
default:
return nil, fmt.Errorf("unsupported tar entry type: %v", header.Typeflag)
}
}
return &destinationFolder, nil
}

View File

@ -1,6 +1,7 @@
package plugin
import (
"encoding/json"
"fmt"
"kvm/internal/storage"
"os"
@ -10,7 +11,7 @@ import (
)
const pluginsFolder = "/userdata/jetkvm/plugins"
const pluginsUploadFolder = pluginsFolder + "/_uploads"
const pluginsUploadFolder = pluginsFolder + "/uploads"
func init() {
_ = os.MkdirAll(pluginsUploadFolder, 0755)
@ -51,3 +52,122 @@ func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUplo
DataChannel: uploadId,
}, nil
}
func RpcPluginExtract(filename string) (*PluginManifest, error) {
sanitizedFilename, err := storage.SanitizeFilename(filename)
if err != nil {
return nil, err
}
filePath := path.Join(pluginsUploadFolder, sanitizedFilename)
extractFolder, err := extractPlugin(filePath)
if err != nil {
return nil, err
}
if err := os.Remove(filePath); err != nil {
return nil, fmt.Errorf("failed to delete uploaded file: %v", err)
}
manifest, err := readManifest(*extractFolder)
if err != nil {
return nil, err
}
// Get existing PluginInstall
install, ok := pluginDatabase.Plugins[manifest.Name]
if !ok {
install = PluginInstall{
Enabled: false,
Version: manifest.Version,
ExtractedVersions: make(map[string]string),
}
}
_, ok = install.ExtractedVersions[manifest.Version]
if ok {
return nil, fmt.Errorf("this version has already been uploaded: %s", manifest.Version)
}
install.ExtractedVersions[manifest.Version] = *extractFolder
pluginDatabase.Plugins[manifest.Name] = install
if err := pluginDatabase.Save(); err != nil {
return nil, fmt.Errorf("failed to save plugin database: %v", err)
}
return manifest, nil
}
func RpcPluginInstall(name string, version string) error {
// TODO: find the plugin version in the plugins.json file
pluginInstall, ok := pluginDatabase.Plugins[name]
if !ok {
return fmt.Errorf("plugin not found: %s", name)
}
if pluginInstall.Version == version && pluginInstall.Enabled {
fmt.Printf("Plugin %s is already installed with version %s\n", name, version)
return nil
}
_, ok = pluginInstall.ExtractedVersions[version]
if !ok {
return fmt.Errorf("plugin version not found: %s", version)
}
// TODO: If there is a running plugin with the same name, stop it and start the new version
pluginInstall.Version = version
pluginInstall.Enabled = true
pluginDatabase.Plugins[name] = pluginInstall
if err := pluginDatabase.Save(); err != nil {
return fmt.Errorf("failed to save plugin database: %v", err)
}
// TODO: start the plugin
// TODO: Determine if the old version should be removed
return nil
}
func readManifest(extractFolder string) (*PluginManifest, error) {
manifestPath := path.Join(extractFolder, "manifest.json")
manifestFile, err := os.Open(manifestPath)
if err != nil {
return nil, fmt.Errorf("failed to open manifest file: %v", err)
}
defer manifestFile.Close()
manifest := PluginManifest{}
if err := json.NewDecoder(manifestFile).Decode(&manifest); err != nil {
return nil, fmt.Errorf("failed to read manifest file: %v", err)
}
if err := validateManifest(&manifest); err != nil {
return nil, fmt.Errorf("invalid manifest file: %v", err)
}
return &manifest, nil
}
func validateManifest(manifest *PluginManifest) error {
if manifest.ManifestVersion != "1" {
return fmt.Errorf("unsupported manifest version: %s", manifest.ManifestVersion)
}
if manifest.Name == "" {
return fmt.Errorf("missing plugin name")
}
if manifest.Version == "" {
return fmt.Errorf("missing plugin version")
}
if manifest.Homepage == "" {
return fmt.Errorf("missing plugin homepage")
}
return nil
}

30
internal/plugin/type.go Normal file
View File

@ -0,0 +1,30 @@
package plugin
import "sync"
type PluginManifest struct {
ManifestVersion string `json:"manifest_version"`
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Homepage string `json:"homepage"`
BinaryPath string `json:"bin"`
SystemMinVersion string `json:"system_min_version,omitempty"`
}
type PluginInstall struct {
Enabled bool `json:"enabled"`
// Current active version of the plugin
Version string `json:"version"`
// Map of a plugin version to the extracted directory
ExtractedVersions map[string]string `json:"extracted_versions"`
}
type PluginDatabase struct {
// Map with the plugin name as the key
Plugins map[string]PluginInstall `json:"plugins"`
saveMutex sync.Mutex
}

View File

@ -556,4 +556,6 @@ var rpcHandlers = map[string]RPCHandler{
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
"pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}},
"pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}},
"pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}},
}

View File

@ -5,6 +5,8 @@ import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import {
PluginManifest,
usePluginStore,
useRTCStore,
} from "../hooks/stores";
import { cx } from "../cva.config";
@ -16,6 +18,7 @@ import { formatters } from "@/utils";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import AutoHeight from "./AutoHeight";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import notifications from "../notifications";
import { isOnDevice } from "../main";
import { ViewHeader } from "./MountMediaDialog";
@ -35,6 +38,28 @@ export default function UploadPluginModal({
}
function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const {
pluginUploadModalView,
setPluginUploadModalView,
pluginUploadFilename,
setPluginUploadFilename,
pluginUploadManifest,
setPluginUploadManifest,
} = usePluginStore();
const [send] = useJsonRpc();
const [extractError, setExtractError] = useState<string | null>(null);
function extractPlugin(filename: string) {
send("pluginExtract", { filename }, resp => {
if ("error" in resp) {
setExtractError(resp.error.data || resp.error.message);
return
}
setPluginUploadManifest(resp.result as PluginManifest);
});
}
return (
<AutoHeight>
<div
@ -54,14 +79,46 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
className="h-[24px] dark:block hidden dark:!mt-0"
/>
<UploadFileView
{!extractError && pluginUploadModalView === "upload" && <UploadFileView
onBack={() => {
setOpen(false)
}}
onCancelUpload={() => {
setOpen(false)
onUploadCompleted={(filename) => {
setPluginUploadFilename(filename)
setPluginUploadModalView("install")
extractPlugin(filename)
}}
/>
/>}
{extractError && (
<ErrorView
errorMessage={extractError}
onClose={() => {
setOpen(false)
setPluginUploadFilename(null)
setExtractError(null)
}}
onRetry={() => {
setExtractError(null)
setPluginUploadFilename(null)
setPluginUploadModalView("upload")
}}
/>
)}
{!extractError && pluginUploadModalView === "install" && <InstallPluginView
filename={pluginUploadFilename!}
manifest={pluginUploadManifest}
onInstall={() => {
setOpen(false)
setPluginUploadFilename(null)
// TODO: Open plugin settings dialog
}}
onBack={() => {
setPluginUploadModalView("upload")
setPluginUploadFilename(null)
}}
/>}
</div>
</div>
</GridCard>
@ -74,10 +131,10 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
// TODO: refactor to a shared component
function UploadFileView({
onBack,
onCancelUpload,
onUploadCompleted,
}: {
onBack: () => void;
onCancelUpload: () => void;
onUploadCompleted: (filename: string) => void;
}) {
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">(
"idle",
@ -177,6 +234,7 @@ function UploadFileView({
if (offset >= file.size) {
rtcDataChannel.close();
setUploadState("success");
onUploadCompleted(file.name);
return;
}
@ -260,6 +318,7 @@ function UploadFileView({
xhr.onload = () => {
if (xhr.status === 200) {
setUploadState("success");
onUploadCompleted(file.name);
} else {
console.error("Upload error:", xhr.statusText);
setUploadError(xhr.statusText);
@ -459,7 +518,7 @@ function UploadFileView({
theme="light"
text="Cancel Upload"
onClick={() => {
onCancelUpload();
onBack();
setUploadState("idle");
setUploadProgress(0);
setUploadedFileName(null);
@ -470,7 +529,7 @@ function UploadFileView({
) : (
<Button
size="MD"
theme={uploadState === "success" ? "primary" : "light"}
theme="light"
text="Back"
onClick={onBack}
/>
@ -479,4 +538,134 @@ function UploadFileView({
</div>
</div>
);
}
function InstallPluginView({
filename,
manifest,
onInstall,
onBack,
}: {
filename: string;
manifest: PluginManifest | null;
onInstall: () => void;
onBack: () => void;
}) {
const [send] = useJsonRpc();
const [error, setError] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
function handleInstall() {
if (installing) return;
setInstalling(true);
send("pluginInstall", { name: manifest!.name, version: manifest!.version }, resp => {
if ("error" in resp) {
setError(resp.error.message);
return
}
setInstalling(false);
onInstall();
});
}
return (
<div className="w-full space-y-4">
<ViewHeader
title="Install Plugin"
description={
!manifest ?
`Extracting plugin from ${filename}...` :
`Do you want to install the plugin?`
}
/>
{manifest && (
<div className="space-y-2">
<div className="text-sm text-slate-700 dark:text-slate-300">
<h3 className="text-lg font-semibold">{manifest.name}</h3>
<p className="text-xs">{manifest.description}</p>
<p className="text-xs">
Version: {manifest.version}
</p>
<p className="text-xs">
<a
href={manifest.homepage}
target="_blank"
rel="noreferrer"
className="text-blue-500 dark:text-blue-400"
>
{manifest.homepage}
</a>
</p>
</div>
</div>
)}
{error && (
<div
className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn"
style={{ animationDuration: "0.7s" }}
>
Error: {error}
</div>
)}
<div
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
}}
>
<div className="flex justify-end w-full space-x-2">
<Button
size="MD"
theme="light"
text="Cancel"
onClick={() => {
// TODO: Delete the orphaned extraction
setError(null);
onBack();
}}
/>
<Button
size="MD"
theme="primary"
text="Install"
onClick={handleInstall}
/>
</div>
</div>
</div>
);
}
function ErrorView({
errorMessage,
onClose,
onRetry,
}: {
errorMessage: string | null;
onClose: () => void;
onRetry: () => void;
}) {
return (
<div className="w-full space-y-4">
<div className="space-y-2">
<div className="flex items-center space-x-2 text-red-600">
<ExclamationTriangleIcon className="w-6 h-6" />
<h2 className="text-lg font-bold leading-tight">Plugin Extract Error</h2>
</div>
<p className="text-sm leading-snug text-slate-600">
An error occurred while attempting to extract the plugin. Please ensure the plugin is valid and try again.
</p>
</div>
{errorMessage && (
<Card className="p-4 border border-red-200 bg-red-50">
<p className="text-sm font-medium text-red-800">{errorMessage}</p>
</Card>
)}
<div className="flex justify-end space-x-2">
<Button size="SM" theme="light" text="Close" onClick={onClose} />
<Button size="SM" theme="primary" text="Back to Upload" onClick={onRetry} />
</div>
</div>
);
}

View File

@ -253,7 +253,7 @@ export default function SettingsSidebar() {
}
};
const {isPluginUploadModalOpen, setIsPluginUploadModalOpen} = usePluginStore();
const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore();
useEffect(() => {
getCloudState();
@ -776,7 +776,10 @@ export default function SettingsSidebar() {
size="SM"
theme="primary"
text="Upload Plugin"
onClick={() => setIsPluginUploadModalOpen(true)}
onClick={() => {
setPluginUploadModalView("upload");
setIsPluginUploadModalOpen(true)
}}
/>
<UploadPluginModal
open={isPluginUploadModalOpen}

View File

@ -530,12 +530,37 @@ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
}));
export interface PluginManifest {
name: string;
version: string;
description?: string;
homepage: string;
}
interface PluginState {
isPluginUploadModalOpen: boolean;
setIsPluginUploadModalOpen: (isOpen: boolean) => void;
pluginUploadFilename: string | null;
setPluginUploadFilename: (filename: string | null) => void;
pluginUploadManifest: PluginManifest | null;
setPluginUploadManifest: (manifest: PluginManifest | null) => void;
pluginUploadModalView: "upload" | "install";
setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void;
}
export const usePluginStore = create<PluginState>(set => ({
isPluginUploadModalOpen: false,
setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }),
}));
pluginUploadFilename: null,
setPluginUploadFilename: filename => set({ pluginUploadFilename: filename }),
pluginUploadManifest: null,
setPluginUploadManifest: manifest => set({ pluginUploadManifest: manifest }),
pluginUploadModalView: "upload",
setPluginUploadModalView: view => set({ pluginUploadModalView: view }),
}));