snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
126
server/.gitignore
vendored
Normal file
126
server/.gitignore
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
|
||||
logs
|
||||
.DS_Store
|
||||
node_modules
|
||||
/data
|
||||
/dist
|
||||
/webapp/dist
|
||||
jobserver
|
||||
npm-debug.log
|
||||
webapp/yarn-error.log
|
||||
mattermost.mattermost-license
|
||||
config/mattermost.mattermost-license
|
||||
config/config*.json
|
||||
config/*.crt
|
||||
config/*.key
|
||||
|
||||
web/static/js/bundle*.js
|
||||
web/static/js/bundle*.js.map
|
||||
web/static/js/libs*.js
|
||||
|
||||
config/active.dat
|
||||
config/logging.json
|
||||
/plugins
|
||||
|
||||
# go.work file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
#license files
|
||||
*.license
|
||||
*.mattermost-license
|
||||
|
||||
# Build Targets
|
||||
.prebuild
|
||||
.npminstall
|
||||
.yarninstall
|
||||
/prepackaged_plugins
|
||||
|
||||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Folders
|
||||
_obj
|
||||
_test
|
||||
testfiles
|
||||
|
||||
# Architecture specific extensions/prefixes
|
||||
[568vq].out
|
||||
|
||||
*.cgo1.go
|
||||
*.cgo2.c
|
||||
_cgo_defun.c
|
||||
_cgo_gotypes.go
|
||||
_cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
*.log.jsonl
|
||||
*.log.gz
|
||||
|
||||
# Fuzz binaries and working dir
|
||||
*fuzz.zip
|
||||
app/workdir
|
||||
|
||||
.tmp
|
||||
|
||||
# Vim temporary files
|
||||
[._]*.s[a-w][a-z]
|
||||
[._]s[a-w][a-z]
|
||||
*.un~
|
||||
Session.vim
|
||||
.netrwhist
|
||||
*~
|
||||
|
||||
# VSCode project files
|
||||
.vscode
|
||||
*.code-workspace
|
||||
|
||||
# Gogland project files
|
||||
mattermost-server.iml
|
||||
|
||||
# Build files
|
||||
*bundle.js
|
||||
|
||||
web/sass-files/sass/.sass-cache/
|
||||
*config.codekit
|
||||
*.sass-cache
|
||||
*styles.css
|
||||
|
||||
# Default local file storage
|
||||
data/*
|
||||
webapp/data/*
|
||||
api/data/*
|
||||
api4/data/*
|
||||
app/data/*
|
||||
|
||||
cover.out
|
||||
ecover.out
|
||||
mmctlcover.out
|
||||
cprofile.out
|
||||
*.test
|
||||
webapp/coverage
|
||||
/report.xml
|
||||
/gotestsum.json
|
||||
|
||||
.agignore
|
||||
.ctags
|
||||
/tags
|
||||
.idea
|
||||
|
||||
/debug
|
||||
/client
|
||||
__debug_bin
|
||||
report.xml
|
||||
*coverage.txt
|
||||
go.*.orig
|
||||
config.override.mk
|
||||
docker-compose.override.yaml
|
||||
1
server/.go-version
Normal file
1
server/.go-version
Normal file
@@ -0,0 +1 @@
|
||||
1.26.7
|
||||
281
server/.golangci.yml
Normal file
281
server/.golangci.yml
Normal file
@@ -0,0 +1,281 @@
|
||||
version: "2"
|
||||
linters:
|
||||
default: all
|
||||
disable:
|
||||
- bodyclose
|
||||
- canonicalheader
|
||||
- containedctx # storing context.Context in a struct is an established pattern here
|
||||
- contextcheck
|
||||
- cyclop
|
||||
- depguard
|
||||
- dogsled # test helpers return many values; blank-heavy destructuring is idiomatic
|
||||
- dupl
|
||||
- dupword
|
||||
- embeddedstructfieldcheck
|
||||
- err113
|
||||
- errchkjson
|
||||
- errname
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- exhaustruct
|
||||
- forbidigo
|
||||
- forcetypeassert
|
||||
- funcorder
|
||||
- funlen
|
||||
- gocheckcompilerdirectives # //go:fix is a valid directive in Go 1.24+; linter doesn't know it yet
|
||||
- gochecknoglobals
|
||||
- gochecknoinits
|
||||
- gocognit
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- godoclint
|
||||
- godot
|
||||
- godox
|
||||
- gomoddirectives # replace directives in go.mod are intentional forks
|
||||
- gomodguard # deprecated since v2.12.0; replaced by gomodguard_v2 (enabled via default: all)
|
||||
- goprintffuncname # Ephemeral → Ephemeralf rename is a plugin API breaking change; deferred
|
||||
- gosec
|
||||
- gosmopolitan
|
||||
- iface # identical job interfaces are intentional — type-safe scheduling without coupling
|
||||
- inamedparam
|
||||
- interfacebloat
|
||||
- iotamixing # const blocks intentionally mix iota with explicit values (ABI stability, ASCII)
|
||||
- ireturn
|
||||
- lll
|
||||
- maintidx
|
||||
- mnd
|
||||
- musttag
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr # intentionally dropping errors is common here (graceful degradation, security non-disclosure, fallbacks)
|
||||
- nilnil
|
||||
- nlreturn
|
||||
- noctx
|
||||
- noinlineerr
|
||||
- nolintlint
|
||||
- nonamedreturns
|
||||
- paralleltest
|
||||
- perfsprint
|
||||
- prealloc
|
||||
- predeclared # variable named 'copy' is intentional; already suppressed for revive
|
||||
- promlinter # metric renames are a breaking change; deferred
|
||||
- protogetter
|
||||
- recvcheck
|
||||
- sqlclosecheck # wrapper functions return *sqlx.Rows to callers who close them; not a real leak
|
||||
- tagalign
|
||||
- tagliatelle
|
||||
- testableexamples
|
||||
- testifylint
|
||||
- testpackage
|
||||
- thelper
|
||||
- tparallel
|
||||
- unparam
|
||||
- usestdlibvars
|
||||
- usetesting
|
||||
- varnamelen
|
||||
- wastedassign
|
||||
- wrapcheck
|
||||
- wsl
|
||||
- wsl_v5
|
||||
settings:
|
||||
govet:
|
||||
disable:
|
||||
- fieldalignment
|
||||
enable-all: true
|
||||
unqueryvet:
|
||||
check-sql-builders: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- std-error-handling
|
||||
rules:
|
||||
- linters:
|
||||
- unused
|
||||
text: SetupEnterprise
|
||||
- linters:
|
||||
- revive
|
||||
text: var-naming|error-naming|exported|increment-decrement|error-strings|if-return|unused-parameter|blank-imports|empty-block
|
||||
- linters:
|
||||
- revive
|
||||
path: enterprise
|
||||
text: var-naming|error-naming|exported|increment-decrement|error-strings|if-return|unused-parameter|blank-imports|empty-block
|
||||
- linters:
|
||||
- misspell
|
||||
path: platform/shared/markdown/html_entities.go
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: SA1019
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: ST1023
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: ST1012
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: ST1005
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: QF1011
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: QF1001
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: QF1003
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: QF1012
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: QT1019
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: QF1008
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: QF1004
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: ST1019
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: QF1006
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: QF1002
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: QF1007
|
||||
- linters:
|
||||
- errcheck
|
||||
path: "\
|
||||
channels/store/localcachelayer/autotranslation_layer.go|\
|
||||
channels/store/localcachelayer/channel_layer.go|\
|
||||
channels/store/localcachelayer/channel_layer_test.go|\
|
||||
channels/store/localcachelayer/emoji_layer.go|\
|
||||
channels/store/localcachelayer/emoji_layer_test.go|\
|
||||
channels/store/localcachelayer/file_info_layer.go|\
|
||||
channels/store/localcachelayer/file_info_layer_test.go|\
|
||||
channels/store/localcachelayer/post_layer.go|\
|
||||
channels/store/localcachelayer/post_layer_test.go|\
|
||||
channels/store/localcachelayer/reaction_layer.go|\
|
||||
channels/store/localcachelayer/reaction_layer_test.go|\
|
||||
channels/store/localcachelayer/role_layer.go|\
|
||||
channels/store/localcachelayer/role_layer_test.go|\
|
||||
channels/store/localcachelayer/scheme_layer.go|\
|
||||
channels/store/localcachelayer/scheme_layer_test.go|\
|
||||
channels/store/localcachelayer/team_layer.go|\
|
||||
channels/store/localcachelayer/terms_of_service_layer.go|\
|
||||
channels/store/localcachelayer/terms_of_service_layer_test.go|\
|
||||
channels/store/localcachelayer/user_layer.go|\
|
||||
channels/store/localcachelayer/webhook_layer.go|\
|
||||
channels/store/localcachelayer/webhook_layer_test.go|\
|
||||
channels/store/retrylayer/retrylayer_test.go|\
|
||||
channels/store/searchtest/channel_layer.go|\
|
||||
channels/store/searchtest/cjk_plugins.go|\
|
||||
channels/store/searchtest/file_info_layer.go|\
|
||||
channels/store/searchtest/helper.go|\
|
||||
channels/store/searchtest/post_layer.go|\
|
||||
channels/store/searchtest/user_layer.go|\
|
||||
channels/store/sqlstore/command_store.go|\
|
||||
channels/store/sqlstore/integrity_test.go|\
|
||||
channels/store/sqlstore/session_store.go|\
|
||||
channels/store/sqlstore/store.go|\
|
||||
channels/store/storetest/channel_bookmark.go|\
|
||||
channels/store/storetest/channel_store.go|\
|
||||
channels/store/storetest/channel_store_categories.go|\
|
||||
channels/store/storetest/file_info_store.go|\
|
||||
channels/store/storetest/group_store.go|\
|
||||
channels/store/storetest/job_store.go|\
|
||||
channels/store/storetest/post_persistent_notification_store.go|\
|
||||
channels/store/storetest/post_store.go|\
|
||||
channels/store/storetest/product_notices_store.go|\
|
||||
channels/store/storetest/role_store.go|\
|
||||
channels/store/storetest/scheme_store.go|\
|
||||
channels/store/storetest/shared_channel_store.go|\
|
||||
channels/store/storetest/team_store.go|\
|
||||
channels/store/storetest/thread_store.go|\
|
||||
channels/store/storetest/user_store.go|\
|
||||
cmd/mattermost/commands/cmdtestlib.go|\
|
||||
cmd/mattermost/commands/db.go|\
|
||||
cmd/mattermost/commands/export.go|\
|
||||
cmd/mattermost/commands/import.go|\
|
||||
cmd/mattermost/commands/jobserver.go|\
|
||||
cmd/mattermost/commands/server.go|\
|
||||
cmd/mattermost/commands/server_test.go|\
|
||||
cmd/mattermost/commands/test.go|\
|
||||
cmd/mattermost/commands/version.go|\
|
||||
platform/services/cache/lru_striped.go|\
|
||||
platform/services/cache/lru_striped_bench_test.go|\
|
||||
platform/services/cache/lru_striped_test.go|\
|
||||
platform/services/cache/lru_test.go|\
|
||||
platform/services/docextractor/combine.go|\
|
||||
platform/services/docextractor/pdf.go|\
|
||||
platform/services/imageproxy/local.go|\
|
||||
platform/services/imageproxy/local_test.go|\
|
||||
platform/services/remotecluster/invitation.go|\
|
||||
platform/services/remotecluster/ping_test.go|\
|
||||
platform/services/remotecluster/send_test.go|\
|
||||
platform/services/remotecluster/sendfile.go|\
|
||||
platform/services/remotecluster/sendprofileImage_test.go|\
|
||||
platform/services/sharedchannel/attachment.go|\
|
||||
platform/services/sharedchannel/channelinvite.go|\
|
||||
platform/services/sharedchannel/permalink_test.go|\
|
||||
platform/services/sharedchannel/sync_recv.go|\
|
||||
platform/services/sharedchannel/sync_send.go|\
|
||||
platform/services/telemetry/telemetry.go|\
|
||||
platform/services/telemetry/telemetry_test.go|\
|
||||
platform/services/upgrader/upgrader_linux.go|\
|
||||
platform/services/upgrader/upgrader_linux_test.go|\
|
||||
platform/shared/filestore/filesstore_test.go|\
|
||||
platform/shared/filestore/s3store.go|\
|
||||
platform/shared/filestore/s3store_test.go|\
|
||||
platform/shared/mail/inbucket.go|\
|
||||
platform/shared/mail/mail.go|\
|
||||
platform/shared/mail/mail_test.go|\
|
||||
platform/shared/mfa/mfa.go|\
|
||||
platform/shared/templates/templates_test.go|\
|
||||
public/model/channel.go|\
|
||||
public/model/client4.go|\
|
||||
public/model/config.go|\
|
||||
public/model/incoming_webhook.go|\
|
||||
public/model/license.go|\
|
||||
public/model/link_metadata.go|\
|
||||
public/model/post.go|\
|
||||
public/model/preference.go|\
|
||||
public/model/user.go|\
|
||||
public/model/utils.go|\
|
||||
public/model/websocket_client.go|\
|
||||
public/model/websocket_message_test.go|\
|
||||
public/plugin/http.go|\
|
||||
public/plugin/supervisor_test.go|\
|
||||
public/shared/mlog/default.go|\
|
||||
public/shared/mlog/global_test.go|\
|
||||
public/shared/mlog/mlog.go"
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- goimports
|
||||
settings:
|
||||
gofmt:
|
||||
simplify: true
|
||||
rewrite-rules:
|
||||
- pattern: interface{}
|
||||
replacement: any
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- mock.*
|
||||
issues:
|
||||
max-issues-per-linter: 0 # no maximum
|
||||
max-same-issues: 0 # no maximum
|
||||
178
server/.sass-lint.yml
Normal file
178
server/.sass-lint.yml
Normal file
@@ -0,0 +1,178 @@
|
||||
# sass-lint config generated by make-sass-lint-config v0.1.1
|
||||
#
|
||||
# The following scss-lint Linters are not yet supported by sass-lint:
|
||||
# DisableLinterReason, ElsePlacement, PropertyCount, SelectorDepth
|
||||
# SpaceAroundOperator, TrailingWhitespace, UnnecessaryParentReference, Compass::*
|
||||
#
|
||||
# The following settings/values are unsupported by sass-lint:
|
||||
# Linter Indentation, option "allow_non_nested_indentation"
|
||||
# Linter Indentation, option "character"
|
||||
# Linter NestingDepth, option "ignore_parent_selectors"
|
||||
# Linter PropertySortOrder, option "min_properties"
|
||||
# Linter PropertySortOrder, option "separate_groups"
|
||||
# Linter SpaceBeforeBrace, option "allow_single_line_padding"
|
||||
# Linter VendorPrefix, option "identifier_list"
|
||||
|
||||
files:
|
||||
include: '**/*.scss'
|
||||
options:
|
||||
formatter: stylish
|
||||
merge-default-rules: false
|
||||
rules:
|
||||
bem-depth:
|
||||
- 1
|
||||
- max-depth: 3
|
||||
border-zero:
|
||||
- 1
|
||||
- convention: none
|
||||
brace-style:
|
||||
- 2
|
||||
- allow-single-line: true
|
||||
class-name-format:
|
||||
- 1
|
||||
- convention: hyphenatedbem
|
||||
clean-import-paths:
|
||||
- 1
|
||||
- filename-extension: false
|
||||
leading-underscore: false
|
||||
empty-line-between-blocks:
|
||||
- 2
|
||||
- ignore-single-line-rulesets: true
|
||||
extends-before-declarations: 1
|
||||
extends-before-mixins: 1
|
||||
final-newline:
|
||||
- 1
|
||||
- include: true
|
||||
force-attribute-nesting: 1
|
||||
force-element-nesting: 1
|
||||
force-pseudo-nesting: 1
|
||||
function-name-format:
|
||||
- 1
|
||||
- allow-leading-underscore: true
|
||||
convention: hyphenatedlowercase
|
||||
hex-length:
|
||||
- 1
|
||||
- style: short
|
||||
hex-notation:
|
||||
- 1
|
||||
- style: lowercase
|
||||
id-name-format:
|
||||
- 1
|
||||
- convention: hyphenatedbem
|
||||
indentation:
|
||||
- 2
|
||||
- size: 4
|
||||
leading-zero:
|
||||
- 1
|
||||
- include: false
|
||||
mixin-name-format:
|
||||
- 0
|
||||
- allow-leading-underscore: true
|
||||
convention: hyphenatedlowercase
|
||||
mixins-before-declarations: 1
|
||||
nesting-depth:
|
||||
- 1
|
||||
- max-depth: 4
|
||||
no-color-keyword: 1
|
||||
no-color-literals: 1
|
||||
no-css-comments: 1
|
||||
no-debug: 1
|
||||
no-duplicate-properties: 1
|
||||
no-empty-rulesets: 1
|
||||
no-extends: 0
|
||||
no-ids: 1
|
||||
no-important: 1
|
||||
no-invalid-hex: 1
|
||||
no-mergeable-selectors: 1
|
||||
no-misspelled-properties:
|
||||
- 1
|
||||
- extra-properties: ['overflow-scrolling', 'font-smoothing']
|
||||
no-qualifying-elements:
|
||||
- 1
|
||||
- allow-element-with-attribute: false
|
||||
allow-element-with-class: false
|
||||
allow-element-with-id: false
|
||||
no-trailing-zero: 1
|
||||
no-transition-all: 0
|
||||
no-url-protocols: 1
|
||||
no-vendor-prefixes:
|
||||
- 1
|
||||
- additional-identifiers: []
|
||||
excluded-identifiers: []
|
||||
placeholder-in-extend: 1
|
||||
placeholder-name-format:
|
||||
- 1
|
||||
- convention: hyphenatedlowercase
|
||||
property-sort-order:
|
||||
- 1
|
||||
- ignore-custom-properties: false
|
||||
property-units:
|
||||
- 1
|
||||
- global:
|
||||
- ch
|
||||
- em
|
||||
- ex
|
||||
- rem
|
||||
- cm
|
||||
- in
|
||||
- mm
|
||||
- pc
|
||||
- pt
|
||||
- px
|
||||
- q
|
||||
- vh
|
||||
- vw
|
||||
- vmin
|
||||
- vmax
|
||||
- deg
|
||||
- grad
|
||||
- rad
|
||||
- turn
|
||||
- ms
|
||||
- s
|
||||
- Hz
|
||||
- kHz
|
||||
- dpi
|
||||
- dpcm
|
||||
- dppx
|
||||
- '%'
|
||||
per-property: {}
|
||||
quotes:
|
||||
- 1
|
||||
- style: single
|
||||
shorthand-values:
|
||||
- 1
|
||||
- allowed-shorthands:
|
||||
- 1
|
||||
- 2
|
||||
- 3
|
||||
single-line-per-selector: 2
|
||||
space-after-bang:
|
||||
- 2
|
||||
- include: false
|
||||
space-after-colon:
|
||||
- 2
|
||||
- include: true
|
||||
space-after-comma:
|
||||
- 1
|
||||
- include: true
|
||||
space-before-bang:
|
||||
- 2
|
||||
- include: true
|
||||
space-before-brace:
|
||||
- 2
|
||||
- include: true
|
||||
space-before-colon: 1
|
||||
space-between-parens:
|
||||
- 2
|
||||
- include: false
|
||||
trailing-semicolon: 2
|
||||
url-quotes: 1
|
||||
variable-for-property:
|
||||
- 0
|
||||
- properties: []
|
||||
variable-name-format:
|
||||
- 1
|
||||
- allow-leading-underscore: true
|
||||
convention: hyphenatedlowercase
|
||||
zero-unit: 1
|
||||
8
server/AGENTS.md
Normal file
8
server/AGENTS.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# AGENTS.md
|
||||
|
||||
Never run `go mod tidy` directly. Always run `make modules-tidy` instead — it excludes private enterprise imports that would otherwise break the tidy.
|
||||
|
||||
After editing `i18n/en.json`, always run `make i18n-extract` — it regenerates the file with strings in the required order.
|
||||
|
||||
Prefer request-scoped loggers when logging from request paths. If a method needs to log and does not have access to the request logger, it is reasonable to add `request.CTX` to the method signature when the caller can provide it.
|
||||
In the store layer, do not use `context.Context` in store method signatures. Use `request.CTX` and only call `rctx.Context()` inside internals that require a standard `context.Context`.
|
||||
1076
server/Makefile
Normal file
1076
server/Makefile
Normal file
File diff suppressed because it is too large
Load Diff
3
server/bin/.gitignore
vendored
Normal file
3
server/bin/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Folder required for build process
|
||||
*
|
||||
!.gitignore
|
||||
3
server/build/.gitignore
vendored
Normal file
3
server/build/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
diff.txt
|
||||
latest.sql
|
||||
migrated.sql
|
||||
88
server/build/Dockerfile
Normal file
88
server/build/Dockerfile
Normal file
@@ -0,0 +1,88 @@
|
||||
# First stage - Ubuntu with document processing dependencies and curl for downloading
|
||||
FROM ubuntu:noble-20251013@sha256:c35e29c9450151419d9448b0fd75374fec4fff364a27f176fb458d472dfc9e54 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
|
||||
|
||||
# Declare volumes for mount point directories
|
||||
VOLUME ["/mattermost/data", "/mattermost/logs", "/mattermost/config", "/mattermost/plugins", "/mattermost/client/plugins"]
|
||||
17
server/build/Dockerfile.buildenv
Normal file
17
server/build/Dockerfile.buildenv
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM golang:1.26.7-bookworm@sha256:6ef6e30f0ea5c384f6d111cf856e024e3086bbdcb1779da3f3b3fbba0aea53d2
|
||||
ARG NODE_VERSION=24.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"]
|
||||
17
server/build/Dockerfile.buildenv-fips
Normal file
17
server/build/Dockerfile.buildenv-fips
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM cgr.dev/mattermost.com/go-msft-fips:1.26.7.1-dev@sha256:ae345a37a612265894a7e7f318be45680663346cb7992613916e196daf38e839
|
||||
ARG NODE_VERSION=24.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"]
|
||||
4
server/build/Dockerfile.elasticsearch
Normal file
4
server/build/Dockerfile.elasticsearch
Normal file
@@ -0,0 +1,4 @@
|
||||
ARG ELASTICSEARCH_VERSION=9.0.0
|
||||
FROM docker.elastic.co/elasticsearch/elasticsearch:${ELASTICSEARCH_VERSION}
|
||||
|
||||
RUN /usr/share/elasticsearch/bin/elasticsearch-plugin install --batch analysis-icu analysis-nori analysis-kuromoji analysis-smartcn
|
||||
91
server/build/Dockerfile.fips
Normal file
91
server/build/Dockerfile.fips
Normal file
@@ -0,0 +1,91 @@
|
||||
# First stage - FIPS dev image with dependencies for building
|
||||
FROM cgr.dev/mattermost.com/glibc-openssl-fips:16-dev@sha256:e971e3eb6b5ffd227e17d0fa7944e3430d3e8e24e9587d5488a833139629ab0f 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:16@sha256:a636adba740bb6a53e55c14f7deb979802bcff2203f4aaecfe6f1d159cdfe3a1
|
||||
|
||||
# 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
|
||||
|
||||
# Declare volumes for mount point directories
|
||||
VOLUME ["/mattermost/data", "/mattermost/logs", "/mattermost/config", "/mattermost/plugins", "/mattermost/client/plugins"]
|
||||
4
server/build/Dockerfile.opensearch
Normal file
4
server/build/Dockerfile.opensearch
Normal file
@@ -0,0 +1,4 @@
|
||||
ARG OPENSEARCH_VERSION=3.0.0
|
||||
FROM opensearchproject/opensearch:$OPENSEARCH_VERSION
|
||||
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install analysis-icu analysis-nori analysis-kuromoji analysis-smartcn
|
||||
11
server/build/MIT-COMPILED-LICENSE.md
Normal file
11
server/build/MIT-COMPILED-LICENSE.md
Normal 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
server/build/PRIVACY_POLICY.md
Normal file
1
server/build/PRIVACY_POLICY.md
Normal file
@@ -0,0 +1 @@
|
||||
https://mattermost.com/pl/privacy-policy/
|
||||
21
server/build/README.md
Normal file
21
server/build/README.md
Normal 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`
|
||||
57
server/build/buildenv-tag.sh
Executable file
57
server/build/buildenv-tag.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prints the tag identifying the mattermost-build-server image, e.g.
|
||||
# "go1.26.7-node24.11.1". The image bakes in both toolchains, so the tag names
|
||||
# both. CI resolves this once and propagates it as BUILD_SERVER_TAG.
|
||||
#
|
||||
# The tag comes from the Dockerfiles, since those are what actually determine
|
||||
# the image, and they are checked against .go-version and .nvmrc so the pins
|
||||
# cannot drift apart unnoticed.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/../.."
|
||||
|
||||
DOCKERFILES=(server/build/Dockerfile.buildenv server/build/Dockerfile.buildenv-fips)
|
||||
|
||||
fail() {
|
||||
echo "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# A pin may be more specific than its source of truth: the FIPS base image
|
||||
# carries a fourth component, 1.26.7.1 for Go 1.26.7.
|
||||
satisfies() {
|
||||
[[ "$1" == "$2" || "$1" == "$2."* ]]
|
||||
}
|
||||
|
||||
go_version=$(cat server/.go-version)
|
||||
node_spec=$(cat .nvmrc)
|
||||
|
||||
node_version=""
|
||||
for dockerfile in "${DOCKERFILES[@]}"; do
|
||||
from=$(awk '$1 == "FROM" { print $2; exit }' "${dockerfile}")
|
||||
[[ -n "${from}" ]] || fail "${dockerfile}: no FROM"
|
||||
base_version="${from#*:}"
|
||||
base_version="${base_version%%@*}"
|
||||
base_version="${base_version%-*}"
|
||||
satisfies "${base_version}" "${go_version}" ||
|
||||
fail "${dockerfile}: base image is Go ${base_version}, but server/.go-version says ${go_version}"
|
||||
|
||||
version=$(awk -F= '$1 == "ARG NODE_VERSION" { print $2; exit }' "${dockerfile}")
|
||||
[[ -n "${version}" ]] || fail "${dockerfile}: no ARG NODE_VERSION"
|
||||
satisfies "${version}" "${node_spec}" ||
|
||||
fail "${dockerfile}: ARG NODE_VERSION is ${version}, but .nvmrc says ${node_spec}"
|
||||
|
||||
# A single tag covers both images, so they have to agree.
|
||||
if [[ -n "${node_version}" && "${version}" != "${node_version}" ]]; then
|
||||
fail "${DOCKERFILES[0]} has Node ${node_version} but ${dockerfile} has ${version}"
|
||||
fi
|
||||
node_version="${version}"
|
||||
done
|
||||
|
||||
tag="go${go_version}-node${node_version}"
|
||||
|
||||
# CI interpolates this tag into shell commands and workflow files, so refuse
|
||||
# anything that isn't a well-formed Docker tag.
|
||||
[[ "${tag}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || fail "invalid tag: ${tag}"
|
||||
|
||||
echo "${tag}"
|
||||
64
server/build/docker-compose-generator/main.go
Normal file
64
server/build/docker-compose-generator/main.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
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,
|
||||
"azurite": 10000,
|
||||
"inbucket": 9001,
|
||||
"openldap": 389,
|
||||
"elasticsearch": 9200,
|
||||
"opensearch": 9201,
|
||||
"redis": 6379,
|
||||
"dejavu": 1358,
|
||||
"keycloak": 8080,
|
||||
"prometheus": 9090,
|
||||
"grafana": 3000,
|
||||
"loki": 3100,
|
||||
"otel-collector": 13133,
|
||||
}
|
||||
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))
|
||||
}
|
||||
167
server/build/docker-compose.common.yml
Normal file
167
server/build/docker-compose.common.yml
Normal file
@@ -0,0 +1,167 @@
|
||||
x-logging: &default-logging
|
||||
driver: "json-file"
|
||||
options:
|
||||
tag: "{{.Name}}"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: "postgres:15"
|
||||
logging: *default-logging
|
||||
restart: always
|
||||
networks:
|
||||
- mm-test
|
||||
environment:
|
||||
POSTGRES_USER: mmuser
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mostest_password}
|
||||
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:Z"
|
||||
- "./docker/postgres_node_database.sql:/docker-entrypoint-initdb.d/postgres_node_database.sql:Z"
|
||||
healthcheck:
|
||||
test: [ "CMD", "pg_isready", "-h", "localhost" ]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
minio:
|
||||
image: "cgr.dev/chainguard/minio:latest"
|
||||
logging: *default-logging
|
||||
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=
|
||||
azurite:
|
||||
image: "mcr.microsoft.com/azure-storage/azurite:3.34.0"
|
||||
logging: *default-logging
|
||||
command: "azurite-blob --blobHost 0.0.0.0 --blobPort 10000 --skipApiVersionCheck"
|
||||
networks:
|
||||
- mm-test
|
||||
inbucket:
|
||||
image: "inbucket/inbucket:3.1.1"
|
||||
logging: *default-logging
|
||||
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"
|
||||
logging: *default-logging
|
||||
restart: always
|
||||
networks:
|
||||
- mm-test
|
||||
environment:
|
||||
LDAP_TLS_VERIFY_CLIENT: "never"
|
||||
LDAP_ORGANISATION: "Mattermost Test"
|
||||
LDAP_DOMAIN: "mm.test.com"
|
||||
LDAP_ADMIN_PASSWORD: "mostest_password"
|
||||
elasticsearch:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile.elasticsearch
|
||||
args:
|
||||
ELASTICSEARCH_VERSION: ${ELASTICSEARCH_VERSION:-9.0.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
|
||||
args:
|
||||
OPENSEARCH_VERSION: ${OPENSEARCH_VERSION:-3.0.0}
|
||||
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"
|
||||
DISABLE_INSTALL_DEMO_CONFIG: "true"
|
||||
OPENSEARCH_INITIAL_ADMIN_PASSWORD: "Test@dmin_123"
|
||||
OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m"
|
||||
redis:
|
||||
image: "redis:7.4.0"
|
||||
logging: *default-logging
|
||||
networks:
|
||||
- mm-test
|
||||
dejavu:
|
||||
image: "appbaseio/dejavu:3.4.2"
|
||||
logging: *default-logging
|
||||
networks:
|
||||
- mm-test
|
||||
keycloak:
|
||||
image: "quay.io/keycloak/keycloak:23.0.7"
|
||||
logging: *default-logging
|
||||
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:Z"
|
||||
prometheus:
|
||||
image: "prom/prometheus:v2.46.0"
|
||||
logging: *default-logging
|
||||
user: root
|
||||
volumes:
|
||||
- "./docker/prometheus.yml:/etc/prometheus/prometheus.yml:Z"
|
||||
- "/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"
|
||||
logging: *default-logging
|
||||
volumes:
|
||||
- "./docker/grafana/grafana.ini:/etc/grafana/grafana.ini:Z"
|
||||
- "./docker/grafana/provisioning:/etc/grafana/provisioning:Z"
|
||||
- "./docker/grafana/dashboards:/var/lib/grafana/dashboards:Z"
|
||||
networks:
|
||||
- mm-test
|
||||
loki:
|
||||
image: "grafana/loki:3.0.0"
|
||||
logging: *default-logging
|
||||
volumes:
|
||||
- "./docker/loki/loki-config.yaml:/etc/loki/local-config.yaml:Z"
|
||||
networks:
|
||||
- mm-test
|
||||
otel-collector:
|
||||
image: "otel/opentelemetry-collector-contrib:0.145.0"
|
||||
logging: *default-logging
|
||||
user: "0:0"
|
||||
volumes:
|
||||
- "./docker/otel-collector/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:Z"
|
||||
- "/var/lib/docker/containers:/var/lib/docker/containers:ro"
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
- "../logs:/logs:ro,Z"
|
||||
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
||||
networks:
|
||||
- mm-test
|
||||
77
server/build/docker-compose.yml
Normal file
77
server/build/docker-compose.yml
Normal file
@@ -0,0 +1,77 @@
|
||||
services:
|
||||
postgres:
|
||||
extends:
|
||||
file: docker-compose.common.yml
|
||||
service: postgres
|
||||
tmpfs: /var/lib/postgresql/data
|
||||
minio:
|
||||
extends:
|
||||
file: docker-compose.common.yml
|
||||
service: minio
|
||||
azurite:
|
||||
extends:
|
||||
file: docker-compose.common.yml
|
||||
service: azurite
|
||||
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
|
||||
otel-collector:
|
||||
extends:
|
||||
file: docker-compose.common.yml
|
||||
service: otel-collector
|
||||
|
||||
start_dependencies:
|
||||
image: mattermost/mattermost-wait-for-dep:latest
|
||||
networks:
|
||||
- mm-test
|
||||
depends_on:
|
||||
- postgres
|
||||
- minio
|
||||
- azurite
|
||||
- inbucket
|
||||
- openldap
|
||||
- elasticsearch
|
||||
- opensearch
|
||||
- redis
|
||||
command: postgres:5432 minio:9000 azurite:10000 inbucket:9001 openldap:389 elasticsearch:9200 opensearch:9201 redis:6379
|
||||
|
||||
networks:
|
||||
mm-test:
|
||||
driver: bridge
|
||||
38
server/build/docker-preview/Dockerfile
Normal file
38
server/build/docker-preview/Dockerfile
Normal 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_password
|
||||
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
|
||||
19
server/build/docker-preview/README.md
Normal file
19
server/build/docker-preview/README.md
Normal 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 it’s using a known password string and contains other non-production configuration settings, and it does not support upgrade. If you’re 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
|
||||
```
|
||||
28
server/build/docker-preview/config_docker.json
Normal file
28
server/build/docker-preview/config_docker.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"ServiceSettings": {
|
||||
"EnableOAuthServiceProvider": true,
|
||||
"EnableDeveloper": false,
|
||||
"EnableGifPicker": true
|
||||
},
|
||||
"SqlSettings": {
|
||||
"DriverName": "postgres",
|
||||
"DataSource": "postgres://mmuser:mostest_password@localhost/mattermost_test?sslmode=disable\u0026connect_timeout=10\u0026binary_parameters=yes"
|
||||
},
|
||||
"FileSettings": {
|
||||
"DriverName": "local",
|
||||
"Directory": "/mm/mattermost-data",
|
||||
"PublicLinkSalt": ""
|
||||
},
|
||||
"EmailSettings": {
|
||||
"SMTPServer": "",
|
||||
"SMTPPort": "",
|
||||
"PushNotificationContents": "generic"
|
||||
},
|
||||
"ElasticsearchSettings": {
|
||||
"ConnectionUrl": ""
|
||||
},
|
||||
"PluginSettings": {
|
||||
"EnableUploads": true,
|
||||
"PluginStates": {}
|
||||
}
|
||||
}
|
||||
18
server/build/docker-preview/docker-entry.sh
Normal file
18
server/build/docker-preview/docker-entry.sh
Normal 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
|
||||
15
server/build/docker/grafana/dashboards/README.md
Normal file
15
server/build/docker/grafana/dashboards/README.md
Normal 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.
|
||||
428
server/build/docker/grafana/dashboards/home.json
Normal file
428
server/build/docker/grafana/dashboards/home.json
Normal 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": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [
|
||||
{
|
||||
"options": {
|
||||
"0": {
|
||||
"color": "red",
|
||||
"index": 0,
|
||||
"text": "Offline"
|
||||
},
|
||||
"1": {
|
||||
"color": "green",
|
||||
"index": 1,
|
||||
"text": "Online"
|
||||
}
|
||||
},
|
||||
"type": "value"
|
||||
}
|
||||
],
|
||||
"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": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-postgres\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-postgres\"})) < 15) or vector(0)",
|
||||
"legendFormat": "Postgres",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-inbucket\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-inbucket\"})) < 15) or vector(0)",
|
||||
"legendFormat": "Inbucket",
|
||||
"range": true,
|
||||
"refId": "C"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-minio\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-minio\"})) < 15) or vector(0)",
|
||||
"legendFormat": "MinIO",
|
||||
"range": true,
|
||||
"refId": "D"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-openldap\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-openldap\"})) < 15) or vector(0)",
|
||||
"legendFormat": "OpenLDAP",
|
||||
"range": true,
|
||||
"refId": "E"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-dejavu\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-dejavu\"})) < 15) or vector(0)",
|
||||
"legendFormat": "DejaVu",
|
||||
"range": true,
|
||||
"refId": "F"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-keycloak\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-keycloak\"})) < 15) or vector(0)",
|
||||
"legendFormat": "Keycloak",
|
||||
"range": true,
|
||||
"refId": "G"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-elasticsearch\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-elasticsearch\"})) < 15) or vector(0)",
|
||||
"legendFormat": "Elasticsearch",
|
||||
"range": true,
|
||||
"refId": "H"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-prometheus\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-prometheus\"})) < 15) or vector(0)",
|
||||
"legendFormat": "Prometheus",
|
||||
"range": true,
|
||||
"refId": "I"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-grafana\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-grafana\"})) < 15) or vector(0)",
|
||||
"legendFormat": "Grafana",
|
||||
"range": true,
|
||||
"refId": "J"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-loki\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-loki\"})) < 15) or vector(0)",
|
||||
"legendFormat": "Loki",
|
||||
"range": true,
|
||||
"refId": "K"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-otel-collector\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-otel-collector\"})) < 15) or vector(0)",
|
||||
"legendFormat": "OTel Collector",
|
||||
"range": true,
|
||||
"refId": "L"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "PBFA97CFB590B2093"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "max(container_uptime_seconds{container_name=\"mattermost-redis\"}) >bool 0 and on() (time() - max(timestamp(container_uptime_seconds{container_name=\"mattermost-redis\"})) < 15) or vector(0)",
|
||||
"legendFormat": "Redis",
|
||||
"range": true,
|
||||
"refId": "M"
|
||||
}
|
||||
],
|
||||
"title": "Docker containers",
|
||||
"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": ""
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,456 @@
|
||||
{
|
||||
"__elements": [],
|
||||
"__requires": [
|
||||
{
|
||||
"type": "grafana",
|
||||
"id": "grafana",
|
||||
"name": "Grafana",
|
||||
"version": "8.3.4"
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "graph",
|
||||
"name": "Graph (old)",
|
||||
"version": ""
|
||||
},
|
||||
{
|
||||
"type": "datasource",
|
||||
"id": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
],
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": "-- Grafana --",
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"target": {
|
||||
"limit": 100,
|
||||
"matchAny": false,
|
||||
"tags": [],
|
||||
"type": "dashboard"
|
||||
},
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"iteration": 1646759397232,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"description": "",
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"hiddenSeries": false,
|
||||
"id": 2,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"options": {
|
||||
"alertThreshold": true
|
||||
},
|
||||
"percentage": false,
|
||||
"pluginVersion": "8.3.4",
|
||||
"pointradius": 2,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "histogram_quantile (\n 0.99,\n sum by (le,instance)(\n rate(mattermost_db_store_time_bucket{instance=~\"$server\",method=\"ThreadStore.GetThreadsForUser\"}[5m])\n )\n)",
|
||||
"interval": "",
|
||||
"legendFormat": "p99-{{instance}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "histogram_quantile (\n 0.50,\n sum by (le,instance)(\n rate(mattermost_db_store_time_bucket{instance=~\"$server\",method=\"ThreadStore.GetThreadsForUser\"}[5m])\n )\n)",
|
||||
"interval": "",
|
||||
"legendFormat": "p50-{{instance}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeRegions": [],
|
||||
"title": "GetThreadsForUser duration",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"mode": "time",
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"$$hashKey": "object:98",
|
||||
"format": "s",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:99",
|
||||
"format": "short",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 0
|
||||
},
|
||||
"hiddenSeries": false,
|
||||
"id": 4,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"options": {
|
||||
"alertThreshold": true
|
||||
},
|
||||
"percentage": false,
|
||||
"pluginVersion": "8.3.4",
|
||||
"pointradius": 2,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(mattermost_db_store_time_count{instance=~\"$server\",method=\"ThreadStore.GetThreadsForUser\"}[1m])) by (instance)",
|
||||
"interval": "",
|
||||
"legendFormat": "count-{{instance}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "sum(rate(mattermost_db_store_time_count{instance=~\"$server\",method=\"ThreadStore.GetThreadsForUser\"}[1m]))",
|
||||
"interval": "",
|
||||
"legendFormat": "Total",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeRegions": [],
|
||||
"title": "GetThreadsForUser Requests Per Second",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"mode": "time",
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"$$hashKey": "object:396",
|
||||
"format": "short",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:397",
|
||||
"format": "short",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 9
|
||||
},
|
||||
"hiddenSeries": false,
|
||||
"id": 6,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"options": {
|
||||
"alertThreshold": true
|
||||
},
|
||||
"percentage": false,
|
||||
"pluginVersion": "8.3.4",
|
||||
"pointradius": 2,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "histogram_quantile (\n 0.99,\n sum by (le,instance)(\n rate(mattermost_db_store_time_bucket{instance=~\"$server\",method=\"ThreadStore.MarkAllAsReadByChannels\"}[5m])\n )\n)",
|
||||
"interval": "",
|
||||
"legendFormat": "p99-{{instance}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "histogram_quantile (\n 0.50,\n sum by (le,instance)(\n rate(mattermost_db_store_time_bucket{instance=~\"$server\",method=\"ThreadStore.MarkAllAsReadByChannels\"}[5m])\n )\n)",
|
||||
"interval": "",
|
||||
"legendFormat": "p50-{{instance}}",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeRegions": [],
|
||||
"title": "MarkAllAsReadByChannels duration",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"mode": "time",
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"$$hashKey": "object:504",
|
||||
"format": "s",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:505",
|
||||
"format": "short",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"aliasColors": {},
|
||||
"bars": false,
|
||||
"dashLength": 10,
|
||||
"dashes": false,
|
||||
"fill": 1,
|
||||
"fillGradient": 0,
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 9
|
||||
},
|
||||
"hiddenSeries": false,
|
||||
"id": 8,
|
||||
"legend": {
|
||||
"avg": false,
|
||||
"current": false,
|
||||
"max": false,
|
||||
"min": false,
|
||||
"show": true,
|
||||
"total": false,
|
||||
"values": false
|
||||
},
|
||||
"lines": true,
|
||||
"linewidth": 1,
|
||||
"nullPointMode": "null",
|
||||
"options": {
|
||||
"alertThreshold": true
|
||||
},
|
||||
"percentage": false,
|
||||
"pluginVersion": "8.3.4",
|
||||
"pointradius": 2,
|
||||
"points": false,
|
||||
"renderer": "flot",
|
||||
"seriesOverrides": [],
|
||||
"spaceLength": 10,
|
||||
"stack": false,
|
||||
"steppedLine": false,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "sum(rate(mattermost_db_store_time_count{instance=~\"$server\",method=\"ThreadStore.MarkAllAsReadByChannels\"}[1m])) by (instance)",
|
||||
"interval": "",
|
||||
"legendFormat": "count-{{instance}}",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"exemplar": true,
|
||||
"expr": "sum(rate(mattermost_db_store_time_count{instance=~\"$server\",method=\"ThreadStore.MarkAllAsReadByChannels\"}[1m]))",
|
||||
"interval": "",
|
||||
"legendFormat": "Total",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"thresholds": [],
|
||||
"timeRegions": [],
|
||||
"title": "MarkAllAsReadByChannels Requests Per Second",
|
||||
"tooltip": {
|
||||
"shared": true,
|
||||
"sort": 0,
|
||||
"value_type": "individual"
|
||||
},
|
||||
"type": "graph",
|
||||
"xaxis": {
|
||||
"mode": "time",
|
||||
"show": true,
|
||||
"values": []
|
||||
},
|
||||
"yaxes": [
|
||||
{
|
||||
"$$hashKey": "object:714",
|
||||
"format": "short",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
},
|
||||
{
|
||||
"$$hashKey": "object:715",
|
||||
"format": "short",
|
||||
"logBase": 1,
|
||||
"show": true
|
||||
}
|
||||
],
|
||||
"yaxis": {
|
||||
"align": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"refresh": "10s",
|
||||
"schemaVersion": 34,
|
||||
"style": "dark",
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"definition": "label_values(instance)",
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": "server",
|
||||
"multi": true,
|
||||
"name": "server",
|
||||
"options": [],
|
||||
"query": {
|
||||
"query": "label_values(instance)",
|
||||
"refId": "Prometheus-server-Variable-Query"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"tagValuesQuery": "",
|
||||
"tagsQuery": "",
|
||||
"type": "query",
|
||||
"useTags": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-3h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Collapsed Reply Threads Performance",
|
||||
"uid": "cZY9yFJ7z",
|
||||
"version": 5,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"__elements": {},
|
||||
"__requires": [
|
||||
{
|
||||
"type": "grafana",
|
||||
"id": "grafana",
|
||||
"name": "Grafana",
|
||||
"version": "10.4.2"
|
||||
},
|
||||
{
|
||||
"type": "datasource",
|
||||
"id": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"type": "panel",
|
||||
"id": "timeseries",
|
||||
"name": "Time series",
|
||||
"version": ""
|
||||
}
|
||||
],
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"description": "The average amount of a machine's CPU used by the given Desktop App process over the measuring interval (usually 1 minute). ",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 10,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "histogram_quantile($percentile / 100, sum by(le) (increase(mattermost_desktopapp_cpu_usage_bucket{processName=~\"$processName\",version=~\"$version\",platform=~\"$platform\"}[$decay])))",
|
||||
"instant": false,
|
||||
"legendFormat": "[[percentile]]th Percentile",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "CPU Usage (%)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"description": "The number of megabytes used by a Desktop App process at the time of measurement.",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic-by-name"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 10
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"editorMode": "code",
|
||||
"expr": "histogram_quantile($percentile / 100, sum by(le) (increase(mattermost_desktopapp_memory_usage_bucket{processName=~\"$processName\",version=~\"$version\",platform=~\"$platform\"}[$decay])))",
|
||||
"instant": false,
|
||||
"legendFormat": "[[percentile]]th Percentile",
|
||||
"range": true,
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Memory Usage (MBs)",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "1m",
|
||||
"schemaVersion": 39,
|
||||
"tags": [],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"definition": "label_values(mattermost_desktopapp_cpu_usage_bucket,processName)",
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": "Process",
|
||||
"multi": true,
|
||||
"name": "processName",
|
||||
"options": [],
|
||||
"query": {
|
||||
"qryType": 1,
|
||||
"query": "label_values(mattermost_desktopapp_cpu_usage_bucket,processName)",
|
||||
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"type": "query"
|
||||
},
|
||||
{
|
||||
"current": {},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"definition": "label_values(mattermost_desktopapp_cpu_usage_bucket,version)",
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"label": "App Version",
|
||||
"multi": true,
|
||||
"name": "version",
|
||||
"options": [],
|
||||
"query": {
|
||||
"qryType": 1,
|
||||
"query": "label_values(mattermost_desktopapp_cpu_usage_bucket,version)",
|
||||
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"type": "query"
|
||||
},
|
||||
{
|
||||
"current": {},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "Prometheus"
|
||||
},
|
||||
"definition": "label_values(mattermost_desktopapp_cpu_usage_bucket,platform)",
|
||||
"hide": 0,
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"name": "platform",
|
||||
"options": [],
|
||||
"query": {
|
||||
"qryType": 1,
|
||||
"query": "label_values(mattermost_desktopapp_cpu_usage_bucket,platform)",
|
||||
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||
},
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"sort": 0,
|
||||
"type": "query"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": true,
|
||||
"text": "50",
|
||||
"value": "50"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Percentile",
|
||||
"multi": false,
|
||||
"name": "percentile",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "50",
|
||||
"value": "50"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "75",
|
||||
"value": "75"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "90",
|
||||
"value": "90"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "99",
|
||||
"value": "99"
|
||||
}
|
||||
],
|
||||
"query": "50,75,90,99",
|
||||
"queryValue": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "30m",
|
||||
"value": "30m"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Decay Time",
|
||||
"multi": false,
|
||||
"name": "decay",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "30m",
|
||||
"value": "30m"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "1h",
|
||||
"value": "1h"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "3h",
|
||||
"value": "3h"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "6h",
|
||||
"value": "6h"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "12h",
|
||||
"value": "12h"
|
||||
},
|
||||
{
|
||||
"selected": false,
|
||||
"text": "1d",
|
||||
"value": "1d"
|
||||
}
|
||||
],
|
||||
"query": "30m,1h,3h,6h,12h,1d",
|
||||
"queryValue": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "custom"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-24h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "Desktop App Metrics",
|
||||
"uid": "fe12lkd7062v4a",
|
||||
"version": 4,
|
||||
"weekStart": ""
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
9
server/build/docker/grafana/grafana.ini
Normal file
9
server/build/docker/grafana/grafana.ini
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
39
server/build/docker/keycloak/README.md
Normal file
39
server/build/docker/keycloak/README.md
Normal 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
|
||||
```
|
||||
5
server/build/docker/keycloak/kc-healthcheck.sh
Executable file
5
server/build/docker/keycloak/kc-healthcheck.sh
Executable 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
|
||||
3
server/build/docker/keycloak/keycloak.crt
Normal file
3
server/build/docker/keycloak/keycloak.crt
Normal 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-----
|
||||
36
server/build/docker/keycloak/ldap.mmsettings.json
Normal file
36
server/build/docker/keycloak/ldap.mmsettings.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"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_password",
|
||||
"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": "",
|
||||
"Trace": false
|
||||
}
|
||||
}
|
||||
14
server/build/docker/keycloak/openid.mmsettings.json
Normal file
14
server/build/docker/keycloak/openid.mmsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
2057
server/build/docker/keycloak/realm-export.json
Normal file
2057
server/build/docker/keycloak/realm-export.json
Normal file
File diff suppressed because it is too large
Load Diff
35
server/build/docker/keycloak/saml.mmsettings.json
Normal file
35
server/build/docker/keycloak/saml.mmsettings.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
45
server/build/docker/loki/loki-config.yaml
Normal file
45
server/build/docker/loki/loki-config.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
|
||||
common:
|
||||
ring:
|
||||
instance_addr: 127.0.0.1
|
||||
kvstore:
|
||||
store: inmemory
|
||||
replication_factor: 1
|
||||
path_prefix: /loki
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: "2026-01-01"
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: loki_index_
|
||||
period: 24h
|
||||
|
||||
storage_config:
|
||||
filesystem:
|
||||
directory: /loki/chunks
|
||||
|
||||
distributor:
|
||||
otlp_config:
|
||||
default_resource_attributes_as_index_labels:
|
||||
- service.name
|
||||
- service.instance.id
|
||||
- job
|
||||
- app
|
||||
- container.name
|
||||
|
||||
ingester:
|
||||
max_chunk_age: 480h
|
||||
|
||||
limits_config:
|
||||
allow_structured_metadata: true
|
||||
unordered_writes: true
|
||||
reject_old_samples: false
|
||||
ingestion_rate_mb: 64
|
||||
ingestion_burst_size_mb: 64
|
||||
38
server/build/docker/nginx/default.conf
Normal file
38
server/build/docker/nginx/default.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
receivers:
|
||||
filelog/mattermost:
|
||||
include:
|
||||
- /logs/*.log
|
||||
start_at: beginning
|
||||
operators:
|
||||
- type: json_parser
|
||||
timestamp:
|
||||
parse_from: attributes.timestamp
|
||||
layout_type: gotime
|
||||
layout: '2006-01-02 15:04:05.000 -07:00'
|
||||
severity:
|
||||
parse_from: attributes.level
|
||||
|
||||
filelog/docker:
|
||||
include:
|
||||
- /var/lib/docker/containers/*/*-json.log
|
||||
start_at: beginning
|
||||
operators:
|
||||
- type: json_parser
|
||||
timestamp:
|
||||
parse_from: attributes.time
|
||||
layout_type: gotime
|
||||
layout: '2006-01-02T15:04:05.999999999Z'
|
||||
- type: move
|
||||
from: attributes.log
|
||||
to: body
|
||||
- type: move
|
||||
from: attributes.attrs.tag
|
||||
to: resource["container.name"]
|
||||
|
||||
docker_stats:
|
||||
endpoint: unix:///var/run/docker.sock
|
||||
collection_interval: 10s
|
||||
metrics:
|
||||
container.uptime:
|
||||
enabled: true
|
||||
|
||||
processors:
|
||||
resource/mattermost:
|
||||
attributes:
|
||||
- key: service.name
|
||||
value: mattermost
|
||||
action: upsert
|
||||
- key: job
|
||||
value: mattermost
|
||||
action: upsert
|
||||
- key: app
|
||||
value: mattermost
|
||||
action: upsert
|
||||
|
||||
resource/docker:
|
||||
attributes:
|
||||
- key: job
|
||||
value: docker
|
||||
action: upsert
|
||||
|
||||
exporters:
|
||||
otlp_http/loki:
|
||||
endpoint: http://loki:3100/otlp
|
||||
prometheus:
|
||||
endpoint: 0.0.0.0:8889
|
||||
resource_to_telemetry_conversion:
|
||||
enabled: true
|
||||
|
||||
extensions:
|
||||
health_check:
|
||||
endpoint: 0.0.0.0:13133
|
||||
|
||||
service:
|
||||
extensions: [health_check]
|
||||
pipelines:
|
||||
logs/mattermost:
|
||||
receivers: [filelog/mattermost]
|
||||
processors: [resource/mattermost]
|
||||
exporters: [otlp_http/loki]
|
||||
logs/docker:
|
||||
receivers: [filelog/docker]
|
||||
processors: [resource/docker]
|
||||
exporters: [otlp_http/loki]
|
||||
metrics/docker:
|
||||
receivers: [docker_stats]
|
||||
exporters: [prometheus]
|
||||
8
server/build/docker/postgres.conf
Normal file
8
server/build/docker/postgres.conf
Normal 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'
|
||||
2
server/build/docker/postgres_node_database.sql
Normal file
2
server/build/docker/postgres_node_database.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
CREATE DATABASE mattermost_node_test;
|
||||
GRANT ALL PRIVILEGES ON DATABASE mattermost_node_test TO mmuser;
|
||||
21
server/build/docker/prometheus.yml
Normal file
21
server/build/docker/prometheus.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
global:
|
||||
scrape_interval: 5s
|
||||
evaluation_interval: 60s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'mattermost'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:8067']
|
||||
- job_name: 'otel-collector'
|
||||
static_configs:
|
||||
- targets: ['otel-collector:8889']
|
||||
- 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'
|
||||
1
server/build/dotenv/test-schema-validation.env
Normal file
1
server/build/dotenv/test-schema-validation.env
Normal file
@@ -0,0 +1 @@
|
||||
IS_CI=true
|
||||
14
server/build/dotenv/test.env
Normal file
14
server/build/dotenv/test.env
Normal file
@@ -0,0 +1,14 @@
|
||||
TEST_DATABASE_POSTGRESQL_DSN=postgres://mmuser:mostest_password@postgres:5432/mattermost_test?sslmode=disable&connect_timeout=10
|
||||
GOBIN=/mattermost/server/bin
|
||||
|
||||
CI_INBUCKET_HOST=inbucket
|
||||
CI_MINIO_HOST=minio
|
||||
CI_AZURITE_HOST=azurite
|
||||
CI_INBUCKET_PORT=9001
|
||||
CI_MINIO_PORT=9000
|
||||
CI_AZURITE_PORT=10000
|
||||
CI_INBUCKET_SMTP_PORT=10025
|
||||
CI_LDAP_HOST=openldap
|
||||
IS_CI=true
|
||||
MM_EMAILSETTINGS_SMTPSERVER=inbucket
|
||||
MM_ELASTICSEARCHSETTINGS_CONNECTIONURL=http://elasticsearch:9200
|
||||
7
server/build/entrypoint.sh
Executable file
7
server/build/entrypoint.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ "${1:0:1}" = '-' ]; then
|
||||
set -- mattermost "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
72
server/build/local-test-env.sh
Executable file
72
server/build/local-test-env.sh
Executable 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_password'
|
||||
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_password'
|
||||
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_password'
|
||||
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_password'
|
||||
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_password'
|
||||
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_password'
|
||||
|
||||
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_password@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
|
||||
28
server/build/notice-file/Readme.md
Normal file
28
server/build/notice-file/Readme.md
Normal 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`. |
|
||||
11
server/build/notice-file/config.yaml
Normal file
11
server/build/notice-file/config.yaml
Normal 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/shared"
|
||||
includeDevDependencies: false
|
||||
4
server/build/passwd
Normal file
4
server/build/passwd
Normal 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
|
||||
52
server/build/plugin-production-public-key.gpg
Normal file
52
server/build/plugin-production-public-key.gpg
Normal 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-----
|
||||
341
server/build/release.mk
Normal file
341
server/build/release.mk
Normal file
@@ -0,0 +1,341 @@
|
||||
.PHONY: dist
|
||||
dist: | check-style test package
|
||||
|
||||
.PHONY: build-linux
|
||||
build-linux: build-linux-amd64 build-linux-arm64
|
||||
|
||||
.PHONY: build-linux-amd64
|
||||
build-linux-amd64: setup-go-work
|
||||
@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 -qE "func_go_openssl_OpenSSL_version|_mkcgo_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 -qE "func_go_openssl_OpenSSL_version|_mkcgo_OpenSSL_version" || (echo "ERROR: FIPS mmctl binary missing OpenSSL integration" && exit 1)
|
||||
endif
|
||||
|
||||
.PHONY: build-linux-arm64
|
||||
build-linux-arm64: setup-go-work
|
||||
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
|
||||
|
||||
.PHONY: build-osx
|
||||
build-osx: setup-go-work
|
||||
@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
|
||||
|
||||
.PHONY: build-freebsd
|
||||
build-freebsd: build-freebsd-amd64 build-freebsd-arm64
|
||||
|
||||
.PHONY: build-freebsd-amd64
|
||||
build-freebsd-amd64: setup-go-work
|
||||
@echo Build FreeBSD amd64
|
||||
ifeq ($(BUILDER_GOOS_GOARCH),"freebsd_amd64")
|
||||
env GOOS=freebsd GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./...
|
||||
else
|
||||
mkdir -p $(GOBIN)/freebsd_amd64
|
||||
env GOOS=freebsd GOARCH=amd64 $(GO) build -o $(GOBIN)/freebsd_amd64 $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./...
|
||||
endif
|
||||
|
||||
.PHONY: build-freebsd-arm64
|
||||
build-freebsd-arm64: setup-go-work
|
||||
@echo Build FreeBSD arm64
|
||||
ifeq ($(BUILDER_GOOS_GOARCH),"freebsd_arm64")
|
||||
env GOOS=freebsd GOARCH=arm64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./...
|
||||
else
|
||||
mkdir -p $(GOBIN)/freebsd_arm64
|
||||
env GOOS=freebsd GOARCH=arm64 $(GO) build -o $(GOBIN)/freebsd_arm64 $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./...
|
||||
endif
|
||||
|
||||
.PHONY: build-windows
|
||||
build-windows: setup-go-work
|
||||
@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
|
||||
|
||||
.PHONY: build-cmd-linux
|
||||
build-cmd-linux: setup-go-work
|
||||
@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 -qE "func_go_openssl_OpenSSL_version|_mkcgo_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 -qE "func_go_openssl_OpenSSL_version|_mkcgo_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
|
||||
|
||||
.PHONY: build-cmd-osx
|
||||
build-cmd-osx: setup-go-work
|
||||
@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
|
||||
|
||||
.PHONY: build-cmd-freebsd
|
||||
build-cmd-freebsd: setup-go-work
|
||||
@echo Build CMD FreeBSD amd64
|
||||
ifeq ($(BUILDER_GOOS_GOARCH),"freebsd_amd64")
|
||||
env GOOS=freebsd GOARCH=amd64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./cmd/...
|
||||
else
|
||||
mkdir -p $(GOBIN)/freebsd_amd64
|
||||
env GOOS=freebsd GOARCH=amd64 $(GO) build -o $(GOBIN)/freebsd_amd64 $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./cmd/...
|
||||
endif
|
||||
@echo Build CMD FreeBSD arm64
|
||||
ifeq ($(BUILDER_GOOS_GOARCH),"freebsd_arm64")
|
||||
env GOOS=freebsd GOARCH=arm64 $(GO) build -o $(GOBIN) $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./cmd/...
|
||||
else
|
||||
mkdir -p $(GOBIN)/freebsd_arm64
|
||||
env GOOS=freebsd GOARCH=arm64 $(GO) build -o $(GOBIN)/freebsd_arm64 $(GOFLAGS) -trimpath -tags production -ldflags '$(LDFLAGS)' ./cmd/...
|
||||
endif
|
||||
|
||||
.PHONY: build-cmd-windows
|
||||
build-cmd-windows: setup-go-work
|
||||
@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.
|
||||
.PHONY: build
|
||||
build: setup-go-work build-client build-linux
|
||||
|
||||
# Only build linux by default. Other platforms can be built by specifying the platform.
|
||||
.PHONY: build-cmd
|
||||
build-cmd: setup-go-work build-client build-cmd-linux
|
||||
|
||||
.PHONY: build-client
|
||||
build-client:
|
||||
@echo Building mattermost web app
|
||||
|
||||
cd $(BUILD_WEBAPP_DIR) && $(MAKE) dist
|
||||
|
||||
.PHONY: package-prep
|
||||
package-prep: setup-go-work
|
||||
@ 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/
|
||||
mkdir -p $(DIST_PATH)/i18n
|
||||
cp -L i18n/*.json $(DIST_PATH)/i18n
|
||||
|
||||
@# 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
|
||||
|
||||
.PHONY: fetch-prepackaged-plugins
|
||||
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"
|
||||
|
||||
.PHONY: package-general
|
||||
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
|
||||
|
||||
.PHONY: package-plugins
|
||||
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
|
||||
|
||||
.PHONY: package-osx-amd64
|
||||
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
|
||||
|
||||
.PHONY: package-osx-arm64
|
||||
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
|
||||
|
||||
.PHONY: package-osx
|
||||
package-osx: package-osx-amd64 package-osx-arm64
|
||||
|
||||
.PHONY: package-freebsd-amd64
|
||||
package-freebsd-amd64: package-prep
|
||||
DIST_PATH_GENERIC=$(DIST_PATH_FREEBSD_AMD64) CURRENT_PACKAGE_ARCH=freebsd_amd64 MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general
|
||||
@# Package
|
||||
tar -C $(DIST_PATH_FREEBSD_AMD64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-freebsd-amd64.tar.gz mattermost ../mattermost
|
||||
@# Cleanup
|
||||
rm -rf $(DIST_ROOT)/freebsd_amd64
|
||||
|
||||
.PHONY: package-freebsd-arm64
|
||||
package-freebsd-arm64: package-prep
|
||||
DIST_PATH_GENERIC=$(DIST_PATH_FREEBSD_ARM64) CURRENT_PACKAGE_ARCH=freebsd_arm64 MM_BIN_NAME=mattermost MMCTL_BIN_NAME=mmctl $(MAKE) package-general
|
||||
@# Package
|
||||
tar -C $(DIST_PATH_FREEBSD_ARM64)/.. -czf $(DIST_PATH)-$(BUILD_TYPE_NAME)-freebsd-arm64.tar.gz mattermost ../mattermost
|
||||
@# Cleanup
|
||||
rm -rf $(DIST_ROOT)/freebsd_arm64
|
||||
|
||||
.PHONY: package-freebsd
|
||||
package-freebsd: package-freebsd-amd64 package-freebsd-arm64
|
||||
|
||||
.PHONY: package-linux-amd64
|
||||
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
|
||||
|
||||
.PHONY: package-linux-arm64
|
||||
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
|
||||
|
||||
.PHONY: package-linux
|
||||
package-linux: package-linux-amd64 package-linux-arm64
|
||||
|
||||
.PHONY: package-windows
|
||||
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.
|
||||
.PHONY: package
|
||||
package: package-linux
|
||||
rm -rf tmpprepackaged
|
||||
rm -rf $(DIST_PATH)
|
||||
19
server/channels/README.md
Normal file
19
server/channels/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Server Channels Review Guidelines
|
||||
|
||||
When reviewing or writing code in the server channels package, focus on SQL query performance and API layer efficiency.
|
||||
|
||||
## SQL Store Layer
|
||||
|
||||
- Run `EXPLAIN ANALYZE` on new or modified queries against a large dataset before merging. A query that performs well on a 12M-post database may degrade significantly at 100M+ posts.
|
||||
- Watch for sequential scans on large tables. Ensure appropriate indexes exist for new query patterns.
|
||||
- When adding new queries to the store, check whether an existing query already fetches the needed data. Avoid duplicate round trips to the database.
|
||||
|
||||
## API Layer
|
||||
|
||||
- Minimize database round trips. If an endpoint calls a `Get` followed by a `Delete` on the same row, consider using `DELETE ... RETURNING` to combine them into a single query.
|
||||
- Don't add queries that are unnecessary for the operation. The most efficient work is the work you don't do.
|
||||
- When adding new API endpoints, add them to the load test tooling so performance can be validated under realistic concurrency.
|
||||
|
||||
## Permissions and Security
|
||||
|
||||
- Verify that new endpoints enforce appropriate permissions. Rely on the dedicated security review for thorough coverage, but flag anything obviously missing (e.g., an endpoint that skips permission checks entirely).
|
||||
322
server/channels/api4/abac_file_metadata_test.go
Normal file
322
server/channels/api4/abac_file_metadata_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
|
||||
eMocks "github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
|
||||
)
|
||||
|
||||
// These tests cover the endpoints that build their own file metadata instead of going
|
||||
// through sanitizeFileAttachmentsForUser. With the channel's Access Control Policy denying
|
||||
// download_file_attachment, every direct file endpoint returns 403, and none of these
|
||||
// endpoints may hand back the file's rendered thumbnail for the same file, user and moment.
|
||||
//
|
||||
// Sent posts and their historical versions are redacted outright. Drafts and scheduled posts
|
||||
// keep their metadata, because the author still has to see and send their own unsent
|
||||
// content, and only the thumbnail is withheld.
|
||||
//
|
||||
// The policy is switched to deny only after the upload, which is the case Access Control
|
||||
// Policies exist to cover: revoking access to content the user could previously reach.
|
||||
|
||||
// setFileDownloadPolicy enables ABAC and installs an access control service that returns
|
||||
// the given decision for download_file_attachment while allowing every other action.
|
||||
func setFileDownloadPolicy(t *testing.T, th *TestHelper, allowed bool) *eMocks.AccessControlServiceInterface {
|
||||
t.Helper()
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
|
||||
})
|
||||
|
||||
mockACS := &eMocks.AccessControlServiceInterface{}
|
||||
original := th.App.Srv().Channels().AccessControl
|
||||
th.App.Srv().Channels().AccessControl = mockACS
|
||||
t.Cleanup(func() { th.App.Srv().Channels().AccessControl = original })
|
||||
|
||||
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
|
||||
return req.Action == model.AccessControlPolicyActionDownloadFileAttachment
|
||||
})).Return(model.AccessDecision{Decision: allowed}, (*model.AppError)(nil))
|
||||
mockACS.On("AccessEvaluation", mock.Anything, mock.Anything).
|
||||
Return(model.AccessDecision{Decision: true}, (*model.AppError)(nil)).Maybe()
|
||||
|
||||
// Channel teardown and policy reads touch the service without being part of the
|
||||
// behaviour under test.
|
||||
mockACS.On("GetPolicy", mock.Anything, mock.AnythingOfType("string")).
|
||||
Return(nil, (*model.AppError)(nil)).Maybe()
|
||||
mockACS.On("DeletePolicy", mock.Anything, mock.AnythingOfType("string")).
|
||||
Return((*model.AppError)(nil)).Maybe()
|
||||
|
||||
return mockACS
|
||||
}
|
||||
|
||||
// requireDirectFileEndpointsDenied is the precondition shared by every case below: the
|
||||
// endpoints that already enforce the policy must reject this user for this file.
|
||||
func requireDirectFileEndpointsDenied(t *testing.T, th *TestHelper, fileID string) {
|
||||
t.Helper()
|
||||
|
||||
_, resp, err := th.Client.GetFile(context.Background(), fileID)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
_, resp, err = th.Client.GetFileThumbnail(context.Background(), fileID)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
_, resp, err = th.Client.GetFilePreview(context.Background(), fileID)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
|
||||
_, resp, err = th.Client.GetFileInfo(context.Background(), fileID)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
}
|
||||
|
||||
// requireNoThumbnailLeaked asserts that no rendered thumbnail reached a user the policy
|
||||
// denies. This is the file's actual content, so no endpoint may serve it.
|
||||
func requireNoThumbnailLeaked(t *testing.T, metadata *model.PostMetadata, where string) {
|
||||
t.Helper()
|
||||
|
||||
if metadata == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, info := range metadata.Files {
|
||||
if info.MiniPreview != nil {
|
||||
require.Failf(t, "leaked a thumbnail", "%s: returned a %d byte mini preview for file %s", where, len(*info.MiniPreview), info.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// requireNoLeakedFileMetadata additionally asserts that no file metadata survived, which is
|
||||
// what sent posts and their historical versions must do.
|
||||
func requireNoLeakedFileMetadata(t *testing.T, metadata *model.PostMetadata, where string) {
|
||||
t.Helper()
|
||||
|
||||
requireNoThumbnailLeaked(t, metadata, where)
|
||||
|
||||
if metadata == nil {
|
||||
return
|
||||
}
|
||||
require.Emptyf(t, metadata.Files, "%s: leaked file metadata to a user denied download_file_attachment", where)
|
||||
}
|
||||
|
||||
func uploadTestImage(t *testing.T, th *TestHelper, channelID, name string) *model.FileInfo {
|
||||
t.Helper()
|
||||
|
||||
data, err := testutils.ReadTestFile("test.png")
|
||||
require.NoError(t, err)
|
||||
|
||||
uploaded, _, err := th.Client.UploadFile(context.Background(), data, channelID, name)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, uploaded.FileInfos, 1)
|
||||
require.NotNil(t, uploaded.FileInfos[0].MiniPreview, "precondition: the uploaded image should have a mini preview to leak")
|
||||
|
||||
return uploaded.FileInfos[0]
|
||||
}
|
||||
|
||||
func TestGetDraftsWithholdsThumbnailWhenPolicyDenies(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
}).InitBasic(t)
|
||||
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
|
||||
|
||||
info := uploadTestImage(t, th, th.BasicChannel.Id, "draft-attachment.png")
|
||||
|
||||
_, _, err := th.Client.UpsertDraft(context.Background(), &model.Draft{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "draft with a gated attachment",
|
||||
FileIds: model.StringArray{info.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
setFileDownloadPolicy(t, th, false)
|
||||
requireDirectFileEndpointsDenied(t, th, info.Id)
|
||||
|
||||
drafts, _, err := th.Client.GetDrafts(context.Background(), th.BasicUser.Id, th.BasicTeam.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, drafts, 1)
|
||||
|
||||
requireNoThumbnailLeaked(t, drafts[0].Metadata, "GET /users/me/teams/{team_id}/drafts")
|
||||
|
||||
// The author must still be able to see and send their own unsent draft. The web client
|
||||
// builds the outgoing post's file_ids from metadata.files, so dropping the metadata
|
||||
// would silently strip the attachment when the draft is sent.
|
||||
require.Len(t, drafts[0].Metadata.Files, 1, "the author should still see their own draft attachment")
|
||||
require.Equal(t, info.Id, drafts[0].Metadata.Files[0].Id)
|
||||
require.Equal(t, model.StringArray{info.Id}, drafts[0].FileIds)
|
||||
}
|
||||
|
||||
func TestGetScheduledPostsWithholdsThumbnailWhenPolicyDenies(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
}).InitBasic(t)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
|
||||
|
||||
info := uploadTestImage(t, th, th.BasicChannel.Id, "scheduled-attachment.png")
|
||||
|
||||
created, _, err := th.Client.CreateScheduledPost(context.Background(), &model.ScheduledPost{
|
||||
Draft: model.Draft{
|
||||
CreateAt: model.GetMillis(),
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "scheduled post with a gated attachment",
|
||||
FileIds: model.StringArray{info.Id},
|
||||
},
|
||||
ScheduledAt: model.GetMillis() + 100000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
setFileDownloadPolicy(t, th, false)
|
||||
requireDirectFileEndpointsDenied(t, th, info.Id)
|
||||
|
||||
scheduledPosts, _, err := th.Client.GetUserScheduledPosts(context.Background(), th.BasicTeam.Id, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scheduledPosts[th.BasicTeam.Id], 1)
|
||||
|
||||
scheduled := scheduledPosts[th.BasicTeam.Id][0]
|
||||
requireNoThumbnailLeaked(t, scheduled.Metadata, "GET /posts/scheduled/team/{team_id}")
|
||||
|
||||
// The server sends this post from the stored FileIds when it fires, so dropping the
|
||||
// metadata would make a client-side "send now" and the scheduled send disagree.
|
||||
require.Len(t, scheduled.Metadata.Files, 1, "the author should still see their own scheduled attachment")
|
||||
require.Equal(t, info.Id, scheduled.Metadata.Files[0].Id)
|
||||
require.Equal(t, model.StringArray{info.Id}, scheduled.FileIds)
|
||||
}
|
||||
|
||||
func TestGetEditHistoryForPostDoesNotLeakFilesDeniedByPolicy(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
}).InitBasic(t)
|
||||
|
||||
info := uploadTestImage(t, th, th.BasicChannel.Id, "edit-history-attachment.png")
|
||||
|
||||
post, _, err := th.Client.CreatePost(context.Background(), &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "post with a gated attachment",
|
||||
FileIds: model.StringArray{info.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = th.Client.PatchPost(context.Background(), post.Id, &model.PostPatch{
|
||||
Message: new("post with a gated attachment, edited"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
setFileDownloadPolicy(t, th, false)
|
||||
requireDirectFileEndpointsDenied(t, th, info.Id)
|
||||
|
||||
// The live post is redacted for this user. The historical versions of the same post are
|
||||
// expected to match that, and are the control that proves the redaction works at all.
|
||||
live, _, err := th.Client.GetPost(context.Background(), post.Id, "")
|
||||
require.NoError(t, err)
|
||||
requireNoLeakedFileMetadata(t, live.Metadata, "GET /posts/{post_id} (control)")
|
||||
require.Empty(t, live.FileIds, "control: the live post should not expose file ids either")
|
||||
|
||||
history, _, err := th.Client.GetEditHistoryForPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, history)
|
||||
|
||||
for _, version := range history {
|
||||
require.NotNil(t, version.Metadata)
|
||||
requireNoLeakedFileMetadata(t, version.Metadata, "GET /posts/{post_id}/edit_history")
|
||||
require.Empty(t, version.FileIds, "the historical version should not expose file ids either")
|
||||
require.Equal(t, 1, version.Metadata.RedactedFileCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileMetadataServedWhenPolicyAllows is the other half of the contract. With a policy
|
||||
// active but allowing the action, all three endpoints must still serve the metadata and the
|
||||
// thumbnail, so enforcement cannot be satisfied by redacting unconditionally.
|
||||
func TestFileMetadataServedWhenPolicyAllows(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
}).InitBasic(t)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional))
|
||||
th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.AllowSyncedDrafts = true })
|
||||
|
||||
requireFileServed := func(metadata *model.PostMetadata, fileID, where string) {
|
||||
t.Helper()
|
||||
|
||||
require.NotNil(t, metadata, where)
|
||||
require.Len(t, metadata.Files, 1, "%s: should serve the file metadata", where)
|
||||
require.Equal(t, fileID, metadata.Files[0].Id, where)
|
||||
require.NotNil(t, metadata.Files[0].MiniPreview, "%s: should serve the thumbnail", where)
|
||||
require.Zero(t, metadata.RedactedFileCount, where)
|
||||
}
|
||||
|
||||
draftFile := uploadTestImage(t, th, th.BasicChannel.Id, "draft-allowed.png")
|
||||
_, _, err := th.Client.UpsertDraft(context.Background(), &model.Draft{
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "draft with an allowed attachment",
|
||||
FileIds: model.StringArray{draftFile.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
scheduledFile := uploadTestImage(t, th, th.BasicChannel.Id, "scheduled-allowed.png")
|
||||
_, _, err = th.Client.CreateScheduledPost(context.Background(), &model.ScheduledPost{
|
||||
Draft: model.Draft{
|
||||
CreateAt: model.GetMillis(),
|
||||
UserId: th.BasicUser.Id,
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "scheduled post with an allowed attachment",
|
||||
FileIds: model.StringArray{scheduledFile.Id},
|
||||
},
|
||||
ScheduledAt: model.GetMillis() + 100000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
postFile := uploadTestImage(t, th, th.BasicChannel.Id, "edit-history-allowed.png")
|
||||
post, _, err := th.Client.CreatePost(context.Background(), &model.Post{
|
||||
ChannelId: th.BasicChannel.Id,
|
||||
Message: "post with an allowed attachment",
|
||||
FileIds: model.StringArray{postFile.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = th.Client.PatchPost(context.Background(), post.Id, &model.PostPatch{
|
||||
Message: new("post with an allowed attachment, edited"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
setFileDownloadPolicy(t, th, true)
|
||||
|
||||
_, _, err = th.Client.GetFileThumbnail(context.Background(), postFile.Id)
|
||||
require.NoError(t, err, "precondition: the policy should allow download_file_attachment")
|
||||
|
||||
drafts, _, err := th.Client.GetDrafts(context.Background(), th.BasicUser.Id, th.BasicTeam.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, drafts, 1)
|
||||
requireFileServed(drafts[0].Metadata, draftFile.Id, "GET /users/me/teams/{team_id}/drafts")
|
||||
|
||||
scheduledPosts, _, err := th.Client.GetUserScheduledPosts(context.Background(), th.BasicTeam.Id, false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, scheduledPosts[th.BasicTeam.Id], 1)
|
||||
requireFileServed(scheduledPosts[th.BasicTeam.Id][0].Metadata, scheduledFile.Id, "GET /posts/scheduled/team/{team_id}")
|
||||
|
||||
history, _, err := th.Client.GetEditHistoryForPost(context.Background(), post.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, history)
|
||||
|
||||
for _, version := range history {
|
||||
requireFileServed(version.Metadata, postFile.Id, "GET /posts/{post_id}/edit_history")
|
||||
require.Equal(t, model.StringArray{postFile.Id}, version.FileIds)
|
||||
}
|
||||
}
|
||||
1480
server/channels/api4/access_control.go
Normal file
1480
server/channels/api4/access_control.go
Normal file
File diff suppressed because it is too large
Load Diff
175
server/channels/api4/access_control_decision_test.go
Normal file
175
server/channels/api4/access_control_decision_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 TestSearchAccessControlDecisionActions(t *testing.T) {
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.PermissionPolicies = true
|
||||
}).InitBasic(t)
|
||||
|
||||
channelResource := model.Resource{Type: model.AccessControlPolicyTypeChannel, ID: th.BasicChannel.Id}
|
||||
|
||||
t.Run("requires a session", func(t *testing.T) {
|
||||
client := th.CreateClient() // unauthenticated
|
||||
_, resp, err := client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: channelResource,
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckUnauthorizedStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("unsupported action returns bad request", func(t *testing.T) {
|
||||
_, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: channelResource,
|
||||
Actions: []string{"definitely_not_a_real_action"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("invalid request returns bad request", func(t *testing.T) {
|
||||
_, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: model.Resource{Type: "", ID: th.BasicChannel.Id},
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("unsupported resource type returns bad request", func(t *testing.T) {
|
||||
// A non-channel resource type must be rejected outright rather than silently skipping
|
||||
// the resource-access check. Discovery mode (no actions) previously returned an empty 200.
|
||||
_, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: model.Resource{Type: model.AccessControlPolicyTypeTeam, ID: th.BasicTeam.Id},
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("returns allowed when ABAC is inactive", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
|
||||
})
|
||||
|
||||
out, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: channelResource,
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment, model.AccessControlPolicyActionDownloadFileAttachment},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.Len(t, out.Decisions, 2)
|
||||
require.True(t, out.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Allowed)
|
||||
require.True(t, out.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Evaluated)
|
||||
})
|
||||
|
||||
t.Run("returns PDP deny for the session user", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = true
|
||||
})
|
||||
mockACS := &mocks.AccessControlServiceInterface{}
|
||||
original := th.App.Srv().Channels().AccessControl
|
||||
th.App.Srv().Channels().AccessControl = mockACS
|
||||
defer func() { th.App.Srv().Channels().AccessControl = original }()
|
||||
|
||||
mockACS.On("AccessEvaluation", mock.Anything, mock.MatchedBy(func(req model.AccessRequest) bool {
|
||||
return req.Resource.ID == th.BasicChannel.Id && req.Action == model.AccessControlPolicyActionUploadFileAttachment
|
||||
})).Return(model.AccessDecision{Decision: false}, (*model.AppError)(nil))
|
||||
|
||||
out, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: channelResource,
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.False(t, out.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Allowed)
|
||||
require.True(t, out.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Evaluated)
|
||||
require.Empty(t, out.Results) // denied actions must not appear in the AuthZEN results list
|
||||
})
|
||||
|
||||
t.Run("discovery mode returns all actions when ABAC inactive", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
|
||||
})
|
||||
|
||||
out, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: channelResource,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.Len(t, out.Decisions, 2)
|
||||
require.Len(t, out.Results, 2)
|
||||
})
|
||||
|
||||
t.Run("subject not matching session user returns 403", func(t *testing.T) {
|
||||
_, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: channelResource,
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
Subject: &model.ActionSearchSubject{ID: model.NewId()},
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("subject matching session user is accepted", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
|
||||
})
|
||||
|
||||
out, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: channelResource,
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
Subject: &model.ActionSearchSubject{ID: th.BasicUser.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.True(t, out.Decisions[model.AccessControlPolicyActionUploadFileAttachment].Allowed)
|
||||
})
|
||||
|
||||
t.Run("non-member of private channel returns 403", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
|
||||
})
|
||||
// Create a private channel as system admin; BasicUser is not a member.
|
||||
privateCh, _, err := th.SystemAdminClient.CreateChannel(context.Background(), &model.Channel{
|
||||
TeamId: th.BasicTeam.Id,
|
||||
Name: model.NewId(),
|
||||
DisplayName: "Private Not Member",
|
||||
Type: model.ChannelTypePrivate,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: model.Resource{Type: model.AccessControlPolicyTypeChannel, ID: privateCh.Id},
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("page field accepted and ignored", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.AccessControlSettings.EnableAttributeBasedAccessControl = false
|
||||
})
|
||||
|
||||
out, resp, err := th.Client.SearchAccessControlDecisionActions(context.Background(), model.ActionSearchRequest{
|
||||
Resource: channelResource,
|
||||
Actions: []string{model.AccessControlPolicyActionUploadFileAttachment},
|
||||
Page: &model.ActionSearchPage{NextToken: "tok"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.Nil(t, out.Page)
|
||||
})
|
||||
}
|
||||
25
server/channels/api4/access_control_local.go
Normal file
25
server/channels/api4/access_control_local.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (api *API) InitAccessControlPolicyLocal() {
|
||||
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("/activate", api.APILocal(setActiveStatus)).Methods(http.MethodPut)
|
||||
|
||||
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("/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)
|
||||
}
|
||||
76
server/channels/api4/agents.go
Normal file
76
server/channels/api4/agents.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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/agents/status
|
||||
api.BaseRoutes.Agents.Handle("/status", api.APISessionRequired(getAgentsStatus)).Methods(http.MethodGet)
|
||||
// GET /api/v4/llmservices
|
||||
api.BaseRoutes.LLMServices.Handle("", api.APISessionRequired(getLLMServices)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func getAgentsStatus(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
available, reason := c.App.GetAIPluginBridgeStatus(c.AppContext)
|
||||
|
||||
resp := &model.AgentsIntegrityResponse{
|
||||
Available: available,
|
||||
Reason: reason,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
c.Err = model.NewAppError("Api4.getAgentsStatus", "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 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))
|
||||
}
|
||||
}
|
||||
73
server/channels/api4/ai_bridge_test_helper.go
Normal file
73
server/channels/api4/ai_bridge_test_helper.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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) InitAIBridgeTestHelper() {
|
||||
api.BaseRoutes.System.Handle("/e2e/ai_bridge", api.APISessionRequired(putAIBridgeTestHelper)).Methods(http.MethodPut)
|
||||
api.BaseRoutes.System.Handle("/e2e/ai_bridge", api.APISessionRequired(getAIBridgeTestHelper)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.System.Handle("/e2e/ai_bridge", api.APISessionRequired(deleteAIBridgeTestHelper)).Methods(http.MethodDelete)
|
||||
}
|
||||
|
||||
func requireAIBridgeTestHelperEnabled(c *Context) {
|
||||
if !*c.App.Config().ServiceSettings.EnableTesting {
|
||||
c.Err = model.NewAppError("requireAIBridgeTestHelperEnabled", "api.ai_bridge_test_helper.disabled.app_error", nil, "", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) {
|
||||
c.SetPermissionError(model.PermissionManageSystem)
|
||||
}
|
||||
}
|
||||
|
||||
func putAIBridgeTestHelper(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireAIBridgeTestHelperEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var config model.AIBridgeTestHelperConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
|
||||
c.SetInvalidParamWithErr("body", err)
|
||||
return
|
||||
}
|
||||
|
||||
if appErr := c.App.SetAIBridgeTestHelperConfig(&config); appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
state := c.App.GetAIBridgeTestHelperState()
|
||||
if err := json.NewEncoder(w).Encode(state); err != nil {
|
||||
c.Logger.Warn("Error encoding response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getAIBridgeTestHelper(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireAIBridgeTestHelperEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(c.App.GetAIBridgeTestHelperState()); err != nil {
|
||||
c.Logger.Warn("Error encoding response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAIBridgeTestHelper(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
requireAIBridgeTestHelperEnabled(c)
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.App.ResetAIBridgeTestHelper()
|
||||
ReturnStatusOK(w)
|
||||
}
|
||||
218
server/channels/api4/ai_bridge_test_helper_test.go
Normal file
218
server/channels/api4/ai_bridge_test_helper_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
app "github.com/mattermost/mattermost/server/v8/channels/app"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAIBridgeTestHelperAdminPUTGETDELETE(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableTesting = true
|
||||
}).InitBasic(t)
|
||||
|
||||
recordRequests := true
|
||||
agentID := model.NewId()
|
||||
serviceID := model.NewId()
|
||||
config := &model.AIBridgeTestHelperConfig{
|
||||
Status: &model.AIBridgeTestHelperStatus{
|
||||
Available: true,
|
||||
},
|
||||
FeatureFlags: &model.AIBridgeTestHelperFeatureFlags{
|
||||
EnableAIPluginBridge: new(true),
|
||||
EnableAIRecaps: new(true),
|
||||
},
|
||||
Agents: []model.BridgeAgentInfo{{
|
||||
ID: agentID,
|
||||
DisplayName: "Claude",
|
||||
Username: "claude.bot",
|
||||
ServiceID: serviceID,
|
||||
ServiceType: "anthropic",
|
||||
IsDefault: true,
|
||||
}},
|
||||
Services: []model.BridgeServiceInfo{{
|
||||
ID: serviceID,
|
||||
Name: "Anthropic",
|
||||
Type: "anthropic",
|
||||
}},
|
||||
AgentCompletions: map[string][]model.AIBridgeTestHelperCompletion{
|
||||
string(app.BridgeOperationRewrite): {{
|
||||
Completion: `{"rewritten_text":"Rewritten text"}`,
|
||||
}},
|
||||
},
|
||||
RecordRequests: &recordRequests,
|
||||
}
|
||||
|
||||
state, resp, err := th.SystemAdminClient.SetAIBridgeTestHelper(context.Background(), config)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, state)
|
||||
assert.True(t, state.RecordRequests)
|
||||
require.Len(t, state.Agents, 1)
|
||||
assert.Equal(t, agentID, state.Agents[0].ID)
|
||||
require.NotNil(t, state.FeatureFlags)
|
||||
require.NotNil(t, state.FeatureFlags.EnableAIPluginBridge)
|
||||
require.NotNil(t, state.FeatureFlags.EnableAIRecaps)
|
||||
assert.True(t, *state.FeatureFlags.EnableAIPluginBridge)
|
||||
assert.True(t, *state.FeatureFlags.EnableAIRecaps)
|
||||
|
||||
state, resp, err = th.SystemAdminClient.GetAIBridgeTestHelper(context.Background())
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, state)
|
||||
assert.True(t, state.Status.Available)
|
||||
require.Len(t, state.Services, 1)
|
||||
assert.Equal(t, serviceID, state.Services[0].ID)
|
||||
require.NotNil(t, state.FeatureFlags)
|
||||
assert.True(t, *state.FeatureFlags.EnableAIPluginBridge)
|
||||
assert.True(t, *state.FeatureFlags.EnableAIRecaps)
|
||||
|
||||
resp, err = th.SystemAdminClient.DeleteAIBridgeTestHelper(context.Background())
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
|
||||
state, resp, err = th.SystemAdminClient.GetAIBridgeTestHelper(context.Background())
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
require.NotNil(t, state)
|
||||
assert.Nil(t, state.Status)
|
||||
assert.Empty(t, state.Agents)
|
||||
assert.Empty(t, state.Services)
|
||||
assert.Empty(t, state.AgentCompletions)
|
||||
assert.Empty(t, state.RecordedRequests)
|
||||
assert.False(t, state.RecordRequests)
|
||||
require.NotNil(t, state.FeatureFlags)
|
||||
assert.True(t, *state.FeatureFlags.EnableAIPluginBridge)
|
||||
assert.True(t, *state.FeatureFlags.EnableAIRecaps)
|
||||
}
|
||||
|
||||
func TestAIBridgeTestHelperRejectsNonAdmin(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableTesting = true
|
||||
}).InitBasic(t)
|
||||
|
||||
_, resp, err := th.Client.SetAIBridgeTestHelper(context.Background(), &model.AIBridgeTestHelperConfig{
|
||||
Status: &model.AIBridgeTestHelperStatus{Available: true},
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
}
|
||||
|
||||
func TestAIBridgeTestHelperDisabledWithoutEnableTesting(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
_, resp, err := th.SystemAdminClient.SetAIBridgeTestHelper(context.Background(), &model.AIBridgeTestHelperConfig{
|
||||
Status: &model.AIBridgeTestHelperStatus{Available: true},
|
||||
})
|
||||
require.Error(t, err)
|
||||
CheckNotImplementedStatus(t, resp)
|
||||
}
|
||||
|
||||
func TestAIBridgeTestHelperMocksRealEndpoints(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
*cfg.ServiceSettings.EnableTesting = true
|
||||
}).InitBasic(t)
|
||||
|
||||
recordRequests := true
|
||||
agentID := model.NewId()
|
||||
serviceID := model.NewId()
|
||||
_, resp, err := th.SystemAdminClient.SetAIBridgeTestHelper(context.Background(), &model.AIBridgeTestHelperConfig{
|
||||
Status: &model.AIBridgeTestHelperStatus{
|
||||
Available: true,
|
||||
},
|
||||
FeatureFlags: &model.AIBridgeTestHelperFeatureFlags{
|
||||
EnableAIPluginBridge: new(true),
|
||||
EnableAIRecaps: new(true),
|
||||
},
|
||||
Agents: []model.BridgeAgentInfo{{
|
||||
ID: agentID,
|
||||
DisplayName: "Claude",
|
||||
Username: "claude.bot",
|
||||
ServiceID: serviceID,
|
||||
ServiceType: "anthropic",
|
||||
IsDefault: true,
|
||||
}},
|
||||
Services: []model.BridgeServiceInfo{{
|
||||
ID: serviceID,
|
||||
Name: "Anthropic",
|
||||
Type: "anthropic",
|
||||
}},
|
||||
AgentCompletions: map[string][]model.AIBridgeTestHelperCompletion{
|
||||
string(app.BridgeOperationRewrite): {{
|
||||
Completion: `{"rewritten_text":"Polished text"}`,
|
||||
}},
|
||||
},
|
||||
RecordRequests: &recordRequests,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
assert.True(t, th.App.Config().FeatureFlags.EnableAIPluginBridge)
|
||||
assert.True(t, th.App.Config().FeatureFlags.EnableAIRecaps)
|
||||
|
||||
statusResp, httpResp, err := getAPIResponse[model.AgentsIntegrityResponse](t, th.Client, "/agents/status")
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, httpResp)
|
||||
assert.True(t, statusResp.Available)
|
||||
|
||||
agentsResp, httpResp, err := getAPIResponse[[]model.BridgeAgentInfo](t, th.Client, "/agents")
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, httpResp)
|
||||
require.Len(t, agentsResp, 1)
|
||||
assert.Equal(t, agentID, agentsResp[0].ID)
|
||||
|
||||
servicesResp, httpResp, err := getAPIResponse[[]model.BridgeServiceInfo](t, th.Client, "/llmservices")
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, httpResp)
|
||||
require.Len(t, servicesResp, 1)
|
||||
assert.Equal(t, serviceID, servicesResp[0].ID)
|
||||
|
||||
rewriteReq := &model.RewriteRequest{
|
||||
AgentID: agentID,
|
||||
Message: "the status update",
|
||||
Action: model.RewriteActionFixSpelling,
|
||||
}
|
||||
|
||||
rewriteHTTPResp, err := th.Client.DoAPIPostJSON(context.Background(), "/posts/rewrite", rewriteReq)
|
||||
require.NoError(t, err)
|
||||
defer rewriteHTTPResp.Body.Close()
|
||||
|
||||
rewriteResp, rewriteModelResp, err := model.DecodeJSONFromResponse[model.RewriteResponse](rewriteHTTPResp)
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, rewriteModelResp)
|
||||
assert.Equal(t, "Polished text", rewriteResp.RewrittenText)
|
||||
|
||||
state, helperResp, err := th.SystemAdminClient.GetAIBridgeTestHelper(context.Background())
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, helperResp)
|
||||
require.Len(t, state.RecordedRequests, 1)
|
||||
assert.Equal(t, string(app.BridgeOperationRewrite), state.RecordedRequests[0].Operation)
|
||||
assert.Equal(t, agentID, state.RecordedRequests[0].AgentID)
|
||||
}
|
||||
|
||||
func getAPIResponse[T any](tb testing.TB, client *model.Client4, route string) (T, *model.Response, error) {
|
||||
tb.Helper()
|
||||
|
||||
httpResp, err := client.DoAPIGet(context.Background(), route, "")
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, model.BuildResponse(httpResp), err
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
return model.DecodeJSONFromResponse[T](httpResp)
|
||||
}
|
||||
548
server/channels/api4/api.go
Normal file
548
server/channels/api4/api.go
Normal file
@@ -0,0 +1,548 @@
|
||||
// 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]+}'
|
||||
ChannelViews *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/views'
|
||||
ChannelView *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/views/{view_id:[A-Za-z0-9]+}'
|
||||
ChannelViewPosts *mux.Router // 'api/v4/channels/{channel_id:[A-Za-z0-9]+}/views/{view_id:[A-Za-z0-9]+}/posts'
|
||||
|
||||
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'
|
||||
|
||||
EphemeralMode *mux.Router // 'api/v4/ephemeral_mode'
|
||||
|
||||
Brand *mux.Router // 'api/v4/brand'
|
||||
|
||||
System *mux.Router // 'api/v4/system'
|
||||
|
||||
Jobs *mux.Router // 'api/v4/jobs'
|
||||
|
||||
Recaps *mux.Router // 'api/v4/recaps'
|
||||
|
||||
ScheduledRecaps *mux.Router // 'api/v4/scheduled_recaps'
|
||||
ScheduledRecap *mux.Router // 'api/v4/scheduled_recaps/{scheduled_recap_id:[A-Za-z0-9]+}'
|
||||
|
||||
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]+}'
|
||||
AccessControlDecisions *mux.Router // 'api/v4/access_control/decisions'
|
||||
|
||||
ContentFlagging *mux.Router // 'api/v4/content_flagging'
|
||||
|
||||
DeliveryTracking *mux.Router // 'api/v4/delivery_tracking'
|
||||
|
||||
Agents *mux.Router // 'api/v4/agents'
|
||||
LLMServices *mux.Router // 'api/v4/llmservices'
|
||||
|
||||
Boards *mux.Router // 'api/v4/boards'
|
||||
|
||||
Properties *mux.Router // 'api/v4/properties'
|
||||
PropertyFields *mux.Router // 'api/v4/properties/groups/{group_name:[a-z][a-z0-9_]*}/{object_type:[a-z]+}/fields'
|
||||
PropertyField *mux.Router // 'api/v4/properties/groups/{group_name:[a-z][a-z0-9_]*}/{object_type:[a-z]+}/fields/{field_id:[A-Za-z0-9]+}'
|
||||
PropertyFieldsSearch *mux.Router // 'api/v4/properties/groups/{group_name:[a-z][a-z0-9_]*}/fields/search'
|
||||
PropertyValues *mux.Router // 'api/v4/properties/groups/{group_name:[a-z][a-z0-9_]*}/{object_type:[a-z]+}/values/{target_id:[A-Za-z0-9]+}'
|
||||
PropertySystemValues *mux.Router // 'api/v4/properties/groups/{group_name:[a-z][a-z0-9_]*}/system/values'
|
||||
}
|
||||
|
||||
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.ChannelViews = api.BaseRoutes.Channel.PathPrefix("/views").Subrouter()
|
||||
api.BaseRoutes.ChannelView = api.BaseRoutes.ChannelViews.PathPrefix("/{view_id:[A-Za-z0-9]+}").Subrouter()
|
||||
api.BaseRoutes.ChannelViewPosts = api.BaseRoutes.ChannelView.PathPrefix("/posts").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.Recaps = api.BaseRoutes.APIRoot.PathPrefix("/recaps").Subrouter()
|
||||
api.BaseRoutes.ScheduledRecaps = api.BaseRoutes.APIRoot.PathPrefix("/scheduled_recaps").Subrouter()
|
||||
api.BaseRoutes.ScheduledRecap = api.BaseRoutes.ScheduledRecaps.PathPrefix("/{scheduled_recap_id:[A-Za-z0-9]+}").Subrouter()
|
||||
api.BaseRoutes.Elasticsearch = api.BaseRoutes.APIRoot.PathPrefix("/elasticsearch").Subrouter()
|
||||
api.BaseRoutes.DataRetention = api.BaseRoutes.APIRoot.PathPrefix("/data_retention").Subrouter()
|
||||
api.BaseRoutes.EphemeralMode = api.BaseRoutes.APIRoot.PathPrefix("/ephemeral_mode").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.AccessControlDecisions = api.BaseRoutes.APIRoot.PathPrefix("/access_control/decisions").Subrouter()
|
||||
|
||||
api.BaseRoutes.ContentFlagging = api.BaseRoutes.APIRoot.PathPrefix("/content_flagging").Subrouter()
|
||||
|
||||
api.BaseRoutes.DeliveryTracking = api.BaseRoutes.APIRoot.PathPrefix("/delivery_tracking").Subrouter()
|
||||
|
||||
api.BaseRoutes.Agents = api.BaseRoutes.APIRoot.PathPrefix("/agents").Subrouter()
|
||||
api.BaseRoutes.LLMServices = api.BaseRoutes.APIRoot.PathPrefix("/llmservices").Subrouter()
|
||||
|
||||
api.BaseRoutes.Boards = api.BaseRoutes.APIRoot.PathPrefix("/boards").Subrouter()
|
||||
|
||||
api.BaseRoutes.Properties = api.BaseRoutes.APIRoot.PathPrefix("/properties").Subrouter()
|
||||
api.BaseRoutes.PropertyFields = api.BaseRoutes.Properties.PathPrefix("/groups/{group_name:[a-z][a-z0-9_]*}/{object_type:[a-z]+}/fields").Subrouter()
|
||||
api.BaseRoutes.PropertyField = api.BaseRoutes.PropertyFields.PathPrefix("/{field_id:[A-Za-z0-9]+}").Subrouter()
|
||||
api.BaseRoutes.PropertyFieldsSearch = api.BaseRoutes.Properties.PathPrefix("/groups/{group_name:[a-z][a-z0-9_]*}/fields/search").Subrouter()
|
||||
api.BaseRoutes.PropertyValues = api.BaseRoutes.Properties.PathPrefix("/groups/{group_name:[a-z][a-z0-9_]*}/{object_type:[a-z]+}/values/{target_id:[A-Za-z0-9]+}").Subrouter()
|
||||
api.BaseRoutes.PropertySystemValues = api.BaseRoutes.Properties.PathPrefix("/groups/{group_name:[a-z][a-z0-9_]*}/system/values").Subrouter()
|
||||
|
||||
api.InitUser()
|
||||
api.InitBot()
|
||||
api.InitTeam()
|
||||
api.InitChannel()
|
||||
api.InitPost()
|
||||
api.InitFile()
|
||||
api.InitUpload()
|
||||
api.InitSystem()
|
||||
api.InitAIBridgeTestHelper()
|
||||
api.InitLicense()
|
||||
api.InitConfig()
|
||||
api.InitWebhook()
|
||||
api.InitPreference()
|
||||
api.InitSaml()
|
||||
api.InitCompliance()
|
||||
api.InitCluster()
|
||||
api.InitLdap()
|
||||
api.InitElasticsearch()
|
||||
api.InitDataRetention()
|
||||
api.InitEphemeralMode()
|
||||
api.InitBrand()
|
||||
api.InitJob()
|
||||
api.InitRecap()
|
||||
api.InitScheduledRecap()
|
||||
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.InitView()
|
||||
api.InitBoard()
|
||||
api.InitReports()
|
||||
api.InitLimits()
|
||||
api.InitOutgoingOAuthConnection()
|
||||
api.InitClientPerformanceMetrics()
|
||||
api.InitScheduledPost()
|
||||
api.InitCustomProfileAttributes()
|
||||
api.InitAuditLogging()
|
||||
api.InitAccessControlPolicy()
|
||||
api.InitContentFlagging()
|
||||
api.InitDeliveryTracking()
|
||||
api.InitAgents()
|
||||
api.InitProperties()
|
||||
|
||||
// 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()
|
||||
api.InitStatusLocal()
|
||||
|
||||
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
|
||||
1499
server/channels/api4/apitestlib.go
Normal file
1499
server/channels/api4/apitestlib.go
Normal file
File diff suppressed because it is too large
Load Diff
67
server/channels/api4/apitestlib_test.go
Normal file
67
server/channels/api4/apitestlib_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
|
||||
// Verify our manual implementation works
|
||||
assert.Equal(t, mlog.LvlDebug.Name, defaultConsoleLevel, "Default should be debug")
|
||||
assert.Equal(t, "DEBUG", customConsoleLevel, "Environment variable should be respected")
|
||||
assert.NotEqual(t, defaultConsoleLevel, customConsoleLevel, "Values should be different")
|
||||
})
|
||||
}
|
||||
83
server/channels/api4/audit_logging.go
Normal file
83
server/channels/api4/audit_logging.go
Normal 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)
|
||||
}
|
||||
69
server/channels/api4/board.go
Normal file
69
server/channels/api4/board.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// 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) InitBoard() {
|
||||
if api.srv.Config().FeatureFlags.IntegratedBoards {
|
||||
api.BaseRoutes.Boards.Handle("", api.APISessionRequired(createBoard)).Methods(http.MethodPost)
|
||||
}
|
||||
}
|
||||
|
||||
func createBoard(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
var channel model.Channel
|
||||
if err := json.NewDecoder(r.Body).Decode(&channel); err != nil {
|
||||
c.SetInvalidParamWithDetails("body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if !channel.IsBoard() {
|
||||
c.SetInvalidParamWithDetails("type", "must be BO or BP")
|
||||
return
|
||||
}
|
||||
|
||||
if channel.TeamId == "" {
|
||||
c.SetInvalidParam("team_id")
|
||||
return
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if channel.IsOpenBoard() {
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePublicChannel) {
|
||||
c.SetPermissionError(model.PermissionCreatePublicChannel)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionCreatePrivateChannel) {
|
||||
c.SetPermissionError(model.PermissionCreatePrivateChannel)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
channel.CreatorId = c.AppContext.Session().UserId
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventCreateBoard, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "channel", &channel)
|
||||
|
||||
board, appErr := c.App.CreateBoardChannel(c.AppContext, &channel)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(board)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(board); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
146
server/channels/api4/board_test.go
Normal file
146
server/channels/api4/board_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// 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 setupBoardTest(t *testing.T) *TestHelper {
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.IntegratedBoards = true
|
||||
}).InitBasic(t)
|
||||
return th
|
||||
}
|
||||
|
||||
func TestCreateBoard(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupBoardTest(t)
|
||||
client := th.Client
|
||||
|
||||
t.Run("create open board", func(t *testing.T) {
|
||||
board := &model.Channel{
|
||||
DisplayName: "My Board",
|
||||
Name: GenerateTestChannelName(),
|
||||
Type: model.ChannelTypeOpenBoard,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
}
|
||||
|
||||
created, resp, err := client.CreateBoard(context.Background(), board)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
require.NotNil(t, created)
|
||||
assert.Equal(t, model.ChannelTypeOpenBoard, created.Type)
|
||||
assert.NotEmpty(t, created.Id)
|
||||
|
||||
// Verify view was created with kanban props
|
||||
views, appErr := th.App.GetViewsForChannel(th.Context, created.Id, model.ViewQueryOpts{PerPage: 10})
|
||||
require.Nil(t, appErr)
|
||||
require.Len(t, views, 1)
|
||||
assert.Equal(t, model.ViewTypeKanban, views[0].Type)
|
||||
|
||||
kanban, kErr := model.KanbanPropsFromProps(views[0].Props)
|
||||
require.NoError(t, kErr)
|
||||
assert.NotEmpty(t, kanban.GroupBy.FieldID, "kanban should reference a field")
|
||||
require.Len(t, kanban.GroupBy.Columns, 3, "should have 3 default columns")
|
||||
assert.Equal(t, model.BoardsStatusOptionTodo, kanban.GroupBy.Columns[0].Name)
|
||||
assert.Equal(t, model.BoardsStatusOptionInProgress, kanban.GroupBy.Columns[1].Name)
|
||||
assert.Equal(t, model.BoardsStatusOptionComplete, kanban.GroupBy.Columns[2].Name)
|
||||
for _, col := range kanban.GroupBy.Columns {
|
||||
assert.Len(t, col.OptionIDs, 1, "each default column maps to one option")
|
||||
assert.NotEmpty(t, col.ID, "column should have a stable ID")
|
||||
}
|
||||
|
||||
// Verify linked_properties on channel
|
||||
linkedProps, ok := created.Props[model.ChannelPropsBoardLinkedProperties]
|
||||
require.True(t, ok, "channel should have board:linked_properties")
|
||||
linkedList, ok := linkedProps.([]any)
|
||||
require.True(t, ok, "linked_properties should be a list")
|
||||
assert.Len(t, linkedList, 2, "should have status and assignee field IDs")
|
||||
})
|
||||
|
||||
t.Run("create private board", func(t *testing.T) {
|
||||
board := &model.Channel{
|
||||
DisplayName: "Private Board",
|
||||
Name: GenerateTestChannelName(),
|
||||
Type: model.ChannelTypePrivateBoard,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
}
|
||||
|
||||
_, resp, err := client.CreateBoard(context.Background(), board)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("rejected when feature flag off", func(t *testing.T) {
|
||||
// Routes are registered at startup based on feature flag.
|
||||
// When the flag is off at startup, /boards route does not exist -> 404.
|
||||
// Use a separate TestHelper with the flag off.
|
||||
th2 := Setup(t).InitBasic(t)
|
||||
board := &model.Channel{
|
||||
DisplayName: "Disabled Board",
|
||||
Name: GenerateTestChannelName(),
|
||||
Type: model.ChannelTypeOpenBoard,
|
||||
TeamId: th2.BasicTeam.Id,
|
||||
}
|
||||
|
||||
_, resp, err := th2.Client.CreateBoard(context.Background(), board)
|
||||
require.Error(t, err)
|
||||
CheckNotFoundStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("rejected with empty display name", func(t *testing.T) {
|
||||
board := &model.Channel{
|
||||
DisplayName: "",
|
||||
Name: GenerateTestChannelName(),
|
||||
Type: model.ChannelTypeOpenBoard,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
}
|
||||
|
||||
_, resp, err := client.CreateBoard(context.Background(), board)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("rejected with whitespace-only display name", func(t *testing.T) {
|
||||
board := &model.Channel{
|
||||
DisplayName: " ",
|
||||
Name: GenerateTestChannelName(),
|
||||
Type: model.ChannelTypeOpenBoard,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
}
|
||||
|
||||
_, resp, err := client.CreateBoard(context.Background(), board)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("board not in sidebar", func(t *testing.T) {
|
||||
board := &model.Channel{
|
||||
DisplayName: "Sidebar Check Board",
|
||||
Name: GenerateTestChannelName(),
|
||||
Type: model.ChannelTypeOpenBoard,
|
||||
TeamId: th.BasicTeam.Id,
|
||||
}
|
||||
|
||||
created, _, err := client.CreateBoard(context.Background(), board)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
|
||||
// Verify board doesn't appear in sidebar
|
||||
categories, _, catErr := client.GetSidebarCategoriesForTeamForUser(context.Background(), th.BasicUser.Id, th.BasicTeam.Id, "")
|
||||
require.NoError(t, catErr)
|
||||
for _, cat := range categories.Categories {
|
||||
for _, chID := range cat.Channels {
|
||||
assert.NotEqual(t, created.Id, chID, "board should not appear in sidebar")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
319
server/channels/api4/bot.go
Normal file
319
server/channels/api4/bot.go
Normal 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, 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(c.AppContext, 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))
|
||||
}
|
||||
}
|
||||
17
server/channels/api4/bot_local.go
Normal file
17
server/channels/api4/bot_local.go
Normal 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)
|
||||
}
|
||||
1587
server/channels/api4/bot_test.go
Normal file
1587
server/channels/api4/bot_test.go
Normal file
File diff suppressed because it is too large
Load Diff
105
server/channels/api4/brand.go
Normal file
105
server/channels/api4/brand.go
Normal 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)
|
||||
}
|
||||
166
server/channels/api4/brand_test.go
Normal file
166
server/channels/api4/brand_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// 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)
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
3493
server/channels/api4/channel.go
Normal file
3493
server/channels/api4/channel.go
Normal file
File diff suppressed because it is too large
Load Diff
485
server/channels/api4/channel_bookmark.go
Normal file
485
server/channels/api4/channel_bookmark.go
Normal file
@@ -0,0 +1,485 @@
|
||||
// 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 rejectExternallyManagedBookmarkWrite(op string) *model.AppError {
|
||||
return model.NewAppError(op, "api.channel.bookmark.board.readonly.app_error", nil,
|
||||
"bookmark type is managed outside the channel bookmarks API", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func (api *API) InitChannelBookmarks() {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if model.IsExternallyManagedChannelBookmarkType(channelBookmark.Type) {
|
||||
c.Err = rejectExternallyManagedBookmarkWrite("createChannelBookmark")
|
||||
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
|
||||
}
|
||||
|
||||
switch channel.Type {
|
||||
case model.ChannelTypeOpen:
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPublicChannel); !ok {
|
||||
c.SetPermissionError(model.PermissionAddBookmarkPublicChannel)
|
||||
return
|
||||
}
|
||||
|
||||
case model.ChannelTypePrivate:
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPrivateChannel); !ok {
|
||||
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, 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
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelBookmark, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterAuditableToAuditRec(auditRec, "channelBookmark", patch)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
isMember := false
|
||||
switch channel.Type {
|
||||
case model.ChannelTypeOpen:
|
||||
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPublicChannel)
|
||||
if !ok {
|
||||
c.SetPermissionError(model.PermissionEditBookmarkPublicChannel)
|
||||
return
|
||||
}
|
||||
isMember = member
|
||||
|
||||
case model.ChannelTypePrivate:
|
||||
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPrivateChannel)
|
||||
if !ok {
|
||||
c.SetPermissionError(model.PermissionEditBookmarkPrivateChannel)
|
||||
return
|
||||
}
|
||||
isMember = member
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
isMember = true
|
||||
user, gAppErr := c.App.GetUser(c.AppContext, 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
|
||||
}
|
||||
|
||||
originalChannelBookmark, appErr := c.App.GetBookmark(c.Params.ChannelBookmarkId, false)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
// The channel bookmark should belong to the same channel specified in the URL
|
||||
if originalChannelBookmark.ChannelId != c.Params.ChannelId {
|
||||
c.SetInvalidParam("channel_id")
|
||||
return
|
||||
}
|
||||
|
||||
if model.IsExternallyManagedChannelBookmarkType(originalChannelBookmark.Type) {
|
||||
c.Err = rejectExternallyManagedBookmarkWrite("updateChannelBookmark")
|
||||
return
|
||||
}
|
||||
|
||||
patchedBookmark := originalChannelBookmark.Clone()
|
||||
auditRec.AddEventPriorState(originalChannelBookmark)
|
||||
|
||||
patchedBookmark.Patch(patch)
|
||||
updateChannelBookmarkResponse, appErr := c.App.UpdateChannelBookmark(c.AppContext, patchedBookmark, connectionID)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if !isMember {
|
||||
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
isMember := false
|
||||
switch channel.Type {
|
||||
case model.ChannelTypeOpen:
|
||||
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPublicChannel)
|
||||
if !ok {
|
||||
c.SetPermissionError(model.PermissionOrderBookmarkPublicChannel)
|
||||
return
|
||||
}
|
||||
isMember = member
|
||||
case model.ChannelTypePrivate:
|
||||
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPrivateChannel)
|
||||
if !ok {
|
||||
c.SetPermissionError(model.PermissionOrderBookmarkPrivateChannel)
|
||||
return
|
||||
}
|
||||
isMember = member
|
||||
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
|
||||
}
|
||||
|
||||
isMember = true
|
||||
user, gAppErr := c.App.GetUser(c.AppContext, 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
|
||||
}
|
||||
|
||||
if !isMember {
|
||||
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
isMember := false
|
||||
switch channel.Type {
|
||||
case model.ChannelTypeOpen:
|
||||
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPublicChannel)
|
||||
if !ok {
|
||||
c.SetPermissionError(model.PermissionDeleteBookmarkPublicChannel)
|
||||
return
|
||||
}
|
||||
isMember = member
|
||||
case model.ChannelTypePrivate:
|
||||
ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPrivateChannel)
|
||||
if !ok {
|
||||
c.SetPermissionError(model.PermissionDeleteBookmarkPrivateChannel)
|
||||
return
|
||||
}
|
||||
isMember = member
|
||||
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
|
||||
}
|
||||
|
||||
isMember = true
|
||||
user, gAppErr := c.App.GetUser(c.AppContext, 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
|
||||
}
|
||||
if model.IsExternallyManagedChannelBookmarkType(oldBookmark.Type) {
|
||||
c.Err = rejectExternallyManagedBookmarkWrite("deleteChannelBookmark")
|
||||
return
|
||||
}
|
||||
auditRec.AddEventPriorState(oldBookmark)
|
||||
|
||||
bookmark, appErr := c.App.DeleteChannelBookmark(c.Params.ChannelBookmarkId, connectionID)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if !isMember {
|
||||
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
hasPermission, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
|
||||
if !hasPermission {
|
||||
c.SetPermissionError(model.PermissionReadChannelContent)
|
||||
return
|
||||
}
|
||||
|
||||
bookmarks, appErr := c.App.GetChannelBookmarks(c.Params.ChannelId, c.Params.BookmarksSince)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventListChannelBookmarksForChannel, model.AuditStatusSuccess)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
||||
if !isMember {
|
||||
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(bookmarks); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
1788
server/channels/api4/channel_bookmark_test.go
Normal file
1788
server/channels/api4/channel_bookmark_test.go
Normal file
File diff suppressed because it is too large
Load Diff
395
server/channels/api4/channel_category.go
Normal file
395
server/channels/api4/channel_category.go
Normal 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, *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, *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, *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, *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, *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)
|
||||
}
|
||||
1632
server/channels/api4/channel_category_test.go
Normal file
1632
server/channels/api4/channel_category_test.go
Normal file
File diff suppressed because it is too large
Load Diff
180
server/channels/api4/channel_common_teams_test.go
Normal file
180
server/channels/api4/channel_common_teams_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetDirectOrGroupMessageMembersCommonTeams(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
th := Setup(t).InitBasic(t)
|
||||
client := th.Client
|
||||
|
||||
t.Run("requires authentication", func(t *testing.T) {
|
||||
user1 := th.BasicUser
|
||||
user2 := th.BasicUser2
|
||||
|
||||
testClient := th.CreateClient()
|
||||
_, _, err := testClient.Login(context.Background(), user1.Email, user1.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
dmChannel, _, err := testClient.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = testClient.Logout(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := testClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
|
||||
require.Error(t, err)
|
||||
CheckUnauthorizedStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("forbids guest users", func(t *testing.T) {
|
||||
th.App.UpdateConfig(func(cfg *model.Config) {
|
||||
*cfg.GuestAccountsSettings.Enable = true
|
||||
})
|
||||
th.App.Srv().SetLicense(model.NewTestLicense())
|
||||
|
||||
guestUser, guestClient := th.CreateGuestAndClient(t)
|
||||
team1 := th.BasicTeam
|
||||
th.LinkUserToTeam(t, guestUser, team1)
|
||||
|
||||
user2 := th.BasicUser2
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
|
||||
dmChannel, _, err := th.SystemAdminClient.CreateDirectChannel(context.Background(), guestUser.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := guestClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("requires read permission on channel", func(t *testing.T) {
|
||||
user1 := th.CreateUser(t)
|
||||
user2 := th.CreateUser(t)
|
||||
team1 := th.CreateTeam(t)
|
||||
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
|
||||
dmChannel, _, err := th.SystemAdminClient.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
otherUser := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, otherUser, team1)
|
||||
|
||||
otherClient := th.CreateClient()
|
||||
_, _, err = otherClient.Login(context.Background(), otherUser.Email, otherUser.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := otherClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
|
||||
require.Error(t, err)
|
||||
CheckForbiddenStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("returns bad request for non-DM/GM channel", func(t *testing.T) {
|
||||
testClient := th.CreateClient()
|
||||
_, _, err := testClient.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := testClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), th.BasicChannel.Id)
|
||||
require.Error(t, err)
|
||||
CheckBadRequestStatus(t, resp)
|
||||
})
|
||||
|
||||
t.Run("returns common teams for DM channel members", func(t *testing.T) {
|
||||
user1 := th.BasicUser
|
||||
user2 := th.BasicUser2
|
||||
team1 := th.BasicTeam
|
||||
team2 := th.CreateTeam(t)
|
||||
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user1, team2)
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
|
||||
dmChannel, _, err := client.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
teams, _, err := client.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, teams, 1, "should only return team1 since user2 is not in team2")
|
||||
assert.Equal(t, team1.Id, teams[0].Id)
|
||||
})
|
||||
|
||||
t.Run("returns common teams for GM channel members", func(t *testing.T) {
|
||||
user1 := th.BasicUser
|
||||
user2 := th.BasicUser2
|
||||
user3 := th.CreateUser(t)
|
||||
team1 := th.BasicTeam
|
||||
team2 := th.CreateTeam(t)
|
||||
team3 := th.CreateTeam(t)
|
||||
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user1, team2)
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
th.LinkUserToTeam(t, user2, team3)
|
||||
th.LinkUserToTeam(t, user3, team1)
|
||||
|
||||
gmChannel, _, err := client.CreateGroupChannel(context.Background(), []string{user1.Id, user2.Id, user3.Id})
|
||||
require.NoError(t, err)
|
||||
|
||||
teams, _, err := client.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), gmChannel.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, teams, 1, "should only return team1 since it's the only team all three users share")
|
||||
assert.Equal(t, team1.Id, teams[0].Id)
|
||||
})
|
||||
|
||||
t.Run("returns empty list when requesting user in channel but has no common teams with other members", func(t *testing.T) {
|
||||
user1 := th.CreateUser(t)
|
||||
user2 := th.CreateUser(t)
|
||||
team1 := th.CreateTeam(t)
|
||||
team2 := th.CreateTeam(t)
|
||||
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user2, team2)
|
||||
|
||||
testClient := th.CreateClient()
|
||||
_, _, err := testClient.Login(context.Background(), user1.Email, user1.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
dmChannel, _, err := testClient.CreateDirectChannel(context.Background(), user1.Id, user2.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
teams, _, err := testClient.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), dmChannel.Id)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, teams)
|
||||
})
|
||||
|
||||
t.Run("filters teams to only those common with requesting user", func(t *testing.T) {
|
||||
user1 := th.CreateUser(t)
|
||||
user2 := th.CreateUser(t)
|
||||
user3 := th.BasicUser
|
||||
team1 := th.CreateTeam(t)
|
||||
team2 := th.CreateTeam(t)
|
||||
team3 := th.CreateTeam(t)
|
||||
|
||||
th.LinkUserToTeam(t, user1, team1)
|
||||
th.LinkUserToTeam(t, user1, team2)
|
||||
th.LinkUserToTeam(t, user2, team1)
|
||||
th.LinkUserToTeam(t, user2, team3)
|
||||
th.LinkUserToTeam(t, user3, team1)
|
||||
th.LinkUserToTeam(t, user3, team3)
|
||||
|
||||
gmChannel, _, err := client.CreateGroupChannel(context.Background(), []string{user1.Id, user2.Id, user3.Id})
|
||||
require.NoError(t, err)
|
||||
|
||||
teams, _, err := client.GetDirectOrGroupMessageMembersCommonTeams(context.Background(), gmChannel.Id)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, teams, 1)
|
||||
assert.Equal(t, team1.Id, teams[0].Id)
|
||||
})
|
||||
}
|
||||
293
server/channels/api4/channel_join_request.go
Normal file
293
server/channels/api4/channel_join_request.go
Normal file
@@ -0,0 +1,293 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// initChannelJoinRequestRoutes registers the discoverable-private-channel
|
||||
// join request endpoints. The route group is split into its own file so the
|
||||
// handlers stay isolated from the rest of api4/channel.go.
|
||||
func (api *API) initChannelJoinRequestRoutes() {
|
||||
if !api.srv.Config().FeatureFlags.DiscoverableChannels {
|
||||
return
|
||||
}
|
||||
|
||||
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(requestJoinChannel)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(getMyChannelJoinRequest)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(withdrawMyChannelJoinRequest)).Methods(http.MethodDelete)
|
||||
|
||||
api.BaseRoutes.Channel.Handle("/join_requests", api.APISessionRequired(getChannelJoinRequests)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Channel.Handle("/join_requests/count", api.APISessionRequired(countPendingChannelJoinRequests)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.Channel.Handle("/join_requests/{request_id:[A-Za-z0-9]+}", api.APISessionRequired(patchChannelJoinRequest)).Methods(http.MethodPatch)
|
||||
|
||||
api.BaseRoutes.User.Handle("/channel_join_requests", api.APISessionRequired(getMyChannelJoinRequests)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
// channelJoinRequestBody is the POST body shape for /channels/{id}/join_request.
|
||||
type channelJoinRequestBody struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func requireDiscoverableChannelsEnabled(c *Context, where string) bool {
|
||||
if !c.App.Config().FeatureFlags.DiscoverableChannels {
|
||||
c.Err = model.NewAppError(where, "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func requestJoinChannel(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "requestJoinChannel") {
|
||||
return
|
||||
}
|
||||
|
||||
var body channelJoinRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
c.SetInvalidParamWithErr("body", err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventCreateChannelJoinRequest, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
|
||||
|
||||
joined, req, appErr := c.App.RequestJoinChannel(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId, body.Message)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
if req != nil {
|
||||
auditRec.AddEventResultState(req)
|
||||
}
|
||||
|
||||
if joined {
|
||||
// Mirror the membership endpoint's "no body, just status" semantics
|
||||
// when the user was added directly via the ABAC fast path.
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(map[string]string{"status": model.ChannelJoinRequestStatusApproved}); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(req); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getMyChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "getMyChannelJoinRequest") {
|
||||
return
|
||||
}
|
||||
|
||||
req, appErr := c.App.GetMyChannelJoinRequest(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if req == nil {
|
||||
// Mirror REST conventions: not-found instead of an explicit `null`
|
||||
// so clients can distinguish "no pending request" from "service down".
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(req); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func withdrawMyChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "withdrawMyChannelJoinRequest") {
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventWithdrawChannelJoinRequest, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
|
||||
|
||||
req, appErr := c.App.GetMyChannelJoinRequest(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
if req == nil {
|
||||
c.Err = model.NewAppError("withdrawMyChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "channel_id="+c.Params.ChannelId, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
updated, appErr := c.App.WithdrawChannelJoinRequest(c.AppContext, req.Id, c.AppContext.Session().UserId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(updated)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(updated); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "getChannelJoinRequests") {
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
|
||||
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.GetChannelJoinRequestsOpts{
|
||||
Status: r.URL.Query().Get("status"),
|
||||
Page: c.Params.Page,
|
||||
PerPage: c.Params.PerPage,
|
||||
}
|
||||
|
||||
list, appErr := c.App.GetChannelJoinRequests(c.AppContext, c.Params.ChannelId, opts)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(list); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func countPendingChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "countPendingChannelJoinRequests") {
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
|
||||
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
|
||||
return
|
||||
}
|
||||
|
||||
count, appErr := c.App.CountPendingChannelJoinRequests(c.AppContext, c.Params.ChannelId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(map[string]int64{"count": count}); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func patchChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireChannelId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "patchChannelJoinRequest") {
|
||||
return
|
||||
}
|
||||
if !model.IsValidId(c.Params.RequestId) {
|
||||
c.SetInvalidURLParam("request_id")
|
||||
return
|
||||
}
|
||||
|
||||
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
|
||||
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
|
||||
return
|
||||
}
|
||||
|
||||
var patch model.ChannelJoinRequestPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
c.SetInvalidParamWithErr("channel_join_request_patch", err)
|
||||
return
|
||||
}
|
||||
|
||||
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelJoinRequest, model.AuditStatusFail)
|
||||
defer c.LogAuditRec(auditRec)
|
||||
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "request_id", c.Params.RequestId)
|
||||
model.AddEventParameterToAuditRec(auditRec, "status", patch.Status)
|
||||
// Capture only the presence of a denial reason in the audit log; the
|
||||
// free-text contents are intentionally excluded.
|
||||
model.AddEventParameterToAuditRec(auditRec, "has_denial_reason", strconv.FormatBool(patch.DenialReason != nil && *patch.DenialReason != ""))
|
||||
|
||||
updated, appErr := c.App.UpdateChannelJoinRequest(c.AppContext, c.Params.RequestId, c.Params.ChannelId, &patch, c.AppContext.Session().UserId)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
auditRec.Success()
|
||||
auditRec.AddEventResultState(updated)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(updated); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
|
||||
func getMyChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
|
||||
c.RequireUserId()
|
||||
if c.Err != nil {
|
||||
return
|
||||
}
|
||||
if !requireDiscoverableChannelsEnabled(c, "getMyChannelJoinRequests") {
|
||||
return
|
||||
}
|
||||
|
||||
// Only the calling user can list their own requests; admins should use
|
||||
// the per-channel queue endpoint.
|
||||
if c.Params.UserId != c.AppContext.Session().UserId {
|
||||
c.SetPermissionError(model.PermissionEditOtherUsers)
|
||||
return
|
||||
}
|
||||
|
||||
opts := model.GetChannelJoinRequestsOpts{
|
||||
Status: r.URL.Query().Get("status"),
|
||||
Page: c.Params.Page,
|
||||
PerPage: c.Params.PerPage,
|
||||
}
|
||||
|
||||
list, appErr := c.App.GetMyChannelJoinRequests(c.AppContext, c.AppContext.Session().UserId, opts)
|
||||
if appErr != nil {
|
||||
c.Err = appErr
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(list); err != nil {
|
||||
c.Logger.Warn("Error while writing response", mlog.Err(err))
|
||||
}
|
||||
}
|
||||
154
server/channels/api4/channel_join_request_test.go
Normal file
154
server/channels/api4/channel_join_request_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
// setupDiscoverableTH spins up an api4 fixture with the discoverable channels
|
||||
// feature flag enabled so the new routes are registered.
|
||||
func setupDiscoverableTH(t *testing.T) *TestHelper {
|
||||
t.Helper()
|
||||
return SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.DiscoverableChannels = true
|
||||
}).InitBasic(t)
|
||||
}
|
||||
|
||||
// markDiscoverableViaAdmin patches `channel` to discoverable=true using the
|
||||
// SystemAdminClient so the permission check is satisfied without needing to
|
||||
// rebind the channel-admin role on the test fixture.
|
||||
func markDiscoverableViaAdmin(t *testing.T, th *TestHelper, channel *model.Channel) *model.Channel {
|
||||
t.Helper()
|
||||
on := true
|
||||
patched, _, err := th.SystemAdminClient.PatchChannel(context.Background(), channel.Id, &model.ChannelPatch{Discoverable: &on})
|
||||
require.NoError(t, err)
|
||||
require.True(t, patched.Discoverable)
|
||||
return patched
|
||||
}
|
||||
|
||||
func TestRequestJoinChannelAPI_HappyPath(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
channel = markDiscoverableViaAdmin(t, th, channel)
|
||||
|
||||
other := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, other, th.BasicTeam)
|
||||
_, _, err := th.Client.Login(context.Background(), other.Email, other.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := []byte(`{"message":"hi"}`)
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/channels/"+channel.Id+"/join_request", string(body))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var req model.ChannelJoinRequest
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&req))
|
||||
assert.Equal(t, model.ChannelJoinRequestStatusPending, req.Status)
|
||||
assert.Equal(t, channel.Id, req.ChannelId)
|
||||
assert.Equal(t, other.Id, req.UserId)
|
||||
}
|
||||
|
||||
func TestRequestJoinChannelAPI_FeatureDisabled(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
body := []byte(`{"message":"hi"}`)
|
||||
resp, err := th.Client.DoAPIPost(context.Background(), "/channels/"+channel.Id+"/join_request", string(body))
|
||||
defer closeBodyOrNil(resp)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "route must be unregistered when feature flag is off")
|
||||
}
|
||||
|
||||
func TestPatchChannelDiscoverable_RejectsNonPrivate(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
publicChannel := th.CreatePublicChannel(t)
|
||||
on := true
|
||||
_, resp, err := th.SystemAdminClient.PatchChannel(context.Background(), publicChannel.Id, &model.ChannelPatch{Discoverable: &on})
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestAddChannelMember_BlocksSelfAddOnDiscoverable(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
channel = markDiscoverableViaAdmin(t, th, channel)
|
||||
|
||||
// Add a user that has manage-private-channel-members on a different
|
||||
// channel but not this one. Use Client (BasicUser2) - they're a team
|
||||
// member but not yet a channel member here.
|
||||
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := th.Client.AddChannelMember(context.Background(), channel.Id, th.BasicUser2.Id)
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
// Without channel admin permission the underlying permission check
|
||||
// fails first; either way the request flow is what they need to use.
|
||||
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized,
|
||||
"got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetChannelByName_HiddenForNonQualifyingNonMember(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
// Plain (non-discoverable) private channel: a non-member must still get
|
||||
// 404 — this guards against a regression in the existing read paths.
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
|
||||
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, resp, err := th.Client.GetChannelByName(context.Background(), channel.Name, th.BasicTeam.Id, "")
|
||||
require.Error(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetChannelByName_VisibleForQualifyingNonMemberOnDiscoverable(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := setupDiscoverableTH(t)
|
||||
|
||||
channel := th.CreatePrivateChannel(t)
|
||||
channel = markDiscoverableViaAdmin(t, th, channel)
|
||||
|
||||
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, _, err := th.Client.GetChannelByName(context.Background(), channel.Name, th.BasicTeam.Id, "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, channel.Id, got.Id)
|
||||
assert.True(t, got.Discoverable)
|
||||
}
|
||||
|
||||
// closeBodyOrNil is a tiny helper so the negative-path tests don't need to
|
||||
// branch on a nil response body before deferring Close.
|
||||
func closeBodyOrNil(resp *http.Response) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
445
server/channels/api4/channel_local.go
Normal file
445
server/channels/api4/channel_local.go
Normal 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.AppContext, 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)
|
||||
}
|
||||
634
server/channels/api4/cloud.go
Normal file
634
server/channels/api4/cloud.go
Normal file
@@ -0,0 +1,634 @@
|
||||
// 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/check-cws-connection
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
// The annual renewals feature is deprecated; renewal and cancellation timing are not exposed to clients.
|
||||
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, 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, 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
|
||||
}
|
||||
|
||||
status := "available"
|
||||
if err := c.App.Cloud().CheckCWSConnection(c.AppContext.Session().UserId); err != nil {
|
||||
status = "unavailable"
|
||||
}
|
||||
|
||||
response := map[string]string{"status": status}
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
c.Err = model.NewAppError("Api4.handleCheckCWSConnection", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
488
server/channels/api4/cloud_test.go
Normal file
488
server/channels/api4/cloud_test.go
Normal file
@@ -0,0 +1,488 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
package api4
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckCWSConnection(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
t.Run("returns available when CWS is reachable", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense())
|
||||
|
||||
cloud := mocks.CloudInterface{}
|
||||
cloud.Mock.On("CheckCWSConnection", mock.Anything).Return(nil)
|
||||
|
||||
cloudImpl := th.App.Srv().Cloud
|
||||
defer func() {
|
||||
th.App.Srv().Cloud = cloudImpl
|
||||
}()
|
||||
th.App.Srv().Cloud = &cloud
|
||||
|
||||
r, err := th.Client.DoAPIGet(context.Background(), "/cloud/check-cws-connection", "")
|
||||
require.NoError(t, err)
|
||||
defer closeBody(r)
|
||||
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||
|
||||
var response map[string]string
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&response))
|
||||
assert.Equal(t, "available", response["status"])
|
||||
})
|
||||
|
||||
t.Run("returns unavailable when CWS is not reachable", func(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := Setup(t).InitBasic(t)
|
||||
|
||||
th.App.Srv().SetLicense(model.NewTestLicense())
|
||||
|
||||
cloud := mocks.CloudInterface{}
|
||||
cloud.Mock.On("CheckCWSConnection", mock.Anything).Return(errors.New("connection failed"))
|
||||
|
||||
cloudImpl := th.App.Srv().Cloud
|
||||
defer func() {
|
||||
th.App.Srv().Cloud = cloudImpl
|
||||
}()
|
||||
th.App.Srv().Cloud = &cloud
|
||||
|
||||
r, err := th.Client.DoAPIGet(context.Background(), "/cloud/check-cws-connection", "")
|
||||
require.NoError(t, err)
|
||||
defer closeBody(r)
|
||||
require.Equal(t, http.StatusOK, r.StatusCode)
|
||||
|
||||
var response map[string]string
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&response))
|
||||
assert.Equal(t, "unavailable", response["status"])
|
||||
})
|
||||
}
|
||||
38
server/channels/api4/cluster.go
Normal file
38
server/channels/api4/cluster.go
Normal 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))
|
||||
}
|
||||
}
|
||||
39
server/channels/api4/cluster_test.go
Normal file
39
server/channels/api4/cluster_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
554
server/channels/api4/command.go
Normal file
554
server/channels/api4/command.go
Normal file
@@ -0,0 +1,554 @@
|
||||
// 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(c.AppContext, 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 ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionCreatePost); !ok {
|
||||
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()
|
||||
commandArgs.ConnectionId = r.Header.Get(model.ConnectionId)
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
39
server/channels/api4/command_help_test.go
Normal file
39
server/channels/api4/command_help_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// 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(t)
|
||||
|
||||
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")
|
||||
}
|
||||
51
server/channels/api4/command_local.go
Normal file
51
server/channels/api4/command_local.go
Normal 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))
|
||||
}
|
||||
}
|
||||
2063
server/channels/api4/command_test.go
Normal file
2063
server/channels/api4/command_test.go
Normal file
File diff suppressed because it is too large
Load Diff
477
server/channels/api4/commands_test.go
Normal file
477
server/channels/api4/commands_test.go
Normal file
@@ -0,0 +1,477 @@
|
||||
// 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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
client := th.Client
|
||||
team := th.BasicTeam
|
||||
user1 := th.BasicUser
|
||||
user2 := th.BasicUser2
|
||||
user3 := th.CreateUser(t)
|
||||
user4 := th.CreateUser(t)
|
||||
user5 := th.CreateUser(t)
|
||||
user6 := th.CreateUser(t)
|
||||
user7 := th.CreateUser(t)
|
||||
user8 := th.CreateUser(t)
|
||||
user9 := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, user3, team)
|
||||
th.LinkUserToTeam(t, 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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
client := th.Client
|
||||
team := th.BasicTeam
|
||||
user1 := th.BasicUser
|
||||
user2 := th.BasicUser2
|
||||
user3 := th.CreateUser(t)
|
||||
th.LinkUserToTeam(t, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
_, _, 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(t)
|
||||
|
||||
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(t)
|
||||
|
||||
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
server/channels/api4/compliance.go
Normal file
163
server/channels/api4/compliance.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user