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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

126
server/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1 @@
1.26.7

281
server/.golangci.yml Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

3
server/bin/.gitignore vendored Normal file
View File

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

3
server/build/.gitignore vendored Normal file
View File

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

88
server/build/Dockerfile Normal file
View 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"]

View 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"]

View 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"]

View 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

View 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"]

View 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

View File

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

View File

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

21
server/build/README.md Normal file
View File

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

57
server/build/buildenv-tag.sh Executable file
View 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}"

View 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))
}

View 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

View 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

View File

@@ -0,0 +1,38 @@
# Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
# See License.txt for license information.
FROM postgres:14
RUN apt-get update && apt-get install -y ca-certificates
ARG MATTERMOST_VERSION
#
# Configure PostgreSQL
#
ENV POSTGRES_USER=mmuser
ENV POSTGRES_PASSWORD=mostest_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

View File

@@ -0,0 +1,19 @@
# Mattermost Docker Preview Image
This is a Docker image to install Mattermost in *Preview Mode* for exploring product functionality on a single machine using Docker.
Note: This configuration should not be used in production, as 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
```

View 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": {}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,428 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "datasource",
"uid": "grafana"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 5,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"gridPos": {
"h": 3,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"refId": "A"
}
],
"type": "welcome"
},
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 3
},
"id": 3,
"options": {
"includeVars": false,
"keepTime": false,
"maxItems": 30,
"query": "Mattermost",
"showHeadings": true,
"showRecentlyViewed": false,
"showSearch": true,
"showStarred": false,
"tags": []
},
"pluginVersion": "10.4.2",
"tags": [],
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "PBFA97CFB590B2093"
},
"refId": "A"
}
],
"title": "Dashboards",
"type": "dashlist"
},
{
"datasource": {
"type": "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

View File

@@ -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": ""
}

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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"
}
}

View 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

View File

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

View File

@@ -0,0 +1,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]

View File

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

View File

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

View File

@@ -0,0 +1,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'

View File

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

View 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
View 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
View File

@@ -0,0 +1,72 @@
#!/bin/bash
export COMPOSE_PROJECT_NAME=localdev
local_cmdname=${0##*/}
usage()
{
cat << USAGE >&2
Usage:
$local_cmdname up/down
USAGE
exit 1
}
up()
{
docker compose run --rm start_dependencies
docker compose exec openldap bash -c 'echo -e "dn: ou=testusers,dc=mm,dc=test,dc=com\nobjectclass: organizationalunit" | ldapadd -x -D "cn=admin,dc=mm,dc=test,dc=com" -w mostest_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

View File

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

View File

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

4
server/build/passwd Normal file
View File

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

View File

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

341
server/build/release.mk Normal file
View 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
View 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).

View 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)
}
}

File diff suppressed because it is too large Load Diff

View 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)
})
}

View 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)
}

View 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))
}
}

View 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)
}

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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")
})
}

View File

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

View 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))
}
}

View 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
View File

@@ -0,0 +1,319 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func (api *API) InitBot() {
api.BaseRoutes.Bots.Handle("", api.APISessionRequired(createBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("", api.APISessionRequired(patchBot)).Methods(http.MethodPut)
api.BaseRoutes.Bot.Handle("", api.APISessionRequired(getBot)).Methods(http.MethodGet)
api.BaseRoutes.Bots.Handle("", api.APISessionRequired(getBots)).Methods(http.MethodGet)
api.BaseRoutes.Bot.Handle("/disable", api.APISessionRequired(disableBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/enable", api.APISessionRequired(enableBot)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/convert_to_user", api.APISessionRequired(convertBotToUser)).Methods(http.MethodPost)
api.BaseRoutes.Bot.Handle("/assign/{user_id:[A-Za-z0-9]+}", api.APISessionRequired(assignBot)).Methods(http.MethodPost)
}
func createBot(c *Context, w http.ResponseWriter, r *http.Request) {
var botPatch *model.BotPatch
err := json.NewDecoder(r.Body).Decode(&botPatch)
if err != nil || botPatch == nil {
c.SetInvalidParamWithErr("bot", err)
return
}
bot := &model.Bot{
OwnerId: c.AppContext.Session().UserId,
}
bot.Patch(botPatch)
auditRec := c.MakeAuditRecord(model.AuditEventCreateBot, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "bot", bot)
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreateBot) {
c.SetPermissionError(model.PermissionCreateBot)
return
}
if user, err := c.App.GetUser(c.AppContext, 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))
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View 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)
}

File diff suppressed because it is too large Load Diff

View 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))
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,395 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
func getCategoriesForTeamForUser(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId().RequireTeamId()
if c.Err != nil {
return
}
if !c.App.SessionHasPermissionToUser(c.AppContext, *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)
}

File diff suppressed because it is too large Load Diff

View 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)
})
}

View 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))
}
}

View 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()
}

View File

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

View 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))
}
}

View 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"])
})
}

View File

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

View File

@@ -0,0 +1,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)
})
}

View 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))
}
}

View 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")
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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")
}

View File

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

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