diff --git a/.gitattributes b/.gitattributes index 43591dac..4bd338f4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ server/manifest.go linguist-generated=true -webapp/src/manifest.ts linguist-generated=true +webapp/src/manifest.js linguist-generated=true diff --git a/.gitignore b/.gitignore index 19639c64..11be5249 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ dist +bin/ +webapp/src/manifest.ts +server/manifest.go # Mac .DS_Store @@ -7,3 +10,7 @@ dist .vscode assets/i18n/translate.*.json + +# Jetbrains +.idea/ + diff --git a/Makefile b/Makefile index fc2c58c4..dff75460 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,6 @@ GO ?= $(shell command -v go 2> /dev/null) NPM ?= $(shell command -v npm 2> /dev/null) CURL ?= $(shell command -v curl 2> /dev/null) MM_DEBUG ?= -MANIFEST_FILE ?= plugin.json GOPATH ?= $(shell go env GOPATH) GO_TEST_FLAGS ?= -race GO_BUILD_FLAGS ?= @@ -13,6 +12,10 @@ DEFAULT_GOARCH := $(shell go env GOARCH) export GO111MODULE=on +# We need to export GOBIN to allow it to be set +# for processes spawned from the Makefile +export GOBIN ?= $(PWD)/bin + # You can include assets this directory into the bundle. This can be e.g. used to include profile pictures. ASSETS_DIR ?= assets @@ -22,7 +25,6 @@ default: all # Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed. include build/setup.mk -include build/legacy.mk BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz @@ -41,9 +43,20 @@ endif .PHONY: all all: check-style test dist +## Propagates plugin manifest information into the server/ and webapp/ folders. +.PHONY: apply +apply: + ./build/bin/manifest apply + +## Install go tools +install-go-tools: + @echo Installing go tools + $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.1 + $(GO) install gotest.tools/gotestsum@v1.7.0 + ## Runs eslint and golangci-lint .PHONY: check-style -check-style: webapp/node_modules +check-style: apply webapp/node_modules install-go-tools @echo Checking for style guide compliance ifneq ($(HAS_WEBAPP),) @@ -51,14 +64,13 @@ ifneq ($(HAS_WEBAPP),) cd webapp && npm run check-types endif +# It's highly recommended to run go-vet first +# to find potential compile errors that could introduce +# weird reports at golangci-lint step ifneq ($(HAS_SERVER),) - @if ! [ -x "$$(command -v golangci-lint)" ]; then \ - echo "golangci-lint is not installed. Please see https://github.com/golangci/golangci-lint#install for installation instructions."; \ - exit 1; \ - fi; \ - @echo Running golangci-lint - golangci-lint run ./... + $(GO) vet ./... + $(GOBIN)/golangci-lint run ./... endif ## Builds the server, if it exists, for all supported architectures, unless MM_SERVICESETTINGS_ENABLEDEVELOPER is set. @@ -104,7 +116,7 @@ endif bundle: rm -rf dist/ mkdir -p dist/$(PLUGIN_ID) - cp $(MANIFEST_FILE) dist/$(PLUGIN_ID)/ + ./build/bin/manifest dist ifneq ($(wildcard $(ASSETS_DIR)/.),) cp -r $(ASSETS_DIR) dist/$(PLUGIN_ID)/ endif @@ -125,7 +137,7 @@ endif ## Builds and bundles the plugin. .PHONY: dist -dist: server webapp bundle +dist: apply server webapp bundle ## Builds and installs the plugin to a server. .PHONY: deploy @@ -134,7 +146,7 @@ deploy: dist ## Builds and installs the plugin to a server, updating the webapp automatically when changed. .PHONY: watch -watch: server bundle +watch: apply server bundle ifeq ($(MM_DEBUG),) cd webapp && $(NPM) run build:watch else @@ -188,9 +200,20 @@ detach: setup-attach ## Runs any lints and unit tests defined for the server and webapp, if they exist. .PHONY: test -test: webapp/node_modules +test: apply webapp/node_modules install-go-tools ifneq ($(HAS_SERVER),) - $(GO) test -v $(GO_TEST_FLAGS) ./server/... + $(GOBIN)/gotestsum -- -v ./... +endif +ifneq ($(HAS_WEBAPP),) + cd webapp && $(NPM) run test; +endif + +## Runs any lints and unit tests defined for the server and webapp, if they exist, optimized +## for a CI environment. +.PHONY: test-ci +test-ci: apply webapp/node_modules install-go-tools +ifneq ($(HAS_SERVER),) + $(GOBIN)/gotestsum --format standard-verbose --junitfile report.xml -- ./... endif ifneq ($(HAS_WEBAPP),) cd webapp && $(NPM) run test; @@ -198,7 +221,7 @@ endif ## Creates a coverage report for the server code. .PHONY: coverage -coverage: webapp/node_modules +coverage: apply webapp/node_modules ifneq ($(HAS_SERVER),) $(GO) test $(GO_TEST_FLAGS) -coverprofile=server/coverage.txt ./server/... $(GO) tool cover -html=server/coverage.txt @@ -255,6 +278,14 @@ ifneq ($(HAS_WEBAPP),) endif rm -fr build/bin/ +.PHONY: logs +logs: + ./build/bin/pluginctl logs $(PLUGIN_ID) + +.PHONY: logs-watch +logs-watch: + ./build/bin/pluginctl logs-watch $(PLUGIN_ID) + # Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html help: @cat Makefile build/*.mk | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//" | sed -e "s/^## //" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort diff --git a/README.md b/README.md index 6ab4f176..318aa109 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,15 @@ You can also use make targets like `dist` (`./docker-make dist`) from the [Makef This plugin contains both a server and web app portion. Read our documentation about the [Developer Workflow](https://developers.mattermost.com/integrate/plugins/developer-workflow/) and [Developer Setup](https://developers.mattermost.com/integrate/plugins/developer-setup/) for more information about developing and extending plugins. +### Releasing new versions + +The version of a plugin is determined at compile time, automatically populating a `version` field in the [plugin manifest](plugin.json): +* If the current commit matches a tag, the version will match after stripping any leading `v`, e.g. `1.3.1`. +* Otherwise, the version will combine the nearest tag with `git rev-parse --short HEAD`, e.g. `1.3.1+d06e53e1`. +* If there is no version tag, an empty version will be combined with the short hash, e.g. `0.0.0+76081421`. + +To disable this behaviour, manually populate and maintain the `version` field. + ### Server Inside the `/server` directory, you will find the Go files that make up the server-side of the plugin. Within there, build the plugin like you would any other Go application. diff --git a/build/legacy.mk b/build/legacy.mk deleted file mode 100644 index 0c327339..00000000 --- a/build/legacy.mk +++ /dev/null @@ -1,3 +0,0 @@ -.PHONY: apply -apply: - @echo make apply is deprecated and has no effect. diff --git a/build/manifest/main.go b/build/manifest/main.go index 957a5002..c57fe107 100644 --- a/build/manifest/main.go +++ b/build/manifest/main.go @@ -4,11 +4,50 @@ import ( "encoding/json" "fmt" "os" + "strings" "github.com/mattermost/mattermost/server/public/model" "github.com/pkg/errors" ) +const pluginIDGoFileTemplate = `// This file is automatically generated. Do not modify it manually. + +package main + +import ( + "encoding/json" + "strings" + + "github.com/mattermost/mattermost/server/public/model" +) + +var manifest *model.Manifest + +const manifestStr = ` + "`" + ` +%s +` + "`" + ` + +func init() { + _ = json.NewDecoder(strings.NewReader(manifestStr)).Decode(&manifest) +} +` + +const pluginIDJSFileTemplate = `// This file is automatically generated. Do not modify it manually. + +const manifest = JSON.parse(` + "`" + ` +%s +` + "`" + `); + +export default manifest; +` + +// These build-time vars are read from shell commands and populated in ../setup.mk +var ( + BuildHashShort string + BuildTagLatest string + BuildTagCurrent string +) + func main() { if len(os.Args) <= 1 { panic("no cmd specified") @@ -37,6 +76,16 @@ func main() { fmt.Printf("true") } + case "apply": + if err := applyManifest(manifest); err != nil { + panic("failed to apply manifest: " + err.Error()) + } + + case "dist": + if err := distManifest(manifest); err != nil { + panic("failed to write manifest to dist directory: " + err.Error()) + } + default: panic("unrecognized command: " + cmd) } @@ -62,6 +111,32 @@ func findManifest() (*model.Manifest, error) { return nil, errors.Wrap(err, "failed to parse manifest") } + // If no version is listed in the manifest, generate one based on the state of the current + // commit, and use the first version we find (to prevent causing errors) + if manifest.Version == "" { + var version string + tags := strings.Fields(BuildTagCurrent) + for _, t := range tags { + if strings.HasPrefix(t, "v") { + version = t + break + } + } + if version == "" { + if BuildTagLatest != "" { + version = BuildTagLatest + "+" + BuildHashShort + } else { + version = "v0.0.0+" + BuildHashShort + } + } + manifest.Version = strings.TrimPrefix(version, "v") + } + + // If no release notes specified, generate one from the latest tag, if present. + if manifest.ReleaseNotesURL == "" && BuildTagLatest != "" { + manifest.ReleaseNotesURL = manifest.HomepageURL + "releases/tag/" + BuildTagLatest + } + return &manifest, nil } @@ -74,3 +149,63 @@ func dumpPluginID(manifest *model.Manifest) { func dumpPluginVersion(manifest *model.Manifest) { fmt.Printf("%s", manifest.Version) } + +// applyManifest propagates the plugin_id into the server and webapp folders, as necessary +func applyManifest(manifest *model.Manifest) error { + if manifest.HasServer() { + // generate JSON representation of Manifest. + manifestBytes, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + manifestStr := string(manifestBytes) + + // write generated code to file by using Go file template. + if err := os.WriteFile( + "server/manifest.go", + []byte(fmt.Sprintf(pluginIDGoFileTemplate, manifestStr)), + 0600, + ); err != nil { + return errors.Wrap(err, "failed to write server/manifest.go") + } + } + + if manifest.HasWebapp() { + // generate JSON representation of Manifest. + // JSON is very similar and compatible with JS's object literals. so, what we do here + // is actually JS code generation. + manifestBytes, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + manifestStr := string(manifestBytes) + + // Escape newlines + manifestStr = strings.ReplaceAll(manifestStr, `\n`, `\\n`) + + // write generated code to file by using JS file template. + if err := os.WriteFile( + "webapp/src/manifest.ts", + []byte(fmt.Sprintf(pluginIDJSFileTemplate, manifestStr)), + 0600, + ); err != nil { + return errors.Wrap(err, "failed to open webapp/src/manifest.ts") + } + } + + return nil +} + +// distManifest writes the manifest file to the dist directory +func distManifest(manifest *model.Manifest) error { + manifestBytes, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + + if err := os.WriteFile(fmt.Sprintf("dist/%s/plugin.json", manifest.Id), manifestBytes, 0600); err != nil { + return errors.Wrap(err, "failed to write plugin.json") + } + + return nil +} diff --git a/build/pluginctl/logs.go b/build/pluginctl/logs.go new file mode 100644 index 00000000..f20e8bba --- /dev/null +++ b/build/pluginctl/logs.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "slices" + "strings" + "time" + + "github.com/mattermost/mattermost/server/public/model" +) + +const ( + logsPerPage = 100 // logsPerPage is the number of log entries to fetch per API call + timeStampFormat = "2006-01-02 15:04:05.000 Z07:00" +) + +// logs fetches the latest 500 log entries from Mattermost, +// and prints only the ones related to the plugin to stdout. +func logs(ctx context.Context, client *model.Client4, pluginID string) error { + err := checkJSONLogsSetting(ctx, client) + if err != nil { + return err + } + + logs, err := fetchLogs(ctx, client, 0, 500, pluginID, time.Unix(0, 0)) + if err != nil { + return fmt.Errorf("failed to fetch log entries: %w", err) + } + + err = printLogEntries(logs) + if err != nil { + return fmt.Errorf("failed to print logs entries: %w", err) + } + + return nil +} + +// watchLogs fetches log entries from Mattermost and print them to stdout. +// It will return without an error when ctx is canceled. +func watchLogs(ctx context.Context, client *model.Client4, pluginID string) error { + err := checkJSONLogsSetting(ctx, client) + if err != nil { + return err + } + + now := time.Now() + var oldestEntry string + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + var page int + for { + logs, err := fetchLogs(ctx, client, page, logsPerPage, pluginID, now) + if err != nil { + return fmt.Errorf("failed to fetch log entries: %w", err) + } + + var allNew bool + logs, oldestEntry, allNew = checkOldestEntry(logs, oldestEntry) + + err = printLogEntries(logs) + if err != nil { + return fmt.Errorf("failed to print logs entries: %w", err) + } + + if !allNew { + // No more logs to fetch + break + } + page++ + } + } + } +} + +// checkOldestEntry check a if logs contains new log entries. +// It returns the filtered slice of log entries, the new oldest entry and whether or not all entries were new. +func checkOldestEntry(logs []string, oldest string) ([]string, string, bool) { + if len(logs) == 0 { + return nil, oldest, false + } + + newOldestEntry := logs[(len(logs) - 1)] + + i := slices.Index(logs, oldest) + switch i { + case -1: + // Every log entry is new + return logs, newOldestEntry, true + case len(logs) - 1: + // No new log entries + return nil, oldest, false + default: + // Filter out oldest log entry + return logs[i+1:], newOldestEntry, false + } +} + +// fetchLogs fetches log entries from Mattermost +// and filters them based on pluginID and timestamp. +func fetchLogs(ctx context.Context, client *model.Client4, page, perPage int, pluginID string, since time.Time) ([]string, error) { + logs, _, err := client.GetLogs(ctx, page, perPage) + if err != nil { + return nil, fmt.Errorf("failed to get logs from Mattermost: %w", err) + } + + logs, err = filterLogEntries(logs, pluginID, since) + if err != nil { + return nil, fmt.Errorf("failed to filter log entries: %w", err) + } + + return logs, nil +} + +// filterLogEntries filters a given slice of log entries by pluginID. +// It also filters out any entries which timestamps are older then since. +func filterLogEntries(logs []string, pluginID string, since time.Time) ([]string, error) { + type logEntry struct { + PluginID string `json:"plugin_id"` + Timestamp string `json:"timestamp"` + } + + var ret []string + + for _, e := range logs { + var le logEntry + err := json.Unmarshal([]byte(e), &le) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal log entry into JSON: %w", err) + } + if le.PluginID != pluginID { + continue + } + + let, err := time.Parse(timeStampFormat, le.Timestamp) + if err != nil { + return nil, fmt.Errorf("unknown timestamp format: %w", err) + } + if let.Before(since) { + continue + } + + // Log entries returned by the API have a newline a prefix. + // Remove that to make printing consistent. + e = strings.TrimPrefix(e, "\n") + + ret = append(ret, e) + } + + return ret, nil +} + +// printLogEntries prints a slice of log entries to stdout. +func printLogEntries(entries []string) error { + for _, e := range entries { + _, err := io.WriteString(os.Stdout, e+"\n") + if err != nil { + return fmt.Errorf("failed to write log entry to stdout: %w", err) + } + } + + return nil +} + +func checkJSONLogsSetting(ctx context.Context, client *model.Client4) error { + cfg, _, err := client.GetConfig(ctx) + if err != nil { + return fmt.Errorf("failed to fetch config: %w", err) + } + if cfg.LogSettings.FileJson == nil || !*cfg.LogSettings.FileJson { + return errors.New("JSON output for file logs are disabled. Please enable LogSettings.FileJson via the configration in Mattermost.") //nolint:revive,stylecheck + } + + return nil +} diff --git a/build/pluginctl/logs_test.go b/build/pluginctl/logs_test.go new file mode 100644 index 00000000..687d8c24 --- /dev/null +++ b/build/pluginctl/logs_test.go @@ -0,0 +1,202 @@ +package main + +import ( + "fmt" + "testing" + "time" +) + +func TestCheckOldestEntry(t *testing.T) { + for name, tc := range map[string]struct { + logs []string + oldest string + expectedLogs []string + expectedOldest string + expectedAllNew bool + }{ + "nil logs": { + logs: nil, + oldest: "oldest", + expectedLogs: nil, + expectedOldest: "oldest", + expectedAllNew: false, + }, + "empty logs": { + logs: []string{}, + oldest: "oldest", + expectedLogs: nil, + expectedOldest: "oldest", + expectedAllNew: false, + }, + "no new entries, one old entry": { + logs: []string{"old"}, + oldest: "old", + expectedLogs: []string{}, + expectedOldest: "old", + expectedAllNew: false, + }, + "no new entries, multipile old entries": { + logs: []string{"old1", "old2", "old3"}, + oldest: "old3", + expectedLogs: []string{}, + expectedOldest: "old3", + expectedAllNew: false, + }, + "one new entry, no old entry": { + logs: []string{"new"}, + oldest: "old", + expectedLogs: []string{"new"}, + expectedOldest: "new", + expectedAllNew: true, + }, + "multipile new entries, no old entry": { + logs: []string{"new1", "new2", "new3"}, + oldest: "old", + expectedLogs: []string{"new1", "new2", "new3"}, + expectedOldest: "new3", + expectedAllNew: true, + }, + "one new entry, one old entry": { + logs: []string{"old", "new"}, + oldest: "old", + expectedLogs: []string{"new"}, + expectedOldest: "new", + expectedAllNew: false, + }, + "one new entry, multipile old entries": { + logs: []string{"old1", "old2", "old3", "new"}, + oldest: "old3", + expectedLogs: []string{"new"}, + expectedOldest: "new", + expectedAllNew: false, + }, + "multipile new entries, ultipile old entries": { + logs: []string{"old1", "old2", "old3", "new1", "new2", "new3"}, + oldest: "old3", + expectedLogs: []string{"new1", "new2", "new3"}, + expectedOldest: "new3", + expectedAllNew: false, + }, + } { + t.Run(name, func(t *testing.T) { + logs, oldest, allNew := checkOldestEntry(tc.logs, tc.oldest) + + if allNew != tc.expectedAllNew { + t.Logf("expected allNew: %v, got %v", tc.expectedAllNew, allNew) + t.Fail() + } + if oldest != tc.expectedOldest { + t.Logf("expected oldest: %v, got %v", tc.expectedOldest, oldest) + t.Fail() + } + + compareSlice(t, tc.expectedLogs, logs) + }) + } +} + +func TestFilterLogEntries(t *testing.T) { + now := time.Now() + + for name, tc := range map[string]struct { + logs []string + pluginID string + since time.Time + expectedLogs []string + expectedErr bool + }{ + "nil slice": { + logs: nil, + expectedLogs: nil, + expectedErr: false, + }, + "empty slice": { + logs: []string{}, + expectedLogs: nil, + expectedErr: false, + }, + "no JSON": { + logs: []string{ + `{"foo"`, + }, + expectedLogs: nil, + expectedErr: true, + }, + "unknown time format": { + logs: []string{ + `{"message":"foo", "plugin_id": "some.plugin.id", "timestamp": "2023-12-18 10:58:53"}`, + }, + pluginID: "some.plugin.id", + expectedLogs: nil, + expectedErr: true, + }, + "one matching entry": { + logs: []string{ + `{"message":"foo", "plugin_id": "some.plugin.id", "timestamp": "2023-12-18 10:58:53.091 +01:00"}`, + }, + pluginID: "some.plugin.id", + expectedLogs: []string{ + `{"message":"foo", "plugin_id": "some.plugin.id", "timestamp": "2023-12-18 10:58:53.091 +01:00"}`, + }, + expectedErr: false, + }, + "filter out non plugin entries": { + logs: []string{ + `{"message":"bar1", "timestamp": "2023-12-18 10:58:52.091 +01:00"}`, + `{"message":"foo", "plugin_id": "some.plugin.id", "timestamp": "2023-12-18 10:58:53.091 +01:00"}`, + `{"message":"bar2", "timestamp": "2023-12-18 10:58:54.091 +01:00"}`, + }, + pluginID: "some.plugin.id", + expectedLogs: []string{ + `{"message":"foo", "plugin_id": "some.plugin.id", "timestamp": "2023-12-18 10:58:53.091 +01:00"}`, + }, + expectedErr: false, + }, + "filter out old entries": { + logs: []string{ + fmt.Sprintf(`{"message":"old2", "plugin_id": "some.plugin.id", "timestamp": "%s"}`, time.Now().Add(-2*time.Second).Format(timeStampFormat)), + fmt.Sprintf(`{"message":"old1", "plugin_id": "some.plugin.id", "timestamp": "%s"}`, time.Now().Add(-1*time.Second).Format(timeStampFormat)), + fmt.Sprintf(`{"message":"now", "plugin_id": "some.plugin.id", "timestamp": "%s"}`, time.Now().Format(timeStampFormat)), + fmt.Sprintf(`{"message":"new1", "plugin_id": "some.plugin.id", "timestamp": "%s"}`, time.Now().Add(1*time.Second).Format(timeStampFormat)), + fmt.Sprintf(`{"message":"new2", "plugin_id": "some.plugin.id", "timestamp": "%s"}`, time.Now().Add(2*time.Second).Format(timeStampFormat)), + }, + pluginID: "some.plugin.id", + since: now, + expectedLogs: []string{ + fmt.Sprintf(`{"message":"new1", "plugin_id": "some.plugin.id", "timestamp": "%s"}`, time.Now().Add(1*time.Second).Format(timeStampFormat)), + fmt.Sprintf(`{"message":"new2", "plugin_id": "some.plugin.id", "timestamp": "%s"}`, time.Now().Add(2*time.Second).Format(timeStampFormat)), + }, + expectedErr: false, + }, + } { + t.Run(name, func(t *testing.T) { + logs, err := filterLogEntries(tc.logs, tc.pluginID, tc.since) + if tc.expectedErr { + if err == nil { + t.Logf("expected error, got nil") + t.Fail() + } + } else { + if err != nil { + t.Logf("expected no error, got %v", err) + t.Fail() + } + } + compareSlice(t, tc.expectedLogs, logs) + }) + } +} + +func compareSlice[S ~[]E, E comparable](t *testing.T, expected, got S) { + if len(expected) != len(got) { + t.Logf("expected len: %v, got %v", len(expected), len(got)) + t.FailNow() + } + + for i := 0; i < len(expected); i++ { + if expected[i] != got[i] { + t.Logf("expected [%d]: %v, got %v", i, expected[i], got[i]) + t.Fail() + } + } +} diff --git a/build/pluginctl/main.go b/build/pluginctl/main.go index 54572fc0..2f80af5b 100644 --- a/build/pluginctl/main.go +++ b/build/pluginctl/main.go @@ -57,6 +57,10 @@ func pluginctl() error { return enablePlugin(ctx, client, os.Args[2]) case "reset": return resetPlugin(ctx, client, os.Args[2]) + case "logs": + return logs(ctx, client, os.Args[2]) + case "logs-watch": + return watchLogs(context.WithoutCancel(ctx), client, os.Args[2]) // Keep watching forever default: return errors.New("invalid second argument") } diff --git a/build/setup.mk b/build/setup.mk index 493b06fc..b90963af 100644 --- a/build/setup.mk +++ b/build/setup.mk @@ -4,8 +4,13 @@ ifeq ($(GO),) $(error "go is not available: see https://golang.org/doc/install") endif +# Gather build variables to inject into the manifest tool +BUILD_HASH_SHORT = $(shell git rev-parse --short HEAD) +BUILD_TAG_LATEST = $(shell git describe --tags --match 'v*' --abbrev=0 2>/dev/null) +BUILD_TAG_CURRENT = $(shell git tag --points-at HEAD) + # Ensure that the build tools are compiled. Go's caching makes this quick. -$(shell cd build/manifest && $(GO) build -o ../bin/manifest) +$(shell cd build/manifest && $(GO) build -ldflags '-X "main.BuildHashShort=$(BUILD_HASH_SHORT)" -X "main.BuildTagLatest=$(BUILD_TAG_LATEST)" -X "main.BuildTagCurrent=$(BUILD_TAG_CURRENT)"' -o ../bin/manifest) # Ensure that the deployment tools are compiled. Go's caching makes this quick. $(shell cd build/pluginctl && $(GO) build -o ../bin/pluginctl) diff --git a/go.mod b/go.mod index d3aa21a2..1ad87548 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/mattermost/mattermost-plugin-jitsi -go 1.19 +go 1.21 require ( github.com/cristalhq/jwt/v2 v2.0.0 diff --git a/go.sum b/go.sum index e78a8000..8443592d 100644 --- a/go.sum +++ b/go.sum @@ -9,13 +9,16 @@ dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -55,6 +58,7 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -77,11 +81,13 @@ github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= @@ -137,6 +143,7 @@ github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1: github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rudderlabs/analytics-go v3.3.3+incompatible h1:OG0XlKoXfr539e2t1dXtTB+Gr89uFW+OUNQBVhHIIBY= github.com/rudderlabs/analytics-go v3.3.3+incompatible/go.mod h1:LF8/ty9kUX4PTY3l5c97K3nZZaX5Hwsvt+NBaRL/f30= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= @@ -294,6 +301,7 @@ google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= diff --git a/plugin.json b/plugin.json index 8ce06477..b829eb7b 100644 --- a/plugin.json +++ b/plugin.json @@ -4,9 +4,7 @@ "description": "Jitsi audio and video conferencing plugin for Mattermost.", "homepage_url": "https://github.com/mattermost/mattermost-plugin-jitsi", "support_url": "https://github.com/mattermost/mattermost-plugin-jitsi/issues", - "release_notes_url": "https://github.com/mattermost/mattermost-plugin-jitsi/releases/tag/v2.0.0", "icon_path": "assets/icon.svg", - "version": "2.0.0", "min_server_version": "5.2.0", "server": { "executables": { diff --git a/server/manifest.go b/server/manifest.go deleted file mode 100644 index 25d3e966..00000000 --- a/server/manifest.go +++ /dev/null @@ -1,129 +0,0 @@ -// This file is automatically generated. Do not modify it manually. - -package main - -import ( - "encoding/json" - "strings" - - "github.com/mattermost/mattermost/server/public/model" -) - -var manifest *model.Manifest - -const manifestStr = ` -{ - "id": "jitsi", - "name": "Jitsi", - "description": "Jitsi audio and video conferencing plugin for Mattermost.", - "homepage_url": "https://github.com/mattermost/mattermost-plugin-jitsi", - "support_url": "https://github.com/mattermost/mattermost-plugin-jitsi/issues", - "release_notes_url": "https://github.com/mattermost/mattermost-plugin-jitsi/releases/tag/v2.0.0", - "icon_path": "assets/icon.svg", - "version": "2.0.0", - "min_server_version": "5.2.0", - "server": { - "executables": { - "linux-amd64": "server/dist/plugin-linux-amd64", - "darwin-amd64": "server/dist/plugin-darwin-amd64", - "windows-amd64": "server/dist/plugin-windows-amd64.exe" - }, - "executable": "" - }, - "webapp": { - "bundle_path": "webapp/dist/main.js" - }, - "settings_schema": { - "header": "", - "footer": "", - "settings": [ - { - "key": "JitsiURL", - "display_name": "Jitsi Server URL:", - "type": "text", - "help_text": "The URL for your Jitsi server, for example https://jitsi.example.com. Defaults to https://meet.jit.si, which is the public server provided by Jitsi.", - "placeholder": "https://meet.jit.si", - "default": "https://meet.jit.si" - }, - { - "key": "JitsiEmbedded", - "display_name": "Embed Jitsi video inside Mattermost:", - "type": "bool", - "help_text": "(Experimental) When true, Jitsi video is embedded as a floating window inside Mattermost by default. Users can override this setting with '/jitsi settings'.", - "placeholder": "", - "default": null - }, - { - "key": "JitsiNamingScheme", - "display_name": "Jitsi Meeting Names:", - "type": "radio", - "help_text": "Select how meeting names are generated by default. Users can override this setting with '/jitsi settings'.", - "placeholder": "", - "default": "words", - "options": [ - { - "display_name": "Random English words in title case (e.g. PlayfulDragonsObserveCuriously)", - "value": "words" - }, - { - "display_name": "UUID (universally unique identifier)", - "value": "uuid" - }, - { - "display_name": "Mattermost context specific names. Combination of team name, channel name, and random text in Public and Private channels; personal meeting name in Direct and Group Message channels.", - "value": "mattermost" - }, - { - "display_name": "Allow user to select meeting name", - "value": "ask" - } - ] - }, - { - "key": "JitsiJWT", - "display_name": "Use JWT Authentication for Jitsi:", - "type": "bool", - "help_text": "(Optional) If your Jitsi server uses JSON Web Tokens (JWT) for authentication, set this value to true.", - "placeholder": "", - "default": null - }, - { - "key": "JitsiAppID", - "display_name": "App ID for JWT Authentication:", - "type": "text", - "help_text": "(Optional) The app ID used for authentication by the Jitsi server and JWT token generator.", - "placeholder": "", - "default": null - }, - { - "key": "JitsiAppSecret", - "display_name": "App Secret for JWT Authentication:", - "type": "text", - "help_text": "(Optional) The app secret used for authentication by the Jitsi server and JWT token generator.", - "placeholder": "", - "default": null - }, - { - "key": "JitsiLinkValidTime", - "display_name": "Meeting Link Expiry Time (minutes):", - "type": "number", - "help_text": "(Optional) The number of minutes from when the meeting link is created to when it becomes invalid. Minimum is 1 minute. Only applies if using JWT authentication for your Jitsi server.", - "placeholder": "", - "default": 30 - }, - { - "key": "JitsiCompatibilityMode", - "display_name": "Enable Compatibility Mode:", - "type": "bool", - "help_text": "(Insecure) If your Jitsi server is not compatible with this plugin, include the JavaScript API hosted on your Jitsi server directly in Mattermost instead of the default API version provided by the plugin. **WARNING:** Enabling this setting can compromise the security of your Mattermost system, if your Jitsi server is not fully trusted and allows direct modification of program files. Use with caution.", - "placeholder": "", - "default": false - } - ] - } -} -` - -func init() { - _ = json.NewDecoder(strings.NewReader(manifestStr)).Decode(&manifest) -} diff --git a/webapp/.eslintrc.json b/webapp/.eslintrc.json index 1ed11a1e..c447ac63 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -21,7 +21,8 @@ "browser": true, "node": true, "jquery": true, - "es6": true + "es6": true, + "jest": true }, "globals": { "jest": true, @@ -68,7 +69,6 @@ "line-comment-position": 0, "linebreak-style": 2, "max-lines": [1, {"max": 450, "skipBlankLines": true, "skipComments": false}], - "max-nested-callbacks": [1, {"max":1}], "max-nested-callbacks": [2, {"max":2}], "max-statements-per-line": [2, {"max": 1}], "multiline-ternary": [1, "never"], diff --git a/webapp/src/action_types/index.ts b/webapp/src/action_types/index.ts index 2d11f741..648584db 100644 --- a/webapp/src/action_types/index.ts +++ b/webapp/src/action_types/index.ts @@ -1,7 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {id as pluginId} from '../manifest'; +import manifest from '../manifest'; + +const {id: pluginId} = manifest; export default { OPEN_MEETING: pluginId + '_open_meeting', diff --git a/webapp/src/client/client.ts b/webapp/src/client/client.ts index 67e2953e..962a0a5e 100644 --- a/webapp/src/client/client.ts +++ b/webapp/src/client/client.ts @@ -1,13 +1,13 @@ import {Client4} from 'mattermost-redux/client'; import {ClientError} from 'mattermost-redux/client/client4'; -import {id} from '../manifest'; +import manifest from '../manifest'; export default class Client { private url: string | undefined; setServerRoute(url: string) { - this.url = url + '/plugins/' + id; + this.url = url + '/plugins/' + manifest.id; } startMeeting = async (channelId: string, personal: boolean = false, topic: string = '', meetingId: string = '') => { diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index 32f24d88..907e8b1d 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -14,13 +14,14 @@ import I18nProvider from './components/i18n_provider'; import RootPortal from './components/root_portal'; import reducer from './reducers'; import {startMeeting, loadConfig} from './actions'; -import {id as pluginId} from './manifest'; +import manifest from './manifest'; import Client from './client'; class PluginClass { rootPortal?: RootPortal; initialize(registry: any, store: any) { + const {id: pluginId} = manifest; if ((window as any).JitsiMeetExternalAPI) { this.rootPortal = new RootPortal(registry, store); if (this.rootPortal) { diff --git a/webapp/src/manifest.test.tsx b/webapp/src/manifest.test.tsx new file mode 100644 index 00000000..d71f2d41 --- /dev/null +++ b/webapp/src/manifest.test.tsx @@ -0,0 +1,7 @@ +import manifest from './manifest'; + +test('Plugin manifest, id and version are defined', () => { + expect(manifest).toBeDefined(); + expect(manifest.id).toBeDefined(); + expect(manifest.version).toBeDefined(); +}); diff --git a/webapp/src/manifest.ts b/webapp/src/manifest.ts deleted file mode 100644 index 46ad6386..00000000 --- a/webapp/src/manifest.ts +++ /dev/null @@ -1,118 +0,0 @@ -// This file is automatically generated. Do not modify it manually. - -const manifest = JSON.parse(` -{ - "id": "jitsi", - "name": "Jitsi", - "description": "Jitsi audio and video conferencing plugin for Mattermost.", - "homepage_url": "https://github.com/mattermost/mattermost-plugin-jitsi", - "support_url": "https://github.com/mattermost/mattermost-plugin-jitsi/issues", - "release_notes_url": "https://github.com/mattermost/mattermost-plugin-jitsi/releases/tag/v2.0.0", - "icon_path": "assets/icon.svg", - "version": "2.0.0", - "min_server_version": "5.2.0", - "server": { - "executables": { - "linux-amd64": "server/dist/plugin-linux-amd64", - "darwin-amd64": "server/dist/plugin-darwin-amd64", - "windows-amd64": "server/dist/plugin-windows-amd64.exe" - }, - "executable": "" - }, - "webapp": { - "bundle_path": "webapp/dist/main.js" - }, - "settings_schema": { - "header": "", - "footer": "", - "settings": [ - { - "key": "JitsiURL", - "display_name": "Jitsi Server URL:", - "type": "text", - "help_text": "The URL for your Jitsi server, for example https://jitsi.example.com. Defaults to https://meet.jit.si, which is the public server provided by Jitsi.", - "placeholder": "https://meet.jit.si", - "default": "https://meet.jit.si" - }, - { - "key": "JitsiEmbedded", - "display_name": "Embed Jitsi video inside Mattermost:", - "type": "bool", - "help_text": "(Experimental) When true, Jitsi video is embedded as a floating window inside Mattermost by default. Users can override this setting with '/jitsi settings'.", - "placeholder": "", - "default": null - }, - { - "key": "JitsiNamingScheme", - "display_name": "Jitsi Meeting Names:", - "type": "radio", - "help_text": "Select how meeting names are generated by default. Users can override this setting with '/jitsi settings'.", - "placeholder": "", - "default": "words", - "options": [ - { - "display_name": "Random English words in title case (e.g. PlayfulDragonsObserveCuriously)", - "value": "words" - }, - { - "display_name": "UUID (universally unique identifier)", - "value": "uuid" - }, - { - "display_name": "Mattermost context specific names. Combination of team name, channel name, and random text in Public and Private channels; personal meeting name in Direct and Group Message channels.", - "value": "mattermost" - }, - { - "display_name": "Allow user to select meeting name", - "value": "ask" - } - ] - }, - { - "key": "JitsiJWT", - "display_name": "Use JWT Authentication for Jitsi:", - "type": "bool", - "help_text": "(Optional) If your Jitsi server uses JSON Web Tokens (JWT) for authentication, set this value to true.", - "placeholder": "", - "default": null - }, - { - "key": "JitsiAppID", - "display_name": "App ID for JWT Authentication:", - "type": "text", - "help_text": "(Optional) The app ID used for authentication by the Jitsi server and JWT token generator.", - "placeholder": "", - "default": null - }, - { - "key": "JitsiAppSecret", - "display_name": "App Secret for JWT Authentication:", - "type": "text", - "help_text": "(Optional) The app secret used for authentication by the Jitsi server and JWT token generator.", - "placeholder": "", - "default": null - }, - { - "key": "JitsiLinkValidTime", - "display_name": "Meeting Link Expiry Time (minutes):", - "type": "number", - "help_text": "(Optional) The number of minutes from when the meeting link is created to when it becomes invalid. Minimum is 1 minute. Only applies if using JWT authentication for your Jitsi server.", - "placeholder": "", - "default": 30 - }, - { - "key": "JitsiCompatibilityMode", - "display_name": "Enable Compatibility Mode:", - "type": "bool", - "help_text": "(Insecure) If your Jitsi server is not compatible with this plugin, include the JavaScript API hosted on your Jitsi server directly in Mattermost instead of the default API version provided by the plugin. **WARNING:** Enabling this setting can compromise the security of your Mattermost system, if your Jitsi server is not fully trusted and allows direct modification of program files. Use with caution.", - "placeholder": "", - "default": false - } - ] - } -} -`); - -export default manifest; -export const id: string = manifest.id; -export const version: string = manifest.version;