1// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
2// See LICENSE.txt for license information.
3
4package model
5
6import (
7	"encoding/json"
8	"fmt"
9	"io/ioutil"
10	"os"
11	"path/filepath"
12	"strings"
13
14	"github.com/blang/semver"
15	"github.com/pkg/errors"
16	"gopkg.in/yaml.v2"
17)
18
19type PluginOption struct {
20	// The display name for the option.
21	DisplayName string `json:"display_name" yaml:"display_name"`
22
23	// The string value for the option.
24	Value string `json:"value" yaml:"value"`
25}
26
27type PluginSettingType int
28
29const (
30	Bool PluginSettingType = iota
31	Dropdown
32	Generated
33	Radio
34	Text
35	LongText
36	Number
37	Username
38	Custom
39)
40
41type PluginSetting struct {
42	// The key that the setting will be assigned to in the configuration file.
43	Key string `json:"key" yaml:"key"`
44
45	// The display name for the setting.
46	DisplayName string `json:"display_name" yaml:"display_name"`
47
48	// The type of the setting.
49	//
50	// "bool" will result in a boolean true or false setting.
51	//
52	// "dropdown" will result in a string setting that allows the user to select from a list of
53	// pre-defined options.
54	//
55	// "generated" will result in a string setting that is set to a random, cryptographically secure
56	// string.
57	//
58	// "radio" will result in a string setting that allows the user to select from a short selection
59	// of pre-defined options.
60	//
61	// "text" will result in a string setting that can be typed in manually.
62	//
63	// "longtext" will result in a multi line string that can be typed in manually.
64	//
65	// "number" will result in in integer setting that can be typed in manually.
66	//
67	// "username" will result in a text setting that will autocomplete to a username.
68	//
69	// "custom" will result in a custom defined setting and will load the custom component registered for the Web App System Console.
70	Type string `json:"type" yaml:"type"`
71
72	// The help text to display to the user. Supports Markdown formatting.
73	HelpText string `json:"help_text" yaml:"help_text"`
74
75	// The help text to display alongside the "Regenerate" button for settings of the "generated" type.
76	RegenerateHelpText string `json:"regenerate_help_text,omitempty" yaml:"regenerate_help_text,omitempty"`
77
78	// The placeholder to display for "generated", "text", "longtext", "number" and "username" types when blank.
79	Placeholder string `json:"placeholder" yaml:"placeholder"`
80
81	// The default value of the setting.
82	Default interface{} `json:"default" yaml:"default"`
83
84	// For "radio" or "dropdown" settings, this is the list of pre-defined options that the user can choose
85	// from.
86	Options []*PluginOption `json:"options,omitempty" yaml:"options,omitempty"`
87}
88
89type PluginSettingsSchema struct {
90	// Optional text to display above the settings. Supports Markdown formatting.
91	Header string `json:"header" yaml:"header"`
92
93	// Optional text to display below the settings. Supports Markdown formatting.
94	Footer string `json:"footer" yaml:"footer"`
95
96	// A list of setting definitions.
97	Settings []*PluginSetting `json:"settings" yaml:"settings"`
98}
99
100// The plugin manifest defines the metadata required to load and present your plugin. The manifest
101// file should be named plugin.json or plugin.yaml and placed in the top of your
102// plugin bundle.
103//
104// Example plugin.json:
105//
106//
107//    {
108//      "id": "com.mycompany.myplugin",
109//      "name": "My Plugin",
110//      "description": "This is my plugin",
111//      "homepage_url": "https://example.com",
112//      "support_url": "https://example.com/support",
113//      "release_notes_url": "https://example.com/releases/v0.0.1",
114//      "icon_path": "assets/logo.svg",
115//      "version": "0.1.0",
116//      "min_server_version": "5.6.0",
117//      "server": {
118//        "executables": {
119//          "linux-amd64": "server/dist/plugin-linux-amd64",
120//          "darwin-amd64": "server/dist/plugin-darwin-amd64",
121//          "windows-amd64": "server/dist/plugin-windows-amd64.exe"
122//        }
123//      },
124//      "webapp": {
125//          "bundle_path": "webapp/dist/main.js"
126//      },
127//      "settings_schema": {
128//        "header": "Some header text",
129//        "footer": "Some footer text",
130//        "settings": [{
131//          "key": "someKey",
132//          "display_name": "Enable Extra Feature",
133//          "type": "bool",
134//          "help_text": "When true, an extra feature will be enabled!",
135//          "default": "false"
136//        }]
137//      },
138//      "props": {
139//        "someKey": "someData"
140//      }
141//    }
142type Manifest struct {
143	// The id is a globally unique identifier that represents your plugin. Ids must be at least
144	// 3 characters, at most 190 characters and must match ^[a-zA-Z0-9-_\.]+$.
145	// Reverse-DNS notation using a name you control is a good option, e.g. "com.mycompany.myplugin".
146	Id string `json:"id" yaml:"id"`
147
148	// The name to be displayed for the plugin.
149	Name string `json:"name" yaml:"name"`
150
151	// A description of what your plugin is and does.
152	Description string `json:"description,omitempty" yaml:"description,omitempty"`
153
154	// HomepageURL is an optional link to learn more about the plugin.
155	HomepageURL string `json:"homepage_url,omitempty" yaml:"homepage_url,omitempty"`
156
157	// SupportURL is an optional URL where plugin issues can be reported.
158	SupportURL string `json:"support_url,omitempty" yaml:"support_url,omitempty"`
159
160	// ReleaseNotesURL is an optional URL where a changelog for the release can be found.
161	ReleaseNotesURL string `json:"release_notes_url,omitempty" yaml:"release_notes_url,omitempty"`
162
163	// A relative file path in the bundle that points to the plugins svg icon for use with the Plugin Marketplace.
164	// This should be relative to the root of your bundle and the location of the manifest file. Bitmap image formats are not supported.
165	IconPath string `json:"icon_path,omitempty" yaml:"icon_path,omitempty"`
166
167	// A version number for your plugin. Semantic versioning is recommended: http://semver.org
168	Version string `json:"version" yaml:"version"`
169
170	// The minimum Mattermost server version required for your plugin.
171	//
172	// Minimum server version: 5.6
173	MinServerVersion string `json:"min_server_version,omitempty" yaml:"min_server_version,omitempty"`
174
175	// Server defines the server-side portion of your plugin.
176	Server *ManifestServer `json:"server,omitempty" yaml:"server,omitempty"`
177
178	// If your plugin extends the web app, you'll need to define webapp.
179	Webapp *ManifestWebapp `json:"webapp,omitempty" yaml:"webapp,omitempty"`
180
181	// To allow administrators to configure your plugin via the Mattermost system console, you can
182	// provide your settings schema.
183	SettingsSchema *PluginSettingsSchema `json:"settings_schema,omitempty" yaml:"settings_schema,omitempty"`
184
185	// Plugins can store any kind of data in Props to allow other plugins to use it.
186	Props map[string]interface{} `json:"props,omitempty" yaml:"props,omitempty"`
187
188	// RequiredConfig defines any required server configuration fields for the plugin to function properly.
189	//
190	// Use the pluginapi.Configuration.CheckRequiredServerConfiguration method to enforce this.
191	RequiredConfig *Config `json:"required_configuration,omitempty" yaml:"required_configuration,omitempty"`
192}
193
194type ManifestServer struct {
195	// Executables are the paths to your executable binaries, specifying multiple entry
196	// points for different platforms when bundled together in a single plugin.
197	Executables map[string]string `json:"executables,omitempty" yaml:"executables,omitempty"`
198
199	// Executable is the path to your executable binary. This should be relative to the root
200	// of your bundle and the location of the manifest file.
201	//
202	// On Windows, this file must have a ".exe" extension.
203	//
204	// If your plugin is compiled for multiple platforms, consider bundling them together
205	// and using the Executables field instead.
206	Executable string `json:"executable" yaml:"executable"`
207}
208
209// ManifestExecutables is a legacy structure capturing a subet of the known platform executables.
210type ManifestExecutables struct {
211	// LinuxAmd64 is the path to your executable binary for the corresponding platform
212	LinuxAmd64 string `json:"linux-amd64,omitempty" yaml:"linux-amd64,omitempty"`
213	// DarwinAmd64 is the path to your executable binary for the corresponding platform
214	DarwinAmd64 string `json:"darwin-amd64,omitempty" yaml:"darwin-amd64,omitempty"`
215	// WindowsAmd64 is the path to your executable binary for the corresponding platform
216	// This file must have a ".exe" extension
217	WindowsAmd64 string `json:"windows-amd64,omitempty" yaml:"windows-amd64,omitempty"`
218}
219
220type ManifestWebapp struct {
221	// The path to your webapp bundle. This should be relative to the root of your bundle and the
222	// location of the manifest file.
223	BundlePath string `json:"bundle_path" yaml:"bundle_path"`
224
225	// BundleHash is the 64-bit FNV-1a hash of the webapp bundle, computed when the plugin is loaded
226	BundleHash []byte `json:"-"`
227}
228
229func (m *Manifest) HasClient() bool {
230	return m.Webapp != nil
231}
232
233func (m *Manifest) ClientManifest() *Manifest {
234	cm := new(Manifest)
235	*cm = *m
236	cm.Name = ""
237	cm.Description = ""
238	cm.Server = nil
239	if cm.Webapp != nil {
240		cm.Webapp = new(ManifestWebapp)
241		*cm.Webapp = *m.Webapp
242		cm.Webapp.BundlePath = "/static/" + m.Id + "/" + fmt.Sprintf("%s_%x_bundle.js", m.Id, m.Webapp.BundleHash)
243	}
244	return cm
245}
246
247// GetExecutableForRuntime returns the path to the executable for the given runtime architecture.
248//
249// If the manifest defines multiple executables, but none match, or if only a single executable
250// is defined, the Executable field will be returned. This method does not guarantee that the
251// resulting binary can actually execute on the given platform.
252func (m *Manifest) GetExecutableForRuntime(goOs, goArch string) string {
253	server := m.Server
254
255	if server == nil {
256		return ""
257	}
258
259	var executable string
260	if len(server.Executables) > 0 {
261		osArch := fmt.Sprintf("%s-%s", goOs, goArch)
262		executable = server.Executables[osArch]
263	}
264
265	if executable == "" {
266		executable = server.Executable
267	}
268
269	return executable
270}
271
272func (m *Manifest) HasServer() bool {
273	return m.Server != nil
274}
275
276func (m *Manifest) HasWebapp() bool {
277	return m.Webapp != nil
278}
279
280func (m *Manifest) MeetMinServerVersion(serverVersion string) (bool, error) {
281	minServerVersion, err := semver.Parse(m.MinServerVersion)
282	if err != nil {
283		return false, errors.New("failed to parse MinServerVersion")
284	}
285	sv := semver.MustParse(serverVersion)
286	if sv.LT(minServerVersion) {
287		return false, nil
288	}
289	return true, nil
290}
291
292func (m *Manifest) IsValid() error {
293	if !IsValidPluginId(m.Id) {
294		return errors.New("invalid plugin ID")
295	}
296
297	if strings.TrimSpace(m.Name) == "" {
298		return errors.New("a plugin name is needed")
299	}
300
301	if m.HomepageURL != "" && !IsValidHTTPURL(m.HomepageURL) {
302		return errors.New("invalid HomepageURL")
303	}
304
305	if m.SupportURL != "" && !IsValidHTTPURL(m.SupportURL) {
306		return errors.New("invalid SupportURL")
307	}
308
309	if m.ReleaseNotesURL != "" && !IsValidHTTPURL(m.ReleaseNotesURL) {
310		return errors.New("invalid ReleaseNotesURL")
311	}
312
313	if m.Version != "" {
314		_, err := semver.Parse(m.Version)
315		if err != nil {
316			return errors.Wrap(err, "failed to parse Version")
317		}
318	}
319
320	if m.MinServerVersion != "" {
321		_, err := semver.Parse(m.MinServerVersion)
322		if err != nil {
323			return errors.Wrap(err, "failed to parse MinServerVersion")
324		}
325	}
326
327	if m.SettingsSchema != nil {
328		err := m.SettingsSchema.isValid()
329		if err != nil {
330			return errors.Wrap(err, "invalid settings schema")
331		}
332	}
333
334	return nil
335}
336
337func (s *PluginSettingsSchema) isValid() error {
338	for _, setting := range s.Settings {
339		err := setting.isValid()
340		if err != nil {
341			return err
342		}
343	}
344
345	return nil
346}
347
348func (s *PluginSetting) isValid() error {
349	pluginSettingType, err := convertTypeToPluginSettingType(s.Type)
350	if err != nil {
351		return err
352	}
353
354	if s.RegenerateHelpText != "" && pluginSettingType != Generated {
355		return errors.New("should not set RegenerateHelpText for setting type that is not generated")
356	}
357
358	if s.Placeholder != "" && !(pluginSettingType == Generated ||
359		pluginSettingType == Text ||
360		pluginSettingType == LongText ||
361		pluginSettingType == Number ||
362		pluginSettingType == Username) {
363		return errors.New("should not set Placeholder for setting type not in text, generated or username")
364	}
365
366	if s.Options != nil {
367		if pluginSettingType != Radio && pluginSettingType != Dropdown {
368			return errors.New("should not set Options for setting type not in radio or dropdown")
369		}
370
371		for _, option := range s.Options {
372			if option.DisplayName == "" || option.Value == "" {
373				return errors.New("should not have empty Displayname or Value for any option")
374			}
375		}
376	}
377
378	return nil
379}
380
381func convertTypeToPluginSettingType(t string) (PluginSettingType, error) {
382	var settingType PluginSettingType
383	switch t {
384	case "bool":
385		return Bool, nil
386	case "dropdown":
387		return Dropdown, nil
388	case "generated":
389		return Generated, nil
390	case "radio":
391		return Radio, nil
392	case "text":
393		return Text, nil
394	case "number":
395		return Number, nil
396	case "longtext":
397		return LongText, nil
398	case "username":
399		return Username, nil
400	case "custom":
401		return Custom, nil
402	default:
403		return settingType, errors.New("invalid setting type: " + t)
404	}
405}
406
407// FindManifest will find and parse the manifest in a given directory.
408//
409// In all cases other than a does-not-exist error, path is set to the path of the manifest file that was
410// found.
411//
412// Manifests are JSON or YAML files named plugin.json, plugin.yaml, or plugin.yml.
413func FindManifest(dir string) (manifest *Manifest, path string, err error) {
414	for _, name := range []string{"plugin.yml", "plugin.yaml"} {
415		path = filepath.Join(dir, name)
416		f, ferr := os.Open(path)
417		if ferr != nil {
418			if !os.IsNotExist(ferr) {
419				return nil, "", ferr
420			}
421			continue
422		}
423		b, ioerr := ioutil.ReadAll(f)
424		f.Close()
425		if ioerr != nil {
426			return nil, path, ioerr
427		}
428		var parsed Manifest
429		err = yaml.Unmarshal(b, &parsed)
430		if err != nil {
431			return nil, path, err
432		}
433		manifest = &parsed
434		manifest.Id = strings.ToLower(manifest.Id)
435		return manifest, path, nil
436	}
437
438	path = filepath.Join(dir, "plugin.json")
439	f, ferr := os.Open(path)
440	if ferr != nil {
441		if os.IsNotExist(ferr) {
442			path = ""
443		}
444		return nil, path, ferr
445	}
446	defer f.Close()
447	var parsed Manifest
448	err = json.NewDecoder(f).Decode(&parsed)
449	if err != nil {
450		return nil, path, err
451	}
452	manifest = &parsed
453	manifest.Id = strings.ToLower(manifest.Id)
454	return manifest, path, nil
455}
456