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