initial commit

This commit is contained in:
Дмитрий Пиченикин 2026-02-11 17:06:51 +03:00
commit 5d3926d2a4
104 changed files with 40940 additions and 0 deletions

12
.circleci/config.yml Normal file
View File

@ -0,0 +1,12 @@
version: 2.1
orbs:
plugin-ci: mattermost/plugin-ci@0.1.0
workflows:
version: 2
ci:
jobs:
- plugin-ci/lint
- plugin-ci/test
- plugin-ci/build

27
.editorconfig Normal file
View File

@ -0,0 +1,27 @@
# http://editorconfig.org/
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
[*.go]
indent_style = tab
[*.{js, jsx, ts, tsx, json, html}]
indent_style = space
indent_size = 4
[webapp/package.json]
indent_size = 2
[{Makefile, *.mk}]
indent_style = tab
[*.md]
indent_style = space
indent_size = 4
trim_trailing_whitespace = false

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
server/manifest.go linguist-generated=true

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
dist/
vendor/
# Mac
.DS_Store
# VSCode
.vscode/
# Jetbrains
.idea/

1
.gitpod.yml Normal file
View File

@ -0,0 +1 @@
mainConfiguration: https://github.com/mattermost/mattermost-gitpod-config

56
.golangci.yml Normal file
View File

@ -0,0 +1,56 @@
run:
timeout: 5m
modules-download-mode: readonly
linters-settings:
gofmt:
simplify: true
goimports:
local-prefixes: github.com/mattermost/mattermost-starter-template
golint:
min-confidence: 0
govet:
check-shadowing: true
enable-all: true
misspell:
locale: US
linters:
disable-all: true
enable:
- bodyclose
- deadcode
- errcheck
- gocritic
- gofmt
- goimports
- golint
- gosec
- gosimple
- govet
- ineffassign
- misspell
- nakedret
- staticcheck
- structcheck
- stylecheck
- typecheck
- unconvert
- unused
- varcheck
- whitespace
issues:
exclude-rules:
- path: server/manifest.go
linters:
- deadcode
- unused
- varcheck
- path: server/configuration.go
linters:
- unused
- path: _test\.go
linters:
- bodyclose
- scopelint # https://github.com/kyoh86/scopelint/issues/4

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

382
Makefile Normal file
View File

@ -0,0 +1,382 @@
GO ?= $(shell command -v go 2> /dev/null)
NPM ?= $(shell command -v yarn 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 ?=
MM_UTILITIES_DIR ?= ../mattermost-utilities
DLV_DEBUG_PORT := 2346
DEFAULT_GOOS := $(shell go env GOOS)
DEFAULT_GOARCH := $(shell go env GOARCH)
SANS_RESTAPITEST = $(shell go list ./... | grep -v 'git.wilix.dev/loop/loop-plugin-apps/test/restapitest' | sed -e 's/git.wilix.dev\/loop\/loop-plugin-apps\//.\//g')
ifneq (,$(wildcard ./.env))
include .env
export $(shell sed 's/=.*//' .env)
endif
export GO111MODULE=on
# You can include assets this directory into the bundle. This can be e.g. used to include profile pictures.
ASSETS_DIR ?= assets
## Define the default target (make all)
.PHONY: default
default: all
# Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed.
include build/setup.mk
BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz
# Include custom makefile, if present
ifneq ($(wildcard build/custom.mk),)
include build/custom.mk
endif
# ====================================================================================
# Used for semver bumping
PROTECTED_BRANCH := master
APP_NAME := $(shell basename -s .git `git config --get remote.origin.url`)
CURRENT_VERSION := $(shell git describe --abbrev=0 --tags)
VERSION_PARTS := $(subst ., ,$(subst v,,$(subst -rc, ,$(CURRENT_VERSION))))
MAJOR := $(word 1,$(VERSION_PARTS))
MINOR := $(word 2,$(VERSION_PARTS))
PATCH := $(word 3,$(VERSION_PARTS))
RC := $(shell echo $(CURRENT_VERSION) | grep -oE 'rc[0-9]+' | sed 's/rc//')
# Check if current branch is protected
define check_protected_branch
@current_branch=$$(git rev-parse --abbrev-ref HEAD); \
if ! echo "$(PROTECTED_BRANCH)" | grep -wq "$$current_branch" && ! echo "$$current_branch" | grep -q "^release"; then \
echo "Error: Tagging is only allowed from $(PROTECTED_BRANCH) or release branches. You are on $$current_branch branch."; \
exit 1; \
fi
endef
# Check if there are pending pulls
define check_pending_pulls
@git fetch; \
current_branch=$$(git rev-parse --abbrev-ref HEAD); \
if [ "$$(git rev-parse HEAD)" != "$$(git rev-parse origin/$$current_branch)" ]; then \
echo "Error: Your branch is not up to date with upstream. Please pull the latest changes before performing a release"; \
exit 1; \
fi
endef
# Prompt for approval
define prompt_approval
@read -p "About to bump $(APP_NAME) to version $(1), approve? (y/n) " userinput; \
if [ "$$userinput" != "y" ]; then \
echo "Bump aborted."; \
exit 1; \
fi
endef
# ====================================================================================
.PHONY: patch minor major patch-rc minor-rc major-rc
patch: ## to bump patch version (semver)
$(call check_protected_branch)
$(call check_pending_pulls)
@$(eval PATCH := $(shell echo $$(($(PATCH)+1))))
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH))
@echo Bumping $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH)
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH)"
git push origin v$(MAJOR).$(MINOR).$(PATCH)
@echo Bumped $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH)
minor: ## to bump minor version (semver)
$(call check_protected_branch)
$(call check_pending_pulls)
@$(eval MINOR := $(shell echo $$(($(MINOR)+1))))
@$(eval PATCH := 0)
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH))
@echo Bumping $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH)
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH)"
git push origin v$(MAJOR).$(MINOR).$(PATCH)
@echo Bumped $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH)
major: ## to bump major version (semver)
$(call check_protected_branch)
$(call check_pending_pulls)
$(eval MAJOR := $(shell echo $$(($(MAJOR)+1))))
$(eval MINOR := 0)
$(eval PATCH := 0)
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH))
@echo Bumping $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH)
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH)"
git push origin v$(MAJOR).$(MINOR).$(PATCH)
@echo Bumped $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH)
patch-rc: ## to bump patch release candidate version (semver)
$(call check_protected_branch)
$(call check_pending_pulls)
@$(eval RC := $(shell echo $$(($(RC)+1))))
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC))
@echo Bumping $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)"
git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
@echo Bumped $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
minor-rc: ## to bump minor release candidate version (semver)
$(call check_protected_branch)
$(call check_pending_pulls)
@$(eval MINOR := $(shell echo $$(($(MINOR)+1))))
@$(eval PATCH := 0)
@$(eval RC := 1)
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC))
@echo Bumping $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)"
git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
@echo Bumped $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
major-rc: ## to bump major release candidate version (semver)
$(call check_protected_branch)
$(call check_pending_pulls)
@$(eval MAJOR := $(shell echo $$(($(MAJOR)+1))))
@$(eval MINOR := 0)
@$(eval PATCH := 0)
@$(eval RC := 1)
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC))
@echo Bumping $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)"
git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
@echo Bumped $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
## Checks the code style, tests, builds and bundles the plugin.
.PHONY: all
all: check-style test dist
.PHONY: deps
deps:
@$(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.2
@$(GO) install github.com/nicksnyder/go-i18n/v2/goi18n@v2.2.0
## Runs eslint and golangci-lint
.PHONY: check-style
check-style: webapp/node_modules
@echo Checking for style guide compliance
ifneq ($(HAS_WEBAPP),)
cd webapp && npm run lint
cd webapp && npm run check-types
endif
ifneq ($(HAS_SERVER),)
@echo Running golangci-lint
golangci-lint run ./... --verbose
endif
## Builds the server, if it exists, for all supported architectures, unless MM_SERVICESETTINGS_ENABLEDEVELOPER is set
.PHONY: server
server:
ifneq ($(HAS_SERVER),)
mkdir -p server/dist;
ifeq ($(MM_DEBUG),)
ifneq ($(MM_SERVICESETTINGS_ENABLEDEVELOPER),)
cd server && env CGO_ENABLED=0 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-$(DEFAULT_GOOS)-$(DEFAULT_GOARCH);
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-linux-amd64;
else
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-linux-amd64;
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-linux-arm64;
cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-darwin-amd64;
cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-darwin-arm64;
cd server && env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-windows-amd64.exe;
endif
else
$(info DEBUG mode is on; to disable, unset MM_DEBUG)
ifneq ($(MM_SERVICESETTINGS_ENABLEDEVELOPER),)
cd server && env CGO_ENABLED=0 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-$(DEFAULT_GOOS)-$(DEFAULT_GOARCH);
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-linux-amd64;
else
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-linux-amd64;
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-linux-arm64;
cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-darwin-amd64;
cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-darwin-arm64;
cd server && env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-windows-amd64.exe;
endif
endif
endif
## Ensures NPM dependencies are installed without having to run this all the time.
webapp/node_modules: $(wildcard webapp/package.json)
ifneq ($(HAS_WEBAPP),)
cd webapp && $(NPM) install
touch $@
endif
## Builds the webapp, if it exists.
.PHONY: webapp
webapp: webapp/node_modules
ifneq ($(HAS_WEBAPP),)
ifeq ($(MM_DEBUG),)
cd webapp && $(NPM) build;
else
cd webapp && $(NPM) debug;
endif
endif
## Generates a tar bundle of the plugin for install.
.PHONY: bundle
bundle:
rm -rf dist/
mkdir -p dist/$(PLUGIN_ID)
cp $(MANIFEST_FILE) dist/$(PLUGIN_ID)/
ifneq ($(wildcard $(ASSETS_DIR)/.),)
cp -r $(ASSETS_DIR) dist/$(PLUGIN_ID)/
endif
ifneq ($(HAS_PUBLIC),)
cp -r public dist/$(PLUGIN_ID)/
endif
ifneq ($(HAS_SERVER),)
mkdir -p dist/$(PLUGIN_ID)/server
cp -r server/dist dist/$(PLUGIN_ID)/server/
endif
ifneq ($(HAS_WEBAPP),)
mkdir -p dist/$(PLUGIN_ID)/webapp
cp -r webapp/dist dist/$(PLUGIN_ID)/webapp/
endif
cd dist && \
find ./ -type f \( -name ".DS_Store" -o -name "._*" -o -name ".Trashes" -o -name ".Spotlight-*" -o -name ".fseventsd" \) -delete && \
tar -cvzf $(BUNDLE_NAME) $(PLUGIN_ID)
@echo plugin built at: dist/$(BUNDLE_NAME)
## Builds and bundles the plugin.
.PHONY: dist
dist: server webapp bundle
## Builds and installs the plugin to a server.
.PHONY: deploy
deploy: dist
./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME)
## Builds and installs the plugin to a server, updating the webapp automatically when changed.
.PHONY: watch
watch: apply server bundle
ifeq ($(MM_DEBUG),)
cd webapp && $(NPM) run build:watch
else
cd webapp && $(NPM) run debug:watch
endif
## Installs a previous built plugin with updated webpack assets to a server.
.PHONY: deploy-from-watch
deploy-from-watch: bundle
./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME)
## Setup dlv for attaching, identifying the plugin PID for other targets.
.PHONY: setup-attach
setup-attach:
$(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}'))
$(eval NUM_PID := $(shell echo -n ${PLUGIN_PID} | wc -w))
@if [ ${NUM_PID} -gt 2 ]; then \
echo "** There is more than 1 plugin process running. Run 'make kill reset' to restart just one."; \
exit 1; \
fi
## Check if setup-attach succeeded.
.PHONY: check-attach
check-attach:
@if [ -z ${PLUGIN_PID} ]; then \
echo "Could not find plugin PID; the plugin is not running. Exiting."; \
exit 1; \
else \
echo "Located Plugin running with PID: ${PLUGIN_PID}"; \
fi
## Attach dlv to an existing plugin instance.
.PHONY: attach
attach: setup-attach check-attach
dlv attach ${PLUGIN_PID}
## Attach dlv to an existing plugin instance, exposing a headless instance on $DLV_DEBUG_PORT.
.PHONY: attach-headless
attach-headless: setup-attach check-attach
dlv attach ${PLUGIN_PID} --listen :$(DLV_DEBUG_PORT) --headless=true --api-version=2 --accept-multiclient
## Detach dlv from an existing plugin instance, if previously attached.
.PHONY: detach
detach: setup-attach
@DELVE_PID=$(shell ps aux | grep "dlv attach ${PLUGIN_PID}" | grep -v "grep" | awk -F " " '{print $$2}') && \
if [ "$$DELVE_PID" -gt 0 ] > /dev/null 2>&1 ; then \
echo "Located existing delve process running with PID: $$DELVE_PID. Killing." ; \
kill -9 $$DELVE_PID ; \
fi
## Runs any lints and unit tests defined for the server and webapp, if they exist.
.PHONY: test
test: webapp/node_modules
ifneq ($(HAS_SERVER),)
$(GO) test -v $(GO_TEST_FLAGS) $(SANS_RESTAPITEST)
endif
ifneq ($(HAS_WEBAPP),)
cd webapp && $(NPM) run test;
endif
## Creates a coverage report for the server code.
.PHONY: coverage
coverage: webapp/node_modules
ifneq ($(HAS_SERVER),)
$(GO) test $(GO_TEST_FLAGS) -coverprofile=server/coverage.txt $(SANS_RESTAPITEST)
$(GO) tool cover -html=server/coverage.txt
endif
## Extract strings for translation from the source code.
.PHONY: i18n-extract
i18n-extract:
ifneq ($(HAS_WEBAPP),)
ifeq ($(HAS_MM_UTILITIES),)
@echo "You must clone github.com/mattermost/mattermost-utilities repo in .. to use this command"
else
cd $(MM_UTILITIES_DIR) && npm install && npm run babel && node mmjstool/build/index.js i18n extract-webapp --webapp-dir $(PWD)/webapp
endif
endif
## Disable the plugin.
.PHONY: disable
disable: detach
./build/bin/pluginctl disable $(PLUGIN_ID)
## Enable the plugin.
.PHONY: enable
enable:
./build/bin/pluginctl enable $(PLUGIN_ID)
## Reset the plugin, effectively disabling and re-enabling it on the server.
.PHONY: reset
reset: detach
./build/bin/pluginctl reset $(PLUGIN_ID)
## Kill all instances of the plugin, detaching any existing dlv instance.
.PHONY: kill
kill: detach
$(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}'))
@for PID in ${PLUGIN_PID}; do \
echo "Killing plugin pid $$PID"; \
kill -9 $$PID; \
done; \
## Clean removes all build artifacts.
.PHONY: clean
clean:
rm -fr dist/
ifneq ($(HAS_SERVER),)
rm -fr server/coverage.txt
rm -fr server/dist
endif
ifneq ($(HAS_WEBAPP),)
rm -fr webapp/junit.xml
rm -fr webapp/dist
rm -fr webapp/node_modules
endif
rm -fr build/bin/
rm -fr test/restapitest/mattermost.log
rm -fr test/restapitest/notifications.log
rm -fr test/restapitest/data/
# 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

175
README.md Normal file
View File

@ -0,0 +1,175 @@
# Badges plugin
Let your users show appreciation for their fellow colleagues by granting badges.
## Install
Get the latest release from [GitHub](https://github.com/larkox/mattermost-plugin-badges/releases) and [install it manually](https://developers.mattermost.com/integrate/plugins/server/hello-world/#installing-the-plugin) on your server.
## Configuration
![Screenshot from 2022-03-16 11-02-13](https://user-images.githubusercontent.com/1933730/158565396-9d637c4c-6772-449f-81cb-2b73f8f6670e.png)
- **Badges admin**: Every System Admin is considered a badges admin. System Admins can assign the badges admin role to a single person by specifying their username. Only a single badge admin assignment is permitted.
## Usage
### Creating a type
Badge admins can create different types of badges, and each type of badge can have its own permissions. You must be a badge admin to create a badge type.
Run the slash command `/badges create type` to open the creation dialog.
![Screenshot from 2022-03-16 11-14-31](https://user-images.githubusercontent.com/1933730/158567578-1241cc93-6964-4dc7-a56b-a5b3729229b7.png)
- **Name**: The type of badge that's visible in the badges description.
- **Everyone can create badge**: If you mark this checkbox, every user in your Mattermost instance can create badges of this type.
- **Can create allowlist**: This list contains the usernames (comma separated) of all the people allowed to create badges of this type.
- **Everyone can grant badge**: If you mark this checkbox, every user in your Mattermost instance can grant any badge of this type.
- **Can grant allowlist**: This list contains the usernames (comma separated) of all the people allowed to grant badges of this type.
### Permissions details
Badge admins can always create types, create badges for any type, and grant badges from any type, regardless of the permissions in place for a given badge type.
A badge creator can always grant the badge they created.
Any other user is subject to the permissions defined as part of the badge type.
Some examples of badge permissions by type are included below. Remember that badge admins have full control over badges, and badge creators can always grant badges. The examples below are intended to demonstrate how badge permissions can be configured for non-admin users to get the most out of badges.
(ECC: Everyone Can Create, CC: Can Create Allowlist, ECG: Everyone Can Grant, CG: Can Grant Allowlist)
| Permissions | Example | ECC | ECG | CC | CG |
|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------|-------|--------------|--------------|
| Only badge admins can create and grant badges | | false | false | empty | empty |
| Only user1 can create badges, but everyone can grant them | for peer appreciation badges, like "Thank you" badge | false | true | user1 | empty |
| Only user1 can create badges, and only user2 and user3 can grant them | lead appreciation badges, like "MVP" badge, where the management create the badges, and the team leads are the ones granting them to their team members | false | false | user1 | user2, user3 |
| Only user1 and user2 can create badges, but they can only grant the badges they have created | can be used to have team specific badges without creating a new type for every team | false | false | user1, user2 | empty |
| Everyone can create badges, but can only grant the badges they have created | | true | false | empty | empty |
| Everyone can create and grant any badge | | true | true | empty | empty |
### Creating a badge
Run the slash command `/badges create badge` to open the creation dialog.
![Screenshot from 2022-03-16 11-37-32](https://user-images.githubusercontent.com/1933730/158571687-4983f7e4-1cf9-4fa1-a3f1-6f80918e28e3.png)
- **Name**: Name of the badge.
- **Description**: Description of the badge.
- **Image**: Only emojis are allowed. You must input the emoji name as you would to add it to a message (e.g. `:+1:` or `:smile:`). Custom emojis are also allowed.
- **Type**: The type of badge. This list will show only types you have permissions to create.
- **Multiple**: Whether this badge can be granted more than once to the same person.
### Details about Multiple
All badges can be assigned to any number of people. What the **Multiple** setting controls is whether this badge can be granted more than once to the same person. For example, a "Thank you" badge should be grantable many times (many people can be thankful to you on more than one occasion), and therefore, a Thank You badge should have the **Multiple** option selected. However, a "First year in the company" badge should be granted only once since a user won't celebrate this milestone multiple times at the same company. This type of badge should have the **Multiple** option unselected.
### Granting a badge
There are two ways to open the grant dialog:
- Run the `/badges grant` command.
- Click on the **Grant badge** link available in the Profile Popover, visible when you click on someone's username.
![Screenshot from 2022-03-16 11-47-14](https://user-images.githubusercontent.com/1933730/158573673-723e77a2-6d58-4aa5-8a89-6adcbce50e13.png)
The dialog looks like this:
![Screenshot from 2022-03-16 11-51-05](https://user-images.githubusercontent.com/1933730/158573834-70ea72b0-4a03-4b09-a694-751c0ca1ba04.png)
- **User**: The user you want to grant the badge to (may be prepopulated if you clicked the grant button from the profile popover, or added the username in the command).
- **Badge**: The badge you want to grant (may be prepopulated if you added the badge id in the command).
- **Reason**: An optional reason why you are awarding this badge. (Specially useful for badges like "Thank you").
- **Notify on this channel**: If you select this option, a message from the badges bot will be posted in the current channel, letting everyone in that channel know that you granted this badge to that person.
The user that received the badge will always receive a DM from the badges bot letting them know they have been awarded a badge. In addition, the following may happen:
- If **Notify on this channel** was marked, the badges bot will post a message on the current channel letting everyone know that the user has been awarded a badge.
- If a subscription for this badge type is set, the badges bot will post a message on all subscribed channels letting everyone know that the user has been awarded a badge.
![Screenshot from 2022-03-16 12-28-45](https://user-images.githubusercontent.com/1933730/158580318-592bb139-6c43-48f0-99c3-79d868aa8024.png)
If you try to award a badge that can't be awarded more than once to a single recipient, the badge won't be granted.
### Subscriptions
In order to create a subscription, you must be a badges admin.
Subscriptions will create posts into a channel every time a badge is granted. There is no limit to the number of subscriptions per channel or per type.
There are two ways to open the subscription creation dialog:
- Run the `/badges subscription create` command.
- Click on the **Add badge subscription** menu from the channel menu.
![Screenshot from 2022-03-16 12-16-16](https://user-images.githubusercontent.com/1933730/158578166-1ae6f5de-a53b-4e46-95ba-4fd57f50a315.png)
The dialog looks like this:
![Screenshot from 2022-03-16 12-16-55](https://user-images.githubusercontent.com/1933730/158578272-dc6644a1-3a8b-4f54-8c83-d192d8fab273.png)
- **Type**: The type of badges you want to subscribe to this channel.
In order to remove subscriptions, a similar dialog can be opened by using the `/badges subscription remove` and the **Remove badge subscription** option from the channel menu.
### Editing a deleting badges and types
In order to edit or delete types you must be a badge admin. In order to edit or delete a badge, you must be a badge admin or the creator.
Run `/badges edit type --type typeID` or `/badges edit badge --id badgeID` to open a dialog pretty similar to the creation dialog. IDs are not human readable, but Autocomplete will help you select the right badge.
![Screenshot from 2022-03-16 12-22-49](https://user-images.githubusercontent.com/1933730/158579272-7a7164da-0b90-412f-94f5-7a10fe5f1a1a.png)
![Screenshot from 2022-03-16 12-21-21](https://user-images.githubusercontent.com/1933730/158579256-58b3ad7b-f0c2-44f9-9d33-4679a87cd034.png)
The only difference to the creation is one extra checkbox to remove the current type or badge. If you mark this checkbox and click **Edit**, the badge or type will be removed.
When you remove a badge, the badge is deleted permanently, along with any information about who that badge was granted to. When you remove a type, the type and all the associated badges are removed completely.
### Badge list
Badges show on several places. On the profile popover of the users, they show up to the last 20 badges granted to that user. Hovering over the badges will give you more information, and cliking on them will open the Right Hand Sidebar (RHS) with the badge details.
![Screenshot from 2022-03-16 12-29-39](https://user-images.githubusercontent.com/1933730/158580433-ca57a911-1397-432d-a739-0f06ac474845.png)
The channel header button will open the RHS with the list of all badges.
![Screenshot from 2022-03-16 12-31-18](https://user-images.githubusercontent.com/1933730/158580823-997df585-c775-43ff-9475-7a5900b151e6.png)
![Screenshot from 2022-03-16 12-32-31](https://user-images.githubusercontent.com/1933730/158580924-e24e4884-d321-465c-bd92-8c41c286612e.png)
Clicking on any badge will lead you to the badge details. Here you can check all the users that have been granted this badge.
![Screenshot from 2022-03-16 12-33-17](https://user-images.githubusercontent.com/1933730/158581085-454ff9b8-1614-4625-a4e3-16f2b0356ac8.png)
Clicking on any username on the badge details screen will lead you to the badges granted to that user.
![Screenshot from 2022-03-16 12-34-31](https://user-images.githubusercontent.com/1933730/158581257-ca614b71-3093-48fe-909d-c706c348891e.png)
## Using the Plugin API to create and grant badges
This plugin can be integrated with any other plugin in your system, to automatize the creation and granting of badges.
Using the [PluginHTTP](https://developers.mattermost.com/integrate/plugins/server/reference/#API.PluginHTTP) API method, you can create a request to the badges plugin to "Ensure" and to "Grant" the badges needed.
The badges plugin exposes the `badgesmodel` package to simplify handling several parts of this process. Some important exposed objects:
- badgesmodel.PluginPath (`/com.mattermost.badges`): The base URL for the plugin (the plugin id).
- badgesmodel.PluginAPIPath (`/papi/v1`): The plugin api route.
- badgesmodel.PluginAPIPathEnsure (`/ensure`): The ensure endpoint route.
- badgesmodel.PluginAPIPathGrant (`/grant`): The grant endpoint route.
- badgesmodel.Badge: The data model for badges.
- badgesmodel.EnsureBadgesRequest: The data model of the body of a Ensure Badges Request.
- badgesmodel.GrantBadgeRequest: The data model of the body of a Grant Badge Request.
- badgesmodel.ImageTypeEmoj (`emoji`): The emoji image type. Other image types are considered, but we recommend using emojis.
### Ensure badges
URL: `/com.mattermost.badges/papi/v1/ensure`
Method: `POST`
Body example:
```json
{
"Badges":[
{
"name":"My badge",
"description":"Awesome badge",
"image":"smile",
"image_type":"emoji",
"multiple":true
}
],
"BotId":"myBotId"
}
```
Ensure badges will create badges if they already do not exist, and return the list of badges including the ids. In order to check whether a badge exist or not, it will only check the name of the badge.
### Grant badges
URL: `/com.mattermost.badges/papi/v1/grant`
Method: `POST`
Body example:
```json
{
"BadgeID":"badgeID",
"BotId":"myBotId",
"UserID":"userID",
"Reason":""
}
```
Grant badges will grant the badge with the badge id provided from the bot to the user defined. Reason is optional.

0
assets/.gitkeep Normal file
View File

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="241px" height="240px" viewBox="0 0 241 240" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 46.2 (44496) - http://www.bohemiancoding.com/sketch -->
<title>blue-icon</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="06" transform="translate(-681.000000, -572.000000)" fill="#1875F0">
<g id="Group-2" transform="translate(626.000000, 517.000000)">
<path d="M216.908181,153.127705 C216.908181,153.127705 217.280588,169.452526 205.928754,180.543035 C194.57546,191.633544 180.631383,190.619887 171.560722,187.557072 C162.488602,184.494256 150.79503,176.85251 148.531381,161.16705 C146.269193,145.480133 156.508188,132.736607 156.508188,132.736607 L178.820463,105.066407 L191.815268,89.2629779 L202.969946,75.4912313 C202.969946,75.4912313 208.088713,68.6534193 209.547671,67.2421648 C209.836834,66.9625354 210.133299,66.7790286 210.423923,66.6377576 L210.635683,66.5299837 L210.673654,66.5154197 C211.28703,66.2518108 211.993873,66.195011 212.675888,66.4251227 C213.343299,66.6508652 213.860288,67.1081757 214.187421,67.6718037 L214.256061,67.7810339 L214.315938,67.9062846 C214.475124,68.2063036 214.608022,68.5485583 214.67082,68.9709151 C214.968745,70.976382 214.870897,79.5094471 214.870897,79.5094471 L215.342613,97.2047434 L216.039232,117.630795 L216.908181,153.127705 Z M245.790587,78.2043261 C287.057212,108.155253 305.982915,162.509669 288.774288,213.346872 C267.594104,275.911031 199.706245,309.46073 137.142925,288.281718 C74.5796048,267.10125 41.031812,199.213937 62.2105402,136.649778 C79.4482947,85.7295603 127.625459,54.0324057 178.690632,55.4145322 L162.322339,74.7541074 C132.028106,80.231639 105.87146,100.919843 95.5908489,131.290215 C80.2944535,176.475117 105.932628,225.982624 152.855846,241.866155 C199.777608,257.751142 250.216536,233.998666 265.512932,188.813764 C275.760046,158.543884 267.634882,126.336988 247.050359,103.595256 L245.790587,78.2043261 Z" id="blue-icon"></path>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

15
badgesmodel/constants.go Normal file
View File

@ -0,0 +1,15 @@
package badgesmodel
const (
NameMaxLength = 20
DescriptionMaxLength = 120
ImageTypeEmoji ImageType = "emoji"
ImageTypeRelativeURL ImageType = "rel_url"
ImageTypeAbsoluteURL ImageType = "abs_url"
PluginPath = "/com.mattermost.badges"
PluginAPIPath = "/papi/v1"
PluginAPIPathEnsure = "/ensure"
PluginAPIPathGrant = "/grant"
)

112
badgesmodel/model.go Normal file
View File

@ -0,0 +1,112 @@
package badgesmodel
import (
"time"
)
type BadgeType string
type BadgeID string
type Ownership struct {
User string `json:"user"`
GrantedBy string `json:"granted_by"`
Badge BadgeID `json:"badge"`
Reason string `json:"reason"`
Time time.Time `json:"time"`
}
type OwnershipList []Ownership
type Badge struct {
ID BadgeID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Image string `json:"image"`
ImageType ImageType `json:"image_type"`
Multiple bool `json:"multiple"`
Type BadgeType `json:"type"`
CreatedBy string `json:"created_by"`
}
type UserBadge struct {
Badge
Ownership
GrantedByUsername string `json:"granted_by_name"`
TypeName string `json:"type_name"`
}
type BadgeDetails struct {
Badge
Owners []Ownership `json:"owners"`
CreatedByUsername string `json:"created_by_username"`
TypeName string `json:"type_name"`
}
type AllBadgesBadge struct {
Badge
Granted int `json:"granted"`
GrantedTimes int `json:"granted_times"`
TypeName string `json:"type_name"`
}
type BadgeTypeDefinition struct {
ID BadgeType `json:"id"`
Name string `json:"name"`
Frame string `json:"frame"`
CreatedBy string `json:"created_by"`
CanGrant PermissionScheme `json:"can_grant"`
CanCreate PermissionScheme `json:"can_create"`
}
type PermissionScheme struct {
Everyone bool `json:"everyone"`
Roles map[string]bool `json:"roles"`
AllowList map[string]bool `json:"allow_list"`
BlockList map[string]bool `json:"block_list"`
}
type BadgeTypeList []*BadgeTypeDefinition
type ImageType string
type EnsureBadgesRequest struct {
Badges []*Badge
BotID string
}
type GrantBadgeRequest struct {
BadgeID BadgeID
UserID string
BotID string
Reason string
}
type Subscription struct {
TypeID BadgeType
ChannelID string
}
func (b Badge) IsValid() bool {
return len(b.Name) <= NameMaxLength &&
len(b.Description) <= DescriptionMaxLength &&
b.Image != ""
}
func (l OwnershipList) IsOwned(user string, badge BadgeID) bool {
for _, ownership := range l {
if user == ownership.User && badge == ownership.Badge {
return true
}
}
return false
}
func (l BadgeTypeList) GetType(id BadgeType) *BadgeTypeDefinition {
for _, t := range l {
if t.ID == id {
return t
}
}
return nil
}

1
build/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
bin

1
build/custom.mk Normal file
View File

@ -0,0 +1 @@
# Include custom targets and environment variables here

11
build/go.mod Normal file
View File

@ -0,0 +1,11 @@
module github.com/larkox/mattermost-plugin-badges/build
go 1.12
require (
github.com/go-git/go-git/v5 v5.1.0
github.com/mattermost/mattermost-server/v5 v5.3.2-0.20200924100636-e726b0426826
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.6.1
sigs.k8s.io/yaml v1.2.0
)

1003
build/go.sum Normal file

File diff suppressed because it is too large Load Diff

126
build/manifest/main.go Normal file
View File

@ -0,0 +1,126 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/pkg/errors"
)
const pluginIDGoFileTemplate = `// This file is automatically generated. Do not modify it manually.
package main
import (
"strings"
"github.com/mattermost/mattermost-server/v5/model"
)
var manifest *model.Manifest
const manifestStr = ` + "`" + `
%s
` + "`" + `
func init() {
manifest = model.ManifestFromJson(strings.NewReader(manifestStr))
}
`
func main() {
if len(os.Args) <= 1 {
panic("no cmd specified")
}
manifest, err := findManifest()
if err != nil {
panic("failed to find manifest: " + err.Error())
}
cmd := os.Args[1]
switch cmd {
case "id":
dumpPluginID(manifest)
case "version":
dumpPluginVersion(manifest)
case "has_server":
if manifest.HasServer() {
fmt.Printf("true")
}
case "has_webapp":
if manifest.HasWebapp() {
fmt.Printf("true")
}
case "apply":
if err := applyManifest(manifest); err != nil {
panic("failed to apply manifest: " + err.Error())
}
default:
panic("unrecognized command: " + cmd)
}
}
func findManifest() (*model.Manifest, error) {
_, manifestFilePath, err := model.FindManifest(".")
if err != nil {
return nil, errors.Wrap(err, "failed to find manifest in current working directory")
}
manifestFile, err := os.Open(manifestFilePath)
if err != nil {
return nil, errors.Wrapf(err, "failed to open %s", manifestFilePath)
}
defer manifestFile.Close()
// Re-decode the manifest, disallowing unknown fields. When we write the manifest back out,
// we don't want to accidentally clobber anything we won't preserve.
var manifest model.Manifest
decoder := json.NewDecoder(manifestFile)
decoder.DisallowUnknownFields()
if err = decoder.Decode(&manifest); err != nil {
return nil, errors.Wrap(err, "failed to parse manifest")
}
return &manifest, nil
}
// dumpPluginId writes the plugin id from the given manifest to standard out
func dumpPluginID(manifest *model.Manifest) {
fmt.Printf("%s", manifest.Id)
}
// dumpPluginVersion writes the plugin version from the given manifest to standard out
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 := ioutil.WriteFile(
"server/manifest.go",
[]byte(fmt.Sprintf(pluginIDGoFileTemplate, manifestStr)),
0600,
); err != nil {
return errors.Wrap(err, "failed to write server/manifest.go")
}
}
return nil
}

172
build/pluginctl/main.go Normal file
View File

@ -0,0 +1,172 @@
// main handles deployment of the plugin to a development server using the Client4 API.
package main
import (
"errors"
"fmt"
"log"
"net"
"os"
"github.com/mattermost/mattermost-server/v5/model"
)
const helpText = `
Usage:
pluginctl deploy <plugin id> <bundle path>
pluginctl disable <plugin id>
pluginctl enable <plugin id>
pluginctl reset <plugin id>
`
func main() {
err := pluginctl()
if err != nil {
fmt.Printf("Failed: %s\n", err.Error())
fmt.Print(helpText)
os.Exit(1)
}
}
func pluginctl() error {
if len(os.Args) < 3 {
return errors.New("invalid number of arguments")
}
client, err := getClient()
if err != nil {
return err
}
switch os.Args[1] {
case "deploy":
if len(os.Args) < 4 {
return errors.New("invalid number of arguments")
}
return deploy(client, os.Args[2], os.Args[3])
case "disable":
return disablePlugin(client, os.Args[2])
case "enable":
return enablePlugin(client, os.Args[2])
case "reset":
return resetPlugin(client, os.Args[2])
default:
return errors.New("invalid second argument")
}
}
func getClient() (*model.Client4, error) {
socketPath := os.Getenv("MM_LOCALSOCKETPATH")
if socketPath == "" {
socketPath = model.LOCAL_MODE_SOCKET_PATH
}
client, connected := getUnixClient(socketPath)
if connected {
log.Printf("Connecting using local mode over %s", socketPath)
return client, nil
}
if os.Getenv("MM_LOCALSOCKETPATH") != "" {
log.Printf("No socket found at %s for local mode deployment. Attempting to authenticate with credentials.", socketPath)
}
siteURL := os.Getenv("MM_SERVICESETTINGS_SITEURL")
adminToken := os.Getenv("MM_ADMIN_TOKEN")
adminUsername := os.Getenv("MM_ADMIN_USERNAME")
adminPassword := os.Getenv("MM_ADMIN_PASSWORD")
if siteURL == "" {
return nil, errors.New("MM_SERVICESETTINGS_SITEURL is not set")
}
client = model.NewAPIv4Client(siteURL)
if adminToken != "" {
log.Printf("Authenticating using token against %s.", siteURL)
client.SetToken(adminToken)
return client, nil
}
if adminUsername != "" && adminPassword != "" {
client := model.NewAPIv4Client(siteURL)
log.Printf("Authenticating as %s against %s.", adminUsername, siteURL)
_, resp := client.Login(adminUsername, adminPassword)
if resp.Error != nil {
return nil, fmt.Errorf("failed to login as %s: %w", adminUsername, resp.Error)
}
return client, nil
}
return nil, errors.New("one of MM_ADMIN_TOKEN or MM_ADMIN_USERNAME/MM_ADMIN_PASSWORD must be defined")
}
func getUnixClient(socketPath string) (*model.Client4, bool) {
_, err := net.Dial("unix", socketPath)
if err != nil {
return nil, false
}
return model.NewAPIv4SocketClient(socketPath), true
}
// deploy attempts to upload and enable a plugin via the Client4 API.
// It will fail if plugin uploads are disabled.
func deploy(client *model.Client4, pluginID, bundlePath string) error {
pluginBundle, err := os.Open(bundlePath)
if err != nil {
return fmt.Errorf("failed to open %s: %w", bundlePath, err)
}
defer pluginBundle.Close()
log.Print("Uploading plugin via API.")
_, resp := client.UploadPluginForced(pluginBundle)
if resp.Error != nil {
return fmt.Errorf("failed to upload plugin bundle: %s", resp.Error.Error())
}
log.Print("Enabling plugin.")
_, resp = client.EnablePlugin(pluginID)
if resp.Error != nil {
return fmt.Errorf("failed to enable plugin: %s", resp.Error.Error())
}
return nil
}
// disablePlugin attempts to disable the plugin via the Client4 API.
func disablePlugin(client *model.Client4, pluginID string) error {
log.Print("Disabling plugin.")
_, resp := client.DisablePlugin(pluginID)
if resp.Error != nil {
return fmt.Errorf("failed to disable plugin: %w", resp.Error)
}
return nil
}
// enablePlugin attempts to enable the plugin via the Client4 API.
func enablePlugin(client *model.Client4, pluginID string) error {
log.Print("Enabling plugin.")
_, resp := client.EnablePlugin(pluginID)
if resp.Error != nil {
return fmt.Errorf("failed to enable plugin: %w", resp.Error)
}
return nil
}
// resetPlugin attempts to reset the plugin via the Client4 API.
func resetPlugin(client *model.Client4, pluginID string) error {
err := disablePlugin(client, pluginID)
if err != nil {
return err
}
err = enablePlugin(client, pluginID)
if err != nil {
return err
}
return nil
}

45
build/setup.mk Normal file
View File

@ -0,0 +1,45 @@
# Ensure that go is installed. Note that this is independent of whether or not a server is being
# built, since the build script itself uses go.
ifeq ($(GO),)
$(error "go is not available: see https://golang.org/doc/install")
endif
# Ensure that the build tools are compiled. Go's caching makes this quick.
$(shell cd build/manifest && $(GO) build -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)
# Extract the plugin id from the manifest.
PLUGIN_ID ?= $(shell build/bin/manifest id)
ifeq ($(PLUGIN_ID),)
$(error "Cannot parse id from $(MANIFEST_FILE)")
endif
# Extract the plugin version from the manifest.
PLUGIN_VERSION ?= $(shell build/bin/manifest version)
ifeq ($(PLUGIN_VERSION),)
$(error "Cannot parse version from $(MANIFEST_FILE)")
endif
# Determine if a server is defined in the manifest.
HAS_SERVER ?= $(shell build/bin/manifest has_server)
# Determine if a webapp is defined in the manifest.
HAS_WEBAPP ?= $(shell build/bin/manifest has_webapp)
# Determine if a /public folder is in use
HAS_PUBLIC ?= $(wildcard public/.)
# Determine if the mattermost-utilities repo is present
HAS_MM_UTILITIES ?= $(wildcard $(MM_UTILITIES_DIR)/.)
# Store the current path for later use
PWD ?= $(shell pwd)
# Ensure that npm (and thus node) is installed.
ifneq ($(HAS_WEBAPP),)
ifeq ($(NPM),)
$(error "npm is not available: see https://www.npmjs.com/get-npm")
endif
endif

113
build/sync/README.md Normal file
View File

@ -0,0 +1,113 @@
sync
====
The sync tool is a proof-of-concept implementation of a tool for synchronizing mattermost plugin
repositories with the mattermost-plugin-starter-template repo.
Overview
--------
At its core the tool is just a collection of checks and actions that are executed according to a
synchronization plan (see [./build/sync/plan.yml](https://github.com/mattermost/mattermost-plugin-starter-template/blob/sync/build/sync/plan.yml)
for an example). The plan defines a set of files
and/or directories that need to be kept in sync between the plugin repository and the template (this
repo).
For each set of paths, a set of actions to be performed is outlined. No more than one action of that set
will be executed - the first one whose checks pass. Other actions are meant to act as fallbacks.
The idea is to be able to e.g. overwrite a file if it has no local changes or apply a format-specific
merge algorithm otherwise.
Before running each action, the tool will check if any checks are defined for that action. If there
are any, they will be executed and their results examined. If all checks pass, the action will be executed.
If there is a check failure, the tool will locate the next applicable action according to the plan and
start over with it.
The synchronization plan can also run checks before running any actions, e.g. to check if the source and
target worktrees are clean.
Running
-------
The tool can be executed from the root of this repository with a command:
```
$ go run ./build/sync/main.go ./build/sync/plan.yml ../mattermost-plugin-github
```
(assuming `mattermost-plugin-github` is the target repository we want to synchronize with the source).
plan.yml
---------
The `plan.yml` file (located in `build/sync/plan.yml`) consists of two parts:
- checks
- actions
The `checks` section defines tests to run before executing the plan itself. Currently the only available such check is `repo_is_clean` defined as:
```
type: repo_is_clean
params:
repo: source
```
The `repo` parameter takes one of two values:
- `source` - the `mattermost-plugin-starter-template` repository
- `target` - the repository of the plugin being updated.
The `actions` section defines actions to be run as part of the synchronization.
Each entry in this section has the form:
```
paths:
- path1
- path2
actions:
- type: action_type
params:
action_parameter: value
conditions:
- type: check_type
params:
check_parameter: value
```
`paths` is a list of file or directory paths (relative to the root of the repository)
synchronization should be performed on.
Each action in the `actions` section is defined by its type. Currently supported action types are:
- `overwrite_file` - overwrite the specified file in the `target` repository with the file in the `source` repository.
- `overwrite_directory` - overwrite a directory.
Both actions accept a parameter called `create` which determines if the file or directory should be created if it does not exist in the target repository.
The `conditions` part of an action definition defines tests that need to pass for the
action to be run. Available checks are:
- `exists`
- `file_unaltered`
The `exists` check takes a single parameter - `repo` (referencing either the source or target repository) and it passes only if the file or directory the action is about to be run on exists. If the repo parameter is not specified, it will default to `target`.
The `file_unaltered` check is only applicable to file paths. It passes if the file
has not been altered - i.e. it is identical to some version of that same file in the reference repository (usually `source`). This check takes two parameters:
- `in` - repository to check the file in, default `target`
- `compared-to` - repository to check the file against, default `source`.
When multiple actions are specified for a set of paths, the `sync` tool will only
execute a single action for each path. The first action in the list, whose conditions
are all satisfied will be executed.
If an acton fails due to an error, the synchronization run will be aborted.
Caveat emptor
-------------
This is a very basic proof-of-concept and there are many things that should be improved/implemented:
(in no specific order)
1. Format-specific merge actions for `go.mod`, `go.sum`, `webapp/package.json` and other files should
be implemented.
2. Better logging should be implemented.
3. Handling action dependencies should be investigated.
e.g. if the `build` directory is overwritten, that will in some cases mean that the go.mod file also needs
to be updated.
4. Storing the tree-hash of the template repository that the plugin was synchronized with would allow
improving the performance of the tool by restricting the search space when examining if a file
has been altered in the plugin repository.

85
build/sync/main.go Normal file
View File

@ -0,0 +1,85 @@
package main
import (
"flag"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sigs.k8s.io/yaml"
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
)
func main() {
verbose := flag.Bool("verbose", false, "enable verbose output")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Update a pluging directory with /mattermost-plugin-starter-template/.\n")
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0])
fmt.Fprintf(flag.CommandLine.Output(), "%s <plan.yml> <plugin_directory>\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()
// TODO: implement proper command line parameter parsing.
if len(os.Args) != 3 {
fmt.Fprintf(os.Stderr, "running: \n $ sync [plan.yaml] [plugin path]\n")
os.Exit(1)
}
syncPlan, err := readPlan(os.Args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "coud not load plan: %s\n", err)
os.Exit(1)
}
srcDir, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get current directory: %s\n", err)
os.Exit(1)
}
trgDir, err := filepath.Abs(os.Args[2])
if err != nil {
fmt.Fprintf(os.Stderr, "could not determine target directory: %s\n", err)
os.Exit(1)
}
srcRepo, err := plan.GetRepoSetup(srcDir)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
trgRepo, err := plan.GetRepoSetup(trgDir)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
planSetup := plan.Setup{
Source: srcRepo,
Target: trgRepo,
VerboseLogging: *verbose,
}
err = syncPlan.Execute(planSetup)
if err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1)
}
}
func readPlan(path string) (*plan.Plan, error) {
raw, err := ioutil.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read plan file %q: %v", path, err)
}
var p plan.Plan
err = yaml.Unmarshal(raw, &p)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal plan yaml: %v", err)
}
return &p, err
}

44
build/sync/plan.yml Normal file
View File

@ -0,0 +1,44 @@
checks:
- type: repo_is_clean
params:
repo: source
- type: repo_is_clean
params:
repo: target
actions:
- paths:
- build/pluginctl
- build/manifest
actions:
- type: overwrite_directory
params:
create: true
- paths:
- Makefile
actions:
- type: overwrite_file
params:
create: true
- paths:
- .editorconfig
- .gitattributes
- .gitignore
- build/.gitignore
- build/go.mod
- build/go.sum
- build/setup.mk
- server/.gitignore
- webapp/.eslintrc.json
- webapp/.npmrc
- webapp/babel.config.js
- webapp/package.json
- webapp/tsconfig.json
- webapp/webpack.config.js
- webapp/src/manifest.test.tsx
- webapp/tests/setup.tsx
actions:
- type: overwrite_file
params:
create: true
conditions:
- type: file_unaltered

214
build/sync/plan/actions.go Normal file
View File

@ -0,0 +1,214 @@
package plan
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// ActionConditions adds condition support to actions.
type ActionConditions struct {
// Conditions are checkers run before executing the
// action. If any one fails (returns an error), the action
// itself is not executed.
Conditions []Check
}
// Check runs the conditions associated with the action and returns
// the first error (if any).
func (c ActionConditions) Check(path string, setup Setup) error {
if len(c.Conditions) > 0 {
setup.Logf("checking action conditions")
}
for _, condition := range c.Conditions {
err := condition.Check(path, setup)
if err != nil {
return err
}
}
return nil
}
// OverwriteFileAction is used to overwrite a file.
type OverwriteFileAction struct {
ActionConditions
Params struct {
// Create determines whether the target directory
// will be created if it does not exist.
Create bool `json:"create"`
}
}
// Run implements plan.Action.Run.
func (a OverwriteFileAction) Run(path string, setup Setup) error {
setup.Logf("overwriting file %q", path)
src := setup.PathInRepo(SourceRepo, path)
dst := setup.PathInRepo(TargetRepo, path)
dstInfo, err := os.Stat(dst)
switch {
case os.IsNotExist(err):
if !a.Params.Create {
return fmt.Errorf("path %q does not exist, not creating", dst)
}
case err != nil:
return fmt.Errorf("failed to check path %q: %v", dst, err)
case dstInfo.IsDir():
return fmt.Errorf("path %q is a directory", dst)
}
srcInfo, err := os.Stat(src)
if os.IsNotExist(err) {
return fmt.Errorf("file %q does not exist", src)
} else if err != nil {
return fmt.Errorf("failed to check path %q: %v", src, err)
}
if srcInfo.IsDir() {
return fmt.Errorf("path %q is a directory", src)
}
srcF, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open %q: %v", src, err)
}
defer srcF.Close()
dstF, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, srcInfo.Mode())
if err != nil {
return fmt.Errorf("failed to open %q: %v", src, err)
}
defer dstF.Close()
_, err = io.Copy(dstF, srcF)
if err != nil {
return fmt.Errorf("failed to copy file %q: %v", path, err)
}
return nil
}
// OverwriteDirectoryAction is used to completely overwrite directories.
// If the target directory exists, it will be removed first.
type OverwriteDirectoryAction struct {
ActionConditions
Params struct {
// Create determines whether the target directory
// will be created if it does not exist.
Create bool `json:"create"`
}
}
// Run implements plan.Action.Run.
func (a OverwriteDirectoryAction) Run(path string, setup Setup) error {
setup.Logf("overwriting directory %q", path)
src := setup.PathInRepo(SourceRepo, path)
dst := setup.PathInRepo(TargetRepo, path)
dstInfo, err := os.Stat(dst)
switch {
case os.IsNotExist(err):
if !a.Params.Create {
return fmt.Errorf("path %q does not exist, not creating", dst)
}
case err != nil:
return fmt.Errorf("failed to check path %q: %v", dst, err)
default:
if !dstInfo.IsDir() {
return fmt.Errorf("path %q is not a directory", dst)
}
err = os.RemoveAll(dst)
if err != nil {
return fmt.Errorf("failed to remove directory %q: %v", dst, err)
}
}
srcInfo, err := os.Stat(src)
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", src)
} else if err != nil {
return fmt.Errorf("failed to check path %q: %v", src, err)
}
if !srcInfo.IsDir() {
return fmt.Errorf("path %q is not a directory", src)
}
err = CopyDirectory(src, dst)
if err != nil {
return fmt.Errorf("failed to copy path %q: %v", path, err)
}
return nil
}
// CopyDirectory copies the directory src to dst so that after
// a successful operation the contents of src and dst are equal.
func CopyDirectory(src, dst string) error {
copier := dirCopier{dst: dst, src: src}
return filepath.Walk(src, copier.Copy)
}
type dirCopier struct {
dst string
src string
}
// Convert a path in the source directory to a path in the destination
// directory.
func (d dirCopier) srcToDst(path string) (string, error) {
suff := strings.TrimPrefix(path, d.src)
if suff == path {
return "", fmt.Errorf("path %q is not in %q", path, d.src)
}
return filepath.Join(d.dst, suff), nil
}
// Copy is an implementation of filepatch.WalkFunc that copies the
// source directory to target with all subdirectories.
func (d dirCopier) Copy(srcPath string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("failed to copy directory: %v", err)
}
trgPath, err := d.srcToDst(srcPath)
if err != nil {
return err
}
if info.IsDir() {
err = os.MkdirAll(trgPath, info.Mode())
if err != nil {
return fmt.Errorf("failed to create directory %q: %v", trgPath, err)
}
err = os.Chtimes(trgPath, info.ModTime(), info.ModTime())
if err != nil {
return fmt.Errorf("failed to create directory %q: %v", trgPath, err)
}
return nil
}
err = copyFile(srcPath, trgPath, info)
if err != nil {
return fmt.Errorf("failed to copy file %q: %v", srcPath, err)
}
return nil
}
func copyFile(src, dst string, info os.FileInfo) error {
srcF, err := os.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file %q: %v", src, err)
}
defer srcF.Close()
dstF, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, info.Mode())
if err != nil {
return fmt.Errorf("failed to open destination file %q: %v", dst, err)
}
_, err = io.Copy(dstF, srcF)
if err != nil {
dstF.Close()
return fmt.Errorf("failed to copy file %q: %v", src, err)
}
if err = dstF.Close(); err != nil {
return fmt.Errorf("failed to close file %q: %v", dst, err)
}
err = os.Chtimes(dst, info.ModTime(), info.ModTime())
if err != nil {
return fmt.Errorf("failed to adjust file modification time for %q: %v", dst, err)
}
return nil
}

View File

@ -0,0 +1,112 @@
package plan_test
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
)
func TestCopyDirectory(t *testing.T) {
assert := assert.New(t)
// Create a temporary directory to copy to.
dir, err := ioutil.TempDir("", "test")
assert.Nil(err)
defer os.RemoveAll(dir)
wd, err := os.Getwd()
assert.Nil(err)
srcDir := filepath.Join(wd, "testdata")
err = plan.CopyDirectory(srcDir, dir)
assert.Nil(err)
compareDirectories(t, dir, srcDir)
}
func TestOverwriteFileAction(t *testing.T) {
assert := assert.New(t)
// Create a temporary directory to copy to.
dir, err := ioutil.TempDir("", "test")
assert.Nil(err)
defer os.RemoveAll(dir)
wd, err := os.Getwd()
assert.Nil(err)
setup := plan.Setup{
Source: plan.RepoSetup{
Git: nil,
Path: filepath.Join(wd, "testdata", "b"),
},
Target: plan.RepoSetup{
Git: nil,
Path: dir,
},
}
action := plan.OverwriteFileAction{}
action.Params.Create = true
err = action.Run("c", setup)
assert.Nil(err)
compareDirectories(t, dir, filepath.Join(wd, "testdata", "b"))
}
func TestOverwriteDirectoryAction(t *testing.T) {
assert := assert.New(t)
// Create a temporary directory to copy to.
dir, err := ioutil.TempDir("", "test")
assert.Nil(err)
defer os.RemoveAll(dir)
wd, err := os.Getwd()
assert.Nil(err)
setup := plan.Setup{
Source: plan.RepoSetup{
Git: nil,
Path: wd,
},
Target: plan.RepoSetup{
Git: nil,
Path: dir,
},
}
action := plan.OverwriteDirectoryAction{}
action.Params.Create = true
err = action.Run("testdata", setup)
assert.Nil(err)
destDir := filepath.Join(dir, "testdata")
srcDir := filepath.Join(wd, "testdata")
compareDirectories(t, destDir, srcDir)
}
func compareDirectories(t *testing.T, pathA, pathB string) {
assert := assert.New(t)
t.Helper()
aContents, err := ioutil.ReadDir(pathA)
assert.Nil(err)
bContents, err := ioutil.ReadDir(pathB)
assert.Nil(err)
assert.Len(aContents, len(bContents))
// Check the directory contents are equal.
for i, aFInfo := range aContents {
bFInfo := bContents[i]
assert.Equal(aFInfo.Name(), bFInfo.Name())
assert.Equal(aFInfo.Mode(), bFInfo.Mode())
assert.Equal(aFInfo.IsDir(), bFInfo.IsDir())
if !aFInfo.IsDir() {
assert.Equal(aFInfo.Size(), bFInfo.Size())
}
}
}

176
build/sync/plan/checks.go Normal file
View File

@ -0,0 +1,176 @@
package plan
import (
"fmt"
"os"
"sort"
"github.com/pkg/errors"
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan/git"
)
// CheckFail is a custom error type used to indicate a
// check that did not pass (but did not fail due to external
// causes.
// Use `IsCheckFail` to check if an error is a check failure.
type CheckFail string
func (e CheckFail) Error() string {
return string(e)
}
// CheckFailf creates an error with the specified message string.
// The error will pass the IsCheckFail filter.
func CheckFailf(msg string, args ...interface{}) CheckFail {
if len(args) > 0 {
msg = fmt.Sprintf(msg, args...)
}
return CheckFail(msg)
}
// IsCheckFail determines if an error is a check fail error.
func IsCheckFail(err error) bool {
if err == nil {
return false
}
_, ok := err.(CheckFail)
return ok
}
// RepoIsCleanChecker checks whether the git repository is clean.
type RepoIsCleanChecker struct {
Params struct {
Repo RepoID
}
}
// Check implements the Checker interface.
// The path parameter is ignored because this checker checks the state of a repository.
func (r RepoIsCleanChecker) Check(_ string, ctx Setup) error {
ctx.Logf("checking if repository %q is clean", r.Params.Repo)
rc := ctx.GetRepo(r.Params.Repo)
repo := rc.Git
worktree, err := repo.Worktree()
if err != nil {
return fmt.Errorf("failed to get worktree: %v", err)
}
status, err := worktree.Status()
if err != nil {
return fmt.Errorf("failed to get worktree status: %v", err)
}
if !status.IsClean() {
return CheckFailf("%q repository is not clean", r.Params.Repo)
}
return nil
}
// PathExistsChecker checks whether the fle or directory with the
// path exists. If it does not, an error is returned.
type PathExistsChecker struct {
Params struct {
Repo RepoID
}
}
// Check implements the Checker interface.
func (r PathExistsChecker) Check(path string, ctx Setup) error {
repo := r.Params.Repo
if repo == "" {
repo = TargetRepo
}
ctx.Logf("checking if path %q exists in repo %q", path, repo)
absPath := ctx.PathInRepo(repo, path)
_, err := os.Stat(absPath)
if os.IsNotExist(err) {
return CheckFailf("path %q does not exist", path)
} else if err != nil {
return fmt.Errorf("failed to stat path %q: %v", absPath, err)
}
return nil
}
// FileUnalteredChecker checks whether the file in Repo is
// an unaltered version of that same file in ReferenceRepo.
//
// Its purpose is to check that a file has not been changed after forking a repository.
// It could be an old unaltered version, so the git history of the file is traversed
// until a matching version is found.
//
// If the repositories in the parameters are not specified,
// reference will default to the source repository and repo - to the target.
type FileUnalteredChecker struct {
Params struct {
SourceRepo RepoID `json:"compared-to"`
TargetRepo RepoID `json:"in"`
}
}
// Check implements the Checker interface.
func (f FileUnalteredChecker) Check(path string, setup Setup) error {
setup.Logf("checking if file %q has not been altered", path)
repo := f.Params.TargetRepo
if repo == "" {
repo = TargetRepo
}
source := f.Params.SourceRepo
if source == "" {
source = SourceRepo
}
trgPath := setup.PathInRepo(repo, path)
srcPath := setup.PathInRepo(source, path)
fileHashes, err := git.FileHistory(path, setup.GetRepo(source).Git)
if err != nil {
return err
}
var srcDeleted bool
srcInfo, err := os.Stat(srcPath)
if err != nil {
if os.IsNotExist(err) {
srcDeleted = true
} else {
return fmt.Errorf("failed to get stat for %q: %v", trgPath, err)
}
} else if srcInfo.IsDir() {
return fmt.Errorf("%q is a directory in source repository", path)
}
trgInfo, err := os.Stat(trgPath)
if os.IsNotExist(err) {
if srcDeleted {
// File has been deleted in target and source repositories.
// Consider it unaltered.
return nil
}
// Check if the file was ever in git history.
_, err := git.FileHistory(path, setup.GetRepo(repo).Git)
if errors.Is(err, git.ErrNotFound) {
// This is a new file being introduced to the target repo.
// Consider it unaltered.
return nil
} else if err != nil {
return err
}
return CheckFailf("file %q has been deleted", trgPath)
}
if err != nil {
return fmt.Errorf("failed to get stat for %q: %v", trgPath, err)
}
if trgInfo.IsDir() {
return fmt.Errorf("%q is a directory", trgPath)
}
currentHash, err := git.GetFileHash(trgPath)
if err != nil {
return err
}
sort.Strings(fileHashes)
idx := sort.SearchStrings(fileHashes, currentHash)
if idx < len(fileHashes) && fileHashes[idx] == currentHash {
return nil
}
return CheckFailf("file %q has been altered", trgPath)
}

View File

@ -0,0 +1,213 @@
package plan_test
import (
"fmt"
"io/ioutil"
"os"
"path"
"path/filepath"
"testing"
"time"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
)
// Tests for the RepoIsClean checker.
func TestRepoIsCleanChecker(t *testing.T) {
assert := assert.New(t)
// Create a git repository in a temporary dir.
dir, err := ioutil.TempDir("", "test")
assert.Nil(err)
defer os.RemoveAll(dir)
repo, err := git.PlainInit(dir, false)
assert.Nil(err)
// Repo should be clean.
checker := plan.RepoIsCleanChecker{}
checker.Params.Repo = plan.TargetRepo
ctx := plan.Setup{
Target: plan.RepoSetup{
Path: dir,
Git: repo,
},
}
assert.Nil(checker.Check("", ctx))
// Create a file in the repository.
err = ioutil.WriteFile(path.Join(dir, "data.txt"), []byte("lorem ipsum"), 0600)
assert.Nil(err)
err = checker.Check("", ctx)
assert.EqualError(err, "\"target\" repository is not clean")
assert.True(plan.IsCheckFail(err))
}
func TestPathExistsChecker(t *testing.T) {
assert := assert.New(t)
// Set up a working directory.
wd, err := ioutil.TempDir("", "repo")
assert.Nil(err)
defer os.RemoveAll(wd)
err = os.Mkdir(filepath.Join(wd, "t"), 0755)
assert.Nil(err)
err = ioutil.WriteFile(filepath.Join(wd, "t", "test"), []byte("lorem ipsum"), 0644)
assert.Nil(err)
checker := plan.PathExistsChecker{}
checker.Params.Repo = plan.SourceRepo
ctx := plan.Setup{
Source: plan.RepoSetup{
Path: wd,
},
}
// Check with existing directory.
assert.Nil(checker.Check("t", ctx))
// Check with existing file.
assert.Nil(checker.Check("t/test", ctx))
err = checker.Check("nosuchpath", ctx)
assert.NotNil(err)
assert.True(plan.IsCheckFail(err))
}
func tempGitRepo(assert *assert.Assertions) (string, *git.Repository, func()) {
// Setup repository.
wd, err := ioutil.TempDir("", "repo")
assert.Nil(err)
// Initialize a repository.
repo, err := git.PlainInit(wd, false)
assert.Nil(err)
w, err := repo.Worktree()
assert.Nil(err)
// Create repository files.
err = ioutil.WriteFile(filepath.Join(wd, "test"),
[]byte("lorem ipsum"), 0644)
assert.Nil(err)
sig := &object.Signature{
Name: "test",
Email: "test@example.com",
When: time.Now(),
}
_, err = w.Commit("initial commit", &git.CommitOptions{Author: sig})
assert.Nil(err)
pathA := "a.txt"
err = ioutil.WriteFile(filepath.Join(wd, pathA),
[]byte("lorem ipsum"), 0644)
assert.Nil(err)
_, err = w.Add(pathA)
assert.Nil(err)
_, err = w.Commit("add files", &git.CommitOptions{Author: sig})
assert.Nil(err)
return wd, repo, func() { os.RemoveAll(wd) }
}
func TestUnalteredCheckerSameFile(t *testing.T) {
assert := assert.New(t)
wd, repo, cleanup := tempGitRepo(assert)
defer cleanup()
ctx := plan.Setup{
Source: plan.RepoSetup{
Path: wd,
Git: repo,
},
Target: plan.RepoSetup{
Path: wd,
},
}
checker := plan.FileUnalteredChecker{}
checker.Params.SourceRepo = plan.SourceRepo
checker.Params.TargetRepo = plan.TargetRepo
// Check with the same file - check should succeed
hashPath := "a.txt"
err := checker.Check(hashPath, ctx)
assert.Nil(err)
}
func TestUnalteredCheckerDifferentContents(t *testing.T) {
assert := assert.New(t)
wd, repo, cleanup := tempGitRepo(assert)
defer cleanup()
ctx := plan.Setup{
Source: plan.RepoSetup{
Path: wd,
Git: repo,
},
Target: plan.RepoSetup{
Path: wd,
},
}
checker := plan.FileUnalteredChecker{}
checker.Params.SourceRepo = plan.SourceRepo
checker.Params.TargetRepo = plan.TargetRepo
// Create a file with the same suffix path, but different contents.
tmpDir, err := ioutil.TempDir("", "test")
assert.Nil(err)
defer os.RemoveAll(tmpDir)
err = ioutil.WriteFile(filepath.Join(tmpDir, "a.txt"),
[]byte("not lorem ipsum"), 0644)
assert.Nil(err)
// Set the plugin path to the temporary directory.
ctx.Target.Path = tmpDir
err = checker.Check("a.txt", ctx)
assert.True(plan.IsCheckFail(err))
assert.EqualError(err, fmt.Sprintf("file %q has been altered", filepath.Join(tmpDir, "a.txt")))
}
// TestUnalteredCheckerNonExistant tests running the unaltered file checker
// in the case where the target file does not exist. If the files has no history,
// the checker should pass.
func TestUnalteredCheckerNonExistant(t *testing.T) {
assert := assert.New(t)
hashPath := "a.txt"
wd, repo, cleanup := tempGitRepo(assert)
defer cleanup()
// Temporary repo.
tmpDir, err := ioutil.TempDir("", "test")
assert.Nil(err)
defer os.RemoveAll(tmpDir)
trgRepo, err := git.PlainInit(tmpDir, false)
assert.Nil(err)
ctx := plan.Setup{
Source: plan.RepoSetup{
Path: wd,
Git: repo,
},
Target: plan.RepoSetup{
Path: tmpDir,
Git: trgRepo,
},
}
checker := plan.FileUnalteredChecker{}
checker.Params.SourceRepo = plan.SourceRepo
checker.Params.TargetRepo = plan.TargetRepo
err = checker.Check(hashPath, ctx)
assert.Nil(err)
}

View File

@ -0,0 +1,111 @@
package git
import (
"crypto/sha1" //nolint
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/pkg/errors"
)
// ErrNotFound signifies the file was not found.
var ErrNotFound = fmt.Errorf("not found")
// FileHistory will trace all the versions of a file in the git repository
// and return a list of sha1 hashes of that file.
func FileHistory(path string, repo *git.Repository) ([]string, error) {
logOpts := git.LogOptions{
FileName: &path,
All: true,
}
commits, err := repo.Log(&logOpts)
if errors.Is(err, plumbing.ErrReferenceNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get commits for path %q: %v", path, err)
}
defer commits.Close()
hashHistory := []string{}
cerr := commits.ForEach(func(c *object.Commit) error {
root, err := repo.TreeObject(c.TreeHash)
if err != nil {
return fmt.Errorf("failed to get commit tree: %v", err)
}
f, err := traverseTree(root, path)
if err == object.ErrFileNotFound || err == object.ErrDirectoryNotFound {
// Ignoring file not found errors.
return nil
} else if err != nil {
return err
}
sum, err := getReaderHash(f)
f.Close()
if err != nil {
return err
}
hashHistory = append(hashHistory, sum)
return nil
})
if cerr != nil && cerr != io.EOF {
return nil, cerr
}
if len(hashHistory) == 0 {
return nil, ErrNotFound
}
return hashHistory, nil
}
func traverseTree(root *object.Tree, path string) (io.ReadCloser, error) {
dirName, fileName := filepath.Split(path)
var err error
t := root
if dirName != "" {
t, err = root.Tree(filepath.Clean(dirName))
if err == object.ErrDirectoryNotFound {
return nil, err
} else if err != nil {
return nil, fmt.Errorf("failed to traverse tree to %q: %v", dirName, err)
}
}
f, err := t.File(fileName)
if err == object.ErrFileNotFound {
return nil, err
} else if err != nil {
return nil, fmt.Errorf("failed to lookup file %q: %v", fileName, err)
}
reader, err := f.Reader()
if err != nil {
return nil, fmt.Errorf("failed to open %q: %v", path, err)
}
return reader, nil
}
func getReaderHash(r io.Reader) (string, error) {
h := sha1.New() // nolint
_, err := io.Copy(h, r)
if err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// GetFileHash calculates the sha1 hash sum of the file.
func GetFileHash(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
sum, err := getReaderHash(f)
if err != nil {
return "", err
}
return sum, nil
}

View File

@ -0,0 +1,80 @@
package git_test
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
"time"
git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/assert"
gitutil "github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan/git"
)
var fileContents = []byte("abcdefg")
func TestFileHistory(t *testing.T) {
assert := assert.New(t)
dir, err := ioutil.TempDir("", "repo")
assert.Nil(err)
defer os.RemoveAll(dir)
// Initialize a repository.
repo, err := git.PlainInit(dir, false)
assert.Nil(err)
w, err := repo.Worktree()
assert.Nil(err)
// Create repository files.
err = ioutil.WriteFile(filepath.Join(dir, "test"), fileContents, 0644)
assert.Nil(err)
_, err = w.Add("test")
assert.Nil(err)
sig := &object.Signature{
Name: "test",
Email: "test@example.com",
When: time.Now(),
}
_, err = w.Commit("initial commit", &git.CommitOptions{Author: sig})
assert.Nil(err)
pathA := "a.txt"
err = ioutil.WriteFile(filepath.Join(dir, pathA), fileContents, 0644)
assert.Nil(err)
pathB := "b.txt"
err = ioutil.WriteFile(filepath.Join(dir, pathB), fileContents, 0644)
assert.Nil(err)
_, err = w.Add(pathA)
assert.Nil(err)
_, err = w.Add(pathB)
assert.Nil(err)
_, err = w.Commit("add files", &git.CommitOptions{Author: sig})
assert.Nil(err)
// Delete one of the files.
_, err = w.Remove(pathB)
assert.Nil(err)
_, err = w.Commit("remove file b.txt", &git.CommitOptions{
Author: sig,
All: true,
})
assert.Nil(err)
repo, err = git.PlainOpen(dir)
assert.Nil(err)
// Call file history on an existing file.
sums, err := gitutil.FileHistory("a.txt", repo)
assert.Nil(err)
assert.Equal([]string{"2fb5e13419fc89246865e7a324f476ec624e8740"}, sums)
// Calling with a non-existent file returns error.
sums, err = gitutil.FileHistory(filepath.Join(dir, "nosuch_testfile.txt"), repo)
assert.Equal(gitutil.ErrNotFound, err)
assert.Nil(sums)
// Calling with a non-existent file that was in git history returns no error.
_, err = gitutil.FileHistory(pathB, repo)
assert.Nil(err)
}

245
build/sync/plan/plan.go Normal file
View File

@ -0,0 +1,245 @@
// Package plan handles the synchronization plan.
//
// Each synchronization plan is a set of checks and actions to perform on specified paths
// that will result in the "plugin" repository being updated.
package plan
import (
"encoding/json"
"fmt"
"os"
"sort"
)
// Plan defines the plan for synchronizing a target and a source directory.
type Plan struct {
Checks []Check `json:"checks"`
// Each set of paths has multiple actions associated, each a fallback for the one
// previous to it.
Actions []ActionSet
}
// UnmarshalJSON implements the `json.Unmarshaler` interface.
func (p *Plan) UnmarshalJSON(raw []byte) error {
var t jsonPlan
if err := json.Unmarshal(raw, &t); err != nil {
return err
}
p.Checks = make([]Check, len(t.Checks))
for i, check := range t.Checks {
c, err := parseCheck(check.Type, check.Params)
if err != nil {
return fmt.Errorf("failed to parse check %q: %v", check.Type, err)
}
p.Checks[i] = c
}
if len(t.Actions) > 0 {
p.Actions = make([]ActionSet, len(t.Actions))
}
for i, actionSet := range t.Actions {
var err error
pathActions := make([]Action, len(actionSet.Actions))
for i, action := range actionSet.Actions {
var actionConditions []Check
if len(action.Conditions) > 0 {
actionConditions = make([]Check, len(action.Conditions))
}
for j, check := range action.Conditions {
actionConditions[j], err = parseCheck(check.Type, check.Params)
if err != nil {
return err
}
}
pathActions[i], err = parseAction(action.Type, action.Params, actionConditions)
if err != nil {
return err
}
}
p.Actions[i] = ActionSet{
Paths: actionSet.Paths,
Actions: pathActions,
}
}
return nil
}
// Execute executes the synchronization plan.
func (p *Plan) Execute(c Setup) error {
c.Logf("running pre-checks")
for _, check := range p.Checks {
err := check.Check("", c) // For pre-sync checks, the path is ignored.
if err != nil {
return fmt.Errorf("failed check: %v", err)
}
}
result := []pathResult{}
c.Logf("running actions")
for _, actions := range p.Actions {
PATHS_LOOP:
for _, path := range actions.Paths {
c.Logf("syncing path %q", path)
ACTIONS_LOOP:
for i, action := range actions.Actions {
c.Logf("running action for path %q", path)
err := action.Check(path, c)
if IsCheckFail(err) {
c.Logf("check failed, not running action: %v", err)
// If a check for an action fails, we switch to
// the next action associated with the path.
if i == len(actions.Actions)-1 { // no actions to fallback to.
c.Logf("path %q not handled - no more fallbacks", path)
result = append(result,
pathResult{
Path: path,
Status: statusFailed,
Message: fmt.Sprintf("check failed, %s", err.Error()),
})
}
continue ACTIONS_LOOP
} else if err != nil {
c.LogErrorf("unexpected error when running check: %v", err)
return fmt.Errorf("failed to run checks for action: %v", err)
}
err = action.Run(path, c)
if err != nil {
c.LogErrorf("action failed: %v", err)
return fmt.Errorf("action failed: %v", err)
}
c.Logf("path %q sync'ed successfully", path)
result = append(result,
pathResult{
Path: path,
Status: statusUpdated,
})
continue PATHS_LOOP
}
}
}
// Print execution result.
sort.SliceStable(result, func(i, j int) bool { return result[i].Path < result[j].Path })
for _, res := range result {
if res.Message != "" {
fmt.Fprintf(os.Stdout, "%s\t%s: %s\n", res.Status, res.Path, res.Message)
} else {
fmt.Fprintf(os.Stdout, "%s\t%s\n", res.Status, res.Path)
}
}
return nil
}
// Check returns an error if the condition fails.
type Check interface {
Check(string, Setup) error
}
// ActionSet is a set of actions along with a set of paths to
// perform those actions on.
type ActionSet struct {
Paths []string
Actions []Action
}
// Action runs the defined action.
type Action interface {
// Run performs the action on the specified path.
Run(string, Setup) error
// Check runs checks associated with the action
// before running it.
Check(string, Setup) error
}
// jsonPlan is used to unmarshal Plan structures.
type jsonPlan struct {
Checks []struct {
Type string `json:"type"`
Params json.RawMessage `json:"params,omitempty"`
}
Actions []struct {
Paths []string `json:"paths"`
Actions []struct {
Type string `json:"type"`
Params json.RawMessage `json:"params,omitempty"`
Conditions []struct {
Type string `json:"type"`
Params json.RawMessage `json:"params"`
}
}
}
}
func parseCheck(checkType string, rawParams json.RawMessage) (Check, error) {
var c Check
var params interface{}
switch checkType {
case "repo_is_clean":
tc := RepoIsCleanChecker{}
params = &tc.Params
c = &tc
case "exists":
tc := PathExistsChecker{}
params = &tc.Params
c = &tc
case "file_unaltered":
tc := FileUnalteredChecker{}
params = &tc.Params
c = &tc
default:
return nil, fmt.Errorf("unknown checker type %q", checkType)
}
if len(rawParams) > 0 {
err := json.Unmarshal(rawParams, params)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal params for %s: %v", checkType, err)
}
}
return c, nil
}
func parseAction(actionType string, rawParams json.RawMessage, checks []Check) (Action, error) {
var a Action
var params interface{}
switch actionType {
case "overwrite_file":
ta := OverwriteFileAction{}
ta.Conditions = checks
params = &ta.Params
a = &ta
case "overwrite_directory":
ta := OverwriteDirectoryAction{}
ta.Conditions = checks
params = &ta.Params
a = &ta
default:
return nil, fmt.Errorf("unknown action type %q", actionType)
}
if len(rawParams) > 0 {
err := json.Unmarshal(rawParams, params)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal params for %s: %v", actionType, err)
}
}
return a, nil
}
// pathResult contains the result of synchronizing a path.
type pathResult struct {
Path string
Status status
Message string
}
type status string
const (
statusUpdated status = "UPDATED"
statusFailed status = "FAILED"
)

View File

@ -0,0 +1,253 @@
package plan_test
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
)
func TestUnmarshalPlan(t *testing.T) {
assert := assert.New(t)
rawJSON := []byte(`
{
"checks": [
{"type": "repo_is_clean", "params": {"repo": "template"}}
],
"actions": [
{
"paths": ["abc"],
"actions": [{
"type": "overwrite_file",
"params": {"create": true},
"conditions": [{
"type": "exists",
"params": {"repo": "plugin"}
}]
}]
}
]
}`)
var p plan.Plan
err := json.Unmarshal(rawJSON, &p)
assert.Nil(err)
expectedCheck := plan.RepoIsCleanChecker{}
expectedCheck.Params.Repo = "template"
expectedAction := plan.OverwriteFileAction{}
expectedAction.Params.Create = true
expectedActionCheck := plan.PathExistsChecker{}
expectedActionCheck.Params.Repo = "plugin"
expectedAction.Conditions = []plan.Check{&expectedActionCheck}
expected := plan.Plan{
Checks: []plan.Check{&expectedCheck},
Actions: []plan.ActionSet{{
Paths: []string{"abc"},
Actions: []plan.Action{
&expectedAction,
},
}},
}
assert.Equal(expected, p)
}
type mockCheck struct {
returnErr error
calledWith string // Path parameter the check was called with.
}
// Check implements the plan.Check interface.
func (m *mockCheck) Check(path string, c plan.Setup) error {
m.calledWith = path
return m.returnErr
}
type mockAction struct {
runErr error // Error to be returned by Run.
checkErr error // Error to be returned by Check.
calledWith string
}
// Check implements plan.Action interface.
func (m *mockAction) Check(path string, c plan.Setup) error {
return m.checkErr
}
// Run implements plan.Action interface.
func (m *mockAction) Run(path string, c plan.Setup) error {
m.calledWith = path
return m.runErr
}
// TestRunPlanSuccessfully tests a successful execution of a sync plan.
func TestRunPlanSuccessfully(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
preCheck := &mockCheck{}
action1 := &mockAction{}
action2 := &mockAction{}
p := &plan.Plan{
Checks: []plan.Check{preCheck},
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.Nil(err)
assert.Equal("", preCheck.calledWith)
assert.Equal("somepath", action1.calledWith)
assert.Equal("", action2.calledWith) // second action was not called.
}
// TestRunPlanPreCheckFail checks the scenario where a sync plan precheck
// fails, aborting the whole operation.
func TestRunPlanPreCheckFail(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
preCheck := &mockCheck{returnErr: plan.CheckFailf("check failed")}
action1 := &mockAction{}
action2 := &mockAction{}
p := &plan.Plan{
Checks: []plan.Check{preCheck},
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.EqualError(err, "failed check: check failed")
assert.Equal("", preCheck.calledWith)
// None of the actions were executed.
assert.Equal("", action1.calledWith)
assert.Equal("", action2.calledWith)
}
// TestRunPlanActionCheckFails tests the situation where an action's
// check returns a recoverable error, forcing the plan to execute the fallback action.
func TestRunPlanActionCheckFails(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
action1 := &mockAction{checkErr: plan.CheckFailf("action check failed")}
action2 := &mockAction{}
p := &plan.Plan{
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.Nil(err)
assert.Equal("", action1.calledWith) // First action was not run.
assert.Equal("somepath", action2.calledWith) // Second action was run.
}
// TestRunPlanNoFallbacks tests the case where an action's check fails,
// but there are not more fallback actions for that path.
func TestRunPlanNoFallbacks(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
action1 := &mockAction{checkErr: plan.CheckFailf("fail")}
action2 := &mockAction{checkErr: plan.CheckFailf("fail")}
p := &plan.Plan{
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.Nil(err)
// both actions were not executed.
assert.Equal("", action1.calledWith)
assert.Equal("", action2.calledWith)
}
// TestRunPlanCheckError tests the scenario where a plan check fails with
// an unexpected error. Plan execution is aborted.
func TestRunPlanCheckError(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
preCheck := &mockCheck{returnErr: fmt.Errorf("fail")}
action1 := &mockAction{}
action2 := &mockAction{}
p := &plan.Plan{
Checks: []plan.Check{preCheck},
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.EqualError(err, "failed check: fail")
assert.Equal("", preCheck.calledWith)
// Actions were not run.
assert.Equal("", action1.calledWith)
assert.Equal("", action2.calledWith)
}
// TestRunPlanActionError tests the scenario where an action fails,
// aborting the whole sync process.
func TestRunPlanActionError(t *testing.T) {
assert := assert.New(t)
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
preCheck := &mockCheck{}
action1 := &mockAction{runErr: fmt.Errorf("fail")}
action2 := &mockAction{}
p := &plan.Plan{
Checks: []plan.Check{preCheck},
Actions: []plan.ActionSet{{
Paths: []string{"somepath"},
Actions: []plan.Action{
action1,
action2,
},
}},
}
err := p.Execute(setup)
assert.EqualError(err, "action failed: fail")
assert.Equal("", preCheck.calledWith)
assert.Equal("somepath", action1.calledWith)
assert.Equal("", action2.calledWith) // second action was not called.
}

80
build/sync/plan/setup.go Normal file
View File

@ -0,0 +1,80 @@
package plan
import (
"fmt"
"os"
"path/filepath"
git "github.com/go-git/go-git/v5"
)
// RepoID identifies a repository - either plugin or template.
type RepoID string
const (
// SourceRepo is the id of the template repository (source).
SourceRepo RepoID = "source"
// TargetRepo is the id of the plugin repository (target).
TargetRepo RepoID = "target"
)
// Setup contains information about both parties
// in the sync: the plugin repository being updated
// and the source of the update - the template repo.
type Setup struct {
Source RepoSetup
Target RepoSetup
VerboseLogging bool
}
// Logf logs the provided message.
// If verbose output is not enabled, the message will not be printed.
func (c Setup) Logf(tpl string, args ...interface{}) {
if c.VerboseLogging {
fmt.Fprintf(os.Stderr, tpl+"\n", args...)
}
}
// LogErrorf logs the provided error message.
func (c Setup) LogErrorf(tpl string, args ...interface{}) {
fmt.Fprintf(os.Stderr, tpl+"\n", args...)
}
// GetRepo is a helper to get the required repo setup.
// If the target parameter is not one of "plugin" or "template",
// the function panics.
func (c Setup) GetRepo(r RepoID) RepoSetup {
switch r {
case TargetRepo:
return c.Target
case SourceRepo:
return c.Source
default:
panic(fmt.Sprintf("cannot get repository setup %q", r))
}
}
// PathInRepo returns the full path of a file in the specified repository.
func (c Setup) PathInRepo(repo RepoID, path string) string {
r := c.GetRepo(repo)
return filepath.Join(r.Path, path)
}
// RepoSetup contains relevant information
// about a single repository (either source or target).
type RepoSetup struct {
Git *git.Repository
Path string
}
// GetRepoSetup returns the repository setup for the specified path.
func GetRepoSetup(path string) (RepoSetup, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return RepoSetup{}, fmt.Errorf("failed to access git repository at %q: %v", path, err)
}
return RepoSetup{
Git: repo,
Path: path,
}, nil
}

1
build/sync/plan/testdata/a vendored Normal file
View File

@ -0,0 +1 @@
a

1
build/sync/plan/testdata/b/c vendored Normal file
View File

@ -0,0 +1 @@
c

13
go.mod Normal file
View File

@ -0,0 +1,13 @@
module github.com/larkox/mattermost-plugin-badges
go 1.12
require (
github.com/gorilla/mux v1.8.0
github.com/mattermost/mattermost-plugin-api v0.0.14
github.com/mattermost/mattermost-server/v5 v5.3.2-0.20210422214809-ff657bfdef24
github.com/pkg/errors v0.9.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.7.0
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)

1558
go.sum Normal file

File diff suppressed because it is too large Load Diff

33
plugin.json Normal file
View File

@ -0,0 +1,33 @@
{
"id": "com.mattermost.badges",
"name": "Badges for Mattermost",
"description": "This plugin add badges support to Mattermost.",
"homepage_url": "https://github.com/larkox/mattermost-plugin-badges",
"support_url": "https://github.com/larkox/mattermost-plugin-badges/issues",
"release_notes_url": "https://github.com/larkox/mattermost-plugin-badges/releases/tag/v0.2.1",
"icon_path": "assets/starter-template-icon.svg",
"version": "0.2.1",
"min_server_version": "5.12.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"
}
},
"webapp": {
"bundle_path": "webapp/dist/main.js"
},
"settings_schema": {
"header": "",
"footer": "",
"settings": [
{
"key": "BadgesAdmin",
"display_name": "Badges admin:",
"type": "text",
"help_text": "This user will be considered as an admin for the badges plugin. They can create types, and modify and grant any badge."
}
]
}
}

BIN
public/app-bar-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

1
public/hello.html Normal file
View File

@ -0,0 +1 @@
Hello from the static files public folder for the com.mattermost.plugin-starter-template plugin!

2
server/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
coverage.txt
dist

1047
server/api.go Normal file

File diff suppressed because it is too large Load Diff

887
server/command.go Normal file
View File

@ -0,0 +1,887 @@
package main
import (
"errors"
"fmt"
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
commandparser "github.com/larkox/mattermost-plugin-badges/server/command_parser"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/plugin"
"github.com/spf13/pflag"
)
func getHelp() string {
return `Available Commands:
`
}
func (p *Plugin) getCommand() *model.Command {
return &model.Command{
Trigger: "badges",
DisplayName: "Badges Bot",
Description: "Badges",
AutoComplete: true,
AutoCompleteDesc: "Available commands:",
AutoCompleteHint: "[command]",
AutocompleteData: p.getAutocompleteData(),
}
}
func (p *Plugin) postCommandResponse(args *model.CommandArgs, text string) {
post := &model.Post{
UserId: p.BotUserID,
ChannelId: args.ChannelId,
Message: text,
}
p.mm.Post.SendEphemeralPost(args.UserId, post)
}
func commandError(text string) (bool, *model.CommandResponse, error) {
return true, &model.CommandResponse{}, errors.New(text)
}
// ExecuteCommand executes a given command and returns a command response.
func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
stringArgs := commandparser.Parse(args.Command)
lengthOfArgs := len(stringArgs)
restOfArgs := []string{}
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
if lengthOfArgs == 1 {
p.postCommandResponse(args, getHelp())
return &model.CommandResponse{}, nil
}
command := stringArgs[1]
if lengthOfArgs > 2 {
restOfArgs = stringArgs[2:]
}
switch command {
case "test-clean":
handler = p.runClean
case "grant":
handler = p.runGrant
case "edit":
handler = p.runEdit
case "create":
handler = p.runCreate
case "subscription":
handler = p.runSubscription
default:
p.postCommandResponse(args, getHelp())
return &model.CommandResponse{}, nil
}
isUserError, resp, err := handler(restOfArgs, args)
if err != nil {
if isUserError {
p.postCommandResponse(args, fmt.Sprintf("__Error: %s__", err.Error()))
} else {
p.mm.Log.Error(err.Error())
p.postCommandResponse(args, "An unknown error occurred. Please talk to your system administrator for help.")
}
}
if resp != nil {
return resp, nil
}
return &model.CommandResponse{}, nil
}
func (p *Plugin) runClean(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
user, err := p.mm.User.Get(extra.UserId)
if err != nil {
return false, &model.CommandResponse{Text: "Cannot get user."}, nil
}
if !user.IsSystemAdmin() {
return false, &model.CommandResponse{Text: "Only a system admin can clean the badges database."}, nil
}
_ = p.mm.KV.DeleteAll()
return false, &model.CommandResponse{Text: "Clean"}, nil
}
func (p *Plugin) runCreate(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
lengthOfArgs := len(args)
restOfArgs := []string{}
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
if lengthOfArgs == 0 {
return false, &model.CommandResponse{Text: "Specify what you want to create."}, nil
}
command := args[0]
if lengthOfArgs > 1 {
restOfArgs = args[1:]
}
switch command {
case "badge":
handler = p.runCreateBadge
case "type":
handler = p.runCreateType
default:
return false, &model.CommandResponse{Text: "You can create either badge or type"}, nil
}
return handler(restOfArgs, extra)
}
func (p *Plugin) runCreateBadge(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
u, err := p.mm.User.Get(extra.UserId)
if err != nil {
return commandError(err.Error())
}
typeSuggestions, err := p.filterCreateBadgeTypes(u)
if err != nil {
return commandError(err.Error())
}
typeOptions := []*model.PostActionOptions{}
for _, typeSuggestion := range typeSuggestions {
id := string(typeSuggestion.ID)
typeOptions = append(typeOptions, &model.PostActionOptions{Text: typeSuggestion.Name, Value: id})
}
if len(typeOptions) == 0 {
return commandError("You cannot create badges from any type.")
}
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathCreateBadge,
Dialog: model.Dialog{
Title: "Create badge",
SubmitLabel: "Create",
Elements: []model.DialogElement{
{
DisplayName: "Name",
Type: "text",
Name: DialogFieldBadgeName,
MaxLength: badgesmodel.NameMaxLength,
},
{
DisplayName: "Description",
Type: "text",
Name: DialogFieldBadgeDescription,
MaxLength: badgesmodel.DescriptionMaxLength,
},
{
DisplayName: "Image",
Type: "text",
Name: DialogFieldBadgeImage,
HelpText: "Insert a emoticon name",
},
{
DisplayName: "Type",
Type: "select",
Name: DialogFieldBadgeType,
Options: typeOptions,
},
{
DisplayName: "Multiple",
Type: "bool",
Name: DialogFieldBadgeMultiple,
HelpText: "Whether the badge can be granted multiple times",
Optional: true,
},
},
},
})
if err != nil {
return commandError(err.Error())
}
return false, &model.CommandResponse{}, nil
}
func (p *Plugin) runEdit(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
lengthOfArgs := len(args)
restOfArgs := []string{}
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
if lengthOfArgs == 0 {
return false, &model.CommandResponse{Text: "Specify what you want to create."}, nil
}
command := args[0]
if lengthOfArgs > 1 {
restOfArgs = args[1:]
}
switch command {
case "badge":
handler = p.runEditBadge
case "type":
handler = p.runEditType
default:
return false, &model.CommandResponse{Text: "You can create either badge or type"}, nil
}
return handler(restOfArgs, extra)
}
func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
u, err := p.mm.User.Get(extra.UserId)
if err != nil {
return commandError(err.Error())
}
var badgeIDStr string
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
fs.StringVar(&badgeIDStr, "id", "", "ID of the badge")
if err = fs.Parse(args); err != nil {
return commandError(err.Error())
}
if badgeIDStr == "" {
return commandError("You must set the badge ID")
}
badge, err := p.store.GetBadge(badgesmodel.BadgeID(badgeIDStr))
if err != nil {
return commandError(err.Error())
}
if !canEditBadge(u, p.badgeAdminUserID, badge) {
return commandError("you cannot edit this badge")
}
typeSuggestions, err := p.filterCreateBadgeTypes(u)
if err != nil {
return commandError(err.Error())
}
typeOptions := []*model.PostActionOptions{}
for _, typeSuggestion := range typeSuggestions {
id := string(typeSuggestion.ID)
typeOptions = append(typeOptions, &model.PostActionOptions{Text: typeSuggestion.Name, Value: id})
}
if len(typeOptions) == 0 {
return commandError("You cannot create badges from any type.")
}
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathEditBadge,
Dialog: model.Dialog{
Title: "Create badge",
SubmitLabel: "Edit",
State: string(badge.ID),
Elements: []model.DialogElement{
{
DisplayName: "Name",
Type: "text",
Name: DialogFieldBadgeName,
MaxLength: badgesmodel.NameMaxLength,
Default: badge.Name,
},
{
DisplayName: "Description",
Type: "text",
Name: DialogFieldBadgeDescription,
MaxLength: badgesmodel.DescriptionMaxLength,
Default: badge.Description,
},
{
DisplayName: "Image",
Type: "text",
Name: DialogFieldBadgeImage,
HelpText: "Insert a emoticon name",
Default: badge.Image,
},
{
DisplayName: "Type",
Type: "select",
Name: DialogFieldBadgeType,
Options: typeOptions,
Default: string(badge.Type),
},
{
DisplayName: "Multiple",
Type: "bool",
Name: DialogFieldBadgeMultiple,
HelpText: "Whether the badge can be granted multiple times",
Optional: true,
Default: getBooleanString(badge.Multiple),
},
{
DisplayName: "Delete badge",
Type: "bool",
Name: DialogFieldBadgeDelete,
HelpText: "WARNING: Checking this will remove this badge permanently.",
Optional: true,
},
},
},
})
if err != nil {
return commandError(err.Error())
}
return false, &model.CommandResponse{}, nil
}
func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
u, err := p.mm.User.Get(extra.UserId)
if err != nil {
return commandError(err.Error())
}
if !canCreateType(u, p.badgeAdminUserID, false) {
return commandError("You have no permissions to edit a badge type.")
}
var badgeTypeStr string
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
fs.StringVar(&badgeTypeStr, "type", "", "ID of the type")
if err = fs.Parse(args); err != nil {
return commandError(err.Error())
}
if badgeTypeStr == "" {
return commandError("You must provide a type id")
}
typeDefinition, err := p.store.GetType(badgesmodel.BadgeType(badgeTypeStr))
if err != nil {
return commandError(err.Error())
}
if !canEditType(u, p.badgeAdminUserID, typeDefinition) {
return commandError("you cannot edit this type")
}
canGrantAllowList := ""
for uID, allowed := range typeDefinition.CanGrant.AllowList {
if !allowed {
continue
}
var allowedUser *model.User
allowedUser, err = p.mm.User.Get(uID)
if err != nil {
continue
}
if canGrantAllowList == "" {
canGrantAllowList += allowedUser.Username
continue
}
canGrantAllowList += ", " + allowedUser.Username
}
canCreateAllowList := ""
for uID, allowed := range typeDefinition.CanCreate.AllowList {
if !allowed {
continue
}
var allowedUser *model.User
allowedUser, err = p.mm.User.Get(uID)
if err != nil {
continue
}
if canCreateAllowList == "" {
canCreateAllowList += allowedUser.Username
continue
}
canCreateAllowList += ", " + allowedUser.Username
}
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathEditType,
Dialog: model.Dialog{
Title: "Edit type",
SubmitLabel: "Edit",
State: badgeTypeStr,
Elements: []model.DialogElement{
{
DisplayName: "Name",
Type: "text",
Name: DialogFieldTypeName,
MaxLength: badgesmodel.NameMaxLength,
Default: typeDefinition.Name,
},
{
DisplayName: "Everyone can create badge",
Type: "bool",
Name: DialogFieldTypeEveryoneCanCreate,
HelpText: "Whether any user can create a badge of this type",
Optional: true,
Default: getBooleanString(typeDefinition.CanCreate.Everyone),
},
{
DisplayName: "Can create allowlist",
Type: "text",
Name: DialogFieldTypeAllowlistCanCreate,
HelpText: "Fill the usernames separated by comma (,) of the people that can create badges of this type.",
Placeholder: "user-1, user-2, user-3",
Optional: true,
Default: canCreateAllowList,
},
{
DisplayName: "Everyone can grant badge",
Type: "bool",
Name: DialogFieldTypeEveryoneCanGrant,
HelpText: "Whether any user can grant a badge of this type",
Optional: true,
Default: getBooleanString(typeDefinition.CanGrant.Everyone),
},
{
DisplayName: "Can grant allowlist",
Type: "text",
Name: DialogFieldTypeAllowlistCanGrant,
HelpText: "Fill the usernames separated by comma (,) of the people that can grant badges of this type.",
Placeholder: "user-1, user-2, user-3",
Optional: true,
Default: canGrantAllowList,
},
{
DisplayName: "Remove type",
Type: "bool",
Name: DialogFieldTypeDelete,
HelpText: "WARNING: checking this will remove this type and all associated badges permanently.",
Optional: true,
},
},
},
})
if err != nil {
return commandError(err.Error())
}
return false, &model.CommandResponse{}, nil
}
func (p *Plugin) runCreateType(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
u, err := p.mm.User.Get(extra.UserId)
if err != nil {
return commandError(err.Error())
}
if !canCreateType(u, p.badgeAdminUserID, false) {
return commandError("You have no permissions to create a badge type.")
}
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathCreateType,
Dialog: model.Dialog{
Title: "Create type",
SubmitLabel: "Create",
Elements: []model.DialogElement{
{
DisplayName: "Name",
Type: "text",
Name: DialogFieldTypeName,
MaxLength: badgesmodel.NameMaxLength,
},
{
DisplayName: "Everyone can create badge",
Type: "bool",
Name: DialogFieldTypeEveryoneCanCreate,
HelpText: "Whether any user can create a badge of this type",
Optional: true,
},
{
DisplayName: "Can create allowlist",
Type: "text",
Name: DialogFieldTypeAllowlistCanCreate,
HelpText: "Fill the usernames separated by comma (,) of the people that can create badges of this type.",
Placeholder: "user-1, user-2, user-3",
Optional: true,
},
{
DisplayName: "Everyone can grant badge",
Type: "bool",
Name: DialogFieldTypeEveryoneCanGrant,
HelpText: "Whether any user can grant a badge of this type",
Optional: true,
},
{
DisplayName: "Can grant allowlist",
Type: "text",
Name: DialogFieldTypeAllowlistCanGrant,
HelpText: "Fill the usernames separated by comma (,) of the people that can grant badges of this type.",
Placeholder: "user-1, user-2, user-3",
Optional: true,
},
},
},
})
if err != nil {
return commandError(err.Error())
}
return false, &model.CommandResponse{}, nil
}
func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
badgeStr := ""
username := ""
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
fs.StringVar(&badgeStr, "badge", "", "ID of the badge")
fs.StringVar(&username, "user", "", "Username to grant to")
if err := fs.Parse(args); err != nil {
return commandError(err.Error())
}
if username != "" && badgeStr != "" {
if username[0] == '@' {
username = username[1:]
}
granter, err := p.mm.User.Get(extra.UserId)
if err != nil {
return commandError(err.Error())
}
badge, err := p.store.GetBadge(badgesmodel.BadgeID(badgeStr))
if err != nil {
return commandError(err.Error())
}
badgeType, err := p.store.GetType(badge.Type)
if err != nil {
return commandError(err.Error())
}
if !canGrantBadge(granter, p.badgeAdminUserID, badge, badgeType) {
return commandError("you have no permissions to grant this badge")
}
user, err := p.mm.User.GetByUsername(username)
if err != nil {
return commandError(err.Error())
}
shouldNotify, err := p.store.GrantBadge(badgesmodel.BadgeID(badgeStr), user.Id, extra.UserId, "")
if err != nil {
return commandError(err.Error())
}
if shouldNotify {
p.notifyGrant(badgesmodel.BadgeID(badgeStr), extra.UserId, user, false, "", "")
}
p.postCommandResponse(extra, "Granted")
return false, &model.CommandResponse{}, nil
}
elements := []model.DialogElement{}
stateText := ""
introductionText := ""
if username != "" {
if username[0] == '@' {
username = username[1:]
}
user, err := p.mm.User.GetByUsername(username)
if err != nil {
return commandError(err.Error())
}
introductionText = "Grant badge to @" + username
stateText = user.Id
}
if stateText == "" {
elements = append(elements, model.DialogElement{
DisplayName: "User",
Type: "select",
Name: DialogFieldUser,
DataSource: "users",
})
}
actingUser, err := p.mm.User.Get(extra.UserId)
if err != nil {
return commandError(err.Error())
}
options := []*model.PostActionOptions{}
grantableBadges, err := p.filterGrantBadges(actingUser)
if err != nil {
return commandError(err.Error())
}
for _, badge := range grantableBadges {
options = append(options, &model.PostActionOptions{Text: badge.Name, Value: string(badge.ID)})
}
badgeElement := model.DialogElement{
DisplayName: "Badge",
Type: "select",
Name: DialogFieldBadge,
Options: options,
}
if badgeStr != "" {
found := false
for _, badge := range grantableBadges {
if badgeStr == string(badge.ID) {
found = true
break
}
}
if !found {
return commandError("You cannot grant that badge")
}
badgeElement.Default = badgeStr
}
elements = append(elements, badgeElement)
elements = append(elements, model.DialogElement{
DisplayName: "Reason",
Name: DialogFieldGrantReason,
Optional: true,
HelpText: "Reason why you are granting this badge. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions).",
Type: "text",
})
elements = append(elements, model.DialogElement{
DisplayName: "Notify on this channel",
Name: DialogFieldNotifyHere,
Type: "bool",
HelpText: "If you mark this, the bot will send a message to this channel notifying that you granted this badge to this person.",
Optional: true,
})
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathGrant,
Dialog: model.Dialog{
Title: "Grant badge",
IntroductionText: introductionText,
SubmitLabel: "Grant",
Elements: elements,
State: stateText,
},
})
if err != nil {
return commandError(err.Error())
}
return false, &model.CommandResponse{}, nil
}
func (p *Plugin) runSubscription(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
lengthOfArgs := len(args)
restOfArgs := []string{}
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
if lengthOfArgs == 0 {
return false, &model.CommandResponse{Text: "Specify what you want to do."}, nil
}
command := args[0]
if lengthOfArgs > 1 {
restOfArgs = args[1:]
}
switch command {
case "create":
handler = p.runCreateSubscription
case "remove":
handler = p.runDeleteSubscription
default:
return false, &model.CommandResponse{Text: "You can either create or delete subscriptions"}, nil
}
return handler(restOfArgs, extra)
}
func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
typeStr := ""
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
fs.StringVar(&typeStr, "type", "", "ID of the badge")
if err := fs.Parse(args); err != nil {
return commandError(err.Error())
}
actingUser, err := p.mm.User.Get(extra.UserId)
if err != nil {
return commandError(err.Error())
}
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
return commandError("You cannot create subscriptions")
}
if typeStr != "" {
err = p.store.AddSubscription(badgesmodel.BadgeType(typeStr), extra.ChannelId)
if err != nil {
return commandError(err.Error())
}
p.postCommandResponse(extra, "Granted")
return false, &model.CommandResponse{}, nil
}
options := []*model.PostActionOptions{}
typesDefinitions, err := p.filterEditTypes(actingUser)
if err != nil {
return commandError(err.Error())
}
for _, typeDefinition := range typesDefinitions {
options = append(options, &model.PostActionOptions{Text: typeDefinition.Name, Value: string(typeDefinition.ID)})
}
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathCreateSubscription,
Dialog: model.Dialog{
Title: "Create subscription",
IntroductionText: "Introduce the badge type you want to subscribe to this channel.",
SubmitLabel: "Add",
Elements: []model.DialogElement{
{
DisplayName: "Type",
Type: "select",
Name: DialogFieldBadgeType,
Options: options,
},
},
},
})
if err != nil {
return commandError(err.Error())
}
return false, &model.CommandResponse{}, nil
}
func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
typeStr := ""
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
fs.StringVar(&typeStr, "type", "", "ID of the badge")
if err := fs.Parse(args); err != nil {
return commandError(err.Error())
}
actingUser, err := p.mm.User.Get(extra.UserId)
if err != nil {
return commandError(err.Error())
}
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
return commandError("You cannot create subscriptions")
}
if typeStr != "" {
err = p.store.RemoveSubscriptions(badgesmodel.BadgeType(typeStr), extra.ChannelId)
if err != nil {
return commandError(err.Error())
}
p.postCommandResponse(extra, "Removed")
return false, &model.CommandResponse{}, nil
}
options := []*model.PostActionOptions{}
typesDefinitions, err := p.store.GetChannelSubscriptions(extra.ChannelId)
if err != nil {
return commandError(err.Error())
}
for _, typeDefinition := range typesDefinitions {
options = append(options, &model.PostActionOptions{Text: typeDefinition.Name, Value: string(typeDefinition.ID)})
}
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
TriggerId: extra.TriggerId,
URL: p.getDialogURL() + DialogPathDeleteSubscription,
Dialog: model.Dialog{
Title: "Delete subscription",
IntroductionText: "Introduce the badge type you want to remove from this channel.",
SubmitLabel: "Remove",
Elements: []model.DialogElement{
{
DisplayName: "Type",
Type: "select",
Name: DialogFieldBadgeType,
Options: options,
},
},
},
})
if err != nil {
return commandError(err.Error())
}
return false, &model.CommandResponse{}, nil
}
func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
badges := model.NewAutocompleteData("badges", "[command]", "Available commands: grant")
grant := model.NewAutocompleteData("grant", "--user @username --badge id", "Grant a badge to a user")
grant.AddNamedDynamicListArgument("badge", "--badge badgeID", getAutocompletePath(AutocompletePathBadgeSuggestions), true)
grant.AddNamedTextArgument("user", "User to grant the badge to", "--user @username", "", true)
badges.AddCommand(grant)
create := model.NewAutocompleteData("create", "badge | type", "Create a badge or a type")
badge := model.NewAutocompleteData(
"badge",
"",
"Create a badge",
)
create.AddCommand(badge)
createType := model.NewAutocompleteData(
"type",
"",
"Create a badge type",
)
create.AddCommand(createType)
badges.AddCommand(create)
edit := model.NewAutocompleteData("edit", "badge | type", "Edit a badge or a type")
editBadge := model.NewAutocompleteData(
"badge",
"",
"Edit a badge",
)
editBadge.AddNamedDynamicListArgument("id", "--id badgeID", getAutocompletePath(AutocompletePathEditBadgeSuggestions), true)
edit.AddCommand(editBadge)
editType := model.NewAutocompleteData(
"type",
"",
"Edit a badge type",
)
editType.AddNamedDynamicListArgument("type", "--type typeID", getAutocompletePath(AutocompletePathEditTypeSuggestions), true)
edit.AddCommand(editType)
badges.AddCommand(edit)
subscription := model.NewAutocompleteData("subscription", "create | remove", "Manage this channel subscriptions")
createSubscription := model.NewAutocompleteData(
"create",
"",
"Create a subscription",
)
subscription.AddCommand(createSubscription)
deleteSubscription := model.NewAutocompleteData(
"remove",
"",
"Remove a subscription",
)
subscription.AddCommand(deleteSubscription)
badges.AddCommand(subscription)
return badges
}
func getAutocompletePath(path string) string {
return "plugins/" + manifest.Id + AutocompletePath + path
}

View File

@ -0,0 +1,76 @@
package commandparser
import "unicode"
const (
stateLooking = "looking"
stateQuoted = "quoted"
stateEscape = "escape"
stateFilling = "filling"
)
func Parse(in string) []string {
nextArg := ""
out := []string{}
state := stateLooking
for _, c := range in {
switch state {
case stateLooking:
if unicode.IsSpace(c) {
continue
}
if c == '"' {
state = stateQuoted
continue
}
state = stateFilling
nextArg += string(c)
case stateFilling:
if unicode.IsSpace(c) {
state = stateLooking
out = append(out, nextArg)
nextArg = ""
continue
}
nextArg += string(c)
case stateQuoted:
if c == '"' {
state = stateLooking
out = append(out, nextArg)
nextArg = ""
continue
}
if c == '\\' {
state = stateEscape
continue
}
nextArg += string(c)
case stateEscape:
if c == '"' {
state = stateQuoted
nextArg += string(c)
continue
}
// Not escaping quotes, so we readd the backslash
nextArg += string('\\')
nextArg += string(c)
default:
panic("unexpected state")
}
}
if state == stateEscape {
nextArg += string('\\')
}
if state == stateFilling {
out = append(out, nextArg)
}
return out
}

93
server/configuration.go Normal file
View File

@ -0,0 +1,93 @@
package main
import (
"reflect"
"github.com/pkg/errors"
)
// configuration captures the plugin's external configuration as exposed in the Mattermost server
// configuration, as well as values computed from the configuration. Any public fields will be
// deserialized from the Mattermost server configuration in OnConfigurationChange.
//
// As plugins are inherently concurrent (hooks being called asynchronously), and the plugin
// configuration can change at any time, access to the configuration must be synchronized. The
// strategy used in this plugin is to guard a pointer to the configuration, and clone the entire
// struct whenever it changes. You may replace this with whatever strategy you choose.
//
// If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep
// copy appropriate for your types.
type configuration struct {
BadgesAdmin string
}
// Clone shallow copies the configuration. Your implementation may require a deep copy if
// your configuration has reference types.
func (c *configuration) Clone() *configuration {
var clone = *c
return &clone
}
// getConfiguration retrieves the active configuration under lock, making it safe to use
// concurrently. The active configuration may change underneath the client of this method, but
// the struct returned by this API call is considered immutable.
func (p *Plugin) getConfiguration() *configuration {
p.configurationLock.RLock()
defer p.configurationLock.RUnlock()
if p.configuration == nil {
return &configuration{}
}
return p.configuration
}
// setConfiguration replaces the active configuration under lock.
//
// Do not call setConfiguration while holding the configurationLock, as sync.Mutex is not
// reentrant. In particular, avoid using the plugin API entirely, as this may in turn trigger a
// hook back into the plugin. If that hook attempts to acquire this lock, a deadlock may occur.
//
// This method panics if setConfiguration is called with the existing configuration. This almost
// certainly means that the configuration was modified without being cloned and may result in
// an unsafe access.
func (p *Plugin) setConfiguration(configuration *configuration) {
p.configurationLock.Lock()
defer p.configurationLock.Unlock()
if configuration != nil && p.configuration == configuration {
// Ignore assignment if the configuration struct is empty. Go will optimize the
// allocation for same to point at the same memory address, breaking the check
// above.
if reflect.ValueOf(*configuration).NumField() == 0 {
return
}
panic("setConfiguration called with the existing configuration")
}
p.configuration = configuration
}
// OnConfigurationChange is invoked when configuration changes may have been made.
func (p *Plugin) OnConfigurationChange() error {
var configuration = new(configuration)
// Load the public configuration fields from the Mattermost server configuration.
if err := p.API.LoadPluginConfiguration(configuration); err != nil {
return errors.Wrap(err, "failed to load plugin configuration")
}
p.badgeAdminUserID = ""
if configuration.BadgesAdmin != "" {
u, err := p.API.GetUserByUsername(configuration.BadgesAdmin)
if err != nil {
return errors.Wrap(err, "cannot get badge admin user")
}
p.badgeAdminUserID = u.Id
}
p.setConfiguration(configuration)
return nil
}

45
server/constants.go Normal file
View File

@ -0,0 +1,45 @@
package main
const (
KVKeyBadges = "badges"
KVKeyOwnership = "ownership"
KVKeyTypes = "types"
KVKeySubscriptions = "subs"
AutocompletePath = "/autocomplete"
AutocompletePathBadgeSuggestions = "/getBadgeSuggestions"
AutocompletePathTypeSuggestions = "/getBadgeTypeSuggestions"
AutocompletePathEditBadgeSuggestions = "/getEditBadgeSuggestions"
AutocompletePathEditTypeSuggestions = "/getEditTypeSuggestions"
DialogPath = "/dialog"
DialogPathCreateBadge = "/createBadge"
DialogPathSelectType = "/selectType"
DialogPathCreateType = "/createType"
DialogPathEditType = "/editType"
DialogPathGrant = "/grant"
DialogPathSelectBadge = "/selectBadge"
DialogPathEditBadge = "/editBadge"
DialogPathCreateSubscription = "/createSubscription"
DialogPathDeleteSubscription = "/deleteSubscription"
DialogFieldBadgeName = "name"
DialogFieldBadgeMultiple = "multiple"
DialogFieldBadgeDescription = "description"
DialogFieldBadgeType = "type"
DialogFieldBadgeImage = "image"
DialogFieldBadgeDelete = "delete"
DialogFieldTypeName = "name"
DialogFieldTypeEveryoneCanGrant = "everyoneCanGrant"
DialogFieldTypeAllowlistCanGrant = "whitelistCanGrant"
DialogFieldTypeEveryoneCanCreate = "everyoneCanCreate"
DialogFieldTypeAllowlistCanCreate = "whitelistCanCreate"
DialogFieldTypeDelete = "delete"
DialogFieldUser = "user"
DialogFieldBadge = "badge"
DialogFieldNotifyHere = "notify_here"
DialogFieldGrantReason = "reason"
TrueString = "true"
FalseString = "false"
)

9
server/main.go Normal file
View File

@ -0,0 +1,9 @@
package main
import (
"github.com/mattermost/mattermost-server/v5/plugin"
)
func main() {
plugin.ClientMain(&Plugin{})
}

54
server/manifest.go generated Normal file
View File

@ -0,0 +1,54 @@
// This file is automatically generated. Do not modify it manually.
package main
import (
"strings"
"github.com/mattermost/mattermost-server/v5/model"
)
var manifest *model.Manifest
const manifestStr = `
{
"id": "com.mattermost.badges",
"name": "Badges for Mattermost",
"description": "This plugin add badges support to Mattermost.",
"homepage_url": "https://github.com/larkox/mattermost-plugin-badges",
"support_url": "https://github.com/larkox/mattermost-plugin-badges/issues",
"release_notes_url": "https://github.com/larkox/mattermost-plugin-badges/releases/tag/v0.2.1",
"icon_path": "assets/starter-template-icon.svg",
"version": "0.2.1",
"min_server_version": "5.12.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": "BadgesAdmin",
"display_name": "Badges admin:",
"type": "text",
"help_text": "This user will be considered as an admin for the badges plugin. They can create types, and modify and grant any badge.",
"placeholder": "",
"default": null
}
]
}
}
`
func init() {
manifest = model.ManifestFromJson(strings.NewReader(manifestStr))
}

56
server/plugin.go Normal file
View File

@ -0,0 +1,56 @@
package main
import (
"net/http"
"sync"
"github.com/gorilla/mux"
pluginapi "github.com/mattermost/mattermost-plugin-api"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/plugin"
"github.com/pkg/errors"
)
// Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes.
type Plugin struct {
plugin.MattermostPlugin
// configurationLock synchronizes access to the configuration.
configurationLock sync.RWMutex
// configuration is the active plugin configuration. Consult getConfiguration and
// setConfiguration for usage.
configuration *configuration
mm *pluginapi.Client
BotUserID string
store Store
router *mux.Router
badgeAdminUserID string
}
// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world.
func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
r.Header.Add("Mattermost-Plugin-ID", c.SourcePluginId)
w.Header().Set("Content-Type", "application/json")
p.router.ServeHTTP(w, r)
}
// See https://developers.mattermost.com/extend/plugins/server/reference/
func (p *Plugin) OnActivate() error {
p.mm = pluginapi.NewClient(p.API)
botID, err := p.Helpers.EnsureBot(&model.Bot{
Username: "badges",
DisplayName: "Badges Bot",
Description: "Created by the Badges plugin.",
})
if err != nil {
return errors.Wrap(err, "failed to ensure badges bot")
}
p.BotUserID = botID
p.store = NewStore(p.API)
p.initializeAPI()
return p.mm.SlashCommand.Register(p.getCommand())
}

28
server/plugin_test.go Normal file
View File

@ -0,0 +1,28 @@
package main
import (
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestServeHTTP(t *testing.T) {
assert := assert.New(t)
plugin := Plugin{}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
plugin.ServeHTTP(nil, w, r)
result := w.Result()
assert.NotNil(result)
defer result.Body.Close()
bodyBytes, err := ioutil.ReadAll(result.Body)
assert.Nil(err)
bodyString := string(bodyBytes)
assert.Equal("Hello, world!", bodyString)
}

546
server/store.go Normal file
View File

@ -0,0 +1,546 @@
package main
import (
"encoding/json"
"errors"
"time"
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
"github.com/mattermost/mattermost-server/v5/model"
"github.com/mattermost/mattermost-server/v5/plugin"
)
var errInvalidBadge = errors.New("invalid badge")
var errBadgeNotFound = errors.New("badge not found")
type Store interface {
// Interface
GetUserBadges(userID string) ([]*badgesmodel.UserBadge, error)
GetAllBadges() ([]*badgesmodel.AllBadgesBadge, error)
GetBadgeDetails(badgeID badgesmodel.BadgeID) (*badgesmodel.BadgeDetails, error)
// Autocomplete
GetRawBadges() ([]*badgesmodel.Badge, error)
GetRawTypes() (badgesmodel.BadgeTypeList, error)
// API
AddBadge(badge *badgesmodel.Badge) (*badgesmodel.Badge, error)
GrantBadge(badgeID badgesmodel.BadgeID, userID string, grantedBy string, reason string) (bool, error)
AddType(t *badgesmodel.BadgeTypeDefinition) (*badgesmodel.BadgeTypeDefinition, error)
GetType(tID badgesmodel.BadgeType) (*badgesmodel.BadgeTypeDefinition, error)
GetBadge(badgeID badgesmodel.BadgeID) (*badgesmodel.Badge, error)
UpdateType(t *badgesmodel.BadgeTypeDefinition) error
UpdateBadge(b *badgesmodel.Badge) error
DeleteType(tID badgesmodel.BadgeType) error
DeleteBadge(bID badgesmodel.BadgeID) error
AddSubscription(tID badgesmodel.BadgeType, cID string) error
RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error
GetTypeSubscriptions(tID badgesmodel.BadgeType) ([]string, error)
GetChannelSubscriptions(cID string) ([]*badgesmodel.BadgeTypeDefinition, error)
// PAPI
EnsureBadges(badges []*badgesmodel.Badge, pluginID, botID string) ([]*badgesmodel.Badge, error)
}
type store struct {
api plugin.API
}
func NewStore(api plugin.API) Store {
return &store{
api: api,
}
}
func (s *store) EnsureBadges(badges []*badgesmodel.Badge, pluginID, botID string) ([]*badgesmodel.Badge, error) {
l, _, err := s.getAllTypes()
if err != nil {
return nil, err
}
var tDef *badgesmodel.BadgeTypeDefinition
for i, t := range l {
if t.CreatedBy == botID {
tDef = l[i]
break
}
}
if tDef == nil {
tDef, err = s.addType(&badgesmodel.BadgeTypeDefinition{
Name: "Plugin badges: " + pluginID,
CreatedBy: botID,
}, true)
if err != nil {
return nil, err
}
}
bb, _, err := s.getAllBadges()
if err != nil {
return nil, err
}
out := []*badgesmodel.Badge{}
for _, pb := range badges {
found := false
for _, b := range bb {
if b.CreatedBy == botID && b.Name == pb.Name {
found = true
out = append(out, b)
break
}
}
if !found {
pb.Type = tDef.ID
pb.CreatedBy = botID
newBadge, err := s.AddBadge(pb)
if err != nil {
return nil, err
}
out = append(out, newBadge)
}
}
return out, nil
}
func (s *store) AddBadge(b *badgesmodel.Badge) (*badgesmodel.Badge, error) {
if !b.IsValid() {
return nil, errInvalidBadge
}
badgeTypes, _, err := s.getAllTypes()
if err != nil {
return nil, err
}
t := badgeTypes.GetType(b.Type)
if t == nil {
return nil, errors.New("missing badge type")
}
b.ID = badgesmodel.BadgeID(model.NewId())
err = s.doAtomic(func() (bool, error) { return s.atomicAddBadge(b) })
if err != nil {
return nil, err
}
return b, nil
}
func (s *store) AddType(t *badgesmodel.BadgeTypeDefinition) (*badgesmodel.BadgeTypeDefinition, error) {
return s.addType(t, false)
}
func (s *store) addType(t *badgesmodel.BadgeTypeDefinition, isPlugin bool) (*badgesmodel.BadgeTypeDefinition, error) {
t.ID = badgesmodel.BadgeType(model.NewId())
err := s.doAtomic(func() (bool, error) { return s.atomicAddType(t) })
if err != nil {
return nil, err
}
return t, nil
}
func (s *store) GetAllBadges() ([]*badgesmodel.AllBadgesBadge, error) {
badges, _, err := s.getAllBadges()
if err != nil {
return nil, err
}
ownership, _, err := s.getOwnershipList()
if err != nil {
return nil, err
}
out := []*badgesmodel.AllBadgesBadge{}
for _, b := range badges {
badge := &badgesmodel.AllBadgesBadge{
Badge: *b,
}
grantedTo := map[string]bool{}
for _, o := range ownership {
if o.Badge != badge.ID {
continue
}
badge.GrantedTimes++
if !grantedTo[o.User] {
badge.Granted++
grantedTo[o.User] = true
}
}
badge.TypeName = "unknown"
t, err := s.GetType(badge.Type)
if err == nil {
badge.TypeName = t.Name
}
out = append(out, badge)
}
return out, nil
}
func (s *store) GetRawBadges() ([]*badgesmodel.Badge, error) {
bb, _, err := s.getAllBadges()
return bb, err
}
func (s *store) GetRawTypes() (badgesmodel.BadgeTypeList, error) {
tt, _, err := s.getAllTypes()
return tt, err
}
func (s *store) getAllTypes() (badgesmodel.BadgeTypeList, []byte, error) {
data, appErr := s.api.KVGet(KVKeyTypes)
if appErr != nil {
return nil, nil, appErr
}
typeList := []*badgesmodel.BadgeTypeDefinition{}
if data != nil {
err := json.Unmarshal(data, &typeList)
if err != nil {
return nil, nil, err
}
}
return typeList, data, nil
}
func (s *store) getAllBadges() ([]*badgesmodel.Badge, []byte, error) {
data, appErr := s.api.KVGet(KVKeyBadges)
if appErr != nil {
return nil, nil, appErr
}
badgeList := []*badgesmodel.Badge{}
if data != nil {
err := json.Unmarshal(data, &badgeList)
if err != nil {
return nil, nil, err
}
}
return badgeList, data, nil
}
func (s *store) getBadge(id badgesmodel.BadgeID) (*badgesmodel.Badge, error) {
badgeList, _, err := s.getAllBadges()
if err != nil {
return nil, err
}
return s.getBadgeFromList(id, badgeList)
}
func (s *store) GetBadgeDetails(id badgesmodel.BadgeID) (*badgesmodel.BadgeDetails, error) {
badge, err := s.getBadge(id)
if err != nil {
return nil, err
}
owners, err := s.getBadgeUsers(id)
if err != nil {
return nil, err
}
createdByName := "unknown"
u, appErr := s.api.GetUser(badge.CreatedBy)
if appErr == nil {
conf := s.api.GetConfig()
if conf != nil {
format := conf.TeamSettings.TeammateNameDisplay
if format != nil {
createdByName = u.GetDisplayName(*format)
}
}
}
typeName := "unknown"
t, err := s.GetType(badge.Type)
if err == nil {
typeName = t.Name
}
return &badgesmodel.BadgeDetails{
Badge: *badge,
Owners: owners,
CreatedByUsername: createdByName,
TypeName: typeName,
}, nil
}
func (s *store) getOwnershipList() (badgesmodel.OwnershipList, []byte, error) {
data, appErr := s.api.KVGet(KVKeyOwnership)
if appErr != nil {
return nil, nil, appErr
}
ownership := badgesmodel.OwnershipList{}
if data != nil {
err := json.Unmarshal(data, &ownership)
if err != nil {
return nil, nil, err
}
}
return ownership, data, nil
}
func (s *store) GrantBadge(id badgesmodel.BadgeID, userID string, grantedBy string, reason string) (bool, error) {
badge, err := s.getBadge(id)
if err != nil {
return false, err
}
types, _, err := s.getAllTypes()
if err != nil {
return false, err
}
badgeType := types.GetType(badge.Type)
if badgeType == nil {
return false, errors.New("badge type not found")
}
ownership := badgesmodel.Ownership{
User: userID,
Badge: badge.ID,
Time: time.Now(),
Reason: reason,
GrantedBy: grantedBy,
}
shouldNotify := false
err = s.doAtomic(func() (bool, error) {
var done bool
var err error
shouldNotify, done, err = s.atomicAddBadgeToOwnership(ownership, badge.Multiple)
return done, err
})
if err != nil {
return false, err
}
return shouldNotify, nil
}
func (s *store) GetUserBadges(userID string) ([]*badgesmodel.UserBadge, error) {
ownership, _, err := s.getOwnershipList()
if err != nil {
return nil, err
}
badges, _, err := s.getAllBadges()
if err != nil {
return nil, err
}
out := []*badgesmodel.UserBadge{}
for _, o := range ownership {
if o.User == userID {
badge, err := s.getBadgeFromList(o.Badge, badges)
if err != nil {
s.api.LogDebug("Badge not found while getting user badges", "badgeID", o.Badge, "userID", userID)
continue
}
grantedByName := "unknown"
u, appErr := s.api.GetUser(o.GrantedBy)
if appErr == nil {
conf := s.api.GetConfig()
if conf != nil {
format := conf.TeamSettings.TeammateNameDisplay
if format != nil {
grantedByName = u.GetDisplayName(*format)
}
}
}
typeName := "unknown"
t, err := s.GetType(badge.Type)
if err == nil {
typeName = t.Name
}
out = append([]*badgesmodel.UserBadge{{Badge: *badge, Ownership: o, GrantedByUsername: grantedByName, TypeName: typeName}}, out...)
}
}
return out, nil
}
func (s *store) GetType(tID badgesmodel.BadgeType) (*badgesmodel.BadgeTypeDefinition, error) {
tt, _, err := s.getAllTypes()
if err != nil {
return nil, err
}
for _, t := range tt {
if t.ID == tID {
return t, nil
}
}
return nil, errors.New("not found")
}
func (s *store) GetBadge(badgeID badgesmodel.BadgeID) (*badgesmodel.Badge, error) {
return s.getBadge(badgeID)
}
func (s *store) UpdateType(t *badgesmodel.BadgeTypeDefinition) error {
return s.doAtomic(func() (bool, error) { return s.atomicUpdateType(t) })
}
func (s *store) UpdateBadge(b *badgesmodel.Badge) error {
return s.doAtomic(func() (bool, error) { return s.atomicUpdateBadge(b) })
}
func (s *store) atomicDeleteType(tID badgesmodel.BadgeType) (bool, error) {
tt, data, err := s.getAllTypes()
if err != nil {
return false, err
}
for i, t := range tt {
if t.ID == tID {
tt = append(tt[:i], tt[i+1:]...)
break
}
}
return s.compareAndSet(KVKeyTypes, data, tt)
}
func (s *store) DeleteType(tID badgesmodel.BadgeType) error {
s.doAtomic(func() (bool, error) { return s.atomicDeleteType(tID) })
bb, _, err := s.getAllBadges()
if err != nil {
return err
}
for _, b := range bb {
if b.Type == tID {
s.api.LogDebug("Deleting badge", "name", b.Name)
err := s.DeleteBadge(b.ID)
if err != nil {
return err
}
}
}
return nil
}
func (s *store) DeleteBadge(bID badgesmodel.BadgeID) error {
err := s.doAtomic(func() (bool, error) { return s.atomicRemoveBadge(bID) })
if err != nil {
return err
}
err = s.doAtomic(func() (bool, error) { return s.atomicRemoveBadgeFromOwnership(bID) })
if err != nil {
return err
}
return nil
}
func (s *store) getAllSubscriptions() ([]badgesmodel.Subscription, []byte, error) {
data, appErr := s.api.KVGet(KVKeySubscriptions)
if appErr != nil {
return nil, nil, appErr
}
subs := []badgesmodel.Subscription{}
if data != nil {
err := json.Unmarshal(data, &subs)
if err != nil {
return nil, nil, err
}
}
return subs, data, nil
}
func (s *store) AddSubscription(tID badgesmodel.BadgeType, cID string) error {
toAdd := badgesmodel.Subscription{ChannelID: cID, TypeID: tID}
return s.doAtomic(func() (bool, error) { return s.atomicAddSubscription(toAdd) })
}
func (s *store) RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error {
toRemove := badgesmodel.Subscription{ChannelID: cID, TypeID: tID}
return s.doAtomic(func() (bool, error) { return s.atomicRemoveSubscription(toRemove) })
}
func (s *store) GetTypeSubscriptions(tID badgesmodel.BadgeType) ([]string, error) {
subs, _, err := s.getAllSubscriptions()
if err != nil {
return nil, err
}
out := []string{}
for _, sub := range subs {
if sub.TypeID == tID {
out = append(out, sub.ChannelID)
}
}
return out, nil
}
func (s *store) GetChannelSubscriptions(cID string) ([]*badgesmodel.BadgeTypeDefinition, error) {
subs, _, err := s.getAllSubscriptions()
if err != nil {
return nil, err
}
out := []*badgesmodel.BadgeTypeDefinition{}
for _, sub := range subs {
if sub.ChannelID == cID {
t, err := s.GetType(sub.TypeID)
if err != nil {
s.api.LogDebug("cannot get type", "err", err)
continue
}
out = append(out, t)
}
}
return out, nil
}
func (s *store) getBadgeFromList(badgeID badgesmodel.BadgeID, list []*badgesmodel.Badge) (*badgesmodel.Badge, error) {
for _, badge := range list {
if badgeID == badge.ID {
return badge, nil
}
}
return nil, errBadgeNotFound
}
func (s *store) getBadgeUsers(badgeID badgesmodel.BadgeID) (badgesmodel.OwnershipList, error) {
_, err := s.getBadge(badgeID)
if err != nil {
return nil, errBadgeNotFound
}
ownership, _, err := s.getOwnershipList()
if err != nil {
return nil, err
}
out := badgesmodel.OwnershipList{}
for _, o := range ownership {
if o.Badge == badgeID {
out = append(out, o)
}
}
return out, nil
}

199
server/store_atomic.go Normal file
View File

@ -0,0 +1,199 @@
package main
import (
"encoding/json"
"errors"
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
)
const ATOMICRETRIES = 3
func (s *store) doAtomic(f func() (bool, error)) error {
done := false
for i := 0; i < ATOMICRETRIES; i++ {
var err error
done, err = f()
if err != nil {
return err
}
if done {
break
}
}
if !done {
return errors.New("too many attempts on atomic retry")
}
return nil
}
func (s *store) compareAndSet(key string, old []byte, value interface{}) (bool, error) {
data, err := json.Marshal(value)
if err != nil {
return false, err
}
done, appErr := s.api.KVCompareAndSet(key, old, data)
if appErr != nil {
return false, appErr
}
return done, nil
}
func (s *store) atomicRemoveBadge(bID badgesmodel.BadgeID) (bool, error) {
bb, data, err := s.getAllBadges()
if err != nil {
return false, err
}
for i, b := range bb {
if b.ID == bID {
bb = append(bb[:i], bb[i+1:]...)
break
}
}
return s.compareAndSet(KVKeyBadges, data, bb)
}
func (s *store) atomicRemoveBadgeFromOwnership(bID badgesmodel.BadgeID) (bool, error) {
ownership, data, err := s.getOwnershipList()
if err != nil {
return false, err
}
toDelete := []int{}
for i, o := range ownership {
if o.Badge == bID {
toDelete = append([]int{i}, toDelete...)
}
}
for _, index := range toDelete {
ownership = append(ownership[:index], ownership[index+1:]...)
}
return s.compareAndSet(KVKeyOwnership, data, ownership)
}
func (s *store) atomicAddBadge(b *badgesmodel.Badge) (bool, error) {
bb, data, err := s.getAllBadges()
if err != nil {
return false, err
}
bb = append(bb, b)
return s.compareAndSet(KVKeyBadges, data, bb)
}
func (s *store) atomicAddType(t *badgesmodel.BadgeTypeDefinition) (bool, error) {
tt, data, err := s.getAllTypes()
if err != nil {
return false, err
}
tt = append(tt, t)
return s.compareAndSet(KVKeyTypes, data, tt)
}
func (s *store) atomicAddBadgeToOwnership(o badgesmodel.Ownership, isMultiple bool) (shouldNotify bool, done bool, err error) {
ownership, data, err := s.getOwnershipList()
if err != nil {
return false, false, err
}
if !isMultiple && ownership.IsOwned(o.User, o.Badge) {
return false, true, nil
}
ownership = append(ownership, o)
done, err = s.compareAndSet(KVKeyOwnership, data, ownership)
return done, done, err
}
func (s *store) atomicUpdateType(t *badgesmodel.BadgeTypeDefinition) (bool, error) {
tt, data, err := s.getAllTypes()
if err != nil {
return false, err
}
found := false
for i, tOld := range tt {
if tOld.ID == t.ID {
tt[i] = t
found = true
break
}
}
if !found {
return false, errors.New("not found")
}
return s.compareAndSet(KVKeyTypes, data, tt)
}
func (s *store) atomicUpdateBadge(b *badgesmodel.Badge) (bool, error) {
bb, data, err := s.getAllBadges()
if err != nil {
return false, err
}
found := false
for i, bOld := range bb {
if bOld.ID == b.ID {
bb[i] = b
found = true
break
}
}
if !found {
return false, errors.New("not found")
}
return s.compareAndSet(KVKeyBadges, data, bb)
}
func (s *store) atomicAddSubscription(toAdd badgesmodel.Subscription) (bool, error) {
subs, data, err := s.getAllSubscriptions()
if err != nil {
return false, err
}
for _, sub := range subs {
if sub.ChannelID == toAdd.ChannelID && sub.TypeID == toAdd.TypeID {
return true, nil
}
}
subs = append(subs, toAdd)
return s.compareAndSet(KVKeySubscriptions, data, subs)
}
func (s *store) atomicRemoveSubscription(toRemove badgesmodel.Subscription) (bool, error) {
subs, data, err := s.getAllSubscriptions()
if err != nil {
return false, err
}
found := false
for i, sub := range subs {
if sub.ChannelID == toRemove.ChannelID && sub.TypeID == toRemove.TypeID {
subs = append(subs[:i], subs[i+1:]...)
found = true
break
}
}
if !found {
return true, nil
}
return s.compareAndSet(KVKeySubscriptions, data, subs)
}

80
server/suggestions.go Normal file
View File

@ -0,0 +1,80 @@
package main
import (
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
"github.com/mattermost/mattermost-server/v5/model"
)
func (p *Plugin) filterGrantBadges(user *model.User) ([]*badgesmodel.Badge, error) {
badges, err := p.store.GetRawBadges()
if err != nil {
return nil, err
}
types, err := p.store.GetRawTypes()
if err != nil {
return nil, err
}
out := []*badgesmodel.Badge{}
for _, b := range badges {
badgeType := types.GetType(b.Type)
if badgeType == nil {
p.mm.Log.Debug("Badge with missing type", "badge", b)
continue
}
if canGrantBadge(user, p.badgeAdminUserID, b, badgeType) {
out = append(out, b)
}
}
return out, nil
}
func (p *Plugin) filterCreateBadgeTypes(user *model.User) (badgesmodel.BadgeTypeList, error) {
types, err := p.store.GetRawTypes()
if err != nil {
return nil, err
}
out := badgesmodel.BadgeTypeList{}
for _, t := range types {
if canCreateBadge(user, p.badgeAdminUserID, t) {
out = append(out, t)
}
}
return out, nil
}
func (p *Plugin) filterEditTypes(user *model.User) (badgesmodel.BadgeTypeList, error) {
types, err := p.store.GetRawTypes()
if err != nil {
return nil, err
}
out := badgesmodel.BadgeTypeList{}
for _, t := range types {
if canEditType(user, p.badgeAdminUserID, t) {
out = append(out, t)
}
}
return out, nil
}
func (p *Plugin) filterEditBadges(user *model.User) ([]*badgesmodel.Badge, error) {
bb, err := p.store.GetRawBadges()
if err != nil {
return nil, err
}
out := []*badgesmodel.Badge{}
for _, b := range bb {
if canEditBadge(user, p.badgeAdminUserID, b) {
out = append(out, b)
}
}
return out, nil
}

211
server/utils.go Normal file
View File

@ -0,0 +1,211 @@
package main
import (
"encoding/json"
"fmt"
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
"github.com/mattermost/mattermost-server/v5/model"
)
func areRolesAllowed(userRoles []string, allowedRoles map[string]bool) bool {
for ar, b := range allowedRoles {
if !b {
continue
}
for _, ur := range userRoles {
if ar == ur {
return true
}
}
}
return false
}
func canGrantBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
if badgeAdminID != "" && user.Id == badgeAdminID {
return true
}
if user.IsSystemAdmin() {
return true
}
if badgeType.CreatedBy == user.Id {
return true
}
if badge.CreatedBy == user.Id {
return true
}
blocked := badgeType.CanGrant.BlockList[user.Id]
if blocked {
return false
}
if areRolesAllowed(user.GetRoles(), badgeType.CanGrant.Roles) {
return true
}
allowed := badgeType.CanGrant.AllowList[user.Id]
if allowed {
return true
}
return badgeType.CanGrant.Everyone
}
func canCreateBadge(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool {
if badgeAdminID != "" && user.Id == badgeAdminID {
return true
}
if user.IsSystemAdmin() {
return true
}
if badgeType.CreatedBy == user.Id {
return true
}
blocked := badgeType.CanCreate.BlockList[user.Id]
if blocked {
return false
}
if areRolesAllowed(user.GetRoles(), badgeType.CanCreate.Roles) {
return true
}
allowed := badgeType.CanCreate.AllowList[user.Id]
if allowed {
return true
}
return badgeType.CanCreate.Everyone
}
func canEditType(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool {
if badgeAdminID != "" && user.Id == badgeAdminID {
return true
}
return user.IsSystemAdmin()
}
func canEditBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge) bool {
if badgeAdminID != "" && user.Id == badgeAdminID {
return true
}
return user.IsSystemAdmin() || user.Id == badge.CreatedBy
}
func canCreateType(user *model.User, badgeAdminID string, isPlugin bool) bool {
if isPlugin {
return true
}
if badgeAdminID != "" && user.Id == badgeAdminID {
return true
}
return user.IsSystemAdmin()
}
func canCreateSubscription(user *model.User, badgeAdminID string, channelID string) bool {
if badgeAdminID != "" && user.Id == badgeAdminID {
return true
}
return user.IsSystemAdmin()
}
func dumpObject(o interface{}) {
b, err := json.MarshalIndent(o, "", " ")
if err != nil {
fmt.Println(err.Error())
}
fmt.Println(string(b))
}
func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, granted *model.User, inChannel bool, channelID string, reason string) {
b, errBadge := p.store.GetBadgeDetails(badgeID)
granterUser, errUser := p.mm.User.Get(granter)
if errBadge != nil {
p.mm.Log.Debug("badge error", "err", errBadge)
}
if errUser != nil {
p.mm.Log.Debug("user error", "err", errUser)
}
subs, _ := p.store.GetTypeSubscriptions(b.Type)
if errBadge == nil && errUser == nil {
image := ""
switch b.ImageType {
case badgesmodel.ImageTypeEmoji:
image = fmt.Sprintf(":%s: ", b.Image)
case badgesmodel.ImageTypeAbsoluteURL:
image = fmt.Sprintf("![icon](%s) ", b.Image)
}
dmPost := &model.Post{}
dmText := fmt.Sprintf("@%s granted you the %s`%s` badge.", granterUser.Username, image, b.Name)
if reason != "" {
dmText += "\nWhy? " + reason
}
dmAttachment := model.SlackAttachment{
Title: fmt.Sprintf("%sbadge granted!", image),
Text: dmText,
}
model.ParseSlackAttachment(dmPost, []*model.SlackAttachment{&dmAttachment})
err := p.mm.Post.DM(p.BotUserID, granted.Id, dmPost)
if err != nil {
p.mm.Log.Debug("dm error", "err", err)
}
basePost := model.Post{
UserId: p.BotUserID,
ChannelId: channelID,
}
text := fmt.Sprintf("@%s granted @%s the %s`%s` badge.", granterUser.Username, granted.Username, image, b.Name)
if reason != "" {
text += "\nWhy? " + reason
}
attachment := model.SlackAttachment{
Title: fmt.Sprintf("%sbadge granted!", image),
Text: text,
}
model.ParseSlackAttachment(&basePost, []*model.SlackAttachment{&attachment})
for _, sub := range subs {
post := basePost.Clone()
post.ChannelId = sub
err := p.mm.Post.CreatePost(post)
if err != nil {
p.mm.Log.Debug("notify subscription error", "err", err)
}
}
if inChannel {
if !p.API.HasPermissionToChannel(granter, channelID, model.PERMISSION_CREATE_POST) {
p.mm.Post.SendEphemeralPost(granter, &model.Post{Message: "You don't have permissions to notify the grant on this channel.", ChannelId: channelID})
} else {
post := basePost.Clone()
post.ChannelId = channelID
err := p.mm.Post.CreatePost(post)
if err != nil {
p.mm.Log.Debug("notify here error", "err", err)
}
}
}
}
}
func getBooleanString(in bool) string {
if in {
return TrueString
}
return FalseString
}

704
webapp/.eslintrc.json Normal file
View File

@ -0,0 +1,704 @@
{
"extends": [
"eslint:recommended",
"plugin:react-hooks/recommended"
],
"parserOptions": {
"ecmaVersion": 8,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
"impliedStrict": true,
"modules": true,
"experimentalObjectRestSpread": true
}
},
"parser": "babel-eslint",
"plugins": [
"react",
"import"
],
"env": {
"browser": true,
"node": true,
"jquery": true,
"es6": true,
"jest": true
},
"globals": {
"jest": true,
"describe": true,
"it": true,
"expect": true,
"before": true,
"after": true,
"beforeEach": true
},
"settings": {
"import/resolver": "webpack"
},
"rules": {
"eol-last": ["error", "always"],
"array-bracket-spacing": [
2,
"never"
],
"array-callback-return": 2,
"arrow-body-style": 0,
"arrow-parens": [
2,
"always"
],
"arrow-spacing": [
2,
{
"before": true,
"after": true
}
],
"block-scoped-var": 2,
"brace-style": [
2,
"1tbs",
{
"allowSingleLine": false
}
],
"capitalized-comments": 0,
"class-methods-use-this": 0,
"comma-dangle": [
2,
"always-multiline"
],
"comma-spacing": [
2,
{
"before": false,
"after": true
}
],
"comma-style": [
2,
"last"
],
"complexity": [
0,
10
],
"computed-property-spacing": [
2,
"never"
],
"consistent-return": 2,
"consistent-this": [
2,
"self"
],
"constructor-super": 2,
"curly": [
2,
"all"
],
"dot-location": [
2,
"object"
],
"dot-notation": 2,
"eqeqeq": [
2,
"smart"
],
"func-call-spacing": [
2,
"never"
],
"func-name-matching": 0,
"func-names": 2,
"func-style": [
2,
"declaration",
{
"allowArrowFunctions": true
}
],
"generator-star-spacing": [
2,
{
"before": false,
"after": true
}
],
"global-require": 2,
"guard-for-in": 2,
"id-blacklist": 0,
"import/no-unresolved": 2,
"import/order": [
"error",
{
"newlines-between": "always-and-inside-groups",
"groups": [
"builtin",
"external",
[
"internal",
"parent"
],
"sibling",
"index"
]
}
],
"indent": [
2,
4,
{
"SwitchCase": 0
}
],
"jsx-quotes": [
2,
"prefer-single"
],
"key-spacing": [
2,
{
"beforeColon": false,
"afterColon": true,
"mode": "strict"
}
],
"keyword-spacing": [
2,
{
"before": true,
"after": true,
"overrides": {}
}
],
"line-comment-position": 0,
"linebreak-style": 2,
"lines-around-comment": [
2,
{
"beforeBlockComment": true,
"beforeLineComment": true,
"allowBlockStart": true,
"allowBlockEnd": true
}
],
"max-lines": [
1,
{
"max": 450,
"skipBlankLines": true,
"skipComments": false
}
],
"max-nested-callbacks": [
2,
{
"max": 2
}
],
"max-statements-per-line": [
2,
{
"max": 1
}
],
"multiline-ternary": [
1,
"never"
],
"new-cap": 2,
"new-parens": 2,
"newline-before-return": 0,
"newline-per-chained-call": 0,
"no-alert": 2,
"no-array-constructor": 2,
"no-await-in-loop": 2,
"no-caller": 2,
"no-case-declarations": 2,
"no-class-assign": 2,
"no-compare-neg-zero": 2,
"no-cond-assign": [
2,
"except-parens"
],
"no-confusing-arrow": 2,
"no-console": 2,
"no-const-assign": 2,
"no-constant-condition": 2,
"no-debugger": 2,
"no-div-regex": 2,
"no-dupe-args": 2,
"no-dupe-class-members": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-duplicate-imports": [
2,
{
"includeExports": true
}
],
"no-else-return": 2,
"no-empty": 2,
"no-empty-function": 2,
"no-empty-pattern": 2,
"no-eval": 2,
"no-ex-assign": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-extra-label": 2,
"no-extra-parens": 0,
"no-extra-semi": 2,
"no-fallthrough": 2,
"no-floating-decimal": 2,
"no-func-assign": 2,
"no-global-assign": 2,
"no-implicit-coercion": 2,
"no-implicit-globals": 0,
"no-implied-eval": 2,
"no-inner-declarations": 0,
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-labels": 2,
"no-lone-blocks": 2,
"no-lonely-if": 2,
"no-loop-func": 2,
"no-magic-numbers": [
0,
{
"ignore": [
-1,
0,
1,
2
],
"enforceConst": true,
"detectObjects": true
}
],
"no-mixed-operators": [
2,
{
"allowSamePrecedence": false
}
],
"no-mixed-spaces-and-tabs": 2,
"no-multi-assign": 2,
"no-multi-spaces": [
2,
{
"exceptions": {
"Property": false
}
}
],
"no-multi-str": 0,
"no-multiple-empty-lines": [
2,
{
"max": 1
}
],
"no-native-reassign": 2,
"no-negated-condition": 2,
"no-nested-ternary": 2,
"no-new": 2,
"no-new-func": 2,
"no-new-object": 2,
"no-new-symbol": 2,
"no-new-wrappers": 2,
"no-octal-escape": 2,
"no-param-reassign": 2,
"no-process-env": 2,
"no-process-exit": 2,
"no-proto": 2,
"no-redeclare": 2,
"no-return-assign": [
2,
"always"
],
"no-return-await": 2,
"no-script-url": 2,
"no-self-assign": [
2,
{
"props": true
}
],
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": [
2,
{
"hoist": "functions"
}
],
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-tabs": 0,
"no-template-curly-in-string": 2,
"no-ternary": 0,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-trailing-spaces": [
2,
{
"skipBlankLines": false
}
],
"no-undef-init": 2,
"no-undefined": 2,
"no-underscore-dangle": 2,
"no-unexpected-multiline": 2,
"no-unmodified-loop-condition": 2,
"no-unneeded-ternary": [
2,
{
"defaultAssignment": false
}
],
"no-unreachable": 2,
"no-unsafe-finally": 2,
"no-unsafe-negation": 2,
"no-unused-expressions": 2,
"no-unused-vars": [
2,
{
"vars": "all",
"args": "after-used"
}
],
"no-use-before-define": [
2,
{
"classes": false,
"functions": false,
"variables": false
}
],
"no-useless-computed-key": 2,
"no-useless-concat": 2,
"no-useless-constructor": 2,
"no-useless-escape": 2,
"no-useless-rename": 2,
"no-useless-return": 2,
"no-var": 0,
"no-void": 2,
"no-warning-comments": 1,
"no-whitespace-before-property": 2,
"no-with": 2,
"object-curly-newline": 0,
"object-curly-spacing": [
2,
"never"
],
"object-property-newline": [
2,
{
"allowMultiplePropertiesPerLine": true
}
],
"object-shorthand": [
2,
"always"
],
"one-var": [
2,
"never"
],
"one-var-declaration-per-line": 0,
"operator-assignment": [
2,
"always"
],
"operator-linebreak": [
2,
"after"
],
"padded-blocks": [
2,
"never"
],
"prefer-arrow-callback": 2,
"prefer-const": 2,
"prefer-destructuring": 0,
"prefer-numeric-literals": 2,
"prefer-promise-reject-errors": 2,
"prefer-rest-params": 2,
"prefer-spread": 2,
"prefer-template": 0,
"quote-props": [
2,
"as-needed"
],
"quotes": [
2,
"single",
"avoid-escape"
],
"radix": 2,
"react/display-name": [
0,
{
"ignoreTranspilerName": false
}
],
"react/forbid-component-props": 0,
"react/forbid-elements": [
2,
{
"forbid": [
"embed"
]
}
],
"react/jsx-boolean-value": [
2,
"always"
],
"react/jsx-closing-bracket-location": [
2,
{
"location": "tag-aligned"
}
],
"react/jsx-curly-spacing": [
2,
"never"
],
"react/jsx-equals-spacing": [
2,
"never"
],
"react/jsx-filename-extension": 2,
"react/jsx-first-prop-new-line": [
2,
"multiline"
],
"react/jsx-handler-names": 0,
"react/jsx-indent": [
2,
4
],
"react/jsx-indent-props": [
2,
4
],
"react/jsx-key": 2,
"react/jsx-max-props-per-line": [
2,
{
"maximum": 1
}
],
"react/jsx-no-bind": 0,
"react/jsx-no-comment-textnodes": 2,
"react/jsx-no-duplicate-props": [
2,
{
"ignoreCase": false
}
],
"react/jsx-no-literals": 2,
"react/jsx-no-target-blank": 2,
"react/jsx-no-undef": 2,
"react/jsx-pascal-case": 2,
"react/jsx-tag-spacing": [
2,
{
"closingSlash": "never",
"beforeSelfClosing": "never",
"afterOpening": "never"
}
],
"react/jsx-uses-react": 2,
"react/jsx-uses-vars": 2,
"react/jsx-wrap-multilines": 2,
"react/no-array-index-key": 1,
"react/no-children-prop": 2,
"react/no-danger": 0,
"react/no-danger-with-children": 2,
"react/no-deprecated": 1,
"react/no-did-mount-set-state": 2,
"react/no-did-update-set-state": 2,
"react/no-direct-mutation-state": 2,
"react/no-find-dom-node": 1,
"react/no-is-mounted": 2,
"react/no-multi-comp": [
2,
{
"ignoreStateless": true
}
],
"react/no-render-return-value": 2,
"react/no-set-state": 0,
"react/no-string-refs": 0,
"react/no-unescaped-entities": 2,
"react/no-unknown-property": 2,
"react/no-unused-prop-types": [
1,
{
"skipShapeProps": true
}
],
"react/prefer-es6-class": 2,
"react/prefer-stateless-function": 2,
"react/prop-types": [
2,
{
"ignore": [
"location",
"history",
"component"
]
}
],
"react/require-default-props": 0,
"react/require-optimization": 1,
"react/require-render-return": 2,
"react/self-closing-comp": 2,
"react/sort-comp": 0,
"react/style-prop-object": 2,
"require-yield": 2,
"rest-spread-spacing": [
2,
"never"
],
"semi": [
2,
"always"
],
"semi-spacing": [
2,
{
"before": false,
"after": true
}
],
"sort-imports": 0,
"sort-keys": 0,
"space-before-blocks": [
2,
"always"
],
"space-before-function-paren": [
2,
{
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}
],
"space-in-parens": [
2,
"never"
],
"space-infix-ops": 2,
"space-unary-ops": [
2,
{
"words": true,
"nonwords": false
}
],
"symbol-description": 2,
"template-curly-spacing": [
2,
"never"
],
"valid-typeof": [
2,
{
"requireStringLiterals": false
}
],
"vars-on-top": 0,
"wrap-iife": [
2,
"outside"
],
"wrap-regex": 2,
"yoda": [
2,
"never",
{
"exceptRange": false,
"onlyEquality": false
}
]
},
"overrides": [
{
"files": ["**/*.tsx", "**/*.ts"],
"extends": "plugin:@typescript-eslint/recommended",
"rules": {
"@typescript-eslint/member-delimiter-style": ["error"],
"camelcase": 0,
"no-shadow": 0,
"import/no-unresolved": 0, // ts handles this better
"@typescript-eslint/naming-convention": [
2,
{
"selector": "function",
"format": ["camelCase", "PascalCase"]
},
{
"selector": "variable",
"format": ["camelCase", "PascalCase", "UPPER_CASE"]
},
{
"selector": "parameter",
"format": ["camelCase", "PascalCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
}
],
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-unused-vars": [
2,
{
"vars": "all",
"args": "after-used"
}
],
"@typescript-eslint/no-empty-function": 0,
"@typescript-eslint/ban-ts-ignore": 0,
"@typescript-eslint/ban-types": 1,
"@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/prefer-interface": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/indent": [
2,
4,
{
"SwitchCase": 0
}
],
"no-use-before-define": "off",
"@typescript-eslint/no-use-before-define": [
2,
{
"classes": false,
"functions": false,
"variables": false
}
],
"react/jsx-filename-extension": [
1,
{
"extensions": [".jsx", ".tsx"]
}
]
}
}
]
}

3
webapp/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.eslintcache
junit.xml
node_modules

1
webapp/.npmrc Normal file
View File

@ -0,0 +1 @@
save-exact=true

Binary file not shown.

1
webapp/.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

46
webapp/babel.config.js Normal file
View File

@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const config = {
presets: [
['@babel/preset-env', {
targets: {
chrome: 66,
firefox: 60,
edge: 42,
safari: 12,
},
modules: false,
corejs: 3,
debug: false,
useBuiltIns: 'usage',
shippedProposals: true,
}],
['@babel/preset-react', {
useBuiltIns: true,
}],
['@babel/typescript', {
allExtensions: true,
isTSX: true,
}],
['@emotion/babel-preset-css-prop'],
],
plugins: [
'@babel/plugin-proposal-class-properties',
'@babel/plugin-syntax-dynamic-import',
'@babel/proposal-object-rest-spread',
'@babel/plugin-proposal-optional-chaining',
'babel-plugin-typescript-to-proptypes',
],
};
// Jest needs module transformation
config.env = {
test: {
presets: config.presets,
plugins: config.plugins,
},
};
config.env.test.presets[0][1].modules = 'auto';
module.exports = config;

1
webapp/i18n/en.json Normal file
View File

@ -0,0 +1 @@
{}

16862
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

120
webapp/package.json Normal file
View File

@ -0,0 +1,120 @@
{
"private": true,
"scripts": {
"build": "webpack --mode=production",
"build:watch": "webpack --mode=production --watch",
"debug": "webpack --mode=none",
"debug:watch": "webpack --mode=development --watch",
"lint": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx --ext tsx --ext ts . --quiet --cache",
"fix": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx --ext tsx --ext ts . --quiet --fix --cache",
"test": "jest --forceExit --detectOpenHandles --verbose",
"test:watch": "jest --watch",
"test-ci": "jest --forceExit --detectOpenHandles --maxWorkers=2",
"check-types": "tsc"
},
"devDependencies": {
"@babel/cli": "7.13.16",
"@babel/core": "7.13.16",
"@babel/plugin-proposal-class-properties": "7.13.0",
"@babel/plugin-proposal-object-rest-spread": "7.13.8",
"@babel/plugin-proposal-optional-chaining": "7.13.12",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/polyfill": "7.12.1",
"@babel/preset-env": "7.13.15",
"@babel/preset-react": "7.13.13",
"@babel/preset-typescript": "7.13.0",
"@babel/runtime": "7.13.16",
"@emotion/babel-preset-css-prop": "11.2.0",
"@emotion/core": "11.0.0",
"@types/enzyme": "3.10.8",
"@types/jest": "26.0.22",
"@types/node": "14.14.41",
"@types/react": "17.0.3",
"@types/react-bootstrap": "0.32.25",
"@types/react-custom-scrollbars": "4.0.7",
"@types/react-dom": "17.0.3",
"@types/react-intl": "3.0.0",
"@types/react-redux": "7.1.16",
"@types/react-router-dom": "5.1.7",
"@types/react-transition-group": "4.4.1",
"@typescript-eslint/eslint-plugin": "4.22.0",
"@typescript-eslint/parser": "4.22.0",
"babel-eslint": "10.1.0",
"babel-jest": "26.6.3",
"babel-loader": "8.2.2",
"babel-plugin-typescript-to-proptypes": "1.4.2",
"css-loader": "5.2.4",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.6",
"enzyme-to-json": "3.6.2",
"eslint": "7.24.0",
"eslint-import-resolver-webpack": "0.13.0",
"eslint-plugin-import": "2.22.1",
"eslint-plugin-react": "7.23.2",
"eslint-plugin-react-hooks": "4.2.0",
"file-loader": "6.2.0",
"identity-obj-proxy": "3.0.0",
"jest": "26.6.3",
"jest-canvas-mock": "2.3.1",
"jest-junit": "12.0.0",
"loop-plugin-sdk": "https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz",
"sass": "1.86.0",
"sass-loader": "11.0.1",
"style-loader": "2.0.0",
"webpack": "5.34.0",
"webpack-cli": "4.6.0"
},
"dependencies": {
"@emotion/react": "11.1.5",
"core-js": "3.10.2",
"mattermost-redux": "5.33.1",
"memoize-one": "^5.2.1",
"react": "17.0.2",
"react-custom-scrollbars": "^4.2.1",
"react-redux": "7.2.3",
"redux": "4.0.5",
"typescript": "4.2.4"
},
"jest": {
"snapshotSerializers": [
"<rootDir>/node_modules/enzyme-to-json/serializer"
],
"testPathIgnorePatterns": [
"/node_modules/",
"/non_npm_dependencies/"
],
"clearMocks": true,
"collectCoverageFrom": [
"src/**/*.{js,jsx}"
],
"coverageReporters": [
"lcov",
"text-summary"
],
"moduleNameMapper": {
"^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "identity-obj-proxy",
"^.+\\.(css|less|scss)$": "identity-obj-proxy",
"^.*i18n.*\\.(json)$": "<rootDir>/tests/i18n_mock.json",
"^bundle-loader\\?lazy\\!(.*)$": "$1"
},
"moduleDirectories": [
"",
"node_modules",
"non_npm_dependencies"
],
"reporters": [
"default",
"jest-junit"
],
"transformIgnorePatterns": [
"node_modules/(?!react-native|react-router|loop-plugin-sdk)"
],
"setupFiles": [
"jest-canvas-mock"
],
"setupFilesAfterEnv": [
"<rootDir>/tests/setup.tsx"
],
"testURL": "http://localhost:8065"
}
}

View File

@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {id as pluginId} from '../manifest';
export default {
RECEIVED_SHOW_RHS_ACTION: pluginId + '_received_rhs_action',
RECEIVED_RHS_VIEW: pluginId + '_received_rhs_view',
RECEIVED_RHS_USER: pluginId + '_received_rhs_user',
RECEIVED_RHS_BADGE: pluginId + '_received_rhs_badge',
};

View File

@ -0,0 +1,126 @@
import {AnyAction, Dispatch} from 'redux';
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {GetStateFunc} from 'mattermost-redux/types/actions';
import {Client4} from 'mattermost-redux/client';
import {IntegrationTypes} from 'mattermost-redux/action_types';
import ActionTypes from 'action_types/';
import {BadgeID} from 'types/badges';
import {RHSState} from 'types/general';
/**
* Stores`showRHSPlugin` action returned by
* registerRightHandSidebarComponent in plugin initialization.
*/
export function setShowRHSAction(showRHSPluginAction: () => void) {
return {
type: ActionTypes.RECEIVED_SHOW_RHS_ACTION,
showRHSPluginAction,
};
}
export function setRHSUser(userID: string | null) {
return {
type: ActionTypes.RECEIVED_RHS_USER,
data: userID,
};
}
export function setRHSBadge(badgeID: BadgeID | null) {
return {
type: ActionTypes.RECEIVED_RHS_BADGE,
data: badgeID,
};
}
export function setRHSView(view: RHSState) {
return {
type: ActionTypes.RECEIVED_RHS_VIEW,
data: view,
};
}
export function setTriggerId(triggerId: string) {
return {
type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
data: triggerId,
};
}
export function openGrant(user?: string, badge?: string) {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
let command = '/badges grant';
if (user) {
command += ` --user ${user}`;
}
if (badge) {
command += ` --badge ${badge}`;
}
clientExecuteCommand(dispatch, getState, command);
return {data: true};
};
}
export function openCreateType() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
const command = '/badges create type';
clientExecuteCommand(dispatch, getState, command);
return {data: true};
};
}
export function openCreateBadge() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
const command = '/badges create badge';
clientExecuteCommand(dispatch, getState, command);
return {data: true};
};
}
export function openAddSubscription() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
const command = '/badges subscription create';
clientExecuteCommand(dispatch, getState, command);
return {data: true};
};
}
export function openRemoveSubscription() {
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
const command = '/badges subscription remove';
clientExecuteCommand(dispatch, getState, command);
return {data: true};
};
}
export async function clientExecuteCommand(dispatch: Dispatch<AnyAction>, getState: GetStateFunc, command: string) {
let currentChannel = getCurrentChannel(getState());
const currentTeamId = getCurrentTeamId(getState());
// Default to town square if there is no current channel (i.e., if Mattermost has not yet loaded)
if (!currentChannel) {
currentChannel = await Client4.getChannelByName(currentTeamId, 'town-square');
}
const args = {
channel_id: currentChannel?.id,
team_id: currentTeamId,
};
try {
//@ts-ignore Typing in mattermost-redux is wrong
const data = await Client4.executeCommand(command, args);
dispatch(setTriggerId(data?.trigger_id));
} catch (error) {
console.error(error); //eslint-disable-line no-console
}
}

66
webapp/src/client/api.ts Normal file
View File

@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from 'mattermost-redux/client';
import {ClientError} from 'mattermost-redux/client/client4';
import manifest from 'manifest';
import {AllBadgesBadge, BadgeDetails, BadgeID, UserBadge} from 'types/badges';
export default class Client {
private url: string;
constructor() {
this.url = '/plugins/' + manifest.id + '/api/v1';
}
async getUserBadges(userID: string): Promise<UserBadge[]> {
try {
const res = await this.doGet(`${this.url}/getUserBadges/${userID}`);
return res as UserBadge[];
} catch {
return [];
}
}
async getBadgeDetails(badgeID: BadgeID): Promise<BadgeDetails|null> {
try {
const res = await this.doGet(`${this.url}/getBadgeDetails/${badgeID}`);
return res as BadgeDetails;
} catch {
return null;
}
}
async getAllBadges(): Promise<AllBadgesBadge[]> {
try {
const res = await this.doGet(`${this.url}/getAllBadges`);
return res as AllBadgesBadge[];
} catch {
return [];
}
}
private doGet = async (url: string, headers: {[x:string]: string} = {}) => {
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
const options = {
method: 'get',
headers,
};
const response = await fetch(url, Client4.getOptions(options));
if (response.ok) {
return response.json();
}
const text = await response.text();
throw new ClientError(Client4.url, {
message: text || '',
status_code: response.status,
url,
});
}
}

View File

@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
export default function ChannelHeaderButton() {
const btnClass = 'icon fa fa-award';
return (
<svg
aria-hidden='true'
focusable='false'
data-prefix='fas'
data-icon='award'
className={btnClass}
role='img'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 384 512'
width={16}
height={16}
>
<path
fill='currentColor'
d='M97.12 362.63c-8.69-8.69-4.16-6.24-25.12-11.85-9.51-2.55-17.87-7.45-25.43-13.32L1.2 448.7c-4.39 10.77 3.81 22.47 15.43 22.03l52.69-2.01L105.56 507c8 8.44 22.04 5.81 26.43-4.96l52.05-127.62c-10.84 6.04-22.87 9.58-35.31 9.58-19.5 0-37.82-7.59-51.61-21.37zM382.8 448.7l-45.37-111.24c-7.56 5.88-15.92 10.77-25.43 13.32-21.07 5.64-16.45 3.18-25.12 11.85-13.79 13.78-32.12 21.37-51.62 21.37-12.44 0-24.47-3.55-35.31-9.58L252 502.04c4.39 10.77 18.44 13.4 26.43 4.96l36.25-38.28 52.69 2.01c11.62.44 19.82-11.27 15.43-22.03zM263 340c15.28-15.55 17.03-14.21 38.79-20.14 13.89-3.79 24.75-14.84 28.47-28.98 7.48-28.4 5.54-24.97 25.95-45.75 10.17-10.35 14.14-25.44 10.42-39.58-7.47-28.38-7.48-24.42 0-52.83 3.72-14.14-.25-29.23-10.42-39.58-20.41-20.78-18.47-17.36-25.95-45.75-3.72-14.14-14.58-25.19-28.47-28.98-27.88-7.61-24.52-5.62-44.95-26.41-10.17-10.35-25-14.4-38.89-10.61-27.87 7.6-23.98 7.61-51.9 0-13.89-3.79-28.72.25-38.89 10.61-20.41 20.78-17.05 18.8-44.94 26.41-13.89 3.79-24.75 14.84-28.47 28.98-7.47 28.39-5.54 24.97-25.95 45.75-10.17 10.35-14.15 25.44-10.42 39.58 7.47 28.36 7.48 24.4 0 52.82-3.72 14.14.25 29.23 10.42 39.59 20.41 20.78 18.47 17.35 25.95 45.75 3.72 14.14 14.58 25.19 28.47 28.98C104.6 325.96 106.27 325 121 340c13.23 13.47 33.84 15.88 49.74 5.82a39.676 39.676 0 0 1 42.53 0c15.89 10.06 36.5 7.65 49.73-5.82zM97.66 175.96c0-53.03 42.24-96.02 94.34-96.02s94.34 42.99 94.34 96.02-42.24 96.02-94.34 96.02-94.34-42.99-94.34-96.02z'
/>
</svg>
);
}

View File

@ -0,0 +1,6 @@
.AllBadges {
display: flex;
flex-flow: column;
height: 100%;
padding: 10px;
}

View File

@ -0,0 +1,90 @@
import React from 'react';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
import {BadgeID, AllBadgesBadge} from '../../types/badges';
import Client from '../../client/api';
import {RHSState} from '../../types/general';
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL} from '../../constants';
import AllBadgesRow from './all_badges_row';
import RHSScrollbars from './rhs_scrollbars';
import './all_badges.scss';
type Props = {
actions: {
setRHSView: (view: RHSState) => void;
setRHSBadge: (badge: BadgeID | null) => void;
getCustomEmojisByName: (names: string[]) => void;
};
}
type State = {
loading: boolean;
badges?: AllBadgesBadge[];
}
class AllBadges extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: true,
};
}
componentDidMount() {
const c = new Client();
c.getAllBadges().then((badges) => {
this.setState({badges, loading: false});
});
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.badges !== prevState.badges) {
const names: string[] = [];
this.state.badges?.forEach((badge) => {
if (badge.image_type === IMAGE_TYPE_EMOJI) {
names.push(badge.image);
}
});
const toLoad = names.filter((v) => !systemEmojis.has(v));
this.props.actions.getCustomEmojisByName(toLoad);
}
}
onBadgeClick = (badge: AllBadgesBadge) => {
this.props.actions.setRHSBadge(badge.id);
this.props.actions.setRHSView(RHS_STATE_DETAIL);
}
render() {
if (this.state.loading) {
return (<div className='AllBadges'>{'Loading...'}</div>);
}
if (!this.state.badges || this.state.badges.length === 0) {
return (<div className='AllBadges'>{'No badges yet.'}</div>);
}
const content = this.state.badges.map((badge) => {
return (
<AllBadgesRow
key={badge.id}
badge={badge}
onClick={this.onBadgeClick}
/>
);
});
return (
<div className='AllBadges'>
<div><b>{'All badges'}</b></div>
<RHSScrollbars>{content}</RHSScrollbars>
</div>
);
}
}
export default AllBadges;

View File

@ -0,0 +1,25 @@
.AllBadgesRow {
display: flex;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
align-items: center;
padding: 5px;
margin-bottom: 3px;
.badge-icon {
padding: 10px;
}
.badge-name {
font-weight: 600;
}
.granted-by {
font-size: 10px;
}
.badge-type {
font-size: 10px;
}
.badge-descrition {
p {
margin: 0px
}
}
}

View File

@ -0,0 +1,49 @@
import React from 'react';
import {AllBadgesBadge} from '../../types/badges';
import BadgeImage from '../utils/badge_image';
import {markdown} from 'utils/markdown';
import './all_badges_row.scss';
type Props = {
badge: AllBadgesBadge;
onClick: (badge: AllBadgesBadge) => void;
}
function getGrantedText(badge: AllBadgesBadge): string {
if (badge.granted === 0) {
return 'Not yet granted.';
}
if (badge.multiple) {
return `Granted ${badge.granted_times} to ${badge.granted} users.`;
}
return `Granted to ${badge.granted} users.`;
}
const AllBadgesRow: React.FC<Props> = ({badge, onClick}: Props) => {
return (
<div className='AllBadgesRow'>
<a
className='badge-icon'
onClick={() => onClick(badge)}
>
<span>
<BadgeImage
badge={badge}
size={32}
/>
</span>
</a>
<div>
<div className='badge-name'>{badge.name}</div>
<div className='badge-description'>{markdown(badge.description)}</div>
<div className='badge-type'>{'Type: ' + badge.type_name}</div>
<div className='granted-by'>{getGrantedText(badge)}</div>
</div>
</div>
);
};
export default AllBadgesRow;

View File

@ -0,0 +1,28 @@
.BadgeDetails {
display: flex;
flex-flow: column;
height: 100%;
padding: 10px;
.badge-info {
display: flex;
align-items: center;
padding: 5px;
.badge-icon {
padding: 10px;
}
.badge-name {
font-weight: 600;
}
.created-by {
font-size: 10px;
}
.badge-descrition {
p {
margin: 0px
}
}
.badge-type {
font-size: 10px;
}
}
}

View File

@ -0,0 +1,134 @@
import React from 'react';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
import {BadgeDetails, BadgeID} from '../../types/badges';
import Client from '../../client/api';
import {RHSState} from '../../types/general';
import {RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
import BadgeImage from '../utils/badge_image';
import {markdown} from 'utils/markdown';
import RHSScrollbars from './rhs_scrollbars';
import UserRow from './user_row';
import './badge_details.scss';
type Props = {
badgeID: BadgeID | null;
currentUserID: string;
actions: {
setRHSView: (view: RHSState) => void;
setRHSUser: (user: string | null) => void;
getCustomEmojiByName: (names: string) => void;
};
}
type State = {
loading: boolean;
badge?: BadgeDetails | null;
}
class BadgeDetailsComponent extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: true,
};
}
componentDidMount() {
if (this.props.badgeID === null) {
return;
}
const c = new Client();
c.getBadgeDetails(this.props.badgeID).then((badge) => {
this.setState({badge, loading: false});
});
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.badge !== prevState.badge && this.state.badge && !systemEmojis.has(this.state.badge.name)) {
this.props.actions.getCustomEmojiByName(this.state.badge.name);
}
if (this.props.badgeID === prevProps.badgeID) {
return;
}
if (this.props.badgeID === null) {
return;
}
const c = new Client();
if (!this.state.loading) {
this.setState({loading: true});
}
c.getBadgeDetails(this.props.badgeID).then((badge) => {
this.setState({badge, loading: false});
});
}
onUserClick = (user: string) => {
if (user === this.props.currentUserID) {
this.props.actions.setRHSView(RHS_STATE_MY);
return;
}
this.props.actions.setRHSUser(user);
this.props.actions.setRHSView(RHS_STATE_OTHER);
}
render() {
const {badge, loading} = this.state;
if (this.props.badgeID == null) {
return (<div>{'Badge not found.'}</div>);
}
if (loading) {
return (<div>{'Loading...'}</div>);
}
if (!badge) {
return (<div>{'Badge not found.'}</div>);
}
const content = badge.owners.map((ownership) => {
return (
<UserRow
key={ownership.time}
ownership={ownership}
onClick={this.onUserClick}
/>
);
});
return (
<div className='BadgeDetails'>
<div><b>{'Badge Details'}</b></div>
<div className='badge-info'>
<span className='badge-icon'>
<BadgeImage
badge={badge}
size={32}
/>
</span>
<div className='badge-text'>
<div className='badge-name'>{badge.name}</div>
<div className='badge-description'>{markdown(badge.description)}</div>
<div className='badge-type'>{'Type: ' + badge.type_name}</div>
<div className='created-by'>{`Created by: ${badge.created_by_username}`}</div>
</div>
</div>
<div><b>{'Granted to:'}</b></div>
<RHSScrollbars>{content}</RHSScrollbars>
</div>
);
}
}
export default BadgeDetailsComponent;

View File

@ -0,0 +1,85 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
import {useDispatch, useSelector} from 'react-redux';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import React from 'react';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {GlobalState} from 'mattermost-redux/types/store';
import {getCustomEmojiByName, getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
import {getRHSBadge, getRHSUser, getRHSView} from 'selectors';
import {RHS_STATE_ALL, RHS_STATE_DETAIL, RHS_STATE_OTHER, RHS_STATE_MY} from '../../constants';
import {RHSState} from 'types/general';
import {setRHSBadge, setRHSUser, setRHSView} from 'actions/actions';
import {BadgeID} from 'types/badges';
import UserBadges from './user_badges';
import BadgeDetailsComponent from './badge_details';
import AllBadges from './all_badges';
const RHS: React.FC = () => {
const dispatch = useDispatch();
const currentView = useSelector(getRHSView);
const currentBadge = useSelector(getRHSBadge);
const currentUserID = useSelector(getRHSUser);
const currentUser = useSelector((state: GlobalState) => getUser(state, (currentUserID as string)));
const myUser = useSelector(getCurrentUser);
switch (currentView) {
case RHS_STATE_ALL:
return (
<AllBadges
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
}}
/>
);
case RHS_STATE_DETAIL:
return (
<BadgeDetailsComponent
badgeID={currentBadge}
currentUserID={myUser.id}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSUser: (user: string | null) => dispatch(setRHSUser(user)),
getCustomEmojiByName: (names: string) => dispatch(getCustomEmojiByName(names)),
}}
/>
);
case RHS_STATE_OTHER:
return (
<UserBadges
user={currentUser}
isCurrentUser={false}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
}}
/>
);
case RHS_STATE_MY:
default:
return (
<UserBadges
user={myUser}
isCurrentUser={true}
actions={{
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
}}
/>
);
}
};
export default RHS;

View File

@ -0,0 +1,45 @@
import React from 'react';
import Scrollbars from 'react-custom-scrollbars';
function renderView(props: any) {
return (
<div
{...props}
className='scrollbar--view'
/>);
}
function renderThumbHorizontal(props: any) {
return (
<div
{...props}
className='scrollbar--horizontal'
/>);
}
function renderThumbVertical(props: any) {
return (
<div
{...props}
className='scrollbar--vertical'
/>);
}
const RHSScrollbars = ({children}: {children: React.ReactNode[]}) => {
return (
<Scrollbars
autoHide={true}
autoHideTimeout={500}
autoHideDuration={500}
renderThumbHorizontal={renderThumbHorizontal}
renderThumbVertical={renderThumbVertical}
renderView={renderView}
style={{flex: '1 1 auto', height: ''}}
>
{children}
</Scrollbars>
);
};
export default RHSScrollbars;

View File

@ -0,0 +1,28 @@
.UserBadgesRow {
display: flex;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
align-items: center;
padding: 5px;
margin-bottom: 3px;
.user-badge-icon {
padding: 10px;
}
.user-badge-name {
font-weight: 600;
}
.user-badge-granted-by {
font-size: 10px;
}
.user-badge-granted-at {
font-size: 10px;
}
.user-badge-descrition {
p {
margin: 0px
}
}
.user-badge-type {
font-size: 10px;
}
}

View File

@ -0,0 +1,61 @@
import React from 'react';
import Client4 from 'mattermost-redux/client/client4';
import {UserBadge} from '../../types/badges';
import BadgeImage from '../utils/badge_image';
import {markdown} from 'utils/markdown';
import './user_badge_row.scss';
type Props = {
badge: UserBadge;
isCurrentUser: boolean;
onClick: (badge: UserBadge) => void;
}
const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) => {
const time = new Date(badge.time);
let reason = null;
if (badge.reason) {
reason = (<div className='badge-user-reason'>{'Why? ' + badge.reason}</div>);
}
let setStatus = null;
if (isCurrentUser && badge.image_type === 'emoji') {
setStatus = (
<div className='user-badge-set-status'>
<a
onClick={() => {
const c = new Client4();
c.updateCustomStatus({emoji: badge.image, text: badge.name});
}}
>
{'Set status to this badge'}
</a>
</div>
);
}
return (
<div className='UserBadgesRow'>
<a onClick={() => onClick(badge)}>
<span className='user-badge-icon'>
<BadgeImage
badge={badge}
size={32}
/>
</span>
</a>
<div className='user-badge-text'>
<div className='user-badge-name'>{badge.name}</div>
<div className='user-badge-description'>{markdown(badge.description)}</div>
{reason}
<div className='user-badge-type'>{'Type: ' + badge.type_name}</div>
<div className='user-badge-granted-by'>{`Granted by: ${badge.granted_by_name}`}</div>
<div className='user-badge-granted-at'>{`Granted at: ${time.toDateString()}`}</div>
{setStatus}
</div>
</div>
);
};
export default UserBadgeRow;

View File

@ -0,0 +1,6 @@
.UserBadges {
display: flex;
flex-flow: column;
height: 100%;
padding: 10px;
}

View File

@ -0,0 +1,122 @@
import React from 'react';
import {UserProfile} from 'mattermost-redux/types/users';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
import {BadgeID, UserBadge} from '../../types/badges';
import Client from '../../client/api';
import {RHSState} from 'types/general';
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL} from '../../constants';
import UserBadgeRow from './user_badge_row';
import RHSScrollbars from './rhs_scrollbars';
import './user_badges.scss';
type Props = {
isCurrentUser: boolean;
user: UserProfile | null;
actions: {
setRHSView: (view: RHSState) => void;
setRHSBadge: (badge: BadgeID | null) => void;
getCustomEmojisByName: (names: string[]) => void;
};
}
type State = {
loading: boolean;
badges?: UserBadge[];
}
class UserBadges extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
loading: true,
};
}
componentDidMount() {
if (!this.props.user) {
return;
}
const c = new Client();
c.getUserBadges(this.props.user.id).then((badges) => {
this.setState({badges, loading: false});
});
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.badges !== prevState.badges) {
const names: string[] = [];
this.state.badges?.forEach((badge) => {
if (badge.image_type === IMAGE_TYPE_EMOJI) {
names.push(badge.image);
}
});
const toLoad = names.filter((v) => !systemEmojis.has(v));
this.props.actions.getCustomEmojisByName(toLoad);
}
if (this.props.user?.id === prevProps.user?.id) {
return;
}
if (!this.props.user) {
return;
}
const c = new Client();
if (!this.state.loading) {
this.setState({loading: true});
}
c.getUserBadges(this.props.user.id).then((badges) => {
this.setState({badges, loading: false});
});
}
onBadgeClick = (badge: UserBadge) => {
this.props.actions.setRHSBadge(badge.id);
this.props.actions.setRHSView(RHS_STATE_DETAIL);
}
render() {
if (!this.props.user) {
return (<div>{'User not found.'}</div>);
}
if (this.state.loading) {
return (<div>{'Loading...'}</div>);
}
if (!this.state.badges || this.state.badges.length === 0) {
return (<div>{'No badges yet.'}</div>);
}
const content = this.state.badges.map((badge) => {
return (
<UserBadgeRow
isCurrentUser={this.props.isCurrentUser}
key={badge.time}
badge={badge}
onClick={this.onBadgeClick}
/>
);
});
let title = 'My badges';
if (!this.props.isCurrentUser) {
title = `@${this.props.user.username}'s badges`;
}
return (
<div className='UserBadges'>
<div><b>{title}</b></div>
<RHSScrollbars>{content}</RHSScrollbars>
</div>
);
}
}
export default UserBadges;

View File

@ -0,0 +1,13 @@
.UserRow {
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
align-items: center;
padding: 5px;
margin-bottom: 3px;
.badge-user-username {
font-weight: 600;
}
.badge-user-granted-at {
font-size: 10px;
}
}

View File

@ -0,0 +1,39 @@
import React from 'react';
import {useSelector} from 'react-redux';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import {GlobalState} from 'mattermost-redux/types/store';
import {UserProfile} from 'mattermost-redux/types/users';
import {Ownership} from '../../types/badges';
import './user_row.scss';
type Props = {
ownership: Ownership;
onClick: (user: string) => void;
}
const UserBadgeRow: React.FC<Props> = ({ownership, onClick}: Props) => {
const user = useSelector<GlobalState, UserProfile>((state) => getUser(state, ownership.user));
const grantedBy = useSelector<GlobalState, UserProfile>((state) => getUser(state, ownership.granted_by));
if (!user) {
return null;
}
let grantedByName = 'unknown';
if (grantedBy) {
grantedByName = '@' + grantedBy.username;
}
const time = new Date(ownership.time);
return (
<div className='UserRow'>
<div className='badge-user-username'><a onClick={() => onClick(ownership.user)}>{`@${user.username}`}</a></div>
<div className='badge-user-granted-by'>{`Granted by: ${grantedByName}`}</div>
<div className='badge-user-granted-at'>{`Granted at: ${time.toDateString()}`}</div>
</div>
);
};
export default UserBadgeRow;

View File

@ -0,0 +1,41 @@
#badgePlugin {
#contentContainer {
display: flex;
align-content: flex-end;
align-items: center;
}
#showMoreButton {
background-color: var(--center-channel-bg);
color: rgba(var(--center-channel-color), 0.56);
border: 0;
border-radius: 4px;
font-weight: 600;
height: 24px;
width: 24px;
font-size: 18px;
margin-left: 6px;
padding: 0;
&:hover {
background: rgba(var(--center-channel-color), 0.08);
color: rgba(var(--center-channel-color), 0.72);
}
}
#grantBadgeButton {
margin-top: 4px;
padding-left: 0;
border: 0;
background-color: var(--center-channel-bg);
color: var(--button-bg);
font-size: 12px;
font-weight: 600;
gap: 6px;
line-height: 10px;
.fa-plus-circle {
margin-right: 4px;
}
}
}

View File

@ -0,0 +1,189 @@
import {UserProfile} from 'mattermost-redux/types/users';
import React from 'react';
import {Tooltip, OverlayTrigger} from 'react-bootstrap';
import {GlobalState} from 'mattermost-redux/types/store';
import {systemEmojis} from 'mattermost-redux/actions/emojis';
import {BadgeID, UserBadge} from 'types/badges';
import Client from 'client/api';
import BadgeImage from '../utils/badge_image';
import {RHSState} from 'types/general';
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL, RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
import {markdown} from 'utils/markdown';
import './badge_list.scss';
type Props = {
debug: GlobalState;
user: UserProfile;
currentUserID: string;
openRHS: (() => void) | null;
hide: () => void;
status?: string;
actions: {
setRHSView: (view: RHSState) => Promise<void>;
setRHSBadge: (id: BadgeID | null) => Promise<void>;
setRHSUser: (id: string | null) => Promise<void>;
openGrant: (user?: string, badge?: string) => Promise<void>;
getCustomEmojisByName: (names: string[]) => Promise<unknown>;
};
}
type State = {
badges?: UserBadge[];
loaded?: boolean;
}
const MAX_BADGES = 7;
const BADGE_SIZE = 24;
class BadgeList extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
}
componentDidMount() {
const c = new Client();
c.getUserBadges(this.props.user.id).then((badges) => {
this.setState({badges, loaded: true});
});
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (this.state.badges !== prevState.badges) {
const nBadges = this.state.badges?.length || 0;
const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES;
const names: string[] = [];
for (let i = 0; i < toShow; i++) {
const badge = this.state.badges![i];
if (badge.image_type === IMAGE_TYPE_EMOJI) {
names.push(badge.image);
}
}
const toLoad = names.filter((v) => !systemEmojis.has(v));
this.props.actions.getCustomEmojisByName(toLoad);
}
}
onMoreClick = () => {
if (!this.props.openRHS) {
return;
}
if (this.props.currentUserID === this.props.user.id) {
this.props.actions.setRHSView(RHS_STATE_MY);
this.props.openRHS();
return;
}
this.props.actions.setRHSUser(this.props.user.id);
this.props.actions.setRHSView(RHS_STATE_OTHER);
this.props.openRHS();
this.props.hide();
}
onBadgeClick = (badge: UserBadge) => {
if (!this.props.openRHS) {
return;
}
this.props.actions.setRHSBadge(badge.id);
this.props.actions.setRHSView(RHS_STATE_DETAIL);
this.props.openRHS();
this.props.hide();
}
onGrantClick = () => {
this.props.actions.openGrant(this.props.user.username);
this.props.hide();
}
render() {
const nBadges = this.state.badges?.length || 0;
const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES;
const content: React.ReactNode[] = [];
for (let i = 0; i < toShow; i++) {
const badge = this.state.badges![i];
const time = new Date(badge.time);
let reason = null;
if (badge.reason) {
reason = (<div>{'Why? ' + badge.reason}</div>);
}
const badgeComponent = (
<OverlayTrigger
overlay={<Tooltip id='badgeTooltip'>
<div>{badge.name}</div>
<div>{markdown(badge.description)}</div>
{reason}
<div>{`Granted by: ${badge.granted_by_name}`}</div>
<div>{`Granted at: ${time.toDateString()}`}</div>
</Tooltip>}
>
<span>
<a onClick={() => this.onBadgeClick(badge)}>
<BadgeImage
badge={badge}
size={BADGE_SIZE}
/>
</a>
</span>
</OverlayTrigger>
);
content.push(badgeComponent);
}
let andMore: React.ReactNode = null;
if (nBadges > MAX_BADGES) {
andMore = (
<OverlayTrigger
overlay={<Tooltip id='badgeMoreTooltip'>
{`and ${nBadges - MAX_BADGES} more. Click to see all.`}
</Tooltip>}
>
<button
id='showMoreButton'
onClick={this.onMoreClick}
>
<span className={'fa fa-angle-right'}/>
</button>
</OverlayTrigger>
);
}
const maxWidth = (MAX_BADGES * BADGE_SIZE) + 30;
let loading: React.ReactNode = null;
if (!this.state.loaded) {
loading = (
// Reserve enough height one row of badges and the "and more" button
<div style={{height: BADGE_SIZE, minWidth: 66, maxWidth}}>
{'Loading...'}
</div>
);
}
return (
<div id='badgePlugin'>
<div><b>{'Badges'}</b></div>
<div id='contentContainer' >
{content}
{andMore}
</div>
{loading}
<button
id='grantBadgeButton'
onClick={this.onGrantClick}
>
<span className={'fa fa-plus-circle'}/>
{'Grant badge'}
</button>
<hr className='divider divider--expanded'/>
</div>
);
}
}
export default BadgeList;

View File

@ -0,0 +1,49 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License for license information.
import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {GlobalState} from 'mattermost-redux/types/store';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
import {setRHSView, setRHSBadge, setRHSUser, openGrant} from '../../actions/actions';
import {getShowRHS} from 'selectors';
import {RHSState} from 'types/general';
import {BadgeID} from 'types/badges';
import BadgeList from './badge_list';
function mapStateToProps(state: GlobalState) {
return {
openRHS: getShowRHS(state),
currentUserID: getCurrentUserId(state),
debug: state,
};
}
type Actions = {
setRHSView: (view: RHSState) => Promise<void>;
setRHSBadge: (id: BadgeID | null) => Promise<void>;
setRHSUser: (id: string | null) => Promise<void>;
openGrant: (user?: string, badge?: string) => Promise<void>;
getCustomEmojisByName: (names: string[]) => Promise<unknown>;
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject, Actions>({
setRHSView,
setRHSBadge,
setRHSUser,
openGrant,
getCustomEmojisByName,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(BadgeList);

View File

@ -0,0 +1,34 @@
import React from 'react';
import {Badge} from '../../types/badges';
import RenderEmoji from '../utils/emoji';
import {IMAGE_TYPE_ABSOLUTE_URL, IMAGE_TYPE_EMOJI} from '../../constants';
type Props = {
badge: Badge;
size: number;
}
const BadgeImage: React.FC<Props> = ({badge, size}: Props) => {
switch (badge.image_type) {
case IMAGE_TYPE_EMOJI:
return (
<RenderEmoji
emojiName={badge.image}
size={size}
/>
);
case IMAGE_TYPE_ABSOLUTE_URL:
return (
<img
src={badge.image}
width={size}
height={size}
/>
);
default:
return null;
}
};
export default BadgeImage;

View File

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useSelector} from 'react-redux';
import {GlobalState} from 'mattermost-redux/types/store';
import {getEmojiMap} from 'selectors';
import {getEmojiImageUrl} from 'utils/emoji';
interface ComponentProps {
emojiName: string;
size?: number;
emojiStyle?: React.CSSProperties;
}
const RenderEmoji = ({emojiName, emojiStyle, size}: ComponentProps) => {
const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state));
if (!emojiName) {
return null;
}
const emojiFromMap = emojiMap.get(emojiName);
if (!emojiFromMap) {
return null;
}
const emojiImageUrl = getEmojiImageUrl(emojiFromMap);
return (
<span
className='emoticon'
data-emoticon={emojiName}
style={{
backgroundImage: `url(${emojiImageUrl})`,
backgroundSize: size,
height: size,
width: size,
maxHeight: size,
maxWidth: size,
minHeight: size,
minWidth: size,
...emojiStyle,
}}
/>
);
};
RenderEmoji.defaultProps = {
emoji: '',
emojiStyle: {},
size: 16,
};
export default React.memo(RenderEmoji);

17
webapp/src/constants.ts Normal file
View File

@ -0,0 +1,17 @@
import {PluginState, RHSState} from 'types/general';
export const IMAGE_TYPE_EMOJI = 'emoji';
export const IMAGE_TYPE_RELATIVE_URL = 'rel_url';
export const IMAGE_TYPE_ABSOLUTE_URL = 'abs_url';
export const RHS_STATE_MY: RHSState = 'my';
export const RHS_STATE_OTHER: RHSState = 'other';
export const RHS_STATE_ALL: RHSState = 'all';
export const RHS_STATE_DETAIL: RHSState = 'detail';
export const initialState: PluginState = {
showRHS: null,
rhsView: RHS_STATE_MY,
rhsBadge: null,
rhsUser: null,
};

93
webapp/src/index.tsx Normal file
View File

@ -0,0 +1,93 @@
import {Store} from 'redux';
import {GlobalState} from 'mattermost-redux/types/store';
import {GenericAction} from 'mattermost-redux/types/actions';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import React from 'react';
import {openAddSubscription, openCreateBadge, openCreateType, openRemoveSubscription, setRHSView, setShowRHSAction} from 'actions/actions';
import UserBadges from 'components/rhs';
import ChannelHeaderButton from 'components/channel_header_button';
import Reducer from './reducers';
import manifest from './manifest';
// eslint-disable-next-line import/no-unresolved
import {PluginRegistry} from './types/mattermost-webapp';
import BadgeList from './components/user_popover/';
import {RHS_STATE_ALL} from './constants';
export default class Plugin {
public async initialize(registry: PluginRegistry, store: Store<GlobalState, GenericAction>) {
registry.registerReducer(Reducer);
registry.registerPopoverUserAttributesComponent(BadgeList);
const {showRHSPlugin, toggleRHSPlugin} = registry.registerRightHandSidebarComponent(UserBadges, 'Badges');
store.dispatch(setShowRHSAction(() => store.dispatch(showRHSPlugin)));
const toggleRHS = () => {
store.dispatch(setRHSView(RHS_STATE_ALL));
store.dispatch(toggleRHSPlugin);
}
registry.registerChannelHeaderButtonAction(
<ChannelHeaderButton/>,
toggleRHS,
'Badges',
'Open the list of all badges.',
);
if (registry.registerAppBarComponent) {
const siteUrl = getConfig(store.getState())?.SiteURL || '';
const iconURL = `${siteUrl}/plugins/${manifest.id}/public/app-bar-icon.png`;
registry.registerAppBarComponent(
iconURL,
toggleRHS,
'Open the list of all badges.',
);
}
registry.registerMainMenuAction(
'Create badge',
() => {
store.dispatch(openCreateBadge() as any);
},
null,
);
registry.registerMainMenuAction(
'Create badge type',
() => {
store.dispatch(openCreateType() as any);
},
null,
);
registry.registerChannelHeaderMenuAction(
'Add badge subscription',
() => {
store.dispatch(openAddSubscription() as any);
},
);
registry.registerChannelHeaderMenuAction(
'Remove badge subscription',
() => {
store.dispatch(openRemoveSubscription() as any);
},
);
}
}
declare global {
interface Window {
registerPlugin(id: string, plugin: Plugin): void;
}
}
window.registerPlugin(manifest.id, new Plugin());

View File

@ -0,0 +1,13 @@
import manifest, {id, version} from './manifest';
test('Plugin manifest, id and version are defined', () => {
expect(manifest).toBeDefined();
expect(manifest.id).toBeDefined();
expect(manifest.version).toBeDefined();
});
// To ease migration, verify separate export of id and version.
test('Plugin id and version are defined', () => {
expect(id).toBeDefined();
expect(version).toBeDefined();
});

5
webapp/src/manifest.ts Normal file
View File

@ -0,0 +1,5 @@
import manifest from '../../plugin.json';
export default manifest;
export const id = manifest.id;
export const version = manifest.version;

View File

@ -0,0 +1,49 @@
import {combineReducers} from 'redux';
import {GenericAction} from 'mattermost-redux/types/actions';
import ActionTypes from '../action_types';
import {RHS_STATE_MY} from '../constants';
function showRHS(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_SHOW_RHS_ACTION:
return action.showRHSPluginAction;
default:
return state;
}
}
function rhsView(state = RHS_STATE_MY, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_RHS_VIEW:
return action.data;
default:
return state;
}
}
function rhsUser(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_RHS_USER:
return action.data;
default:
return state;
}
}
function rhsBadge(state = null, action: GenericAction) {
switch (action.type) {
case ActionTypes.RECEIVED_RHS_BADGE:
return action.data;
default:
return state;
}
}
export default combineReducers({
showRHS,
rhsView,
rhsUser,
rhsBadge,
});

View File

@ -0,0 +1,45 @@
import {createSelector} from 'reselect';
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import EmojiMap from '../utils/emoji_map';
import {id as pluginId} from '../manifest';
import {initialState} from '../constants';
import {PluginState} from 'types/general';
const getPluginState = (state: any): PluginState => state['plugins-' + pluginId] || initialState;
export const getEmojiMap = createSelector(
getCustomEmojisByName,
(customEmojisByName) => {
return new EmojiMap(customEmojisByName);
},
);
export const getShowRHS = createSelector(
getPluginState,
(state) => {
return state.showRHS;
},
);
export const getRHSView = createSelector(
getPluginState,
(state) => {
return state.rhsView;
},
);
export const getRHSUser = createSelector(
getPluginState,
(state) => {
return state.rhsUser;
},
);
export const getRHSBadge = createSelector(
getPluginState,
(state) => {
return state.rhsBadge;
},
);

View File

@ -0,0 +1,45 @@
export type Badge = {
id: BadgeID;
name: string;
description: string;
image: string;
image_type: BadgeImageType;
multiple: boolean;
type: BadgeType;
created_by: string;
}
export type Ownership = {
user: string;
granted_by: string;
badge: BadgeID;
reason: string;
time: number;
}
export type BadgeID = number;
export type BadgeType = number
export type BadgeImageType = string;
export type UserBadge = Badge & Ownership & {
granted_by_name: string;
type_name: string;
};
export type BadgeDetails = Badge & {
owners: OwnershipList;
created_by_username: string;
type_name: string;
}
export type AllBadgesBadge = Badge & {
granted: number;
granted_times: number;
type_name: string;
}
export type OwnershipList = Ownership[]
export type BadgeTypeDefinition = {
id: BadgeType;
name: string;
frame: string;
}

View File

@ -0,0 +1,10 @@
import {BadgeID} from './badges';
export type RHSState = string;
export type PluginState = {
showRHS: (() => void)| null;
rhsView: RHSState;
rhsUser: string | null;
rhsBadge: BadgeID | null;
}

View File

@ -0,0 +1,24 @@
import {GenericAction} from 'mattermost-redux/types/actions';
import {Channel, ChannelMembership} from 'mattermost-redux/types/channels';
import React from 'react';
import {Reducer} from 'redux';
export interface PluginRegistry {
registerPostTypeComponent(typeName: string, component: React.ElementType);
registerPopoverUserAttributesComponent(component: React.ElementType);
registerRightHandSidebarComponent(component: React.ElementType, name: string): RegisterRightHandSidebarComponentReturn;
registerReducer(reducer: Reducer);
registerChannelHeaderButtonAction(icon: React.ReactNode, action: () => void, dropdownText: string, tooltip: string);
registerMainMenuAction(text: React.ReactNode, action: () => void, mobileIcon: React.ReactNode);
registerChannelHeaderMenuAction(text: string, action: (channelID: string) => void);
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode)
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
}
type RegisterRightHandSidebarComponentReturn = {
id: string;
showRHSPlugin: GenericAction;
hideRHSPlugin: GenericAction;
toggleRHSPlugin: GenericAction;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {CustomEmoji} from 'mattermost-redux/types/emojis';
import * as Emoji from './emoji';
export default class EmojiMap {
private customEmojis: Map<string, CustomEmoji>;
private customEmojisArray: [string, CustomEmoji][];
constructor(customEmojis: Map<string, CustomEmoji>) {
this.customEmojis = customEmojis;
// Store customEmojis to an array so we can iterate it more easily
this.customEmojisArray = [...customEmojis];
}
has(name: string) {
return Emoji.EmojiIndicesByAlias.has(name) || this.customEmojis.has(name);
}
hasSystemEmoji(name: string) {
return Emoji.EmojiIndicesByAlias.has(name);
}
hasUnicode(codepoint: string) {
return Emoji.EmojiIndicesByUnicode.has(codepoint);
}
get(name: string) {
if (Emoji.EmojiIndicesByAlias.has(name)) {
return Emoji.Emojis[Emoji.EmojiIndicesByAlias.get(name)!];
}
return this.customEmojis.get(name);
}
getUnicode(codepoint: string) {
return Emoji.Emojis[Emoji.EmojiIndicesByUnicode.get(codepoint)!];
}
[Symbol.iterator]() {
const customEmojisArray = this.customEmojisArray;
return {
systemIndex: 0,
customIndex: 0,
next() {
if (this.systemIndex < Emoji.Emojis.length) {
const emoji = Emoji.Emojis[this.systemIndex];
this.systemIndex += 1;
return {value: [(emoji as any).short_names[0], emoji]};
}
if (this.customIndex < customEmojisArray.length) {
const emoji = customEmojisArray[this.customIndex][1] as any;
this.customIndex += 1;
const name = emoji.short_name || emoji.name;
return {value: [name, emoji]};
}
return {done: true};
},
};
}
}

View File

@ -0,0 +1,7 @@
import React from 'react';
export function markdown(text: string, siteURL?: string): React.ReactNode {
const PostUtils = (window as any).PostUtils; // import the post utilities
const htmlFormatedText = PostUtils.formatText(text, {siteURL});
return PostUtils.messageHtmlToComponent(htmlFormatedText);
}

View File

@ -0,0 +1 @@
{}

Some files were not shown because too many files have changed in this diff Show More