package extension_repo import ( "context" "encoding/json" "errors" "fmt" "io" "io/fs" "net/http" "os" "path/filepath" "runtime" "seanime/internal/constants" "seanime/internal/events" "seanime/internal/extension" "seanime/internal/util" "sync" "time" "github.com/Masterminds/semver/v3" "github.com/google/uuid" "github.com/samber/lo" ) ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // External extensions ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (r *Repository) FetchExternalExtensionData(manifestURI string) (*extension.Extension, error) { return r.fetchExternalExtensionData(manifestURI) } // fetchExternalExtensionData fetches the extension data from the manifest URI. // noPayloadDownload is an optional argument to skip downloading the payload from the payload URI if it exists (e.g when checking for updates) func (r *Repository) fetchExternalExtensionData(manifestURI string, noPayloadDownload ...bool) (*extension.Extension, error) { // Fetch the manifest file client := &http.Client{} ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURI, nil) if err != nil { r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to create HTTP request") return nil, fmt.Errorf("failed to create HTTP request, %w", err) } resp, err := client.Do(req) if err != nil { r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to fetch extension manifest") return nil, fmt.Errorf("failed to fetch extension manifest, %w", err) } defer resp.Body.Close() // Parse the response var ext extension.Extension err = json.NewDecoder(resp.Body).Decode(&ext) if err != nil { r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to parse extension manifest") return nil, fmt.Errorf("failed to parse extension manifest, %w", err) } // Before sanity check, fetch the payload if needed if ext.PayloadURI != "" && !lo.Contains(noPayloadDownload, true) { r.logger.Debug().Str("id", ext.ID).Msg("extensions: Downloading payload") payloadFromURI, err := r.downloadPayload(ext.PayloadURI) if err != nil { r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to download payload") return nil, fmt.Errorf("failed to download payload, %w", err) } if payloadFromURI == "" { r.logger.Error().Str("id", ext.ID).Msg("extensions: Downloaded payload is empty") return nil, fmt.Errorf("downloaded payload is empty") } ext.Payload = payloadFromURI } // Check manifest if err = manifestSanityCheck(&ext); err != nil { r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed sanity check") return nil, fmt.Errorf("failed sanity check, %w", err) } // Check if the extension is development mode if ext.IsDevelopment { r.logger.Error().Str("id", ext.ID).Msg("extensions: Development mode enabled, cannot install development mode extensions for security reasons") return nil, fmt.Errorf("cannot install development mode extensions for security reasons") } return &ext, nil } func (r *Repository) downloadPayload(uri string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client := &http.Client{} req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) if err != nil { return "", fmt.Errorf("failed to create HTTP request, %w", err) } // Download the payload resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to download payload, %w", err) } defer resp.Body.Close() // Read the payload payload, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read payload, %w", err) } return string(payload), nil } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Install external extension ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// type ExtensionInstallResponse struct { Message string `json:"message"` } func (r *Repository) InstallExternalExtension(manifestURI string) (*ExtensionInstallResponse, error) { ext, err := r.fetchExternalExtensionData(manifestURI) if err != nil { r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to fetch extension data") return nil, fmt.Errorf("failed to fetch extension data, %w", err) } filename := filepath.Join(r.extensionDir, ext.ID+".json") update := false // Check if the extension is already installed // i.e. a file with the same ID exists if _, err := os.Stat(filename); err == nil { r.logger.Debug().Str("id", ext.ID).Msg("extensions: Updating extension") // Delete the old extension err := os.Remove(filename) if err != nil { r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to remove old extension") return nil, fmt.Errorf("failed to remove old extension, %w", err) } update = true } // Add the extension as a json file file, err := os.Create(filename) if err != nil { r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to create extension file") return nil, fmt.Errorf("failed to create extension file, %w", err) } defer file.Close() // Write the extension to the file enc := json.NewEncoder(file) err = enc.Encode(ext) if err != nil { r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to write extension to file") return nil, fmt.Errorf("failed to write extension to file, %w", err) } // Reload the extensions //r.loadExternalExtensions() r.reloadExtension(ext.ID) if update { r.updateDataMu.Lock() r.updateData = lo.Filter(r.updateData, func(item UpdateData, _ int) bool { return item.ExtensionID != ext.ID }) r.updateDataMu.Unlock() return &ExtensionInstallResponse{ Message: fmt.Sprintf("Successfully updated %s", ext.Name), }, nil } return &ExtensionInstallResponse{ Message: fmt.Sprintf("Successfully installed %s", ext.Name), }, nil } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Uninstall external extension ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (r *Repository) UninstallExternalExtension(id string) error { // Check if the extension exists // Parse the ext ext, err := extractExtensionFromFile(filepath.Join(r.extensionDir, id+".json")) if err != nil { r.logger.Error().Err(err).Str("filepath", filepath.Join(r.extensionDir, id+".json")).Msg("extensions: Failed to read extension file") return fmt.Errorf("failed to read extension file, %w", err) } // Uninstall the extension err = os.Remove(filepath.Join(r.extensionDir, id+".json")) if err != nil { r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to uninstall extension") return fmt.Errorf("failed to uninstall extension, %w", err) } // Reload the extensions //r.loadExternalExtensions() go func() { _ = r.deleteExtensionUserConfig(id) // Delete the plugin data if it was a plugin if ext.Type == extension.TypePlugin { r.deletePluginData(id) r.removePluginFromStoredSettings(id) } }() r.reloadExtension(id) return nil } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Check for updates ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // checkForUpdates checks all extensions for updates by querying their respective repositories. // It returns a list of extension update data containing IDs and versions. func (r *Repository) checkForUpdates() (ret []UpdateData) { wg := sync.WaitGroup{} mu := sync.Mutex{} r.logger.Trace().Msg("extensions: Checking for updates") // Check for updates for all extensions r.extensionBank.Range(func(key string, ext extension.BaseExtension) bool { wg.Add(1) go func(ext extension.BaseExtension) { defer wg.Done() // Skip built-in extensions if ext.GetManifestURI() == "builtin" || ext.GetManifestURI() == "" { return } // Get the extension data from the repository extFromRepo, err := r.fetchExternalExtensionData(ext.GetManifestURI(), true) if err != nil { r.logger.Error().Err(err).Str("id", ext.GetID()).Str("url", ext.GetManifestURI()).Msg("extensions: Failed to fetch extension data while checking for update") return } // Sanity check, this checks for the version too if err = manifestSanityCheck(extFromRepo); err != nil { r.logger.Error().Err(err).Str("id", ext.GetID()).Str("url", ext.GetManifestURI()).Msg("extensions: Failed sanity check while checking for update") return } if extFromRepo.ID != ext.GetID() { r.logger.Warn().Str("id", ext.GetID()).Str("newID", extFromRepo.ID).Str("url", ext.GetManifestURI()).Msg("extensions: Extension ID changed while checking for update") return } // If there's an update, send the update data to the channel if extFromRepo.Version != ext.GetVersion() { mu.Lock() ret = append(ret, UpdateData{ ExtensionID: extFromRepo.ID, Version: extFromRepo.Version, ManifestURI: extFromRepo.ManifestURI, }) mu.Unlock() } }(ext) return true }) wg.Wait() r.logger.Debug().Int("haveUpdates", len(ret)).Msg("extensions: Retrieved update info") return } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Update extension code ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // UpdateExtensionCode updates the code of an external application func (r *Repository) UpdateExtensionCode(id string, payload string) error { if id == "" { r.logger.Error().Msg("extensions: ID is empty") return fmt.Errorf("id is empty") } if payload == "" { r.logger.Error().Msg("extensions: Payload is empty") return fmt.Errorf("payload is empty") } // We don't check if the extension existed in "loaded" extensions since the extension might be invalid // We check if the file exists filename := id + ".json" extensionFilepath := filepath.Join(r.extensionDir, filename) if _, err := os.Stat(extensionFilepath); err != nil { r.logger.Error().Err(err).Str("id", id).Msg("extensions: Extension not found") return fmt.Errorf("extension not found") } ext, err := extractExtensionFromFile(extensionFilepath) if err != nil { r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to read extension file") return fmt.Errorf("failed to read extension file, %w", err) } // Update the payload ext.Payload = payload // Write the extension to the file file, err := os.Create(extensionFilepath) if err != nil { r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to create extension file") return fmt.Errorf("failed to create extension file, %w", err) } defer file.Close() enc := json.NewEncoder(file) err = enc.Encode(ext) if err != nil { r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to write extension to file") return fmt.Errorf("failed to write extension to file, %w", err) } // Call reload extension to unload it r.reloadExtension(id) return nil } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Loading/Reloading external extensions ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (r *Repository) ReloadExternalExtensions() { r.loadExternalExtensions() } func (r *Repository) ReloadExternalExtension(id string) { r.reloadExtension(id) runtime.GC() } // interruptExternalGojaExtensionVMs kills all VMs from currently loaded external Goja extensions & clears the Goja extensions map. func (r *Repository) interruptExternalGojaExtensionVMs() { defer util.HandlePanicInModuleThen("extension_repo/interruptExternalGojaExtensionVMs", func() {}) r.logger.Trace().Msg("extensions: Interrupting Goja VMs") count := 0 // Remove external extensions from the Goja extensions map //r.gojaExtensions.Clear() for _, key := range r.gojaExtensions.Keys() { if gojaExt, ok := r.gojaExtensions.Get(key); ok { if gojaExt.GetExtension().ManifestURI != "builtin" { gojaExt.ClearInterrupt() r.gojaExtensions.Delete(key) count++ } } } r.logger.Debug().Int("count", count).Msg("extensions: Killed Goja VMs") } // unloadExternalExtensions unloads all external extensions from the extension banks. func (r *Repository) unloadExternalExtensions() { r.logger.Trace().Msg("extensions: Unloading external extensions") // We also clear the invalid extensions list, assuming the extensions are reloaded //r.invalidExtensions.Clear() count := 0 for _, key := range r.invalidExtensions.Keys() { if invalidExt, ok := r.invalidExtensions.Get(key); ok { if invalidExt.Extension.ManifestURI != "builtin" { r.invalidExtensions.Delete(key) count++ } } } r.extensionBank.RemoveExternalExtensions() r.logger.Debug().Int("count", count).Msg("extensions: Unloaded external extensions") } // loadExternalExtensions loads all external extensions from the extension directory. // This should be called after the built-in extensions are loaded. func (r *Repository) loadExternalExtensions() { r.logger.Trace().Msg("extensions: Loading external extensions") // Interrupt all Goja VMs r.interruptExternalGojaExtensionVMs() // Unload all external extensions r.unloadExternalExtensions() // // Load external extensions // err := filepath.WalkDir(r.extensionDir, func(path string, d fs.DirEntry, err error) error { if err != nil { r.logger.Error().Err(err).Msg("extensions: Failed to walk directory") return err } if d.IsDir() { return nil } // Check if the file is a .json file // If it is, parse the json and install the extension if filepath.Ext(path) != ".json" { return nil } r.loadExternalExtension(path) return nil }) if err != nil { r.logger.Error().Err(err).Msg("extensions: Failed to load extensions") return } r.logger.Debug().Msg("extensions: Loaded external extensions") if r.firstExternalExtensionLoadedFunc != nil { r.firstExternalExtensionLoadedFunc() } r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil) } // Loads an external extension from a file path func (r *Repository) loadExternalExtension(filePath string) { // Parse the ext ext, err := extractExtensionFromFile(filePath) if err != nil { r.logger.Error().Err(err).Str("filepath", filePath).Msg("extensions: Failed to read extension file") return } ext.Lang = extension.GetExtensionLang(ext.Lang) var manifestError error // + // | Manifest sanity check // + // Sanity check if err = r.extensionSanityCheck(ext); err != nil { r.logger.Error().Err(err).Str("filepath", filePath).Msg("extensions: Failed sanity check") manifestError = err } invalidExtensionID := ext.ID if invalidExtensionID == "" { invalidExtensionID = uuid.NewString() } // If there was an error with the manifest, skip loading the extension, // add the extension to the InvalidExtensions list and return // The extension should be added to the InvalidExtensions list with an auto-generated ID. if manifestError != nil { r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{ ID: invalidExtensionID, Reason: manifestError.Error(), Path: filePath, Code: extension.InvalidExtensionManifestError, Extension: *ext, }) r.logger.Error().Err(manifestError).Str("filepath", filePath).Msg("extensions: Failed to load extension, manifest error") return } if ext.SemverConstraint != "" { c, err := semver.NewConstraint(ext.SemverConstraint) v, _ := semver.NewVersion(constants.Version) if err == nil { if !c.Check(v) { r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{ ID: invalidExtensionID, Reason: fmt.Sprintf("Incompatible with this version of Seanime (%s): %s", constants.Version, ext.SemverConstraint), Path: filePath, Code: extension.InvalidExtensionSemverConstraintError, Extension: *ext, }) r.logger.Error().Str("id", ext.ID).Msg("extensions: Failed to load extension, semver constraint error") return } } } var loadingErr error // + // | Load payload // + // Load the payload URI if the extension is development mode. // The payload URI is a path to the payload file. if ext.IsDevelopment && ext.PayloadURI != "" { if _, err := os.Stat(ext.PayloadURI); errors.Is(err, os.ErrNotExist) { r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to read payload file") return } payload, err := os.ReadFile(ext.PayloadURI) if err != nil { r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to read payload file") return } ext.Payload = string(payload) r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded payload from file") } // + // | Check plugin permissions // + if ext.Type == extension.TypePlugin && !ext.IsDevelopment { if ext.Plugin == nil { // Shouldn't happen because of sanity check, but just in case r.logger.Error().Str("id", ext.ID).Msg("extensions: Plugin manifest is missing plugin object") return } permissionErr := r.checkPluginPermissions(ext) if permissionErr != nil { r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{ ID: invalidExtensionID, Reason: permissionErr.Error(), Path: filePath, Code: extension.InvalidExtensionPluginPermissionsNotGranted, Extension: *ext, PluginPermissionDescription: ext.Plugin.Permissions.GetDescription(), }) r.logger.Warn().Err(permissionErr).Str("id", ext.ID).Msg("extensions: Plugin permissions not granted. Please grant the permissions in the extension page.") return } } // + // | Load user config // + // Load user config configErr := r.loadUserConfig(ext) // If there was an error loading the user config, we add it to the InvalidExtensions list // BUT we still load the extension // DEVNOTE: Failure to load the user config is not a critical error if configErr != nil { r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{ ID: invalidExtensionID, Reason: configErr.Error(), Path: filePath, Code: extension.InvalidExtensionUserConfigError, Extension: *ext, }) r.logger.Warn().Err(configErr).Str("id", invalidExtensionID).Msg("extensions: Failed to load user config") } // + // | Load extension // + // Load extension switch ext.Type { case extension.TypeMangaProvider: // Load manga provider loadingErr = r.loadExternalMangaExtension(ext) case extension.TypeOnlinestreamProvider: // Load online streaming provider loadingErr = r.loadExternalOnlinestreamProviderExtension(ext) case extension.TypeAnimeTorrentProvider: // Load torrent provider loadingErr = r.loadExternalAnimeTorrentProviderExtension(ext) case extension.TypePlugin: // Load plugin loadingErr = r.loadPlugin(ext) default: r.logger.Error().Str("type", string(ext.Type)).Msg("extensions: Extension type not supported") loadingErr = fmt.Errorf("extension type not supported") } // If there was an error loading the extension, skip adding it to the extension bank // and add the extension to the InvalidExtensions list if loadingErr != nil { r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{ ID: invalidExtensionID, Reason: loadingErr.Error(), Path: filePath, Code: extension.InvalidExtensionPayloadError, Extension: *ext, }) r.logger.Error().Err(loadingErr).Str("filepath", filePath).Msg("extensions: Failed to load extension") return } r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded external extension") } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Reload specific extension ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (r *Repository) reloadExtension(id string) { r.logger.Trace().Str("id", id).Msg("extensions: Reloading extension") // 1. Unload the extension // Remove extension from bank r.extensionBank.Delete(id) // Kill Goja VM if it exists gojaExtension, ok := r.gojaExtensions.Get(id) if ok { // Interrupt the extension's runtime and running processed before unloading gojaExtension.ClearInterrupt() r.logger.Trace().Str("id", id).Msg("extensions: Killed extension's runtime") r.gojaExtensions.Delete(id) } // Remove from invalid extensions r.invalidExtensions.Delete(id) time.Sleep(200 * time.Millisecond) // 2. Load the extension back // Load the extension from the file extensionFilepath := filepath.Join(r.extensionDir, id+".json") // Check if the extension still exists if _, err := os.Stat(extensionFilepath); err != nil { // If the extension doesn't exist anymore, return silently - it was uninstalled r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil) r.logger.Debug().Str("id", id).Msg("extensions: Extension removed") return } // If the extension still exist, load it back r.loadExternalExtension(extensionFilepath) r.logger.Debug().Str("id", id).Msg("extensions: Reloaded extension") r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil) }