Merge: Complete Mattermost Server with Community Enterprise

Full Mattermost server source with integrated Community Enterprise features.
Includes vendor directory for offline/air-gapped builds.

Structure:
- enterprise-impl/: Enterprise feature implementations
- enterprise-community/: Init files that register implementations
- enterprise/: Bridge imports (community_imports.go)
- vendor/: All dependencies for offline builds

Build (online):
  go build ./cmd/mattermost

Build (offline/air-gapped):
  go build -mod=vendor ./cmd/mattermost

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude 2025-12-17 23:59:07 +09:00
parent fad2fe9d3c
commit ec1f89217a
14384 changed files with 10233814 additions and 54 deletions

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
# Binaries
mattermost
*.exe
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
# Logs
*.log
# Local config
config/config.json

860
Makefile Normal file
View File

@ -0,0 +1,860 @@
.PHONY: build package run stop run-client run-server run-node run-haserver stop-haserver stop-client stop-server restart restart-server restart-client restart-haserver start-docker update-docker clean-dist clean nuke check-style check-client-style check-server-style check-unit-tests test dist run-client-tests setup-run-client-tests cleanup-run-client-tests test-client build-linux build-osx build-windows package-prep package-linux package-osx package-windows internal-test-web-client vet run-server-for-web-client-tests diff-config prepackaged-plugins prepackaged-binaries test-server test-server-ee test-server-quick test-server-race test-mmctl-unit test-mmctl-e2e test-mmctl test-mmctl-coverage mmctl-build mmctl-docs new-migration migrations-extract test-public mocks-public
ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
ifeq ($(OS),Windows_NT)
PLATFORM := Windows
else
PLATFORM := $(shell uname)
endif
# Detect Apple Silicon and set a flag.
ifeq ($(shell uname)/$(shell uname -m),Darwin/arm64)
ARM_BASED_MAC = true
endif
define LICENSE_HEADER
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
endef
IS_CI ?= false
# Build Flags
BUILD_NUMBER ?= $(BUILD_NUMBER:)
BUILD_DATE = $(shell date -u)
BUILD_HASH = $(shell git rev-parse HEAD)
BUILD_TAGS =
# Docker
export COMPOSE_PROJECT_NAME=mattermost-server
# If we don't set the build number it defaults to dev
ifeq ($(BUILD_NUMBER),)
BUILD_DATE := n/a
BUILD_NUMBER := dev
endif
# Go test sum configuration
GOTESTSUM_FORMAT ?= testname
GOTESTSUM_JUNITFILE ?= report.xml
GOTESTSUM_JSONFILE ?= gotestsum.json
# Go test coverage
ENABLE_COVERAGE ?= false
ifeq ($(ENABLE_COVERAGE),true)
COVERAGE_FLAG = -coverprofile=cover.out -covermode=atomic
endif
# mmctl
MMCTL_BUILD_TAGS =
MMCTL_TESTFLAGS ?= -timeout 30m
MMCTL_PKG = github.com/mattermost/mattermost/server/v8/cmd/mmctl/commands
MMCTL_BUILD_DATE = $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
MMCTL_LDFLAGS += -X "$(MMCTL_PKG).buildDate=$(MMCTL_BUILD_DATE)"
# Enterprise
BUILD_ENTERPRISE_DIR ?= ../../enterprise
BUILD_ENTERPRISE ?= true
BUILD_ENTERPRISE_READY = false
BUILD_TYPE_NAME = team
BUILD_HASH_ENTERPRISE = none
ifneq ($(wildcard $(BUILD_ENTERPRISE_DIR)/.),)
MMCTL_TESTFLAGS += -ldflags '-X "$(MMCTL_PKG).EnableEnterpriseTests=true" -X "github.com/mattermost/mattermost/server/public/model.BuildEnterpriseReady=true"'
MMCTL_BUILD_TAGS += enterprise
ifeq ($(BUILD_ENTERPRISE),true)
BUILD_ENTERPRISE_READY = true
BUILD_TYPE_NAME = enterprise
BUILD_HASH_ENTERPRISE = $(shell cd $(BUILD_ENTERPRISE_DIR) && git rev-parse HEAD)
BUILD_TAGS += enterprise
else
BUILD_ENTERPRISE_READY = false
BUILD_TYPE_NAME = team
endif
else
BUILD_ENTERPRISE_READY = false
BUILD_TYPE_NAME = team
endif
FIPS_ENABLED ?= false
ifeq ($(FIPS_ENABLED),true)
BUILD_TYPE_NAME := $(BUILD_TYPE_NAME)-fips
endif
# Clean up the old means of importing enterprise source, if it exists
ifneq ($(wildcard channels/imports/imports.go),)
IGNORE := $(shell rm -f channels/imports/imports.go)
endif
# Source available, already included with enterprise but also available during development.
ifeq ($(BUILD_NUMBER),dev)
BUILD_TAGS += sourceavailable
endif
# Webapp
BUILD_WEBAPP_DIR ?= ../webapp
# We need current user's UID for `run-haserver` so docker compose does not run server
# as root and mess up file permissions for devs. When running like this HOME will be blank
# and docker will add '/', so we need to set the go-build cache location or we'll get
# permission errors on build as it tries to create a cache in filesystem root.
export CURRENT_UID = $(shell id -u):$(shell id -g)
ifeq ($(HOME),/)
export XDG_CACHE_HOME = /tmp/go-cache/
endif
# Go Flags
GOFLAGS ?= $(GOFLAGS:)
# We need to export GOBIN to allow it to be set
# for processes spawned from the Makefile
export GOBIN ?= $(PWD)/bin
GO ?= go
ifeq ($(FIPS_ENABLED),true)
BUILD_TAGS += requirefips
endif
DELVE ?= dlv
LDFLAGS += -X "github.com/mattermost/mattermost/server/public/model.BuildNumber=$(BUILD_NUMBER)"
LDFLAGS += -X "github.com/mattermost/mattermost/server/public/model.BuildDate=$(BUILD_DATE)"
LDFLAGS += -X "github.com/mattermost/mattermost/server/public/model.BuildHash=$(BUILD_HASH)"
LDFLAGS += -X "github.com/mattermost/mattermost/server/public/model.BuildHashEnterprise=$(BUILD_HASH_ENTERPRISE)"
LDFLAGS += -X "github.com/mattermost/mattermost/server/public/model.BuildEnterpriseReady=$(BUILD_ENTERPRISE_READY)"
GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
MINIMUM_SUPPORTED_GO_MAJOR_VERSION = 1
MINIMUM_SUPPORTED_GO_MINOR_VERSION = 15
GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MINIMUM_SUPPORTED_GO_MAJOR_VERSION).$(MINIMUM_SUPPORTED_GO_MINOR_VERSION)
GO_COMPATIBILITY_TEST_VERSIONS := 1.22.7 1.23.6
# GOOS/GOARCH of the build host, used to determine whether we're cross-compiling or not
BUILDER_GOOS_GOARCH="$(shell $(GO) env GOOS)_$(shell $(GO) env GOARCH)"
PLATFORM_FILES="./cmd/mattermost"
# Output paths
DIST_ROOT=dist
DIST_PATH=$(DIST_ROOT)/mattermost
DIST_PATH_LIN_AMD64=$(DIST_ROOT)/linux_amd64/mattermost
DIST_PATH_LIN_ARM64=$(DIST_ROOT)/linux_arm64/mattermost
DIST_PATH_OSX_AMD64=$(DIST_ROOT)/darwin_amd64/mattermost
DIST_PATH_OSX_ARM64=$(DIST_ROOT)/darwin_arm64/mattermost
DIST_PATH_WIN=$(DIST_ROOT)/windows/mattermost
# Packages lists
TE_PACKAGES=$(shell $(GO) list ./public/...) $(shell $(GO) list ./... | grep -vE 'server/v8/cmd/mmctl')
MMCTL_PACKAGES=$(shell $(GO) list ./... | grep -E 'server/v8/cmd/mmctl')
TEMPLATES_DIR=templates
# Plugins Packages
PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:)
PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.0
PLUGIN_PACKAGES += mattermost-plugin-github-v2.5.0
PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.11.0
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.4.1
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.6.1
PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.10.0
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.6.3
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.1
PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1
PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.4.0
PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.3.0
PLUGIN_PACKAGES += mattermost-plugin-metrics-v0.7.0
PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.2.1
# Overwrite the definition of PLUGIN_PACKAGES with the list of FIPS-ready plugins
# Note that the '+' in the file name is encoded as %2B for the URL we use to
# download the package from to work. This will no longer be needed when we unify
# the way we pre-package FIPS and non-FIPS plugins.
ifeq ($(FIPS_ENABLED),true)
PLUGIN_PACKAGES = mattermost-plugin-playbooks-v2.6.1%2B0e01d28-fips
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.6.3%2B4acccc8-fips
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.1%2Bdf49b26-fips
endif
EE_PACKAGES=$(shell $(GO) list $(BUILD_ENTERPRISE_DIR)/...)
ifeq ($(BUILD_ENTERPRISE_READY),true)
ALL_PACKAGES=$(TE_PACKAGES) $(EE_PACKAGES)
else
ALL_PACKAGES=$(TE_PACKAGES)
endif
CONFIG_FILE_PATH ?= ./config/config.json
all: run ## Alias for 'run'.
-include config.override.mk
# Make sure not to modify an overridden ENABLED_DOCKER_SERVICES variable
DOCKER_SERVICES_OVERRIDE=false
ifneq (,$(ENABLED_DOCKER_SERVICES))
$(info ENABLED_DOCKER_SERVICES has been overridden)
DOCKER_SERVICES_OVERRIDE=true
endif
include config.mk
include build/*.mk
include public/Makefile
LDFLAGS += -X "github.com/mattermost/mattermost/server/public/model.MockCWS=$(MM_ENABLE_CWS_MOCK)"
LDFLAGS += -X "github.com/mattermost/mattermost/server/public/model.MattermostGiphySdkKey=$(MM_GIPHY_SDK_KEY)"
RUN_IN_BACKGROUND ?=
ifeq ($(RUN_SERVER_IN_BACKGROUND),true)
RUN_IN_BACKGROUND := &
endif
DOCKER_COMPOSE_OVERRIDE=
ifneq ("$(wildcard ./docker-compose.override.yaml)","")
DOCKER_COMPOSE_OVERRIDE=-f docker-compose.override.yaml
endif
ifeq ($(ARM_BASED_MAC),true)
$(info Apple Silicon detected, applying elasticsearch override)
DOCKER_COMPOSE_OVERRIDE := -f docker-compose.makefile.m1.yml $(DOCKER_COMPOSE_OVERRIDE)
endif
# Support for pgvector postgres image
MM_USE_PGVECTOR ?= false
ifeq ($(MM_USE_PGVECTOR),true)
$(info Using pgvector/pgvector image for PostgreSQL)
DOCKER_COMPOSE_OVERRIDE := -f docker-compose.pgvector.yml $(DOCKER_COMPOSE_OVERRIDE)
endif
ifneq ($(DOCKER_SERVICES_OVERRIDE),true)
ifeq (,$(findstring minio,$(ENABLED_DOCKER_SERVICES)))
TEMP_DOCKER_SERVICES:=$(TEMP_DOCKER_SERVICES) minio
endif
ifeq ($(BUILD_ENTERPRISE_READY),true)
ifeq (,$(findstring openldap,$(ENABLED_DOCKER_SERVICES)))
TEMP_DOCKER_SERVICES:=$(TEMP_DOCKER_SERVICES) openldap
endif
endif
ENABLED_DOCKER_SERVICES:=$(ENABLED_DOCKER_SERVICES) $(TEMP_DOCKER_SERVICES)
endif
start-docker: ## Starts the docker containers for local development.
ifneq ($(IS_CI),false)
@echo CI Build: skipping docker start
else ifeq ($(MM_NO_DOCKER),true)
@echo No Docker Enabled: skipping docker start
else
@echo Starting docker containers
ifeq ($(MM_USE_PGVECTOR),true)
@echo Using pgvector PostgreSQL image
endif
docker compose rm start_dependencies
$(GO) run ./build/docker-compose-generator/main.go $(ENABLED_DOCKER_SERVICES) | docker compose -f docker-compose.makefile.yml -f /dev/stdin $(DOCKER_COMPOSE_OVERRIDE) run -T --rm start_dependencies
ifneq (,$(findstring openldap,$(ENABLED_DOCKER_SERVICES)))
cat tests/custom-schema-objectID.ldif | docker compose -f docker-compose.makefile.yml $(DOCKER_COMPOSE_OVERRIDE) exec -T openldap bash -c 'ldapadd -Y EXTERNAL -H ldapi:/// -w mostest || true';
cat tests/custom-schema-cpa.ldif | docker compose -f docker-compose.makefile.yml ${DOCKER_COMPOSE_OVERRIDE} exec -T openldap bash -c 'ldapadd -Y EXTERNAL -H ldapi:/// -w mostest || true';
cat tests/${LDAP_DATA}-data.ldif | docker compose -f docker-compose.makefile.yml ${DOCKER_COMPOSE_OVERRIDE} exec -T openldap bash -c 'ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest || true';
endif
endif
update-docker: stop-docker ## Updates the docker containers for local development.
@echo Updating docker containers
$(GO) run ./build/docker-compose-generator/main.go $(ENABLED_DOCKER_SERVICES) | docker compose -f docker-compose.makefile.yml -f /dev/stdin $(DOCKER_COMPOSE_OVERRIDE) up --no-start
run-haserver:
ifeq ($(BUILD_ENTERPRISE_READY),true)
@echo Starting mattermost in an HA topology '(3 node cluster)'
docker compose -f docker-compose.yaml $(DOCKER_COMPOSE_OVERRIDE) up --remove-orphans haproxy
endif
stop-haserver:
@echo Stopping docker containers for HA topology
docker compose stop
stop-docker: ## Stops the docker containers for local development.
ifeq ($(MM_NO_DOCKER),true)
@echo No Docker Enabled: skipping docker stop
else
@echo Stopping docker containers
docker compose stop
endif
clean-docker: ## Deletes the docker containers for local development.
ifeq ($(MM_NO_DOCKER),true)
@echo No Docker Enabled: skipping docker clean
else
@echo Removing docker containers
docker compose down -v
docker compose rm -v
docker volume rm mattermost-server_postgres-14-data || true
endif
plugin-checker:
$(GO) run $(GOFLAGS) ./public/plugin/checker
prepackaged-plugins: ## Populate the prepackaged-plugins directory.
@echo Downloading prepackaged plugins: $(PLUGIN_PACKAGES)
mkdir -p prepackaged_plugins
@cd prepackaged_plugins && for plugin_package in $(PLUGIN_PACKAGES) ; do \
curl -f -O -L https://plugins.releases.mattermost.com/release/$$plugin_package.tar.gz; \
curl -f -O -L https://plugins.releases.mattermost.com/release/$$plugin_package.tar.gz.sig; \
done
prepackaged-binaries: ## Populate the prepackaged-binaries to the bin directory
ifeq ($(shell test -f bin/mmctl && printf "yes"),yes)
@echo "MMCTL already exists in bin/mmctl, not compiling."
else
$(MAKE) mmctl-build
endif
golang-versions: ## Install Golang versions used for compatibility testing (e.g. plugins)
@for version in $(GO_COMPATIBILITY_TEST_VERSIONS); do \
$(GO) install golang.org/dl/go$$version@latest && \
$(GOBIN)/go$$version download; \
done
export GO_COMPATIBILITY_TEST_VERSIONS="${GO_COMPATIBILITY_TEST_VERSIONS}"
golangci-lint: ## Run golangci-lint on codebase
$(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.0
ifeq ($(BUILD_ENTERPRISE_READY),true)
$(GOBIN)/golangci-lint run ./... ./public/... $(BUILD_ENTERPRISE_DIR)/...
else
$(GOBIN)/golangci-lint run ./... ./public/...
endif
i18n-extract: ## Extract strings for translation from the source code
cd ../tools/mmgotool && $(GO) install .
$(GOBIN)/mmgotool i18n extract --portal-dir=""
i18n-check: ## Exit on empty translation strings and translation source strings
cd ../tools/mmgotool && $(GO) install .
$(GOBIN)/mmgotool i18n clean-empty --portal-dir="" --check
$(GOBIN)/mmgotool i18n check-empty-src --portal-dir=""
store-mocks: ## Creates mock files.
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --config channels/store/.mockery.yaml
cache-mocks:
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --config platform/services/cache/.mockery.yaml
store-layers: ## Generate layers for the store
$(GO) generate $(GOFLAGS) ./channels/store
new-migration: ## Creates a new migration. Run with make new-migration name=<>
$(GO) install github.com/mattermost/morph/cmd/morph@1e0640c
@echo "Generating new migration for postgres"
$(GOBIN)/morph new script $(name) --driver postgres --dir channels/db/migrations --sequence
filestore-mocks: ## Creates mock files.
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --config platform/shared/filestore/.mockery.yaml
ldap-mocks: ## Creates mock files for ldap.
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --dir $(BUILD_ENTERPRISE_DIR)/ldap --all --inpackage --note 'Regenerate this file using `make ldap-mocks`.'
plugin-mocks: ## Creates mock files for plugins.
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --config public/plugin/.mockery.yaml
einterfaces-mocks: ## Creates mock files for einterfaces.
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --config einterfaces/.mockery.yaml
searchengine-mocks: ## Creates mock files for searchengines.
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --config platform/services/searchengine/.mockery.yaml
sharedchannel-mocks: ## Creates mock files for shared channels.
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --config platform/services/sharedchannel/.mockery.yaml
misc-mocks: ## Creates mocks for misc interfaces.
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --config channels/utils/.mockery.yaml
email-mocks: ## Creates mocks for misc interfaces.
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --config channels/app/email/.mockery.yaml
platform-mocks: ## Creates mocks for platform interfaces.
$(GO) install github.com/vektra/mockery/v2/...@v2.53.4
$(GOBIN)/mockery --config channels/app/platform/.mockery.yaml
mmctl-mocks: ## Creates mocks for mmctl
$(GO) install github.com/golang/mock/mockgen@v1.6.0
$(GOBIN)/mockgen -destination=cmd/mmctl/mocks/client_mock.go -copyright_file=cmd/mmctl/mocks/copyright.txt -package=mocks github.com/mattermost/mattermost/server/v8/cmd/mmctl/client Client
pluginapi: ## Generates api and hooks glue code for plugins
cd ./public && $(GO) generate $(GOFLAGS) ./plugin
mocks: store-mocks filestore-mocks ldap-mocks plugin-mocks einterfaces-mocks searchengine-mocks sharedchannel-mocks misc-mocks email-mocks platform-mocks mmctl-mocks mocks-public cache-mocks
layers: store-layers pluginapi
generated: mocks layers
check-prereqs-enterprise: setup-go-work ## Checks prerequisite software status for enterprise.
ifeq ($(BUILD_ENTERPRISE_READY),true)
./scripts/prereq-check-enterprise.sh
endif
setup-go-work: export BUILD_ENTERPRISE_READY := $(BUILD_ENTERPRISE_READY)
setup-go-work: ## Sets up your go.work file
ifneq ($(IGNORE_GO_WORK_IF_EXISTS),true)
@echo "Creating a go.work file"
rm -f go.work
$(GO) work init
$(GO) work use .
$(GO) work use ./public
ifeq ($(BUILD_ENTERPRISE_READY),true)
$(GO) work use ../../enterprise
endif
endif
check-style: plugin-checker vet golangci-lint ## Runs style/lint checks
gotestsum:
$(GO) install gotest.tools/gotestsum@v1.11.0
test-compile: gotestsum ## Compile tests.
@echo COMPILE TESTS
for package in $(TE_PACKAGES) $(EE_PACKAGES); do \
$(GO) test $(GOFLAGS) -c $$package; \
done
modules-tidy: ## Tidy Go modules
mv enterprise/external_imports.go enterprise/external_imports.go.orig
-$(GO) mod tidy
-cd public && $(GO) mod tidy
mv enterprise/external_imports.go.orig enterprise/external_imports.go
test-server-pre: check-prereqs-enterprise start-docker gotestsum golang-versions ## Runs tests.
ifeq ($(BUILD_ENTERPRISE_READY),true)
@echo Running all tests
else
@echo Running only TE tests
endif
test-server-race: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
test-server-race: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
test-server-race: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
test-server-race: test-server-pre
ifeq ($(IS_CI),true)
GOMAXPROCS=4 $(GOBIN)/gotestsum --rerun-fails=3 --packages="$(TE_PACKAGES) $(EE_PACKAGES)" -- -race $(GOFLAGS) -timeout=2h
else
$(GOBIN)/gotestsum --rerun-fails=3 --packages="$(TE_PACKAGES) $(EE_PACKAGES)" -- -race $(GOFLAGS) -timeout=2h
endif
ifneq ($(IS_CI),true)
ifneq ($(MM_NO_DOCKER),true)
ifneq ($(TEMP_DOCKER_SERVICES),)
@echo Stopping temporary docker services
docker compose stop $(TEMP_DOCKER_SERVICES)
endif
endif
endif
test-server: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
test-server: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
test-server: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
test-server: test-server-pre
$(GOBIN)/gotestsum --rerun-fails=3 --packages="$(TE_PACKAGES) $(EE_PACKAGES)" -- $(GOFLAGS) -timeout=90m $(COVERAGE_FLAG)
ifneq ($(IS_CI),true)
ifneq ($(MM_NO_DOCKER),true)
ifneq ($(TEMP_DOCKER_SERVICES),)
@echo Stopping temporary docker services
docker compose stop $(TEMP_DOCKER_SERVICES)
endif
endif
endif
test-server-ee: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
test-server-ee: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
test-server-ee: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
test-server-ee: check-prereqs-enterprise start-docker gotestsum ## Runs EE tests.
@echo Running only EE tests
$(GOBIN)/gotestsum --packages="$(EE_PACKAGES)" -- $(GOFLAGS) -timeout=20m
test-server-quick: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
test-server-quick: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
test-server-quick: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
test-server-quick: check-prereqs-enterprise ## Runs only quick tests.
ifeq ($(BUILD_ENTERPRISE_READY),true)
@echo Running all tests
$(GOBIN)/gotestsum --packages="$(TE_PACKAGES) $(EE_PACKAGES)" -- $(GOFLAGS) -short
else
@echo Running only TE tests
$(GOBIN)/gotestsum --packages="$(TE_PACKAGES)" -- $(GOFLAGS) -short
endif
internal-test-web-client: ## Runs web client tests.
$(GO) run $(GOFLAGS) $(PLATFORM_FILES) test web_client_tests
run-server-for-web-client-tests: ## Tests the server for web client.
$(GO) run $(GOFLAGS) $(PLATFORM_FILES) test web_client_tests_server
test-client: ## Test client app.
@echo Running client tests
cd $(BUILD_WEBAPP_DIR) && $(MAKE) test
test: test-server test-client ## Runs all checks and tests below (except race detection and postgres).
cover: ## Runs the golang coverage tool. You must run the unit tests first.
@echo Opening coverage info in browser. If this failed run make test first
$(GO) tool cover -html=cover.out
$(GO) tool cover -html=ecover.out
test-data: export MM_SERVICESETTINGS_ENABLELOCALMODE := true
test-data: run-server inject-test-data stop-server ## start a local instance and add test data to it.
inject-test-data: # add test data to the local instance.
@if ! ./scripts/wait-for-system-start.sh; then \
make stop-server; \
fi
bin/mmctl config set TeamSettings.MaxUsersPerTeam 100 --local
bin/mmctl sampledata -u 60 --local
@echo You may need to restart the Mattermost server before using the following
@echo ========================================================================
@echo Login with a system admin account username=sysadmin password=Sys@dmin-sample1
@echo Login with a regular account username=user-1 password=SampleUs@r-1
@echo ========================================================================
test-mmctl-unit: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
test-mmctl-unit: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
test-mmctl-unit: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
test-mmctl-unit: check-prereqs-enterprise gotestsum
@echo Running mmctl unit tests
$(GOBIN)/gotestsum --packages="$(MMCTL_PACKAGES)" -- -tags 'unit $(MMCTL_BUILD_TAGS)' $(MMCTL_TESTFLAGS)
test-mmctl-e2e: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
test-mmctl-e2e: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
test-mmctl-e2e: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
test-mmctl-e2e: check-prereqs-enterprise gotestsum start-docker
@echo Running mmctl e2e tests
$(GOBIN)/gotestsum --packages="$(MMCTL_PACKAGES)" -- -tags 'e2e $(MMCTL_BUILD_TAGS)' $(MMCTL_TESTFLAGS)
test-mmctl: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
test-mmctl: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
test-mmctl: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
test-mmctl: check-prereqs-enterprise gotestsum start-docker
@echo Running all mmctl tests
$(GOBIN)/gotestsum --packages="$(MMCTL_PACKAGES)" -- -tags 'unit e2e $(MMCTL_BUILD_TAGS)' $(MMCTL_TESTFLAGS)
test-mmctl-coverage: export GOTESTSUM_FORMAT := $(GOTESTSUM_FORMAT)
test-mmctl-coverage: export GOTESTSUM_JUNITFILE := $(GOTESTSUM_JUNITFILE)
test-mmctl-coverage: export GOTESTSUM_JSONFILE := $(GOTESTSUM_JSONFILE)
test-mmctl-coverage: check-prereqs-enterprise gotestsum start-docker
@echo Running all mmctl tests with coverage
$(GOBIN)/gotestsum --packages="$(MMCTL_PACKAGES)" -- -tags 'unit e2e $(MMCTL_BUILD_TAGS)' -coverprofile=mmctlcover.out $(MMCTL_TESTFLAGS)
$(GO) tool cover -html=mmctlcover.out
validate-go-version: ## Validates the installed version of go against Mattermost's minimum requirement.
@if [ $(GO_MAJOR_VERSION) -gt $(MINIMUM_SUPPORTED_GO_MAJOR_VERSION) ]; then \
exit 0 ;\
elif [ $(GO_MAJOR_VERSION) -lt $(MINIMUM_SUPPORTED_GO_MAJOR_VERSION) ]; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
elif [ $(GO_MINOR_VERSION) -lt $(MINIMUM_SUPPORTED_GO_MINOR_VERSION) ] ; then \
echo '$(GO_VERSION_VALIDATION_ERR_MSG)';\
exit 1; \
fi
build-templates: ## Compile all mjml email templates
cd $(TEMPLATES_DIR) && $(MAKE) build
run-server: setup-go-work prepackaged-binaries validate-go-version start-docker client ## Starts the server.
@echo Running mattermost for development
ifeq ($(MM_USE_PGVECTOR),true)
@echo With pgvector PostgreSQL support enabled
endif
mkdir -p $(BUILD_WEBAPP_DIR)/channels/dist/files
$(GO) run $(GOFLAGS) -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' $(PLATFORM_FILES) $(RUN_IN_BACKGROUND)
run-server-pgvector: ## Starts the server with pgvector PostgreSQL image.
@MM_USE_PGVECTOR=true $(MAKE) run-server
debug-server: start-docker ## Compile and start server using delve.
mkdir -p $(BUILD_WEBAPP_DIR)/channels/dist/files
$(DELVE) debug $(PLATFORM_FILES) --build-flags="-ldflags '\
-X github.com/mattermost/mattermost/server/public/model.BuildNumber=$(BUILD_NUMBER)\
-X \"github.com/mattermost/mattermost/server/public/model.BuildDate=$(BUILD_DATE)\"\
-X github.com/mattermost/mattermost/server/public/model.BuildHash=$(BUILD_HASH)\
-X github.com/mattermost/mattermost/server/public/model.BuildHashEnterprise=$(BUILD_HASH_ENTERPRISE)\
-X github.com/mattermost/mattermost/server/public/model.BuildEnterpriseReady=$(BUILD_ENTERPRISE_READY)'\
-tags '$(BUILD_TAGS)'"
debug-server-headless: start-docker ## Debug server from within an IDE like VSCode or IntelliJ.
mkdir -p $(BUILD_WEBAPP_DIR)/channels/dist/files
$(DELVE) debug --headless --listen=:2345 --api-version=2 --accept-multiclient $(PLATFORM_FILES) --build-flags="-ldflags '\
-X github.com/mattermost/mattermost/server/public/model.BuildNumber=$(BUILD_NUMBER)\
-X \"github.com/mattermost/mattermost/server/public/model.BuildDate=$(BUILD_DATE)\"\
-X github.com/mattermost/mattermost/server/public/model.BuildHash=$(BUILD_HASH)\
-X github.com/mattermost/mattermost/server/public/model.BuildHashEnterprise=$(BUILD_HASH_ENTERPRISE)\
-X github.com/mattermost/mattermost/server/public/model.BuildEnterpriseReady=$(BUILD_ENTERPRISE_READY)'\
-tags '$(BUILD_TAGS)'"
run-node: export MM_SERVICESETTINGS_SITEURL=http://localhost:8066
run-node: export MM_SERVICESETTINGS_LISTENADDRESS=:8066
run-node: export MM_SERVICESETTINGS_ENABLELOCALMODE=true
run-node: export MM_SERVICESETTINGS_LOCALMODESOCKETLOCATION=/var/tmp/mattermost_local_node.socket
run-node: export MM_SQLSETTINGS_DRIVERNAME=postgres
run-node: export MM_SQLSETTINGS_DATASOURCE=postgres://mmuser:mostest@localhost/mattermost_node_test?sslmode=disable&sslmode=disable&connect_timeout=10&binary_parameters=yes
run-node: start-docker ## Runs a shared channel node.
@echo Running mattermost node
$(GO) run $(GOFLAGS) -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' $(PLATFORM_FILES) $(RUN_IN_BACKGROUND)
run-cli: start-docker ## Runs CLI.
@echo Running mattermost for development
@echo Example should be like 'make ARGS="-version" run-cli'
$(GO) run $(GOFLAGS) -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' $(PLATFORM_FILES) ${ARGS}
run-client: client ## Runs the webapp.
@echo Running mattermost client for development
cd $(BUILD_WEBAPP_DIR) && $(MAKE) run
client: ## Sets up a symlink to the compiled files generated by the web app
@echo Setting up symlink to client directory
ln -nfs $(BUILD_WEBAPP_DIR)/channels/dist client
run-client-fullmap: client ## Legacy alias to run-client
@echo Running mattermost client for development
cd $(BUILD_WEBAPP_DIR) && $(MAKE) run
run: run-server run-client ## Runs the server and webapp.
run-pgvector: ## Runs the server and webapp with pgvector PostgreSQL image.
@MM_USE_PGVECTOR=true $(MAKE) run
run-fullmap: run-server run-client ## Legacy alias to run
stop-server: ## Stops the server.
@echo Stopping mattermost
ifeq ($(BUILDER_GOOS_GOARCH),"windows_amd64")
wmic process where "Caption='go.exe' and CommandLine like '%go.exe run%'" call terminate
wmic process where "Caption='mattermost.exe' and CommandLine like '%go-build%'" call terminate
else
@for PID in $$(ps -ef | grep "[g]o run" | grep "mattermost" | awk '{ print $$2 }'); do \
echo stopping go $$PID; \
kill $$PID; \
done
@for PID in $$(ps -ef | grep "[g]o-build" | grep "mattermost" | awk '{ print $$2 }'); do \
echo stopping mattermost $$PID; \
kill $$PID; \
done
endif
stop-client: ## Stops the webapp.
@echo Stopping mattermost client
cd $(BUILD_WEBAPP_DIR) && $(MAKE) stop
stop: stop-server stop-client stop-docker ## Stops server, client and the docker compose.
restart: restart-server restart-client ## Restarts the server and webapp.
restart-server: | stop-server run-server ## Restarts the mattermost server to pick up development change.
restart-server-pgvector: ## Restarts the server with pgvector PostgreSQL image.
@MM_USE_PGVECTOR=true $(MAKE) restart-server
restart-haserver:
@echo Restarting mattermost in an HA topology
docker compose restart follower2
docker compose restart follower
docker compose restart leader
docker compose restart haproxy
restart-client: | stop-client run-client ## Restarts the webapp.
run-job-server: ## Runs the background job server.
@echo Running job server for development
$(GO) run $(GOFLAGS) -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' $(PLATFORM_FILES) jobserver &
config-ldap: ## Configures LDAP.
@echo Setting up configuration for local LDAP
# Check if jq is installed
@jq --version > /dev/null 2>&1 || (echo "jq is not installed. Please install jq to continue." && exit 1)
$(eval TMPDIR := $(shell mktemp -d))
jq --slurp '.[0] * .[1]' ${CONFIG_FILE_PATH} build/docker/keycloak/ldap.mmsettings.json > ${TMPDIR}/config.json
cp ${TMPDIR}/config.json ${CONFIG_FILE_PATH}
rm ${TMPDIR}/config.json
config-saml: ## Configures SAML.
@echo Setting up configuration for local SAML with keycloak, please ensure your keycloak is running on http://localhost:8484
# Check if jq is installed
@jq --version > /dev/null 2>&1 || (echo "jq is not installed. Please install jq to continue." && exit 1)
@cp build/docker/keycloak/keycloak.crt ./config/saml-idp.crt
$(eval TMPDIR := $(shell mktemp -d))
jq --slurp '.[0] * .[1]' ${CONFIG_FILE_PATH} build/docker/keycloak/saml.mmsettings.json > ${TMPDIR}/config.json
cp ${TMPDIR}/config.json ${CONFIG_FILE_PATH}
rm ${TMPDIR}/config.json
config-openid: ## Configures OpenID.
@echo Setting up configuration for local OpenID with keycloak, please ensure your keycloak is running on http://localhost:8484
# Check if jq is installed
@jq --version > /dev/null 2>&1 || (echo "jq is not installed. Please install jq to continue." && exit 1)
$(eval TMPDIR := $(shell mktemp -d))
jq --slurp '.[0] * .[1]' ${CONFIG_FILE_PATH} build/docker/keycloak/openid.mmsettings.json > ${TMPDIR}/config.json
cp ${TMPDIR}/config.json ${CONFIG_FILE_PATH}
rm ${TMPDIR}/config.json
@echo Finished setting up configuration for local OpenID with keycloak
config-reset: ## Resets the config/config.json file to the default production values.
@echo Resetting configuration to production default
rm -f config/config.json
OUTPUT_CONFIG=$(PWD)/config/config.json $(GO) run $(GOFLAGS) -tags production ./scripts/config_generator
diff-config: ## Compares default configuration between two mattermost versions
@./scripts/diff-config.sh
clean: stop-docker ## Clean up everything except persistent server data.
@echo Cleaning
rm -Rf $(DIST_ROOT)
$(GO) clean $(GOFLAGS) -i ./...
cd $(BUILD_WEBAPP_DIR) && $(MAKE) clean
find . -type d -name data | xargs rm -rf
rm -rf logs
rm -f mattermost.log
rm -f mattermost.log.jsonl
rm -f npm-debug.log
rm -f .prepare-go
rm -f cover.out
rm -f ecover.out
rm -f *.out
rm -f *.test
rm -f channels/imports/imports.go
rm -f cmd/mattermost/cprofile*.out
nuke: clean clean-docker ## Clean plus removes persistent server data.
@echo BOOM
rm -rf data
rm -f go.work go.work.sum
setup-mac: ## Adds macOS hosts entries for Docker.
echo $$(boot2docker ip 2> /dev/null) dockerhost | sudo tee -a /etc/hosts
update-dependencies: ## Uses go get -u to update all the dependencies while holding back any that require it.
@echo Updating Dependencies
ifeq ($(BUILD_ENTERPRISE_READY),true)
@echo Enterprise repository detected, temporarily removing external_imports.go
mv enterprise/external_imports.go enterprise/external_imports.go.orig
endif
# Update all dependencies (does not update across major versions)
$(GO) get -u ./...
# Tidy up
$(GO) mod tidy
ifeq ($(BUILD_ENTERPRISE_READY),true)
mv enterprise/external_imports.go.orig enterprise/external_imports.go
endif
vet: ## Run mattermost go vet specific checks
$(GO) install github.com/mattermost/mattermost-govet/v2@7d8db289e508999dfcac47b97c9490a0fec12d66
$(GO) vet -vettool=$(GOBIN)/mattermost-govet -structuredLogging -inconsistentReceiverName -emptyStrCmp -tFatal -configtelemetry -errorAssertions -requestCtxNaming -license -inconsistentReceiverName.ignore=session_serial_gen.go,team_member_serial_gen.go,user_serial_gen.go,utils_serial_gen.go ./...
ifeq ($(BUILD_ENTERPRISE_READY),true)
ifneq ($(MM_NO_ENTERPRISE_LINT),true)
$(GO) vet -vettool=$(GOBIN)/mattermost-govet -structuredLogging -inconsistentReceiverName -emptyStrCmp -tFatal -configtelemetry -errorAssertions -requestCtxNaming -enterpriseLicense $(BUILD_ENTERPRISE_DIR)/...
endif
endif
vet-api: export GO := $(GO)
vet-api: export GOBIN := $(GOBIN)
vet-api: export ROOT := $(ROOT)
vet-api: ## Run mattermost go vet to verify api4 documentation, currently not passing
$(GO) install github.com/mattermost/mattermost-govet/v2@7d8db289e508999dfcac47b97c9490a0fec12d66
make -C ../api build
./scripts/vet-api-check.sh
gen-serialized: export LICENSE_HEADER:=$(LICENSE_HEADER)
gen-serialized: ## Generates serialization methods for hot structs
# This tool only works at a file level, not at a package level.
# There will be some warnings about "unresolved identifiers",
# but that is because of the above problem. Since we are generating
# methods for all the relevant files at a package level, all
# identifiers will be resolved. An alternative to remove the warnings
# would be to temporarily move all the structs to the same file,
# but that involves a lot of manual work.
$(GO) install github.com/tinylib/msgp@v1.1.6
$(GOBIN)/msgp -file=./public/model/utils.go -tests=false -o=./public/model/utils_serial_gen.go
@echo "$$LICENSE_HEADER" > tmp.go
@cat ./public/model/utils_serial_gen.go >> tmp.go
@mv tmp.go ./public/model/utils_serial_gen.go
$(GOBIN)/msgp -file=./public/model/session.go -tests=false -o=./public/model/session_serial_gen.go
@echo "$$LICENSE_HEADER" > tmp.go
@cat ./public/model/session_serial_gen.go >> tmp.go
@mv tmp.go ./public/model/session_serial_gen.go
$(GOBIN)/msgp -file=./public/model/user.go -tests=false -o=./public/model/user_serial_gen.go
@echo "$$LICENSE_HEADER" > tmp.go
@cat ./public/model/user_serial_gen.go >> tmp.go
@mv tmp.go ./public/model/user_serial_gen.go
$(GOBIN)/msgp -file=./public/model/team_member.go -tests=false -o=./public/model/team_member_serial_gen.go
@echo "$$LICENSE_HEADER" > tmp.go
@cat ./public/model/team_member_serial_gen.go >> tmp.go
@mv tmp.go ./public/model/team_member_serial_gen.go
todo: ## Display TODO and FIXME items in the source code.
@! ag --ignore Makefile --ignore-dir runtime '(TODO|XXX|FIXME|"FIX ME")[: ]+'
ifeq ($(BUILD_ENTERPRISE_READY),true)
@! ag --ignore Makefile --ignore-dir runtime '(TODO|XXX|FIXME|"FIX ME")[: ]+' $(BUILD_ENTERPRISE_DIR)/
endif
mmctl-build: ## Compiles and generates the mmctl binary
go build -trimpath -ldflags '$(MMCTL_LDFLAGS)' -o bin/mmctl ./cmd/mmctl
mmctl-docs: ## Generate the mmctl docs
rm -rf ./cmd/mmctl/docs
cd ./cmd/mmctl && go run mmctl.go docs
## Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
help:
@grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' ./Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@echo
@echo You can modify the default settings for this Makefile creating a file config.mk based on the default-config.mk
@echo
migrations-extract:
@echo Listing migration files
@echo "# Autogenerated file to synchronize migrations sequence in the PR workflow, please do not edit." > channels/db/migrations/migrations.list
find channels/db/migrations -maxdepth 2 -mindepth 2 | sort >> channels/db/migrations/migrations.list
test-local-filestore: # Run tests for local filestore
$(GO) test ./platform/shared/filestore -run '^TestLocalFileBackend' -v

3
bin/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Folder required for build process
*
!.gitignore

3
build/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
diff.txt
latest.sql
migrated.sql

88
build/Dockerfile Normal file
View File

@ -0,0 +1,88 @@
# First stage - Ubuntu with document processing dependencies and curl for downloading
FROM ubuntu:noble-20250415.1@sha256:dc17125eaac86538c57da886e494a34489122fb6a3ebb6411153d742594c2ddc AS builder
# Setting bash as our shell, and enabling pipefail option
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Build Arguments
ARG PUID=2000
ARG PGID=2000
# MM_PACKAGE build arguments controls which version of mattermost to install, defaults to latest stable enterprise
# e.g. https://releases.mattermost.com/9.7.1/mattermost-9.7.1-linux-amd64.tar.gz
ARG MM_PACKAGE="https://latest.mattermost.com/mattermost-enterprise-linux"
# Install needed packages and indirect dependencies
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \
ca-certificates \
curl \
media-types \
mailcap \
unrtf \
wv \
poppler-utils \
tidy \
tzdata \
&& rm -rf /var/lib/apt/lists/*
# Set mattermost group/user and download Mattermost
RUN mkdir -p /mattermost/data /mattermost/plugins /mattermost/client/plugins \
&& groupadd --gid ${PGID} mattermost \
&& useradd --uid ${PUID} --gid ${PGID} --comment "" --home-dir /mattermost mattermost \
&& curl -L $MM_PACKAGE | tar -xvz \
&& chown -R mattermost:mattermost /mattermost /mattermost/data /mattermost/plugins /mattermost/client/plugins
# Create PostgreSQL client SSL directory structure for ssl_mode=require
RUN mkdir -p /mattermost/.postgresql \
&& chmod 700 /mattermost/.postgresql
# Final stage using distroless for minimal attack surface
FROM gcr.io/distroless/base-debian12
# Some ENV variables
ENV PATH="/mattermost/bin:${PATH}"
ENV MM_SERVICESETTINGS_ENABLELOCALMODE="true"
ENV MM_INSTALL_TYPE="docker"
# Copy over metadata files needed by runtime
COPY --from=builder /etc/mime.types /etc
# Copy CA certificates for SSL/TLS validation with proper ownership
COPY --from=builder --chown=2000:2000 /etc/ssl/certs /etc/ssl/certs
# Copy document processing utilities and necessary support files
COPY --from=builder /usr/bin/pdftotext /usr/bin/pdftotext
COPY --from=builder /usr/bin/wvText /usr/bin/wvText
COPY --from=builder /usr/bin/wvWare /usr/bin/wvWare
COPY --from=builder /usr/bin/unrtf /usr/bin/unrtf
COPY --from=builder /usr/bin/tidy /usr/bin/tidy
COPY --from=builder /usr/share/wv /usr/share/wv
# Copy necessary libraries for document processing utilities
COPY --from=builder /usr/lib/libpoppler.so* /usr/lib/
COPY --from=builder /usr/lib/libfreetype.so* /usr/lib/
COPY --from=builder /usr/lib/libpng.so* /usr/lib/
COPY --from=builder /usr/lib/libwv.so* /usr/lib/
COPY --from=builder /usr/lib/libtidy.so* /usr/lib/
COPY --from=builder /usr/lib/libfontconfig.so* /usr/lib/
# Copy mattermost from builder stage
COPY --from=builder --chown=2000:2000 /mattermost /mattermost
# Copy passwd including mattermost user
COPY passwd /etc/passwd
# We should refrain from running as privileged user
USER mattermost
# Healthcheck to make sure container is ready - using mmctl instead of curl for distroless compatibility
HEALTHCHECK --interval=30s --timeout=10s \
CMD ["/mattermost/bin/mmctl", "system", "status", "--local"]
# Configure entrypoint and command with proper permissions
WORKDIR /mattermost
CMD ["/mattermost/bin/mattermost"]
EXPOSE 8065 8067 8074 8075
# Declare volumes for mount point directories
VOLUME ["/mattermost/data", "/mattermost/logs", "/mattermost/config", "/mattermost/plugins", "/mattermost/client/plugins"]

17
build/Dockerfile.buildenv Normal file
View File

@ -0,0 +1,17 @@
FROM golang:1.24.6-bullseye@sha256:cf78ce8205287fdb2ca403aac77d68965c75734749e560c577c00e20ecb11954
ARG NODE_VERSION=20.11.1
RUN apt-get update && apt-get install -y make git apt-transport-https ca-certificates curl software-properties-common build-essential zip xmlsec1 jq pgloader gnupg
# Download and install node via nvm
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
RUN bash -c "source /root/.nvm/nvm.sh && nvm install $NODE_VERSION && nvm use $NODE_VERSION && nvm alias default $NODE_VERSION"
# Make node and npm globally available
ENV PATH="/root/.nvm/versions/node/v$NODE_VERSION/bin:$PATH"
RUN git config --global --add safe.directory /mattermost
# Remove any existing entrypoint, and use bash as the default command
ENTRYPOINT []
CMD ["bash"]

View File

@ -0,0 +1,17 @@
FROM cgr.dev/mattermost.com/go-msft-fips:1.24.6-dev@sha256:53d076b1cfa53f8189c4723d813d711d92107c2e8b140805c71e39f4a06dc9cc
ARG NODE_VERSION=20.11.1
RUN apk add curl ca-certificates mailcap unrtf wv poppler-utils tzdata gpg xmlsec
# Download and install node via nvm
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
RUN bash -c "source /root/.nvm/nvm.sh && nvm install $NODE_VERSION && nvm use $NODE_VERSION && nvm alias default $NODE_VERSION"
# Make node and npm globally available
ENV PATH="/root/.nvm/versions/node/v$NODE_VERSION/bin:$PATH"
RUN git config --global --add safe.directory /mattermost
# Remove any existing entrypoint, and use bash as the default command
ENTRYPOINT []
CMD ["bash"]

91
build/Dockerfile.fips Normal file
View File

@ -0,0 +1,91 @@
# First stage - FIPS dev image with dependencies for building
FROM cgr.dev/mattermost.com/glibc-openssl-fips:15-dev@sha256:9223f9245fb026a3c255ce9b7028a069fe11432aa7710713a331eaa36f44851c AS builder
# Setting bash as our shell, and enabling pipefail option
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Build Arguments
ARG PUID=2000
ARG PGID=2000
# MM_PACKAGE build arguments controls which version of mattermost to install, defaults to latest stable enterprise
# e.g. https://releases.mattermost.com/9.7.1/mattermost-9.7.1-linux-amd64.tar.gz
ARG MM_PACKAGE="https://latest.mattermost.com/mattermost-enterprise-linux"
# Install needed packages and indirect dependencies
USER 0:0
RUN apk add \
curl \
ca-certificates \
mailcap \
unrtf \
wv \
poppler-utils \
tzdata
# Set mattermost group/user and download Mattermost
RUN mkdir -p /mattermost/data /mattermost/plugins /mattermost/client/plugins \
&& addgroup -g ${PGID} mattermost \
&& adduser -D -u ${PUID} -G mattermost -g "" -s /bin/sh -h /mattermost mattermost \
&& curl -L $MM_PACKAGE | tar -xvz \
&& chown -R mattermost:mattermost /mattermost /mattermost/data /mattermost/plugins /mattermost/client/plugins
# Create PostgreSQL client SSL directory structure for ssl_mode=require
RUN mkdir -p /mattermost/.postgresql \
&& chmod 700 /mattermost/.postgresql
# Create /var/tmp directory needed for local socket files
RUN mkdir -p /var/tmp \
&& chmod 755 /var/tmp
# Final stage using FIPS runtime image
FROM cgr.dev/mattermost.com/glibc-openssl-fips:15@sha256:7947eecc0d82fa3bc661aaca039bcd86d55fdf3ee581c8ecdef1b3c6f63fa83a
# Some ENV variables
ENV PATH="/mattermost/bin:${PATH}"
ENV MM_SERVICESETTINGS_ENABLELOCALMODE="true"
ENV MM_INSTALL_TYPE="docker"
# Copy over metadata files needed by runtime
COPY --from=builder /etc/mime.types /etc
# Copy CA certificates for SSL/TLS validation with proper ownership
COPY --from=builder --chown=2000:2000 /etc/ssl/certs /etc/ssl/certs
# Copy document processing utilities and necessary support files
COPY --from=builder /usr/bin/pdftotext /usr/bin/pdftotext
COPY --from=builder /usr/bin/wvText /usr/bin/wvText
COPY --from=builder /usr/bin/wvWare /usr/bin/wvWare
COPY --from=builder /usr/bin/unrtf /usr/bin/unrtf
COPY --from=builder /usr/share/wv /usr/share/wv
# Copy necessary libraries for document processing utilities
COPY --from=builder /usr/lib/libpoppler.so* /usr/lib/
COPY --from=builder /usr/lib/libfreetype.so* /usr/lib/
COPY --from=builder /usr/lib/libpng16.so* /usr/lib/
COPY --from=builder /usr/lib/libwv.so* /usr/lib/
COPY --from=builder /usr/lib/libfontconfig.so* /usr/lib/
# Copy mattermost from builder stage
COPY --from=builder --chown=2000:2000 /mattermost /mattermost
# Copy group and passwd files including mattermost user
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
# Copy /var/tmp directory needed for local socket files
COPY --from=builder --chown=2000:2000 /var/tmp /var/tmp
# We should refrain from running as privileged user
USER mattermost
# Healthcheck to make sure container is ready - using mmctl instead of curl for distroless compatibility
HEALTHCHECK --interval=30s --timeout=10s \
CMD ["/mattermost/bin/mmctl", "system", "status", "--local"]
# Configure entrypoint and command with proper permissions
WORKDIR /mattermost
CMD ["/mattermost/bin/mattermost"]
EXPOSE 8065 8067 8074 8075
# Declare volumes for mount point directories
VOLUME ["/mattermost/data", "/mattermost/logs", "/mattermost/config", "/mattermost/plugins", "/mattermost/client/plugins"]

View File

@ -0,0 +1,4 @@
ARG OPENSEARCH_VERSION=2.7.0
FROM opensearchproject/opensearch:$OPENSEARCH_VERSION
RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-icu

View File

@ -0,0 +1,11 @@
Mattermost MIT Compiled License
**Note: this license does not cover source code, for information on source code licensing see LICENSE.txt in the Mattermost source code.
Copyright (c) 2016-present Mattermost, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software;
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

1
build/PRIVACY_POLICY.md Normal file
View File

@ -0,0 +1 @@
https://mattermost.com/pl/privacy-policy/

21
build/README.md Normal file
View File

@ -0,0 +1,21 @@
## About this folder
This folder contains some files that we use to build the `mattermost-server` and other files like privacy policy and licenses.
The `Dockerfile` in this folder (`Dockerfile.buildenv`) is the build environment for our current builds you can find the docker image to download [here](https://hub.docker.com/r/mattermost/mattermost-build-server/tags/) or build your own.
### Docker Image for building the Server
We have a docker image to build `mattermost-server` and it is based on Go docker image.
In our Docker Hub Repository we have the following images:
- `mattermost/mattermost-build-server:dec-7-2018` which is based on Go 1.11 you can use for MM versions <= `5.8.0`
- `mattermost/mattermost-build-server:feb-28-2019` which is based on Go 1.12 you can use for MM versions >= `5.9.0` <= `5.15.0`
- `mattermost/mattermost-build-server:sep-17-2019` which is based on Go 1.12.9 you can use for MM versions >= `5.16.0`
- `mattermost/mattermost-build-server:20200322_golang-1.14.1` which is based on Go 1.14.1 you can use for MM versions >= `5.24.x`
- `mattermost/mattermost-build-server:20201023_golang-1.14.6` which is based on Go 1.14.6 you can use for MM versions >= `5.25.x`
- `mattermost/mattermost-build-server:20201119_golang-1.15.5` which is based on Go 1.15.5 you can use for MM versions >= `5.26.x` to `5.37.x`
- `mattermost/mattermost-build-server:20210810_golang-1.16.7` which is based on Go 1.16.X you can use for MM versions >= `5.38.x`

View File

@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
type DockerCompose struct {
Services map[string]*Container `yaml:"services"`
}
type Container struct {
Command string `yaml:"command,omitempty"`
Image string `yaml:"image,omitempty"`
Network []string `yaml:"networks,omitempty"`
DependsOn []string `yaml:"depends_on,omitempty"`
}
func main() {
validServices := map[string]int{
"postgres": 5432,
"minio": 9000,
"inbucket": 9001,
"openldap": 389,
"elasticsearch": 9200,
"opensearch": 9201,
"redis": 6379,
"dejavu": 1358,
"keycloak": 8080,
"prometheus": 9090,
"grafana": 3000,
"loki": 3100,
"promtail": 3180,
}
command := []string{}
for _, arg := range os.Args[1:] {
port, ok := validServices[arg]
if !ok {
panic(fmt.Sprintf("Unknown service %s", arg))
}
command = append(command, fmt.Sprintf("%s:%d", arg, port))
}
var dockerCompose DockerCompose
dockerCompose.Services = map[string]*Container{}
dockerCompose.Services["start_dependencies"] = &Container{
Image: "mattermost/mattermost-wait-for-dep:latest",
Network: []string{"mm-test"},
DependsOn: os.Args[1:],
Command: strings.Join(command, " "),
}
resultData, err := yaml.Marshal(dockerCompose)
if err != nil {
panic(fmt.Sprintf("Unable to serialize the docker-compose file: %s.", err.Error()))
}
fmt.Println(string(resultData))
}

View File

@ -0,0 +1,134 @@
services:
postgres:
image: "postgres:14"
restart: always
networks:
- mm-test
environment:
POSTGRES_USER: mmuser
POSTGRES_PASSWORD: mostest
POSTGRES_DB: mattermost_test
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256 --auth-local=scram-sha-256"
command: postgres -c 'config_file=/etc/postgresql/postgresql.conf'
volumes:
- "./docker/postgres.conf:/etc/postgresql/postgresql.conf"
- "./docker/postgres_node_database.sql:/docker-entrypoint-initdb.d/postgres_node_database.sql"
healthcheck:
test: [ "CMD", "pg_isready", "-h", "localhost" ]
interval: 5s
timeout: 10s
retries: 3
minio:
image: "minio/minio:RELEASE.2024-06-22T05-26-45Z"
command: "server /data --console-address :9002"
networks:
- mm-test
environment:
MINIO_ROOT_USER: minioaccesskey
MINIO_ROOT_PASSWORD: miniosecretkey
MINIO_KMS_SECRET_KEY: my-minio-key:OSMM+vkKUTCvQs9YL/CVMIMt43HFhkUpqJxTmGl6rYw=
inbucket:
image: "inbucket/inbucket:stable"
restart: always
environment:
INBUCKET_WEB_ADDR: "0.0.0.0:9001"
INBUCKET_POP3_ADDR: "0.0.0.0:10110"
INBUCKET_SMTP_ADDR: "0.0.0.0:10025"
networks:
- mm-test
openldap:
image: "osixia/openldap:1.4.0"
restart: always
networks:
- mm-test
environment:
LDAP_TLS_VERIFY_CLIENT: "never"
LDAP_ORGANISATION: "Mattermost Test"
LDAP_DOMAIN: "mm.test.com"
LDAP_ADMIN_PASSWORD: "mostest"
elasticsearch:
image: "mattermostdevelopment/mattermost-elasticsearch:8.9.0"
networks:
- mm-test
environment:
http.host: "0.0.0.0"
http.port: 9200
http.cors.enabled: "true"
http.cors.allow-origin: "http://localhost:1358,http://127.0.0.1:1358"
http.cors.allow-headers: "X-Requested-With,X-Auth-Token,Content-Type,Content-Length,Authorization"
http.cors.allow-credentials: "true"
transport.host: "127.0.0.1"
xpack.security.enabled: "false"
action.destructive_requires_name: "false"
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
opensearch:
build:
context: .
dockerfile: ./Dockerfile.opensearch
networks:
- mm-test
environment:
http.host: "0.0.0.0"
http.port: 9201
http.cors.enabled: "true"
http.cors.allow-origin: "http://localhost:1358,http://127.0.0.1:1358"
http.cors.allow-headers: "X-Requested-With,X-Auth-Token,Content-Type,Content-Length,Authorization"
http.cors.allow-credentials: "true"
transport.host: "127.0.0.1"
discovery.type: single-node
plugins.security.disabled: "true"
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
redis:
image: "redis:7.4.0"
networks:
- mm-test
dejavu:
image: "appbaseio/dejavu:3.4.2"
networks:
- mm-test
keycloak:
image: "quay.io/keycloak/keycloak:23.0.7"
restart: always
entrypoint: /opt/keycloak/bin/kc.sh start --import-realm
networks:
- mm-test
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HOSTNAME_STRICT: 'false'
KC_HOSTNAME_STRICT_HTTPS: 'false'
KC_HTTP_ENABLED: 'true'
volumes:
- "./docker/keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json"
prometheus:
image: "prom/prometheus:v2.46.0"
user: root
volumes:
- "./docker/prometheus.yml:/etc/prometheus/prometheus.yml"
- "/var/run/docker.sock:/var/run/docker.sock"
networks:
- mm-test
extra_hosts:
- "host.docker.internal:host-gateway"
grafana:
image: "grafana/grafana:10.4.2"
volumes:
- "./docker/grafana/grafana.ini:/etc/grafana/grafana.ini"
- "./docker/grafana/provisioning:/etc/grafana/provisioning"
- "./docker/grafana/dashboards:/var/lib/grafana/dashboards"
networks:
- mm-test
loki:
image: "grafana/loki:3.0.0"
networks:
- mm-test
promtail:
image: "grafana/promtail:3.0.0"
volumes:
- "./docker/promtail/promtail-local-config.yaml:/etc/promtail/docker-config.yaml"
- "/var/lib/docker/containers:/var/lib/docker/containers:ro"
- "/var/run/docker.sock:/var/run/docker.sock"
- "../logs:/logs"
command: -config.file=/etc/promtail/docker-config.yaml
networks:
- mm-test

72
build/docker-compose.yml Normal file
View File

@ -0,0 +1,72 @@
services:
postgres:
extends:
file: docker-compose.common.yml
service: postgres
tmpfs: /var/lib/postgresql/data
minio:
extends:
file: docker-compose.common.yml
service: minio
inbucket:
extends:
file: docker-compose.common.yml
service: inbucket
openldap:
extends:
file: docker-compose.common.yml
service: openldap
elasticsearch:
extends:
file: docker-compose.common.yml
service: elasticsearch
opensearch:
extends:
file: docker-compose.common.yml
service: opensearch
redis:
extends:
file: docker-compose.common.yml
service: redis
dejavu:
extends:
file: docker-compose.common.yml
service: dejavu
keycloak:
extends:
file: docker-compose.common.yml
service: keycloak
prometheus:
extends:
file: docker-compose.common.yml
service: prometheus
grafana:
extends:
file: docker-compose.common.yml
service: grafana
loki:
extends:
file: docker-compose.common.yml
service: loki
promtail:
extends:
file: docker-compose.common.yml
service: promtail
start_dependencies:
image: mattermost/mattermost-wait-for-dep:latest
networks:
- mm-test
depends_on:
- postgres
- minio
- inbucket
- openldap
- elasticsearch
- opensearch
- redis
command: postgres:5432 minio:9000 inbucket:9001 openldap:389 elasticsearch:9200 opensearch:9201 redis:6379
networks:
mm-test:
driver: bridge

View File

@ -0,0 +1,38 @@
# Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
# See License.txt for license information.
FROM postgres:14
RUN apt-get update && apt-get install -y ca-certificates
ARG MATTERMOST_VERSION
#
# Configure PostgreSQL
#
ENV POSTGRES_USER=mmuser
ENV POSTGRES_PASSWORD=mostest
ENV POSTGRES_DB=mattermost_test
#
# Configure Mattermost
#
WORKDIR /mm
# Copy over files
ADD https://releases.mattermost.com/${MATTERMOST_VERSION}/mattermost-team-${MATTERMOST_VERSION}-linux-amd64.tar.gz .
RUN tar -zxvf mattermost-team-${MATTERMOST_VERSION}-linux-amd64.tar.gz
ADD config_docker.json ./mattermost/config/config_docker.json
ADD docker-entry.sh .
RUN chmod +x ./docker-entry.sh
ENTRYPOINT ./docker-entry.sh
# Mattermost environment variables
ENV PATH="/mm/mattermost/bin:${PATH}"
# Create default storage directory
RUN mkdir ./mattermost-data
VOLUME /mm/mattermost-data
# Ports
EXPOSE 8065

View File

@ -0,0 +1,19 @@
# Mattermost Docker Preview Image
This is a Docker image to install Mattermost in *Preview Mode* for exploring product functionality on a single machine using Docker.
Note: This configuration should not be used in production, as its using a known password string and contains other non-production configuration settings, and it does not support upgrade. If youre looking for a production installation with Docker, please see the [Mattermost Production Docker Deployment Guide](https://docs.mattermost.com/install/install-docker.html#deploy-mattermost-on-docker-for-production-use).
To contribute, please see [Contribution Guidelines](https://developers.mattermost.com/contribute/more-info/getting-started/).
To file issues, [search for existing bugs and file a GitHub issue if your bug is new](https://developers.mattermost.com/contribute/why-contribute/#youve-found-a-bug).
## Usage
Please see [documentation for usage](http://docs.mattermost.com/install/docker-local-machine.html).
If you have Docker already set up, you can run this image in one line:
```
docker run --name mattermost-preview -d --publish 8065:8065 --add-host dockerhost:127.0.0.1 mattermost/mattermost-preview
```

View File

@ -0,0 +1,29 @@
{
"ServiceSettings": {
"EnableOAuthServiceProvider": true,
"EnableDeveloper": false,
"EnableGifPicker": true
},
"SqlSettings": {
"DriverName": "postgres",
"DataSource": "postgres://mmuser:mostest@localhost/mattermost_test?sslmode=disable\u0026connect_timeout=10\u0026binary_parameters=yes",
"AtRestEncryptKey": ""
},
"FileSettings": {
"DriverName": "local",
"Directory": "/mm/mattermost-data",
"PublicLinkSalt": ""
},
"EmailSettings": {
"SMTPServer": "",
"SMTPPort": "",
"PushNotificationContents": "generic"
},
"ElasticsearchSettings": {
"ConnectionUrl": ""
},
"PluginSettings": {
"EnableUploads": true,
"PluginStates": {}
}
}

View File

@ -0,0 +1,18 @@
#!/bin/bash
# Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
# See License.txt for license information.
echo "Starting PostgreSQL"
docker-entrypoint.sh -c 'shared_buffers=256MB' -c 'max_connections=300' &
until pg_isready -hlocalhost -p 5432 -U "$POSTGRES_USER" &> /dev/null; do
echo "postgres still not ready, sleeping"
sleep 5
done
echo "Updating CA certificates"
update-ca-certificates --fresh >/dev/null
echo "Starting platform"
cd mattermost
exec ./bin/mattermost --config=config/config_docker.json

View File

@ -0,0 +1,15 @@
# Developer Dashboards
This folder contains a modified form of the following Grafana dashboards:
* [Mattermost Performance Monitoring](https://grafana.com/grafana/dashboards/2542)
* [Mattermost Performance Monitoring (Bonus Metrics)](https://grafana.com/grafana/dashboards/2545)
* [Mattermost Performance KPI Metrics](https://grafana.com/grafana/dashboards/2539)
The dashboards are modified from the version available on grafana.com since [Grafana doesn't currently support variables](https://github.com/grafana/grafana/issues/10786) (i.e. `${DS_MATTERMOST}`) and has no way of binding the datasource with the dashboards at the time of provisioning. Instead of falling back to the REST API to effect these changes, the following in-place changes were made to the exported dashboards available above:
* Remove the top-level `__inputs` key
* Remove the top-level `__requires` key
* Replace all instances of `${DS_MATTERMOST}` with `Prometheus`, matching the name of the provisioned datasource.
Upon using the dashboards within Grafana however, it immediately adds various missing fields, presumably due to some internal migration. This results in a spurious prompt to "save" the dashboard. To avoid confusion, these changes were subsequently exported and written back into the provisioned dashboards above.
This entire process above will need to be repeated in the event newer versions of these dashboards are published.

View File

@ -0,0 +1,428 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 5,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"gridPos": {
"h": 3,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"refId": "A"
}
],
"type": "welcome"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 3
},
"id": 3,
"options": {
"includeVars": false,
"keepTime": false,
"maxItems": 30,
"query": "Mattermost",
"showHeadings": true,
"showRecentlyViewed": false,
"showSearch": true,
"showStarred": false,
"tags": []
},
"pluginVersion": "10.4.2",
"tags": [],
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"refId": "A"
}
],
"title": "Dashboards",
"type": "dashlist"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Mixed --"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [
{
"options": {
"0": {
"color": "red",
"index": 0,
"text": "Offline"
}
},
"type": "value"
},
{
"options": {
"from": 0,
"result": {
"color": "green",
"index": 1,
"text": "Online"
},
"to": 99999999999999
},
"type": "range"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 12,
"w": 12,
"x": 12,
"y": 3
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "/^(?!Time)/",
"values": false
},
"showPercentChange": false,
"textMode": "value_and_name",
"wideLayout": true
},
"pluginVersion": "10.4.2",
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({container=\"mattermost-postgres\"}[5m])) or vector(0)",
"legendFormat": "Postgres",
"queryType": "range",
"refId": "A"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({container=\"mattermost-inbucket\"}[5m])) or vector(0)",
"legendFormat": "Inbucket",
"queryType": "range",
"refId": "C"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({container=\"mattermost-minio\"}[5m])) or vector(0)",
"legendFormat": "MinIO",
"queryType": "range",
"refId": "D"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({container=\"mattermost-openldap\"}[5m])) or vector(0)",
"legendFormat": "OpenLDAP",
"queryType": "range",
"refId": "E"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({container=\"mattermost-dejavu\"}[5m])) or vector(0)",
"legendFormat": "DejaVu",
"queryType": "range",
"refId": "F"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({container=\"mattermost-keycloak\"}[5m])) or vector(0)",
"legendFormat": "Keycloak",
"queryType": "range",
"refId": "G"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "sum(count_over_time({container=\"mattermost-elasticsearch\"}[5m])) or vector(0)",
"legendFormat": "Elasticsearch",
"queryType": "range",
"refId": "H"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"editorMode": "code",
"expr": "sum(up{container=\"mattermost-prometheus\"}) or vector(0)",
"hide": false,
"legendFormat": "Prometheus",
"range": true,
"refId": "I"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"editorMode": "code",
"expr": "sum(up{container=\"mattermost-grafana\"}) or vector(0)",
"hide": false,
"legendFormat": "Grafana",
"range": true,
"refId": "J"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"editorMode": "code",
"expr": "sum(up{container=\"mattermost-loki\"}) or vector(0)",
"hide": false,
"legendFormat": "Loki",
"range": true,
"refId": "K"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"editorMode": "code",
"expr": "sum(up{container=\"mattermost-promtail\"}) or vector(0)",
"hide": false,
"legendFormat": "Promtail",
"range": true,
"refId": "L"
}
],
"title": "Panel Title",
"type": "stat"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"description": "Links to common queries.",
"gridPos": {
"h": 5,
"w": 12,
"x": 0,
"y": 10
},
"id": 6,
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "* [Mattermost Error Logs](/explore?schemaVersion=1&panes=%7B%22z8g%22:%7B%22datasource%22:%22P8E80F9AEF21F6940%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bapp%3D%5C%22mattermost%5C%22%7D%20%7C%20level%3D%5C%22error%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22P8E80F9AEF21F6940%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1)\n* [Plugin Logs](/explore?schemaVersion=1&panes=%7B%22z8g%22:%7B%22datasource%22:%22P8E80F9AEF21F6940%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22expr%22:%22%7Bapp%3D%5C%22mattermost%5C%22%7D%20%7C%20json%20%7C%20plugin_id%20%21%3D%20%5C%22%5C%22%22,%22queryType%22:%22range%22,%22datasource%22:%7B%22type%22:%22loki%22,%22uid%22:%22P8E80F9AEF21F6940%22%7D,%22editorMode%22:%22code%22%7D%5D,%22range%22:%7B%22from%22:%22now-6h%22,%22to%22:%22now%22%7D%7D%7D&orgId=1)",
"mode": "markdown"
},
"pluginVersion": "10.4.2",
"title": "Links",
"type": "text"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"description": "Logs from the localhost Mattermost instance.",
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 15
},
"id": 4,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": false,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "{app=\"mattermost\"}",
"queryType": "range",
"refId": "A"
}
],
"title": "Mattermost Logs",
"type": "logs"
},
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"description": "Logs from the Docker services running to support Mattermost.",
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 25
},
"id": 5,
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": false,
"showCommonLabels": false,
"showLabels": false,
"showTime": false,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"targets": [
{
"datasource": {
"type": "loki",
"uid": "P8E80F9AEF21F6940"
},
"editorMode": "code",
"expr": "{job=\"docker\"}",
"queryType": "range",
"refId": "A"
}
],
"title": "Docker Logs",
"type": "logs"
}
],
"schemaVersion": 39,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"hidden": true,
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"time_options": [
"5m",
"15m",
"1h",
"6h",
"12h",
"24h",
"2d",
"7d",
"30d"
],
"type": "timepicker"
},
"timezone": "browser",
"title": "Home",
"uid": "4yRNKfaGz",
"version": 1,
"weekStart": ""
}

View File

@ -0,0 +1,9 @@
[auth]
disable_login_form = false
[auth.anonymous]
enabled = true
org_role = Editor
[dashboards]
default_home_dashboard_path = /var/lib/grafana/dashboards/home.json

View File

@ -0,0 +1,14 @@
apiVersion: 1
providers:
- name: 'mattermost'
orgId: 1
folder: 'mattermost'
type: file
disableDeletion: true
updateIntervalSeconds: 300
allowUiUpdates: true
options:
# <string, required> path to dashboard files on disk. Required when using the 'file' type
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true

View File

@ -0,0 +1,11 @@
apiVersion: 1
datasources:
- name: Loki
type: 'loki'
access: 'proxy'
orgId: 1
url: 'http://loki:3100'
isDefault: false
version: 1
editable: false

View File

@ -0,0 +1,11 @@
apiVersion: 1
datasources:
- name: Prometheus
type: 'prometheus'
access: 'proxy'
orgId: 1
url: 'http://prometheus:9090'
isDefault: true
version: 1
editable: false

View File

@ -0,0 +1,39 @@
# Keycloak development environment
## Setting up
### OpenID
Overwrite your `OpenIdSettings` section in your config.json file by running `make config-openid` and restarting your server.
- [Official OpenID with Keycloak documentation](https://docs.mattermost.com/onboard/sso-openidconnect.html)
### SAML
Overwrite your `SamlSettings` section in your config.json file by running `make config-saml` and restarting your server.
- [Official SAML with Keycloak documentation](https://docs.mattermost.com/onboard/sso-saml-keycloak.html)
### LDAP
Overwrite your `LdapSettings` section in your config.json file by running `make config-ldap` and restarting your server.
- [Official LDAP with Keycloak documentation](https://docs.mattermost.com/onboard/ad-ldap.html)
## Credentials to log in
- **Admin account**, used to log in to the Keycloak Admin UI:
- `admin`/`admin`
- **User accounts**, used to log in to Mattermost:
- `homer`/`password`
- `marge`/`password`
- `lisa`/`password`
## Updating the `realm-export.json`
The `realm-export.json` file is automatically imported by the keycloak development container. If you make any modifications to this file or to the base configuration, export it by running a terminal in the container and running:
```bash
/opt/keycloak/bin/kc.sh export --realm mattermost --users realm_file --file /opt/keycloak/data/import/realm-export.json
```

View File

@ -0,0 +1,5 @@
#!/bin/bash
set -eu -o pipefail
/opt/keycloak/bin/kcadm.sh config credentials -x --server http://localhost:8080 --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD"
/opt/keycloak/bin/kcadm.sh get realms/mattermost >/dev/null

View File

@ -0,0 +1,3 @@
-----BEGIN CERTIFICATE-----
MIICozCCAYsCBgGNzWfMwjANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDAptYXR0ZXJtb3N0MB4XDTI0MDIyMTIwNDA0OFoXDTM0MDIyMTIwNDIyOFowFTETMBEGA1UEAwwKbWF0dGVybW9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOnsgNexkO5tbKkFXN+SdMUuLHbqdjZ9/JSnKrYPHLarf8801YDDzV8wI9jjdCCgq+xtKFKWlwU2rGpjPbefDLV1m7CSu0Iq+hNxDiBdX3wkEIK98piDpx+xYGL0aAbXn3nAlqFOWQJLKLM1I65ZmK31YZeVj4Kn01W4WfsvKHoxPjLPwPTug4HB6vaQXqEpzYYYHyuJKvIYNuVwo0WQdaPRXb0poZoYzOnoB6tOFrim6B7/chqtZeXQc7h6/FejBsV59aO5uATI0aAJw1twzjCNIiOeJLB2jlLuIMR3/Yaqr8IRpRXzcRPETpisWNilhV07ZBW0YL9ZwuU4sHWy+iMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAW4I1egm+czdnnZxTtth3cjCmLg/UsalUDKSfFOLAlnbe6TtVhP4DpAl+OaQO4+kdEKemLENPmh4ddaHUjSSbbCQZo8B7IjByEe7x3kQdj2ucQpA4bh0vGZ11pVhk5HfkGqAO+UVNQsyLpTmWXQ8SEbxcw6mlTM4SjuybqaGOva1LBscI158Uq5FOVT6TJaxCt3dQkBH0tK+vhRtIM13pNZ/+SFgecn16AuVdBfjjqXynefrSihQ20BZ3NTyjs/N5J2qvSwQ95JARZrlhfiS++L81u2N/0WWni9cXmHsdTLxRrDZjz2CXBNeFOBRio74klSx8tMK27/2lxMsEC7R+DA==
-----END CERTIFICATE-----

View File

@ -0,0 +1,39 @@
{
"LdapSettings": {
"Enable": true,
"EnableSync": false,
"LdapServer": "localhost",
"LdapPort": 389,
"ConnectionSecurity": "",
"BaseDN": "dc=mm,dc=test,dc=com",
"BindUsername": "cn=admin,dc=mm,dc=test,dc=com",
"BindPassword": "mostest",
"UserFilter": "",
"GroupFilter": "",
"GuestFilter": "",
"EnableAdminFilter": false,
"AdminFilter": "",
"GroupDisplayNameAttribute": "cn",
"GroupIdAttribute": "entryUUID",
"FirstNameAttribute": "cn",
"LastNameAttribute": "sn",
"EmailAttribute": "mail",
"UsernameAttribute": "uid",
"NicknameAttribute": "cn",
"IdAttribute": "uid",
"PositionAttribute": "title",
"LoginIdAttribute": "uid",
"PictureAttribute": "",
"SyncIntervalMinutes": 60,
"SkipCertificateVerification": false,
"PublicCertificateFile": "",
"PrivateKeyFile": "",
"QueryTimeout": 60,
"MaxPageSize": 0,
"LoginFieldName": "",
"LoginButtonColor": "#0000",
"LoginButtonBorderColor": "#2389D7",
"LoginButtonTextColor": "#2389D7",
"Trace": false
}
}

View File

@ -0,0 +1,14 @@
{
"OpenIdSettings": {
"Enable": true,
"Secret": "9Y7dykcoA9luTC77XtXxOu9UbNx3rhj6",
"Id": "mattermost-openid",
"Scope": "profile openid email",
"AuthEndpoint": "",
"TokenEndpoint": "",
"UserAPIEndpoint": "",
"DiscoveryEndpoint": "http://localhost:8484/realms/mattermost/.well-known/openid-configuration",
"ButtonText": "Login using OpenID",
"ButtonColor": "#ffaa4c"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
{
"SamlSettings": {
"Enable": true,
"EnableSyncWithLdap": false,
"EnableSyncWithLdapIncludeAuth": false,
"IgnoreGuestsLdapSync": false,
"Verify": false,
"Encrypt": false,
"SignRequest": false,
"IdpURL": "http://localhost:8484/realms/mattermost/protocol/saml",
"IdpDescriptorURL": "http://localhost:8484/realms/mattermost",
"IdpMetadataURL": "http://localhost:8484/realms/mattermost/protocol/saml/descriptor",
"ServiceProviderIdentifier": "mattermost",
"AssertionConsumerServiceURL": "http://localhost:8065/login/sso/saml",
"SignatureAlgorithm": "RSAwithSHA256",
"CanonicalAlgorithm": "Canonical1.0",
"ScopingIDPProviderId": "",
"ScopingIDPName": "",
"IdpCertificateFile": "saml-idp.crt",
"PublicCertificateFile": "",
"PrivateKeyFile": "",
"IdAttribute": "id",
"GuestAttribute": "",
"EnableAdminAttribute": false,
"AdminAttribute": "",
"FirstNameAttribute": "givenName",
"LastNameAttribute": "surname",
"EmailAttribute": "email",
"UsernameAttribute": "username",
"NicknameAttribute": "",
"LocaleAttribute": "",
"PositionAttribute": "",
"LoginButtonText": "SAML",
"LoginButtonColor": "#34a28b",
"LoginButtonBorderColor": "#2389D7",
"LoginButtonTextColor": "#ffffff"
}
}

View File

@ -0,0 +1,38 @@
upstream app_cluster {
server leader:8065 fail_timeout=10s max_fails=10;
server follower:8065 fail_timeout=10s max_fails=10;
server follower2:8065 fail_timeout=10s max_fails=10;
}
server {
listen 8065;
location ~ /api/v[0-9]+/(users/)?websocket$ {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
client_max_body_size 50M;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_buffers 256 16k;
proxy_buffer_size 16k;
proxy_read_timeout 600s;
proxy_pass http://app_cluster;
}
location / {
client_max_body_size 100M;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Frame-Options SAMEORIGIN;
proxy_pass http://app_cluster;
}
}

View File

@ -0,0 +1,8 @@
max_connections = 500
listen_addresses = '*'
fsync = off
full_page_writes = off
default_text_search_config = 'pg_catalog.english'
commit_delay=1000
logging_collector=off
password_encryption = 'scram-sha-256'

View File

@ -0,0 +1,2 @@
CREATE DATABASE mattermost_node_test;
GRANT ALL PRIVILEGES ON DATABASE mattermost_node_test TO mmuser;

View File

@ -0,0 +1,18 @@
global:
scrape_interval: 5s
evaluation_interval: 60s
scrape_configs:
- job_name: 'mattermost'
static_configs:
- targets: ['host.docker.internal:8067']
- job_name: fmetrics_scrape
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container'
- source_labels: ['__meta_docker_container_log_stream']
target_label: 'logstream'

View File

@ -0,0 +1,46 @@
server:
http_listen_port: 3180
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: mattermost
pipeline_stages:
- match:
selector: '{job="mattermost"}'
stages:
- json:
expressions:
timestamp: timestamp
level: level
- labels:
level:
- timestamp:
format: '2006-01-02 15:04:05.999 -07:00'
source: timestamp
static_configs:
- targets:
- localhost
labels:
job: mattermost
app: mattermost
__path__: /logs/*.log
# https://ruanbekker.medium.com/logging-with-docker-promtail-and-grafana-loki-d920fd790ca8
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container'
- source_labels: ['__meta_docker_container_log_stream']
target_label: 'logstream'
pipeline_stages:
- static_labels:
job: docker

View File

@ -0,0 +1 @@
IS_CI=true

12
build/dotenv/test.env Normal file
View File

@ -0,0 +1,12 @@
TEST_DATABASE_POSTGRESQL_DSN=postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10
GOBIN=/mattermost/server/bin
CI_INBUCKET_HOST=inbucket
CI_MINIO_HOST=minio
CI_INBUCKET_PORT=9001
CI_MINIO_PORT=9000
CI_INBUCKET_SMTP_PORT=10025
CI_LDAP_HOST=openldap
IS_CI=true
MM_EMAILSETTINGS_SMTPSERVER=inbucket
MM_ELASTICSEARCHSETTINGS_CONNECTIONURL=http://elasticsearch:9200

7
build/entrypoint.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
if [ "${1:0:1}" = '-' ]; then
set -- mattermost "$@"
fi
exec "$@"

72
build/local-test-env.sh Executable file
View File

@ -0,0 +1,72 @@
#!/bin/bash
export COMPOSE_PROJECT_NAME=localdev
local_cmdname=${0##*/}
usage()
{
cat << USAGE >&2
Usage:
$local_cmdname up/down
USAGE
exit 1
}
up()
{
docker compose run --rm start_dependencies
docker compose exec openldap bash -c 'echo -e "dn: ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: organizationalunit" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'
docker compose exec openldap bash -c 'echo -e "dn: uid=test.one,ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: iNetOrgPerson\nsn: User\ncn: Test1\nmail: success+testone@simulator.amazonses.com" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'
docker compose exec openldap bash -c 'ldappasswd -s Password1 -D "cn=admin,dc=mm,dc=test,dc=com" -x "uid=test.one,ou=testusers,dc=mm,dc=test,dc=com" -w mostest'
docker compose exec openldap bash -c 'echo -e "dn: uid=test.two,ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: iNetOrgPerson\nsn: User\ncn: Test2\nmail: success+testtwo@simulator.amazonses.com" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'
docker compose exec openldap bash -c 'ldappasswd -s Password1 -D "cn=admin,dc=mm,dc=test,dc=com" -x "uid=test.two,ou=testusers,dc=mm,dc=test,dc=com" -w mostest'
docker compose exec openldap bash -c 'echo -e "dn: cn=tgroup,ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: groupOfUniqueNames\nuniqueMember: uid=test.one,ou=testusers,dc=mm,dc=test,dc=com" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest'
docker run -it -u root \
--privileged \
-v $GOPATH:/go \
-w /go/src/github.com/mattermost/mattermost-server/ \
--net ${COMPOSE_PROJECT_NAME}_mm-test \
--env-file=dotenv/test.env
-e GOPATH="/go" \
-e MM_SQLSETTINGS_DATASOURCE="postgres://mmuser:mostest@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10" \
-e MM_SQLSETTINGS_DRIVERNAME=postgres
mattermost/mattermost-build-server:20210810_golang-1.16.7 bash
}
down()
{
docker compose down
}
# process arguments
while [[ $# -gt 0 ]]
do
case "$1" in
up)
echo "Starting Containers"
up
break
;;
down)
echo "Stopping Containers"
down
break
;;
--help)
usage
;;
*)
echo "Unknown argument: $1" >&2
usage
;;
esac
done
if [[ "$1" == "" ]]; then
usage
fi

View File

@ -0,0 +1,28 @@
# Notice.txt File Configuration
We are automatically generating Notice.txt by using first-level dependencies of the project. The related pipeline uses `config.yaml` stored in this folder.
## Configuration
Sample:
```
title: "Mattermost Playbooks"
copyright: "©2015-present Mattermost, Inc. All Rights Reserved. See LICENSE for license information."
description: "This document includes a list of open source components used in Mattermost Playbooks, including those that have been modified."
search:
- "go.mod"
- "client/go.mod"
dependencies: []
devDependencies: []
```
| Field | Type | Purpose |
| :-- | :-- | :-- |
| title | string | Field content will be used as a title of the application. See first line of `NOTICE.txt` file. |
| copyright | string | Field content will be used as a copyright message. See second line of `NOTICE.txt` file. |
| description | string | Field content will be used as notice file description. See third line of `NOTICE.txt` file. |
| dependencies | array | If any dependency name mentioned, it will be automatically added even if it is not a first-level dependency. |
| devDependencies | array | If any dependency name mentioned, it will be added when it is referenced in devDependency section. |
| search | array | Pipeline will search for package.json/go.mod files mentioned here. Globstar format is supported ie. `x/**/go.mod`. |

View File

@ -0,0 +1,11 @@
---
title: "Mattermost Server"
copyright: "©2015-present Mattermost,Inc. All Rights Reserved. See LICENSE for license information."
description: "This document includes a list of open source components used in Mattermost Server, including those that have been modified."
search:
- "server/go.mod"
- "webapp/package.json"
- "webapp/channels/package.json"
ignoreDependencies:
- "@mattermost/dynamic-virtualized-list"
includeDevDependencies: false

4
build/passwd Normal file
View File

@ -0,0 +1,4 @@
root:x:0:0:root:/root:/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/sbin/nologin
nonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologin
mattermost:x:2000:2000:mattermost:/mattermost:/sbin/nologin

View File

@ -0,0 +1,52 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBF3YTiEBEACgkhnZ5+xylKZhLVj193b6d/rSQuCU/zwWeZJnqyR8wRsPotXO
CMXOUM9bTTaGfItCP9KlPPcyrshNEIgqcqhB6TSKkWSyrV5XS95Opd9Esbjw1VZq
PMezLG3m36cytEmM82+Zpagm20/1PSQS2cQxEfieUDVkM7o4iE5TpifD6rgSaObQ
njbV1z+yljWfK+1OvM4VLAQ4dC5U0NS9SwRcencGyTnw9Vynoc+tRyQn5R+RST8h
rFEjpY11BsVJ+vAdZ7NoNH6P3gOTXI8lpcSn1G0lXJrJGyd45fGR8IT1imDIdmHN
B/nIg9J+PtMmH3BuybtYzxIgZF3SKan1avirpZCKkiW/93UK02Z/aHgoth27fJLf
kKNgO+5Rvf9TOdWujAgOnGHMedEslGzFv74tlnrGYvqo/hWhHuV6jFFEtZvI5LuL
RD5lzZePEgN9XQ80In6LD1TAIOs3LPLvg7+Wxd9foc0qAMLUtmYjKG0/hmXPjrPY
shuXUVCj0VD0RATdhWGKV8cWlOnh6elnUr9/BBZ32SP1ID76oLXjoPgy/jWxAvJf
UqxYkwcGpEJqxywE5BAw4v29mz4nbzxLcpgK30soeIIu7A+tYtgMaJ89TPA53Hwt
h+8+pPsVG2uB5Iz2SFiHQ1GymGpwxsqPRJosC5/6xsoKIqfvLFNCx83FhQARAQAB
tClNYXR0ZXJtb3N0LCBJbmMuIDxzdXBwb3J0QG1hdHRlcm1vc3QuY29tPokCTgQT
AQoAOBYhBMVYgbgPaehjuFrV0dG1S0elzv7EBQJd2E4hAhsDBQsJCAcDBRUKCQgL
BRYCAwEAAh4BAheAAAoJENG1S0elzv7ETDsQAIthyS2tHHmZKVnWrwTuhcls88GI
gTOzGzlUCyMH7mAQzv9PgtUz9LVDN1wiIRzqdVUc0ljKFF5urjLcg1t1ni3Ocy87
ysE+bpOtBJcVK7fLV/XVAGmxtGblkWaCHaYFCXSqrXialXm588Bnsbfd+2HNPX63
8mlfYgCIO5Kowlk2OPCACiB4TM8vOi0q6xV740hWfDor7BQo7jK/LL24EPGY4m30
nMXHsZnyLCDvfJ8y3clb7ac0qCyF2g8npU2EVdE01IpYuOVCIZ8zenXKCHeTKisl
ahUpm+WbhFGrys1cXLad1TaBVjw1aPmBA1PGh9yDCIxI4WBX5l0qqr3rxT7DdrBh
YD7biejekWYlw/MDkK1bp6tryiR2H7kK4Jp8zUcrsBT0cIr7m2UdP/qChK7Z9jBA
gUEMM0gmZYyaK7dqOESifg+67tm1D35qrD8lAb1xXjk76PdzkTleMJfpVgONs870
GLsqbik2ZAWX/dP7g6k3TRMOkDW2TvnzKjK7K6ETMWFObB+k8FgOibdgmLyXfMAq
IAEmCqhGv17qd+99xru76TCRIisnzG0/kSALBKny9w/nuUDx6xS4w7E8szEe5geg
VJ3CxBNSOlA8YJo2BpCG9LH7O6l4gg65jGQZ//8LSJel7EXsFJQizwPTdh5T61xb
ktH5ucon5W99mvpuuQINBF3YTiEBEAC3ottuAdWtXjZEQg1k9c3enb+aYlqcElY1
5izMqha/t+ySZn4ZRjux/6soPDUEEH1kAasrewvAcG4dcFEhRUSv67AJ+Jc0dpEd
RNcZBCYGVqo/PxHwSKdxQduKSd6Qwm19UPpRvw5ETV83mOcpUCiupS3Chx8pjQB0
vxCf9L545dLOFppWKcAjB96Snhl9IuwluzoPjO2zuY5qCHfWtyw4n5XVfvMbkL3v
KRaVHPheT6L4Hrr12D+BycYUE1yztWoX2wqMpRole3qpvkCQ46pPok/XLMIlCKgp
Hc/RbnKKFCYjIhQEQLR9WhnBBnhhXS6lglgyHzTVOIMEAe6jXDfSb0pk29g6NTjW
7YycWKNO6Cwzpnbu/fHOzeP4oBURKAzPBgGBRzTZiBtMoViQy3l6HoCsWhMeyez7
5jvk3ZrRvijNenE6gzuMsN6LnKgLGRMRYerDP466uZmp6qrVQbAgHSWZzK7j2fv8
NsJw+Abf/sN+UrDPZqxLnSvMy2qtr+ZXAEjfVEerN9OSyC5wnEpRoPbV3vSsjImo
JWeVtxPUBubTGns92/9CjV0jHPQofpKnWqFPh0OzNCZWtFdv5J1FrMnZo+sg1vTb
4Vkx3SXg69JriicmbD5Ohd0cfcDfhAB/Ip5W/uNOUNWDHfFdInCywMzILrNJNPYU
aqj2uxdKlwARAQABiQI2BBgBCgAgFiEExViBuA9p6GO4WtXR0bVLR6XO/sQFAl3Y
TiECGwwACgkQ0bVLR6XO/sRHIw//WwLthCOZABhq1H+wvF8MPmkKE/JOBaLCYgCz
vIZ2aOeDilKEfQdhjv4257VuKet+VjC7dnWafVi7S51PIgmrOh3VUT9+RDB3dDTZ
OT3uD4xpNdbkgfIzRo0FgOGYpnE6SBxF9Tr9U9ENjyfqrt1w0odvuVp1WK1jJhDz
HWRqDyNrdMYLS/VsgXv5Pz8XoFpKryyOYqG/x3rU62cZ5McFBu/b9dVyyZuxfmKV
rhe5zXKIaBVnv9G7U9+1Zqbkhbfj5irne4hwuGT9RHRkxfuM0+YW0nopIniKYDFI
F7mX2QHf7HN4jip8N+vht6fsRe1UY9Ktp6jOlNkCJEAhhoLI/bOenop3i07xAMry
gHk/FGNbpM9M7oCn7XDRTr4afw0JUIFQQ9/NUPv5Ktjf3CAuY1NCivzSIqRwEnsc
MO/8PtKxycbLAxfZe3QIp/bD9czU3F00rM4JIc+sBS5l373BJY3gqqpKd6/Oe1uK
Pl1UP/KBMTTBnpcwvRnAXzg7petoNojBnbtmRzsHoNwNYVRu7Gpscb1WU/+IgBe6
VbpFWCb4naPYMMhl/2afOKtmASJnw3MQkMXlTyf59+NoYRtAEh7mQ2AzBqYcsYbs
ue8bSAG/lgJ7XKl4JyfhvC/63CTi/xMklsW5BGQn77vGjKo0bv5Oi1XfqhEujFqY
d+eYY64=
=UePK
-----END PGP PUBLIC KEY BLOCK-----

257
build/release.mk Normal file
View File

@ -0,0 +1,257 @@
dist: | check-style test package
build-linux: build-linux-amd64 build-linux-arm64
build-linux-amd64:
@echo Build Linux amd64
ifeq ($(BUILDER_GOOS_GOARCH),"linux_amd64")
env GOOS=linux GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
else
mkdir -p $(GOBIN)/linux_amd64
env GOOS=linux GOARCH=amd64 $(GO) build -o $(GOBIN)/linux_amd64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
endif
ifeq ($(FIPS_ENABLED),true)
@echo Verifying Build Linux amd64 for FIPS
$(GO) version -m $(GOBIN)/$(MM_BIN_NAME) | grep -q "GOEXPERIMENT=systemcrypto" || (echo "ERROR: FIPS mattermost binary missing GOEXPERIMENT=systemcrypto" && exit 1)
$(GO) version -m $(GOBIN)/$(MM_BIN_NAME) | grep "\-tags" | grep -q "requirefips" || (echo "ERROR: FIPS mattermost binary missing -tags=requirefips" && exit 1)
$(GO) tool nm $(GOBIN)/$(MM_BIN_NAME) | grep -q "func_go_openssl_OpenSSL_version" || (echo "ERROR: FIPS mattermost binary missing OpenSSL integration" && exit 1)
$(GO) version -m $(GOBIN)/$(MMCTL_BIN_NAME) | grep -q "GOEXPERIMENT=systemcrypto" || (echo "ERROR: FIPS mmctl binary missing GOEXPERIMENT=systemcrypto" && exit 1)
$(GO) version -m $(GOBIN)/$(MMCTL_BIN_NAME) | grep "\-tags" | grep -q "requirefips" || (echo "ERROR: FIPS mmctl binary missing -tags=requirefips" && exit 1)
$(GO) tool nm $(GOBIN)/$(MMCTL_BIN_NAME) | grep -q "func_go_openssl_OpenSSL_version" || (echo "ERROR: FIPS mmctl binary missing OpenSSL integration" && exit 1)
endif
build-linux-arm64:
ifeq ($(FIPS_ENABLED),true)
@echo Skipping Build Linux arm64 for FIPS
else
@echo Build Linux arm64
ifeq ($(BUILDER_GOOS_GOARCH),"linux_arm64")
env GOOS=linux GOARCH=arm64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
else
mkdir -p $(GOBIN)/linux_arm64
env GOOS=linux GOARCH=arm64 $(GO) build -o $(GOBIN)/linux_arm64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
endif
endif
build-osx:
@echo Build OSX amd64
ifeq ($(BUILDER_GOOS_GOARCH),"darwin_amd64")
env GOOS=darwin GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
else
mkdir -p $(GOBIN)/darwin_amd64
env GOOS=darwin GOARCH=amd64 $(GO) build -o $(GOBIN)/darwin_amd64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
endif
@echo Build OSX arm64
ifeq ($(BUILDER_GOOS_GOARCH),"darwin_arm64")
env GOOS=darwin GOARCH=arm64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
else
mkdir -p $(GOBIN)/darwin_arm64
env GOOS=darwin GOARCH=arm64 $(GO) build -o $(GOBIN)/darwin_arm64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
endif
build-windows:
@echo Build Windows amd64
ifeq ($(BUILDER_GOOS_GOARCH),"windows_amd64")
env GOOS=windows GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
else
mkdir -p $(GOBIN)/windows_amd64
env GOOS=windows GOARCH=amd64 $(GO) build -o $(GOBIN)/windows_amd64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./...
endif
build-cmd-linux:
@echo Build CMD Linux amd64
ifeq ($(BUILDER_GOOS_GOARCH),"linux_amd64")
env GOOS=linux GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
else
mkdir -p $(GOBIN)/linux_amd64
env GOOS=linux GOARCH=amd64 $(GO) build -o $(GOBIN)/linux_amd64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
endif
ifeq ($(FIPS_ENABLED),true)
@echo Verifying Build Linux amd64 for FIPS
$(GO) version -m $(GOBIN)/mattermost | grep -q "GOEXPERIMENT=systemcrypto" || (echo "ERROR: FIPS mattermost binary missing GOEXPERIMENT=systemcrypto" && exit 1)
$(GO) version -m $(GOBIN)/mattermost | grep "\-tags" | grep -q "requirefips" || (echo "ERROR: FIPS mattermost binary missing -tags=requirefips" && exit 1)
$(GO) tool nm $(GOBIN)/mattermost | grep -q "func_go_openssl_OpenSSL_version" || (echo "ERROR: FIPS mattermost binary missing OpenSSL integration" && exit 1)
$(GO) version -m $(GOBIN)/mmctl | grep -q "GOEXPERIMENT=systemcrypto" || (echo "ERROR: FIPS mmctl binary missing GOEXPERIMENT=systemcrypto" && exit 1)
$(GO) version -m $(GOBIN)/mmctl | grep "\-tags" | grep -q "requirefips" || (echo "ERROR: FIPS mmctl binary missing -tags=requirefips" && exit 1)
$(GO) tool nm $(GOBIN)/mmctl | grep -q "func_go_openssl_OpenSSL_version" || (echo "ERROR: FIPS mmctl binary missing OpenSSL integration" && exit 1)
endif
ifeq ($(FIPS_ENABLED),true)
@echo Skipping Build Linux arm64 for FIPS
else
@echo Build CMD Linux arm64
ifeq ($(BUILDER_GOOS_GOARCH),"linux_arm64")
env GOOS=linux GOARCH=arm64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
else
mkdir -p $(GOBIN)/linux_arm64
env GOOS=linux GOARCH=arm64 $(GO) build -o $(GOBIN)/linux_arm64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
endif
endif
build-cmd-osx:
@echo Build CMD OSX amd64
ifeq ($(BUILDER_GOOS_GOARCH),"darwin_amd64")
env GOOS=darwin GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
else
mkdir -p $(GOBIN)/darwin_amd64
env GOOS=darwin GOARCH=amd64 $(GO) build -o $(GOBIN)/darwin_amd64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
endif
@echo Build CMD OSX arm64
ifeq ($(BUILDER_GOOS_GOARCH),"darwin_arm64")
env GOOS=darwin GOARCH=arm64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
else
mkdir -p $(GOBIN)/darwin_arm64
env GOOS=darwin GOARCH=arm64 $(GO) build -o $(GOBIN)/darwin_arm64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
endif
build-cmd-windows:
@echo Build CMD Windows amd64
ifeq ($(BUILDER_GOOS_GOARCH),"windows_amd64")
env GOOS=windows GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
else
mkdir -p $(GOBIN)/windows_amd64
env GOOS=windows GOARCH=amd64 $(GO) build -o $(GOBIN)/windows_amd64 $(GOFLAGS) -trimpath -tags '$(BUILD_TAGS) production' -ldflags '$(LDFLAGS)' ./cmd/...
endif
# Only build linux by default. Other platforms can be built by specifying the platform.
build: setup-go-work build-client build-linux
# Only build linux by default. Other platforms can be built by specifying the platform.
build-cmd: setup-go-work build-client build-cmd-linux
build-client:
@echo Building mattermost web app
cd $(BUILD_WEBAPP_DIR) && $(MAKE) dist
package-prep:
@ echo Packaging mattermost
@# Remove any old files
rm -Rf $(DIST_ROOT)
@# Resource directories
mkdir -p $(DIST_PATH)/config
cp -L config/README.md $(DIST_PATH)/config
OUTPUT_CONFIG=$(PWD)/$(DIST_PATH)/config/config.json go run ./scripts/config_generator
cp -RL fonts $(DIST_PATH)
cp -RL templates $(DIST_PATH)
rm -rf $(DIST_PATH)/templates/*.mjml $(DIST_PATH)/templates/partials/
cp -RL i18n $(DIST_PATH)
@# Disable developer settings
sed -i'' -e 's|"ConsoleLevel": "DEBUG"|"ConsoleLevel": "INFO"|g' $(DIST_PATH)/config/config.json
sed -i'' -e 's|"SiteURL": "http://localhost:8065"|"SiteURL": ""|g' $(DIST_PATH)/config/config.json
@# Reset email sending to original configuration
sed -i'' -e 's|"SendEmailNotifications": true,|"SendEmailNotifications": false,|g' $(DIST_PATH)/config/config.json
sed -i'' -e 's|"FeedbackEmail": "test@example.com",|"FeedbackEmail": "",|g' $(DIST_PATH)/config/config.json
sed -i'' -e 's|"ReplyToAddress": "test@example.com",|"ReplyToAddress": "",|g' $(DIST_PATH)/config/config.json
sed -i'' -e 's|"SMTPServer": "localhost",|"SMTPServer": "",|g' $(DIST_PATH)/config/config.json
sed -i'' -e 's|"SMTPPort": "2500",|"SMTPPort": "",|g' $(DIST_PATH)/config/config.json
chmod 600 $(DIST_PATH)/config/config.json
@# Package web app
mkdir -p $(DIST_PATH)/client
cp -RL $(BUILD_WEBAPP_DIR)/channels/dist/* $(DIST_PATH)/client
@# Help files
ifeq ($(BUILD_ENTERPRISE_READY),true)
cp $(BUILD_ENTERPRISE_DIR)/ENTERPRISE-EDITION-LICENSE.txt $(DIST_PATH)
cp -L $(BUILD_ENTERPRISE_DIR)/cloud/config/cloud_defaults.json $(DIST_PATH)/config
else
cp build/MIT-COMPILED-LICENSE.md $(DIST_PATH)
endif
cp ../NOTICE.txt $(DIST_PATH)
cp ../README.md $(DIST_PATH)
if [ -f bin/manifest.txt ]; then \
cp bin/manifest.txt $(DIST_PATH); \
fi
fetch-prepackaged-plugins:
@# Import Mattermost plugin public key, ignoring errors. In FIPS mode, GPG fails to start
@# the gpg-agent, but still imports the key. If it really fails, it will fail validation later.
-gpg --import build/plugin-production-public-key.gpg
@# Download prepackaged plugins
mkdir -p tmpprepackaged
@echo "Downloading prepackaged plugins ... "
@cd tmpprepackaged && for plugin_package in $(PLUGIN_PACKAGES) ; do \
curl -f -O -L https://plugins.releases.mattermost.com/release/$$plugin_package-$(PLUGIN_ARCH).tar.gz; \
curl -f -O -L https://plugins.releases.mattermost.com/release/$$plugin_package-$(PLUGIN_ARCH).tar.gz.sig; \
done
@echo "Done"
package-general:
@# Create needed directories
mkdir -p $(DIST_PATH_GENERIC)/bin
mkdir -p $(DIST_PATH_GENERIC)/logs
@# Copy binaries
ifeq ($(BUILDER_GOOS_GOARCH),"$(CURRENT_PACKAGE_ARCH)")
cp $(GOBIN)/$(MM_BIN_NAME) $(GOBIN)/$(MMCTL_BIN_NAME) $(DIST_PATH_GENERIC)/bin # from native bin dir, not cross-compiled
else
cp $(GOBIN)/$(CURRENT_PACKAGE_ARCH)/$(MM_BIN_NAME) $(GOBIN)/$(CURRENT_PACKAGE_ARCH)/$(MMCTL_BIN_NAME) $(DIST_PATH_GENERIC)/bin # from cross-compiled bin dir
endif
package-plugins: fetch-prepackaged-plugins
@# Create needed directories
mkdir -p $(DIST_PATH_GENERIC)/prepackaged_plugins
@# Prepackage plugins
@for plugin_package in $(PLUGIN_PACKAGES) ; do \
ARCH=$(PLUGIN_ARCH); \
cp tmpprepackaged/$$plugin_package-$$ARCH.tar.gz $(DIST_PATH_GENERIC)/prepackaged_plugins; \
cp tmpprepackaged/$$plugin_package-$$ARCH.tar.gz.sig $(DIST_PATH_GENERIC)/prepackaged_plugins; \
gpg --verify $(DIST_PATH_GENERIC)/prepackaged_plugins/$$plugin_package-$$ARCH.tar.gz.sig $(DIST_PATH_GENERIC)/prepackaged_plugins/$$plugin_package-$$ARCH.tar.gz; \
if [ $$? -ne 0 ]; then \
echo "Failed to verify $$plugin_package-$$ARCH.tar.gz|$$plugin_package-$$ARCH.tar.gz.sig"; \
exit 1; \
fi; \
done
package-osx-amd64: package-prep
DIST_PATH_GENERIC=$(DIST_PATH_OSX_AMD64) CURRENT_PACKAGE_ARCH=darwin_amd64 MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general
@# Package
tar -C $(DIST_PATH_OSX_AMD64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-darwin-amd64.tar.gz mattermost ../mattermost
@# Cleanup
rm -rf $(DIST_ROOT)/darwin_amd64
package-osx-arm64: package-prep
DIST_PATH_GENERIC=$(DIST_PATH_OSX_ARM64) CURRENT_PACKAGE_ARCH=darwin_arm64 MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general
@# Package
tar -C $(DIST_PATH_OSX_ARM64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-darwin-arm64.tar.gz mattermost ../mattermost
@# Cleanup
rm -rf $(DIST_ROOT)/darwin_arm64
package-osx: package-osx-amd64 package-osx-arm64
package-linux-amd64: package-prep
DIST_PATH_GENERIC=$(DIST_PATH_LIN_AMD64) PLUGIN_ARCH=linux-amd64 $(MAKE) package-plugins
DIST_PATH_GENERIC=$(DIST_PATH_LIN_AMD64) CURRENT_PACKAGE_ARCH=linux_amd64 MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general
@# Package
tar -C $(DIST_PATH_LIN_AMD64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-linux-amd64.tar.gz mattermost ../mattermost
@# Cleanup
rm -rf $(DIST_ROOT)/linux_amd64
package-linux-arm64: package-prep
ifeq ($(FIPS_ENABLED),true)
@echo Skipping package linux arm64 for FIPS
else
DIST_PATH_GENERIC=$(DIST_PATH_LIN_ARM64) CURRENT_PACKAGE_ARCH=linux_arm64 MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general
@# Package
tar -C $(DIST_PATH_LIN_ARM64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-linux-arm64.tar.gz mattermost ../mattermost
@# Cleanup
rm -rf $(DIST_ROOT)/linux_arm64
endif
package-linux: package-linux-amd64 package-linux-arm64
package-windows: package-prep
DIST_PATH_GENERIC=$(DIST_PATH_WIN) CURRENT_PACKAGE_ARCH=windows_amd64 MM_BIN_NAME=mattermost.exe MMCTL_BIN_NAME=mmctl.exe $(MAKE) package-general
@# Package
cd $(DIST_PATH_WIN)/.. && zip -9 -r -q -l ../mattermost-$(BUILD_TYPE_NAME)-windows-amd64.zip mattermost ../mattermost && cd ../..
@# Cleanup
rm -rf $(DIST_ROOT)/windows
# Only package linux by default. Other platforms can be packaged by specifying the platform.
package: package-linux
rm -rf tmpprepackaged
rm -rf $(DIST_PATH)

View File

@ -0,0 +1,721 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitAccessControlPolicy() {
if !api.srv.Config().FeatureFlags.AttributeBasedAccessControl {
return
}
api.BaseRoutes.AccessControlPolicies.Handle("", api.APISessionRequired(createAccessControlPolicy)).Methods(http.MethodPut)
api.BaseRoutes.AccessControlPolicies.Handle("/search", api.APISessionRequired(searchAccessControlPolicies)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/check", api.APISessionRequired(checkExpression)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/test", api.APISessionRequired(testExpression)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/validate_requester", api.APISessionRequired(validateExpressionAgainstRequester)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/autocomplete/fields", api.APISessionRequired(getFieldsAutocomplete)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/visual_ast", api.APISessionRequired(convertToVisualAST)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicy.Handle("", api.APISessionRequired(getAccessControlPolicy)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicy.Handle("", api.APISessionRequired(deleteAccessControlPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.AccessControlPolicy.Handle("/activate", api.APISessionRequired(updateActiveStatus)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicy.Handle("/assign", api.APISessionRequired(assignAccessPolicy)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicy.Handle("/unassign", api.APISessionRequired(unassignAccessPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels", api.APISessionRequired(getChannelsForAccessControlPolicy)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels/search", api.APISessionRequired(searchChannelsForAccessControlPolicy)).Methods(http.MethodPost)
}
func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
var policy model.AccessControlPolicy
if jsonErr := json.NewDecoder(r.Body).Decode(&policy); jsonErr != nil {
c.SetInvalidParamWithErr("policy", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateAccessControlPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "requested", &policy)
switch policy.Type {
case model.AccessControlPolicyTypeParent:
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
case model.AccessControlPolicyTypeChannel:
// Check if user has system admin permission first
hasManageSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasManageSystemPermission {
// For non-system admins, check channel-specific permission
if !model.IsValidId(policy.ID) {
c.SetInvalidParam("policy.id")
return
}
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, policy.ID, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
}
// Now do the full validation (channel exists, is private, etc.)
if appErr := c.App.ValidateChannelAccessControlPolicyCreation(c.AppContext, c.AppContext.Session().UserId, &policy); appErr != nil {
c.Err = appErr
return
}
}
default:
c.SetInvalidParam("type")
return
}
np, appErr := c.App.CreateOrUpdateAccessControlPolicy(c.AppContext, &policy)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventObjectType("access_control_policy")
auditRec.AddEventResultState(np)
js, err := json.Marshal(np)
if err != nil {
c.Err = model.NewAppError("createAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
if c.Err != nil {
return
}
policyID := c.Params.PolicyId
// Extract optional channelId from query parameters for context
channelID := r.URL.Query().Get("channelId")
// Check if user has system admin permission OR channel-specific permission
hasManageSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasManageSystemPermission {
// For non-system admins, validate policy access permission (read-only access for GET requests)
if appErr := c.App.ValidateAccessControlPolicyPermissionWithChannelContext(c.AppContext, c.AppContext.Session().UserId, policyID, true, channelID); appErr != nil {
c.SetPermissionError(model.PermissionManageSystem)
return
}
}
policy, appErr := c.App.GetAccessControlPolicy(c.AppContext, policyID)
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(policy)
if err != nil {
c.Err = model.NewAppError("getAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
if c.Err != nil {
return
}
policyID := c.Params.PolicyId
auditRec := c.MakeAuditRecord(model.AuditEventDeleteAccessControlPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", policyID)
// Check if user has system admin permission OR channel-specific permission
hasManageSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasManageSystemPermission {
// For non-system admins, validate policy access permission
if appErr := c.App.ValidateAccessControlPolicyPermission(c.AppContext, c.AppContext.Session().UserId, policyID); appErr != nil {
c.SetPermissionError(model.PermissionManageSystem)
return
}
}
appErr := c.App.DeleteAccessControlPolicy(c.AppContext, policyID)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
}
func checkExpression(c *Context, w http.ResponseWriter, r *http.Request) {
// request type reserved for future expansion
// for now, we only support the expression check
checkExpressionRequest := struct {
Expression string `json:"expression"`
ChannelId string `json:"channelId,omitempty"`
}{}
if jsonErr := json.NewDecoder(r.Body).Decode(&checkExpressionRequest); jsonErr != nil {
c.SetInvalidParamWithErr("user", jsonErr)
return
}
// Get channelId from request body (required for channel-specific permission check)
channelId := checkExpressionRequest.ChannelId
if channelId != "" && !model.IsValidId(channelId) {
c.SetInvalidParam("channelId")
return
}
// Check permissions: system admin OR channel-specific permission
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasSystemPermission {
// For channel admins, channelId is required
if channelId == "" {
c.SetPermissionError(model.PermissionManageSystem)
return
}
// SECURE: Check specific channel permission
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
}
}
errs, appErr := c.App.CheckExpression(c.AppContext, checkExpressionRequest.Expression)
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(errs)
if err != nil {
c.Err = model.NewAppError("checkExpression", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func testExpression(c *Context, w http.ResponseWriter, r *http.Request) {
var checkExpressionRequest model.QueryExpressionParams
if jsonErr := json.NewDecoder(r.Body).Decode(&checkExpressionRequest); jsonErr != nil {
c.SetInvalidParamWithErr("user", jsonErr)
return
}
// Get channelId from request body (required for channel-specific permission check)
channelId := checkExpressionRequest.ChannelId
if channelId != "" && !model.IsValidId(channelId) {
c.SetInvalidParam("channelId")
return
}
// Check permissions: system admin OR channel-specific permission
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasSystemPermission {
// For channel admins, channelId is required
if channelId == "" {
c.SetPermissionError(model.PermissionManageSystem)
return
}
// SECURE: Check specific channel permission
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
}
}
var users []*model.User
var count int64
var appErr *model.AppError
searchOpts := model.SubjectSearchOptions{
Term: checkExpressionRequest.Term,
Limit: checkExpressionRequest.Limit,
Cursor: model.SubjectCursor{
TargetID: checkExpressionRequest.After,
},
}
if hasSystemPermission {
// SYSTEM ADMIN: Can see ALL users (no restrictions)
users, count, appErr = c.App.TestExpression(c.AppContext, checkExpressionRequest.Expression, searchOpts)
} else {
// CHANNEL ADMIN: Only see users matching expressions with attributes they possess
users, count, appErr = c.App.TestExpressionWithChannelContext(c.AppContext, checkExpressionRequest.Expression, searchOpts)
}
if appErr != nil {
c.Err = appErr
return
}
resp := model.AccessControlPolicyTestResponse{
Users: users,
Total: count,
}
js, err := json.Marshal(resp)
if err != nil {
c.Err = model.NewAppError("checkExpression", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func validateExpressionAgainstRequester(c *Context, w http.ResponseWriter, r *http.Request) {
var request struct {
Expression string `json:"expression"`
ChannelId string `json:"channelId,omitempty"`
}
if jsonErr := json.NewDecoder(r.Body).Decode(&request); jsonErr != nil {
c.SetInvalidParamWithErr("request", jsonErr)
return
}
// Get channelId from request body (required for channel-specific permission check)
channelId := request.ChannelId
if channelId != "" && !model.IsValidId(channelId) {
c.SetInvalidParam("channelId")
return
}
// Check permissions: system admin OR channel-specific permission
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasSystemPermission {
// For channel admins, channelId is required
if channelId == "" {
c.SetPermissionError(model.PermissionManageSystem)
return
}
// SECURE: Check specific channel permission
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
}
}
// Direct validation against requester
matches, appErr := c.App.ValidateExpressionAgainstRequester(c.AppContext, request.Expression, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = appErr
return
}
response := struct {
RequesterMatches bool `json:"requester_matches"`
}{
RequesterMatches: matches,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func searchAccessControlPolicies(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
var props *model.AccessControlPolicySearch
err := json.NewDecoder(r.Body).Decode(&props)
if err != nil || props == nil {
c.SetInvalidParamWithErr("access_control_policy_search", err)
return
}
policies, total, appErr := c.App.SearchAccessControlPolicies(c.AppContext, *props)
if appErr != nil {
c.Err = appErr
return
}
result := model.AccessControlPoliciesWithCount{
Policies: policies,
Total: total,
}
js, err := json.Marshal(result)
if err != nil {
c.Err = model.NewAppError("searchAccessControlPolicies", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
if c.Err != nil {
return
}
policyID := c.Params.PolicyId
// Check if user has system admin permission OR channel-specific permission for this policy
hasManageSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasManageSystemPermission {
// For non-system admins, validate policy access permission
if appErr := c.App.ValidateAccessControlPolicyPermission(c.AppContext, c.AppContext.Session().UserId, policyID); appErr != nil {
c.SetPermissionError(model.PermissionManageSystem)
return
}
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateActiveStatus, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", policyID)
active := r.URL.Query().Get("active")
if active != "true" && active != "false" {
c.SetInvalidParam("active")
return
}
activeBool, err := strconv.ParseBool(active)
if err != nil {
c.SetInvalidParamWithErr("active", err)
return
}
model.AddEventParameterToAuditRec(auditRec, "active", activeBool)
appErr := c.App.UpdateAccessControlPolicyActive(c.AppContext, policyID, activeBool)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
// Return success response
response := map[string]any{
"status": "OK",
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func assignAccessPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
c.RequirePolicyId()
if c.Err != nil {
return
}
policyID := c.Params.PolicyId
var assignments struct {
ChannelIds []string `json:"channel_ids"`
}
err := json.NewDecoder(r.Body).Decode(&assignments)
if err != nil {
c.SetInvalidParamWithErr("assignments", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAssignAccessPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", policyID)
model.AddEventParameterToAuditRec(auditRec, "channel_ids", assignments.ChannelIds)
if len(assignments.ChannelIds) != 0 {
_, appErr := c.App.AssignAccessControlPolicyToChannels(c.AppContext, policyID, assignments.ChannelIds)
if appErr != nil {
c.Err = appErr
return
}
}
auditRec.Success()
}
func unassignAccessPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
c.RequirePolicyId()
if c.Err != nil {
return
}
policyID := c.Params.PolicyId
var assignments struct {
ChannelIds []string `json:"channel_ids"`
}
auditRec := c.MakeAuditRecord(model.AuditEventUnassignAccessPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", policyID)
model.AddEventParameterToAuditRec(auditRec, "channel_ids", assignments.ChannelIds)
err := json.NewDecoder(r.Body).Decode(&assignments)
if err != nil {
c.SetInvalidParamWithErr("assignments", err)
return
}
if len(assignments.ChannelIds) != 0 {
appErr := c.App.UnassignPoliciesFromChannels(c.AppContext, policyID, assignments.ChannelIds)
if appErr != nil {
c.Err = appErr
return
}
}
auditRec.Success()
}
func getChannelsForAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
c.RequirePolicyId()
if c.Err != nil {
return
}
policyID := c.Params.PolicyId
afterID := r.URL.Query().Get("after")
if afterID != "" && !model.IsValidId(afterID) {
c.SetInvalidParam("after")
return
}
limitStr := r.URL.Query().Get("limit")
limit, err := strconv.Atoi(limitStr)
if err != nil {
c.Err = model.NewAppError("getChannelsForAccessControlPolicy", "api.access_control_policy.get_channels.limit.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
channels, total, appErr := c.App.GetChannelsForPolicy(c.AppContext, policyID, model.AccessControlPolicyCursor{
ID: afterID,
}, limit)
if appErr != nil {
c.Err = appErr
return
}
data := model.ChannelsWithCount{Channels: channels, TotalCount: total}
js, err := json.Marshal(data)
if err != nil {
c.Err = model.NewAppError("getChannelsForAccessControlPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func searchChannelsForAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
c.RequirePolicyId()
if c.Err != nil {
return
}
var props *model.ChannelSearch
err := json.NewDecoder(r.Body).Decode(&props)
if err != nil || props == nil {
c.SetInvalidParamWithErr("channel_search", err)
return
}
policyID := c.Params.PolicyId
c.RequirePolicyId()
opts := model.ChannelSearchOpts{
Deleted: props.Deleted,
IncludeDeleted: props.IncludeDeleted,
Private: true,
ExcludeGroupConstrained: true,
TeamIds: props.TeamIds,
ParentAccessControlPolicyId: policyID,
}
channels, total, appErr := c.App.SearchAllChannels(c.AppContext, props.Term, opts)
if appErr != nil {
c.Err = appErr
return
}
data := model.ChannelsWithCount{Channels: channels, TotalCount: total}
channelsJSON, jsonErr := json.Marshal(data)
if jsonErr != nil {
c.Err = model.NewAppError("searchChannelsInPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
if _, err := w.Write(channelsJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getFieldsAutocomplete(c *Context, w http.ResponseWriter, r *http.Request) {
// Get channelId from query parameters (required for channel-specific permission check)
channelId := r.URL.Query().Get("channelId")
if channelId != "" && !model.IsValidId(channelId) {
c.SetInvalidParam("channelId")
return
}
// Check permissions: system admin OR channel-specific permission
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasSystemPermission {
// For channel admins, channelId is required
if channelId == "" {
c.SetPermissionError(model.PermissionManageSystem)
return
}
// SECURE: Check specific channel permission
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
}
}
after := r.URL.Query().Get("after")
if after != "" && !model.IsValidId(after) {
c.SetInvalidParam("after")
return
} else if after == "" {
after = strings.Repeat("0", 26)
}
limitStr := r.URL.Query().Get("limit")
limit, err := strconv.Atoi(limitStr)
if err != nil {
c.Err = model.NewAppError("getFieldsAutocomplete", "api.access_control_policy.get_fields.limit.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
if limit <= 0 || limit > 100 {
c.Err = model.NewAppError("getFieldsAutocomplete", "api.access_control_policy.get_fields.limit.app_error", nil, "", http.StatusBadRequest)
return
}
var ac []*model.PropertyField
var appErr *model.AppError
ac, appErr = c.App.GetAccessControlFieldsAutocomplete(c.AppContext, after, limit)
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(ac)
if err != nil {
c.Err = model.NewAppError("getExpressionAutocomplete", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func convertToVisualAST(c *Context, w http.ResponseWriter, r *http.Request) {
var cel struct {
Expression string `json:"expression"`
ChannelId string `json:"channelId,omitempty"`
}
if jsonErr := json.NewDecoder(r.Body).Decode(&cel); jsonErr != nil {
c.SetInvalidParamWithErr("user", jsonErr)
return
}
// Get channelId from request body (required for channel-specific permission check)
channelId := cel.ChannelId
if channelId != "" && !model.IsValidId(channelId) {
c.SetInvalidParam("channelId")
return
}
// Check permissions: system admin OR channel-specific permission
hasSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasSystemPermission {
// For channel admins, channelId is required
if channelId == "" {
c.SetPermissionError(model.PermissionManageSystem)
return
}
// SECURE: Check specific channel permission
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
}
}
visualAST, appErr := c.App.ExpressionToVisualAST(c.AppContext, cel.Expression)
if appErr != nil {
c.Err = appErr
return
}
b, err := json.Marshal(visualAST)
if err != nil {
c.Err = model.NewAppError("convertToVisualAST", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitAccessControlPolicyLocal() {
if !api.srv.Config().FeatureFlags.AttributeBasedAccessControl {
return
}
api.BaseRoutes.AccessControlPolicies.Handle("", api.APILocal(createAccessControlPolicy)).Methods(http.MethodPut)
api.BaseRoutes.AccessControlPolicies.Handle("/search", api.APILocal(searchAccessControlPolicies)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/check", api.APILocal(checkExpression)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/test", api.APILocal(testExpression)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/validate_requester", api.APILocal(validateExpressionAgainstRequester)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/autocomplete/fields", api.APILocal(getFieldsAutocomplete)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicies.Handle("/cel/visual_ast", api.APILocal(convertToVisualAST)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicy.Handle("", api.APILocal(getAccessControlPolicy)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicy.Handle("", api.APILocal(deleteAccessControlPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.AccessControlPolicy.Handle("/activate", api.APILocal(updateActiveStatus)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicy.Handle("/assign", api.APILocal(assignAccessPolicy)).Methods(http.MethodPost)
api.BaseRoutes.AccessControlPolicy.Handle("/unassign", api.APILocal(unassignAccessPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels", api.APILocal(getChannelsForAccessControlPolicy)).Methods(http.MethodGet)
api.BaseRoutes.AccessControlPolicy.Handle("/resources/channels/search", api.APILocal(searchChannelsForAccessControlPolicy)).Methods(http.MethodPost)
}

View File

@ -0,0 +1,867 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"os"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
"github.com/stretchr/testify/require"
)
func TestCreateAccessControlPolicy(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic()
t.Cleanup(func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL")
})
samplePolicy := &model.AccessControlPolicy{
ID: th.BasicChannel.Id,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
t.Run("CreateAccessControlPolicy without license", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.CreateAccessControlPolicy(context.Background(), samplePolicy)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("CreateAccessControlPolicy with regular user", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
// Create another user who will create the channel
channelCreator := th.CreateUser()
th.LinkUserToTeam(channelCreator, th.BasicTeam)
channelCreatorClient := th.CreateClient()
_, _, err := channelCreatorClient.Login(context.Background(), channelCreator.Email, channelCreator.Password)
require.NoError(t, err)
// Create a private channel with the other user (not th.BasicUser)
privateChannel, _, err := channelCreatorClient.CreateChannel(context.Background(), &model.Channel{
TeamId: th.BasicTeam.Id,
Name: "private-channel-" + model.NewId(),
DisplayName: "Private Channel",
Type: model.ChannelTypePrivate,
})
require.NoError(t, err)
// Create channel-specific policy (regular user should not have permission)
channelPolicy := &model.AccessControlPolicy{
ID: privateChannel.Id, // Set to actual channel ID
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
// Create and set up the mock
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := th.Client.CreateAccessControlPolicy(context.Background(), channelPolicy)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("CreateAccessControlPolicy with channel admin for their channel", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
// Add the permission to channel admin role
th.AddPermissionToRole(model.PermissionManageChannelAccessRules.Id, model.ChannelAdminRoleId)
// Create a private channel and make user channel admin
privateChannel := th.CreatePrivateChannel()
channelAdmin := th.CreateUser()
th.LinkUserToTeam(channelAdmin, th.BasicTeam)
th.AddUserToChannel(channelAdmin, privateChannel)
th.MakeUserChannelAdmin(channelAdmin, privateChannel)
channelAdminClient := th.CreateClient()
th.LoginBasicWithClient(channelAdminClient)
_, _, err := channelAdminClient.Login(context.Background(), channelAdmin.Email, channelAdmin.Password)
require.NoError(t, err)
// Create channel-specific policy
channelPolicy := &model.AccessControlPolicy{
ID: privateChannel.Id,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
// Create and set up the mock
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(channelPolicy, nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := channelAdminClient.CreateAccessControlPolicy(context.Background(), channelPolicy)
require.NoError(t, err)
CheckOKStatus(t, resp)
})
t.Run("CreateAccessControlPolicy with channel admin for another channel should fail", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
// Create two private channels
privateChannel1 := th.CreatePrivateChannel()
privateChannel2 := th.CreatePrivateChannel()
channelAdmin := th.CreateUser()
th.LinkUserToTeam(channelAdmin, th.BasicTeam)
th.AddUserToChannel(channelAdmin, privateChannel1)
th.MakeUserChannelAdmin(channelAdmin, privateChannel1)
channelAdminClient := th.CreateClient()
th.LoginBasicWithClient(channelAdminClient)
_, _, err := channelAdminClient.Login(context.Background(), channelAdmin.Email, channelAdmin.Password)
require.NoError(t, err)
// Try to create policy for different channel
channelPolicy := &model.AccessControlPolicy{
ID: privateChannel2.Id,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := channelAdminClient.CreateAccessControlPolicy(context.Background(), channelPolicy)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("CreateAccessControlPolicy with channel admin creating parent policy should fail", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
// Create a private channel and make user channel admin
privateChannel := th.CreatePrivateChannel()
channelAdmin := th.CreateUser()
th.LinkUserToTeam(channelAdmin, th.BasicTeam)
th.AddUserToChannel(channelAdmin, privateChannel)
th.MakeUserChannelAdmin(channelAdmin, privateChannel)
channelAdminClient := th.CreateClient()
th.LoginBasicWithClient(channelAdminClient)
_, _, err := channelAdminClient.Login(context.Background(), channelAdmin.Email, channelAdmin.Password)
require.NoError(t, err)
// Try to create parent-type policy
parentPolicy := &model.AccessControlPolicy{
ID: model.NewId(),
Type: model.AccessControlPolicyTypeParent,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := channelAdminClient.CreateAccessControlPolicy(context.Background(), parentPolicy)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
// Set up a test license with Data Retention enabled
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
// Create and set up the mock
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
// Set up mock expectations
mockAccessControlService.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(samplePolicy, nil).Times(1)
// Set the mock on the app
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := client.CreateAccessControlPolicy(context.Background(), samplePolicy)
require.NoError(t, err)
CheckOKStatus(t, resp)
}, "CreateAccessControlPolicy with system admin")
t.Run("CreateAccessControlPolicy with channel scope permissions", func(t *testing.T) {
// Set up a test license with Data Retention enabled
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
// Create and set up the mock
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
ch := th.CreatePrivateChannel()
// Set up mock expectations
mockAccessControlService.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(samplePolicy, nil).Times(1)
// Set the mock on the app
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
th.AddPermissionToRole(model.PermissionManageChannelAccessRules.Id, model.ChannelAdminRoleId)
channelPolicy := &model.AccessControlPolicy{
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
ID: ch.Id,
}
_, resp, err := th.Client.CreateAccessControlPolicy(context.Background(), channelPolicy)
require.NoError(t, err)
CheckOKStatus(t, resp)
})
}
func TestGetAccessControlPolicy(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic()
t.Cleanup(func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL")
})
samplePolicy := &model.AccessControlPolicy{
ID: model.NewId(),
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
t.Run("GetAccessControlPolicy without license", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.GetAccessControlPolicy(context.Background(), samplePolicy.ID)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("GetAccessControlPolicy with regular user", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
// Create and set up the mock
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicy.ID).Return(samplePolicy, nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := th.Client.GetAccessControlPolicy(context.Background(), samplePolicy.ID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
// Create and set up the mock
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicy.ID).Return(samplePolicy, nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := client.GetAccessControlPolicy(context.Background(), samplePolicy.ID)
require.NoError(t, err)
CheckOKStatus(t, resp)
}, "GetAccessControlPolicy with system admin")
}
func TestDeleteAccessControlPolicy(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic()
t.Cleanup(func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL")
})
samplePolicyID := model.NewId()
t.Run("DeleteAccessControlPolicy without license", func(t *testing.T) {
resp, err := th.SystemAdminClient.DeleteAccessControlPolicy(context.Background(), samplePolicyID)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("DeleteAccessControlPolicy with regular user", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
// Mock the GetPolicy call that happens in ValidateAccessControlPolicyPermission
channelPolicy := &model.AccessControlPolicy{
ID: samplePolicyID,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicyID).Return(channelPolicy, nil)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
resp, err := th.Client.DeleteAccessControlPolicy(context.Background(), samplePolicyID)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("DeletePolicy", mock.AnythingOfType("*request.Context"), samplePolicyID).Return(nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
resp, err := client.DeleteAccessControlPolicy(context.Background(), samplePolicyID)
require.NoError(t, err)
CheckOKStatus(t, resp)
})
}
func TestCheckExpression(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic()
t.Cleanup(func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL")
})
t.Run("CheckExpression without license", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.CheckExpression(context.Background(), "true")
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("CheckExpression with regular user", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := th.Client.CheckExpression(context.Background(), "true")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("CheckExpression", mock.AnythingOfType("*request.Context"), "true").Return([]model.CELExpressionError{}, nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
errors, resp, err := client.CheckExpression(context.Background(), "true")
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Empty(t, errors, "expected no errors")
}, "CheckExpression with system admin")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("CheckExpression", mock.AnythingOfType("*request.Context"), "true").Return([]model.CELExpressionError{
{
Line: 1,
Column: 1,
Message: "Syntax error",
},
}, nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
errors, resp, err := client.CheckExpression(context.Background(), "true")
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotEmpty(t, errors, "expected errors")
}, "CheckExpression with system admin errors returned")
t.Run("CheckExpression with channel admin for their channel", func(t *testing.T) {
// Reload config to pick up the feature flag
err := th.App.ReloadConfig()
require.NoError(t, err)
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
// Add permission to channel admin role
th.AddPermissionToRole(model.PermissionManageChannelAccessRules.Id, model.ChannelAdminRoleId)
// Create private channel and make user channel admin
privateChannel := th.CreatePrivateChannel()
channelAdmin := th.CreateUser()
th.LinkUserToTeam(channelAdmin, th.BasicTeam)
th.AddUserToChannel(channelAdmin, privateChannel)
th.MakeUserChannelAdmin(channelAdmin, privateChannel)
channelAdminClient := th.CreateClient()
_, _, err = channelAdminClient.Login(context.Background(), channelAdmin.Email, channelAdmin.Password)
require.NoError(t, err)
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("CheckExpression", mock.AnythingOfType("*request.Context"), "true").Return([]model.CELExpressionError{}, nil).Times(1)
// Channel admin should be able to check expressions for their channel
errors, resp, err := channelAdminClient.CheckExpression(context.Background(), "true", privateChannel.Id)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Empty(t, errors, "expected no errors")
})
}
func TestTestExpression(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic()
t.Cleanup(func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL")
})
t.Run("TestExpression without license", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.TestExpression(context.Background(), model.QueryExpressionParams{})
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("TestExpression with regular user", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := th.Client.TestExpression(context.Background(), model.QueryExpressionParams{})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("QueryUsersForExpression", mock.AnythingOfType("*request.Context"), "true", model.SubjectSearchOptions{}).Return([]*model.User{}, int64(0), nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
usersResp, resp, err := client.TestExpression(context.Background(), model.QueryExpressionParams{
Expression: "true",
})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Empty(t, usersResp.Users, "expected no users")
require.Equal(t, int64(0), usersResp.Total, "expected count 0 users")
}, "TestExpression with system admin")
}
func TestSearchAccessControlPolicies(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic()
t.Cleanup(func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL")
})
t.Run("SearchAccessControlPolicies without license", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.SearchAccessControlPolicies(context.Background(), model.AccessControlPolicySearch{})
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("SearchAccessControlPolicies with regular user", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := th.Client.SearchAccessControlPolicies(context.Background(), model.AccessControlPolicySearch{})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("SearchPolicies", mock.AnythingOfType("*request.Context"), model.AccessControlPolicySearch{
Term: "engineering",
}).Return([]*model.AccessControlPolicy{}, int64(0), nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
policiesResp, resp, err := client.SearchAccessControlPolicies(context.Background(), model.AccessControlPolicySearch{
Term: "engineering",
})
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Empty(t, policiesResp.Policies, "expected no policies")
require.Equal(t, int64(0), policiesResp.Total, "expected count 0 policies")
}, "SearchAccessControlPolicies with system admin")
}
func TestAssignAccessPolicy(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic()
t.Cleanup(func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL")
})
samplePolicy := &model.AccessControlPolicy{
ID: model.NewId(),
Type: model.AccessControlPolicyTypeParent,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
t.Run("AssignAccessPolicy without license", func(t *testing.T) {
resp, err := th.SystemAdminClient.AssignAccessControlPolicies(context.Background(), model.NewId(), []string{model.NewId()})
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("AssignAccessPolicy with regular user", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
resp, err := th.Client.AssignAccessControlPolicies(context.Background(), model.NewId(), []string{model.NewId()})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
resourceID := model.NewId()
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
child := model.AccessControlPolicy{
ID: resourceID,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
}
appErr := child.Inherit(samplePolicy)
require.Nil(t, appErr)
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicy.ID).Return(samplePolicy, nil).Times(1)
mockAccessControlService.On("SavePolicy", mock.AnythingOfType("*request.Context"), mock.AnythingOfType("*model.AccessControlPolicy")).Return(child, nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
resp, err := client.AssignAccessControlPolicies(context.Background(), samplePolicy.ID, []string{resourceID})
require.NoError(t, err)
CheckOKStatus(t, resp)
}, "AssignAccessPolicy with system admin")
}
func TestUnassignAccessPolicy(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic()
t.Cleanup(func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL")
})
samplePolicy := &model.AccessControlPolicy{
ID: model.NewId(),
Type: model.AccessControlPolicyTypeParent,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
t.Run("UnassignAccessPolicy without license", func(t *testing.T) {
resp, err := th.SystemAdminClient.UnassignAccessControlPolicies(context.Background(), samplePolicy.ID, []string{model.NewId()})
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("UnassignAccessPolicy with regular user", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
resp, err := th.Client.UnassignAccessControlPolicies(context.Background(), samplePolicy.ID, []string{model.NewId()})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
resourceID := model.NewId()
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
child := &model.AccessControlPolicy{
ID: resourceID,
Type: model.AccessControlPolicyTypeChannel,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
}
appErr := child.Inherit(samplePolicy)
require.Nil(t, appErr)
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicy.ID).Return(samplePolicy, nil).Times(1)
mockAccessControlService.On("SearchPolicies", mock.AnythingOfType("*request.Context"), model.AccessControlPolicySearch{
Type: model.AccessControlPolicyTypeChannel,
ParentID: samplePolicy.ID,
}).Return([]*model.AccessControlPolicy{child}, nil).Times(1)
mockAccessControlService.On("DeletePolicy", mock.AnythingOfType("*request.Context"), child.ID).Return(nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
resp, err := client.UnassignAccessControlPolicies(context.Background(), samplePolicy.ID, []string{child.ID})
require.NoError(t, err)
CheckOKStatus(t, resp)
}, "UnassignAccessPolicy with system admin")
}
func TestGetChannelsForAccessControlPolicy(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic()
t.Cleanup(func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL")
})
samplePolicy := &model.AccessControlPolicy{
ID: model.NewId(),
Type: model.AccessControlPolicyTypeParent,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
t.Run("GetChannelsForAccessControlPolicy without license", func(t *testing.T) {
_, resp, err := th.SystemAdminClient.GetChannelsForAccessControlPolicy(context.Background(), samplePolicy.ID, "", 1000)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("GetChannelsForAccessControlPolicy with regular user", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := th.Client.GetChannelsForAccessControlPolicy(context.Background(), samplePolicy.ID, "", 1000)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
mockAccessControlService.On("GetPolicy", mock.AnythingOfType("*request.Context"), samplePolicy.ID).Return(samplePolicy, nil).Times(1)
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
channelsResp, resp, err := client.GetChannelsForAccessControlPolicy(context.Background(), samplePolicy.ID, "", 1000)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.Empty(t, channelsResp.Channels, "expected no channels")
require.Equal(t, int64(0), channelsResp.TotalCount, "expected count 0 channels")
}, "GetChannelsForAccessControlPolicy with system admin")
}
func TestSearchChannelsForAccessControlPolicy(t *testing.T) {
os.Setenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL", "true")
th := Setup(t).InitBasic()
t.Cleanup(func() {
th.TearDown()
os.Unsetenv("MM_FEATUREFLAGS_ATTRIBUTEBASEDACCESSCONTROL")
})
samplePolicy := &model.AccessControlPolicy{
ID: model.NewId(),
Type: model.AccessControlPolicyTypeParent,
Version: model.AccessControlPolicyVersionV0_2,
Revision: 1,
Rules: []model.AccessControlPolicyRule{
{
Expression: "user.attributes.team == 'engineering'",
Actions: []string{"*"},
},
},
}
t.Run("SearchChannelsForAccessControlPolicy with regular user", func(t *testing.T) {
ok := th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
require.True(t, ok, "SetLicense should return true")
mockAccessControlService := &mocks.AccessControlServiceInterface{}
th.App.Srv().Channels().AccessControl = mockAccessControlService
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.AccessControlSettings.EnableAttributeBasedAccessControl = model.NewPointer(true)
})
_, resp, err := th.Client.SearchChannelsForAccessControlPolicy(context.Background(), samplePolicy.ID, model.ChannelSearch{})
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}

55
channels/api4/agents.go Normal file
View File

@ -0,0 +1,55 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitAgents() {
// GET /api/v4/agents
api.BaseRoutes.Agents.Handle("", api.APISessionRequired(getAgents)).Methods(http.MethodGet)
// GET /api/v4/llmservices
api.BaseRoutes.LLMServices.Handle("", api.APISessionRequired(getLLMServices)).Methods(http.MethodGet)
}
func getAgents(c *Context, w http.ResponseWriter, r *http.Request) {
agents, appErr := c.App.GetAgents(c.AppContext, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getAgents", "app.agents.get_agents.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
jsonData, err := json.Marshal(agents)
if err != nil {
c.Err = model.NewAppError("Api4.getAgents", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(jsonData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getLLMServices(c *Context, w http.ResponseWriter, r *http.Request) {
services, appErr := c.App.GetLLMServices(c.AppContext, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getLLMServices", "app.agents.get_services.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
jsonData, err := json.Marshal(services)
if err != nil {
c.Err = model.NewAppError("Api4.getLLMServices", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(jsonData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

498
channels/api4/api.go Normal file
View File

@ -0,0 +1,498 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"net/http"
"github.com/gorilla/mux"
_ "github.com/mattermost/go-i18n/i18n"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/manualtesting"
"github.com/mattermost/mattermost/server/v8/channels/web"
)
type Routes struct {
Root *mux.Router // ''
APIRoot *mux.Router // 'api/v4'
APIRoot5 *mux.Router // 'api/v5'
Users *mux.Router // 'api/v4/users'
User *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}'
UserByUsername *mux.Router // 'api/v4/users/username/{username:[A-Za-z0-9\\_\\-\\.]+}'
UserByEmail *mux.Router // 'api/v4/users/email/{email:.+}'
Bots *mux.Router // 'api/v4/bots'
Bot *mux.Router // 'api/v4/bots/{bot_user_id:[A-Za-z0-9]+}'
Teams *mux.Router // 'api/v4/teams'
TeamsForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams'
Team *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}'
TeamForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}'
UserThreads *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/threads'
UserThread *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/threads/{thread_id:[A-Za-z0-9]+}'
TeamByName *mux.Router // 'api/v4/teams/name/{team_name:[A-Za-z0-9_-]+}'
TeamMembers *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/members'
TeamMember *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/members/{user_id:[A-Za-z0-9]+}'
TeamMembersForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/members'
Channels *mux.Router // 'api/v4/channels'
Channel *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}'
ChannelForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/channels/{channel_id:[A-Za-z0-9]+}'
ChannelByName *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/channels/name/{channel_name:[A-Za-z0-9_-]+}'
ChannelByNameForTeamName *mux.Router // 'api/v4/teams/name/{team_name:[A-Za-z0-9_-]+}/channels/name/{channel_name:[A-Za-z0-9_-]+}'
ChannelsForTeam *mux.Router // 'api/v4/teams/{team_id:[A-Za-z0-9]+}/channels'
ChannelMembers *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/members'
ChannelMember *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/members/{user_id:[A-Za-z0-9]+}'
ChannelMembersForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/channels/members'
ChannelModerations *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/moderations'
ChannelCategories *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/teams/{team_id:[A-Za-z0-9]+}/channels/categories'
ChannelBookmarks *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/bookmarks'
ChannelBookmark *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/bookmarks/{bookmark_id:[A-Za-z0-9]+}'
Posts *mux.Router // 'api/v4/posts'
Post *mux.Router // 'api/v4/posts/{post_id:[A-Za-z0-9]+}'
PostsForChannel *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/posts'
PostsForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/posts'
PostForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/posts/{post_id:[A-Za-z0-9]+}'
Files *mux.Router // 'api/v4/files'
File *mux.Router // 'api/v4/files/{file_id:[A-Za-z0-9]+}'
Uploads *mux.Router // 'api/v4/uploads'
Upload *mux.Router // 'api/v4/uploads/{upload_id:[A-Za-z0-9]+}'
Plugins *mux.Router // 'api/v4/plugins'
Plugin *mux.Router // 'api/v4/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}'
PublicFile *mux.Router // '/files/{file_id:[A-Za-z0-9]+}/public'
Commands *mux.Router // 'api/v4/commands'
Command *mux.Router // 'api/v4/commands/{command_id:[A-Za-z0-9]+}'
Hooks *mux.Router // 'api/v4/hooks'
IncomingHooks *mux.Router // 'api/v4/hooks/incoming'
IncomingHook *mux.Router // 'api/v4/hooks/incoming/{hook_id:[A-Za-z0-9]+}'
OutgoingHooks *mux.Router // 'api/v4/hooks/outgoing'
OutgoingHook *mux.Router // 'api/v4/hooks/outgoing/{hook_id:[A-Za-z0-9]+}'
OAuth *mux.Router // 'api/v4/oauth'
OAuthApps *mux.Router // 'api/v4/oauth/apps'
OAuthApp *mux.Router // 'api/v4/oauth/apps/{app_id:[A-Za-z0-9]+}'
SAML *mux.Router // 'api/v4/saml'
Compliance *mux.Router // 'api/v4/compliance'
Cluster *mux.Router // 'api/v4/cluster'
Image *mux.Router // 'api/v4/image'
LDAP *mux.Router // 'api/v4/ldap'
Elasticsearch *mux.Router // 'api/v4/elasticsearch'
DataRetention *mux.Router // 'api/v4/data_retention'
Brand *mux.Router // 'api/v4/brand'
System *mux.Router // 'api/v4/system'
Jobs *mux.Router // 'api/v4/jobs'
Preferences *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/preferences'
License *mux.Router // 'api/v4/license'
Public *mux.Router // 'api/v4/public'
Reactions *mux.Router // 'api/v4/reactions'
Roles *mux.Router // 'api/v4/roles'
Schemes *mux.Router // 'api/v4/schemes'
Emojis *mux.Router // 'api/v4/emoji'
Emoji *mux.Router // 'api/v4/emoji/{emoji_id:[A-Za-z0-9]+}'
EmojiByName *mux.Router // 'api/v4/emoji/name/{emoji_name:[A-Za-z0-9\\_\\-\\+]+}'
ReactionByNameForPostForUser *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/posts/{post_id:[A-Za-z0-9]+}/reactions/{emoji_name:[A-Za-z0-9\\_\\-\\+]+}'
TermsOfService *mux.Router // 'api/v4/terms_of_service'
Groups *mux.Router // 'api/v4/groups'
Cloud *mux.Router // 'api/v4/cloud'
Imports *mux.Router // 'api/v4/imports'
Import *mux.Router // 'api/v4/imports/{import_name:.+\\.zip}'
Exports *mux.Router // 'api/v4/exports'
Export *mux.Router // 'api/v4/exports/{export_name:.+\\.zip}'
RemoteCluster *mux.Router // 'api/v4/remotecluster'
SharedChannels *mux.Router // 'api/v4/sharedchannels'
ChannelForRemote *mux.Router // 'api/v4/remotecluster/{remote_id:[A-Za-z0-9]+}/channels/{channel_id:[A-Za-z0-9]+}'
SharedChannelRemotes *mux.Router // 'api/v4/remotecluster/{remote_id:[A-Za-z0-9]+}/sharedchannelremotes'
Permissions *mux.Router // 'api/v4/permissions'
Usage *mux.Router // 'api/v4/usage'
HostedCustomer *mux.Router // 'api/v4/hosted_customer'
Drafts *mux.Router // 'api/v4/drafts'
IPFiltering *mux.Router // 'api/v4/ip_filtering'
Reports *mux.Router // 'api/v4/reports'
Limits *mux.Router // 'api/v4/limits'
OutgoingOAuthConnections *mux.Router // 'api/v4/oauth/outgoing_connections'
OutgoingOAuthConnection *mux.Router // 'api/v4/oauth/outgoing_connections/{outgoing_oauth_connection_id:[A-Za-z0-9]+}'
CustomProfileAttributes *mux.Router // 'api/v4/custom_profile_attributes'
CustomProfileAttributesFields *mux.Router // 'api/v4/custom_profile_attributes/fields'
CustomProfileAttributesField *mux.Router // 'api/v4/custom_profile_attributes/fields/{field_id:[A-Za-z0-9]+}'
CustomProfileAttributesValues *mux.Router // 'api/v4/custom_profile_attributes/values'
AuditLogs *mux.Router // 'api/v4/audit_logs'
AccessControlPolicies *mux.Router // 'api/v4/access_control_policies'
AccessControlPolicy *mux.Router // 'api/v4/access_control_policies/{policy_id:[A-Za-z0-9]+}'
ContentFlagging *mux.Router // 'api/v4/content_flagging'
Agents *mux.Router // 'api/v4/agents'
LLMServices *mux.Router // 'api/v4/llmservices'
}
type API struct {
srv *app.Server
BaseRoutes *Routes
}
func Init(srv *app.Server) (*API, error) {
api := &API{
srv: srv,
BaseRoutes: &Routes{},
}
api.BaseRoutes.Root = srv.Router
api.BaseRoutes.APIRoot = srv.Router.PathPrefix(model.APIURLSuffix).Subrouter()
api.BaseRoutes.APIRoot5 = srv.Router.PathPrefix(model.APIURLSuffixV5).Subrouter()
api.BaseRoutes.Users = api.BaseRoutes.APIRoot.PathPrefix("/users").Subrouter()
api.BaseRoutes.User = api.BaseRoutes.APIRoot.PathPrefix("/users/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.UserByUsername = api.BaseRoutes.Users.PathPrefix("/username/{username:[A-Za-z0-9\\_\\-\\.]+}").Subrouter()
api.BaseRoutes.UserByEmail = api.BaseRoutes.Users.PathPrefix("/email/{email:.+}").Subrouter()
api.BaseRoutes.Bots = api.BaseRoutes.APIRoot.PathPrefix("/bots").Subrouter()
api.BaseRoutes.Bot = api.BaseRoutes.APIRoot.PathPrefix("/bots/{bot_user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Teams = api.BaseRoutes.APIRoot.PathPrefix("/teams").Subrouter()
api.BaseRoutes.TeamsForUser = api.BaseRoutes.User.PathPrefix("/teams").Subrouter()
api.BaseRoutes.Team = api.BaseRoutes.Teams.PathPrefix("/{team_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.TeamForUser = api.BaseRoutes.TeamsForUser.PathPrefix("/{team_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.UserThreads = api.BaseRoutes.TeamForUser.PathPrefix("/threads").Subrouter()
api.BaseRoutes.UserThread = api.BaseRoutes.TeamForUser.PathPrefix("/threads/{thread_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.TeamByName = api.BaseRoutes.Teams.PathPrefix("/name/{team_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.TeamMembers = api.BaseRoutes.Team.PathPrefix("/members").Subrouter()
api.BaseRoutes.TeamMember = api.BaseRoutes.TeamMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.TeamMembersForUser = api.BaseRoutes.User.PathPrefix("/teams/members").Subrouter()
api.BaseRoutes.Channels = api.BaseRoutes.APIRoot.PathPrefix("/channels").Subrouter()
api.BaseRoutes.Channel = api.BaseRoutes.Channels.PathPrefix("/{channel_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelForUser = api.BaseRoutes.User.PathPrefix("/channels/{channel_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelByName = api.BaseRoutes.Team.PathPrefix("/channels/name/{channel_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.ChannelByNameForTeamName = api.BaseRoutes.TeamByName.PathPrefix("/channels/name/{channel_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.ChannelsForTeam = api.BaseRoutes.Team.PathPrefix("/channels").Subrouter()
api.BaseRoutes.ChannelMembers = api.BaseRoutes.Channel.PathPrefix("/members").Subrouter()
api.BaseRoutes.ChannelMember = api.BaseRoutes.ChannelMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelMembersForUser = api.BaseRoutes.User.PathPrefix("/teams/{team_id:[A-Za-z0-9]+}/channels/members").Subrouter()
api.BaseRoutes.ChannelModerations = api.BaseRoutes.Channel.PathPrefix("/moderations").Subrouter()
api.BaseRoutes.ChannelCategories = api.BaseRoutes.User.PathPrefix("/teams/{team_id:[A-Za-z0-9]+}/channels/categories").Subrouter()
api.BaseRoutes.ChannelBookmarks = api.BaseRoutes.Channel.PathPrefix("/bookmarks").Subrouter()
api.BaseRoutes.ChannelBookmark = api.BaseRoutes.ChannelBookmarks.PathPrefix("/{bookmark_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Posts = api.BaseRoutes.APIRoot.PathPrefix("/posts").Subrouter()
api.BaseRoutes.Post = api.BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.PostsForChannel = api.BaseRoutes.Channel.PathPrefix("/posts").Subrouter()
api.BaseRoutes.PostsForUser = api.BaseRoutes.User.PathPrefix("/posts").Subrouter()
api.BaseRoutes.PostForUser = api.BaseRoutes.PostsForUser.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Files = api.BaseRoutes.APIRoot.PathPrefix("/files").Subrouter()
api.BaseRoutes.File = api.BaseRoutes.Files.PathPrefix("/{file_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.PublicFile = api.BaseRoutes.Root.PathPrefix("/files/{file_id:[A-Za-z0-9]+}/public").Subrouter()
api.BaseRoutes.Uploads = api.BaseRoutes.APIRoot.PathPrefix("/uploads").Subrouter()
api.BaseRoutes.Upload = api.BaseRoutes.Uploads.PathPrefix("/{upload_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Plugins = api.BaseRoutes.APIRoot.PathPrefix("/plugins").Subrouter()
api.BaseRoutes.Plugin = api.BaseRoutes.Plugins.PathPrefix("/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}").Subrouter()
api.BaseRoutes.Commands = api.BaseRoutes.APIRoot.PathPrefix("/commands").Subrouter()
api.BaseRoutes.Command = api.BaseRoutes.Commands.PathPrefix("/{command_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Hooks = api.BaseRoutes.APIRoot.PathPrefix("/hooks").Subrouter()
api.BaseRoutes.IncomingHooks = api.BaseRoutes.Hooks.PathPrefix("/incoming").Subrouter()
api.BaseRoutes.IncomingHook = api.BaseRoutes.IncomingHooks.PathPrefix("/{hook_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.OutgoingHooks = api.BaseRoutes.Hooks.PathPrefix("/outgoing").Subrouter()
api.BaseRoutes.OutgoingHook = api.BaseRoutes.OutgoingHooks.PathPrefix("/{hook_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.SAML = api.BaseRoutes.APIRoot.PathPrefix("/saml").Subrouter()
api.BaseRoutes.OAuth = api.BaseRoutes.APIRoot.PathPrefix("/oauth").Subrouter()
api.BaseRoutes.OAuthApps = api.BaseRoutes.OAuth.PathPrefix("/apps").Subrouter()
api.BaseRoutes.OAuthApp = api.BaseRoutes.OAuthApps.PathPrefix("/{app_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Compliance = api.BaseRoutes.APIRoot.PathPrefix("/compliance").Subrouter()
api.BaseRoutes.Cluster = api.BaseRoutes.APIRoot.PathPrefix("/cluster").Subrouter()
api.BaseRoutes.LDAP = api.BaseRoutes.APIRoot.PathPrefix("/ldap").Subrouter()
api.BaseRoutes.Brand = api.BaseRoutes.APIRoot.PathPrefix("/brand").Subrouter()
api.BaseRoutes.System = api.BaseRoutes.APIRoot.PathPrefix("/system").Subrouter()
api.BaseRoutes.Preferences = api.BaseRoutes.User.PathPrefix("/preferences").Subrouter()
api.BaseRoutes.License = api.BaseRoutes.APIRoot.PathPrefix("/license").Subrouter()
api.BaseRoutes.Public = api.BaseRoutes.APIRoot.PathPrefix("/public").Subrouter()
api.BaseRoutes.Reactions = api.BaseRoutes.APIRoot.PathPrefix("/reactions").Subrouter()
api.BaseRoutes.Jobs = api.BaseRoutes.APIRoot.PathPrefix("/jobs").Subrouter()
api.BaseRoutes.Elasticsearch = api.BaseRoutes.APIRoot.PathPrefix("/elasticsearch").Subrouter()
api.BaseRoutes.DataRetention = api.BaseRoutes.APIRoot.PathPrefix("/data_retention").Subrouter()
api.BaseRoutes.Emojis = api.BaseRoutes.APIRoot.PathPrefix("/emoji").Subrouter()
api.BaseRoutes.Emoji = api.BaseRoutes.APIRoot.PathPrefix("/emoji/{emoji_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.EmojiByName = api.BaseRoutes.Emojis.PathPrefix("/name/{emoji_name:[A-Za-z0-9\\_\\-\\+]+}").Subrouter()
api.BaseRoutes.ReactionByNameForPostForUser = api.BaseRoutes.PostForUser.PathPrefix("/reactions/{emoji_name:[A-Za-z0-9\\_\\-\\+]+}").Subrouter()
api.BaseRoutes.Roles = api.BaseRoutes.APIRoot.PathPrefix("/roles").Subrouter()
api.BaseRoutes.Schemes = api.BaseRoutes.APIRoot.PathPrefix("/schemes").Subrouter()
api.BaseRoutes.Image = api.BaseRoutes.APIRoot.PathPrefix("/image").Subrouter()
api.BaseRoutes.TermsOfService = api.BaseRoutes.APIRoot.PathPrefix("/terms_of_service").Subrouter()
api.BaseRoutes.Groups = api.BaseRoutes.APIRoot.PathPrefix("/groups").Subrouter()
api.BaseRoutes.Cloud = api.BaseRoutes.APIRoot.PathPrefix("/cloud").Subrouter()
api.BaseRoutes.Imports = api.BaseRoutes.APIRoot.PathPrefix("/imports").Subrouter()
api.BaseRoutes.Import = api.BaseRoutes.Imports.PathPrefix("/{import_name:.+\\.zip}").Subrouter()
api.BaseRoutes.Exports = api.BaseRoutes.APIRoot.PathPrefix("/exports").Subrouter()
api.BaseRoutes.Export = api.BaseRoutes.Exports.PathPrefix("/{export_name:.+\\.zip}").Subrouter()
api.BaseRoutes.RemoteCluster = api.BaseRoutes.APIRoot.PathPrefix("/remotecluster").Subrouter()
api.BaseRoutes.SharedChannels = api.BaseRoutes.APIRoot.PathPrefix("/sharedchannels").Subrouter()
api.BaseRoutes.SharedChannelRemotes = api.BaseRoutes.RemoteCluster.PathPrefix("/{remote_id:[A-Za-z0-9]+}/sharedchannelremotes").Subrouter()
api.BaseRoutes.ChannelForRemote = api.BaseRoutes.RemoteCluster.PathPrefix("/{remote_id:[A-Za-z0-9]+}/channels/{channel_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Permissions = api.BaseRoutes.APIRoot.PathPrefix("/permissions").Subrouter()
api.BaseRoutes.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter()
api.BaseRoutes.HostedCustomer = api.BaseRoutes.APIRoot.PathPrefix("/hosted_customer").Subrouter()
api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter()
api.BaseRoutes.IPFiltering = api.BaseRoutes.APIRoot.PathPrefix("/ip_filtering").Subrouter()
api.BaseRoutes.Reports = api.BaseRoutes.APIRoot.PathPrefix("/reports").Subrouter()
api.BaseRoutes.Limits = api.BaseRoutes.APIRoot.PathPrefix("/limits").Subrouter()
api.BaseRoutes.OutgoingOAuthConnections = api.BaseRoutes.APIRoot.PathPrefix("/oauth/outgoing_connections").Subrouter()
api.BaseRoutes.OutgoingOAuthConnection = api.BaseRoutes.OutgoingOAuthConnections.PathPrefix("/{outgoing_oauth_connection_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.CustomProfileAttributes = api.BaseRoutes.APIRoot.PathPrefix("/custom_profile_attributes").Subrouter()
api.BaseRoutes.CustomProfileAttributesFields = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/fields").Subrouter()
api.BaseRoutes.CustomProfileAttributesField = api.BaseRoutes.CustomProfileAttributesFields.PathPrefix("/{field_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.CustomProfileAttributesValues = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/values").Subrouter()
api.BaseRoutes.AuditLogs = api.BaseRoutes.APIRoot.PathPrefix("/audit_logs").Subrouter()
api.BaseRoutes.AccessControlPolicies = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies").Subrouter()
api.BaseRoutes.AccessControlPolicy = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies/{policy_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ContentFlagging = api.BaseRoutes.APIRoot.PathPrefix("/content_flagging").Subrouter()
api.BaseRoutes.Agents = api.BaseRoutes.APIRoot.PathPrefix("/agents").Subrouter()
api.BaseRoutes.LLMServices = api.BaseRoutes.APIRoot.PathPrefix("/llmservices").Subrouter()
api.InitUser()
api.InitBot()
api.InitTeam()
api.InitChannel()
api.InitPost()
api.InitFile()
api.InitUpload()
api.InitSystem()
api.InitLicense()
api.InitConfig()
api.InitWebhook()
api.InitPreference()
api.InitSaml()
api.InitCompliance()
api.InitCluster()
api.InitLdap()
api.InitElasticsearch()
api.InitDataRetention()
api.InitBrand()
api.InitJob()
api.InitCommand()
api.InitStatus()
api.InitWebSocket()
api.InitEmoji()
api.InitOAuth()
api.InitReaction()
api.InitPlugin()
api.InitRole()
api.InitScheme()
api.InitImage()
api.InitTermsOfService()
api.InitGroup()
api.InitAction()
api.InitCloud()
api.InitImport()
api.InitRemoteCluster()
api.InitSharedChannels()
api.InitPermissions()
api.InitExport()
api.InitUsage()
api.InitHostedCustomer()
api.InitDrafts()
api.InitIPFiltering()
api.InitChannelBookmarks()
api.InitReports()
api.InitLimits()
api.InitOutgoingOAuthConnection()
api.InitClientPerformanceMetrics()
api.InitScheduledPost()
api.InitCustomProfileAttributes()
api.InitAuditLogging()
api.InitAccessControlPolicy()
api.InitContentFlagging()
api.InitAgents()
// If we allow testing then listen for manual testing URL hits
if *srv.Config().ServiceSettings.EnableTesting {
api.BaseRoutes.Root.Handle("/manualtest", api.APIHandler(manualtesting.ManualTest)).Methods(http.MethodGet)
}
srv.Router.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404))
InitLocal(srv)
return api, nil
}
func InitLocal(srv *app.Server) *API {
api := &API{
srv: srv,
BaseRoutes: &Routes{},
}
api.BaseRoutes.Root = srv.LocalRouter
api.BaseRoutes.APIRoot = srv.LocalRouter.PathPrefix(model.APIURLSuffix).Subrouter()
api.BaseRoutes.Users = api.BaseRoutes.APIRoot.PathPrefix("/users").Subrouter()
api.BaseRoutes.User = api.BaseRoutes.Users.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.UserByUsername = api.BaseRoutes.Users.PathPrefix("/username/{username:[A-Za-z0-9\\_\\-\\.]+}").Subrouter()
api.BaseRoutes.UserByEmail = api.BaseRoutes.Users.PathPrefix("/email/{email:.+}").Subrouter()
api.BaseRoutes.Bots = api.BaseRoutes.APIRoot.PathPrefix("/bots").Subrouter()
api.BaseRoutes.Bot = api.BaseRoutes.APIRoot.PathPrefix("/bots/{bot_user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Teams = api.BaseRoutes.APIRoot.PathPrefix("/teams").Subrouter()
api.BaseRoutes.Team = api.BaseRoutes.Teams.PathPrefix("/{team_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.TeamByName = api.BaseRoutes.Teams.PathPrefix("/name/{team_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.TeamMembers = api.BaseRoutes.Team.PathPrefix("/members").Subrouter()
api.BaseRoutes.TeamMember = api.BaseRoutes.TeamMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Channels = api.BaseRoutes.APIRoot.PathPrefix("/channels").Subrouter()
api.BaseRoutes.Channel = api.BaseRoutes.Channels.PathPrefix("/{channel_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelByName = api.BaseRoutes.Team.PathPrefix("/channels/name/{channel_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.ChannelByNameForTeamName = api.BaseRoutes.TeamByName.PathPrefix("/channels/name/{channel_name:[A-Za-z0-9_-]+}").Subrouter()
api.BaseRoutes.ChannelsForTeam = api.BaseRoutes.Team.PathPrefix("/channels").Subrouter()
api.BaseRoutes.ChannelMembers = api.BaseRoutes.Channel.PathPrefix("/members").Subrouter()
api.BaseRoutes.ChannelMember = api.BaseRoutes.ChannelMembers.PathPrefix("/{user_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.ChannelMembersForUser = api.BaseRoutes.User.PathPrefix("/teams/{team_id:[A-Za-z0-9]+}/channels/members").Subrouter()
api.BaseRoutes.Plugins = api.BaseRoutes.APIRoot.PathPrefix("/plugins").Subrouter()
api.BaseRoutes.Plugin = api.BaseRoutes.Plugins.PathPrefix("/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}").Subrouter()
api.BaseRoutes.Commands = api.BaseRoutes.APIRoot.PathPrefix("/commands").Subrouter()
api.BaseRoutes.Command = api.BaseRoutes.Commands.PathPrefix("/{command_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Hooks = api.BaseRoutes.APIRoot.PathPrefix("/hooks").Subrouter()
api.BaseRoutes.IncomingHooks = api.BaseRoutes.Hooks.PathPrefix("/incoming").Subrouter()
api.BaseRoutes.IncomingHook = api.BaseRoutes.IncomingHooks.PathPrefix("/{hook_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.OutgoingHooks = api.BaseRoutes.Hooks.PathPrefix("/outgoing").Subrouter()
api.BaseRoutes.OutgoingHook = api.BaseRoutes.OutgoingHooks.PathPrefix("/{hook_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.License = api.BaseRoutes.APIRoot.PathPrefix("/license").Subrouter()
api.BaseRoutes.Groups = api.BaseRoutes.APIRoot.PathPrefix("/groups").Subrouter()
api.BaseRoutes.LDAP = api.BaseRoutes.APIRoot.PathPrefix("/ldap").Subrouter()
api.BaseRoutes.System = api.BaseRoutes.APIRoot.PathPrefix("/system").Subrouter()
api.BaseRoutes.Preferences = api.BaseRoutes.User.PathPrefix("/preferences").Subrouter()
api.BaseRoutes.Posts = api.BaseRoutes.APIRoot.PathPrefix("/posts").Subrouter()
api.BaseRoutes.Post = api.BaseRoutes.Posts.PathPrefix("/{post_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.PostsForChannel = api.BaseRoutes.Channel.PathPrefix("/posts").Subrouter()
api.BaseRoutes.Roles = api.BaseRoutes.APIRoot.PathPrefix("/roles").Subrouter()
api.BaseRoutes.Uploads = api.BaseRoutes.APIRoot.PathPrefix("/uploads").Subrouter()
api.BaseRoutes.Upload = api.BaseRoutes.Uploads.PathPrefix("/{upload_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.Imports = api.BaseRoutes.APIRoot.PathPrefix("/imports").Subrouter()
api.BaseRoutes.Import = api.BaseRoutes.Imports.PathPrefix("/{import_name:.+\\.zip}").Subrouter()
api.BaseRoutes.Exports = api.BaseRoutes.APIRoot.PathPrefix("/exports").Subrouter()
api.BaseRoutes.Export = api.BaseRoutes.Exports.PathPrefix("/{export_name:.+\\.zip}").Subrouter()
api.BaseRoutes.Jobs = api.BaseRoutes.APIRoot.PathPrefix("/jobs").Subrouter()
api.BaseRoutes.SAML = api.BaseRoutes.APIRoot.PathPrefix("/saml").Subrouter()
api.BaseRoutes.CustomProfileAttributes = api.BaseRoutes.APIRoot.PathPrefix("/custom_profile_attributes").Subrouter()
api.BaseRoutes.CustomProfileAttributesFields = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/fields").Subrouter()
api.BaseRoutes.CustomProfileAttributesField = api.BaseRoutes.CustomProfileAttributesFields.PathPrefix("/{field_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.CustomProfileAttributesValues = api.BaseRoutes.CustomProfileAttributes.PathPrefix("/values").Subrouter()
api.BaseRoutes.AccessControlPolicies = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies").Subrouter()
api.BaseRoutes.AccessControlPolicy = api.BaseRoutes.APIRoot.PathPrefix("/access_control_policies/{policy_id:[A-Za-z0-9]+}").Subrouter()
api.InitUserLocal()
api.InitTeamLocal()
api.InitChannelLocal()
api.InitConfigLocal()
api.InitWebhookLocal()
api.InitPluginLocal()
api.InitCommandLocal()
api.InitLicenseLocal()
api.InitBotLocal()
api.InitGroupLocal()
api.InitLdapLocal()
api.InitSystemLocal()
api.InitPostLocal()
api.InitPreferenceLocal()
api.InitRoleLocal()
api.InitUploadLocal()
api.InitImportLocal()
api.InitExportLocal()
api.InitJobLocal()
api.InitSamlLocal()
api.InitCustomProfileAttributesLocal()
api.InitAccessControlPolicyLocal()
srv.LocalRouter.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404))
return api
}
func (api *API) Handle404(w http.ResponseWriter, r *http.Request) {
app := app.New(app.ServerConnector(api.srv.Channels()))
web.Handle404(app, w, r)
}
var ReturnStatusOK = web.ReturnStatusOK

1480
channels/api4/apitestlib.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"os"
"testing"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/stretchr/testify/assert"
)
func TestEnvironmentVariableHandling(t *testing.T) {
// TestEnvironmentVariableHandling should NEVER be run with t.Parallel()
originalConsoleLevel := os.Getenv("MM_LOGSETTINGS_CONSOLELEVEL")
defer func() {
// Restore original environment variables
if originalConsoleLevel != "" {
os.Setenv("MM_LOGSETTINGS_CONSOLELEVEL", originalConsoleLevel)
} else {
os.Unsetenv("MM_LOGSETTINGS_CONSOLELEVEL")
}
}()
t.Run("MM_LOGSETTINGS_CONSOLELEVEL should be respected when set", func(t *testing.T) {
// never run with t.Parallel()
// Set the console level environment variable
os.Setenv("MM_LOGSETTINGS_CONSOLELEVEL", "ERROR")
defer os.Unsetenv("MM_LOGSETTINGS_CONSOLELEVEL")
th := SetupEnterprise(t)
defer th.TearDown()
// Verify the console level was set from the environment variable
config := th.App.Config()
assert.Equal(t, "ERROR", *config.LogSettings.ConsoleLevel)
})
t.Run("Only MM_LOGSETTINGS_CONSOLELEVEL is manually processed", func(t *testing.T) {
// never run with t.Parallel()
// This test verifies that we haven't accidentally enabled general environment
// variable processing - we only manually handle MM_LOGSETTINGS_CONSOLELEVEL
// First, test without MM_LOGSETTINGS_CONSOLELEVEL set
os.Unsetenv("MM_LOGSETTINGS_CONSOLELEVEL")
th1 := SetupEnterprise(t)
config1 := th1.App.Config()
defaultConsoleLevel := *config1.LogSettings.ConsoleLevel
th1.TearDown()
// Now test with MM_LOGSETTINGS_CONSOLELEVEL set
os.Setenv("MM_LOGSETTINGS_CONSOLELEVEL", "DEBUG")
defer os.Unsetenv("MM_LOGSETTINGS_CONSOLELEVEL")
th2 := SetupEnterprise(t)
config2 := th2.App.Config()
customConsoleLevel := *config2.LogSettings.ConsoleLevel
th2.TearDown()
// Verify our manual implementation works
assert.Equal(t, mlog.LvlStdLog.Name, defaultConsoleLevel, "Default should be stdlog")
assert.Equal(t, "DEBUG", customConsoleLevel, "Environment variable should be respected")
assert.NotEqual(t, defaultConsoleLevel, customConsoleLevel, "Values should be different")
})
}

View File

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"mime/multipart"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
)
func (api *API) InitAuditLogging() {
api.BaseRoutes.AuditLogs.Handle("/certificate", api.APISessionRequired(addAuditLogCertificate)).Methods(http.MethodPost)
api.BaseRoutes.AuditLogs.Handle("/certificate", api.APISessionRequired(removeAuditLogCertificate)).Methods(http.MethodDelete)
}
func parseAuditLogCertificateRequest(r *http.Request, maxFileSize int64) (*multipart.FileHeader, *model.AppError) {
err := r.ParseMultipartForm(maxFileSize)
if err != nil {
return nil, model.NewAppError("addAuditLogCertificate", "api.admin.add_certificate.no_file.app_error", nil, "", http.StatusBadRequest).Wrap(err)
}
m := r.MultipartForm
fileArray, ok := m.File["certificate"]
if !ok || len(fileArray) == 0 {
return nil, model.NewAppError("addAuditLogCertificate", "api.admin.add_certificate.no_file.app_error", nil, "", http.StatusBadRequest)
}
if len(fileArray) > 1 {
return nil, model.NewAppError("addAuditLogCertificate", "api.admin.add_certificate.multiple_files.app_error", nil, "", http.StatusBadRequest)
}
return fileArray[0], nil
}
func addAuditLogCertificate(c *Context, w http.ResponseWriter, r *http.Request) {
c.Logger.Debug("addAuditLogCertificate")
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteExperimentalFeatures) {
c.SetPermissionError(model.PermissionSysconsoleWriteExperimentalFeatures)
return
}
fileData, err := parseAuditLogCertificateRequest(r, *c.App.Config().FileSettings.MaxFileSize)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAddAuditLogCertificate, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "filename", fileData.Filename)
if err := c.App.AddAuditLogCertificate(c.AppContext, fileData); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func removeAuditLogCertificate(c *Context, w http.ResponseWriter, r *http.Request) {
c.Logger.Debug("removeAuditLogCertificate")
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteExperimentalFeatures) {
c.SetPermissionError(model.PermissionSysconsoleWriteExperimentalFeatures)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRemoveAuditLogCertificate, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if err := c.App.RemoveAuditLogCertificate(c.AppContext); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}

319
channels/api4/bot.go Normal file
View File

@ -0,0 +1,319 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitBot() {
api.BaseRoutes.Bots.Handle("", api.APISessionRequired(createBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("", api.APISessionRequired(patchBot)).Methods(http.MethodPut)
api.BaseRoutes.Bot.Handle("", api.APISessionRequired(getBot)).Methods(http.MethodGet)
api.BaseRoutes.Bots.Handle("", api.APISessionRequired(getBots)).Methods(http.MethodGet)
api.BaseRoutes.Bot.Handle("/disable", api.APISessionRequired(disableBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/enable", api.APISessionRequired(enableBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/convert_to_user", api.APISessionRequired(convertBotToUser)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/assign/{user_id:[A-Za-z0-9]+}", api.APISessionRequired(assignBot)).Methods(http.MethodPost)
}
func createBot(c *Context, w http.ResponseWriter, r *http.Request) {
var botPatch *model.BotPatch
err := json.NewDecoder(r.Body).Decode(&botPatch)
if err != nil || botPatch == nil {
c.SetInvalidParamWithErr("bot", err)
return
}
bot := &model.Bot{
OwnerId: c.AppContext.Session().UserId,
}
bot.Patch(botPatch)
auditRec := c.MakeAuditRecord(model.AuditEventCreateBot, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "bot", bot)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreateBot) {
c.SetPermissionError(model.PermissionCreateBot)
return
}
if user, err := c.App.GetUser(c.AppContext.Session().UserId); err == nil {
if user.IsBot {
c.SetPermissionError(model.PermissionCreateBot)
return
}
}
if !*c.App.Config().ServiceSettings.EnableBotAccountCreation {
c.Err = model.NewAppError("createBot", "api.bot.create_disabled", nil, "", http.StatusForbidden)
return
}
createdBot, appErr := c.App.CreateBot(c.AppContext, bot)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventObjectType("bot")
auditRec.AddEventResultState(createdBot) // overwrite meta
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(createdBot); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchBot(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
var botPatch *model.BotPatch
err := json.NewDecoder(r.Body).Decode(&botPatch)
if err != nil || botPatch == nil {
c.SetInvalidParamWithErr("bot", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPatchBot, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", botUserId)
model.AddEventParameterAuditableToAuditRec(auditRec, "bot", botPatch)
if err := c.App.SessionHasPermissionToManageBot(c.AppContext, *c.AppContext.Session(), botUserId); err != nil {
c.Err = err
return
}
updatedBot, appErr := c.App.PatchBot(c.AppContext, botUserId, botPatch)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(updatedBot)
auditRec.AddEventObjectType("bot")
if err := json.NewEncoder(w).Encode(updatedBot); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getBot(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
includeDeleted, _ := strconv.ParseBool(r.URL.Query().Get("include_deleted"))
bot, appErr := c.App.GetBot(c.AppContext, botUserId, includeDeleted)
if appErr != nil {
c.Err = appErr
return
}
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadOthersBots) {
// Allow access to any bot.
} else if bot.OwnerId == c.AppContext.Session().UserId {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadBots) {
// Pretend like the bot doesn't exist at all to avoid revealing that the
// user is a bot. It's kind of silly in this case, sine we created the bot,
// but we don't have read bot permissions.
c.Err = model.MakeBotNotFoundError("permissions", botUserId)
return
}
} else {
// Pretend like the bot doesn't exist at all, to avoid revealing that the
// user is a bot.
c.Err = model.MakeBotNotFoundError("permissions", botUserId)
return
}
if c.HandleEtag(bot.Etag(), "Get Bot", w, r) {
return
}
if err := json.NewEncoder(w).Encode(bot); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getBots(c *Context, w http.ResponseWriter, r *http.Request) {
includeDeleted, _ := strconv.ParseBool(r.URL.Query().Get("include_deleted"))
onlyOrphaned, _ := strconv.ParseBool(r.URL.Query().Get("only_orphaned"))
var OwnerId string
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadOthersBots) {
// Get bots created by any user.
OwnerId = ""
} else if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadBots) {
// Only get bots created by this user.
OwnerId = c.AppContext.Session().UserId
} else {
c.SetPermissionError(model.PermissionReadBots)
return
}
bots, appErr := c.App.GetBots(c.AppContext, &model.BotGetOptions{
Page: c.Params.Page,
PerPage: c.Params.PerPage,
OwnerId: OwnerId,
IncludeDeleted: includeDeleted,
OnlyOrphaned: onlyOrphaned,
})
if appErr != nil {
c.Err = appErr
return
}
if c.HandleEtag(bots.Etag(), "Get Bots", w, r) {
return
}
if err := json.NewEncoder(w).Encode(bots); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func disableBot(c *Context, w http.ResponseWriter, _ *http.Request) {
updateBotActive(c, w, false)
}
func enableBot(c *Context, w http.ResponseWriter, _ *http.Request) {
updateBotActive(c, w, true)
}
func updateBotActive(c *Context, w http.ResponseWriter, active bool) {
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
auditRec := c.MakeAuditRecord(model.AuditEventUpdateBotActive, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", botUserId)
model.AddEventParameterToAuditRec(auditRec, "enable", active)
if err := c.App.SessionHasPermissionToManageBot(c.AppContext, *c.AppContext.Session(), botUserId); err != nil {
c.Err = err
return
}
bot, err := c.App.UpdateBotActive(c.AppContext, botUserId, active)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(bot)
auditRec.AddEventObjectType("bot")
if err := json.NewEncoder(w).Encode(bot); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func assignBot(c *Context, w http.ResponseWriter, _ *http.Request) {
c.RequireUserId()
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
userId := c.Params.UserId
auditRec := c.MakeAuditRecord(model.AuditEventAssignBot, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", botUserId)
model.AddEventParameterToAuditRec(auditRec, "user_id", userId)
if err := c.App.SessionHasPermissionToManageBot(c.AppContext, *c.AppContext.Session(), botUserId); err != nil {
c.Err = err
return
}
if user, err := c.App.GetUser(userId); err == nil {
if user.IsBot {
c.SetPermissionError(model.PermissionAssignBot)
return
}
}
bot, err := c.App.UpdateBotOwner(c.AppContext, botUserId, userId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(bot)
auditRec.AddEventObjectType("bot")
if err := json.NewEncoder(w).Encode(bot); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func convertBotToUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireBotUserId()
if c.Err != nil {
return
}
bot, err := c.App.GetBot(c.AppContext, c.Params.BotUserId, false)
if err != nil {
c.Err = err
return
}
var userPatch model.UserPatch
jsonErr := json.NewDecoder(r.Body).Decode(&userPatch)
if jsonErr != nil || userPatch.Password == nil || *userPatch.Password == "" {
c.SetInvalidParamWithErr("userPatch", jsonErr)
return
}
systemAdmin, _ := strconv.ParseBool(r.URL.Query().Get("set_system_admin"))
auditRec := c.MakeAuditRecord(model.AuditEventConvertBotToUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "bot", bot)
model.AddEventParameterAuditableToAuditRec(auditRec, "user_patch", &userPatch)
model.AddEventParameterToAuditRec(auditRec, "set_system_admin", systemAdmin)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
user, err := c.App.ConvertBotToUser(c.AppContext, bot, &userPatch, systemAdmin)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(user)
auditRec.AddEventObjectType("user")
if err := json.NewEncoder(w).Encode(user); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitBotLocal() {
api.BaseRoutes.Bot.Handle("", api.APILocal(getBot)).Methods(http.MethodGet)
api.BaseRoutes.Bot.Handle("", api.APILocal(patchBot)).Methods(http.MethodPut)
api.BaseRoutes.Bot.Handle("/disable", api.APILocal(disableBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/enable", api.APILocal(enableBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/convert_to_user", api.APILocal(convertBotToUser)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/assign/{user_id:[A-Za-z0-9]+}", api.APILocal(assignBot)).Methods(http.MethodPost)
api.BaseRoutes.Bots.Handle("", api.APILocal(getBots)).Methods(http.MethodGet)
}

1597
channels/api4/bot_test.go Normal file

File diff suppressed because it is too large Load Diff

105
channels/api4/brand.go Normal file
View File

@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"io"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitBrand() {
api.BaseRoutes.Brand.Handle("/image", api.APIHandlerTrustRequester(getBrandImage)).Methods(http.MethodGet)
api.BaseRoutes.Brand.Handle("/image", api.APISessionRequired(uploadBrandImage, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.Brand.Handle("/image", api.APISessionRequired(deleteBrandImage)).Methods(http.MethodDelete)
}
func getBrandImage(c *Context, w http.ResponseWriter, r *http.Request) {
// No permission check required
img, err := c.App.GetBrandImage(c.AppContext)
if err != nil {
w.WriteHeader(http.StatusNotFound)
if _, err := w.Write(nil); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
w.Header().Set("Content-Type", "image/png")
if _, err := w.Write(img); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func uploadBrandImage(c *Context, w http.ResponseWriter, r *http.Request) {
defer func() {
if _, err := io.Copy(io.Discard, r.Body); err != nil {
c.Logger.Warn("Error discarding request body", mlog.Err(err))
}
}()
if r.ContentLength > *c.App.Config().FileSettings.MaxFileSize {
c.Err = model.NewAppError("uploadBrandImage", "api.admin.upload_brand_image.too_large.app_error", nil, "", http.StatusRequestEntityTooLarge)
return
}
if err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize); err != nil {
c.Err = model.NewAppError("uploadBrandImage", "api.admin.upload_brand_image.parse.app_error", nil, "", http.StatusBadRequest)
return
}
m := r.MultipartForm
imageArray, ok := m.File["image"]
if !ok {
c.Err = model.NewAppError("uploadBrandImage", "api.admin.upload_brand_image.no_file.app_error", nil, "", http.StatusBadRequest)
return
}
if len(imageArray) <= 0 {
c.Err = model.NewAppError("uploadBrandImage", "api.admin.upload_brand_image.array.app_error", nil, "", http.StatusBadRequest)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUploadBrandImage, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionEditBrand) {
c.SetPermissionError(model.PermissionEditBrand)
return
}
if err := c.App.SaveBrandImage(c.AppContext, imageArray[0]); err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("")
w.WriteHeader(http.StatusCreated)
ReturnStatusOK(w)
}
func deleteBrandImage(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventDeleteBrandImage, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionEditBrand) {
c.SetPermissionError(model.PermissionEditBrand)
return
}
if err := c.App.DeleteBrandImage(c.AppContext); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}

170
channels/api4/brand_test.go Normal file
View File

@ -0,0 +1,170 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
)
func TestGetBrandImage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
client := th.Client
_, resp, err := client.GetBrandImage(context.Background())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, err = client.Logout(context.Background())
require.NoError(t, err)
_, resp, err = client.GetBrandImage(context.Background())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, resp, err = th.SystemAdminClient.GetBrandImage(context.Background())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
}
func TestUploadBrandImage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
client := th.Client
data, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
resp, err := client.UploadBrandImage(context.Background(), data)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// status code returns either forbidden or unauthorized
// note: forbidden is set as default at Client4.SetProfileImage when request is terminated early by server
_, err = client.Logout(context.Background())
require.NoError(t, err)
resp, err = client.UploadBrandImage(context.Background(), data)
require.Error(t, err)
if resp.StatusCode == http.StatusForbidden {
CheckForbiddenStatus(t, resp)
} else if resp.StatusCode == http.StatusUnauthorized {
CheckUnauthorizedStatus(t, resp)
} else {
require.Fail(t, "Should have failed either forbidden or unauthorized")
}
resp, err = th.SystemAdminClient.UploadBrandImage(context.Background(), data)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
}
func TestUploadBrandImageTwice(t *testing.T) {
th := Setup(t)
defer th.TearDown()
data, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
// First upload as system admin
resp, err := th.SystemAdminClient.UploadBrandImage(context.Background(), data)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
// Verify the image exists and contents match what was uploaded
receivedImg, resp, err := th.SystemAdminClient.GetBrandImage(context.Background())
require.NoError(t, err)
require.NotNil(t, receivedImg)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, receivedImg, "Received image data should not be empty")
// Get the list of files in the brand directory
files, err := th.App.FileBackend().ListDirectory("brand/")
require.NoError(t, err)
require.Len(t, files, 1, "Expected only the original image file")
// ListDirectory returns paths with the directory prefix included
fileName := files[0]
fileName = strings.TrimPrefix(fileName, "brand/")
require.Equal(t, "image.png", fileName, "Expected the original image file")
// Second upload (which should back up the previous one)
data2, err := testutils.ReadTestFile("test.tiff")
require.NoError(t, err)
resp, err = th.SystemAdminClient.UploadBrandImage(context.Background(), data2)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
// Get the list of files in the brand directory again
files, err = th.App.FileBackend().ListDirectory("brand/")
require.NoError(t, err)
// Should now have the new image.png and a backup with timestamp
require.Len(t, files, 2, "Expected the original and backup files")
// Check that one of the files is image.png
hasOriginal := false
hasBackup := false
for _, file := range files {
// ListDirectory returns paths with the directory prefix included
fileName := strings.TrimPrefix(file, "brand/")
if fileName == "image.png" {
hasOriginal = true
} else if strings.HasSuffix(fileName, ".png") && strings.Contains(fileName, "-") {
// Backup file should have a timestamp format like 2006-01-02T15:04:05.png
hasBackup = true
}
}
require.True(t, hasOriginal, "Original image.png file should exist")
require.True(t, hasBackup, "Backup image file should exist")
// Verify the new image is available through the API and matches what was uploaded
receivedImg2, resp, err := th.SystemAdminClient.GetBrandImage(context.Background())
require.NoError(t, err)
require.NotNil(t, receivedImg2)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotEmpty(t, receivedImg2, "Received image data should not be empty")
}
func TestDeleteBrandImage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
data, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
resp, err := th.SystemAdminClient.UploadBrandImage(context.Background(), data)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
resp, err = th.Client.DeleteBrandImage(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, err = th.Client.Logout(context.Background())
require.NoError(t, err)
resp, err = th.Client.DeleteBrandImage(context.Background())
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
resp, err = th.SystemAdminClient.DeleteBrandImage(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
resp, err = th.SystemAdminClient.DeleteBrandImage(context.Background())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
}

2568
channels/api4/channel.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,433 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitChannelBookmarks() {
if api.srv.Config().FeatureFlags.ChannelBookmarks {
api.BaseRoutes.ChannelBookmarks.Handle("", api.APISessionRequired(createChannelBookmark)).Methods(http.MethodPost)
api.BaseRoutes.ChannelBookmark.Handle("", api.APISessionRequired(updateChannelBookmark)).Methods(http.MethodPatch)
api.BaseRoutes.ChannelBookmark.Handle("/sort_order", api.APISessionRequired(updateChannelBookmarkSortOrder)).Methods(http.MethodPost)
api.BaseRoutes.ChannelBookmark.Handle("", api.APISessionRequired(deleteChannelBookmark)).Methods(http.MethodDelete)
api.BaseRoutes.ChannelBookmarks.Handle("", api.APISessionRequired(listChannelBookmarksForChannel)).Methods(http.MethodGet)
}
}
func createChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil {
c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented)
return
}
connectionID := r.Header.Get(model.ConnectionId)
c.RequireChannelId()
if c.Err != nil {
return
}
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if channel.DeleteAt != 0 {
c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.deleted_channel.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
var channelBookmark *model.ChannelBookmark
err := json.NewDecoder(r.Body).Decode(&channelBookmark)
if err != nil || channelBookmark == nil {
c.SetInvalidParamWithErr("channelBookmark", err)
return
}
channelBookmark.ChannelId = c.Params.ChannelId
auditRec := c.MakeAuditRecord(model.AuditEventCreateChannelBookmark, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "channelBookmark", channelBookmark)
switch channel.Type {
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPublicChannel) {
c.SetPermissionError(model.PermissionAddBookmarkPublicChannel)
return
}
case model.ChannelTypePrivate:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPrivateChannel) {
c.SetPermissionError(model.PermissionAddBookmarkPrivateChannel)
return
}
case model.ChannelTypeGroup, model.ChannelTypeDirect:
// Any member of DM/GMs but guests can manage channel bookmarks
if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil {
c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden)
return
}
user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
return
}
if user.IsGuest() {
c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
default:
c.Err = model.NewAppError("createChannelBookmark", "api.channel.bookmark.create_channel_bookmark.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
newChannelBookmark, appErr := c.App.CreateChannelBookmark(c.AppContext, channelBookmark, connectionID)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(newChannelBookmark)
auditRec.AddEventObjectType("channelBookmarkWithFileInfo")
c.LogAudit("display_name=" + newChannelBookmark.DisplayName)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(newChannelBookmark); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil {
c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented)
return
}
connectionID := r.Header.Get(model.ConnectionId)
c.RequireChannelId()
if c.Err != nil {
return
}
var patch *model.ChannelBookmarkPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil || patch == nil {
c.SetInvalidParamWithErr("channelBookmarkPatch", err)
return
}
originalChannelBookmark, appErr := c.App.GetBookmark(c.Params.ChannelBookmarkId, false)
if appErr != nil {
c.Err = appErr
return
}
patchedBookmark := originalChannelBookmark.Clone()
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelBookmark, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "channelBookmark", patch)
// The channel bookmark should belong to the same channel specified in the URL
if patchedBookmark.ChannelId != c.Params.ChannelId {
c.SetInvalidParam("channel_id")
return
}
auditRec.AddEventPriorState(originalChannelBookmark)
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if channel.DeleteAt != 0 {
c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.deleted_channel.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
switch channel.Type {
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPublicChannel) {
c.SetPermissionError(model.PermissionEditBookmarkPublicChannel)
return
}
case model.ChannelTypePrivate:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPrivateChannel) {
c.SetPermissionError(model.PermissionEditBookmarkPrivateChannel)
return
}
case model.ChannelTypeGroup, model.ChannelTypeDirect:
// Any member of DM/GMs but guests can manage channel bookmarks
if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil {
c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden)
return
}
user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
return
}
if user.IsGuest() {
c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
default:
c.Err = model.NewAppError("updateChannelBookmark", "api.channel.bookmark.update_channel_bookmark.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
patchedBookmark.Patch(patch)
updateChannelBookmarkResponse, appErr := c.App.UpdateChannelBookmark(c.AppContext, patchedBookmark, connectionID)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(updateChannelBookmarkResponse)
auditRec.AddEventObjectType("updateChannelBookmarkResponse")
c.LogAudit("")
if err := json.NewEncoder(w).Encode(updateChannelBookmarkResponse); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateChannelBookmarkSortOrder(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil {
c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented)
return
}
connectionID := r.Header.Get(model.ConnectionId)
c.RequireChannelId()
if c.Err != nil {
return
}
var newSortOrder int64
if err := json.NewDecoder(r.Body).Decode(&newSortOrder); err != nil {
c.SetInvalidParamWithErr("channelBookmarkSortOrder", err)
return
}
if newSortOrder < 0 {
c.SetInvalidParam("channelBookmarkSortOrder")
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelBookmarkSortOrder, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", c.Params.ChannelBookmarkId)
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if channel.DeleteAt != 0 {
c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.deleted_channel.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
switch channel.Type {
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPublicChannel) {
c.SetPermissionError(model.PermissionOrderBookmarkPublicChannel)
return
}
case model.ChannelTypePrivate:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPrivateChannel) {
c.SetPermissionError(model.PermissionOrderBookmarkPrivateChannel)
return
}
case model.ChannelTypeGroup, model.ChannelTypeDirect:
// Any member of DM/GMs but guests can manage channel bookmarks
if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil {
c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden)
return
}
user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
return
}
if user.IsGuest() {
c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
default:
c.Err = model.NewAppError("updateChannelBookmarkSortOrder", "api.channel.bookmark.update_channel_bookmark_sort_order.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
bookmarks, appErr := c.App.UpdateChannelBookmarkSortOrder(c.Params.ChannelBookmarkId, c.Params.ChannelId, newSortOrder, connectionID)
if appErr != nil {
c.Err = appErr
return
}
for _, b := range bookmarks {
if b.Id == c.Params.ChannelBookmarkId {
auditRec.AddEventResultState(b)
auditRec.AddEventObjectType("channelBookmarkWithFileInfo")
break
}
}
auditRec.Success()
c.LogAudit("")
if err := json.NewEncoder(w).Encode(bookmarks); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil {
c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented)
return
}
connectionID := r.Header.Get(model.ConnectionId)
c.RequireChannelId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteChannelBookmark, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "id", c.Params.ChannelBookmarkId)
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if channel.DeleteAt != 0 {
c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.deleted_channel.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
switch channel.Type {
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPublicChannel) {
c.SetPermissionError(model.PermissionDeleteBookmarkPublicChannel)
return
}
case model.ChannelTypePrivate:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPrivateChannel) {
c.SetPermissionError(model.PermissionDeleteBookmarkPrivateChannel)
return
}
case model.ChannelTypeGroup, model.ChannelTypeDirect:
// Any member of DM/GMs but guests can manage channel bookmarks
if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil {
c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels.forbidden.app_error", nil, errGet.Message, http.StatusForbidden)
return
}
user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
return
}
if user.IsGuest() {
c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
default:
c.Err = model.NewAppError("deleteChannelBookmark", "api.channel.bookmark.delete_channel_bookmark.forbidden.app_error", nil, "", http.StatusForbidden)
return
}
oldBookmark, obErr := c.App.GetBookmark(c.Params.ChannelBookmarkId, false)
if obErr != nil {
c.Err = obErr
return
}
// The channel bookmark should belong to the same channel specified in the URL
if oldBookmark.ChannelId != c.Params.ChannelId {
c.SetInvalidParam("channel_id")
return
}
auditRec.AddEventPriorState(oldBookmark)
bookmark, appErr := c.App.DeleteChannelBookmark(c.Params.ChannelBookmarkId, connectionID)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(bookmark)
c.LogAudit("bookmark=" + bookmark.DisplayName)
if err := json.NewEncoder(w).Encode(bookmark); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func listChannelBookmarksForChannel(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.Channels().License() == nil {
c.Err = model.NewAppError("listChannelBookmarksForChannel", "api.channel.bookmark.channel_bookmark.license.error", nil, "", http.StatusNotImplemented)
return
}
c.RequireChannelId()
if c.Err != nil {
return
}
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
bookmarks, appErr := c.App.GetChannelBookmarks(c.Params.ChannelId, c.Params.BookmarksSince)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(bookmarks); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,395 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func getCategoriesForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
categories, appErr := c.App.GetSidebarCategoriesForTeamForUser(c.AppContext, c.Params.UserId, c.Params.TeamId)
if appErr != nil {
c.Err = appErr
return
}
categoriesJSON, err := json.Marshal(categories)
if err != nil {
c.Err = model.NewAppError("getCategoriesForTeamForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(categoriesJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func createCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateCategoryForTeamForUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
var categoryCreateRequest model.SidebarCategoryWithChannels
err := json.NewDecoder(r.Body).Decode(&categoryCreateRequest)
if err != nil || c.Params.UserId != categoryCreateRequest.UserId || c.Params.TeamId != categoryCreateRequest.TeamId {
c.SetInvalidParamWithErr("category", err)
return
}
if appErr := validateSidebarCategory(c, c.Params.TeamId, c.Params.UserId, &categoryCreateRequest); appErr != nil {
c.Err = appErr
return
}
category, appErr := c.App.CreateSidebarCategory(c.AppContext, c.Params.UserId, c.Params.TeamId, &categoryCreateRequest)
if appErr != nil {
c.Err = appErr
return
}
categoryJSON, err := json.Marshal(category)
if err != nil {
c.Err = model.NewAppError("createCategoryForTeamForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
if _, err := w.Write(categoryJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getCategoryOrderForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
order, appErr := c.App.GetSidebarCategoryOrder(c.AppContext, c.Params.UserId, c.Params.TeamId)
if appErr != nil {
c.Err = appErr
return
}
err := json.NewEncoder(w).Encode(order)
if err != nil {
c.Logger.Warn("Error writing response", mlog.Err(err))
}
}
func updateCategoryOrderForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateCategoryOrderForTeamForUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
categoryOrder, err := model.NonSortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("updateCategoryOrderForTeamForUser", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
}
for _, categoryId := range categoryOrder {
if !c.App.SessionHasPermissionToCategory(c.AppContext, *c.AppContext.Session(), c.Params.UserId, c.Params.TeamId, categoryId) {
c.SetInvalidParam("category")
return
}
}
appErr := c.App.UpdateSidebarCategoryOrder(c.AppContext, c.Params.UserId, c.Params.TeamId, categoryOrder)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
if _, err := w.Write([]byte(model.ArrayToJSON(categoryOrder))); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId().RequireCategoryId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToCategory(c.AppContext, *c.AppContext.Session(), c.Params.UserId, c.Params.TeamId, c.Params.CategoryId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
categories, appErr := c.App.GetSidebarCategory(c.AppContext, c.Params.CategoryId)
if appErr != nil {
c.Err = appErr
return
}
categoriesJSON, err := json.Marshal(categories)
if err != nil {
c.Err = model.NewAppError("getCategoryForTeamForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(categoriesJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateCategoriesForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateCategoriesForTeamForUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
var categoriesUpdateRequest []*model.SidebarCategoryWithChannels
err := json.NewDecoder(r.Body).Decode(&categoriesUpdateRequest)
if err != nil {
c.SetInvalidParamWithErr("category", err)
return
}
for _, category := range categoriesUpdateRequest {
if !c.App.SessionHasPermissionToCategory(c.AppContext, *c.AppContext.Session(), c.Params.UserId, c.Params.TeamId, category.Id) {
c.SetInvalidParam("category")
return
}
}
if appErr := validateSidebarCategories(c, c.Params.TeamId, c.Params.UserId, categoriesUpdateRequest); appErr != nil {
c.Err = appErr
return
}
categories, appErr := c.App.UpdateSidebarCategories(c.AppContext, c.Params.UserId, c.Params.TeamId, categoriesUpdateRequest)
if appErr != nil {
c.Err = appErr
return
}
categoriesJSON, err := json.Marshal(categories)
if err != nil {
c.Err = model.NewAppError("updateCategoriesForTeamForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
if _, err := w.Write(categoriesJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func validateSidebarCategory(c *Context, teamId, userId string, category *model.SidebarCategoryWithChannels) *model.AppError {
channels, appErr := c.App.GetChannelsForTeamForUser(c.AppContext, teamId, userId, &model.ChannelSearchOpts{
IncludeDeleted: true,
LastDeleteAt: 0,
})
if appErr != nil {
return model.NewAppError("validateSidebarCategory", "api.invalid_channel", nil, "", http.StatusBadRequest).Wrap(appErr)
}
category.Channels = validateSidebarCategoryChannels(c, userId, category.Channels, channelListToMap(channels))
return nil
}
func validateSidebarCategories(c *Context, teamId, userId string, categories []*model.SidebarCategoryWithChannels) *model.AppError {
channels, err := c.App.GetChannelsForTeamForUser(c.AppContext, teamId, userId, &model.ChannelSearchOpts{
IncludeDeleted: true,
LastDeleteAt: 0,
})
if err != nil {
return model.NewAppError("validateSidebarCategory", "api.invalid_channel", nil, "", http.StatusBadRequest).Wrap(err)
}
channelMap := channelListToMap(channels)
for _, category := range categories {
category.Channels = validateSidebarCategoryChannels(c, userId, category.Channels, channelMap)
}
return nil
}
func channelListToMap(channelList model.ChannelList) map[string]*model.Channel {
channelMap := make(map[string]*model.Channel, len(channelList))
for _, channel := range channelList {
channelMap[channel.Id] = channel
}
return channelMap
}
// validateSidebarCategoryChannels returns a normalized slice of channel IDs by removing duplicates from it and
// ensuring that it only contains IDs of channels in the given map of Channels by IDs.
func validateSidebarCategoryChannels(c *Context, userId string, channelIds []string, channelMap map[string]*model.Channel) []string {
var filtered []string
for _, channelId := range channelIds {
if _, ok := channelMap[channelId]; ok {
filtered = append(filtered, channelId)
} else {
c.Logger.Info("Stopping user from adding channel to their sidebar when they are not a member", mlog.String("user_id", userId), mlog.String("channel_id", channelId))
}
}
filtered = model.RemoveDuplicateStringsNonSort(filtered)
return filtered
}
func updateCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId().RequireCategoryId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToCategory(c.AppContext, *c.AppContext.Session(), c.Params.UserId, c.Params.TeamId, c.Params.CategoryId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateCategoryForTeamForUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
var categoryUpdateRequest model.SidebarCategoryWithChannels
err := json.NewDecoder(r.Body).Decode(&categoryUpdateRequest)
if err != nil || categoryUpdateRequest.TeamId != c.Params.TeamId || categoryUpdateRequest.UserId != c.Params.UserId {
c.SetInvalidParamWithErr("category", err)
return
}
if appErr := validateSidebarCategory(c, c.Params.TeamId, c.Params.UserId, &categoryUpdateRequest); appErr != nil {
c.Err = appErr
return
}
categoryUpdateRequest.Id = c.Params.CategoryId
categories, appErr := c.App.UpdateSidebarCategories(c.AppContext, c.Params.UserId, c.Params.TeamId, []*model.SidebarCategoryWithChannels{&categoryUpdateRequest})
if appErr != nil {
c.Err = appErr
return
}
categoryJSON, err := json.Marshal(categories[0])
if err != nil {
c.Err = model.NewAppError("updateCategoryForTeamForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
if _, err := w.Write(categoryJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteCategoryForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId().RequireCategoryId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToCategory(c.AppContext, *c.AppContext.Session(), c.Params.UserId, c.Params.TeamId, c.Params.CategoryId) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteCategoryForTeamForUser, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
appErr := c.App.DeleteSidebarCategory(c.AppContext, c.Params.UserId, c.Params.TeamId, c.Params.CategoryId)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
ReturnStatusOK(w)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,445 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
)
func (api *API) InitChannelLocal() {
api.BaseRoutes.Channels.Handle("", api.APILocal(getAllChannels)).Methods(http.MethodGet)
api.BaseRoutes.Channels.Handle("", api.APILocal(localCreateChannel)).Methods(http.MethodPost)
api.BaseRoutes.Channel.Handle("", api.APILocal(getChannel)).Methods(http.MethodGet)
api.BaseRoutes.ChannelByName.Handle("", api.APILocal(getChannelByName)).Methods(http.MethodGet)
api.BaseRoutes.Channel.Handle("", api.APILocal(localDeleteChannel)).Methods(http.MethodDelete)
api.BaseRoutes.Channel.Handle("/patch", api.APILocal(localPatchChannel)).Methods(http.MethodPut)
api.BaseRoutes.Channel.Handle("/move", api.APILocal(localMoveChannel)).Methods(http.MethodPost)
api.BaseRoutes.Channel.Handle("/privacy", api.APILocal(localUpdateChannelPrivacy)).Methods(http.MethodPut)
api.BaseRoutes.Channel.Handle("/restore", api.APILocal(localRestoreChannel)).Methods(http.MethodPost)
api.BaseRoutes.ChannelMember.Handle("", api.APILocal(localRemoveChannelMember)).Methods(http.MethodDelete)
api.BaseRoutes.ChannelMember.Handle("", api.APILocal(getChannelMember)).Methods(http.MethodGet)
api.BaseRoutes.ChannelMembers.Handle("", api.APILocal(localAddChannelMember)).Methods(http.MethodPost)
api.BaseRoutes.ChannelMembers.Handle("", api.APILocal(getChannelMembers)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("", api.APILocal(getPublicChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("/deleted", api.APILocal(getDeletedChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelsForTeam.Handle("/private", api.APILocal(getPrivateChannelsForTeam)).Methods(http.MethodGet)
api.BaseRoutes.ChannelByName.Handle("", api.APILocal(getChannelByName)).Methods(http.MethodGet)
api.BaseRoutes.ChannelByNameForTeamName.Handle("", api.APILocal(getChannelByNameForTeamName)).Methods(http.MethodGet)
}
func localCreateChannel(c *Context, w http.ResponseWriter, r *http.Request) {
var channel *model.Channel
err := json.NewDecoder(r.Body).Decode(&channel)
if err != nil || channel == nil {
c.SetInvalidParamWithErr("channel", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalCreateChannel, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "channel", channel)
sc, appErr := c.App.CreateChannel(c.AppContext, channel, false)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(sc)
auditRec.AddEventObjectType("channel")
c.LogAudit("name=" + channel.Name)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(sc); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localUpdateChannelPrivacy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
props := model.StringInterfaceFromJSON(r.Body)
privacy, ok := props["privacy"].(string)
if !ok || (model.ChannelType(privacy) != model.ChannelTypeOpen && model.ChannelType(privacy) != model.ChannelTypePrivate) {
c.SetInvalidParam("privacy")
return
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalUpdateChannelPrivacy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "privacy", privacy)
if channel.Name == model.DefaultChannelName && model.ChannelType(privacy) == model.ChannelTypePrivate {
c.Err = model.NewAppError("updateChannelPrivacy", "api.channel.update_channel_privacy.default_channel_error", nil, "", http.StatusBadRequest)
return
}
channel.Type = model.ChannelType(privacy)
updatedChannel, err := c.App.UpdateChannelPrivacy(c.AppContext, channel, nil)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(channel)
auditRec.AddEventObjectType("channel")
auditRec.Success()
c.LogAudit("name=" + updatedChannel.Name)
if err := json.NewEncoder(w).Encode(updatedChannel); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localRestoreChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalRestoreChannel, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
channel, err = c.App.RestoreChannel(c.AppContext, channel, "")
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(channel)
auditRec.AddEventObjectType("channel")
auditRec.Success()
c.LogAudit("name=" + channel.Name)
if err := json.NewEncoder(w).Encode(channel); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localAddChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalAddChannelMember, model.AuditStatusFail)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
defer c.LogAuditRec(auditRec)
props := model.StringInterfaceFromJSON(r.Body)
userId, ok := props["user_id"].(string)
if !ok || !model.IsValidId(userId) {
c.SetInvalidParam("user_id")
return
}
model.AddEventParameterToAuditRec(auditRec, "user_id", userId)
member := &model.ChannelMember{
ChannelId: c.Params.ChannelId,
UserId: userId,
}
postRootId, ok := props["post_root_id"].(string)
if ok && postRootId != "" && !model.IsValidId(postRootId) {
c.SetInvalidParam("post_root_id")
return
}
model.AddEventParameterToAuditRec(auditRec, "post_root_id", postRootId)
if ok && len(postRootId) == 26 {
rootPost, err := c.App.GetSinglePost(c.AppContext, postRootId, false)
if err != nil {
c.Err = err
return
}
if rootPost.ChannelId != member.ChannelId {
c.SetInvalidParam("post_root_id")
return
}
}
channel, err := c.App.GetChannel(c.AppContext, member.ChannelId)
if err != nil {
c.Err = err
return
}
model.AddEventParameterAuditableToAuditRec(auditRec, "channel", channel)
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
c.Err = model.NewAppError("localAddChannelMember", "api.channel.add_user_to_channel.type.app_error", nil, "", http.StatusBadRequest)
return
}
if channel.IsGroupConstrained() {
nonMembers, err := c.App.FilterNonGroupChannelMembers(c.AppContext, []string{member.UserId}, channel)
if err != nil {
if v, ok := err.(*model.AppError); ok {
c.Err = v
} else {
c.Err = model.NewAppError("localAddChannelMember", "api.channel.add_members.error", nil, "", http.StatusBadRequest).Wrap(err)
}
return
}
if len(nonMembers) > 0 {
c.Err = model.NewAppError("localAddChannelMember", "api.channel.add_members.user_denied", map[string]any{"UserIDs": nonMembers}, "", http.StatusBadRequest)
return
}
}
cm, err := c.App.AddChannelMember(c.AppContext, member.UserId, channel, app.ChannelMemberOpts{
PostRootID: postRootId,
})
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddMeta("add_user_id", cm.UserId)
auditRec.AddEventResultState(cm)
auditRec.AddEventObjectType("channel_member")
c.LogAudit("name=" + channel.Name + " user_id=" + cm.UserId)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(cm); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localRemoveChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId().RequireUserId()
if c.Err != nil {
return
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = err
return
}
user, err := c.App.GetUser(c.Params.UserId)
if err != nil {
c.Err = err
return
}
if !(channel.Type == model.ChannelTypeOpen || channel.Type == model.ChannelTypePrivate) {
c.Err = model.NewAppError("removeChannelMember", "api.channel.remove_channel_member.type.app_error", nil, "", http.StatusBadRequest)
return
}
if channel.IsGroupConstrained() && !user.IsBot {
c.Err = model.NewAppError("removeChannelMember", "api.channel.remove_member.group_constrained.app_error", nil, "", http.StatusBadRequest)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalRemoveChannelMember, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
model.AddEventParameterToAuditRec(auditRec, "remove_user_id", c.Params.UserId)
if err = c.App.RemoveUserFromChannel(c.AppContext, c.Params.UserId, "", channel); err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("name=" + channel.Name + " user_id=" + c.Params.UserId)
ReturnStatusOK(w)
}
func localPatchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
var patch *model.ChannelPatch
err := json.NewDecoder(r.Body).Decode(&patch)
if err != nil || patch == nil {
c.SetInvalidParamWithErr("channel", err)
return
}
originalOldChannel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
channel := originalOldChannel.DeepCopy()
auditRec := c.MakeAuditRecord(model.AuditEventLocalPatchChannel, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "channel_patch", patch)
channel.Patch(patch)
rchannel, appErr := c.App.UpdateChannel(c.AppContext, channel)
if appErr != nil {
c.Err = appErr
return
}
appErr = c.App.FillInChannelProps(c.AppContext, rchannel)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
c.LogAudit("")
auditRec.AddEventResultState(rchannel)
auditRec.AddEventObjectType("channel")
if err := json.NewEncoder(w).Encode(rchannel); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localMoveChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = err
return
}
props := model.StringInterfaceFromJSON(r.Body)
teamId, ok := props["team_id"].(string)
if !ok {
c.SetInvalidParam("team_id")
return
}
force, ok := props["force"].(bool)
if !ok {
c.SetInvalidParam("force")
return
}
team, err := c.App.GetTeam(teamId)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalMoveChannel, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "team_id", teamId)
model.AddEventParameterToAuditRec(auditRec, "force", force)
// TODO do we need these?
auditRec.AddMeta("channel_id", channel.Id)
auditRec.AddMeta("channel_name", channel.Name)
auditRec.AddMeta("team_id", team.Id)
auditRec.AddMeta("team_name", team.Name)
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
c.Err = model.NewAppError("moveChannel", "api.channel.move_channel.type.invalid", nil, "", http.StatusForbidden)
return
}
err = c.App.RemoveAllDeactivatedMembersFromChannel(c.AppContext, channel)
if err != nil {
c.Err = err
return
}
if force {
err = c.App.RemoveUsersFromChannelNotMemberOfTeam(c.AppContext, nil, channel, team)
if err != nil {
c.Err = err
return
}
}
err = c.App.MoveChannel(c.AppContext, team, channel, nil)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(channel)
auditRec.AddEventObjectType("channel")
auditRec.Success()
c.LogAudit("channel=" + channel.Name)
c.LogAudit("team=" + team.Name)
if err := json.NewEncoder(w).Encode(channel); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localDeleteChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalDeleteChannel, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
auditRec.AddEventPriorState(channel)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
c.Err = model.NewAppError("localDeleteChannel", "api.channel.delete_channel.type.invalid", nil, "", http.StatusBadRequest)
return
}
if c.Params.Permanent {
err = c.App.PermanentDeleteChannel(c.AppContext, channel)
} else {
err = c.App.DeleteChannel(c.AppContext, channel, "")
}
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(channel)
auditRec.AddEventObjectType("channel")
c.LogAudit("name=" + channel.Name)
ReturnStatusOK(w)
}

File diff suppressed because it is too large Load Diff

632
channels/api4/cloud.go Normal file
View File

@ -0,0 +1,632 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/platform/shared/web"
)
func (api *API) InitCloud() {
// GET /api/v4/cloud/products
api.BaseRoutes.Cloud.Handle("/products", api.APISessionRequired(getCloudProducts)).Methods(http.MethodGet)
// GET /api/v4/cloud/limits
api.BaseRoutes.Cloud.Handle("/limits", api.APISessionRequired(getCloudLimits)).Methods(http.MethodGet)
// GET /api/v4/cloud/customer
// PUT /api/v4/cloud/customer
// PUT /api/v4/cloud/customer/address
api.BaseRoutes.Cloud.Handle("/customer", api.APISessionRequired(getCloudCustomer)).Methods(http.MethodGet)
api.BaseRoutes.Cloud.Handle("/customer", api.APISessionRequired(updateCloudCustomer)).Methods(http.MethodPut)
api.BaseRoutes.Cloud.Handle("/customer/address", api.APISessionRequired(updateCloudCustomerAddress)).Methods(http.MethodPut)
// GET /api/v4/cloud/subscription
api.BaseRoutes.Cloud.Handle("/subscription", api.APISessionRequired(getSubscription)).Methods(http.MethodGet)
api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.APISessionRequired(getInvoicesForSubscription)).Methods(http.MethodGet)
api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:[_A-Za-z0-9]+}/pdf", api.APISessionRequired(getSubscriptionInvoicePDF)).Methods(http.MethodGet)
// GET /api/v4/cloud/validate-business-email
api.BaseRoutes.Cloud.Handle("/validate-business-email", api.APISessionRequired(validateBusinessEmail)).Methods(http.MethodPost)
api.BaseRoutes.Cloud.Handle("/validate-workspace-business-email", api.APISessionRequired(validateWorkspaceBusinessEmail)).Methods(http.MethodPost)
// POST /api/v4/cloud/webhook
api.BaseRoutes.Cloud.Handle("/webhook", api.CloudAPIKeyRequired(handleCWSWebhook)).Methods(http.MethodPost)
// GET /api/v4/cloud/installation
api.BaseRoutes.Cloud.Handle("/installation", api.APISessionRequired(getInstallation)).Methods(http.MethodGet)
// GET /api/v4/cloud/cws-health-check
api.BaseRoutes.Cloud.Handle("/check-cws-connection", api.APIHandler(handleCheckCWSConnection)).Methods(http.MethodGet)
// GET /api/v4/cloud/preview/modal_data
api.BaseRoutes.Cloud.Handle("/preview/modal_data", api.APISessionRequired(getPreviewModalData)).Methods(http.MethodGet)
}
func ensureCloudInterface(c *Context, where string) bool {
cloud := c.App.Cloud()
disabled := c.App.Config().CloudSettings.Disable != nil && *c.App.Config().CloudSettings.Disable
if cloud == nil {
c.Err = model.NewAppError(where, "api.server.cws.needs_enterprise_edition", nil, "", http.StatusBadRequest)
return false
}
if disabled {
c.Err = model.NewAppError(where, "api.server.cws.disabled", nil, "", http.StatusUnprocessableEntity)
return false
}
return true
}
func getPreviewSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
license := c.App.Channels().License()
subscription := &model.Subscription{
ID: "cloud-preview",
ProductID: license.SkuName,
StartAt: license.StartsAt,
TrialEndAt: license.ExpiresAt,
EndAt: license.ExpiresAt,
IsFreeTrial: "true",
IsCloudPreview: true,
}
json, err := json.Marshal(subscription)
if err != nil {
c.Err = model.NewAppError("Api4.getSubscription", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
// Preview subscription is a special case for cloud preview licenses.
if c.App.Channels().License().IsCloudPreview() {
getPreviewSubscription(c, w, r)
return
}
ensured := ensureCloudInterface(c, "Api4.getSubscription")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getSubscription", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
subscription, err := c.App.Cloud().GetSubscription(c.AppContext.Session().UserId)
if err != nil {
c.Err = model.NewAppError("Api4.getSubscription", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
// if it is an end user, return basic subscription data without sensitive information
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) {
subscription = &model.Subscription{
ID: subscription.ID,
ProductID: subscription.ProductID,
IsFreeTrial: subscription.IsFreeTrial,
TrialEndAt: subscription.TrialEndAt,
EndAt: subscription.EndAt,
CancelAt: subscription.CancelAt,
DelinquentSince: subscription.DelinquentSince,
CustomerID: "",
AddOns: []string{},
StartAt: 0,
CreateAt: 0,
Seats: 0,
Status: "",
DNS: "",
LastInvoice: &model.Invoice{},
BillingType: "",
}
}
if model.GetServiceEnvironment() != model.ServiceEnvironmentTest {
subscription.SimulatedCurrentTimeMs = nil
}
if !c.App.Config().FeatureFlags.CloudAnnualRenewals {
subscription.WillRenew = ""
subscription.CancelAt = nil
}
json, err := json.Marshal(subscription)
if err != nil {
c.Err = model.NewAppError("Api4.getSubscription", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func validateBusinessEmail(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.validateBusinessEmail")
if !ensured {
return
}
user, appErr := c.App.GetUser(c.AppContext.Session().UserId)
if appErr != nil {
c.Err = model.NewAppError("Api4.validateBusinessEmail", "api.cloud.request_error", nil, "", http.StatusForbidden).Wrap(appErr)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
var emailToValidate *model.ValidateBusinessEmailRequest
err = json.Unmarshal(bodyBytes, &emailToValidate)
if err != nil || emailToValidate == nil {
c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
err = c.App.Cloud().ValidateBusinessEmail(user.Id, emailToValidate.Email)
if err != nil {
c.Err = model.NewAppError("Api4.validateBusinessEmail", "api.cloud.request_error", nil, "", http.StatusForbidden).Wrap(err)
emailResp := model.ValidateBusinessEmailResponse{IsValid: false}
if err := json.NewEncoder(w).Encode(emailResp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
emailResp := model.ValidateBusinessEmailResponse{IsValid: true}
if err := json.NewEncoder(w).Encode(emailResp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func validateWorkspaceBusinessEmail(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.validateWorkspaceBusinessEmail")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
user, userErr := c.App.GetUser(c.AppContext.Session().UserId)
if userErr != nil {
c.Err = userErr
return
}
// get the cloud customer email to validate if is a valid business email
cloudCustomer, err := c.App.Cloud().GetCloudCustomer(user.Id)
if err != nil {
c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
emailErr := c.App.Cloud().ValidateBusinessEmail(user.Id, cloudCustomer.Email)
// if the current workspace email is not a valid business email
if emailErr != nil {
// grab the current admin email and validate it
errValidatingAdminEmail := c.App.Cloud().ValidateBusinessEmail(user.Id, user.Email)
if errValidatingAdminEmail != nil {
c.Err = model.NewAppError("Api4.validateWorkspaceBusinessEmail", "api.cloud.request_error", nil, "", http.StatusForbidden).Wrap(errValidatingAdminEmail)
emailResp := model.ValidateBusinessEmailResponse{IsValid: false}
if err := json.NewEncoder(w).Encode(emailResp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
}
// if any of the emails is valid, return ok
emailResp := model.ValidateBusinessEmailResponse{IsValid: true}
if err := json.NewEncoder(w).Encode(emailResp); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getCloudProducts(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getCloudProducts")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
includeLegacyProducts := r.URL.Query().Get("include_legacy") == "true"
products, err := c.App.Cloud().GetCloudProducts(c.AppContext.Session().UserId, includeLegacyProducts)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
byteProductsData, err := json.Marshal(products)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) {
sanitizedProducts := []model.UserFacingProduct{}
err = json.Unmarshal(byteProductsData, &sanitizedProducts)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
byteSanitizedProductsData, err := json.Marshal(sanitizedProducts)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(byteSanitizedProductsData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
if _, err := w.Write(byteProductsData); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getCloudLimits(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getCloudLimits")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getCloudLimits", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
limits, err := c.App.Cloud().GetCloudLimits(c.AppContext.Session().UserId)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudLimits", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
json, err := json.Marshal(limits)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudLimits", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getCloudCustomer(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getCloudCustomer")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getCloudCustomer", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) {
c.SetPermissionError(model.PermissionSysconsoleReadBilling)
return
}
customer, err := c.App.Cloud().GetCloudCustomer(c.AppContext.Session().UserId)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudCustomer", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
json, err := json.Marshal(customer)
if err != nil {
c.Err = model.NewAppError("Api4.getCloudCustomer", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getInstallation(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getInstallation")
if !ensured {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadIPFilters) {
c.SetPermissionError(model.PermissionSysconsoleReadIPFilters)
return
}
installation, err := c.App.Cloud().GetInstallation(c.AppContext.Session().UserId)
if err != nil {
c.Err = model.NewAppError("Api4.getInstallation", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if err := json.NewEncoder(w).Encode(installation); err != nil {
c.Err = model.NewAppError("Api4.getInstallation", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
func updateCloudCustomer(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.updateCloudCustomer")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.updateCloudCustomer", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomer", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
var customerInfo *model.CloudCustomerInfo
if err = json.Unmarshal(bodyBytes, &customerInfo); err != nil || customerInfo == nil {
c.Err = model.NewAppError("Api4.updateCloudCustomer", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
customer, appErr := c.App.Cloud().UpdateCloudCustomer(c.AppContext.Session().UserId, customerInfo)
if appErr != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomer", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
json, err := json.Marshal(customer)
if err != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomer", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateCloudCustomerAddress(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.updateCloudCustomerAddress")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.updateCloudCustomerAddress", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) {
c.SetPermissionError(model.PermissionSysconsoleWriteBilling)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomerAddress", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
var address *model.Address
if err = json.Unmarshal(bodyBytes, &address); err != nil || address == nil {
c.Err = model.NewAppError("Api4.updateCloudCustomerAddress", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
customer, appErr := c.App.Cloud().UpdateCloudCustomerAddress(c.AppContext.Session().UserId, address)
if appErr != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomerAddress", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
json, err := json.Marshal(customer)
if err != nil {
c.Err = model.NewAppError("Api4.updateCloudCustomerAddress", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getInvoicesForSubscription(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getInvoicesForSubscription")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getInvoicesForSubscription", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) {
c.SetPermissionError(model.PermissionSysconsoleReadBilling)
return
}
invoices, appErr := c.App.Cloud().GetInvoicesForSubscription(c.AppContext.Session().UserId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getInvoicesForSubscription", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
json, err := json.Marshal(invoices)
if err != nil {
c.Err = model.NewAppError("Api4.getInvoicesForSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(json); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getSubscriptionInvoicePDF(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.getSubscriptionInvoicePDF")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.getSubscriptionInvoicePDF", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
c.RequireInvoiceId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) {
c.SetPermissionError(model.PermissionSysconsoleReadBilling)
return
}
pdfData, filename, appErr := c.App.Cloud().GetInvoicePDF(c.AppContext.Session().UserId, c.Params.InvoiceId)
if appErr != nil {
c.Err = model.NewAppError("Api4.getSubscriptionInvoicePDF", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr)
return
}
web.WriteFileResponse(
filename,
"application/pdf",
int64(binary.Size(pdfData)),
time.Now(),
*c.App.Config().ServiceSettings.WebserverMode,
bytes.NewReader(pdfData),
false,
w,
r,
)
}
func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.handleCWSWebhook")
if !ensured {
return
}
if !c.App.Channels().License().IsCloud() {
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.license_error", nil, "", http.StatusForbidden)
return
}
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
defer r.Body.Close()
var event *model.CWSWebhookPayload
if err = json.Unmarshal(bodyBytes, &event); err != nil || event == nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
switch event.Event {
case model.EventTypeSendAdminWelcomeEmail:
user, appErr := c.App.GetUserByUsername(event.CloudWorkspaceOwner.UserName)
if appErr != nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", appErr.Id, nil, "", appErr.StatusCode).Wrap(appErr)
return
}
teams, appErr := c.App.GetAllTeams()
if appErr != nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", appErr.Id, nil, "", appErr.StatusCode).Wrap(appErr)
return
}
team := teams[0]
subscription, err := c.App.Cloud().GetSubscription(user.Id)
if err != nil {
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if err := c.App.Srv().EmailService.SendCloudWelcomeEmail(user.Email, user.Locale, team.InviteId, subscription.GetWorkSpaceNameFromDNS(), subscription.DNS, *c.App.Config().ServiceSettings.SiteURL); err != nil {
c.Err = model.NewAppError("SendCloudWelcomeEmail", "api.user.send_cloud_welcome_email.error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
default:
c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.cws_webhook_event_missing_error", nil, "", http.StatusNotFound)
return
}
ReturnStatusOK(w)
}
func handleCheckCWSConnection(c *Context, w http.ResponseWriter, r *http.Request) {
ensured := ensureCloudInterface(c, "Api4.handleCheckCWSConnection")
if !ensured {
return
}
if err := c.App.Cloud().CheckCWSConnection(c.AppContext.Session().UserId); err != nil {
c.Err = model.NewAppError("Api4.handleCWSHealthCheck", "api.server.cws.health_check.app_error", nil, "CWS Server is not available.", http.StatusInternalServerError)
return
}
ReturnStatusOK(w)
}
func getPreviewModalData(c *Context, w http.ResponseWriter, r *http.Request) {
modalData, err := c.App.GetPreviewModalData()
if err != nil {
c.Err = err
return
}
responseData, jsonErr := json.Marshal(modalData)
if jsonErr != nil {
c.Err = model.NewAppError("Api4.getPreviewModalData", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
if _, writeErr := w.Write(responseData); writeErr != nil {
c.Logger.Warn("Error while writing response", mlog.Err(writeErr))
}
}

442
channels/api4/cloud_test.go Normal file
View File

@ -0,0 +1,442 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
func TestGetSubscription(t *testing.T) {
mainHelper.Parallel(t)
deliquencySince := int64(2000000000)
subscription := &model.Subscription{
ID: "MySubscriptionID",
CustomerID: "MyCustomer",
ProductID: "SomeProductId",
AddOns: []string{},
StartAt: 1000000000,
EndAt: 2000000000,
CreateAt: 1000000000,
Seats: 10,
IsFreeTrial: "true",
DNS: "some.dns.server",
TrialEndAt: 2000000000,
LastInvoice: &model.Invoice{},
DelinquentSince: &deliquencySince,
}
userFacingSubscription := &model.Subscription{
ID: "MySubscriptionID",
CustomerID: "",
ProductID: "SomeProductId",
AddOns: []string{},
StartAt: 0,
EndAt: 2000000000,
CreateAt: 0,
Seats: 0,
IsFreeTrial: "true",
DNS: "",
TrialEndAt: 2000000000,
LastInvoice: &model.Invoice{},
DelinquentSince: &deliquencySince,
}
t.Run("NON Admin users receive the user facing subscription", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
subscriptionReturned, r, err := th.Client.GetSubscription(context.Background())
require.NoError(t, err)
require.Equal(t, subscriptionReturned, userFacingSubscription)
require.Equal(t, http.StatusOK, r.StatusCode, "Status OK")
})
t.Run("Admin users receive the full subscription information", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
subscriptionReturned, r, err := th.SystemAdminClient.GetSubscription(context.Background())
require.NoError(t, err)
require.Equal(t, subscriptionReturned, subscription)
require.Equal(t, http.StatusOK, r.StatusCode, "Status OK")
})
}
func TestValidateBusinessEmail(t *testing.T) {
mainHelper.Parallel(t)
t.Run("Returns forbidden for invalid business email", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
validBusinessEmail := model.ValidateBusinessEmailRequest{Email: "invalid@slacker.com"}
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, validBusinessEmail.Email).Return(errors.New("invalid email"))
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
res, err := th.SystemAdminClient.ValidateBusinessEmail(context.Background(), &validBusinessEmail)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, res.StatusCode, "403")
})
t.Run("Validate business email for admin", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
validBusinessEmail := model.ValidateBusinessEmailRequest{Email: "valid@mattermost.com"}
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, validBusinessEmail.Email).Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
res, err := th.SystemAdminClient.ValidateBusinessEmail(context.Background(), &validBusinessEmail)
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode, "200")
})
t.Run("Empty body returns bad request", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
r, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/cloud/validate-business-email", "")
require.Error(t, err)
closeBody(r)
require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request")
})
}
func TestValidateWorkspaceBusinessEmail(t *testing.T) {
mainHelper.Parallel(t)
t.Run("validate the Cloud Customer has used a valid email to create the workspace", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloudCustomerInfo := model.CloudCustomerInfo{
Email: "valid@mattermost.com",
}
cloudCustomer := &model.CloudCustomer{
CloudCustomerInfo: cloudCustomerInfo,
}
cloud.Mock.On("GetCloudCustomer", th.SystemAdminUser.Id).Return(cloudCustomer, nil)
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, cloudCustomerInfo.Email).Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
_, err = th.SystemAdminClient.ValidateWorkspaceBusinessEmail(context.Background())
require.NoError(t, err)
})
t.Run("validate the Cloud Customer has used a invalid email to create the workspace and must validate admin email", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloudCustomerInfo := model.CloudCustomerInfo{
Email: "invalid@gmail.com",
}
cloudCustomer := &model.CloudCustomer{
CloudCustomerInfo: cloudCustomerInfo,
}
cloud.Mock.On("GetCloudCustomer", th.SystemAdminUser.Id).Return(cloudCustomer, nil)
// first call to validate the cloud customer email
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, cloudCustomerInfo.Email).Return(errors.New("invalid email"))
// second call to validate the user admin email
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, th.SystemAdminUser.Email).Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
_, err = th.SystemAdminClient.ValidateWorkspaceBusinessEmail(context.Background())
require.NoError(t, err)
})
t.Run("Error while grabbing the cloud customer returns bad request", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloudCustomerInfo := model.CloudCustomerInfo{
Email: "badrequest@gmail.com",
}
// return an error while getting the cloud customer so we validate the forbidden error return
cloud.Mock.On("GetCloudCustomer", th.SystemAdminUser.Id).Return(nil, errors.New("error while gettings the cloud customer"))
// required cloud mocks so the request doesn't fail
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, cloudCustomerInfo.Email).Return(errors.New("invalid email"))
cloud.Mock.On("ValidateBusinessEmail", th.SystemAdminUser.Id, th.SystemAdminUser.Email).Return(nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
r, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/cloud/validate-workspace-business-email", "")
require.Error(t, err)
closeBody(r)
require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request")
})
}
func TestGetCloudProducts(t *testing.T) {
mainHelper.Parallel(t)
cloudProducts := []*model.Product{
{
ID: "prod_test1",
Name: "name",
Description: "description",
PricePerSeat: 10,
SKU: "sku",
PriceID: "price_id",
Family: "family",
RecurringInterval: "monthly",
BillingScheme: "billing_scheme",
CrossSellsTo: "",
},
{
ID: "prod_test2",
Name: "name2",
Description: "description2",
PricePerSeat: 100,
SKU: "sku2",
PriceID: "price_id2",
Family: "family2",
RecurringInterval: "monthly",
BillingScheme: "billing_scheme2",
CrossSellsTo: "prod_test3",
},
{
ID: "prod_test3",
Name: "name3",
Description: "description3",
PricePerSeat: 1000,
SKU: "sku3",
PriceID: "price_id3",
Family: "family3",
RecurringInterval: "yearly",
BillingScheme: "billing_scheme3",
CrossSellsTo: "prod_test2",
},
}
sanitizedProducts := []*model.Product{
{
ID: "prod_test1",
Name: "name",
PricePerSeat: 10,
SKU: "sku",
RecurringInterval: "monthly",
CrossSellsTo: "",
},
{
ID: "prod_test2",
Name: "name2",
PricePerSeat: 100,
SKU: "sku2",
RecurringInterval: "monthly",
CrossSellsTo: "prod_test3",
},
{
ID: "prod_test3",
Name: "name3",
PricePerSeat: 1000,
SKU: "sku3",
RecurringInterval: "yearly",
CrossSellsTo: "prod_test2",
},
}
t.Run("get products for admins", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetCloudProducts", mock.Anything, mock.Anything).Return(cloudProducts, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
returnedProducts, r, err := th.Client.GetCloudProducts(context.Background())
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode, "Status OK")
require.Equal(t, returnedProducts, cloudProducts)
})
t.Run("get products for non admins", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
require.NoError(t, err)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
cloud := mocks.CloudInterface{}
cloud.Mock.On("GetCloudProducts", mock.Anything, mock.Anything).Return(cloudProducts, nil)
cloudImpl := th.App.Srv().Cloud
defer func() {
th.App.Srv().Cloud = cloudImpl
}()
th.App.Srv().Cloud = &cloud
returnedProducts, r, err := th.Client.GetCloudProducts(context.Background())
require.NoError(t, err)
require.Equal(t, http.StatusOK, r.StatusCode, "Status OK")
require.Equal(t, returnedProducts, sanitizedProducts)
// make a more explicit check
require.Equal(t, returnedProducts[0].ID, "prod_test1")
require.Equal(t, returnedProducts[0].Name, "name")
require.Equal(t, returnedProducts[0].SKU, "sku")
require.Equal(t, returnedProducts[0].PricePerSeat, float64(10))
require.Equal(t, returnedProducts[0].Description, "")
require.Equal(t, returnedProducts[0].PriceID, "")
require.Equal(t, returnedProducts[0].Family, model.SubscriptionFamily(""))
require.Equal(t, returnedProducts[0].RecurringInterval, model.RecurringInterval("monthly"))
require.Equal(t, returnedProducts[0].BillingScheme, model.BillingScheme(""))
require.Equal(t, returnedProducts[0].CrossSellsTo, "")
require.Equal(t, returnedProducts[1].ID, "prod_test2")
require.Equal(t, returnedProducts[1].Name, "name2")
require.Equal(t, returnedProducts[1].SKU, "sku2")
require.Equal(t, returnedProducts[1].PricePerSeat, float64(100))
require.Equal(t, returnedProducts[1].Description, "")
require.Equal(t, returnedProducts[1].PriceID, "")
require.Equal(t, returnedProducts[1].Family, model.SubscriptionFamily(""))
require.Equal(t, returnedProducts[1].RecurringInterval, model.RecurringInterval("monthly"))
require.Equal(t, returnedProducts[1].BillingScheme, model.BillingScheme(""))
require.Equal(t, returnedProducts[1].CrossSellsTo, "prod_test3")
require.Equal(t, returnedProducts[2].ID, "prod_test3")
require.Equal(t, returnedProducts[2].Name, "name3")
require.Equal(t, returnedProducts[2].SKU, "sku3")
require.Equal(t, returnedProducts[2].PricePerSeat, float64(1000))
require.Equal(t, returnedProducts[2].Description, "")
require.Equal(t, returnedProducts[2].PriceID, "")
require.Equal(t, returnedProducts[2].Family, model.SubscriptionFamily(""))
require.Equal(t, returnedProducts[2].RecurringInterval, model.RecurringInterval("yearly"))
require.Equal(t, returnedProducts[2].BillingScheme, model.BillingScheme(""))
require.Equal(t, returnedProducts[2].CrossSellsTo, "prod_test2")
})
}

38
channels/api4/cluster.go Normal file
View File

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitCluster() {
api.BaseRoutes.Cluster.Handle("/status", api.APISessionRequired(getClusterStatus)).Methods(http.MethodGet)
}
func getClusterStatus(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionSysconsoleReadEnvironmentHighAvailability) {
c.SetPermissionError(model.PermissionSysconsoleReadEnvironmentHighAvailability)
return
}
infos, err := c.App.GetClusterStatus(c.AppContext)
if err != nil {
c.Err = model.NewAppError("getClusterStatus", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
js, err := json.Marshal(infos)
if err != nil {
c.Err = model.NewAppError("getClusterStatus", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestGetClusterStatus(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
t.Run("as system user", func(t *testing.T) {
_, resp, err := th.Client.GetClusterStatus(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("as system admin", func(t *testing.T) {
infos, _, err := th.SystemAdminClient.GetClusterStatus(context.Background())
require.NoError(t, err)
require.NotNil(t, infos, "cluster status should not be nil")
})
t.Run("as restricted system admin", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
_, resp, err := th.SystemAdminClient.GetClusterStatus(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}

553
channels/api4/command.go Normal file
View File

@ -0,0 +1,553 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitCommand() {
api.BaseRoutes.Commands.Handle("", api.APISessionRequired(createCommand)).Methods(http.MethodPost)
api.BaseRoutes.Commands.Handle("", api.APISessionRequired(listCommands)).Methods(http.MethodGet)
api.BaseRoutes.Commands.Handle("/execute", api.APISessionRequired(executeCommand)).Methods(http.MethodPost)
api.BaseRoutes.Command.Handle("", api.APISessionRequired(getCommand)).Methods(http.MethodGet)
api.BaseRoutes.Command.Handle("", api.APISessionRequired(updateCommand)).Methods(http.MethodPut)
api.BaseRoutes.Command.Handle("/move", api.APISessionRequired(moveCommand)).Methods(http.MethodPut)
api.BaseRoutes.Command.Handle("", api.APISessionRequired(deleteCommand)).Methods(http.MethodDelete)
api.BaseRoutes.Team.Handle("/commands/autocomplete", api.APISessionRequired(listAutocompleteCommands)).Methods(http.MethodGet)
api.BaseRoutes.Team.Handle("/commands/autocomplete_suggestions", api.APISessionRequired(listCommandAutocompleteSuggestions)).Methods(http.MethodGet)
api.BaseRoutes.Command.Handle("/regen_token", api.APISessionRequired(regenCommandToken)).Methods(http.MethodPut)
}
func createCommand(c *Context, w http.ResponseWriter, r *http.Request) {
var cmd model.Command
if jsonErr := json.NewDecoder(r.Body).Decode(&cmd); jsonErr != nil {
c.SetInvalidParamWithErr("command", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateCommand, model.AuditStatusFail)
model.AddEventParameterAuditableToAuditRec(auditRec, "command", &cmd)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOwnSlashCommands) {
c.SetPermissionError(model.PermissionManageOwnSlashCommands)
return
}
userId := c.AppContext.Session().UserId
if cmd.CreatorId != "" && cmd.CreatorId != userId {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOthersSlashCommands)
return
}
if _, err := c.App.GetUser(cmd.CreatorId); err != nil {
c.Err = err
return
}
userId = cmd.CreatorId
}
cmd.CreatorId = userId
rcmd, err := c.App.CreateCommand(&cmd)
if err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
auditRec.AddEventResultState(rcmd)
auditRec.AddEventObjectType("command")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(rcmd); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func updateCommand(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCommandId()
if c.Err != nil {
return
}
var cmd model.Command
if jsonErr := json.NewDecoder(r.Body).Decode(&cmd); jsonErr != nil || cmd.Id != c.Params.CommandId {
c.SetInvalidParamWithErr("command", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateCommand, model.AuditStatusFail)
model.AddEventParameterAuditableToAuditRec(auditRec, "command", &cmd)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
oldCmd, err := c.App.GetCommand(c.Params.CommandId)
if err != nil {
model.AddEventParameterToAuditRec(auditRec, "command_id", c.Params.CommandId)
c.SetCommandNotFoundError()
return
}
auditRec.AddEventPriorState(oldCmd)
if cmd.TeamId != oldCmd.TeamId {
c.Err = model.NewAppError("updateCommand", "api.command.team_mismatch.app_error", nil, "user_id="+c.AppContext.Session().UserId, http.StatusBadRequest)
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), oldCmd.TeamId, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
// here we return Not_found instead of a permissions error so we don't leak the existence of
// a command to someone without permissions for the team it belongs to.
c.SetCommandNotFoundError()
return
}
if c.AppContext.Session().UserId != oldCmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), oldCmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOthersSlashCommands)
return
}
rcmd, err := c.App.UpdateCommand(oldCmd, &cmd)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(rcmd)
auditRec.AddEventObjectType("command")
auditRec.Success()
c.LogAudit("success")
if err := json.NewEncoder(w).Encode(rcmd); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func moveCommand(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCommandId()
if c.Err != nil {
return
}
var cmr model.CommandMoveRequest
if jsonErr := json.NewDecoder(r.Body).Decode(&cmr); jsonErr != nil {
c.SetInvalidParamWithErr("team_id", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventMoveCommand, model.AuditStatusFail)
model.AddEventParameterToAuditRec(auditRec, "command_move_request", cmr.TeamId)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
newTeam, appErr := c.App.GetTeam(cmr.TeamId)
if appErr != nil {
c.Err = appErr
return
}
model.AddEventParameterAuditableToAuditRec(auditRec, "team", newTeam)
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), newTeam.Id, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOwnSlashCommands)
return
}
cmd, appErr := c.App.GetCommand(c.Params.CommandId)
if appErr != nil {
c.SetCommandNotFoundError()
return
}
auditRec.AddEventPriorState(cmd)
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
// here we return Not_found instead of a permissions error so we don't leak the existence of
// a command to someone without permissions for the team it belongs to.
c.SetCommandNotFoundError()
return
}
if c.AppContext.Session().UserId != cmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOthersSlashCommands)
return
}
// Verify that the command creator has permission to the new team
// This prevents moving a command to a team where its creator doesn't have access
if !c.App.HasPermissionToTeam(c.AppContext, cmd.CreatorId, newTeam.Id, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - command creator does not have permission to new team")
c.Err = model.NewAppError("moveCommand", "api.command.move_command.creator_no_permission.app_error", nil, "creator_id="+cmd.CreatorId+" team_id="+newTeam.Id, http.StatusBadRequest)
return
}
if appErr = c.App.MoveCommand(newTeam, cmd); appErr != nil {
c.Err = appErr
return
}
auditRec.AddEventResultState(cmd)
auditRec.AddEventObjectType("command")
auditRec.Success()
c.LogAudit("success")
ReturnStatusOK(w)
}
func deleteCommand(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCommandId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteCommand, model.AuditStatusFail)
model.AddEventParameterToAuditRec(auditRec, "command_id", c.Params.CommandId)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
cmd, err := c.App.GetCommand(c.Params.CommandId)
if err != nil {
c.SetCommandNotFoundError()
return
}
auditRec.AddEventPriorState(cmd)
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
// here we return Not_found instead of a permissions error so we don't leak the existence of
// a command to someone without permissions for the team it belongs to.
c.SetCommandNotFoundError()
return
}
if c.AppContext.Session().UserId != cmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOthersSlashCommands)
return
}
err = c.App.DeleteCommand(cmd.Id)
if err != nil {
c.Err = err
return
}
auditRec.AddEventObjectType("command")
auditRec.Success()
c.LogAudit("success")
ReturnStatusOK(w)
}
func listCommands(c *Context, w http.ResponseWriter, r *http.Request) {
customOnly, _ := strconv.ParseBool(r.URL.Query().Get("custom_only"))
teamId := r.URL.Query().Get("team_id")
if teamId == "" {
c.SetInvalidParam("team_id")
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
var commands []*model.Command
var err *model.AppError
if customOnly {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOwnSlashCommands) {
c.SetPermissionError(model.PermissionManageOwnSlashCommands)
return
}
// Filter to only commands the user can manage
userIdFilter := c.AppContext.Session().UserId
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOthersSlashCommands) {
userIdFilter = "" // Empty means return all commands
}
commands, err = c.App.ListTeamCommandsByUser(teamId, userIdFilter)
if err != nil {
c.Err = err
return
}
} else {
//User with no permission should see only system commands
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOwnSlashCommands) {
commands, err = c.App.ListAutocompleteCommands(teamId, c.AppContext.T)
if err != nil {
c.Err = err
return
}
} else {
// Filter custom commands to only those the user can manage
userIdFilter := c.AppContext.Session().UserId
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOthersSlashCommands) {
userIdFilter = "" // Empty means return all commands
}
commands, err = c.App.ListAllCommandsByUser(teamId, userIdFilter, c.AppContext.T)
if err != nil {
c.Err = err
return
}
}
}
if err := json.NewEncoder(w).Encode(commands); err != nil {
c.Logger.Warn("Error writing response", mlog.Err(err))
}
}
func getCommand(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCommandId()
if c.Err != nil {
return
}
cmd, err := c.App.GetCommand(c.Params.CommandId)
if err != nil {
c.SetCommandNotFoundError()
return
}
// check for permissions to view this command; must have perms to view team and
// PERMISSION_MANAGE_SLASH_COMMANDS for the team the command belongs to.
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionViewTeam) {
// here we return Not_found instead of a permissions error so we don't leak the existence of
// a command to someone without permissions for the team it belongs to.
c.SetCommandNotFoundError()
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOwnSlashCommands) {
// again, return not_found to ensure id existence does not leak.
c.SetCommandNotFoundError()
return
}
if c.AppContext.Session().UserId != cmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.SetCommandNotFoundError()
return
}
if err := json.NewEncoder(w).Encode(cmd); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) {
var commandArgs model.CommandArgs
if jsonErr := json.NewDecoder(r.Body).Decode(&commandArgs); jsonErr != nil {
c.SetInvalidParamWithErr("command_args", jsonErr)
return
}
if len(commandArgs.Command) <= 1 || strings.Index(commandArgs.Command, "/") != 0 || !model.IsValidId(commandArgs.ChannelId) {
c.Err = model.NewAppError("executeCommand", "api.command.execute_command.start.app_error", nil, "", http.StatusBadRequest)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventExecuteCommand, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "command_args", &commandArgs)
// Checks that user is a member of the specified channel, and that they have permission to create a post in it.
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionCreatePost) {
c.SetPermissionError(model.PermissionCreatePost)
return
}
channel, err := c.App.GetChannel(c.AppContext, commandArgs.ChannelId)
if err != nil {
c.Err = err
return
}
if channel.DeleteAt != 0 {
c.Err = model.NewAppError("createPost", "api.command.execute_command.deleted.error", nil, "", http.StatusBadRequest)
return
}
if channel.Type != model.ChannelTypeDirect && channel.Type != model.ChannelTypeGroup {
// if this isn't a DM or GM, the team id is implicitly taken from the channel so that slash commands created on
// some other team can't be run against this one
commandArgs.TeamId = channel.TeamId
} else {
restrictDM, appErr := c.App.CheckIfChannelIsRestrictedDM(c.AppContext, channel)
if appErr != nil {
c.Err = err
return
}
if restrictDM {
c.Err = model.NewAppError("createPost", "api.command.execute_command.restricted_dm.error", nil, "", http.StatusBadRequest)
return
}
// if the slash command was used in a DM or GM, ensure that the user is a member of the specified team, so that
// they can't just execute slash commands against arbitrary teams
if c.AppContext.Session().GetTeamByTeamId(commandArgs.TeamId) == nil {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreatePost) {
c.SetPermissionError(model.PermissionCreatePost)
return
}
}
}
commandArgs.UserId = c.AppContext.Session().UserId
commandArgs.T = c.AppContext.T
commandArgs.SiteURL = c.GetSiteURLHeader()
response, err := c.App.ExecuteCommand(c.AppContext, &commandArgs)
if err != nil {
c.Err = err
return
}
auditRec.Success()
if err := json.NewEncoder(w).Encode(response); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func listAutocompleteCommands(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
commands, err := c.App.ListAutocompleteCommands(c.Params.TeamId, c.AppContext.T)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(commands); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func listCommandAutocompleteSuggestions(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
roleId := model.SystemUserRoleId
if c.IsSystemAdmin() {
roleId = model.SystemAdminRoleId
}
query := r.URL.Query()
userInput := query.Get("user_input")
if userInput == "" {
c.SetInvalidParam("userInput")
return
}
userInput = strings.TrimPrefix(userInput, "/")
commands, appErr := c.App.ListAutocompleteCommands(c.Params.TeamId, c.AppContext.T)
if appErr != nil {
c.Err = appErr
return
}
commandArgs := &model.CommandArgs{
ChannelId: query.Get("channel_id"),
TeamId: c.Params.TeamId,
RootId: query.Get("root_id"),
UserId: c.AppContext.Session().UserId,
T: c.AppContext.T,
SiteURL: c.GetSiteURLHeader(),
Command: userInput,
}
suggestions := c.App.GetSuggestions(c.AppContext, commandArgs, commands, roleId)
js, err := json.Marshal(suggestions)
if err != nil {
c.Err = model.NewAppError("listCommandAutocompleteSuggestions", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func regenCommandToken(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireCommandId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRegenCommandToken, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
cmd, err := c.App.GetCommand(c.Params.CommandId)
if err != nil {
model.AddEventParameterToAuditRec(auditRec, "command_id", c.Params.CommandId)
c.SetCommandNotFoundError()
return
}
auditRec.AddEventPriorState(cmd)
model.AddEventParameterToAuditRec(auditRec, "command_id", c.Params.CommandId)
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOwnSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
// here we return Not_found instead of a permissions error so we don't leak the existence of
// a command to someone without permissions for the team it belongs to.
c.SetCommandNotFoundError()
return
}
if c.AppContext.Session().UserId != cmd.CreatorId && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), cmd.TeamId, model.PermissionManageOthersSlashCommands) {
c.LogAudit("fail - inappropriate permissions")
c.SetPermissionError(model.PermissionManageOthersSlashCommands)
return
}
rcmd, err := c.App.RegenCommandToken(cmd)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(rcmd)
auditRec.Success()
c.LogAudit("success")
resp := make(map[string]string)
resp["token"] = rcmd.Token
if _, err := w.Write([]byte(model.MapToJSON(resp))); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestHelpCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
channel := th.BasicChannel
HelpLink := *th.App.Config().SupportSettings.HelpLink
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.SupportSettings.HelpLink = HelpLink })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.SupportSettings.HelpLink = "" })
rs1, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/help ")
require.NoError(t, err)
assert.Contains(t, rs1.Text, model.SupportSettingsDefaultHelpLink, "failed to default help link")
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.SupportSettings.HelpLink = "https://docs.mattermost.com/guides/user.html"
})
rs2, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/help ")
require.NoError(t, err)
assert.Contains(t, rs2.Text, "https://docs.mattermost.com/guides/user.html", "failed to help link")
}

View File

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitCommandLocal() {
api.BaseRoutes.Commands.Handle("", api.APILocal(localCreateCommand)).Methods(http.MethodPost)
api.BaseRoutes.Commands.Handle("", api.APILocal(listCommands)).Methods(http.MethodGet)
api.BaseRoutes.Command.Handle("", api.APILocal(getCommand)).Methods(http.MethodGet)
api.BaseRoutes.Command.Handle("", api.APILocal(updateCommand)).Methods(http.MethodPut)
api.BaseRoutes.Command.Handle("/move", api.APILocal(moveCommand)).Methods(http.MethodPut)
api.BaseRoutes.Command.Handle("", api.APILocal(deleteCommand)).Methods(http.MethodDelete)
}
func localCreateCommand(c *Context, w http.ResponseWriter, r *http.Request) {
var cmd model.Command
if jsonErr := json.NewDecoder(r.Body).Decode(&cmd); jsonErr != nil {
c.SetInvalidParamWithErr("command", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalCreateCommand, model.AuditStatusFail)
model.AddEventParameterAuditableToAuditRec(auditRec, "command", &cmd)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
rcmd, err := c.App.CreateCommand(&cmd)
if err != nil {
c.Err = err
return
}
auditRec.Success()
c.LogAudit("success")
auditRec.AddEventResultState(rcmd)
auditRec.AddEventObjectType("command")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(rcmd); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,495 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
_ "github.com/mattermost/mattermost/server/v8/channels/app/slashcommands"
"github.com/mattermost/mattermost/server/public/model"
)
func TestEchoCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
channel1 := th.BasicChannel
echoTestString := "/echo test"
r1, _, err := client.ExecuteCommand(context.Background(), channel1.Id, echoTestString)
require.NoError(t, err)
require.NotNil(t, r1, "Echo command failed to execute")
r1, _, err = client.ExecuteCommand(context.Background(), channel1.Id, "/echo ")
require.NoError(t, err)
require.NotNil(t, r1, "Echo command failed to execute")
time.Sleep(time.Second)
p1, _, err := client.GetPostsForChannel(context.Background(), channel1.Id, 0, 2, "", false, false)
require.NoError(t, err)
require.Len(t, p1.Order, 2, "Echo command failed to send")
}
func TestGroupmsgCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
team := th.BasicTeam
user1 := th.BasicUser
user2 := th.BasicUser2
user3 := th.CreateUser()
user4 := th.CreateUser()
user5 := th.CreateUser()
user6 := th.CreateUser()
user7 := th.CreateUser()
user8 := th.CreateUser()
user9 := th.CreateUser()
th.LinkUserToTeam(user3, team)
th.LinkUserToTeam(user4, team)
rs1, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg "+user2.Username+","+user3.Username)
require.NoError(t, err)
group1 := model.GetGroupNameFromUserIds([]string{user1.Id, user2.Id, user3.Id})
require.True(t, strings.HasSuffix(rs1.GotoLocation, "/"+team.Name+"/channels/"+group1), "failed to create group channel")
rs2, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg "+user3.Username+","+user4.Username+" foobar")
require.NoError(t, err)
group2 := model.GetGroupNameFromUserIds([]string{user1.Id, user3.Id, user4.Id})
require.True(t, strings.HasSuffix(rs2.GotoLocation, "/"+team.Name+"/channels/"+group2), "failed to create second direct channel")
result, _, err := client.SearchPosts(context.Background(), team.Id, "foobar", false)
require.NoError(t, err)
require.NotEqual(t, 0, len(result.Order), "post did not get sent to direct message")
rs3, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg "+user2.Username+","+user3.Username)
require.NoError(t, err)
require.True(t, strings.HasSuffix(rs3.GotoLocation, "/"+team.Name+"/channels/"+group1), "failed to go back to existing group channel")
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg "+user2.Username+" foobar")
require.NoError(t, err)
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg "+user2.Username+","+user3.Username+","+user4.Username+","+user5.Username+","+user6.Username+","+user7.Username+","+user8.Username+","+user9.Username+" foobar")
require.NoError(t, err)
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg junk foobar")
require.NoError(t, err)
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/groupmsg junk,junk2 foobar")
require.NoError(t, err)
}
func TestInvitePeopleCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
channel := th.BasicChannel
r1, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/invite_people test@example.com")
require.NoError(t, err)
require.NotNil(t, r1, "Command failed to execute")
r2, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/invite_people test1@example.com test2@example.com")
require.NoError(t, err)
require.NotNil(t, r2, "Command failed to execute")
r3, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/invite_people")
require.NoError(t, err)
require.NotNil(t, r3, "Command failed to execute")
}
// also used to test /open (see command_open_test.go)
func testJoinCommands(t *testing.T, alias string) {
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
team := th.BasicTeam
user2 := th.BasicUser2
channel0 := &model.Channel{DisplayName: "00", Name: "00" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id}
channel0, _, err := client.CreateChannel(context.Background(), channel0)
require.NoError(t, err)
channel1 := &model.Channel{DisplayName: "AA", Name: "aa" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id}
channel1, _, err = client.CreateChannel(context.Background(), channel1)
require.NoError(t, err)
_, err = client.RemoveUserFromChannel(context.Background(), channel1.Id, th.BasicUser.Id)
require.NoError(t, err)
channel2 := &model.Channel{DisplayName: "BB", Name: "bb" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id}
channel2, _, err = client.CreateChannel(context.Background(), channel2)
require.NoError(t, err)
_, err = client.RemoveUserFromChannel(context.Background(), channel2.Id, th.BasicUser.Id)
require.NoError(t, err)
channel3, _, err := client.CreateDirectChannel(context.Background(), th.BasicUser.Id, user2.Id)
require.NoError(t, err)
rs5, _, err := client.ExecuteCommand(context.Background(), channel0.Id, "/"+alias+" "+channel2.Name)
require.NoError(t, err)
require.True(t, strings.HasSuffix(rs5.GotoLocation, "/"+team.Name+"/channels/"+channel2.Name), "failed to join channel")
rs6, _, err := client.ExecuteCommand(context.Background(), channel0.Id, "/"+alias+" "+channel3.Name)
require.NoError(t, err)
require.False(t, strings.HasSuffix(rs6.GotoLocation, "/"+team.Name+"/channels/"+channel3.Name), "should not have joined direct message channel")
c1, _, err := client.GetChannelsForTeamForUser(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, false, "")
require.NoError(t, err)
found := false
for _, c := range c1 {
if c.Id == channel2.Id {
found = true
}
}
require.True(t, found, "did not join channel")
// test case insensitively
channel4 := &model.Channel{DisplayName: "BB", Name: "bb" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id}
channel4, _, err = client.CreateChannel(context.Background(), channel4)
require.NoError(t, err)
_, err = client.RemoveUserFromChannel(context.Background(), channel4.Id, th.BasicUser.Id)
require.NoError(t, err)
rs7, _, err := client.ExecuteCommand(context.Background(), channel0.Id, "/"+alias+" "+strings.ToUpper(channel4.Name))
require.NoError(t, err)
require.True(t, strings.HasSuffix(rs7.GotoLocation, "/"+team.Name+"/channels/"+channel4.Name), "failed to join channel")
}
func TestJoinCommands(t *testing.T) {
mainHelper.Parallel(t)
testJoinCommands(t, "join")
}
func TestLoadTestHelpCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
channel := th.BasicChannel
enableTesting := *th.App.Config().ServiceSettings.EnableTesting
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = enableTesting })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = true })
rs, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/test help")
require.NoError(t, err)
require.True(t, strings.Contains(rs.Text, "Mattermost testing commands to help"), rs.Text)
}
func TestLoadTestSetupCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
channel := th.BasicChannel
enableTesting := *th.App.Config().ServiceSettings.EnableTesting
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = enableTesting })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = true })
rs, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/test setup fuzz 1 1 1")
require.NoError(t, err)
require.Equal(t, "Created environment", rs.Text, rs.Text)
}
func TestLoadTestUsersCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
channel := th.BasicChannel
enableTesting := *th.App.Config().ServiceSettings.EnableTesting
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = enableTesting })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = true })
rs, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/test users fuzz 1 2")
require.NoError(t, err)
require.Equal(t, "Added users", rs.Text, rs.Text)
}
func TestLoadTestChannelsCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
channel := th.BasicChannel
enableTesting := *th.App.Config().ServiceSettings.EnableTesting
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = enableTesting })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = true })
rs, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/test channels fuzz 1 2")
require.NoError(t, err)
require.Equal(t, "Added channels", rs.Text, rs.Text)
}
func TestLoadTestPostsCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
channel := th.BasicChannel
enableTesting := *th.App.Config().ServiceSettings.EnableTesting
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = enableTesting })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableTesting = true })
rs, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/test posts fuzz 2 3 2")
require.NoError(t, err)
require.Equal(t, "Added posts", rs.Text, rs.Text)
}
func TestLeaveCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
team := th.BasicTeam
user2 := th.BasicUser2
channel1 := &model.Channel{DisplayName: "AA", Name: "aa" + model.NewId() + "a", Type: model.ChannelTypeOpen, TeamId: team.Id}
channel1, _, err := client.CreateChannel(context.Background(), channel1)
require.NoError(t, err)
_, _, err = client.AddChannelMember(context.Background(), channel1.Id, th.BasicUser.Id)
require.NoError(t, err)
channel2 := &model.Channel{DisplayName: "BB", Name: "bb" + model.NewId() + "a", Type: model.ChannelTypePrivate, TeamId: team.Id}
channel2, _, err = client.CreateChannel(context.Background(), channel2)
require.NoError(t, err)
_, _, err = client.AddChannelMember(context.Background(), channel2.Id, th.BasicUser.Id)
require.NoError(t, err)
_, _, err = client.AddChannelMember(context.Background(), channel2.Id, user2.Id)
require.NoError(t, err)
channel3, _, err := client.CreateDirectChannel(context.Background(), th.BasicUser.Id, user2.Id)
require.NoError(t, err)
rs1, _, err := client.ExecuteCommand(context.Background(), channel1.Id, "/leave")
require.NoError(t, err)
require.True(t, strings.HasSuffix(rs1.GotoLocation, "/"+team.Name+"/channels/"+model.DefaultChannelName), "failed to leave open channel 1")
rs2, _, err := client.ExecuteCommand(context.Background(), channel2.Id, "/leave")
require.NoError(t, err)
require.True(t, strings.HasSuffix(rs2.GotoLocation, "/"+team.Name+"/channels/"+model.DefaultChannelName), "failed to leave private channel 1")
_, _, err = client.ExecuteCommand(context.Background(), channel3.Id, "/leave")
require.Error(t, err)
cdata, _, err := client.GetChannelsForTeamForUser(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, false, "")
require.NoError(t, err)
found := false
for _, c := range cdata {
if c.Id == channel1.Id || c.Id == channel2.Id {
found = true
}
}
require.False(t, found, "did not leave right channels")
for _, c := range cdata {
if c.Name == model.DefaultChannelName {
_, err := client.RemoveUserFromChannel(context.Background(), c.Id, th.BasicUser.Id)
require.Error(t, err, "should have errored on leaving default channel")
break
}
}
}
func TestLogoutTestCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/logout")
require.NoError(t, err)
}
func TestMeCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
channel := th.BasicChannel
testString := "/me hello"
r1, _, err := client.ExecuteCommand(context.Background(), channel.Id, testString)
require.NoError(t, err)
require.NotNil(t, r1, "Command failed to execute")
time.Sleep(time.Second)
p1, _, err := client.GetPostsForChannel(context.Background(), channel.Id, 0, 2, "", false, false)
require.NoError(t, err)
require.Len(t, p1.Order, 2, "Command failed to send")
pt := p1.Posts[p1.Order[0]].Type
require.Equal(t, model.PostTypeMe, pt, "invalid post type")
msg := p1.Posts[p1.Order[0]].Message
want := "*hello*"
require.Equal(t, want, msg, "invalid me response")
}
func TestMsgCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
team := th.BasicTeam
user1 := th.BasicUser
user2 := th.BasicUser2
user3 := th.CreateUser()
th.LinkUserToTeam(user3, team)
_, _, err := client.CreateDirectChannel(context.Background(), th.BasicUser.Id, user2.Id)
require.NoError(t, err)
_, _, err = client.CreateDirectChannel(context.Background(), th.BasicUser.Id, user3.Id)
require.NoError(t, err)
rs1, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/msg "+user2.Username)
require.NoError(t, err)
require.Condition(t, func() bool {
return strings.HasSuffix(rs1.GotoLocation, "/"+team.Name+"/channels/"+user1.Id+"__"+user2.Id) ||
strings.HasSuffix(rs1.GotoLocation, "/"+team.Name+"/channels/"+user2.Id+"__"+user1.Id)
}, "failed to create direct channel")
rs2, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/msg "+user3.Username+" foobar")
require.NoError(t, err)
require.Condition(t, func() bool {
return strings.HasSuffix(rs2.GotoLocation, "/"+team.Name+"/channels/"+user1.Id+"__"+user3.Id) ||
strings.HasSuffix(rs2.GotoLocation, "/"+team.Name+"/channels/"+user3.Id+"__"+user1.Id)
}, "failed to create second direct channel")
result, _, err := client.SearchPosts(context.Background(), th.BasicTeam.Id, "foobar", false)
require.NoError(t, err)
require.NotEqual(t, 0, len(result.Order), "post did not get sent to direct message")
rs3, _, err := client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/msg "+user2.Username)
require.NoError(t, err)
require.Condition(t, func() bool {
return strings.HasSuffix(rs3.GotoLocation, "/"+team.Name+"/channels/"+user1.Id+"__"+user2.Id) ||
strings.HasSuffix(rs3.GotoLocation, "/"+team.Name+"/channels/"+user2.Id+"__"+user1.Id)
}, "failed to go back to existing direct channel")
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/msg "+th.BasicUser.Username+" foobar")
require.NoError(t, err)
_, _, err = client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/msg junk foobar")
require.NoError(t, err)
}
func TestOpenCommands(t *testing.T) {
mainHelper.Parallel(t)
testJoinCommands(t, "open")
}
func TestSearchCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/search")
require.NoError(t, err)
}
func TestSettingsCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/settings")
require.NoError(t, err)
}
func TestShortcutsCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
_, _, err := th.Client.ExecuteCommand(context.Background(), th.BasicChannel.Id, "/shortcuts")
require.NoError(t, err)
}
func TestShrugCommand(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
channel := th.BasicChannel
testString := "/shrug"
r1, _, err := client.ExecuteCommand(context.Background(), channel.Id, testString)
require.NoError(t, err)
require.NotNil(t, r1, "Command failed to execute")
time.Sleep(time.Second)
p1, _, err := client.GetPostsForChannel(context.Background(), channel.Id, 0, 2, "", false, false)
require.NoError(t, err)
require.Len(t, p1.Order, 2, "Command failed to send")
require.Equal(t, `¯\\\_(ツ)\_/¯`, p1.Posts[p1.Order[0]].Message, "invalid shrug response")
}
func TestStatusCommands(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
commandAndTest(t, th, "away")
commandAndTest(t, th, "offline")
commandAndTest(t, th, "online")
}
func commandAndTest(t *testing.T, th *TestHelper, status string) {
client := th.Client
channel := th.BasicChannel
user := th.BasicUser
r1, _, err := client.ExecuteCommand(context.Background(), channel.Id, "/"+status)
require.NoError(t, err)
require.NotEqual(t, "Command failed to execute", r1)
time.Sleep(2 * time.Second)
rstatus, _, err := client.GetUserStatus(context.Background(), user.Id, "")
require.NoError(t, err)
require.Equal(t, status, rstatus.Status, "Error setting status")
}

163
channels/api4/compliance.go Normal file
View File

@ -0,0 +1,163 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strconv"
"github.com/avct/uasurfer"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitCompliance() {
api.BaseRoutes.Compliance.Handle("/reports", api.APISessionRequired(createComplianceReport)).Methods(http.MethodPost)
api.BaseRoutes.Compliance.Handle("/reports", api.APISessionRequired(getComplianceReports)).Methods(http.MethodGet)
api.BaseRoutes.Compliance.Handle("/reports/{report_id:[A-Za-z0-9]+}", api.APISessionRequired(getComplianceReport)).Methods(http.MethodGet)
api.BaseRoutes.Compliance.Handle("/reports/{report_id:[A-Za-z0-9]+}/download", api.APISessionRequiredTrustRequester(downloadComplianceReport)).Methods(http.MethodGet)
}
func createComplianceReport(c *Context, w http.ResponseWriter, r *http.Request) {
var job model.Compliance
if jsonErr := json.NewDecoder(r.Body).Decode(&job); jsonErr != nil {
c.SetInvalidParamWithErr("compliance", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateComplianceReport, model.AuditStatusFail)
model.AddEventParameterAuditableToAuditRec(auditRec, "compliance", &job)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreateComplianceExportJob) {
c.SetPermissionError(model.PermissionCreateComplianceExportJob)
return
}
job.UserId = c.AppContext.Session().UserId
rjob, err := c.App.SaveComplianceReport(c.AppContext, &job)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(rjob)
auditRec.AddEventObjectType("compliance")
auditRec.AddMeta("compliance_id", rjob.Id)
auditRec.AddMeta("compliance_desc", rjob.Desc)
c.LogAudit("")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(rjob); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getComplianceReports(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadComplianceExportJob) {
c.SetPermissionError(model.PermissionReadComplianceExportJob)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetComplianceReports, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
crs, err := c.App.GetComplianceReports(c.Params.Page, c.Params.PerPage)
if err != nil {
c.Err = err
return
}
auditRec.Success()
if err := json.NewEncoder(w).Encode(crs); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getComplianceReport(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireReportId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetComplianceReport, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionReadComplianceExportJob) {
c.SetPermissionError(model.PermissionReadComplianceExportJob)
return
}
model.AddEventParameterToAuditRec(auditRec, "report_id", c.Params.ReportId)
job, err := c.App.GetComplianceReport(c.Params.ReportId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddMeta("compliance_id", job.Id)
auditRec.AddMeta("compliance_desc", job.Desc)
if err := json.NewEncoder(w).Encode(job); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func downloadComplianceReport(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireReportId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDownloadComplianceReport, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "compliance_id", c.Params.ReportId)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionDownloadComplianceExportResult) {
c.SetPermissionError(model.PermissionDownloadComplianceExportResult)
return
}
job, err := c.App.GetComplianceReport(c.Params.ReportId)
if err != nil {
c.Err = err
return
}
auditRec.AddEventResultState(job)
auditRec.AddEventObjectType("compliance")
reportBytes, err := c.App.GetComplianceFile(job)
if err != nil {
c.Err = err
return
}
auditRec.AddMeta("length", len(reportBytes))
c.LogAudit("downloaded " + job.Desc)
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set("Content-Length", strconv.Itoa(len(reportBytes)))
w.Header().Del("Content-Type") // Content-Type will be set automatically by the http writer
// attach extra headers to trigger a download on IE, Edge, and Safari
ua := uasurfer.Parse(r.UserAgent())
w.Header().Set("Content-Disposition", "attachment;filename=\""+job.JobName()+".zip\"")
if ua.Browser.Name == uasurfer.BrowserIE || ua.Browser.Name == uasurfer.BrowserSafari {
// trim off anything before the final / so we just get the file's name
w.Header().Set("Content-Type", "application/octet-stream")
}
auditRec.Success()
if _, err := w.Write(reportBytes); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

446
channels/api4/config.go Normal file
View File

@ -0,0 +1,446 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strconv"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/i18n"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/config"
)
var (
writeFilter func(c *Context, structField reflect.StructField) bool
readFilter func(c *Context, structField reflect.StructField) bool
permissionMap map[string]*model.Permission
)
type filterType string
const (
FilterTypeWrite filterType = "write"
FilterTypeRead filterType = "read"
)
func (api *API) InitConfig() {
api.BaseRoutes.APIRoot.Handle("/config", api.APISessionRequired(getConfig)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/config", api.APISessionRequired(updateConfig)).Methods(http.MethodPut)
api.BaseRoutes.APIRoot.Handle("/config/patch", api.APISessionRequired(patchConfig)).Methods(http.MethodPut)
api.BaseRoutes.APIRoot.Handle("/config/reload", api.APISessionRequired(configReload)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/config/client", api.APIHandler(getClientConfig)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/config/environment", api.APISessionRequired(getEnvironmentConfig)).Methods(http.MethodGet)
}
func init() {
writeFilter = makeFilterConfigByPermission(FilterTypeWrite)
readFilter = makeFilterConfigByPermission(FilterTypeRead)
permissionMap = map[string]*model.Permission{}
for _, p := range model.AllPermissions {
permissionMap[p.Id] = p
}
}
func getConfig(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionToAny(*c.AppContext.Session(), model.SysconsoleReadPermissions) {
c.SetPermissionError(model.SysconsoleReadPermissions...)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
cfg, err := config.Merge(&model.Config{}, c.App.GetSanitizedConfig(), &utils.MergeConfig{
StructFieldFilter: func(structField reflect.StructField, base, patch reflect.Value) bool {
return readFilter(c, structField)
},
})
if err != nil {
c.Err = model.NewAppError("getConfig", "api.config.get_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
filterMasked, _ := strconv.ParseBool(r.URL.Query().Get("remove_masked"))
filterDefaults, _ := strconv.ParseBool(r.URL.Query().Get("remove_defaults"))
auditRec.Success()
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
filterOpts := model.ConfigFilterOptions{
GetConfigOptions: model.GetConfigOptions{
RemoveDefaults: filterDefaults,
RemoveMasked: filterMasked,
},
}
if c.App.Channels().License().IsCloud() {
filterOpts.TagFilters = append(filterOpts.TagFilters, model.FilterTag{
TagType: model.ConfigAccessTagType,
TagName: model.ConfigAccessTagCloudRestrictable,
})
}
m, err := model.FilterConfig(cfg, filterOpts)
if err != nil {
c.Err = model.NewAppError("getConfig", "api.filter_config_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if err := json.NewEncoder(w).Encode(m); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func configReload(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventConfigReload, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionReloadConfig) {
c.SetPermissionError(model.PermissionReloadConfig)
return
}
if err := c.App.ReloadConfig(); err != nil {
c.Err = model.NewAppError("configReload", "api.config.reload_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
ReturnStatusOK(w)
}
func updateConfig(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil || cfg == nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
cfg.SetDefaults()
if !c.App.SessionHasPermissionToAny(*c.AppContext.Session(), model.SysconsoleWritePermissions) {
c.SetPermissionError(model.SysconsoleWritePermissions...)
return
}
appCfg := c.App.Config()
if *appCfg.ServiceSettings.SiteURL != "" && *cfg.ServiceSettings.SiteURL == "" {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.clear_siteurl.app_error", nil, "", http.StatusBadRequest)
return
}
cfg, err = config.Merge(appCfg, cfg, &utils.MergeConfig{
StructFieldFilter: func(structField reflect.StructField, base, patch reflect.Value) bool {
return writeFilter(c, structField)
},
})
if err != nil {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
// Do not allow plugin uploads to be toggled through the API
*cfg.PluginSettings.EnableUploads = *appCfg.PluginSettings.EnableUploads
// Do not allow certificates to be changed through the API
// This shallow-copies the slice header. So be careful if there are concurrent
// modifications to the slice.
cfg.PluginSettings.SignaturePublicKeyFiles = appCfg.PluginSettings.SignaturePublicKeyFiles
// Do not allow marketplace URL to be toggled through the API if EnableUploads are disabled.
if cfg.PluginSettings.EnableUploads != nil && !*appCfg.PluginSettings.EnableUploads {
*cfg.PluginSettings.MarketplaceURL = *appCfg.PluginSettings.MarketplaceURL
}
// There are some settings that cannot be changed in a cloud env
if c.App.Channels().License().IsCloud() {
// Both of them cannot be nil since cfg.SetDefaults is called earlier for cfg,
// and appCfg is the existing earlier config and if it's nil, server sets a default value.
if *appCfg.ComplianceSettings.Directory != *cfg.ComplianceSettings.Directory {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "ComplianceSettings.Directory"}, "", http.StatusForbidden)
return
}
}
// if ES autocomplete was enabled, we need to make sure that index has been checked.
// we need to stop enabling ES autocomplete otherwise.
if !*appCfg.ElasticsearchSettings.EnableAutocomplete && *cfg.ElasticsearchSettings.EnableAutocomplete {
if !c.App.SearchEngine().ElasticsearchEngine.IsAutocompletionEnabled() {
c.Err = model.NewAppError("updateConfig", "api.config.update.elasticsearch.autocomplete_cannot_be_enabled_error", nil, "", http.StatusBadRequest)
return
}
}
c.App.HandleMessageExportConfig(cfg, appCfg)
if appErr := cfg.IsValid(); appErr != nil {
c.Err = appErr
return
}
oldCfg, newCfg, appErr := c.App.SaveConfig(cfg, true)
if appErr != nil {
c.Err = appErr
return
}
// If the config for default server locale has changed, reinitialize the server's translations.
if oldCfg.LocalizationSettings.DefaultServerLocale != newCfg.LocalizationSettings.DefaultServerLocale {
s := newCfg.LocalizationSettings
if err = i18n.InitTranslations(*s.DefaultServerLocale, *s.DefaultClientLocale); err != nil {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.translations.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
}
diffs, err := config.Diff(oldCfg, newCfg)
if err != nil {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.diff.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.AddEventPriorState(&diffs)
c.App.SanitizedConfig(newCfg)
cfg, err = config.Merge(&model.Config{}, newCfg, &utils.MergeConfig{
StructFieldFilter: func(structField reflect.StructField, base, patch reflect.Value) bool {
return readFilter(c, structField)
},
})
if err != nil {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
// auditRec.AddEventResultState(cfg) // TODO we can do this too but do we want to? the config object is huge
auditRec.AddEventObjectType("config")
auditRec.Success()
c.LogAudit("updateConfig")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if c.App.Channels().License().IsCloud() {
js, err := cfg.ToJSONFiltered(model.ConfigAccessTagType, model.ConfigAccessTagCloudRestrictable)
if err != nil {
c.Err = model.NewAppError("updateConfig", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
if err := json.NewEncoder(w).Encode(cfg); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getClientConfig(c *Context, w http.ResponseWriter, r *http.Request) {
var config map[string]string
if c.AppContext.Session().UserId == "" {
config = c.App.Srv().Platform().LimitedClientConfigWithComputed()
} else {
config = c.App.Srv().Platform().ClientConfigWithComputed()
}
if err := json.NewEncoder(w).Encode(config); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getEnvironmentConfig(c *Context, w http.ResponseWriter, r *http.Request) {
// Only return the environment variables for the subsections which the client is
// allowed to see
envConfig := c.App.GetEnvironmentConfig(func(structField reflect.StructField) bool {
return readFilter(c, structField)
})
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if _, err := w.Write([]byte(model.StringInterfaceToJSON(envConfig))); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchConfig(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil || cfg == nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPatchConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionToAny(*c.AppContext.Session(), model.SysconsoleWritePermissions) {
c.SetPermissionError(model.SysconsoleWritePermissions...)
return
}
appCfg := c.App.Config()
if *appCfg.ServiceSettings.SiteURL != "" && cfg.ServiceSettings.SiteURL != nil && *cfg.ServiceSettings.SiteURL == "" {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.clear_siteurl.app_error", nil, "", http.StatusBadRequest)
return
}
filterFn := func(structField reflect.StructField, base, patch reflect.Value) bool {
return writeFilter(c, structField)
}
// Do not allow plugin uploads to be toggled through the API
if cfg.PluginSettings.EnableUploads != nil && *cfg.PluginSettings.EnableUploads != *appCfg.PluginSettings.EnableUploads {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "PluginSettings.EnableUploads"}, "", http.StatusForbidden)
return
}
// Do not allow marketplace URL to be toggled if plugin uploads are disabled.
if cfg.PluginSettings.MarketplaceURL != nil && cfg.PluginSettings.EnableUploads != nil {
// Breaking it down to 2 conditions to make it simple.
if *cfg.PluginSettings.MarketplaceURL != *appCfg.PluginSettings.MarketplaceURL && !*cfg.PluginSettings.EnableUploads {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "PluginSettings.MarketplaceURL"}, "", http.StatusForbidden)
return
}
}
// There are some settings that cannot be changed in a cloud env
if c.App.Channels().License().IsCloud() {
if cfg.ComplianceSettings.Directory != nil && *appCfg.ComplianceSettings.Directory != *cfg.ComplianceSettings.Directory {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "ComplianceSettings.Directory"}, "", http.StatusForbidden)
return
}
}
if cfg.MessageExportSettings.EnableExport != nil {
c.App.HandleMessageExportConfig(cfg, appCfg)
}
updatedCfg, err := config.Merge(appCfg, cfg, &utils.MergeConfig{
StructFieldFilter: filterFn,
})
if err != nil {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
appErr := updatedCfg.IsValid()
if appErr != nil {
c.Err = appErr
return
}
oldCfg, newCfg, appErr := c.App.SaveConfig(updatedCfg, true)
if appErr != nil {
c.Err = appErr
return
}
diffs, err := config.Diff(oldCfg, newCfg)
if err != nil {
c.Err = model.NewAppError("patchConfig", "api.config.patch_config.diff.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.AddEventPriorState(&diffs)
c.App.SanitizedConfig(newCfg)
auditRec.Success()
cfg, err = config.Merge(&model.Config{}, newCfg, &utils.MergeConfig{
StructFieldFilter: func(structField reflect.StructField, base, patch reflect.Value) bool {
return readFilter(c, structField)
},
})
if err != nil {
c.Err = model.NewAppError("patchConfig", "api.config.patch_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if c.App.Channels().License().IsCloud() {
js, err := cfg.ToJSONFiltered(model.ConfigAccessTagType, model.ConfigAccessTagCloudRestrictable)
if err != nil {
c.Err = model.NewAppError("patchConfig", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
if err := json.NewEncoder(w).Encode(cfg); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func makeFilterConfigByPermission(accessType filterType) func(c *Context, structField reflect.StructField) bool {
return func(c *Context, structField reflect.StructField) bool {
if structField.Type.Kind() == reflect.Struct {
return true
}
tagPermissions := strings.Split(structField.Tag.Get("access"), ",")
if c.AppContext.Session().IsUnrestricted() {
return true
}
// one iteration for write_restrictable value, it could be anywhere in the order of values
for _, val := range tagPermissions {
tagValue := strings.TrimSpace(val)
if tagValue == "" {
continue
}
// ConfigAccessTagWriteRestrictable trumps all other permissions
if tagValue == model.ConfigAccessTagWriteRestrictable || tagValue == model.ConfigAccessTagCloudRestrictable {
if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin && accessType == FilterTypeWrite {
return false
}
continue
}
}
// another iteration for permissions checks of other tag values
for _, val := range tagPermissions {
tagValue := strings.TrimSpace(val)
if tagValue == "" {
continue
}
if tagValue == model.ConfigAccessTagWriteRestrictable {
continue
}
if tagValue == model.ConfigAccessTagCloudRestrictable {
continue
}
if tagValue == model.ConfigAccessTagAnySysConsoleRead && accessType == FilterTypeRead &&
c.App.SessionHasPermissionToAny(*c.AppContext.Session(), model.SysconsoleReadPermissions) {
return true
}
permissionID := fmt.Sprintf("sysconsole_%s_%s", accessType, tagValue)
if permission, ok := permissionMap[permissionID]; ok {
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), permission) {
return true
}
} else {
c.Logger.Warn("Unrecognized config permissions tag value.", mlog.String("tag_value", permissionID))
}
}
// with manage_system, default to allow, otherwise default not-allow
return c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
}
}

View File

@ -0,0 +1,196 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"reflect"
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/config"
)
func (api *API) InitConfigLocal() {
api.BaseRoutes.APIRoot.Handle("/config", api.APILocal(localGetConfig)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/config", api.APILocal(localUpdateConfig)).Methods(http.MethodPut)
api.BaseRoutes.APIRoot.Handle("/config/patch", api.APILocal(localPatchConfig)).Methods(http.MethodPut)
api.BaseRoutes.APIRoot.Handle("/config/reload", api.APILocal(configReload)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/config/migrate", api.APILocal(localMigrateConfig)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/config/client", api.APILocal(localGetClientConfig)).Methods(http.MethodGet)
}
func localGetConfig(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventLocalGetConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
filterMasked, _ := strconv.ParseBool(r.URL.Query().Get("remove_masked"))
filterDefaults, _ := strconv.ParseBool(r.URL.Query().Get("remove_defaults"))
filterOpts := model.ConfigFilterOptions{
GetConfigOptions: model.GetConfigOptions{
RemoveDefaults: filterDefaults,
RemoveMasked: filterMasked,
},
}
m, err := model.FilterConfig(c.App.Config(), filterOpts)
if err != nil {
c.Err = model.NewAppError("getConfig", "api.filter_config_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := json.NewEncoder(w).Encode(m); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localUpdateConfig(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil || cfg == nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalUpdateConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
cfg.SetDefaults()
appCfg := c.App.Config()
c.App.HandleMessageExportConfig(cfg, appCfg)
appErr := cfg.IsValid()
if appErr != nil {
c.Err = appErr
return
}
oldCfg, newCfg, appErr := c.App.SaveConfig(cfg, true)
if appErr != nil {
c.Err = appErr
return
}
diffs, diffErr := config.Diff(oldCfg, newCfg)
if diffErr != nil {
c.Err = model.NewAppError("updateConfig", "api.config.update_config.diff.app_error", nil, "", http.StatusInternalServerError).Wrap(diffErr)
return
}
auditRec.AddEventPriorState(&diffs)
c.App.SanitizedConfig(newCfg)
auditRec.Success()
c.LogAudit("updateConfig")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := json.NewEncoder(w).Encode(newCfg); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localPatchConfig(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil || cfg == nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventLocalPatchConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
appCfg := c.App.Config()
filterFn := func(structField reflect.StructField, base, patch reflect.Value) bool {
return true
}
if cfg.MessageExportSettings.EnableExport != nil {
c.App.HandleMessageExportConfig(cfg, appCfg)
}
updatedCfg, mergeErr := config.Merge(appCfg, cfg, &utils.MergeConfig{
StructFieldFilter: filterFn,
})
if mergeErr != nil {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.restricted_merge.app_error", nil, "", http.StatusInternalServerError).Wrap(mergeErr)
return
}
appErr := updatedCfg.IsValid()
if appErr != nil {
c.Err = appErr
return
}
oldCfg, newCfg, appErr := c.App.SaveConfig(updatedCfg, true)
if appErr != nil {
c.Err = appErr
return
}
diffs, err := config.Diff(oldCfg, newCfg)
if err != nil {
c.Err = model.NewAppError("patchConfig", "api.config.patch_config.diff.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.AddEventPriorState(&diffs)
auditRec.Success()
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := json.NewEncoder(w).Encode(c.App.GetSanitizedConfig()); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func localMigrateConfig(c *Context, w http.ResponseWriter, r *http.Request) {
props := model.StringInterfaceFromJSON(r.Body)
from, ok := props["from"].(string)
if !ok {
c.SetInvalidParam("from")
return
}
to, ok := props["to"].(string)
if !ok {
c.SetInvalidParam("to")
return
}
auditRec := c.MakeAuditRecord(model.AuditEventMigrateConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
err := config.Migrate(from, to)
if err != nil {
c.Err = model.NewAppError("migrateConfig", "api.config.migrate_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func localGetClientConfig(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventLocalGetClientConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
auditRec.Success()
_, err := w.Write([]byte(model.MapToJSON(c.App.Srv().Platform().ClientConfigWithComputed())))
if err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@ -0,0 +1,937 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/config"
)
func TestGetConfig(t *testing.T) {
th := Setup(t)
defer th.TearDown()
client := th.Client
_, resp, err := client.GetConfig(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
t.Run("Get config for system admin client", func(t *testing.T) {
cfg, _, err := th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
require.NotEqual(t, "", cfg.TeamSettings.SiteName)
if *cfg.LdapSettings.BindPassword != model.FakeSetting && *cfg.LdapSettings.BindPassword != "" {
require.FailNow(t, "did not sanitize properly")
}
require.Equal(t, model.FakeSetting, *cfg.FileSettings.PublicLinkSalt, "did not sanitize properly")
if *cfg.FileSettings.AmazonS3SecretAccessKey != model.FakeSetting && *cfg.FileSettings.AmazonS3SecretAccessKey != "" {
require.FailNow(t, "did not sanitize properly")
}
if *cfg.EmailSettings.SMTPPassword != model.FakeSetting && *cfg.EmailSettings.SMTPPassword != "" {
require.FailNow(t, "did not sanitize properly")
}
if *cfg.GitLabSettings.Secret != model.FakeSetting && *cfg.GitLabSettings.Secret != "" {
require.FailNow(t, "did not sanitize properly")
}
require.Equal(t, model.FakeSetting, *cfg.SqlSettings.DataSource, "did not sanitize properly")
require.Equal(t, model.FakeSetting, *cfg.SqlSettings.AtRestEncryptKey, "did not sanitize properly")
if !strings.Contains(strings.Join(cfg.SqlSettings.DataSourceReplicas, " "), model.FakeSetting) && len(cfg.SqlSettings.DataSourceReplicas) != 0 {
require.FailNow(t, "did not sanitize properly")
}
if !strings.Contains(strings.Join(cfg.SqlSettings.DataSourceSearchReplicas, " "), model.FakeSetting) && len(cfg.SqlSettings.DataSourceSearchReplicas) != 0 {
require.FailNow(t, "did not sanitize properly")
}
})
t.Run("Get config for local client", func(t *testing.T) {
cfg, _, err := th.LocalClient.GetConfig(context.Background())
require.NoError(t, err)
require.NotEqual(t, model.FakeSetting, *cfg.SqlSettings.DataSource)
require.NotEqual(t, model.FakeSetting, *cfg.FileSettings.PublicLinkSalt)
})
}
func TestGetConfigWithAccessTag(t *testing.T) {
th := Setup(t)
defer th.TearDown()
// set some values so that we know they're not blank
mockVaryByHeader := model.NewId()
mockSupportEmail := model.NewId() + "@mattermost.com"
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.RateLimitSettings.VaryByHeader = mockVaryByHeader
cfg.SupportSettings.SupportEmail = &mockSupportEmail
})
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Username, th.BasicUser.Password)
require.NoError(t, err)
// add read sysconsole environment config
th.AddPermissionToRole(model.PermissionSysconsoleReadEnvironmentRateLimiting.Id, model.SystemUserRoleId)
defer th.RemovePermissionFromRole(model.PermissionSysconsoleReadEnvironmentRateLimiting.Id, model.SystemUserRoleId)
cfg, _, err := th.Client.GetConfig(context.Background())
require.NoError(t, err)
t.Run("Cannot read value without permission", func(t *testing.T) {
assert.Nil(t, cfg.SupportSettings.SupportEmail)
})
t.Run("Can read value with permission", func(t *testing.T) {
assert.Equal(t, mockVaryByHeader, cfg.RateLimitSettings.VaryByHeader)
})
t.Run("Contains Feature Flags", func(t *testing.T) {
assert.NotNil(t, cfg.FeatureFlags)
})
}
func TestGetConfigAnyFlagsAccess(t *testing.T) {
th := Setup(t)
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Username, th.BasicUser.Password)
require.NoError(t, err)
_, resp, _ := th.Client.GetConfig(context.Background())
t.Run("Check permissions error with no sysconsole read permission", func(t *testing.T) {
CheckForbiddenStatus(t, resp)
})
// add read sysconsole environment config
th.AddPermissionToRole(model.PermissionSysconsoleReadEnvironmentRateLimiting.Id, model.SystemUserRoleId)
defer th.RemovePermissionFromRole(model.PermissionSysconsoleReadEnvironmentRateLimiting.Id, model.SystemUserRoleId)
cfg, _, err := th.Client.GetConfig(context.Background())
require.NoError(t, err)
t.Run("Can read value with permission", func(t *testing.T) {
assert.NotNil(t, cfg.FeatureFlags)
assert.NotNil(t, cfg.ExperimentalSettings.RestrictSystemAdmin)
})
}
func TestReloadConfig(t *testing.T) {
th := Setup(t)
defer th.TearDown()
client := th.Client
t.Run("as system user", func(t *testing.T) {
resp, err := client.ReloadConfig(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
_, err := client.ReloadConfig(context.Background())
require.NoError(t, err)
}, "as system admin and local mode")
t.Run("as restricted system admin", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
resp, err := client.ReloadConfig(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestUpdateConfig(t *testing.T) {
th := Setup(t)
defer th.TearDown()
client := th.Client
cfg, _, err := th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
_, resp, err := client.UpdateConfig(context.Background(), cfg)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
SiteName := th.App.Config().TeamSettings.SiteName
*cfg.TeamSettings.SiteName = "MyFancyName"
cfg, _, err = client.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
require.Equal(t, "MyFancyName", *cfg.TeamSettings.SiteName, "It should update the SiteName")
//Revert the change
cfg.TeamSettings.SiteName = SiteName
cfg, _, err = client.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
require.Equal(t, SiteName, cfg.TeamSettings.SiteName, "It should update the SiteName")
t.Run("Should set defaults for missing fields", func(t *testing.T) {
_, err = th.SystemAdminClient.DoAPIPut(context.Background(), "/config", "{}")
require.NoError(t, err)
})
t.Run("Should fail with validation error if invalid config setting is passed", func(t *testing.T) {
//Revert the change
badcfg := cfg.Clone()
badcfg.PasswordSettings.MinimumLength = model.NewPointer(4)
badcfg.PasswordSettings.MinimumLength = model.NewPointer(4)
_, resp, err = client.UpdateConfig(context.Background(), badcfg)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "model.config.is_valid.password_length.app_error")
})
})
t.Run("Ensure PluginSettings.EnableUploads settings are protected", func(t *testing.T) {
t.Run("sysadmin", func(t *testing.T) {
oldEnableUploads := *th.App.Config().PluginSettings.EnableUploads
*cfg.PluginSettings.EnableUploads = !oldEnableUploads
cfg, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.Equal(t, oldEnableUploads, *cfg.PluginSettings.EnableUploads)
assert.Equal(t, oldEnableUploads, *th.App.Config().PluginSettings.EnableUploads)
cfg.PluginSettings.EnableUploads = nil
cfg, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.Equal(t, oldEnableUploads, *cfg.PluginSettings.EnableUploads)
assert.Equal(t, oldEnableUploads, *th.App.Config().PluginSettings.EnableUploads)
})
t.Run("local mode", func(t *testing.T) {
oldEnableUploads := *th.App.Config().PluginSettings.EnableUploads
*cfg.PluginSettings.EnableUploads = !oldEnableUploads
cfg, _, err = th.LocalClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.NotEqual(t, oldEnableUploads, *cfg.PluginSettings.EnableUploads)
assert.NotEqual(t, oldEnableUploads, *th.App.Config().PluginSettings.EnableUploads)
cfg.PluginSettings.EnableUploads = nil
cfg, _, err = th.LocalClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.Equal(t, oldEnableUploads, *cfg.PluginSettings.EnableUploads)
assert.Equal(t, oldEnableUploads, *th.App.Config().PluginSettings.EnableUploads)
})
})
t.Run("Should not be able to modify PluginSettings.SignaturePublicKeyFiles", func(t *testing.T) {
t.Run("sysadmin", func(t *testing.T) {
oldPublicKeys := th.App.Config().PluginSettings.SignaturePublicKeyFiles
cfg.PluginSettings.SignaturePublicKeyFiles = append(cfg.PluginSettings.SignaturePublicKeyFiles, "new_signature")
cfg, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.Equal(t, oldPublicKeys, cfg.PluginSettings.SignaturePublicKeyFiles)
assert.Equal(t, oldPublicKeys, th.App.Config().PluginSettings.SignaturePublicKeyFiles)
cfg.PluginSettings.SignaturePublicKeyFiles = nil
cfg, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.Equal(t, oldPublicKeys, cfg.PluginSettings.SignaturePublicKeyFiles)
assert.Equal(t, oldPublicKeys, th.App.Config().PluginSettings.SignaturePublicKeyFiles)
})
t.Run("local mode", func(t *testing.T) {
oldPublicKeys := th.App.Config().PluginSettings.SignaturePublicKeyFiles
cfg.PluginSettings.SignaturePublicKeyFiles = append(cfg.PluginSettings.SignaturePublicKeyFiles, "new_signature")
cfg, _, err = th.LocalClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.NotEqual(t, oldPublicKeys, cfg.PluginSettings.SignaturePublicKeyFiles)
assert.NotEqual(t, oldPublicKeys, th.App.Config().PluginSettings.SignaturePublicKeyFiles)
cfg.PluginSettings.SignaturePublicKeyFiles = nil
cfg, _, err = th.LocalClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.Equal(t, oldPublicKeys, cfg.PluginSettings.SignaturePublicKeyFiles)
assert.Equal(t, oldPublicKeys, th.App.Config().PluginSettings.SignaturePublicKeyFiles)
})
})
t.Run("Should not be able to modify PluginSettings.MarketplaceURL if EnableUploads is disabled", func(t *testing.T) {
oldURL := "hello.com"
newURL := "new.com"
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.EnableUploads = false
*cfg.PluginSettings.MarketplaceURL = oldURL
})
cfg2 := th.App.Config().Clone()
*cfg2.PluginSettings.MarketplaceURL = newURL
cfg2, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg2)
require.NoError(t, err)
assert.Equal(t, oldURL, *cfg2.PluginSettings.MarketplaceURL)
// Allowing uploads
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.EnableUploads = true
*cfg.PluginSettings.MarketplaceURL = oldURL
})
cfg2 = th.App.Config().Clone()
*cfg2.PluginSettings.MarketplaceURL = newURL
cfg2, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg2)
require.NoError(t, err)
assert.Equal(t, newURL, *cfg2.PluginSettings.MarketplaceURL)
})
t.Run("Should not be able to modify ComplianceSettings.Directory in cloud", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
defer func() {
appErr := th.App.Srv().RemoveLicense()
require.Nil(t, appErr)
}()
cfg2 := th.App.Config().Clone()
*cfg2.ComplianceSettings.Directory = "hellodir"
_, resp, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg2)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("System Admin should not be able to clear Site URL", func(t *testing.T) {
siteURL := cfg.ServiceSettings.SiteURL
defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = siteURL })
nonEmptyURL := "http://localhost"
cfg.ServiceSettings.SiteURL = &nonEmptyURL
// Set the SiteURL
cfg, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
require.Equal(t, nonEmptyURL, *cfg.ServiceSettings.SiteURL)
// Check that the Site URL can't be cleared
cfg.ServiceSettings.SiteURL = model.NewPointer("")
cfg, resp, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.config.update_config.clear_siteurl.app_error")
// Check that the Site URL wasn't cleared
cfg, _, err = th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
require.Equal(t, nonEmptyURL, *cfg.ServiceSettings.SiteURL)
})
}
func TestGetConfigWithoutManageSystemPermission(t *testing.T) {
th := Setup(t)
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Username, th.BasicUser.Password)
require.NoError(t, err)
t.Run("any sysconsole read permission provides config read access", func(t *testing.T) {
// forbidden by default
_, resp, err := th.Client.GetConfig(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// add any sysconsole read permission
th.AddPermissionToRole(model.SysconsoleReadPermissions[0].Id, model.SystemUserRoleId)
_, _, err = th.Client.GetConfig(context.Background())
// should be readable now
require.NoError(t, err)
})
}
func TestUpdateConfigWithoutManageSystemPermission(t *testing.T) {
th := Setup(t)
defer th.TearDown()
_, _, err := th.Client.Login(context.Background(), th.BasicUser.Username, th.BasicUser.Password)
require.NoError(t, err)
// add read sysconsole integrations config
th.AddPermissionToRole(model.PermissionSysconsoleReadIntegrationsIntegrationManagement.Id, model.SystemUserRoleId)
defer th.RemovePermissionFromRole(model.PermissionSysconsoleReadIntegrationsIntegrationManagement.Id, model.SystemUserRoleId)
t.Run("sysconsole read permission does not provides config write access", func(t *testing.T) {
// should be readable because has a sysconsole read permission
cfg, _, err := th.Client.GetConfig(context.Background())
require.NoError(t, err)
_, resp, err := th.Client.UpdateConfig(context.Background(), cfg)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("the wrong write permission does not grant access", func(t *testing.T) {
// should be readable because has a sysconsole read permission
cfg, _, err := th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
originalValue := *cfg.ServiceSettings.AllowCorsFrom
// add the wrong write permission
th.AddPermissionToRole(model.PermissionSysconsoleWriteAboutEditionAndLicense.Id, model.SystemUserRoleId)
defer th.RemovePermissionFromRole(model.PermissionSysconsoleWriteAboutEditionAndLicense.Id, model.SystemUserRoleId)
// try update a config value allowed by sysconsole WRITE integrations
mockVal := model.NewId()
cfg.ServiceSettings.AllowCorsFrom = &mockVal
_, _, err = th.Client.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
// ensure the config setting was not updated
cfg, _, err = th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
assert.Equal(t, *cfg.ServiceSettings.AllowCorsFrom, originalValue)
})
t.Run("config value is writeable by specific system console permission", func(t *testing.T) {
// should be readable because has a sysconsole read permission
cfg, _, err := th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
th.AddPermissionToRole(model.PermissionSysconsoleWriteIntegrationsCors.Id, model.SystemUserRoleId)
defer th.RemovePermissionFromRole(model.PermissionSysconsoleWriteIntegrationsCors.Id, model.SystemUserRoleId)
th.AddPermissionToRole(model.PermissionSysconsoleReadIntegrationsCors.Id, model.SystemUserRoleId)
defer th.RemovePermissionFromRole(model.PermissionSysconsoleReadIntegrationsCors.Id, model.SystemUserRoleId)
// try update a config value allowed by sysconsole WRITE integrations
mockVal := model.NewId()
cfg.ServiceSettings.AllowCorsFrom = &mockVal
_, _, err = th.Client.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
// ensure the config setting was updated
cfg, _, err = th.Client.GetConfig(context.Background())
require.NoError(t, err)
assert.Equal(t, *cfg.ServiceSettings.AllowCorsFrom, mockVal)
})
}
func TestUpdateConfigMessageExportSpecialHandling(t *testing.T) {
th := Setup(t)
defer th.TearDown()
messageExportEnabled := *th.App.Config().MessageExportSettings.EnableExport
messageExportTimestamp := *th.App.Config().MessageExportSettings.ExportFromTimestamp
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.MessageExportSettings.EnableExport = messageExportEnabled
*cfg.MessageExportSettings.ExportFromTimestamp = messageExportTimestamp
})
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.MessageExportSettings.EnableExport = false
*cfg.MessageExportSettings.ExportFromTimestamp = int64(0)
})
// Turn it on, timestamp should be updated.
cfg, _, err := th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
*cfg.MessageExportSettings.EnableExport = true
_, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.True(t, *th.App.Config().MessageExportSettings.EnableExport)
assert.NotEqual(t, int64(0), *th.App.Config().MessageExportSettings.ExportFromTimestamp)
// Turn it off, timestamp should be cleared.
cfg, _, err = th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
*cfg.MessageExportSettings.EnableExport = false
_, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.False(t, *th.App.Config().MessageExportSettings.EnableExport)
assert.Equal(t, int64(0), *th.App.Config().MessageExportSettings.ExportFromTimestamp)
// Set a value from the config file.
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.MessageExportSettings.EnableExport = false
*cfg.MessageExportSettings.ExportFromTimestamp = int64(12345)
})
// Turn it on, timestamp should *not* be updated.
cfg, _, err = th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
*cfg.MessageExportSettings.EnableExport = true
_, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.True(t, *th.App.Config().MessageExportSettings.EnableExport)
assert.Equal(t, int64(12345), *th.App.Config().MessageExportSettings.ExportFromTimestamp)
// Turn it off, timestamp should be cleared.
cfg, _, err = th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
*cfg.MessageExportSettings.EnableExport = false
_, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
assert.False(t, *th.App.Config().MessageExportSettings.EnableExport)
assert.Equal(t, int64(0), *th.App.Config().MessageExportSettings.ExportFromTimestamp)
}
func TestUpdateConfigRestrictSystemAdmin(t *testing.T) {
th := Setup(t)
defer th.TearDown()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
t.Run("Restrict flag should be honored for sysadmin", func(t *testing.T) {
originalCfg, _, err := th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
cfg := originalCfg.Clone()
*cfg.TeamSettings.SiteName = "MyFancyName" // Allowed
*cfg.ServiceSettings.SiteURL = "http://example.com" // Ignored
returnedCfg, _, err := th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
require.Equal(t, "MyFancyName", *returnedCfg.TeamSettings.SiteName)
require.Equal(t, *originalCfg.ServiceSettings.SiteURL, *returnedCfg.ServiceSettings.SiteURL)
actualCfg, _, err := th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
require.Equal(t, returnedCfg, actualCfg)
})
t.Run("Restrict flag should be ignored by local mode", func(t *testing.T) {
originalCfg, _, err := th.LocalClient.GetConfig(context.Background())
require.NoError(t, err)
cfg := originalCfg.Clone()
*cfg.TeamSettings.SiteName = "MyFancyName" // Allowed
*cfg.ServiceSettings.SiteURL = "http://example.com" // Ignored
returnedCfg, _, err := th.LocalClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
require.Equal(t, "MyFancyName", *returnedCfg.TeamSettings.SiteName)
require.Equal(t, "http://example.com", *returnedCfg.ServiceSettings.SiteURL)
})
}
func TestUpdateConfigDiffInAuditRecord(t *testing.T) {
logFile, err := os.CreateTemp("", "adv.log")
require.NoError(t, err)
defer os.Remove(logFile.Name())
os.Setenv("MM_EXPERIMENTALAUDITSETTINGS_FILEENABLED", "true")
os.Setenv("MM_EXPERIMENTALAUDITSETTINGS_FILENAME", logFile.Name())
defer os.Unsetenv("MM_EXPERIMENTALAUDITSETTINGS_FILEENABLED")
defer os.Unsetenv("MM_EXPERIMENTALAUDITSETTINGS_FILENAME")
options := []app.Option{app.WithLicense(model.NewTestLicense("advanced_logging"))}
th := SetupWithServerOptions(t, options)
defer th.TearDown()
cfg, _, err := th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
timeoutVal := *cfg.ServiceSettings.ReadTimeout
cfg.ServiceSettings.ReadTimeout = model.NewPointer(timeoutVal + 1)
cfg, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg)
require.NoError(t, err)
defer th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ServiceSettings.ReadTimeout = model.NewPointer(timeoutVal)
})
require.Equal(t, timeoutVal+1, *cfg.ServiceSettings.ReadTimeout)
// Forcing a flush before attempting to read log's content.
err = th.Server.Audit.Flush()
require.NoError(t, err)
require.NoError(t, logFile.Sync())
data, err := io.ReadAll(logFile)
require.NoError(t, err)
require.NotEmpty(t, data)
require.Contains(t, string(data),
fmt.Sprintf(`"config_diffs":[{"actual_val":%d,"base_val":%d,"path":"ServiceSettings.ReadTimeout"}`,
timeoutVal+1, timeoutVal))
}
func TestGetEnvironmentConfig(t *testing.T) {
os.Setenv("MM_SERVICESETTINGS_SITEURL", "http://example.mattermost.com")
os.Setenv("MM_SERVICESETTINGS_ENABLECUSTOMEMOJI", "true")
defer os.Unsetenv("MM_SERVICESETTINGS_SITEURL")
defer os.Unsetenv("MM_SERVICESETTINGS_ENABLECUSTOMEMOJI")
th := Setup(t)
defer th.TearDown()
t.Run("as system admin", func(t *testing.T) {
SystemAdminClient := th.SystemAdminClient
envConfig, _, err := SystemAdminClient.GetEnvironmentConfig(context.Background())
require.NoError(t, err)
serviceSettings, ok := envConfig["ServiceSettings"]
require.True(t, ok, "should've returned ServiceSettings")
serviceSettingsAsMap, ok := serviceSettings.(map[string]any)
require.True(t, ok, "should've returned ServiceSettings as a map")
siteURL, ok := serviceSettingsAsMap["SiteURL"]
require.True(t, ok, "should've returned ServiceSettings.SiteURL")
siteURLAsBool, ok := siteURL.(bool)
require.True(t, ok, "should've returned ServiceSettings.SiteURL as a boolean")
require.True(t, siteURLAsBool, "should've returned ServiceSettings.SiteURL as true")
enableCustomEmoji, ok := serviceSettingsAsMap["EnableCustomEmoji"]
require.True(t, ok, "should've returned ServiceSettings.EnableCustomEmoji")
enableCustomEmojiAsBool, ok := enableCustomEmoji.(bool)
require.True(t, ok, "should've returned ServiceSettings.EnableCustomEmoji as a boolean")
require.True(t, enableCustomEmojiAsBool, "should've returned ServiceSettings.EnableCustomEmoji as true")
_, ok = envConfig["TeamSettings"]
require.False(t, ok, "should not have returned TeamSettings")
})
t.Run("as team admin", func(t *testing.T) {
TeamAdminClient := th.CreateClient()
th.LoginTeamAdminWithClient(TeamAdminClient)
envConfig, _, err := TeamAdminClient.GetEnvironmentConfig(context.Background())
require.NoError(t, err)
require.Empty(t, envConfig)
})
t.Run("as regular user", func(t *testing.T) {
client := th.Client
envConfig, _, err := client.GetEnvironmentConfig(context.Background())
require.NoError(t, err)
require.Empty(t, envConfig)
})
t.Run("as not-regular user", func(t *testing.T) {
client := th.CreateClient()
_, resp, err := client.GetEnvironmentConfig(context.Background())
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
}
func TestGetClientConfig(t *testing.T) {
th := Setup(t)
defer th.TearDown()
testKey := "supersecretkey"
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.GoogleDeveloperKey = testKey })
t.Run("with session", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.GoogleDeveloperKey = testKey
})
client := th.Client
config, _, err := client.GetClientConfig(context.Background(), "")
require.NoError(t, err)
require.NotEmpty(t, config["Version"], "config not returned correctly")
require.Equal(t, testKey, config["GoogleDeveloperKey"])
})
t.Run("without session", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.GoogleDeveloperKey = testKey
})
client := th.CreateClient()
config, _, err := client.GetClientConfig(context.Background(), "")
require.NoError(t, err)
require.NotEmpty(t, config["Version"], "config not returned correctly")
require.Empty(t, config["GoogleDeveloperKey"], "config should be missing developer key")
})
t.Run("format=old (backward compatibility)", func(t *testing.T) {
client := th.Client
resp, err := client.DoAPIGet(context.Background(), "/config/client?format=old", "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var config map[string]string
err = json.NewDecoder(resp.Body).Decode(&config)
require.NoError(t, err)
require.NotEmpty(t, config["Version"], "config not returned correctly")
})
t.Run("format=junk (ignored)", func(t *testing.T) {
client := th.Client
resp, err := client.DoAPIGet(context.Background(), "/config/client?format=junk", "")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var config map[string]string
err = json.NewDecoder(resp.Body).Decode(&config)
require.NoError(t, err)
require.NotEmpty(t, config["Version"], "config not returned correctly")
})
}
func TestPatchConfig(t *testing.T) {
th := Setup(t)
defer th.TearDown()
// Ensure ConsoleLevel is set to DEBUG
config := model.Config{LogSettings: model.LogSettings{
ConsoleLevel: model.NewPointer("DEBUG"),
}}
_, _, err := th.SystemAdminClient.PatchConfig(context.Background(), &config)
require.NoError(t, err)
t.Run("config is missing", func(t *testing.T) {
_, response, err := th.Client.PatchConfig(context.Background(), nil)
require.Error(t, err)
CheckBadRequestStatus(t, response)
})
t.Run("user is not system admin", func(t *testing.T) {
_, response, err := th.Client.PatchConfig(context.Background(), &model.Config{})
require.Error(t, err)
CheckForbiddenStatus(t, response)
})
t.Run("should not update the restricted fields when restrict toggle is on for sysadmin", func(t *testing.T) {
*th.App.Config().ExperimentalSettings.RestrictSystemAdmin = true
config := model.Config{LogSettings: model.LogSettings{
ConsoleLevel: model.NewPointer("INFO"),
}}
updatedConfig, _, _ := th.SystemAdminClient.PatchConfig(context.Background(), &config)
assert.Equal(t, "DEBUG", *updatedConfig.LogSettings.ConsoleLevel)
})
t.Run("should not bypass the restrict toggle if local client", func(t *testing.T) {
*th.App.Config().ExperimentalSettings.RestrictSystemAdmin = true
config := model.Config{LogSettings: model.LogSettings{
ConsoleLevel: model.NewPointer("INFO"),
}}
oldConfig, _, _ := th.LocalClient.GetConfig(context.Background())
updatedConfig, _, _ := th.LocalClient.PatchConfig(context.Background(), &config)
assert.Equal(t, "INFO", *updatedConfig.LogSettings.ConsoleLevel)
// reset the config
_, _, err := th.LocalClient.UpdateConfig(context.Background(), oldConfig)
require.NoError(t, err)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
t.Run("check if config is valid", func(t *testing.T) {
config := model.Config{PasswordSettings: model.PasswordSettings{
MinimumLength: model.NewPointer(4),
}}
_, response, err := client.PatchConfig(context.Background(), &config)
assert.Equal(t, http.StatusBadRequest, response.StatusCode)
assert.Error(t, err)
CheckErrorID(t, err, "model.config.is_valid.password_length.app_error")
})
t.Run("should patch the config", func(t *testing.T) {
*th.App.Config().ExperimentalSettings.RestrictSystemAdmin = false
th.App.UpdateConfig(func(cfg *model.Config) { cfg.TeamSettings.ExperimentalDefaultChannels = []string{"some-channel"} })
oldConfig, _, err := client.GetConfig(context.Background())
require.NoError(t, err)
assert.False(t, *oldConfig.PasswordSettings.Lowercase)
assert.NotEqual(t, 15, *oldConfig.PasswordSettings.MinimumLength)
assert.Equal(t, "DEBUG", *oldConfig.LogSettings.ConsoleLevel)
assert.True(t, oldConfig.PluginSettings.PluginStates["com.mattermost.nps"].Enable)
states := make(map[string]*model.PluginState)
states["com.mattermost.nps"] = &model.PluginState{Enable: *model.NewPointer(false)}
config := model.Config{PasswordSettings: model.PasswordSettings{
Lowercase: model.NewPointer(true),
MinimumLength: model.NewPointer(15),
}, LogSettings: model.LogSettings{
ConsoleLevel: model.NewPointer("INFO"),
},
TeamSettings: model.TeamSettings{
ExperimentalDefaultChannels: []string{"another-channel"},
},
PluginSettings: model.PluginSettings{
PluginStates: states,
},
}
_, response, err := client.PatchConfig(context.Background(), &config)
require.NoError(t, err)
updatedConfig, _, err := client.GetConfig(context.Background())
require.NoError(t, err)
assert.True(t, *updatedConfig.PasswordSettings.Lowercase)
assert.Equal(t, "INFO", *updatedConfig.LogSettings.ConsoleLevel)
assert.Equal(t, []string{"another-channel"}, updatedConfig.TeamSettings.ExperimentalDefaultChannels)
assert.False(t, updatedConfig.PluginSettings.PluginStates["com.mattermost.nps"].Enable)
assert.Equal(t, "no-cache, no-store, must-revalidate", response.Header.Get("Cache-Control"))
// reset the config
_, _, err = client.UpdateConfig(context.Background(), oldConfig)
require.NoError(t, err)
})
t.Run("should sanitize config", func(t *testing.T) {
config := model.Config{PasswordSettings: model.PasswordSettings{
Symbol: model.NewPointer(true),
}}
updatedConfig, _, err := client.PatchConfig(context.Background(), &config)
require.NoError(t, err)
assert.Equal(t, model.FakeSetting, *updatedConfig.SqlSettings.DataSource)
})
t.Run("not allowing to toggle enable uploads for plugin via api", func(t *testing.T) {
config := model.Config{PluginSettings: model.PluginSettings{
EnableUploads: model.NewPointer(true),
}}
updatedConfig, resp, err := client.PatchConfig(context.Background(), &config)
if client == th.LocalClient {
require.NoError(t, err)
CheckOKStatus(t, resp)
assert.Equal(t, true, *updatedConfig.PluginSettings.EnableUploads)
} else {
require.Error(t, err)
CheckForbiddenStatus(t, resp)
}
})
})
t.Run("Should not be able to modify PluginSettings.MarketplaceURL if EnableUploads is disabled", func(t *testing.T) {
oldURL := "hello.com"
newURL := "new.com"
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.EnableUploads = false
*cfg.PluginSettings.MarketplaceURL = oldURL
})
cfg := th.App.Config().Clone()
*cfg.PluginSettings.MarketplaceURL = newURL
_, _, err := th.SystemAdminClient.PatchConfig(context.Background(), cfg)
require.Error(t, err)
// Allowing uploads
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.PluginSettings.EnableUploads = true
*cfg.PluginSettings.MarketplaceURL = oldURL
})
cfg = th.App.Config().Clone()
*cfg.PluginSettings.MarketplaceURL = newURL
cfg, _, err = th.SystemAdminClient.PatchConfig(context.Background(), cfg)
require.NoError(t, err)
assert.Equal(t, newURL, *cfg.PluginSettings.MarketplaceURL)
})
t.Run("System Admin should not be able to clear Site URL", func(t *testing.T) {
cfg, _, err := th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
siteURL := cfg.ServiceSettings.SiteURL
defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = siteURL })
// Set the SiteURL
nonEmptyURL := "http://localhost"
config := model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: model.NewPointer(nonEmptyURL),
},
}
updatedConfig, _, err := th.SystemAdminClient.PatchConfig(context.Background(), &config)
require.NoError(t, err)
require.Equal(t, nonEmptyURL, *updatedConfig.ServiceSettings.SiteURL)
// Check that the Site URL can't be cleared
config = model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: model.NewPointer(""),
},
}
_, resp, err := th.SystemAdminClient.PatchConfig(context.Background(), &config)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.config.update_config.clear_siteurl.app_error")
// Check that the Site URL wasn't cleared
cfg, _, err = th.SystemAdminClient.GetConfig(context.Background())
require.NoError(t, err)
require.Equal(t, nonEmptyURL, *cfg.ServiceSettings.SiteURL)
// Check that sending an empty config returns no error.
_, _, err = th.SystemAdminClient.PatchConfig(context.Background(), &model.Config{})
require.NoError(t, err)
})
}
func TestMigrateConfig(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
t.Run("LocalClient", func(t *testing.T) {
cfg := &model.Config{}
cfg.SetDefaults()
file, err := json.MarshalIndent(cfg, "", " ")
require.NoError(t, err)
err = os.WriteFile("from.json", file, 0644)
require.NoError(t, err)
defer os.Remove("from.json")
f, err := config.NewStoreFromDSN("from.json", false, nil, false)
require.NoError(t, err)
defer func() {
err = f.RemoveFile("from.json")
require.NoError(t, err)
}()
_, err = config.NewStoreFromDSN("to.json", false, nil, true)
require.NoError(t, err)
defer func() {
err = f.RemoveFile("to.json")
require.NoError(t, err)
}()
_, err = th.LocalClient.MigrateConfig(context.Background(), "from.json", "to.json")
require.NoError(t, err)
})
}

View File

@ -0,0 +1,600 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"slices"
"strings"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitContentFlagging() {
if !api.srv.Config().FeatureFlags.ContentFlagging {
return
}
api.BaseRoutes.ContentFlagging.Handle("/flag/config", api.APISessionRequired(getFlaggingConfiguration)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/status", api.APISessionRequired(getTeamPostFlaggingFeatureStatus)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/flag", api.APISessionRequired(flagPost)).Methods(http.MethodPost)
api.BaseRoutes.ContentFlagging.Handle("/fields", api.APISessionRequired(getContentFlaggingFields)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/field_values", api.APISessionRequired(getPostPropertyValues)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}", api.APISessionRequired(getFlaggedPost)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/remove", api.APISessionRequired(removeFlaggedPost)).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/keep", api.APISessionRequired(keepFlaggedPost)).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/config", api.APISessionRequired(saveContentFlaggingSettings)).Methods(http.MethodPut)
api.BaseRoutes.ContentFlagging.Handle("/config", api.APISessionRequired(getContentFlaggingSettings)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/team/{team_id:[A-Za-z0-9]+}/reviewers/search", api.APISessionRequired(searchReviewers)).Methods(http.MethodGet)
api.BaseRoutes.ContentFlagging.Handle("/post/{post_id:[A-Za-z0-9]+}/assign/{content_reviewer_id:[A-Za-z0-9]+}", api.APISessionRequired(assignFlaggedPostReviewer)).Methods(http.MethodPost)
}
func requireContentFlaggingAvailable(c *Context) {
if !model.MinimumEnterpriseAdvancedLicense(c.App.License()) {
c.Err = model.NewAppError("requireContentFlaggingEnabled", "api.content_flagging.error.license", nil, "", http.StatusNotImplemented)
return
}
}
func requireContentFlaggingEnabled(c *Context) {
requireContentFlaggingAvailable(c)
if c.Err != nil {
return
}
contentFlaggingEnabled := c.App.Config().ContentFlaggingSettings.EnableContentFlagging
if contentFlaggingEnabled == nil || !*contentFlaggingEnabled {
c.Err = model.NewAppError("requireContentFlaggingEnabled", "api.content_flagging.error.disabled", nil, "", http.StatusNotImplemented)
return
}
}
func requireTeamContentReviewer(c *Context, userId, teamId string) {
isReviewer, appErr := c.App.IsUserTeamContentReviewer(userId, teamId)
if appErr != nil {
c.Err = appErr
return
}
if !isReviewer {
c.Err = model.NewAppError("requireTeamContentReviewer", "api.content_flagging.error.user_not_reviewer", nil, "", http.StatusForbidden)
return
}
}
func requireFlaggedPost(c *Context, postId string) {
if postId == "" {
c.SetInvalidParam("flagged_post_id")
return
}
_, appErr := c.App.GetPostContentFlaggingPropertyValue(postId, app.ContentFlaggingPropertyNameStatus)
if appErr != nil {
c.Err = appErr
return
}
}
func getFlaggingConfiguration(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
// A team ID is expected to be specified by a content reviewer.
// When specified, we verify that the user is a content reviewer of the team.
// If the user is indeed a content reviewer, we return the configuration along with some extra fields
// that only a reviewer should be aware of.
// If no team ID is specified, we return the configuration as is, without the extra fields.
// This is the expected usage for non-reviewers.
teamId := r.URL.Query().Get("team_id")
asReviewer := false
if teamId != "" {
requireTeamContentReviewer(c, c.AppContext.Session().UserId, teamId)
if c.Err != nil {
return
}
asReviewer = true
}
config := getFlaggingConfig(c.App.Config().ContentFlaggingSettings, asReviewer)
if err := json.NewEncoder(w).Encode(config); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("getFlaggingConfiguration", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func getTeamPostFlaggingFeatureStatus(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequireTeamId()
if c.Err != nil {
return
}
teamID := c.Params.TeamId
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamID, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
enabled, appErr := c.App.ContentFlaggingEnabledForTeam(teamID)
if appErr != nil {
c.Err = appErr
return
}
payload := map[string]bool{
"enabled": enabled,
}
if err := json.NewEncoder(w).Encode(payload); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("getTeamPostFlaggingFeatureStatus", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func flagPost(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
var flagRequest model.FlagContentRequest
if err := json.NewDecoder(r.Body).Decode(&flagRequest); err != nil {
c.SetInvalidParamWithErr("flagPost", err)
return
}
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
auditRec := c.MakeAuditRecord(model.AuditEventFlagPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
post, appErr := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
enabled, appErr := c.App.ContentFlaggingEnabledForTeam(channel.TeamId)
if appErr != nil {
c.Err = appErr
return
}
if !enabled {
c.Err = model.NewAppError("flagPost", "api.content_flagging.error.not_available_on_team", nil, "", http.StatusBadRequest)
return
}
appErr = c.App.FlagPost(c.AppContext, post, channel.TeamId, userId, flagRequest)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventObjectType("post")
writeOKResponse(w)
}
func getFlaggingConfig(contentFlaggingSettings model.ContentFlaggingSettings, asReviewer bool) *model.ContentFlaggingReportingConfig {
config := &model.ContentFlaggingReportingConfig{
Reasons: contentFlaggingSettings.AdditionalSettings.Reasons,
ReporterCommentRequired: contentFlaggingSettings.AdditionalSettings.ReporterCommentRequired,
ReviewerCommentRequired: contentFlaggingSettings.AdditionalSettings.ReviewerCommentRequired,
}
if asReviewer {
config.NotifyReporterOnRemoval = model.NewPointer(slices.Contains(contentFlaggingSettings.NotificationSettings.EventTargetMapping[model.EventContentRemoved], model.TargetReporter))
config.NotifyReporterOnDismissal = model.NewPointer(slices.Contains(contentFlaggingSettings.NotificationSettings.EventTargetMapping[model.EventContentDismissed], model.TargetReporter))
}
return config
}
func getContentFlaggingFields(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
groupId, appErr := c.App.ContentFlaggingGroupId()
if appErr != nil {
c.Err = appErr
return
}
mappedFields, appErr := c.App.GetContentFlaggingMappedFields(groupId)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(mappedFields); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("getContentFlaggingFields", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func getPostPropertyValues(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
// The requesting user must be a reviewer of the post's team
// to be able to fetch the post's Content Flagging property values
postId := c.Params.PostId
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
userId := c.AppContext.Session().UserId
requireTeamContentReviewer(c, userId, channel.TeamId)
if c.Err != nil {
return
}
propertyValues, appErr := c.App.GetPostContentFlaggingPropertyValues(postId)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(propertyValues); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("getPostPropertyValues", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func getFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
// A user can obtain a flagged post if-
// 1. The post is currently flagged and in any status
// 2. The user is a reviewer of the post's team
// check if user is a reviewer of the post's team
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
auditRec := c.MakeAuditRecord(model.AuditEventGetFlaggedPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
requireTeamContentReviewer(c, userId, channel.TeamId)
if c.Err != nil {
return
}
// This validates that the post is flagged
requireFlaggedPost(c, postId)
if c.Err != nil {
return
}
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true, RetainContent: true, IncludeDeleted: true})
post, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
}
if err := post.EncodeJSON(w); err != nil {
c.Err = model.NewAppError("getFlaggedPost", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
}
func removeFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
actionRequest, userId, post := keepRemoveFlaggedPostChecks(c, r)
if c.Err != nil {
c.Err.Where = "removeFlaggedPost"
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPermanentlyRemoveFlaggedPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", post.Id)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
if appErr := c.App.PermanentDeleteFlaggedPost(c.AppContext, actionRequest, userId, post); appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}
func keepFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
actionRequest, userId, post := keepRemoveFlaggedPostChecks(c, r)
if c.Err != nil {
c.Err.Where = "keepFlaggedPost"
return
}
auditRec := c.MakeAuditRecord(model.AuditEventKeepFlaggedPost, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterToAuditRec(auditRec, "postId", post.Id)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
if appErr := c.App.KeepFlaggedPost(c.AppContext, actionRequest, userId, post); appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}
func keepRemoveFlaggedPostChecks(c *Context, r *http.Request) (*model.FlagContentActionRequest, string, *model.Post) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return nil, "", nil
}
c.RequirePostId()
if c.Err != nil {
return nil, "", nil
}
var actionRequest model.FlagContentActionRequest
if err := json.NewDecoder(r.Body).Decode(&actionRequest); err != nil {
c.SetInvalidParamWithErr("flagContentActionRequestBody", err)
return nil, "", nil
}
postId := c.Params.PostId
userId := c.AppContext.Session().UserId
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return nil, "", nil
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return nil, "", nil
}
requireTeamContentReviewer(c, userId, channel.TeamId)
if c.Err != nil {
return nil, "", nil
}
commentRequired := c.App.Config().ContentFlaggingSettings.AdditionalSettings.ReviewerCommentRequired
if err := actionRequest.IsValid(*commentRequired); err != nil {
c.Err = err
return nil, "", nil
}
return &actionRequest, userId, post
}
func saveContentFlaggingSettings(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingAvailable(c)
if c.Err != nil {
return
}
var config model.ContentFlaggingSettingsRequest
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
c.SetInvalidParamWithErr("config", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateContentFlaggingConfig, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
config.SetDefaults()
if appErr := config.IsValid(); appErr != nil {
c.Err = appErr
return
}
appErr := c.App.SaveContentFlaggingConfig(config)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}
func getContentFlaggingSettings(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingAvailable(c)
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
reviewerIDs, appErr := c.App.GetContentFlaggingConfigReviewerIDs()
if appErr != nil {
c.Err = appErr
return
}
config := c.App.Config().ContentFlaggingSettings
fullConfig := model.ContentFlaggingSettingsRequest{
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: *config.ReviewerSettings,
ReviewerIDsSettings: *reviewerIDs,
},
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: config.EnableContentFlagging,
NotificationSettings: config.NotificationSettings,
AdditionalSettings: config.AdditionalSettings,
},
}
if err := json.NewEncoder(w).Encode(fullConfig); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("getContentFlaggingSettings", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func searchReviewers(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequireTeamId()
if c.Err != nil {
return
}
teamId := c.Params.TeamId
userId := c.AppContext.Session().UserId
searchTerm := strings.TrimSpace(r.URL.Query().Get("term"))
requireTeamContentReviewer(c, userId, teamId)
if c.Err != nil {
return
}
reviewers, appErr := c.App.SearchReviewers(c.AppContext, searchTerm, teamId)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(reviewers); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
c.Err = model.NewAppError("searchReviewers", "api.encoding_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
}
func assignFlaggedPostReviewer(c *Context, w http.ResponseWriter, r *http.Request) {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
c.RequirePostId()
if c.Err != nil {
return
}
c.RequireContentReviewerId()
if c.Err != nil {
return
}
postId := c.Params.PostId
post, appErr := c.App.GetSinglePost(c.AppContext, postId, true)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
assignedBy := c.AppContext.Session().UserId
requireTeamContentReviewer(c, assignedBy, channel.TeamId)
if c.Err != nil {
return
}
reviewerId := c.Params.ContentReviewerId
requireTeamContentReviewer(c, reviewerId, channel.TeamId)
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventSetReviewer, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "assigningUserId", assignedBy)
model.AddEventParameterToAuditRec(auditRec, "reviewerUserId", reviewerId)
appErr = c.App.AssignFlaggedPostReviewer(c.AppContext, postId, channel.TeamId, reviewerId, assignedBy)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
writeOKResponse(w)
}

View File

@ -0,0 +1,973 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"net/http"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
"github.com/stretchr/testify/require"
)
func setBasicCommonReviewerConfig(th *TestHelper) *model.AppError {
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id},
},
},
}
config.SetDefaults()
return th.App.SaveContentFlaggingConfig(config)
}
func TestGetFlaggingConfiguration(t *testing.T) {
th := Setup(t)
defer th.TearDown()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
status, resp, err := client.GetFlaggingConfiguration(context.Background())
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, status)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
status, resp, err := client.GetFlaggingConfiguration(context.Background())
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, status)
})
}
func TestSaveContentFlaggingSettings(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
t.Run("Should return 403 when user does not have manage system permission", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id},
},
},
}
// Use basic user who doesn't have manage system permission
th.LoginBasic()
resp, err := client.SaveContentFlaggingSettings(context.Background(), &config)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("Should return 400 when config is invalid", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
// Invalid config - missing required fields
config := model.ContentFlaggingSettingsRequest{
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
TeamAdminsAsReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{},
},
},
}
config.SetDefaults()
th.LoginSystemAdmin()
resp, err := th.SystemAdminClient.SaveContentFlaggingSettings(context.Background(), &config)
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("Should successfully save content flagging settings when user has manage system permission", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id},
},
},
}
// Use system admin who has manage system permission
resp, err := th.SystemAdminClient.SaveContentFlaggingSettings(context.Background(), &config)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
}
func TestGetContentFlaggingSettings(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
t.Run("Should return 403 when user does not have manage system permission", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
// Use basic user who doesn't have manage system permission
th.LoginBasic()
settings, resp, err := th.Client.GetContentFlaggingSettings(context.Background())
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Nil(t, settings)
})
t.Run("Should successfully get content flagging settings when user has manage system permission", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
// First save some settings
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
// Use system admin who has manage system permission
settings, resp, err := th.SystemAdminClient.GetContentFlaggingSettings(context.Background())
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, settings)
require.NotNil(t, settings.EnableContentFlagging)
require.True(t, *settings.EnableContentFlagging)
require.NotNil(t, settings.ReviewerSettings)
require.NotNil(t, settings.ReviewerSettings.CommonReviewers)
require.True(t, *settings.ReviewerSettings.CommonReviewers)
require.NotNil(t, settings.ReviewerSettings.CommonReviewerIds)
require.Contains(t, settings.ReviewerSettings.CommonReviewerIds, th.BasicUser.Id)
})
}
func TestGetPostPropertyValues(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, propertyValues)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, propertyValues)
})
t.Run("Should return 404 when post does not exist", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), model.NewId())
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Nil(t, propertyValues)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Nil(t, propertyValues)
})
t.Run("Should successfully get property values when user is a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost()
response, err := client.FlagPostForContentReview(context.Background(), post.Id, &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive content",
})
require.NoError(t, err)
require.Equal(t, http.StatusOK, response.StatusCode)
// Now get the property values
propertyValues, resp, err := client.GetPostPropertyValues(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, propertyValues)
require.Len(t, propertyValues, 6)
})
}
func TestGetFlaggedPost(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, flaggedPost)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, flaggedPost)
})
t.Run("Should return 404 when post does not exist", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), model.NewId())
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Nil(t, flaggedPost)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{}, // Empty list - user is not a reviewer
},
},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost()
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Nil(t, flaggedPost)
})
t.Run("Should return 404 when post is not flagged", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost()
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
require.Nil(t, flaggedPost)
})
t.Run("Should successfully get flagged post when user is a reviewer and post is flagged", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost()
// First flag the post
flagRequest := &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Now get the flagged post
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, flaggedPost)
require.Equal(t, post.Id, flaggedPost.Id)
})
t.Run("Should return flagged post's file info", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
data, err2 := testutils.ReadTestFile("test.png")
require.NoError(t, err2)
fileResponse, _, err := client.UploadFile(context.Background(), data, th.BasicChannel.Id, "test.png")
require.NoError(t, err)
require.Equal(t, 1, len(fileResponse.FileInfos))
fileInfo := fileResponse.FileInfos[0]
post := th.CreatePostInChannelWithFiles(th.BasicChannel, fileInfo)
// First flag the post
flagRequest := &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
flaggedPost, resp, err := client.GetContentFlaggedPost(context.Background(), post.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, 1, len(flaggedPost.Metadata.Files))
require.Equal(t, fileInfo.Id, flaggedPost.Metadata.Files[0].Id)
})
}
func TestFlagPost(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
flagRequest := &model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
flagRequest := &model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("Should return 404 when post does not exist", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
flagRequest := &model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
resp, err := client.FlagPostForContentReview(context.Background(), model.NewId(), flagRequest)
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("Should return 403 when user does not have permission to view post", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
// Create a private channel and post
privateChannel := th.CreatePrivateChannel()
post := th.CreatePostWithClient(th.Client, privateChannel)
th.RemoveUserFromChannel(th.BasicUser, privateChannel)
flagRequest := &model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("Should return 400 when content flagging is not enabled for the team", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {Enabled: model.NewPointer(false)},
},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost()
flagRequest := &model.FlagContentRequest{
Reason: "spam",
Comment: "This is spam content",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("Should successfully flag a post when all conditions are met", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost()
flagRequest := &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive data",
}
resp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
}
func TestGetTeamPostReportingFeatureStatus(t *testing.T) {
th := Setup(t)
defer th.TearDown()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
status, resp, err := client.GetTeamPostFlaggingFeatureStatus(context.Background(), model.NewId())
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, status)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
status, resp, err := client.GetTeamPostFlaggingFeatureStatus(context.Background(), model.NewId())
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, status)
})
t.Run("Should return Forbidden error when calling for a team without the team membership", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{"reviewer_user_id_1", "reviewer_user_id_2"},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
// using basic user because the default user is a system admin, and they have
// access to all teams even without being an explicit team member
th.LoginBasic()
team := th.CreateTeam()
// unlinking from the created team as by default the team's creator is
// a team member, so we need to leave the team explicitly
th.UnlinkUserFromTeam(th.BasicUser, team)
status, resp, err := client.GetTeamPostFlaggingFeatureStatus(context.Background(), team.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Nil(t, status)
// now we will join the team and that will allow us to call the endpoint without error
th.LinkUserToTeam(th.BasicUser, team)
status, resp, err = client.GetTeamPostFlaggingFeatureStatus(context.Background(), team.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.True(t, status["enabled"])
})
}
func TestSearchReviewers(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "test")
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, reviewers)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "test")
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
require.Nil(t, reviewers)
})
t.Run("Should return 403 when user is not a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{}, // Empty list - user is not a reviewer
},
},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "test")
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
require.Nil(t, reviewers)
})
t.Run("Should successfully search reviewers when user is a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "basic")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, reviewers)
})
t.Run("Should successfully search reviewers when user is a team reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{th.BasicUser.Id},
},
},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
reviewers, resp, err := client.SearchContentFlaggingReviewers(context.Background(), th.BasicTeam.Id, "basic")
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
require.NotNil(t, reviewers)
})
}
func TestAssignContentFlaggingReviewer(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
t.Run("Should return 501 when Enterprise Advanced license is not present even if feature is enabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, th.BasicUser.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("Should return 501 when feature is disabled", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(false)
config.ContentFlaggingSettings.SetDefaults()
})
post := th.CreatePost()
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, th.BasicUser.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotImplemented, resp.StatusCode)
})
t.Run("Should return 404 when post does not exist", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
th.App.UpdateConfig(func(config *model.Config) {
config.ContentFlaggingSettings.EnableContentFlagging = model.NewPointer(true)
config.ContentFlaggingSettings.SetDefaults()
})
resp, err := client.AssignContentFlaggingReviewer(context.Background(), model.NewId(), th.BasicUser.Id)
require.Error(t, err)
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("Should return 400 when user ID is invalid", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
appErr := setBasicCommonReviewerConfig(th)
require.Nil(t, appErr)
post := th.CreatePost()
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, "invalidUserId")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("Should return 403 when assigning user is not a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{}, // Empty list - user is not a reviewer
},
},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost()
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, th.BasicUser.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("Should return 400 when assignee is not a reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
// Create another user who will not be a reviewer
nonReviewerUser := th.CreateUser()
th.LinkUserToTeam(nonReviewerUser, th.BasicTeam)
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id}, // Only BasicUser is a reviewer
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost()
// Try to assign non-reviewer user
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, nonReviewerUser.Id)
require.Error(t, err)
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("Should successfully assign reviewer when all conditions are met", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
// Create another reviewer user
reviewerUser := th.CreateUser()
th.LinkUserToTeam(reviewerUser, th.BasicTeam)
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(true),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
CommonReviewerIds: []string{th.BasicUser.Id, reviewerUser.Id},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost()
// First flag the post so it can be assigned
flagRequest := &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive content",
}
flagResp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.NoError(t, err)
require.Equal(t, http.StatusOK, flagResp.StatusCode)
// Now assign the reviewer
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, reviewerUser.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("Should successfully assign reviewer when user is team reviewer", func(t *testing.T) {
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterpriseAdvanced))
defer th.RemoveLicense()
// Create another reviewer user
reviewerUser := th.CreateUser()
th.LinkUserToTeam(reviewerUser, th.BasicTeam)
config := model.ContentFlaggingSettingsRequest{
ContentFlaggingSettingsBase: model.ContentFlaggingSettingsBase{
EnableContentFlagging: model.NewPointer(true),
},
ReviewerSettings: &model.ReviewSettingsRequest{
ReviewerSettings: model.ReviewerSettings{
CommonReviewers: model.NewPointer(false),
},
ReviewerIDsSettings: model.ReviewerIDsSettings{
TeamReviewersSetting: map[string]*model.TeamReviewerSetting{
th.BasicTeam.Id: {
Enabled: model.NewPointer(true),
ReviewerIds: []string{th.BasicUser.Id, reviewerUser.Id},
},
},
},
},
}
config.SetDefaults()
appErr := th.App.SaveContentFlaggingConfig(config)
require.Nil(t, appErr)
post := th.CreatePost()
// First flag the post so it can be assigned
flagRequest := &model.FlagContentRequest{
Reason: "Sensitive data",
Comment: "This is sensitive content",
}
flagResp, err := client.FlagPostForContentReview(context.Background(), post.Id, flagRequest)
require.NoError(t, err)
require.Equal(t, http.StatusOK, flagResp.StatusCode)
// Now assign the reviewer
resp, err := client.AssignContentFlaggingReviewer(context.Background(), post.Id, reviewerUser.Id)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
})
}

155
channels/api4/cors_test.go Normal file
View File

@ -0,0 +1,155 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks"
)
const (
acAllowOrigin = "Access-Control-Allow-Origin"
acExposeHeaders = "Access-Control-Expose-Headers"
acMaxAge = "Access-Control-Max-Age"
acAllowCredentials = "Access-Control-Allow-Credentials"
acAllowMethods = "Access-Control-Allow-Methods"
acAllowHeaders = "Access-Control-Allow-Headers"
)
func TestCORSRequestHandling(t *testing.T) {
mainHelper.Parallel(t)
for name, testcase := range map[string]struct {
AllowCorsFrom string
CorsExposedHeaders string
CorsAllowCredentials bool
ModifyRequest func(req *http.Request)
ExpectedAllowOrigin string
ExpectedExposeHeaders string
ExpectedAllowCredentials string
}{
"NoCORS": {
"",
"",
false,
func(req *http.Request) {
},
"",
"",
"",
},
"CORSEnabled": {
"http://somewhere.com",
"",
false,
func(req *http.Request) {
},
"",
"",
"",
},
"CORSEnabledStarOrigin": {
"*",
"",
false,
func(req *http.Request) {
req.Header.Set("Origin", "http://pre-release.mattermost.com")
},
"*",
"",
"",
},
"CORSEnabledStarNoOrigin": { // CORS spec requires this, not a bug.
"*",
"",
false,
func(req *http.Request) {
},
"",
"",
"",
},
"CORSEnabledMatching": {
"http://mattermost.com",
"",
false,
func(req *http.Request) {
req.Header.Set("Origin", "http://mattermost.com")
},
"http://mattermost.com",
"",
"",
},
"CORSEnabledMultiple": {
"http://spinmint.com http://mattermost.com",
"",
false,
func(req *http.Request) {
req.Header.Set("Origin", "http://mattermost.com")
},
"http://mattermost.com",
"",
"",
},
"CORSEnabledWithCredentials": {
"http://mattermost.com",
"",
true,
func(req *http.Request) {
req.Header.Set("Origin", "http://mattermost.com")
},
"http://mattermost.com",
"",
"true",
},
"CORSEnabledWithHeaders": {
"http://mattermost.com",
"x-my-special-header x-blueberry",
true,
func(req *http.Request) {
req.Header.Set("Origin", "http://mattermost.com")
},
"http://mattermost.com",
"X-My-Special-Header, X-Blueberry",
"true",
},
} {
t.Run(name, func(t *testing.T) {
th := SetupConfigWithStoreMock(t, func(cfg *model.Config) {
*cfg.ServiceSettings.AllowCorsFrom = testcase.AllowCorsFrom
*cfg.ServiceSettings.CorsExposedHeaders = testcase.CorsExposedHeaders
*cfg.ServiceSettings.CorsAllowCredentials = testcase.CorsAllowCredentials
})
defer th.TearDown()
licenseStore := mocks.LicenseStore{}
licenseStore.On("Get", "").Return(&model.LicenseRecord{}, nil)
th.App.Srv().Store().(*mocks.Store).On("License").Return(&licenseStore)
port := th.App.Srv().ListenAddr.Port
host := fmt.Sprintf("http://localhost:%v", port)
url := fmt.Sprintf("%v/api/v4/system/ping", host)
req, err := http.NewRequest("GET", url, nil)
require.NoError(t, err)
testcase.ModifyRequest(req)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, testcase.ExpectedAllowOrigin, resp.Header.Get(acAllowOrigin))
assert.Equal(t, testcase.ExpectedExposeHeaders, resp.Header.Get(acExposeHeaders))
assert.Equal(t, "", resp.Header.Get(acMaxAge))
assert.Equal(t, testcase.ExpectedAllowCredentials, resp.Header.Get(acAllowCredentials))
assert.Equal(t, "", resp.Header.Get(acAllowMethods))
assert.Equal(t, "", resp.Header.Get(acAllowHeaders))
})
}
}

View File

@ -0,0 +1,375 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strings"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitCustomProfileAttributes() {
if api.srv.Config().FeatureFlags.CustomProfileAttributes {
api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APISessionRequired(listCPAFields)).Methods(http.MethodGet)
api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APISessionRequired(createCPAField)).Methods(http.MethodPost)
api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APISessionRequired(patchCPAField)).Methods(http.MethodPatch)
api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APISessionRequired(deleteCPAField)).Methods(http.MethodDelete)
api.BaseRoutes.User.Handle("/custom_profile_attributes", api.APISessionRequired(listCPAValues)).Methods(http.MethodGet)
api.BaseRoutes.CustomProfileAttributesValues.Handle("", api.APISessionRequired(patchCPAValues)).Methods(http.MethodPatch)
api.BaseRoutes.CustomProfileAttributes.Handle("/group", api.APISessionRequired(getCPAGroup)).Methods(http.MethodGet)
api.BaseRoutes.User.Handle("/custom_profile_attributes", api.APISessionRequired(patchCPAValuesForUser)).Methods(http.MethodPatch)
}
}
func listCPAFields(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
c.Err = model.NewAppError("Api4.listCPAFields", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
return
}
fields, appErr := c.App.ListCPAFields()
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(fields); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func createCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
c.Err = model.NewAppError("Api4.createCPAField", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
return
}
var pf *model.CPAField
err := json.NewDecoder(r.Body).Decode(&pf)
if err != nil || pf == nil {
c.SetInvalidParamWithErr("property_field", err)
return
}
pf.Name = strings.TrimSpace(pf.Name)
auditRec := c.MakeAuditRecord(model.AuditEventCreateCPAField, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field", pf)
createdField, appErr := c.App.CreateCPAField(pf)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(createdField)
auditRec.AddEventObjectType("property_field")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(createdField); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
c.Err = model.NewAppError("Api4.patchCPAField", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
return
}
c.RequireFieldId()
if c.Err != nil {
return
}
var patch *model.PropertyFieldPatch
err := json.NewDecoder(r.Body).Decode(&patch)
if err != nil || patch == nil {
c.SetInvalidParamWithErr("property_field_patch", err)
return
}
if patch.Name != nil {
*patch.Name = strings.TrimSpace(*patch.Name)
}
if err := patch.IsValid(); err != nil {
if appErr, ok := err.(*model.AppError); ok {
c.Err = appErr
} else {
c.Err = model.NewAppError("createCPAField", "api.custom_profile_attributes.invalid_field_patch", nil, "", http.StatusBadRequest)
}
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPatchCPAField, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "property_field_patch", patch)
originalField, appErr := c.App.GetCPAField(c.Params.FieldId)
if appErr != nil {
c.Err = appErr
return
}
auditRec.AddEventPriorState(originalField)
patchedField, appErr := c.App.PatchCPAField(c.Params.FieldId, patch)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(patchedField)
auditRec.AddEventObjectType("property_field")
if err := json.NewEncoder(w).Encode(patchedField); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteCPAField(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
c.Err = model.NewAppError("Api4.deleteCPAField", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
return
}
c.RequireFieldId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteCPAField, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "field_id", c.Params.FieldId)
field, appErr := c.App.GetCPAField(c.Params.FieldId)
if appErr != nil {
c.Err = appErr
return
}
auditRec.AddEventPriorState(field)
if appErr := c.App.DeleteCPAField(c.Params.FieldId); appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(field)
auditRec.AddEventObjectType("property_field")
ReturnStatusOK(w)
}
func getCPAGroup(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
c.Err = model.NewAppError("Api4.getCPAGroup", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
return
}
groupID, err := c.App.CpaGroupID()
if err != nil {
c.Err = model.NewAppError("Api4.getCPAGroup", "app.custom_profile_attributes.cpa_group_id.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if err := json.NewEncoder(w).Encode(map[string]string{"id": groupID}); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
c.Err = model.NewAppError("Api4.patchCPAValues", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
return
}
userID := c.AppContext.Session().UserId
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), userID) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
var updates map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
c.SetInvalidParamWithErr("value", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPatchCPAValues, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "user_id", userID)
// if the user is not an admin, we need to check that there are no
// admin-managed fields
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
fields, appErr := c.App.ListCPAFields()
if appErr != nil {
c.Err = appErr
return
}
// Check if any of the fields being updated are admin-managed
for _, field := range fields {
if _, isBeingUpdated := updates[field.ID]; isBeingUpdated {
// Convert to CPAField to check if managed
cpaField, fErr := model.NewCPAFieldFromPropertyField(field)
if fErr != nil {
c.Err = model.NewAppError("Api4.patchCPAValues", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(fErr)
return
}
if cpaField.IsAdminManaged() {
c.Err = model.NewAppError("Api4.patchCPAValues", "app.custom_profile_attributes.property_field_is_managed.app_error", nil, "", http.StatusForbidden)
return
}
}
}
}
results := make(map[string]json.RawMessage, len(updates))
for fieldID, rawValue := range updates {
patchedValue, appErr := c.App.PatchCPAValue(userID, fieldID, rawValue, false)
if appErr != nil {
c.Err = appErr
return
}
results[fieldID] = patchedValue.Value
}
auditRec.Success()
auditRec.AddEventObjectType("patchCPAValues")
if err := json.NewEncoder(w).Encode(results); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func listCPAValues(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
c.Err = model.NewAppError("Api4.listCPAValues", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
return
}
c.RequireUserId()
if c.Err != nil {
return
}
userID := c.Params.UserId
// we check unrestricted sessions to allow local mode requests to go through
if !c.AppContext.Session().IsUnrestricted() {
canSee, err := c.App.UserCanSeeOtherUser(c.AppContext, c.AppContext.Session().UserId, userID)
if err != nil || !canSee {
c.SetPermissionError(model.PermissionViewMembers)
return
}
}
values, appErr := c.App.ListCPAValues(userID)
if appErr != nil {
c.Err = appErr
return
}
returnValue := make(map[string]json.RawMessage)
for _, value := range values {
returnValue[value.FieldID] = value.Value
}
if err := json.NewEncoder(w).Encode(returnValue); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchCPAValuesForUser(c *Context, w http.ResponseWriter, r *http.Request) {
if !model.MinimumEnterpriseLicense(c.App.Channels().License()) {
c.Err = model.NewAppError("Api4.patchCPAValues", "api.custom_profile_attributes.license_error", nil, "", http.StatusForbidden)
return
}
// Get userID from URL
c.RequireUserId()
if c.Err != nil {
return
}
userID := c.Params.UserId
if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), userID) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
var updates map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
c.SetInvalidParamWithErr("value", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventPatchCPAValues, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "user_id", userID)
// if the user is not an admin, we need to check that there are no
// admin-managed fields
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
fields, appErr := c.App.ListCPAFields()
if appErr != nil {
c.Err = appErr
return
}
// Check if any of the fields being updated are admin-managed
for _, field := range fields {
if _, isBeingUpdated := updates[field.ID]; isBeingUpdated {
// Convert to CPAField to check if managed
cpaField, fErr := model.NewCPAFieldFromPropertyField(field)
if fErr != nil {
c.Err = model.NewAppError("Api4.patchCPAValues", "app.custom_profile_attributes.property_field_conversion.app_error", nil, "", http.StatusInternalServerError).Wrap(fErr)
return
}
if cpaField.IsAdminManaged() {
c.Err = model.NewAppError("Api4.patchCPAValues", "app.custom_profile_attributes.property_field_is_managed.app_error", nil, "", http.StatusForbidden)
return
}
}
}
}
results := make(map[string]json.RawMessage, len(updates))
for fieldID, rawValue := range updates {
patchedValue, appErr := c.App.PatchCPAValue(userID, fieldID, rawValue, false)
if appErr != nil {
c.Err = appErr
return
}
results[fieldID] = patchedValue.Value
}
auditRec.Success()
auditRec.AddEventObjectType("patchCPAValues")
if err := json.NewEncoder(w).Encode(results); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitCustomProfileAttributesLocal() {
if api.srv.Config().FeatureFlags.CustomProfileAttributes {
api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APILocal(listCPAFields)).Methods(http.MethodGet)
api.BaseRoutes.CustomProfileAttributesFields.Handle("", api.APILocal(createCPAField)).Methods(http.MethodPost)
api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APILocal(patchCPAField)).Methods(http.MethodPatch)
api.BaseRoutes.CustomProfileAttributesField.Handle("", api.APILocal(deleteCPAField)).Methods(http.MethodDelete)
api.BaseRoutes.User.Handle("/custom_profile_attributes", api.APILocal(listCPAValues)).Methods(http.MethodGet)
api.BaseRoutes.CustomProfileAttributesValues.Handle("", api.APILocal(patchCPAValues)).Methods(http.MethodPatch)
api.BaseRoutes.User.Handle("/custom_profile_attributes", api.APILocal(patchCPAValuesForUser)).Methods(http.MethodPatch)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,514 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitDataRetention() {
api.BaseRoutes.DataRetention.Handle("/policy", api.APISessionRequired(getGlobalPolicy)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies", api.APISessionRequired(getPolicies)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies_count", api.APISessionRequired(getPoliciesCount)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies", api.APISessionRequired(createPolicy)).Methods(http.MethodPost)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}", api.APISessionRequired(getPolicy)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}", api.APISessionRequired(patchPolicy)).Methods(http.MethodPatch)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}", api.APISessionRequired(deletePolicy)).Methods(http.MethodDelete)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/teams", api.APISessionRequired(getTeamsForPolicy)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/teams", api.APISessionRequired(addTeamsToPolicy)).Methods(http.MethodPost)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/teams", api.APISessionRequired(removeTeamsFromPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/teams/search", api.APISessionRequired(searchTeamsInPolicy)).Methods(http.MethodPost)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/channels", api.APISessionRequired(getChannelsForPolicy)).Methods(http.MethodGet)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/channels", api.APISessionRequired(addChannelsToPolicy)).Methods(http.MethodPost)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/channels", api.APISessionRequired(removeChannelsFromPolicy)).Methods(http.MethodDelete)
api.BaseRoutes.DataRetention.Handle("/policies/{policy_id:[A-Za-z0-9]+}/channels/search", api.APISessionRequired(searchChannelsInPolicy)).Methods(http.MethodPost)
api.BaseRoutes.User.Handle("/data_retention/team_policies", api.APISessionRequired(getTeamPoliciesForUser)).Methods(http.MethodGet)
api.BaseRoutes.User.Handle("/data_retention/channel_policies", api.APISessionRequired(getChannelPoliciesForUser)).Methods(http.MethodGet)
}
func getGlobalPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
// No permission check required.
policy, appErr := c.App.GetGlobalRetentionPolicy()
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(policy)
if err != nil {
c.Err = model.NewAppError("getGlobalPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getPolicies(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
limit := c.Params.PerPage
offset := c.Params.Page * limit
policies, appErr := c.App.GetRetentionPolicies(offset, limit)
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(policies)
if err != nil {
c.Err = model.NewAppError("getPolicies", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getPoliciesCount(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
count, appErr := c.App.GetRetentionPoliciesCount()
if appErr != nil {
c.Err = appErr
return
}
body := struct {
TotalCount int64 `json:"total_count"`
}{count}
err := json.NewEncoder(w).Encode(body)
if err != nil {
c.Logger.Warn("Error writing response", mlog.Err(err))
}
}
func getPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
c.RequirePolicyId()
policy, appErr := c.App.GetRetentionPolicy(c.Params.PolicyId)
if appErr != nil {
c.Err = appErr
return
}
js, err := json.Marshal(policy)
if err != nil {
c.Err = model.NewAppError("getPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func createPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
var policy model.RetentionPolicyWithTeamAndChannelIDs
if jsonErr := json.NewDecoder(r.Body).Decode(&policy); jsonErr != nil {
c.SetInvalidParamWithErr("policy", jsonErr)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreatePolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "policy", &policy)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
newPolicy, appErr := c.App.CreateRetentionPolicy(&policy)
if appErr != nil {
c.Err = appErr
return
}
auditRec.AddEventResultState(newPolicy)
auditRec.AddEventObjectType("policy")
js, err := json.Marshal(newPolicy)
if err != nil {
c.Err = model.NewAppError("createPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
w.WriteHeader(http.StatusCreated)
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
var patch model.RetentionPolicyWithTeamAndChannelIDs
if jsonErr := json.NewDecoder(r.Body).Decode(&patch); jsonErr != nil {
c.SetInvalidParamWithErr("policy", jsonErr)
return
}
c.RequirePolicyId()
patch.ID = c.Params.PolicyId
auditRec := c.MakeAuditRecord(model.AuditEventPatchPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "patch", &patch)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
policy, appErr := c.App.PatchRetentionPolicy(&patch)
if appErr != nil {
c.Err = appErr
return
}
auditRec.AddEventResultState(policy)
auditRec.AddEventObjectType("retention_policy")
js, err := json.Marshal(policy)
if err != nil {
c.Err = model.NewAppError("patchPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deletePolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
policyId := c.Params.PolicyId
auditRec := c.MakeAuditRecord(model.AuditEventDeletePolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "policy_id", policyId)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
err := c.App.DeleteRetentionPolicy(policyId)
if err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func getTeamsForPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
c.RequirePolicyId()
policyId := c.Params.PolicyId
limit := c.Params.PerPage
offset := c.Params.Page * limit
teams, appErr := c.App.GetTeamsForRetentionPolicy(policyId, offset, limit)
if appErr != nil {
c.Err = appErr
return
}
b, err := json.Marshal(teams)
if err != nil {
c.Err = model.NewAppError("Api4.getTeamsForPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func searchTeamsInPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
var props model.TeamSearch
if err := json.NewDecoder(r.Body).Decode(&props); err != nil {
c.SetInvalidParamWithErr("team_search", err)
return
}
props.PolicyID = model.NewPointer(c.Params.PolicyId)
props.IncludePolicyID = model.NewPointer(true)
teams, _, appErr := c.App.SearchAllTeams(&props)
if appErr != nil {
c.Err = appErr
return
}
c.App.SanitizeTeams(*c.AppContext.Session(), teams)
js, err := json.Marshal(teams)
if err != nil {
c.Err = model.NewAppError("searchTeamsInPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func addTeamsToPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
policyId := c.Params.PolicyId
teamIDs, err := model.SortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("addTeamsToPolicy", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAddTeamsToPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "policy_id", policyId)
model.AddEventParameterToAuditRec(auditRec, "team_ids", teamIDs)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
appErr := c.App.AddTeamsToRetentionPolicy(policyId, teamIDs)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func removeTeamsFromPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
policyId := c.Params.PolicyId
teamIDs, err := model.SortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("removeTeamsFromPolicy", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRemoveTeamsFromPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "policy_id", policyId)
model.AddEventParameterToAuditRec(auditRec, "team_ids", teamIDs)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
appErr := c.App.RemoveTeamsFromRetentionPolicy(policyId, teamIDs)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func getChannelsForPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
c.RequirePolicyId()
policyId := c.Params.PolicyId
limit := c.Params.PerPage
offset := c.Params.Page * limit
channels, appErr := c.App.GetChannelsForRetentionPolicy(policyId, offset, limit)
if appErr != nil {
c.Err = appErr
return
}
b, err := json.Marshal(channels)
if err != nil {
c.Err = model.NewAppError("Api4.getChannelsForPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(b); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func searchChannelsInPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
var props *model.ChannelSearch
err := json.NewDecoder(r.Body).Decode(&props)
if err != nil || props == nil {
c.SetInvalidParamWithErr("channel_search", err)
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleReadComplianceDataRetentionPolicy)
return
}
opts := model.ChannelSearchOpts{
PolicyID: c.Params.PolicyId,
IncludePolicyID: true,
Deleted: props.Deleted,
IncludeDeleted: props.IncludeDeleted,
Public: props.Public,
Private: props.Private,
TeamIds: props.TeamIds,
}
channels, _, appErr := c.App.SearchAllChannels(c.AppContext, props.Term, opts)
if appErr != nil {
c.Err = appErr
return
}
channelsJSON, jsonErr := json.Marshal(channels)
if jsonErr != nil {
c.Err = model.NewAppError("searchChannelsInPolicy", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
if _, err := w.Write(channelsJSON); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func addChannelsToPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
policyId := c.Params.PolicyId
channelIDs, err := model.SortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("addChannelsToPolicy", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventAddChannelsToPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "policy_id", policyId)
model.AddEventParameterToAuditRec(auditRec, "channel_ids", channelIDs)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
appErr := c.App.AddChannelsToRetentionPolicy(policyId, channelIDs)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func removeChannelsFromPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequirePolicyId()
policyId := c.Params.PolicyId
channelIDs, err := model.SortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("removeChannelsFromPolicy", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRemoveChannelsFromPolicy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "policy_id", policyId)
model.AddEventParameterToAuditRec(auditRec, "channel_ids", channelIDs)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteComplianceDataRetentionPolicy) {
c.SetPermissionError(model.PermissionSysconsoleWriteComplianceDataRetentionPolicy)
return
}
appErr := c.App.RemoveChannelsFromRetentionPolicy(policyId, channelIDs)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func getTeamPoliciesForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId()
if c.Err != nil {
return
}
userID := c.Params.UserId
limit := c.Params.PerPage
offset := c.Params.Page * limit
if userID != c.AppContext.Session().UserId && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
policies, err := c.App.GetTeamPoliciesForUser(userID, offset, limit)
if err != nil {
c.Err = err
return
}
js, jsonErr := json.Marshal(policies)
if jsonErr != nil {
c.Err = model.NewAppError("getTeamPoliciesForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getChannelPoliciesForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId()
if c.Err != nil {
return
}
userID := c.Params.UserId
limit := c.Params.PerPage
offset := c.Params.Page * limit
if userID != c.AppContext.Session().UserId && !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
c.SetPermissionError(model.PermissionManageSystem)
return
}
policies, err := c.App.GetChannelPoliciesForUser(userID, offset, limit)
if err != nil {
c.Err = err
return
}
js, jsonErr := json.Marshal(policies)
if jsonErr != nil {
c.Err = model.NewAppError("getChannelPoliciesForUser", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr)
return
}
if _, err := w.Write(js); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

File diff suppressed because it is too large Load Diff

56
channels/api4/doc.go Normal file
View File

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/*
Package api4 implements the HTTP REST API layer for Mattermost server.
This package provides the primary interface between client applications
(web, mobile, desktop) and the Mattermost server backend. It exposes
HTTP endpoints that follow REST conventions for managing users, teams,
channels, posts, and other Mattermost resources.
# Architecture
The API is structured around resource-based endpoints under the /api/v4/ path.
Each endpoint is handled by specific handler functions that provide different
levels of authentication and authorization:
- APIHandler: Public endpoints requiring no authentication
- APISessionRequired: Endpoints requiring authenticated user sessions
- APISessionRequiredTrustRequester: Authenticated endpoints for trusted requests
- CloudAPIKeyRequired: Cloud installation webhook endpoints
- RemoteClusterTokenRequired: Remote cluster communication endpoints
- APILocal: Local mode access via UNIX socket
# Key Responsibilities
- Input validation: Validate request parameters and body content
- Permission checks: Verify user has required permissions for the operation
- HTTP handling: Parse requests, format responses, set appropriate status codes
- Error formatting: Convert app layer errors to appropriate HTTP responses
- Audit logging: Log security-relevant operations
# Error Handling
The API uses consistent error responses with appropriate HTTP status codes.
All handlers use the Context object for standardized error reporting and
audit logging. Errors are returned in a structured JSON format with
error codes, messages, and additional context when available.
# Security
Security is implemented through multiple layers:
- Authentication via sessions, tokens, or API keys
- Role-based access control and permission checking
- CSRF protection through request validation
- Rate limiting to prevent abuse
- Multi-factor authentication support
- Secure session management
The api4 package serves as the HTTP interface layer in Mattermost's
layered architecture, providing a stable, versioned API for client
applications while maintaining clear separation from business logic
and data persistence concerns.
*/
package api4

144
channels/api4/drafts.go Normal file
View File

@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitDrafts() {
api.BaseRoutes.Drafts.Handle("", api.APISessionRequired(upsertDraft)).Methods(http.MethodPost)
api.BaseRoutes.TeamForUser.Handle("/drafts", api.APISessionRequired(getDrafts)).Methods(http.MethodGet)
api.BaseRoutes.ChannelForUser.Handle("/drafts/{thread_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteDraft)).Methods(http.MethodDelete)
api.BaseRoutes.ChannelForUser.Handle("/drafts", api.APISessionRequired(deleteDraft)).Methods(http.MethodDelete)
}
func upsertDraft(c *Context, w http.ResponseWriter, r *http.Request) {
if !*c.App.Config().ServiceSettings.AllowSyncedDrafts {
c.Err = model.NewAppError("upsertDraft", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
var draft model.Draft
if jsonErr := json.NewDecoder(r.Body).Decode(&draft); jsonErr != nil {
c.SetInvalidParam("draft")
return
}
draft.DeleteAt = 0
draft.UserId = c.AppContext.Session().UserId
connectionID := r.Header.Get(model.ConnectionId)
hasPermission := false
if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), draft.ChannelId, model.PermissionCreatePost) {
hasPermission = true
} else if channel, err := c.App.GetChannel(c.AppContext, draft.ChannelId); err == nil {
// Temporary permission check method until advanced permissions, please do not copy
if channel.Type == model.ChannelTypeOpen && c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePostPublic) {
hasPermission = true
}
}
if !hasPermission {
c.SetPermissionError(model.PermissionCreatePost)
return
}
dt, err := c.App.UpsertDraft(c.AppContext, &draft, connectionID)
if err != nil {
c.Err = err
return
}
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(dt); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getDrafts(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
if !*c.App.Config().ServiceSettings.AllowSyncedDrafts {
c.Err = model.NewAppError("getDrafts", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
hasPermission := false
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
hasPermission = true
}
if !hasPermission {
c.SetPermissionError(model.PermissionCreatePost)
return
}
drafts, err := c.App.GetDraftsForUser(c.AppContext, c.AppContext.Session().UserId, c.Params.TeamId)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(drafts); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteDraft(c *Context, w http.ResponseWriter, r *http.Request) {
if c.Err != nil {
return
}
if !*c.App.Config().ServiceSettings.AllowSyncedDrafts {
c.Err = model.NewAppError("deleteDraft", "api.drafts.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
rootID := ""
connectionID := r.Header.Get(model.ConnectionId)
if c.Params.ThreadId != "" {
rootID = c.Params.ThreadId
}
userID := c.AppContext.Session().UserId
channelID := c.Params.ChannelId
draft, err := c.App.GetDraft(userID, channelID, rootID)
if err != nil {
switch {
case err.StatusCode == http.StatusNotFound:
// If the draft doesn't exist in the server, we don't need to delete.
ReturnStatusOK(w)
default:
c.Err = err
}
return
}
if c.AppContext.Session().UserId != draft.UserId {
c.SetPermissionError(model.PermissionDeletePost)
return
}
if err := c.App.DeleteDraft(c.AppContext, draft, connectionID); err != nil {
c.Err = err
return
}
ReturnStatusOK(w)
}

View File

@ -0,0 +1,214 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
)
func TestUpsertDraft(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// set config
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
client := th.Client
channel := th.BasicChannel
user := th.BasicUser
draft := &model.Draft{
CreateAt: 12345,
UpdateAt: 12345,
UserId: user.Id,
ChannelId: channel.Id,
Message: "original",
}
// try to upsert draft
draftResp, _, err := client.UpsertDraft(context.Background(), draft)
require.NoError(t, err)
assert.Equal(t, draft.UserId, draftResp.UserId)
assert.Equal(t, draft.Message, draftResp.Message)
assert.Equal(t, draft.ChannelId, draftResp.ChannelId)
// upload file
sent, err := testutils.ReadTestFile("test.png")
require.NoError(t, err)
fileResp, _, err := client.UploadFile(context.Background(), sent, channel.Id, "test.png")
require.NoError(t, err)
draftWithFiles := draft
draftWithFiles.FileIds = []string{fileResp.FileInfos[0].Id}
// try to upsert draft with file
draftResp, _, err = client.UpsertDraft(context.Background(), draftWithFiles)
require.NoError(t, err)
assert.Equal(t, draftWithFiles.UserId, draftResp.UserId)
assert.Equal(t, draftWithFiles.Message, draftResp.Message)
assert.Equal(t, draftWithFiles.ChannelId, draftResp.ChannelId)
assert.ElementsMatch(t, draftWithFiles.FileIds, draftResp.FileIds)
// try to upsert draft for invalid channel
draftInvalidChannel := draft
draftInvalidChannel.ChannelId = "12345"
_, resp, err := client.UpsertDraft(context.Background(), draft)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// try to upsert draft without config setting set to true
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false })
_, resp, err = client.UpsertDraft(context.Background(), draft)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
}
func TestGetDrafts(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
client := th.Client
channel1 := th.BasicChannel
channel2 := th.BasicChannel2
user := th.BasicUser
team := th.BasicTeam
draft1 := &model.Draft{
CreateAt: 00001,
UserId: user.Id,
ChannelId: channel1.Id,
Message: "draft1",
}
draft2 := &model.Draft{
CreateAt: 11111,
UserId: user.Id,
ChannelId: channel2.Id,
Message: "draft2",
}
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
// upsert draft1
_, _, err := client.UpsertDraft(context.Background(), draft1)
require.NoError(t, err)
// Wait a bit so the second draft gets a newer UpdateAt
time.Sleep(100 * time.Millisecond)
// upsert draft2
_, _, err = client.UpsertDraft(context.Background(), draft2)
require.NoError(t, err)
// try to get drafts
draftResp, _, err := client.GetDrafts(context.Background(), user.Id, team.Id)
require.NoError(t, err)
assert.Equal(t, draft2.UserId, draftResp[0].UserId)
assert.Equal(t, draft2.Message, draftResp[0].Message)
assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId)
assert.Equal(t, draft1.UserId, draftResp[1].UserId)
assert.Equal(t, draft1.Message, draftResp[1].Message)
assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId)
assert.Len(t, draftResp, 2)
// try to get drafts on invalid team
_, resp, err := client.GetDrafts(context.Background(), user.Id, "12345")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// try to get drafts when config is turned off
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = false })
_, resp, err = client.GetDrafts(context.Background(), user.Id, team.Id)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
}
func TestDeleteDraft(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
client := th.Client
channel1 := th.BasicChannel
channel2 := th.BasicChannel2
user := th.BasicUser
team := th.BasicTeam
draft1 := &model.Draft{
CreateAt: 00001,
UserId: user.Id,
ChannelId: channel1.Id,
Message: "draft1",
RootId: "",
}
draft2 := &model.Draft{
CreateAt: 11111,
UserId: user.Id,
ChannelId: channel2.Id,
Message: "draft2",
RootId: model.NewId(),
}
// upsert draft1
_, _, err := client.UpsertDraft(context.Background(), draft1)
require.NoError(t, err)
// Wait a bit so the second draft gets a newer UpdateAt
time.Sleep(100 * time.Millisecond)
// upsert draft2
_, _, err = client.UpsertDraft(context.Background(), draft2)
require.NoError(t, err)
// get drafts
draftResp, _, err := client.GetDrafts(context.Background(), user.Id, team.Id)
require.NoError(t, err)
assert.Equal(t, draft2.UserId, draftResp[0].UserId)
assert.Equal(t, draft2.Message, draftResp[0].Message)
assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId)
assert.Equal(t, draft1.UserId, draftResp[1].UserId)
assert.Equal(t, draft1.Message, draftResp[1].Message)
assert.Equal(t, draft1.ChannelId, draftResp[1].ChannelId)
// try to delete draft1
_, _, err = client.DeleteDraft(context.Background(), user.Id, channel1.Id, draft1.RootId)
require.NoError(t, err)
// get drafts
draftResp, _, err = client.GetDrafts(context.Background(), user.Id, team.Id)
require.NoError(t, err)
assert.Equal(t, draft2.UserId, draftResp[0].UserId)
assert.Equal(t, draft2.Message, draftResp[0].Message)
assert.Equal(t, draft2.ChannelId, draftResp[0].ChannelId)
assert.Len(t, draftResp, 1)
}

View File

@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitElasticsearch() {
api.BaseRoutes.Elasticsearch.Handle("/test", api.APISessionRequired(testElasticsearch)).Methods(http.MethodPost)
api.BaseRoutes.Elasticsearch.Handle("/purge_indexes", api.APISessionRequired(purgeElasticsearchIndexes)).Methods(http.MethodPost)
}
func testElasticsearch(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)
if err != nil {
c.Logger.Warn("Error decoding config.", mlog.Err(err))
}
if cfg == nil {
cfg = c.App.Config()
}
// we set BulkIndexingTimeWindowSeconds to a random value to avoid failing on the nil check
// TODO: remove this hack once we remove BulkIndexingTimeWindowSeconds from the config.
if cfg.ElasticsearchSettings.BulkIndexingTimeWindowSeconds == nil {
cfg.ElasticsearchSettings.BulkIndexingTimeWindowSeconds = model.NewPointer(0)
}
if checkHasNilFields(&cfg.ElasticsearchSettings) {
c.Err = model.NewAppError("testElasticsearch", "api.elasticsearch.test_elasticsearch_settings_nil.app_error", nil, "", http.StatusBadRequest)
return
}
// PERMISSION_TEST_ELASTICSEARCH is an ancillary permission of PERMISSION_SYSCONSOLE_WRITE_ENVIRONMENT_ELASTICSEARCH,
// which should prevent read-only managers from password sniffing
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionTestElasticsearch) {
c.SetPermissionError(model.PermissionTestElasticsearch)
return
}
if err := c.App.TestElasticsearch(c.AppContext, cfg); err != nil {
c.Err = err
return
}
ReturnStatusOK(w)
}
func purgeElasticsearchIndexes(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventPurgeElasticsearchIndexes, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
if !c.App.SessionHasPermissionToAndNotRestrictedAdmin(*c.AppContext.Session(), model.PermissionPurgeElasticsearchIndexes) {
c.SetPermissionError(model.PermissionPurgeElasticsearchIndexes)
return
}
specifiedIndexesQuery := r.URL.Query()["index"]
if err := c.App.PurgeElasticsearchIndexes(c.AppContext, specifiedIndexesQuery); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}

View File

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
func TestElasticsearchTest(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
t.Run("as system user", func(t *testing.T) {
resp, err := th.Client.TestElasticsearch(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("as system admin", func(t *testing.T) {
resp, err := th.SystemAdminClient.TestElasticsearch(context.Background())
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("invalid config", func(t *testing.T) {
cfg := &model.Config{}
cfg.SetDefaults()
cfg.ElasticsearchSettings.Password = nil
data, err := json.Marshal(cfg)
require.NoError(t, err)
resp, err := th.SystemAdminClient.DoAPIPost(context.Background(), "/elasticsearch/test", string(data))
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("as restricted system admin", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) {
cfg.ElasticsearchSettings.SetDefaults()
*cfg.ExperimentalSettings.RestrictSystemAdmin = true
})
resp, err := th.SystemAdminClient.TestElasticsearch(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}
func TestElasticsearchPurgeIndexes(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
t.Run("as system user", func(t *testing.T) {
resp, err := th.Client.PurgeElasticsearchIndexes(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
t.Run("as system admin", func(t *testing.T) {
resp, err := th.SystemAdminClient.PurgeElasticsearchIndexes(context.Background())
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
})
t.Run("as restricted system admin", func(t *testing.T) {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExperimentalSettings.RestrictSystemAdmin = true })
resp, err := th.SystemAdminClient.PurgeElasticsearchIndexes(context.Background())
require.Error(t, err)
CheckForbiddenStatus(t, resp)
})
}

342
channels/api4/emoji.go Normal file
View File

@ -0,0 +1,342 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"io"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/web"
)
const (
EmojiMaxAutocompleteItems = 100
GetEmojisByNamesMax = 200
)
func (api *API) InitEmoji() {
api.BaseRoutes.Emojis.Handle("", api.APISessionRequired(createEmoji, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.Emojis.Handle("", api.APISessionRequired(getEmojiList)).Methods(http.MethodGet)
api.BaseRoutes.Emojis.Handle("/names", api.APISessionRequired(getEmojisByNames)).Methods(http.MethodPost)
api.BaseRoutes.Emojis.Handle("/search", api.APISessionRequired(searchEmojis)).Methods(http.MethodPost)
api.BaseRoutes.Emojis.Handle("/autocomplete", api.APISessionRequired(autocompleteEmojis)).Methods(http.MethodGet)
api.BaseRoutes.Emoji.Handle("", api.APISessionRequired(deleteEmoji)).Methods(http.MethodDelete)
api.BaseRoutes.Emoji.Handle("", api.APISessionRequired(getEmoji)).Methods(http.MethodGet)
api.BaseRoutes.EmojiByName.Handle("", api.APISessionRequired(getEmojiByName)).Methods(http.MethodGet)
api.BaseRoutes.Emoji.Handle("/image", api.APISessionRequiredTrustRequester(getEmojiImage)).Methods(http.MethodGet)
}
func createEmoji(c *Context, w http.ResponseWriter, r *http.Request) {
defer func() {
if _, err := io.Copy(io.Discard, r.Body); err != nil {
c.Logger.Warn("Error while discarding request body", mlog.Err(err))
}
}()
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("createEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
if r.ContentLength > app.MaxEmojiFileSize {
c.Err = model.NewAppError("createEmoji", "api.emoji.create.too_large.app_error", nil, "", http.StatusRequestEntityTooLarge)
return
}
if err := r.ParseMultipartForm(app.MaxEmojiFileSize); err != nil {
c.Err = model.NewAppError("createEmoji", "api.emoji.create.parse.app_error", nil, "", http.StatusBadRequest).Wrap(err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateEmoji, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
// Allow any user with CREATE_EMOJIS permission at Team level to create emojis at system level
memberships, err := c.App.GetTeamMembersForUser(c.AppContext, c.AppContext.Session().UserId, "", true)
if err != nil {
c.Err = err
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreateEmojis) {
hasPermission := false
for _, membership := range memberships {
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), membership.TeamId, model.PermissionCreateEmojis) {
hasPermission = true
break
}
}
if !hasPermission {
c.SetPermissionError(model.PermissionCreateEmojis)
return
}
}
m := r.MultipartForm
props := m.Value
if len(props["emoji"]) == 0 {
c.SetInvalidParam("emoji")
return
}
var emoji model.Emoji
if jsonErr := json.Unmarshal([]byte(props["emoji"][0]), &emoji); jsonErr != nil {
c.SetInvalidParam("emoji")
return
}
auditRec.AddEventResultState(&emoji)
auditRec.AddEventObjectType("emoji")
newEmoji, err := c.App.CreateEmoji(c.AppContext, c.AppContext.Session().UserId, &emoji, m)
if err != nil {
c.Err = err
return
}
auditRec.Success()
if err := json.NewEncoder(w).Encode(newEmoji); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getEmojiList(c *Context, w http.ResponseWriter, r *http.Request) {
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("getEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
sort := r.URL.Query().Get("sort")
if sort != "" && sort != model.EmojiSortByName {
c.SetInvalidURLParam("sort")
return
}
listEmoji, err := c.App.GetEmojiList(c.AppContext, c.Params.Page, c.Params.PerPage, sort)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(listEmoji); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteEmoji(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireEmojiId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteEmoji, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
emoji, err := c.App.GetEmoji(c.AppContext, c.Params.EmojiId)
if err != nil {
model.AddEventParameterToAuditRec(auditRec, "emoji_id", c.Params.EmojiId)
c.Err = err
return
}
auditRec.AddEventPriorState(emoji)
auditRec.AddEventObjectType("emoji")
// Allow any user with DELETE_EMOJIS permission at Team level to delete emojis at system level
memberships, err := c.App.GetTeamMembersForUser(c.AppContext, c.AppContext.Session().UserId, "", true)
if err != nil {
c.Err = err
return
}
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionDeleteEmojis) {
hasPermission := false
for _, membership := range memberships {
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), membership.TeamId, model.PermissionDeleteEmojis) {
hasPermission = true
break
}
}
if !hasPermission {
c.SetPermissionError(model.PermissionDeleteEmojis)
return
}
}
if c.AppContext.Session().UserId != emoji.CreatorId {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionDeleteOthersEmojis) {
hasPermission := false
for _, membership := range memberships {
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), membership.TeamId, model.PermissionDeleteOthersEmojis) {
hasPermission = true
break
}
}
if !hasPermission {
c.SetPermissionError(model.PermissionDeleteOthersEmojis)
return
}
}
}
err = c.App.DeleteEmoji(c.AppContext, emoji)
if err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func getEmoji(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireEmojiId()
if c.Err != nil {
return
}
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("getEmoji", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
emoji, err := c.App.GetEmoji(c.AppContext, c.Params.EmojiId)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(emoji); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getEmojiByName(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireEmojiName()
if c.Err != nil {
return
}
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("getEmojiByName", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
emoji, err := c.App.GetEmojiByName(c.AppContext, c.Params.EmojiName)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(emoji); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getEmojisByNames(c *Context, w http.ResponseWriter, r *http.Request) {
names, err := model.SortedArrayFromJSON(r.Body)
if err != nil {
c.Err = model.NewAppError("getEmojisByNames", model.PayloadParseError, nil, "", http.StatusBadRequest).Wrap(err)
return
} else if len(names) == 0 {
c.SetInvalidParam("names")
return
}
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("getEmojisByNames", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
if len(names) > GetEmojisByNamesMax {
c.Err = model.NewAppError("getEmojisByNames", "api.emoji.get_multiple_by_name_too_many.request_error", map[string]any{
"MaxNames": GetEmojisByNamesMax,
}, "", http.StatusBadRequest)
return
}
emojis, appErr := c.App.GetMultipleEmojiByName(c.AppContext, names)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(emojis); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getEmojiImage(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireEmojiId()
if c.Err != nil {
return
}
if !*c.App.Config().ServiceSettings.EnableCustomEmoji {
c.Err = model.NewAppError("getEmojiImage", "api.emoji.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
image, imageType, err := c.App.GetEmojiImage(c.AppContext, c.Params.EmojiId)
if err != nil {
c.Err = err
return
}
w.Header().Set("Content-Type", "image/"+imageType)
w.Header().Set("Cache-Control", "max-age=2592000, private")
if _, err := w.Write(image); err != nil {
c.Logger.Warn("Error while writing image response", mlog.Err(err))
}
}
func searchEmojis(c *Context, w http.ResponseWriter, r *http.Request) {
var emojiSearch model.EmojiSearch
if jsonErr := json.NewDecoder(r.Body).Decode(&emojiSearch); jsonErr != nil {
c.SetInvalidParamWithErr("term", jsonErr)
return
}
if emojiSearch.Term == "" {
c.SetInvalidParam("term")
return
}
emojis, err := c.App.SearchEmoji(c.AppContext, emojiSearch.Term, emojiSearch.PrefixOnly, web.PerPageMaximum)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(emojis); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func autocompleteEmojis(c *Context, w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
c.SetInvalidURLParam("name")
return
}
emojis, err := c.App.SearchEmoji(c.AppContext, name, true, EmojiMaxAutocompleteItems)
if err != nil {
c.Err = err
return
}
if err := json.NewEncoder(w).Encode(emojis); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

891
channels/api4/emoji_test.go Normal file
View File

@ -0,0 +1,891 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"context"
"image"
_ "image/gif"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
)
func TestCreateEmoji(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
EnableCustomEmoji := *th.App.Config().ServiceSettings.EnableCustomEmoji
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = false })
defaultRolePermissions := th.SaveDefaultRolePermissions()
defer func() {
th.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
// constants to be used along with checkEmojiFile
emojiWidth := app.MaxEmojiWidth
emojiHeight := app.MaxEmojiHeight * 2
// check that emoji gets resized correctly, respecting proportions, and is of expected type
checkEmojiFile := func(id, expectedImageType string) {
path := *th.App.Config().FileSettings.Directory
file, fileErr := os.Open(filepath.Join(path, "/emoji/"+id+"/image"))
require.NoError(t, fileErr)
defer file.Close()
config, imageType, err := image.DecodeConfig(file)
require.NoError(t, err)
require.Equal(t, expectedImageType, imageType)
require.Equal(t, emojiWidth/2, config.Width)
require.Equal(t, emojiHeight/2, config.Height)
}
emoji := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
// try to create an emoji when they're disabled
_, resp, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
// enable emoji creation for next cases
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
// try to create a valid gif emoji when they're enabled
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, emojiWidth, emojiHeight), "image.gif")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
checkEmojiFile(newEmoji.Id, "gif")
// try to create an emoji with a duplicate name
emoji2 := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: newEmoji.Name,
}
_, resp, err = client.CreateEmoji(context.Background(), emoji2, utils.CreateTestGif(t, 10, 10), "image.gif")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.emoji.create.duplicate.app_error")
// try to create a valid animated gif emoji
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestAnimatedGif(t, emojiWidth, emojiHeight, 10), "image.gif")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
checkEmojiFile(newEmoji.Id, "gif")
// try to create a valid webp emoji
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
path, _ := fileutils.FindDir("tests")
bytes, err := os.ReadFile(filepath.Join(path, "testwebp.webp"))
require.NoError(t, err)
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, bytes, "image.webp")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
checkEmojiFile(newEmoji.Id, "png") // emoji must be converted from webp to png
// try to create a valid jpeg emoji
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestJpeg(t, emojiWidth, emojiHeight), "image.jpeg")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
checkEmojiFile(newEmoji.Id, "png") // emoji must be converted from jpeg to png
// try to create a valid png emoji
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestPng(t, emojiWidth, emojiHeight), "image.png")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
checkEmojiFile(newEmoji.Id, "png")
// try to create an emoji that's too wide
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 1000, 10), "image.gif")
require.NoError(t, err)
require.Equal(t, newEmoji.Name, emoji.Name, "create with wrong name")
// try to create an emoji that's too wide
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, app.MaxEmojiOriginalWidth+1), "image.gif")
require.Error(t, err, "should fail - emoji is too wide")
// try to create an emoji that's too tall
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, app.MaxEmojiOriginalHeight+1, 10), "image.gif")
require.Error(t, err, "should fail - emoji is too tall")
// try to create an emoji that's too large
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestAnimatedGif(t, 100, 100, 10000), "image.gif")
require.Error(t, err, "should fail - emoji is too big")
// try to create an emoji with data that isn't an image
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, resp, err = client.CreateEmoji(context.Background(), emoji, make([]byte, 100), "image.gif")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
CheckErrorID(t, err, "api.emoji.upload.image.app_error")
// try to create an emoji as another user
emoji = &model.Emoji{
CreatorId: th.BasicUser2.Id,
Name: model.NewId(),
}
_, resp, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// try to create an emoji without permissions
th.RemovePermissionFromRole(model.PermissionCreateEmojis.Id, model.SystemUserRoleId)
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, resp, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
// create an emoji with permissions in one team
th.AddPermissionToRole(model.PermissionCreateEmojis.Id, model.TeamUserRoleId)
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
_, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
}
func TestGetEmojiList(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
EnableCustomEmoji := *th.App.Config().ServiceSettings.EnableCustomEmoji
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
t.Run("should return an empty array when there are no custom emoijs", func(t *testing.T) {
listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 100)
require.NoError(t, err)
require.NotEqual(t, nil, listEmoji)
require.Equal(t, []*model.Emoji{}, listEmoji)
})
emojis := []*model.Emoji{
{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
},
{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
},
{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
},
}
t.Run("should return an array of emojis", func(t *testing.T) {
for idx, emoji := range emojis {
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
emojis[idx] = newEmoji
}
listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 100)
require.NoError(t, err)
for _, emoji := range emojis {
found := false
for _, savedEmoji := range listEmoji {
if emoji.Id == savedEmoji.Id {
found = true
break
}
}
require.Truef(t, found, "failed to get emoji with id %v, %v", emoji.Id, len(listEmoji))
}
})
t.Run("should return an empty array when past the maximum page count", func(t *testing.T) {
listEmoji, _, err := client.GetEmojiList(context.Background(), 1, 100)
require.NoError(t, err)
require.NotEqual(t, nil, listEmoji)
require.Equal(t, []*model.Emoji{}, listEmoji)
})
t.Run("should not return a deleted emoji", func(t *testing.T) {
_, err := client.DeleteEmoji(context.Background(), emojis[0].Id)
require.NoError(t, err)
listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 100)
require.NoError(t, err)
found := false
for _, savedEmoji := range listEmoji {
if savedEmoji.Id == emojis[0].Id {
found = true
break
}
}
require.Falsef(t, found, "should not get a deleted emoji %v", emojis[0].Id)
})
t.Run("should return fewer results based on the provided page size", func(t *testing.T) {
listEmoji, _, err := client.GetEmojiList(context.Background(), 0, 1)
require.NoError(t, err)
require.Len(t, listEmoji, 1, "should only return 1")
})
t.Run("should return a sorted emoji list", func(t *testing.T) {
listEmoji, _, err := client.GetSortedEmojiList(context.Background(), 0, 100, model.EmojiSortByName)
require.NoError(t, err)
require.Greater(t, len(listEmoji), 0, "should return more than 0")
})
}
func TestGetEmojisByNames(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
// Set up some custom emojis
adminClient := th.SystemAdminClient
imageBytes := utils.CreateTestJpeg(t, 10, 10)
emoji1 := &model.Emoji{
CreatorId: th.SystemAdminUser.Id,
Name: model.NewId(),
}
emoji1, _, err := adminClient.CreateEmoji(context.Background(), emoji1, imageBytes, "emoji.jpg")
require.NoError(t, err)
emoji2 := &model.Emoji{
CreatorId: th.SystemAdminUser.Id,
Name: model.NewId(),
}
emoji2, _, err = adminClient.CreateEmoji(context.Background(), emoji2, imageBytes, "emoji.jpg")
require.NoError(t, err)
client := th.Client
t.Run("should return a single emoji", func(t *testing.T) {
emojis, _, err := client.GetEmojisByNames(context.Background(), []string{emoji1.Name})
require.NoError(t, err)
require.Len(t, emojis, 1)
assert.Equal(t, emoji1.Id, emojis[0].Id)
})
t.Run("should return multiple emojis", func(t *testing.T) {
emojis, _, err := client.GetEmojisByNames(context.Background(), []string{emoji1.Name, emoji2.Name})
var emojiIds []string
for _, emoji := range emojis {
emojiIds = append(emojiIds, emoji.Id)
}
require.NoError(t, err)
require.Len(t, emojis, 2)
assert.Contains(t, emojiIds, emoji1.Id)
assert.Contains(t, emojiIds, emoji2.Id)
})
t.Run("should ignore non-existent emojis", func(t *testing.T) {
emojis, _, err := client.GetEmojisByNames(context.Background(), []string{emoji1.Name, emoji2.Name, model.NewId()})
var emojiIds []string
for _, emoji := range emojis {
emojiIds = append(emojiIds, emoji.Id)
}
require.NoError(t, err)
require.Len(t, emojis, 2)
assert.Contains(t, emojiIds, emoji1.Id)
assert.Contains(t, emojiIds, emoji2.Id)
})
t.Run("should return an empty array when no emojis are found", func(t *testing.T) {
emojis, _, err := client.GetEmojisByNames(context.Background(), []string{model.NewId(), model.NewId()})
require.NoError(t, err)
assert.NotNil(t, emojis)
assert.Equal(t, []*model.Emoji{}, emojis)
})
t.Run("should return an error when too many emojis are requested", func(t *testing.T) {
names := make([]string, GetEmojisByNamesMax+1)
for i := range names {
names[i] = model.NewId()
}
_, _, err := client.GetEmojisByNames(context.Background(), names)
require.Error(t, err)
})
}
func TestDeleteEmoji(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
EnableCustomEmoji := *th.App.Config().ServiceSettings.EnableCustomEmoji
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
defaultRolePermissions := th.SaveDefaultRolePermissions()
defer func() {
th.RestoreDefaultRolePermissions(defaultRolePermissions)
}()
emoji := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
_, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
_, _, err = client.GetEmoji(context.Background(), newEmoji.Id)
require.Error(t, err, "expected error fetching deleted emoji")
// Admin can delete other users emoji
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
_, err = th.SystemAdminClient.DeleteEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
_, _, err = th.SystemAdminClient.GetEmoji(context.Background(), newEmoji.Id)
require.Error(t, err, "expected error fetching deleted emoji")
// Try to delete just deleted emoji
resp, err := client.DeleteEmoji(context.Background(), newEmoji.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// Try to delete non-existing emoji
resp, err = client.DeleteEmoji(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// Try to delete without Id
resp, err = client.DeleteEmoji(context.Background(), "")
require.Error(t, err)
CheckNotFoundStatus(t, resp)
// Try to delete my custom emoji without permissions
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.RemovePermissionFromRole(model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
resp, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
th.AddPermissionToRole(model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
// Try to delete other user's custom emoji without DELETE_EMOJIS permissions
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.RemovePermissionFromRole(model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
th.AddPermissionToRole(model.PermissionDeleteOthersEmojis.Id, model.SystemUserRoleId)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic2()
resp, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
th.RemovePermissionFromRole(model.PermissionDeleteOthersEmojis.Id, model.SystemUserRoleId)
th.AddPermissionToRole(model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic()
// Try to delete other user's custom emoji without DELETE_OTHERS_EMOJIS permissions
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic2()
resp, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic()
// Try to delete other user's custom emoji with permissions
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.AddPermissionToRole(model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
th.AddPermissionToRole(model.PermissionDeleteOthersEmojis.Id, model.SystemUserRoleId)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic2()
_, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic()
// Try to delete my custom emoji with permissions at team level
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.RemovePermissionFromRole(model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
th.AddPermissionToRole(model.PermissionDeleteEmojis.Id, model.TeamUserRoleId)
_, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
th.AddPermissionToRole(model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
th.RemovePermissionFromRole(model.PermissionDeleteEmojis.Id, model.TeamUserRoleId)
// Try to delete other user's custom emoji with permissions at team level
emoji = &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err = client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.RemovePermissionFromRole(model.PermissionDeleteEmojis.Id, model.SystemUserRoleId)
th.RemovePermissionFromRole(model.PermissionDeleteOthersEmojis.Id, model.SystemUserRoleId)
th.AddPermissionToRole(model.PermissionDeleteEmojis.Id, model.TeamUserRoleId)
th.AddPermissionToRole(model.PermissionDeleteOthersEmojis.Id, model.TeamUserRoleId)
_, err = client.Logout(context.Background())
require.NoError(t, err)
th.LoginBasic2()
_, err = client.DeleteEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
}
func TestGetEmoji(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
EnableCustomEmoji := *th.App.Config().ServiceSettings.EnableCustomEmoji
defer func() {
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = EnableCustomEmoji })
}()
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
emoji := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
emoji, _, err = client.GetEmoji(context.Background(), newEmoji.Id)
require.NoError(t, err)
require.Equal(t, newEmoji.Id, emoji.Id, "wrong emoji was returned")
_, resp, err := client.GetEmoji(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
}
func TestGetEmojiByName(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
emoji := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
emoji, _, err = client.GetEmojiByName(context.Background(), newEmoji.Name)
require.NoError(t, err)
assert.Equal(t, newEmoji.Name, emoji.Name)
_, resp, err := client.GetEmojiByName(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, err = client.Logout(context.Background())
require.NoError(t, err)
_, resp, err = client.GetEmojiByName(context.Background(), newEmoji.Name)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
}
func TestGetEmojiImage(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
emoji1 := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
emoji1, _, err := client.CreateEmoji(context.Background(), emoji1, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = false })
_, resp, err := client.GetEmojiImage(context.Background(), emoji1.Id)
require.Error(t, err)
CheckNotImplementedStatus(t, resp)
CheckErrorID(t, err, "api.emoji.disabled.app_error")
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.FileSettings.DriverName = "local" })
emojiImage, _, err := client.GetEmojiImage(context.Background(), emoji1.Id)
require.NoError(t, err)
require.Greater(t, len(emojiImage), 0, "should return the image")
_, imageType, err := image.DecodeConfig(bytes.NewReader(emojiImage))
require.NoError(t, err)
require.Equal(t, imageType, "gif", "expected gif")
emoji2 := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
emoji2, _, err = client.CreateEmoji(context.Background(), emoji2, utils.CreateTestAnimatedGif(t, 10, 10, 10), "image.gif")
require.NoError(t, err)
emojiImage, _, err = client.GetEmojiImage(context.Background(), emoji2.Id)
require.NoError(t, err)
require.Greater(t, len(emojiImage), 0, "no image returned")
_, imageType, err = image.DecodeConfig(bytes.NewReader(emojiImage))
require.NoError(t, err, "unable to identify received image")
require.Equal(t, imageType, "gif", "expected gif")
emoji3 := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
emoji3, _, err = client.CreateEmoji(context.Background(), emoji3, utils.CreateTestJpeg(t, 10, 10), "image.jpg")
require.NoError(t, err)
emojiImage, _, err = client.GetEmojiImage(context.Background(), emoji3.Id)
require.NoError(t, err)
require.Greater(t, len(emojiImage), 0, "no image returned")
_, imageType, err = image.DecodeConfig(bytes.NewReader(emojiImage))
require.NoError(t, err, "unable to identify received image")
require.Equal(t, imageType, "jpeg", "expected jpeg")
emoji4 := &model.Emoji{
CreatorId: th.BasicUser.Id,
Name: model.NewId(),
}
emoji4, _, err = client.CreateEmoji(context.Background(), emoji4, utils.CreateTestPng(t, 10, 10), "image.png")
require.NoError(t, err)
emojiImage, _, err = client.GetEmojiImage(context.Background(), emoji4.Id)
require.NoError(t, err)
require.Greater(t, len(emojiImage), 0, "no image returned")
_, imageType, err = image.DecodeConfig(bytes.NewReader(emojiImage))
require.NoError(t, err, "unable to identify received image")
require.Equal(t, imageType, "png", "expected png")
_, err = client.DeleteEmoji(context.Background(), emoji4.Id)
require.NoError(t, err)
_, resp, err = client.GetEmojiImage(context.Background(), emoji4.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, resp, err = client.GetEmojiImage(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, resp, err = client.GetEmojiImage(context.Background(), "")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
}
func TestSearchEmoji(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
searchTerm1 := model.NewId()
searchTerm2 := model.NewId()
emojis := []*model.Emoji{
{
CreatorId: th.BasicUser.Id,
Name: searchTerm1,
},
{
CreatorId: th.BasicUser.Id,
Name: "blargh_" + searchTerm2,
},
}
for idx, emoji := range emojis {
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
emojis[idx] = newEmoji
}
t.Run("should return emojis based on the query", func(t *testing.T) {
search := &model.EmojiSearch{Term: searchTerm1}
remojis, resp, err := client.SearchEmoji(context.Background(), search)
require.NoError(t, err)
CheckOKStatus(t, resp)
found := false
for _, e := range remojis {
if e.Name == emojis[0].Name {
found = true
}
}
assert.True(t, found)
search.Term = searchTerm2
search.PrefixOnly = true
remojis, resp, err = client.SearchEmoji(context.Background(), search)
require.NoError(t, err)
CheckOKStatus(t, resp)
found = false
for _, e := range remojis {
if e.Name == emojis[1].Name {
found = true
}
}
assert.False(t, found)
search.PrefixOnly = false
remojis, resp, err = client.SearchEmoji(context.Background(), search)
require.NoError(t, err)
CheckOKStatus(t, resp)
found = false
for _, e := range remojis {
if e.Name == emojis[1].Name {
found = true
}
}
assert.True(t, found)
})
t.Run("should return an empty array when no emojis match the query", func(t *testing.T) {
search := &model.EmojiSearch{Term: model.NewId()}
remojis, _, err := client.SearchEmoji(context.Background(), search)
require.NoError(t, err)
require.NotEqual(t, nil, remojis)
require.Equal(t, []*model.Emoji{}, remojis)
})
t.Run("should return a 400 error when an empty term is passed", func(t *testing.T) {
search := &model.EmojiSearch{Term: ""}
_, resp, err := client.SearchEmoji(context.Background(), search)
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("should return a 401 error when logged out", func(t *testing.T) {
search := &model.EmojiSearch{Term: searchTerm1}
_, err := client.Logout(context.Background())
require.NoError(t, err)
_, resp, err := client.SearchEmoji(context.Background(), search)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
}
func TestAutocompleteEmoji(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic()
defer th.TearDown()
client := th.Client
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCustomEmoji = true })
searchTerm1 := model.NewId()
emojis := []*model.Emoji{
{
CreatorId: th.BasicUser.Id,
Name: searchTerm1,
},
{
CreatorId: th.BasicUser.Id,
Name: "blargh_" + searchTerm1,
},
}
for idx, emoji := range emojis {
newEmoji, _, err := client.CreateEmoji(context.Background(), emoji, utils.CreateTestGif(t, 10, 10), "image.gif")
require.NoError(t, err)
emojis[idx] = newEmoji
}
t.Run("should return autocompleted emojis based on the search term", func(t *testing.T) {
remojis, resp, err := client.AutocompleteEmoji(context.Background(), searchTerm1, "")
require.NoErrorf(t, err, "AutocompleteEmoji failed with search term: %s", searchTerm1)
CheckOKStatus(t, resp)
found1 := false
found2 := false
for _, e := range remojis {
if e.Name == emojis[0].Name {
found1 = true
}
if e.Name == emojis[1].Name {
found2 = true
}
}
assert.True(t, found1)
assert.False(t, found2)
})
t.Run("should return an empty array when no emojis match the search term", func(t *testing.T) {
remojis, resp, err := client.AutocompleteEmoji(context.Background(), model.NewId(), "")
require.NoErrorf(t, err, "AutocompleteEmoji failed with search term: %s", searchTerm1)
CheckOKStatus(t, resp)
require.NotEqual(t, nil, remojis)
require.Equal(t, []*model.Emoji{}, remojis)
})
t.Run("should return a 400 error when an empty term is passed", func(t *testing.T) {
_, resp, err := client.AutocompleteEmoji(context.Background(), "", "")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})
t.Run("should return a 401 error when logged out", func(t *testing.T) {
_, err := client.Logout(context.Background())
require.NoError(t, err)
_, resp, err := client.AutocompleteEmoji(context.Background(), searchTerm1, "")
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})
}

118
channels/api4/export.go Normal file
View File

@ -0,0 +1,118 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"path/filepath"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitExport() {
api.BaseRoutes.Exports.Handle("", api.APISessionRequired(listExports)).Methods(http.MethodGet)
api.BaseRoutes.Export.Handle("", api.APISessionRequired(deleteExport)).Methods(http.MethodDelete)
api.BaseRoutes.Export.Handle("", api.APISessionRequired(downloadExport)).Methods(http.MethodGet)
api.BaseRoutes.Export.Handle("/presign-url", api.APISessionRequired(generatePresignURLExport)).Methods(http.MethodPost)
}
func listExports(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PermissionManageSystem)
return
}
exports, appErr := c.App.ListExports()
if appErr != nil {
c.Err = appErr
return
}
data, err := json.Marshal(exports)
if err != nil {
c.Err = model.NewAppError("listImports", "app.export.marshal.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if _, err := w.Write(data); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func deleteExport(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventDeleteExport, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "export_name", c.Params.ExportName)
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PermissionManageSystem)
return
}
if err := c.App.DeleteExport(c.Params.ExportName); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}
func downloadExport(c *Context, w http.ResponseWriter, r *http.Request) {
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PermissionManageSystem)
return
}
filePath := filepath.Join(*c.App.Config().ExportSettings.Directory, c.Params.ExportName)
if ok, err := c.App.ExportFileExists(filePath); err != nil {
c.Err = err
return
} else if !ok {
c.Err = model.NewAppError("downloadExport", "api.export.export_not_found.app_error", nil, "", http.StatusNotFound)
return
}
file, err := c.App.ExportFileReader(filePath)
if err != nil {
c.Err = err
return
}
defer file.Close()
w.Header().Set("Content-Type", "application/zip")
http.ServeContent(w, r, c.Params.ExportName, time.Time{}, file)
}
func generatePresignURLExport(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord(model.AuditEventGeneratePresignURLExport, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "export_name", c.Params.ExportName)
if !c.IsSystemAdmin() {
c.SetPermissionError(model.PermissionManageSystem)
return
}
res, appErr := c.App.GeneratePresignURLForExport(c.Params.ExportName)
if appErr != nil {
c.Err = appErr
return
}
data, err := json.Marshal(res)
if err != nil {
c.Err = model.NewAppError("generatePresignURLExport", "app.export.marshal.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
auditRec.Success()
if _, err := w.Write(data); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}

View File

@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import "net/http"
func (api *API) InitExportLocal() {
api.BaseRoutes.Exports.Handle("", api.APILocal(listExports)).Methods(http.MethodGet)
api.BaseRoutes.Export.Handle("", api.APILocal(deleteExport)).Methods(http.MethodDelete)
api.BaseRoutes.Export.Handle("", api.APILocal(downloadExport)).Methods(http.MethodGet)
api.BaseRoutes.Export.Handle("/presign-url", api.APILocal(generatePresignURLExport)).Methods(http.MethodPost)
}

View File

@ -0,0 +1,235 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
func TestListExports(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
t.Run("no permissions", func(t *testing.T) {
exports, _, err := th.Client.ListExports(context.Background())
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
require.Nil(t, exports)
})
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
exports, _, err := c.ListExports(context.Background())
require.NoError(t, err)
require.Empty(t, exports)
}, "no exports")
dataDir := *th.App.Config().FileSettings.Directory
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
err := os.Mkdir(exportDir, 0700)
require.NoError(t, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(t, err)
}()
f, err := os.Create(filepath.Join(exportDir, "export.zip"))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
exports, _, err := c.ListExports(context.Background())
require.NoError(t, err)
require.Len(t, exports, 1)
require.Equal(t, exports[0], "export.zip")
}, "expected exports")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
originalExportDir := *th.App.Config().ExportSettings.Directory
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ExportSettings.Directory = "new" })
defer th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ExportSettings.Directory = originalExportDir
})
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
err := os.Mkdir(exportDir, 0700)
require.NoError(t, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(t, err)
}()
exports, _, err := c.ListExports(context.Background())
require.NoError(t, err)
require.Empty(t, exports)
f, err := os.Create(filepath.Join(exportDir, "export.zip"))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
exports, _, err = c.ListExports(context.Background())
require.NoError(t, err)
require.Len(t, exports, 1)
require.Equal(t, "export.zip", exports[0])
}, "change export directory")
}
func TestDeleteExport(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
t.Run("no permissions", func(t *testing.T) {
_, err := th.Client.DeleteExport(context.Background(), "export.zip")
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
})
dataDir := *th.App.Config().FileSettings.Directory
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
err := os.Mkdir(exportDir, 0700)
require.NoError(t, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(t, err)
}()
exportName := "export.zip"
f, err := os.Create(filepath.Join(exportDir, exportName))
require.NoError(t, err)
err = f.Close()
require.NoError(t, err)
exports, _, err := c.ListExports(context.Background())
require.NoError(t, err)
require.Len(t, exports, 1)
require.Equal(t, exports[0], exportName)
_, err = c.DeleteExport(context.Background(), exportName)
require.NoError(t, err)
exports, _, err = c.ListExports(context.Background())
require.NoError(t, err)
require.Empty(t, exports)
// verify idempotence
_, err = c.DeleteExport(context.Background(), exportName)
require.NoError(t, err)
}, "successfully delete export")
}
func TestDownloadExport(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t)
defer th.TearDown()
t.Run("no permissions", func(t *testing.T) {
var buf bytes.Buffer
n, _, err := th.Client.DownloadExport(context.Background(), "export.zip", &buf, 0)
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
require.Zero(t, n)
})
dataDir := *th.App.Config().FileSettings.Directory
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
var buf bytes.Buffer
n, _, err := c.DownloadExport(context.Background(), "export.zip", &buf, 0)
require.Error(t, err)
CheckErrorID(t, err, "api.export.export_not_found.app_error")
require.Zero(t, n)
}, "not found")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
err := os.Mkdir(exportDir, 0700)
require.NoError(t, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(t, err)
}()
data := randomBytes(t, 1024*1024)
var buf bytes.Buffer
exportName := "export.zip"
err = os.WriteFile(filepath.Join(exportDir, exportName), data, 0600)
require.NoError(t, err)
n, _, err := c.DownloadExport(context.Background(), exportName, &buf, 0)
require.NoError(t, err)
require.Equal(t, len(data), int(n))
require.Equal(t, data, buf.Bytes())
}, "full download")
th.TestForSystemAdminAndLocal(t, func(t *testing.T, c *model.Client4) {
err := os.Mkdir(exportDir, 0700)
require.NoError(t, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(t, err)
}()
data := randomBytes(t, 1024*1024)
var buf bytes.Buffer
exportName := "export.zip"
err = os.WriteFile(filepath.Join(exportDir, exportName), data, 0600)
require.NoError(t, err)
offset := 1024 * 512
n, _, err := c.DownloadExport(context.Background(), exportName, &buf, int64(offset))
require.NoError(t, err)
require.Equal(t, len(data)-offset, int(n))
require.Equal(t, data[offset:], buf.Bytes())
}, "download with offset")
}
func BenchmarkDownloadExport(b *testing.B) {
th := Setup(b)
defer th.TearDown()
dataDir := *th.App.Config().FileSettings.Directory
exportDir := filepath.Join(dataDir, *th.App.Config().ExportSettings.Directory)
err := os.Mkdir(exportDir, 0700)
require.NoError(b, err)
defer func() {
err = os.RemoveAll(exportDir)
require.NoError(b, err)
}()
exportName := "export.zip"
f, err := os.Create(filepath.Join(exportDir, exportName))
require.NoError(b, err)
err = f.Close()
require.NoError(b, err)
err = os.Truncate(filepath.Join(exportDir, exportName), 1024*1024*1024)
require.NoError(b, err)
for i := 0; b.Loop(); i++ {
outFilePath := filepath.Join(dataDir, fmt.Sprintf("export%d.zip", i))
outFile, err := os.Create(outFilePath)
require.NoError(b, err)
_, _, err = th.SystemAdminClient.DownloadExport(context.Background(), exportName, outFile, 0)
require.NoError(b, err)
err = outFile.Close()
require.NoError(b, err)
err = os.Remove(outFilePath)
require.NoError(b, err)
}
}

907
channels/api4/file.go Normal file
View File

@ -0,0 +1,907 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"bytes"
"crypto/subtle"
"encoding/json"
"io"
"mime"
"mime/multipart"
"net/http"
"strconv"
"time"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/platform/shared/web"
)
const (
FileTeamId = "noteam"
PreviewImageType = "image/jpeg"
ThumbnailImageType = "image/jpeg"
)
const maxMultipartFormDataBytes = 10 * 1024 // 10Kb
func (api *API) InitFile() {
api.BaseRoutes.Files.Handle("", api.APISessionRequired(uploadFileStream, handlerParamFileAPI)).Methods(http.MethodPost)
api.BaseRoutes.File.Handle("", api.APISessionRequiredTrustRequester(getFile)).Methods(http.MethodGet)
api.BaseRoutes.File.Handle("/thumbnail", api.APISessionRequiredTrustRequester(getFileThumbnail)).Methods(http.MethodGet)
api.BaseRoutes.File.Handle("/link", api.APISessionRequired(getFileLink)).Methods(http.MethodGet)
api.BaseRoutes.File.Handle("/preview", api.APISessionRequiredTrustRequester(getFilePreview)).Methods(http.MethodGet)
api.BaseRoutes.File.Handle("/info", api.APISessionRequired(getFileInfo)).Methods(http.MethodGet)
api.BaseRoutes.Team.Handle("/files/search", api.APISessionRequiredDisableWhenBusy(searchFilesInTeam)).Methods(http.MethodPost)
api.BaseRoutes.Files.Handle("/search", api.APISessionRequiredDisableWhenBusy(searchFilesInAllTeams)).Methods(http.MethodPost)
api.BaseRoutes.PublicFile.Handle("", api.APIHandler(getPublicFile)).Methods(http.MethodGet, http.MethodHead)
}
func parseMultipartRequestHeader(req *http.Request) (boundary string, err error) {
v := req.Header.Get("Content-Type")
if v == "" {
return "", http.ErrNotMultipart
}
d, params, err := mime.ParseMediaType(v)
if err != nil || d != "multipart/form-data" {
return "", http.ErrNotMultipart
}
boundary, ok := params["boundary"]
if !ok {
return "", http.ErrMissingBoundary
}
return boundary, nil
}
func multipartReader(req *http.Request, stream io.Reader) (*multipart.Reader, error) {
boundary, err := parseMultipartRequestHeader(req)
if err != nil {
return nil, err
}
if stream != nil {
return multipart.NewReader(stream, boundary), nil
}
return multipart.NewReader(req.Body, boundary), nil
}
func uploadFileStream(c *Context, w http.ResponseWriter, r *http.Request) {
if !*c.App.Config().FileSettings.EnableFileAttachments {
c.Err = model.NewAppError("uploadFileStream",
"api.file.attachments.disabled.app_error",
nil, "", http.StatusForbidden)
return
}
// Parse the post as a regular form (in practice, use the URL values
// since we never expect a real application/x-www-form-urlencoded
// form).
if r.Form == nil {
err := r.ParseForm()
if err != nil {
c.Err = model.NewAppError("uploadFileStream",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
return
}
}
if r.ContentLength == 0 {
c.Err = model.NewAppError("uploadFileStream",
"api.file.upload_file.read_request.app_error",
nil, "Content-Length should not be 0", http.StatusBadRequest)
return
}
timestamp := time.Now()
var fileUploadResponse *model.FileUploadResponse
_, err := parseMultipartRequestHeader(r)
switch err {
case nil:
fileUploadResponse = uploadFileMultipart(c, r, nil, timestamp)
case http.ErrNotMultipart:
fileUploadResponse = uploadFileSimple(c, r, timestamp)
default:
c.Err = model.NewAppError("uploadFileStream",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
}
if c.Err != nil {
return
}
// Write the response values to the output upon return
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(fileUploadResponse); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
// uploadFileSimple uploads a file from a simple POST with the file in the request body
func uploadFileSimple(c *Context, r *http.Request, timestamp time.Time) *model.FileUploadResponse {
// Simple POST with the file in the body and all metadata in the args.
c.RequireChannelId()
c.RequireFilename()
if c.Err != nil {
return nil
}
auditRec := c.MakeAuditRecord(model.AuditEventUploadFileSimple, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionUploadFile) {
c.SetPermissionError(model.PermissionUploadFile)
return nil
}
channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if err != nil {
c.Err = model.NewAppError("uploadFileSimple",
"api.file.upload_file.get_channel.app_error",
nil, err.Error(), http.StatusBadRequest)
return nil
}
restrictDM, appErr := c.App.CheckIfChannelIsRestrictedDM(c.AppContext, channel)
if appErr != nil {
c.Err = appErr
return nil
}
if restrictDM {
c.Err = model.NewAppError("uploadFileSimple", "api.file.upload_file.restricted_dm.error", nil, "", http.StatusBadRequest)
return nil
}
clientId := r.Form.Get("client_id")
model.AddEventParameterToAuditRec(auditRec, "client_id", clientId)
creatorId := c.AppContext.Session().UserId
if isBookmark, err := strconv.ParseBool(r.URL.Query().Get(model.BookmarkFileOwner)); err == nil && isBookmark {
creatorId = model.BookmarkFileOwner
model.AddEventParameterToAuditRec(auditRec, model.BookmarkFileOwner, true)
}
info, appErr := c.App.UploadFileX(c.AppContext, c.Params.ChannelId, c.Params.Filename, r.Body,
app.UploadFileSetTeamId(FileTeamId),
app.UploadFileSetUserId(creatorId),
app.UploadFileSetTimestamp(timestamp),
app.UploadFileSetContentLength(r.ContentLength),
app.UploadFileSetClientId(clientId))
if appErr != nil {
c.Err = appErr
return nil
}
model.AddEventParameterAuditableToAuditRec(auditRec, "file", info)
fileUploadResponse := &model.FileUploadResponse{
FileInfos: []*model.FileInfo{info},
}
if clientId != "" {
fileUploadResponse.ClientIds = []string{clientId}
}
auditRec.Success()
return fileUploadResponse
}
// uploadFileMultipart parses and uploads file(s) from a mime/multipart
// request. It pre-buffers up to the first part which is either the (a)
// `channel_id` value, or (b) a file. Then in case of (a) it re-processes the
// entire message recursively calling itself in stream mode. In case of (b) it
// calls to uploadFileMultipartLegacy for legacy support
func uploadFileMultipart(c *Context, r *http.Request, asStream io.Reader, timestamp time.Time) *model.FileUploadResponse {
expectClientIds := true
var clientIds []string
resp := model.FileUploadResponse{
FileInfos: []*model.FileInfo{},
ClientIds: []string{},
}
var buf *bytes.Buffer
var mr *multipart.Reader
var err error
if asStream == nil {
// We need to buffer until we get the channel_id, or the first file.
buf = &bytes.Buffer{}
mr, err = multipartReader(r, io.TeeReader(r.Body, buf))
} else {
mr, err = multipartReader(r, asStream)
}
if err != nil {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
return nil
}
nFiles := 0
NextPart:
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
return nil
}
// Parse any form fields in the multipart.
formname := part.FormName()
if formname == "" {
continue
}
filename := part.FileName()
if filename == "" {
var b bytes.Buffer
_, err = io.CopyN(&b, part, maxMultipartFormDataBytes)
if err != nil && err != io.EOF {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.read_form_value.app_error",
map[string]any{"Formname": formname},
err.Error(), http.StatusBadRequest)
return nil
}
v := b.String()
switch formname {
case "channel_id":
if c.Params.ChannelId != "" && c.Params.ChannelId != v {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.multiple_channel_ids.app_error",
nil, "", http.StatusBadRequest)
return nil
}
if v != "" {
c.Params.ChannelId = v
}
// Got channel_id, re-process the entire post
// in the streaming mode.
if asStream == nil {
return uploadFileMultipart(c, r, io.MultiReader(buf, r.Body), timestamp)
}
case "client_ids":
if !expectClientIds {
c.SetInvalidParam("client_ids")
return nil
}
clientIds = append(clientIds, v)
default:
c.SetInvalidParam(formname)
return nil
}
continue NextPart
}
isBookmark := false
if val, queryErr := strconv.ParseBool(r.URL.Query().Get(model.BookmarkFileOwner)); queryErr == nil {
isBookmark = val
}
// A file part.
if c.Params.ChannelId == "" && asStream == nil {
// Got file before channel_id, fall back to legacy buffered mode
mr, err = multipartReader(r, io.MultiReader(buf, r.Body))
if err != nil {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
return nil
}
return uploadFileMultipartLegacy(c, mr, timestamp, isBookmark)
}
c.RequireChannelId()
if c.Err != nil {
return nil
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionUploadFile) {
c.SetPermissionError(model.PermissionUploadFile)
return nil
}
channel, appErr := c.App.GetChannel(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.get_channel.app_error",
nil, appErr.Error(), http.StatusBadRequest)
return nil
}
restrictDM, appErr := c.App.CheckIfChannelIsRestrictedDM(c.AppContext, channel)
if appErr != nil {
c.Err = appErr
return nil
}
if restrictDM {
c.Err = model.NewAppError("uploadFileSimple", "api.file.upload_file.restricted_dm.error", nil, "", http.StatusBadRequest)
return nil
}
// If there's no clientIds when the first file comes, expect
// none later.
if nFiles == 0 && len(clientIds) == 0 {
expectClientIds = false
}
// Must have a exactly one client ID for each file.
clientId := ""
if expectClientIds {
if nFiles >= len(clientIds) {
c.SetInvalidParam("client_ids")
return nil
}
clientId = clientIds[nFiles]
}
auditRec := c.MakeAuditRecord(model.AuditEventUploadFileMultipart, model.AuditStatusFail)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
model.AddEventParameterToAuditRec(auditRec, "client_id", clientId)
creatorId := c.AppContext.Session().UserId
if isBookmark {
creatorId = model.BookmarkFileOwner
model.AddEventParameterToAuditRec(auditRec, model.BookmarkFileOwner, true)
}
info, appErr := c.App.UploadFileX(c.AppContext, c.Params.ChannelId, filename, part,
app.UploadFileSetTeamId(FileTeamId),
app.UploadFileSetUserId(creatorId),
app.UploadFileSetTimestamp(timestamp),
app.UploadFileSetContentLength(-1),
app.UploadFileSetClientId(clientId))
if appErr != nil {
c.Err = appErr
c.LogAuditRec(auditRec)
return nil
}
model.AddEventParameterAuditableToAuditRec(auditRec, "file", info)
auditRec.Success()
c.LogAuditRec(auditRec)
// add to the response
resp.FileInfos = append(resp.FileInfos, info)
if expectClientIds {
resp.ClientIds = append(resp.ClientIds, clientId)
}
nFiles++
}
// Verify that the number of ClientIds matched the number of files.
if expectClientIds && len(clientIds) != nFiles {
c.Err = model.NewAppError("uploadFileMultipart",
"api.file.upload_file.incorrect_number_of_client_ids.app_error",
map[string]any{"NumClientIds": len(clientIds), "NumFiles": nFiles},
"", http.StatusBadRequest)
return nil
}
return &resp
}
// uploadFileMultipartLegacy reads, buffers, and then uploads the message,
// borrowing from http.ParseMultipartForm. If successful it returns a
// *model.FileUploadResponse filled in with the individual model.FileInfo's.
func uploadFileMultipartLegacy(c *Context, mr *multipart.Reader,
timestamp time.Time, isBookmark bool) *model.FileUploadResponse {
// Parse the entire form.
form, err := mr.ReadForm(*c.App.Config().FileSettings.MaxFileSize)
if err != nil {
c.Err = model.NewAppError("uploadFileMultipartLegacy",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusInternalServerError)
return nil
}
// get and validate the channel Id, permission to upload there.
if len(form.Value["channel_id"]) == 0 {
c.SetInvalidParam("channel_id")
return nil
}
channelId := form.Value["channel_id"][0]
c.Params.ChannelId = channelId
c.RequireChannelId()
if c.Err != nil {
return nil
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionUploadFile) {
c.SetPermissionError(model.PermissionUploadFile)
return nil
}
// Check that we have either no client IDs, or one per file.
clientIds := form.Value["client_ids"]
fileHeaders := form.File["files"]
if len(clientIds) != 0 && len(clientIds) != len(fileHeaders) {
c.Err = model.NewAppError("uploadFilesMultipartBuffered",
"api.file.upload_file.incorrect_number_of_client_ids.app_error",
map[string]any{"NumClientIds": len(clientIds), "NumFiles": len(fileHeaders)},
"", http.StatusBadRequest)
return nil
}
resp := model.FileUploadResponse{
FileInfos: []*model.FileInfo{},
ClientIds: []string{},
}
for i, fileHeader := range fileHeaders {
f, err := fileHeader.Open()
if err != nil {
c.Err = model.NewAppError("uploadFileMultipartLegacy",
"api.file.upload_file.read_request.app_error",
nil, err.Error(), http.StatusBadRequest)
return nil
}
clientId := ""
if len(clientIds) > 0 {
clientId = clientIds[i]
}
auditRec := c.MakeAuditRecord(model.AuditEventUploadFileMultipartLegacy, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", channelId)
model.AddEventParameterToAuditRec(auditRec, "client_id", clientId)
creatorId := c.AppContext.Session().UserId
if isBookmark {
creatorId = model.BookmarkFileOwner
model.AddEventParameterToAuditRec(auditRec, model.BookmarkFileOwner, true)
}
info, appErr := c.App.UploadFileX(c.AppContext, c.Params.ChannelId, fileHeader.Filename, f,
app.UploadFileSetTeamId(FileTeamId),
app.UploadFileSetUserId(creatorId),
app.UploadFileSetTimestamp(timestamp),
app.UploadFileSetContentLength(-1),
app.UploadFileSetClientId(clientId))
f.Close()
if appErr != nil {
c.Err = appErr
c.LogAuditRec(auditRec)
return nil
}
model.AddEventParameterAuditableToAuditRec(auditRec, "file", info)
auditRec.Success()
c.LogAuditRec(auditRec)
resp.FileInfos = append(resp.FileInfos, info)
if clientId != "" {
resp.ClientIds = append(resp.ClientIds, clientId)
}
}
return &resp
}
func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireFileId()
if c.Err != nil {
return
}
forceDownload, _ := strconv.ParseBool(r.URL.Query().Get("download"))
auditRec := c.MakeAuditRecord(model.AuditEventGetFile, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "force_download", forceDownload)
fileInfos, storeErr := c.App.Srv().Store().FileInfo().GetByIds([]string{c.Params.FileId}, true, true)
if storeErr != nil {
c.Err = model.NewAppError("getFile", "api.file.get_file_info.app_error", nil, "", http.StatusInternalServerError)
setInaccessibleFileHeader(w, c.Err)
return
} else if len(fileInfos) == 0 {
c.Err = model.NewAppError("getFile", "api.file.get_file_info.app_error", nil, "", http.StatusNotFound)
setInaccessibleFileHeader(w, c.Err)
return
}
fileInfo := fileInfos[0]
channel, err := c.App.GetChannel(c.AppContext, fileInfo.ChannelId)
if err != nil {
c.Err = err
return
}
isContentReviewer := false
asContentReviewer, _ := strconv.ParseBool(r.URL.Query().Get(model.AsContentReviewerParam))
if asContentReviewer {
requireContentFlaggingEnabled(c)
if c.Err != nil {
return
}
flaggedPostId := r.URL.Query().Get("flagged_post_id")
requireFlaggedPost(c, flaggedPostId)
if c.Err != nil {
return
}
if flaggedPostId != fileInfo.PostId {
c.Err = model.NewAppError("getFile", "api.file.get_file.invalid_flagged_post.app_error", nil, "file_id="+fileInfo.Id+", flagged_post_id="+flaggedPostId, http.StatusBadRequest)
return
}
requireTeamContentReviewer(c, c.AppContext.Session().UserId, channel.TeamId)
if c.Err != nil {
return
}
isContentReviewer = true
}
// at this point we may have fetched a deleted file info and
// if the user is not a content reviewer, the request should fail as
// fetching deleted file info is only allowed for content reviewers of the specific post
if fileInfo.DeleteAt != 0 && !isContentReviewer {
c.Err = model.NewAppError("getFile", "app.file_info.get.app_error", nil, "", http.StatusNotFound)
setInaccessibleFileHeader(w, c.Err)
return
}
model.AddEventParameterAuditableToAuditRec(auditRec, "file", fileInfo)
if !isContentReviewer {
perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if fileInfo.CreatorId == model.BookmarkFileOwner {
if !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
} else if fileInfo.CreatorId != c.AppContext.Session().UserId && !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
}
fileReader, err := c.App.FileReader(fileInfo.Path)
if err != nil {
c.Err = err
c.Err.StatusCode = http.StatusNotFound
return
}
defer fileReader.Close()
auditRec.Success()
web.WriteFileResponse(fileInfo.Name, fileInfo.MimeType, fileInfo.Size, time.Unix(0, fileInfo.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r)
}
func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireFileId()
if c.Err != nil {
return
}
forceDownload, _ := strconv.ParseBool(r.URL.Query().Get("download"))
info, err := c.App.GetFileInfo(c.AppContext, c.Params.FileId)
if err != nil {
c.Err = err
setInaccessibleFileHeader(w, err)
return
}
channel, err := c.App.GetChannel(c.AppContext, info.ChannelId)
if err != nil {
c.Err = err
return
}
perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if info.CreatorId == model.BookmarkFileOwner {
if !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
} else if info.CreatorId != c.AppContext.Session().UserId && !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
if info.ThumbnailPath == "" {
c.Err = model.NewAppError("getFileThumbnail", "api.file.get_file_thumbnail.no_thumbnail.app_error", nil, "file_id="+info.Id, http.StatusBadRequest)
return
}
fileReader, err := c.App.FileReader(info.ThumbnailPath)
if err != nil {
c.Err = err
c.Err.StatusCode = http.StatusNotFound
return
}
defer fileReader.Close()
web.WriteFileResponse(info.Name, ThumbnailImageType, 0, time.Unix(0, info.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r)
}
func getFileLink(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireFileId()
if c.Err != nil {
return
}
if !*c.App.Config().FileSettings.EnablePublicLink {
c.Err = model.NewAppError("getPublicLink", "api.file.get_public_link.disabled.app_error", nil, "", http.StatusForbidden)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetFileLink, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
info, err := c.App.GetFileInfo(c.AppContext, c.Params.FileId)
if err != nil {
c.Err = err
setInaccessibleFileHeader(w, err)
return
}
model.AddEventParameterAuditableToAuditRec(auditRec, "file", info)
channel, err := c.App.GetChannel(c.AppContext, info.ChannelId)
if err != nil {
c.Err = err
return
}
perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if info.CreatorId == model.BookmarkFileOwner {
if !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
} else if info.CreatorId != c.AppContext.Session().UserId && !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
if info.PostId == "" && info.CreatorId != model.BookmarkFileOwner {
c.Err = model.NewAppError("getPublicLink", "api.file.get_public_link.no_post.app_error", nil, "file_id="+info.Id, http.StatusBadRequest)
return
}
resp := make(map[string]string)
link := c.App.GeneratePublicLink(c.GetSiteURLHeader(), info)
resp["link"] = link
auditRec.Success()
if _, err := w.Write([]byte(model.MapToJSON(resp))); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireFileId()
if c.Err != nil {
return
}
forceDownload, _ := strconv.ParseBool(r.URL.Query().Get("download"))
info, err := c.App.GetFileInfo(c.AppContext, c.Params.FileId)
if err != nil {
c.Err = err
setInaccessibleFileHeader(w, err)
return
}
channel, err := c.App.GetChannel(c.AppContext, info.ChannelId)
if err != nil {
c.Err = err
return
}
perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if info.CreatorId == model.BookmarkFileOwner {
if !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
} else if info.CreatorId != c.AppContext.Session().UserId && !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
if info.PreviewPath == "" {
c.Err = model.NewAppError("getFilePreview", "api.file.get_file_preview.no_preview.app_error", nil, "file_id="+info.Id, http.StatusBadRequest)
return
}
fileReader, err := c.App.FileReader(info.PreviewPath)
if err != nil {
c.Err = err
c.Err.StatusCode = http.StatusNotFound
return
}
defer fileReader.Close()
web.WriteFileResponse(info.Name, PreviewImageType, 0, time.Unix(0, info.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r)
}
func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireFileId()
if c.Err != nil {
return
}
info, err := c.App.GetFileInfo(c.AppContext, c.Params.FileId)
if err != nil {
c.Err = err
setInaccessibleFileHeader(w, err)
return
}
channel, err := c.App.GetChannel(c.AppContext, info.ChannelId)
if err != nil {
c.Err = err
return
}
perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if info.CreatorId == model.BookmarkFileOwner {
if !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
} else if info.CreatorId != c.AppContext.Session().UserId && !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
w.Header().Set("Cache-Control", "max-age=2592000, private")
if err := json.NewEncoder(w).Encode(info); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireFileId()
if c.Err != nil {
return
}
if !*c.App.Config().FileSettings.EnablePublicLink {
c.Err = model.NewAppError("getPublicFile", "api.file.get_public_link.disabled.app_error", nil, "", http.StatusForbidden)
return
}
info, err := c.App.GetFileInfo(c.AppContext, c.Params.FileId)
if err != nil {
c.Err = err
setInaccessibleFileHeader(w, err)
return
}
hash := r.URL.Query().Get("h")
if hash == "" {
c.Err = model.NewAppError("getPublicFile", "api.file.get_file.public_invalid.app_error", nil, "", http.StatusBadRequest)
utils.RenderWebAppError(c.App.Config(), w, r, c.Err, c.App.AsymmetricSigningKey())
return
}
if subtle.ConstantTimeCompare([]byte(hash), []byte(app.GeneratePublicLinkHash(info.Id, *c.App.Config().FileSettings.PublicLinkSalt))) != 1 {
c.Err = model.NewAppError("getPublicFile", "api.file.get_file.public_invalid.app_error", nil, "", http.StatusBadRequest)
utils.RenderWebAppError(c.App.Config(), w, r, c.Err, c.App.AsymmetricSigningKey())
return
}
fileReader, err := c.App.FileReader(info.Path)
if err != nil {
c.Err = err
c.Err.StatusCode = http.StatusNotFound
return
}
defer fileReader.Close()
web.WriteFileResponse(info.Name, info.MimeType, info.Size, time.Unix(0, info.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, false, w, r)
}
func searchFilesInTeam(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) {
c.SetPermissionError(model.PermissionViewTeam)
return
}
searchFiles(c, w, r, c.Params.TeamId)
}
func searchFilesInAllTeams(c *Context, w http.ResponseWriter, r *http.Request) {
searchFiles(c, w, r, "")
}
func searchFiles(c *Context, w http.ResponseWriter, r *http.Request, teamID string) {
var params model.SearchParameter
jsonErr := json.NewDecoder(r.Body).Decode(&params)
if jsonErr != nil {
c.Err = model.NewAppError("searchFiles", "api.post.search_files.invalid_body.app_error", nil, "", http.StatusBadRequest).Wrap(jsonErr)
return
}
if params.Terms == nil || *params.Terms == "" {
c.SetInvalidParam("terms")
return
}
terms := *params.Terms
timeZoneOffset := 0
if params.TimeZoneOffset != nil {
timeZoneOffset = *params.TimeZoneOffset
}
isOrSearch := false
if params.IsOrSearch != nil {
isOrSearch = *params.IsOrSearch
}
page := 0
if params.Page != nil {
page = *params.Page
}
perPage := 60
if params.PerPage != nil {
perPage = *params.PerPage
}
includeDeletedChannels := false
if params.IncludeDeletedChannels != nil {
includeDeletedChannels = *params.IncludeDeletedChannels
}
startTime := time.Now()
results, err := c.App.SearchFilesInTeamForUser(c.AppContext, terms, c.AppContext.Session().UserId, teamID, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage)
elapsedTime := float64(time.Since(startTime)) / float64(time.Second)
metrics := c.App.Metrics()
if metrics != nil {
metrics.IncrementFilesSearchCounter()
metrics.ObserveFilesSearchDuration(elapsedTime)
}
if err != nil {
c.Err = err
return
}
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
if err := json.NewEncoder(w).Encode(results); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func setInaccessibleFileHeader(w http.ResponseWriter, appErr *model.AppError) {
// File is inaccessible due to cloud plan's limit.
if appErr.Id == "app.file.cloud.get.app_error" {
w.Header().Set(model.HeaderFirstInaccessibleFileTime, "1")
}
}

1628
channels/api4/file_test.go Normal file

File diff suppressed because it is too large Load Diff

1528
channels/api4/group.go Normal file

File diff suppressed because it is too large Load Diff

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