github.com/rclone/rclone@v1.66.1-0.20240517100346-7b89735ae726/fs/rc/webgui/plugins.go (about)

     1  // Package webgui provides plugin functionality to the Web GUI.
     2  package webgui
     3  
     4  import (
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"net/http"
     9  	"net/http/httputil"
    10  	"net/url"
    11  	"os"
    12  	"path/filepath"
    13  	"regexp"
    14  	"strings"
    15  	"sync"
    16  
    17  	"github.com/rclone/rclone/fs"
    18  	"github.com/rclone/rclone/fs/config"
    19  	"github.com/rclone/rclone/fs/rc/rcflags"
    20  )
    21  
    22  // PackageJSON is the structure of package.json of a plugin
    23  type PackageJSON struct {
    24  	Name        string `json:"name"`
    25  	Version     string `json:"version"`
    26  	Description string `json:"description"`
    27  	Author      string `json:"author"`
    28  	Copyright   string `json:"copyright"`
    29  	License     string `json:"license"`
    30  	Private     bool   `json:"private"`
    31  	Homepage    string `json:"homepage"`
    32  	TestURL     string `json:"testUrl"`
    33  	Repository  struct {
    34  		Type string `json:"type"`
    35  		URL  string `json:"url"`
    36  	} `json:"repository"`
    37  	Bugs struct {
    38  		URL string `json:"url"`
    39  	} `json:"bugs"`
    40  	Rclone RcloneConfig `json:"rclone"`
    41  }
    42  
    43  // RcloneConfig represents the rclone specific config
    44  type RcloneConfig struct {
    45  	HandlesType      []string `json:"handlesType"`
    46  	PluginType       string   `json:"pluginType"`
    47  	RedirectReferrer bool     `json:"redirectReferrer"`
    48  	Test             bool     `json:"-"`
    49  }
    50  
    51  func (r *PackageJSON) isTesting() bool {
    52  	return r.Rclone.Test
    53  }
    54  
    55  var (
    56  	//loadedTestPlugins *Plugins
    57  	cachePath string
    58  
    59  	loadedPlugins *Plugins
    60  	pluginsProxy  = &httputil.ReverseProxy{}
    61  	// PluginsMatch is used for matching author and plugin name in the url path
    62  	PluginsMatch = regexp.MustCompile(`^plugins\/([^\/]*)\/([^\/\?]+)[\/]?(.*)$`)
    63  	// PluginsPath is the base path where webgui plugins are stored
    64  	PluginsPath              string
    65  	pluginsConfigPath        string
    66  	availablePluginsJSONPath = "availablePlugins.json"
    67  	initSuccess              = false
    68  	initMutex                = &sync.Mutex{}
    69  )
    70  
    71  // Plugins represents the structure how plugins are saved onto disk
    72  type Plugins struct {
    73  	mutex         sync.Mutex
    74  	LoadedPlugins map[string]PackageJSON `json:"loadedPlugins"`
    75  	fileName      string
    76  }
    77  
    78  func newPlugins(fileName string) *Plugins {
    79  	p := Plugins{LoadedPlugins: map[string]PackageJSON{}}
    80  	p.fileName = fileName
    81  	p.mutex = sync.Mutex{}
    82  	return &p
    83  }
    84  
    85  func initPluginsOrError() error {
    86  	if !rcflags.Opt.WebUI {
    87  		return errors.New("WebUI needs to be enabled for plugins to work")
    88  	}
    89  	initMutex.Lock()
    90  	defer initMutex.Unlock()
    91  	if !initSuccess {
    92  		cachePath = filepath.Join(config.GetCacheDir(), "webgui")
    93  		PluginsPath = filepath.Join(cachePath, "plugins")
    94  		pluginsConfigPath = filepath.Join(PluginsPath, "config")
    95  		loadedPlugins = newPlugins(availablePluginsJSONPath)
    96  		err := loadedPlugins.readFromFile()
    97  		if err != nil {
    98  			fs.Errorf(nil, "error reading available plugins: %v", err)
    99  		}
   100  		initSuccess = true
   101  	}
   102  
   103  	return nil
   104  }
   105  
   106  func (p *Plugins) readFromFile() (err error) {
   107  	err = CreatePathIfNotExist(pluginsConfigPath)
   108  	if err != nil {
   109  		return err
   110  	}
   111  	availablePluginsJSON := filepath.Join(pluginsConfigPath, p.fileName)
   112  	_, err = os.Stat(availablePluginsJSON)
   113  	if err == nil {
   114  		data, err := os.ReadFile(availablePluginsJSON)
   115  		if err != nil {
   116  			return err
   117  		}
   118  		err = json.Unmarshal(data, &p)
   119  		if err != nil {
   120  			fs.Logf(nil, "%s", err)
   121  		}
   122  		return nil
   123  	} else if os.IsNotExist(err) {
   124  		// path does not exist
   125  		err = p.writeToFile()
   126  		if err != nil {
   127  			return err
   128  		}
   129  	}
   130  	return nil
   131  }
   132  
   133  func (p *Plugins) addPlugin(pluginName string, packageJSONPath string) (err error) {
   134  	p.mutex.Lock()
   135  	defer p.mutex.Unlock()
   136  	data, err := os.ReadFile(packageJSONPath)
   137  	if err != nil {
   138  		return err
   139  	}
   140  	var pkgJSON = PackageJSON{}
   141  	err = json.Unmarshal(data, &pkgJSON)
   142  	if err != nil {
   143  		return err
   144  	}
   145  	p.LoadedPlugins[pluginName] = pkgJSON
   146  
   147  	err = p.writeToFile()
   148  	if err != nil {
   149  		return err
   150  	}
   151  
   152  	return nil
   153  }
   154  
   155  func (p *Plugins) writeToFile() (err error) {
   156  	availablePluginsJSON := filepath.Join(pluginsConfigPath, p.fileName)
   157  
   158  	file, err := json.MarshalIndent(p, "", " ")
   159  	if err != nil {
   160  		fs.Logf(nil, "%s", err)
   161  	}
   162  	err = os.WriteFile(availablePluginsJSON, file, 0755)
   163  	if err != nil {
   164  		fs.Logf(nil, "%s", err)
   165  	}
   166  	return nil
   167  }
   168  
   169  func (p *Plugins) removePlugin(name string) (err error) {
   170  	p.mutex.Lock()
   171  	defer p.mutex.Unlock()
   172  	err = p.readFromFile()
   173  	if err != nil {
   174  		return err
   175  	}
   176  
   177  	_, ok := p.LoadedPlugins[name]
   178  	if !ok {
   179  		return fmt.Errorf("plugin %s not loaded", name)
   180  	}
   181  	delete(p.LoadedPlugins, name)
   182  
   183  	err = p.writeToFile()
   184  	if err != nil {
   185  		return err
   186  	}
   187  	return nil
   188  }
   189  
   190  // GetPluginByName returns the plugin object for the key (author/plugin-name)
   191  func (p *Plugins) GetPluginByName(name string) (out *PackageJSON, err error) {
   192  	p.mutex.Lock()
   193  	defer p.mutex.Unlock()
   194  	po, ok := p.LoadedPlugins[name]
   195  	if !ok {
   196  		return nil, fmt.Errorf("plugin %s not loaded", name)
   197  	}
   198  	return &po, nil
   199  
   200  }
   201  
   202  // getAuthorRepoBranchGitHub gives author, repoName and branch from a github.com url
   203  //
   204  //	url examples:
   205  //	https://github.com/rclone/rclone-webui-react/
   206  //	http://github.com/rclone/rclone-webui-react
   207  //	https://github.com/rclone/rclone-webui-react/tree/caman-js
   208  //	github.com/rclone/rclone-webui-react
   209  func getAuthorRepoBranchGitHub(url string) (author string, repoName string, branch string, err error) {
   210  	repoURL := url
   211  	repoURL = strings.Replace(repoURL, "https://", "", 1)
   212  	repoURL = strings.Replace(repoURL, "http://", "", 1)
   213  
   214  	urlSplits := strings.Split(repoURL, "/")
   215  
   216  	if len(urlSplits) < 3 || len(urlSplits) > 5 || urlSplits[0] != "github.com" {
   217  		return "", "", "", fmt.Errorf("invalid github url: %s", url)
   218  	}
   219  
   220  	// get branch name
   221  	if len(urlSplits) == 5 && urlSplits[3] == "tree" {
   222  		return urlSplits[1], urlSplits[2], urlSplits[4], nil
   223  	}
   224  
   225  	return urlSplits[1], urlSplits[2], "master", nil
   226  }
   227  
   228  func filterPlugins(plugins *Plugins, compare func(packageJSON *PackageJSON) bool) map[string]PackageJSON {
   229  	output := map[string]PackageJSON{}
   230  
   231  	for key, val := range plugins.LoadedPlugins {
   232  		if compare(&val) {
   233  			output[key] = val
   234  		}
   235  	}
   236  
   237  	return output
   238  }
   239  
   240  // getDirectorForProxy is a helper function for reverse proxy of test plugins
   241  func getDirectorForProxy(origin *url.URL) func(req *http.Request) {
   242  	return func(req *http.Request) {
   243  		req.Header.Add("X-Forwarded-Host", req.Host)
   244  		req.Header.Add("X-Origin-Host", origin.Host)
   245  		req.URL.Scheme = "http"
   246  		req.URL.Host = origin.Host
   247  		req.URL.Path = origin.Path
   248  	}
   249  }
   250  
   251  // ServePluginOK checks the plugin url and uses reverse proxy to allow redirection for content not being served by rclone
   252  func ServePluginOK(w http.ResponseWriter, r *http.Request, pluginsMatchResult []string) (ok bool) {
   253  	testPlugin, err := loadedPlugins.GetPluginByName(fmt.Sprintf("%s/%s", pluginsMatchResult[1], pluginsMatchResult[2]))
   254  	if err != nil {
   255  		return false
   256  	}
   257  	if !testPlugin.Rclone.Test {
   258  		return false
   259  	}
   260  	origin, _ := url.Parse(fmt.Sprintf("%s/%s", testPlugin.TestURL, pluginsMatchResult[3]))
   261  
   262  	director := getDirectorForProxy(origin)
   263  
   264  	pluginsProxy.Director = director
   265  	pluginsProxy.ServeHTTP(w, r)
   266  	return true
   267  }
   268  
   269  var referrerPathReg = regexp.MustCompile(`^(https?):\/\/(.+):([0-9]+)?\/(.*)\/?\?(.*)$`)
   270  
   271  // ServePluginWithReferrerOK check if redirectReferrer is set for the referred a plugin, if yes,
   272  // sends a redirect to actual url. This function is useful for plugins to refer to absolute paths when
   273  // the referrer in http.Request is set
   274  func ServePluginWithReferrerOK(w http.ResponseWriter, r *http.Request, path string) (ok bool) {
   275  	err := initPluginsOrError()
   276  	if err != nil {
   277  		return false
   278  	}
   279  	referrer := r.Referer()
   280  	referrerPathMatch := referrerPathReg.FindStringSubmatch(referrer)
   281  
   282  	if len(referrerPathMatch) > 3 {
   283  		referrerPluginMatch := PluginsMatch.FindStringSubmatch(referrerPathMatch[4])
   284  		if len(referrerPluginMatch) > 2 {
   285  			pluginKey := fmt.Sprintf("%s/%s", referrerPluginMatch[1], referrerPluginMatch[2])
   286  			currentPlugin, err := loadedPlugins.GetPluginByName(pluginKey)
   287  			if err != nil {
   288  				return false
   289  			}
   290  			if currentPlugin.Rclone.RedirectReferrer {
   291  				path = fmt.Sprintf("/plugins/%s/%s/%s", referrerPluginMatch[1], referrerPluginMatch[2], path)
   292  
   293  				http.Redirect(w, r, path, http.StatusMovedPermanently)
   294  				return true
   295  			}
   296  		}
   297  	}
   298  	return false
   299  }