Merge branch 'master' into migrate/brand-image-setting-to-function-component

This commit is contained in:
Mattermost Build 2026-01-21 12:30:23 +02:00 committed by GitHub
commit ac0ea510b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
938 changed files with 40603 additions and 41043 deletions

View file

@ -15,6 +15,9 @@ runs:
path: |
webapp/node_modules
webapp/channels/node_modules
webapp/platform/client/node_modules
webapp/platform/components/node_modules
webapp/platform/types/node_modules
key: node-modules-${{ runner.os }}-${{ hashFiles('webapp/package-lock.json') }}
- name: ci/cache-platform-builds
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0

View file

@ -20,7 +20,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: .nvmrc
cache: "npm"

View file

@ -33,8 +33,8 @@ jobs:
- name: buildenv/docker-login
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKERHUB_DEV_USERNAME }}
password: ${{ secrets.DOCKERHUB_DEV_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: buildenv/build
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
@ -44,16 +44,16 @@ jobs:
load: true
push: false
pull: false
tags: mattermostdevelopment/mattermost-build-server:test
tags: mattermost/mattermost-build-server:test
- name: buildenv/test
run: |
docker run --rm mattermostdevelopment/mattermost-build-server:test /bin/sh -c "go version && node --version"
docker run --rm mattermost/mattermost-build-server:test /bin/sh -c "go version && node --version"
- name: buildenv/calculate-golang-version
id: go
run: |
GO_VERSION=$(docker run --rm mattermostdevelopment/mattermost-build-server:test go version | awk '{print $3}' | sed 's/go//')
GO_VERSION=$(docker run --rm mattermost/mattermost-build-server:test go version | awk '{print $3}' | sed 's/go//')
echo "GO_VERSION=${GO_VERSION}" >> "${GITHUB_OUTPUT}"
- name: buildenv/push
@ -65,7 +65,7 @@ jobs:
load: false
push: true
pull: true
tags: mattermostdevelopment/mattermost-build-server:${{ steps.go.outputs.GO_VERSION }}
tags: mattermost/mattermost-build-server:${{ steps.go.outputs.GO_VERSION }}
build-image-fips:
runs-on: ubuntu-22.04
@ -79,8 +79,8 @@ jobs:
- name: buildenv/docker-login
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
username: ${{ secrets.DOCKERHUB_DEV_USERNAME }}
password: ${{ secrets.DOCKERHUB_DEV_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: buildenv/build
uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0
@ -90,16 +90,16 @@ jobs:
load: true
push: false
pull: false
tags: mattermostdevelopment/mattermost-build-server-fips:test
tags: mattermost/mattermost-build-server-fips:test
- name: buildenv/test
run: |
docker run --rm --entrypoint bash mattermostdevelopment/mattermost-build-server-fips:test -c "go version && node --version"
docker run --rm --entrypoint bash mattermost/mattermost-build-server-fips:test -c "go version && node --version"
- name: buildenv/calculate-golang-version
id: go
run: |
GO_VERSION=$(docker run --rm --entrypoint bash mattermostdevelopment/mattermost-build-server-fips:test -c "go version" | awk '{print $3}' | sed 's/go//')
GO_VERSION=$(docker run --rm --entrypoint bash mattermost/mattermost-build-server-fips:test -c "go version" | awk '{print $3}' | sed 's/go//')
echo "GO_VERSION=${GO_VERSION}" >> "${GITHUB_OUTPUT}"
- name: buildenv/push
@ -111,4 +111,4 @@ jobs:
load: false
push: true
pull: true
tags: mattermostdevelopment/mattermost-build-server-fips:${{ steps.go.outputs.GO_VERSION }}
tags: mattermost/mattermost-build-server-fips:${{ steps.go.outputs.GO_VERSION }}

View file

@ -133,7 +133,7 @@ jobs:
fetch-depth: 0
- name: ci/setup-node
if: "${{ inputs.run_preflight_checks }}"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
id: setup_node
with:
node-version-file: ".nvmrc"
@ -164,7 +164,7 @@ jobs:
fetch-depth: 0
- name: ci/setup-node
if: "${{ inputs.run_preflight_checks }}"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
id: setup_node
with:
node-version-file: ".nvmrc"
@ -246,7 +246,7 @@ jobs:
ref: ${{ inputs.commit_sha }}
fetch-depth: 0
- name: ci/setup-node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
id: setup_node
with:
node-version-file: ".nvmrc"
@ -333,7 +333,7 @@ jobs:
ln -sfn /usr/local/opt/docker-compose/bin/docker-compose ~/.docker/cli-plugins/docker-compose
sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock
- name: ci/setup-node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
id: setup_node
with:
node-version-file: ".nvmrc"
@ -412,7 +412,7 @@ jobs:
e2e-tests/${{ inputs.TEST }}/results/
- name: ci/setup-node
if: "${{ inputs.enable_reporting }}"
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
id: setup_node
with:
node-version-file: ".nvmrc"

View file

@ -46,7 +46,7 @@ jobs:
echo "BUILD_IMAGE=mattermost/mattermost-build-server-fips:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}"
echo "LOG_ARTIFACT_NAME=${{ inputs.logsartifact }}-fips" >> "${GITHUB_OUTPUT}"
else
echo "BUILD_IMAGE=mattermostdevelopment/mattermost-build-server:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}"
echo "BUILD_IMAGE=mattermost/mattermost-build-server:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}"
echo "LOG_ARTIFACT_NAME=${{ inputs.logsartifact }}" >> "${GITHUB_OUTPUT}"
fi

View file

@ -40,7 +40,7 @@ jobs:
name: Check mocks
needs: go
runs-on: ubuntu-22.04
container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }}
container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }}
defaults:
run:
working-directory: server
@ -57,7 +57,7 @@ jobs:
name: Check go mod tidy
needs: go
runs-on: ubuntu-22.04
container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }}
container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }}
defaults:
run:
working-directory: server
@ -74,7 +74,7 @@ jobs:
name: check-style
needs: go
runs-on: ubuntu-22.04
container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }}
container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }}
defaults:
run:
working-directory: server
@ -91,7 +91,7 @@ jobs:
name: Check serialization methods for hot structs
needs: go
runs-on: ubuntu-22.04
container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }}
container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }}
defaults:
run:
working-directory: server
@ -108,7 +108,7 @@ jobs:
name: Vet API
needs: go
runs-on: ubuntu-22.04
container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }}
container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }}
defaults:
run:
working-directory: server
@ -123,7 +123,7 @@ jobs:
name: Check migration files
needs: go
runs-on: ubuntu-22.04
container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }}
container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }}
defaults:
run:
working-directory: server
@ -138,7 +138,7 @@ jobs:
name: Generate email templates
needs: go
runs-on: ubuntu-22.04
container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }}
container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }}
defaults:
run:
working-directory: server
@ -155,7 +155,7 @@ jobs:
name: Check store layers
needs: go
runs-on: ubuntu-22.04
container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }}
container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }}
defaults:
run:
working-directory: server
@ -172,7 +172,7 @@ jobs:
name: Check mmctl docs
needs: go
runs-on: ubuntu-22.04
container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }}
container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }}
defaults:
run:
working-directory: server
@ -271,7 +271,7 @@ jobs:
name: Build mattermost server app
needs: go
runs-on: ubuntu-22.04
container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }}
container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }}
defaults:
run:
working-directory: server
@ -282,6 +282,12 @@ jobs:
steps:
- name: Checkout mattermost project
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/setup-node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: "npm"
cache-dependency-path: "webapp/package-lock.json"
- name: Run setup-go-work
run: make setup-go-work
- name: Build

View file

@ -59,7 +59,7 @@ jobs:
echo "BUILD_IMAGE=mattermost/mattermost-build-server-fips:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}"
echo "LOG_ARTIFACT_NAME=${{ inputs.logsartifact }}-fips" >> "${GITHUB_OUTPUT}"
else
echo "BUILD_IMAGE=mattermostdevelopment/mattermost-build-server:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}"
echo "BUILD_IMAGE=mattermost/mattermost-build-server:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}"
echo "LOG_ARTIFACT_NAME=${{ inputs.logsartifact }}" >> "${GITHUB_OUTPUT}"
fi

View file

@ -41,18 +41,10 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: ci/setup
uses: ./.github/actions/webapp-setup
- name: ci/lint
- name: ci/i18n-extract
working-directory: webapp/channels
run: |
cp src/i18n/en.json /tmp/en.json
mkdir -p /tmp/fake-mobile-dir/assets/base/i18n/
echo '{}' > /tmp/fake-mobile-dir/assets/base/i18n/en.json
npm run mmjstool -- i18n extract-webapp --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir
diff /tmp/en.json src/i18n/en.json
# Address weblate behavior which does not remove whole translation item when translation string is set to empty
npm run mmjstool -- i18n clean-empty --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir --check
npm run mmjstool -- i18n check-empty-src --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir
rm -rf tmp
npm run i18n-extract:check
check-types:
needs: check-lint

2
.gitignore vendored
View file

@ -161,5 +161,5 @@ docker-compose.override.yaml
.env
**/CLAUDE.local.md
CLAUDE.md
**/CLAUDE.md
.cursorrules

2
.nvmrc
View file

@ -1 +1 @@
20.11
24.11

View file

@ -9695,41 +9695,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## oov/psd
This product contains 'psd' by oov.
A PSD/PSB file reader for go
* HOMEPAGE:
* https://github.com/oov/psd
* LICENSE: MIT
MIT License
Copyright (c) 2016 oov
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.
---
## opensearch-project/opensearch-go
@ -10896,6 +10861,21 @@ Internationalize React apps. This library provides React components and an API t
---
## react-intl
This product contains 'react-intl' by Eric Ferraiuolo.
Internationalize React apps. This library provides React components and an API to format dates, numbers, and strings, including pluralization and handling translations.
* HOMEPAGE:
* https://formatjs.github.io/docs/react-intl
* LICENSE: BSD-3-Clause
---
## react-is
@ -13051,5 +13031,3 @@ 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

@ -20,6 +20,8 @@ build-v4: node_modules playbooks
@cat $(V4_SRC)/posts.yaml >> $(V4_YAML)
@cat $(V4_SRC)/preferences.yaml >> $(V4_YAML)
@cat $(V4_SRC)/files.yaml >> $(V4_YAML)
@cat $(V4_SRC)/recaps.yaml >> $(V4_YAML)
@cat $(V4_SRC)/ai.yaml >> $(V4_YAML)
@cat $(V4_SRC)/uploads.yaml >> $(V4_YAML)
@cat $(V4_SRC)/jobs.yaml >> $(V4_YAML)
@cat $(V4_SRC)/system.yaml >> $(V4_YAML)

54
api/v4/source/ai.yaml Normal file
View file

@ -0,0 +1,54 @@
/api/v4/ai/agents:
get:
tags:
- ai
summary: Get available AI agents
description: >
Retrieve all available AI agents from the AI plugin's bridge API.
If a user ID is provided, only agents accessible to that user are returned.
##### Permissions
Must be authenticated.
__Minimum server version__: 11.2
operationId: GetAIAgents
responses:
"200":
description: AI agents retrieved successfully
content:
application/json:
schema:
$ref: "#/components/schemas/AgentsResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/ai/services:
get:
tags:
- ai
summary: Get available AI services
description: >
Retrieve all available AI services from the AI plugin's bridge API.
If a user ID is provided, only services accessible to that user
(via their permitted bots) are returned.
##### Permissions
Must be authenticated.
__Minimum server version__: 11.2
operationId: GetAIServices
responses:
"200":
description: AI services retrieved successfully
content:
application/json:
schema:
$ref: "#/components/schemas/ServicesResponse"
"401":
$ref: "#/components/responses/Unauthorized"
"500":
$ref: "#/components/responses/InternalServerError"

View file

@ -3704,7 +3704,7 @@ components:
type: array
description: list of users participating in this thread. only includes IDs unless 'extended' was set to 'true'
items:
$ref: "#/components/schemas/Post"
$ref: "#/components/schemas/User"
post:
$ref: "#/components/schemas/Post"
RelationalIntegrityCheckData:
@ -4633,6 +4633,83 @@ components:
active:
type: boolean
description: The active status of the policy.
Recap:
type: object
properties:
id:
type: string
description: Unique identifier for the recap
user_id:
type: string
description: ID of the user who created the recap
title:
type: string
description: AI-generated title for the recap (max 5 words)
create_at:
type: integer
format: int64
description: The time in milliseconds the recap was created
update_at:
type: integer
format: int64
description: The time in milliseconds the recap was last updated
delete_at:
type: integer
format: int64
description: The time in milliseconds the recap was deleted
read_at:
type: integer
format: int64
description: The time in milliseconds the recap was marked as read
total_message_count:
type: integer
description: Total number of messages summarized across all channels
status:
type: string
enum: [pending, processing, completed, failed]
description: Current status of the recap job
bot_id:
type: string
description: ID of the AI agent/bot used to generate this recap
channels:
type: array
items:
$ref: "#/components/schemas/RecapChannel"
description: List of channel summaries included in this recap
RecapChannel:
type: object
properties:
id:
type: string
description: Unique identifier for the recap channel
recap_id:
type: string
description: ID of the parent recap
channel_id:
type: string
description: ID of the channel that was summarized
channel_name:
type: string
description: Display name of the channel
highlights:
type: array
items:
type: string
description: Key discussion points and important information from the channel
action_items:
type: array
items:
type: string
description: Tasks, todos, and action items mentioned in the channel
source_post_ids:
type: array
items:
type: string
description: IDs of the posts used to generate this summary
create_at:
type: integer
format: int64
description: The time in milliseconds the recap channel was created
externalDocs:
description: Find out more about Mattermost
url: 'https://about.mattermost.com'

View file

@ -462,8 +462,10 @@ tags:
description: Endpoints related to metrics, including the Client Performance Monitoring feature.
- name: audit_logs
description: Endpoints for managing audit log certificates and configuration.
- name: ai
description: Endpoints for interacting with AI agents and services.
- name: recaps
description: Endpoints for creating and managing AI-powered channel recaps that summarize unread messages.
- name: agents
description: Endpoints for interacting with AI agents and LLM services.
servers:
- url: "{your-mattermost-url}"
variables:

240
api/v4/source/recaps.yaml Normal file
View file

@ -0,0 +1,240 @@
"/api/v4/recaps":
post:
tags:
- recaps
- ai
summary: Create a channel recap
description: >
Create a new AI-powered recap for the specified channels. The recap will
summarize unread messages in the selected channels, extracting highlights
and action items. This creates a background job that processes the recap
asynchronously. The recap is created for the authenticated user.
##### Permissions
Must be authenticated. User must be a member of all specified channels.
__Minimum server version__: 11.2
operationId: CreateRecap
requestBody:
content:
application/json:
schema:
type: object
required:
- channel_ids
- title
- agent_id
properties:
title:
type: string
description: Title for the recap
channel_ids:
type: array
items:
type: string
description: List of channel IDs to include in the recap
minItems: 1
agent_id:
type: string
description: ID of the AI agent to use for generating the recap
description: Recap creation request
required: true
responses:
"201":
description: Recap creation successful. The recap will be processed asynchronously.
content:
application/json:
schema:
$ref: "#/components/schemas/Recap"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
get:
tags:
- recaps
- ai
summary: Get current user's recaps
description: >
Get a paginated list of recaps created by the authenticated user.
##### Permissions
Must be authenticated.
__Minimum server version__: 11.2
operationId: GetRecapsForUser
parameters:
- name: page
in: query
description: The page to select.
schema:
type: integer
default: 0
- name: per_page
in: query
description: The number of recaps per page.
schema:
type: integer
default: 60
responses:
"200":
description: Recaps retrieval successful
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Recap"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"/api/v4/recaps/{recap_id}":
get:
tags:
- recaps
- ai
summary: Get a specific recap
description: >
Get a recap by its ID, including all channel summaries. Only the authenticated
user who created the recap can retrieve it.
##### Permissions
Must be authenticated. Can only retrieve recaps created by the current user.
__Minimum server version__: 11.2
operationId: GetRecap
parameters:
- name: recap_id
in: path
description: Recap GUID
required: true
schema:
type: string
responses:
"200":
description: Recap retrieval successful
content:
application/json:
schema:
$ref: "#/components/schemas/Recap"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
delete:
tags:
- recaps
- ai
summary: Delete a recap
description: >
Delete a recap by its ID. Only the authenticated user who created the recap
can delete it.
##### Permissions
Must be authenticated. Can only delete recaps created by the current user.
__Minimum server version__: 11.2
operationId: DeleteRecap
parameters:
- name: recap_id
in: path
description: Recap GUID
required: true
schema:
type: string
responses:
"200":
description: Recap deletion successful
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"/api/v4/recaps/{recap_id}/read":
post:
tags:
- recaps
- ai
summary: Mark a recap as read
description: >
Mark a recap as read by the authenticated user. This updates the recap's
read status and timestamp.
##### Permissions
Must be authenticated. Can only mark recaps created by the current user as read.
__Minimum server version__: 11.2
operationId: MarkRecapAsRead
parameters:
- name: recap_id
in: path
description: Recap GUID
required: true
schema:
type: string
responses:
"200":
description: Recap marked as read successfully
content:
application/json:
schema:
$ref: "#/components/schemas/Recap"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"
"/api/v4/recaps/{recap_id}/regenerate":
post:
tags:
- recaps
- ai
summary: Regenerate a recap
description: >
Regenerate a recap by its ID. This creates a new background job to
regenerate the AI-powered recap with the latest messages from the
specified channels.
##### Permissions
Must be authenticated. Can only regenerate recaps created by the current user.
__Minimum server version__: 11.2
operationId: RegenerateRecap
parameters:
- name: recap_id
in: path
description: Recap GUID
required: true
schema:
type: string
responses:
"200":
description: Recap regeneration initiated successfully
content:
application/json:
schema:
$ref: "#/components/schemas/Recap"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/Forbidden"
"404":
$ref: "#/components/responses/NotFound"

View file

@ -260,7 +260,7 @@ $(if mme2e_is_token_in_list "webhook-interactions" "$ENABLED_DOCKER_SERVICES"; t
# shellcheck disable=SC2016
echo '
webhook-interactions:
image: mattermostdevelopment/mirrored-node:${NODE_VERSION_REQUIRED}
image: node:${NODE_VERSION_REQUIRED}
command: sh -c "npm install --global --legacy-peer-deps && exec node webhook_serve.js"
healthcheck:
test: ["CMD", "curl", "-s", "-o/dev/null", "127.0.0.1:3000"]
@ -275,11 +275,21 @@ $(if mme2e_is_token_in_list "webhook-interactions" "$ENABLED_DOCKER_SERVICES"; t
fi)
$(if mme2e_is_token_in_list "playwright" "$ENABLED_DOCKER_SERVICES"; then
# shellcheck disable=SC2016
echo '
playwright:
image: mcr.microsoft.com/playwright:v1.56.0-noble
image: mcr.microsoft.com/playwright:v1.57.0-noble
entrypoint: ["/bin/bash", "-c"]
command: ["until [ -f /var/run/mm_terminate ]; do sleep 5; done"]
command:
- |
# Install Node.js based on .nvmrc
NODE_VERSION=$$(cat /mattermost/.nvmrc)
echo "Installing Node.js $${NODE_VERSION}..."
curl -fsSL https://deb.nodesource.com/setup_$${NODE_VERSION%%.*}.x | bash -
apt-get install -y nodejs
echo "Node.js version: $$(node --version)"
# Wait for termination signal
until [ -f /var/run/mm_terminate ]; do sleep 5; done
env_file:
- "./.env.playwright"
environment:

View file

@ -74,7 +74,6 @@
"mochawesome-merge": "4.4.1",
"mochawesome-report-generator": "6.2.0",
"moment-timezone": "0.6.0",
"mysql": "2.18.1",
"path": "0.12.7",
"pdf-parse": "1.1.1",
"pg": "8.16.3",
@ -93,7 +92,6 @@
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
@ -1071,7 +1069,6 @@
"integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@ -1114,6 +1111,7 @@
"integrity": "sha512-N4ntErOlKvcbTt01rr5wj3y55xnIdx1ymrfIr8C2WnM1Y9glFgWaGDEULJIazOX3XM9NRzhfJ6zZnQ1sBNWU+w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
"eslint-visitor-keys": "^2.1.0",
@ -1150,7 +1148,6 @@
"integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.0",
"@babel/types": "^7.28.0",
@ -1168,7 +1165,6 @@
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/compat-data": "^7.27.2",
"@babel/helper-validator-option": "^7.27.1",
@ -1186,7 +1182,6 @@
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@ -1197,7 +1192,6 @@
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
@ -1212,7 +1206,6 @@
"integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
@ -1231,7 +1224,6 @@
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@ -1252,7 +1244,6 @@
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@ -1263,7 +1254,6 @@
"integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.2"
@ -1278,7 +1268,6 @@
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.28.0"
},
@ -1305,7 +1294,6 @@
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.2",
@ -1321,7 +1309,6 @@
"integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.0",
@ -1341,7 +1328,6 @@
"integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
@ -1813,7 +1799,6 @@
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
@ -1825,7 +1810,6 @@
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.0.0"
}
@ -1836,7 +1820,6 @@
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
@ -1847,8 +1830,7 @@
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.30",
@ -1856,7 +1838,6 @@
"integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
@ -1884,6 +1865,7 @@
"integrity": "sha512-2795KUkp2EkuJ9NVohPkJmrgKunt6OZiLyo8zUoIWPJjxQ0upjiWJz/KenABx38v8+QfpSEN8tZSBN3lsZCueg==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"typescript": "^4.3.0 || ^5.0.0"
},
@ -2866,7 +2848,6 @@
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@ -2878,7 +2859,6 @@
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint": "*",
"@types/estree": "*"
@ -3179,6 +3159,7 @@
"integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.39.1",
"@typescript-eslint/types": "8.39.1",
@ -3397,7 +3378,6 @@
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/helper-numbers": "1.13.2",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
@ -3408,24 +3388,21 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-api-error": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-buffer": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-numbers": {
"version": "1.13.2",
@ -3433,7 +3410,6 @@
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
"@webassemblyjs/helper-api-error": "1.13.2",
@ -3445,8 +3421,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@webassemblyjs/helper-wasm-section": {
"version": "1.14.1",
@ -3454,7 +3429,6 @@
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -3468,7 +3442,6 @@
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@xtuc/ieee754": "^1.2.0"
}
@ -3479,7 +3452,6 @@
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@xtuc/long": "4.2.2"
}
@ -3489,8 +3461,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@webassemblyjs/wasm-edit": {
"version": "1.14.1",
@ -3498,7 +3469,6 @@
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -3516,7 +3486,6 @@
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
@ -3531,7 +3500,6 @@
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -3545,7 +3513,6 @@
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-api-error": "1.13.2",
@ -3561,7 +3528,6 @@
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@xtuc/long": "4.2.2"
@ -3572,16 +3538,14 @@
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/@xtuc/long": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true
"license": "Apache-2.0"
},
"node_modules/accepts": {
"version": "2.0.0",
@ -3603,6 +3567,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3616,7 +3581,6 @@
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.13.0"
},
@ -3671,7 +3635,6 @@
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@ -3690,7 +3653,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -3707,8 +3669,7 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/ally.js": {
"version": "1.4.1",
@ -4198,16 +4159,6 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/bignumber.js": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz",
"integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/blob-util": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz",
@ -4340,8 +4291,7 @@
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/byte-length": {
"version": "1.0.2",
@ -4462,8 +4412,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0",
"peer": true
"license": "CC-BY-4.0"
},
"node_modules/caseless": {
"version": "0.12.0",
@ -4558,7 +4507,6 @@
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.0"
}
@ -4848,8 +4796,7 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
@ -4871,13 +4818,6 @@
"node": ">=6.6.0"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.0.0.tgz",
@ -4935,6 +4875,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cypress/request": "^3.0.9",
"@cypress/xvfb": "^1.2.4",
@ -5423,8 +5364,7 @@
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz",
"integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==",
"dev": true,
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
@ -5473,6 +5413,7 @@
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-colors": "^4.1.1",
"strip-ansi": "^6.0.1"
@ -5603,8 +5544,7 @@
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
@ -5702,6 +5642,7 @@
"integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@ -5903,6 +5844,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -6535,8 +6477,7 @@
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
@ -6916,7 +6857,6 @@
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@ -7083,8 +7023,7 @@
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.0.3",
@ -7985,13 +7924,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -8046,7 +7978,6 @@
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"merge-stream": "^2.0.0",
@ -8062,7 +7993,6 @@
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@ -8079,6 +8009,7 @@
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@ -8116,7 +8047,6 @@
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jsesc": "bin/jsesc"
},
@ -8136,8 +8066,7 @@
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
@ -8173,7 +8102,6 @@
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"json5": "lib/cli.js"
},
@ -8449,7 +8377,6 @@
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.11.5"
}
@ -8652,7 +8579,6 @@
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"yallist": "^3.0.2"
}
@ -8874,6 +8800,7 @@
"integrity": "sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"browser-stdout": "^1.3.1",
"chokidar": "^4.0.1",
@ -9372,29 +9299,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/mysql": {
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz",
"integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==",
"dev": true,
"license": "MIT",
"dependencies": {
"bignumber.js": "9.0.0",
"readable-stream": "2.3.7",
"safe-buffer": "5.1.2",
"sqlstring": "2.3.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mysql/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -9417,8 +9321,7 @@
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/node-ensure": {
"version": "0.0.0",
@ -9432,8 +9335,7 @@
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/notp": {
"version": "2.0.3",
@ -10264,13 +10166,6 @@
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true,
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -10441,29 +10336,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@ -10619,7 +10491,6 @@
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -10852,7 +10723,6 @@
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
@ -10891,7 +10761,6 @@
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
@ -10904,8 +10773,7 @@
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/semver": {
"version": "6.3.1",
@ -10972,6 +10840,7 @@
"integrity": "sha512-b0IrY3b1gVMsWvJppCf19g1p3JSnS0hQi6xu4Hi40CIhf0Lx8pQHcvBL+xunShpmOiQzg1NOia812NAWdSaShw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@servie/events": "^1.0.0",
"byte-length": "^1.0.2",
@ -11229,7 +11098,6 @@
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -11240,7 +11108,6 @@
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@ -11256,16 +11123,6 @@
"node": ">= 10.x"
}
},
"node_modules/sqlstring": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz",
"integrity": "sha512-ooAzh/7dxIG5+uDik1z/Rd1vli0+38izZhGzSa34FwR7IbelPWCCKSNIl8jlL/F7ERvy8CB2jNeM1E9i9mXMAQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
@ -11659,7 +11516,6 @@
"integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.14.0",
@ -11679,7 +11535,6 @@
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5",
@ -11714,8 +11569,7 @@
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/thirty-two": {
"version": "0.0.2",
@ -12038,6 +11892,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -12106,6 +11961,7 @@
"integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.41.0",
"@typescript-eslint/types": "8.41.0",
@ -12394,7 +12250,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
@ -12513,7 +12368,6 @@
"integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.1.2"
@ -12528,7 +12382,6 @@
"integrity": "sha512-rHY3vHXRbkSfhG6fH8zYQdth/BtDgXXuR2pHF++1f/EBkI8zkgM5XWfsC3BvOoW9pr1CvZ1qQCxhCEsbNgT50g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@ -12578,7 +12431,6 @@
"integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.13.0"
}
@ -12589,7 +12441,6 @@
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
@ -12604,7 +12455,6 @@
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@ -12615,7 +12465,6 @@
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@ -12629,7 +12478,6 @@
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@ -12906,8 +12754,7 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",

View file

@ -69,7 +69,6 @@
"mochawesome-merge": "4.4.1",
"mochawesome-report-generator": "6.2.0",
"moment-timezone": "0.6.0",
"mysql": "2.18.1",
"path": "0.12.7",
"pdf-parse": "1.1.1",
"pg": "8.16.3",

View file

@ -50,7 +50,7 @@ describe('Verify Accessibility Support in Popovers', () => {
{ariaLabel: 'People & Body', header: 'People & Body'},
{ariaLabel: 'Animals & Nature', header: 'Animals & Nature'},
{ariaLabel: 'Food & Drink', header: 'Food & Drink'},
{ariaLabel: 'Travel Places', header: 'Travel Places'},
{ariaLabel: 'Travel & Places', header: 'Travel & Places'},
{ariaLabel: 'Activities', header: 'Activities'},
{ariaLabel: 'Objects', header: 'Objects'},
{ariaLabel: 'Symbols', header: 'Symbols'},

View file

@ -85,7 +85,7 @@ describe('Profile > Profile Settings > Email', () => {
cy.get('#primaryEmail').should('be.visible').click().blur();
// * Check that the correct error message is shown.
cy.get('#error_primaryEmail').should('be.visible').should('have.text', 'Please enter a valid email address');
cy.get('#error_primaryEmail').should('be.visible').should('have.text', 'Please enter a valid email address.');
});
it('MM-T2067 email address already taken error', () => {

View file

@ -103,7 +103,7 @@ describe('Archived channels', () => {
function verifyViewingArchivedChannel(channel) {
// * Verify that we've switched to the correct channel and that the header contains the archived icon
cy.get('#channelHeaderTitle').should('contain', channel.display_name);
cy.get('#channelHeaderInfo .icon__archive').should('be.visible');
cy.findByTestId('channel-header-archive-icon').should('be.visible');
// * Verify that the channel is visible in the sidebar with the archived icon
cy.get(`#sidebarItem_${channel.name}`).should('be.visible').

View file

@ -61,7 +61,7 @@ describe('Authentication', () => {
cy.get('#input_name').clear().type(`test${getRandomId()}`);
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Make sure account was created successfully and we are at the select team page
cy.findByText('Teams you can join:', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
@ -113,7 +113,7 @@ describe('Authentication', () => {
cy.get('#input_name').clear().type(`test${getRandomId()}`);
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Make sure account was not created successfully
cy.get('.AlertBanner__title').scrollIntoView().should('be.visible');
@ -146,7 +146,7 @@ describe('Authentication', () => {
cy.get('#input_name').clear().type(username);
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Make sure account was created successfully and we are on the team joining page
cy.findByText('Teams you can join:', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');

View file

@ -61,14 +61,14 @@ describe('Authentication', () => {
cy.get('#input_password-input').clear().type('less');
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Assert the error is what is expected;
cy.findByText('Your password must be 7-72 characters long.').should('be.visible');
cy.get('#input_password-input').clear().type('greaterthan7');
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Assert that we are not shown an MFA screen and instead a Teams You Can join page
cy.findByText('Teams you can join:', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
@ -114,7 +114,7 @@ describe('Authentication', () => {
['NOLOWERCASE123!', 'noupppercase123!', 'NoNumber!', 'NoSymbol123'].forEach((option) => {
cy.get('#input_password-input').clear().type(option);
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Assert the error is what is expected;
cy.findByText('Your password must be 5-72 characters long and include both lowercase and uppercase letters, numbers, and special characters.').should('be.visible');

View file

@ -151,7 +151,7 @@ describe('Authentication', () => {
['1user', 'te', 'user#1', 'user!1'].forEach((option) => {
cy.get('#input_name').clear().type(option);
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Assert the error is what is expected;
cy.get('.Input___error').scrollIntoView().should('be.visible');
@ -183,7 +183,7 @@ describe('Authentication', () => {
cy.get('#input_name').clear().type(`Test${getRandomId()}`);
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Make sure account was created successfully and we are on the team joining page
cy.findByText('Teams you can join:', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
@ -245,7 +245,7 @@ describe('Authentication', () => {
cy.get('#input_name').clear().type(`Test${getRandomId()}`);
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Make sure account was not created successfully
cy.get('.AlertBanner__title').scrollIntoView().should('be.visible');
@ -271,7 +271,7 @@ describe('Authentication', () => {
cy.findByText('Copy invite link').click();
// # Input email, select member
cy.findByLabelText('Add or Invite People').type(`test-${getRandomId()}@mattermost.com{downarrow}{downarrow}{enter}`, {force: true});
cy.findByLabelText('Invite People').type(`test-${getRandomId()}@mattermost.com{downarrow}{downarrow}{enter}`, {force: true});
// # Click invite members button
cy.findByRole('button', {name: 'Invite'}).click({force: true});

View file

@ -49,7 +49,7 @@ describe('Verify Accessibility Support in different input fields', () => {
// * Verify Accessibility Support in Add or Invite People input field
cy.get('.users-emails-input__control').should('be.visible').within(() => {
cy.get('input').should('have.attr', 'aria-label', 'Add or Invite People').and('have.attr', 'aria-autocomplete', 'list');
cy.get('input').should('have.attr', 'aria-label', 'Invite People').and('have.attr', 'aria-autocomplete', 'list');
cy.get('.users-emails-input__placeholder').should('have.text', 'Enter a name or email address');
});
@ -58,7 +58,7 @@ describe('Verify Accessibility Support in different input fields', () => {
// * Verify Accessibility Support in Invite People input field
cy.get('.users-emails-input__control').should('be.visible').within(() => {
cy.get('input').should('have.attr', 'aria-label', 'Add or Invite People').and('have.attr', 'aria-autocomplete', 'list');
cy.get('input').should('have.attr', 'aria-label', 'Invite People').and('have.attr', 'aria-autocomplete', 'list');
cy.get('.users-emails-input__placeholder').should('have.text', 'Enter a name or email address');
});

View file

@ -58,7 +58,7 @@ describe('Authentication', () => {
cy.get('#input_name').clear().type(`Test${getRandomId()}`);
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Make sure account was not created successfully
cy.get('.AlertBanner__title').scrollIntoView().should('be.visible');

View file

@ -78,13 +78,16 @@ describe('Guest Accounts', () => {
// # Click "Save".
cy.findByText('Save').click().wait(TIMEOUTS.ONE_SEC);
// # Get MFA secret
// # Visit a page to trigger MFA setup redirect, then complete MFA setup for admin
cy.visit('/');
cy.url().should('include', 'mfa/setup');
cy.uiGetMFASecret(sysadmin.id).then((secret) => {
adminMFASecret = secret;
});
// # Navigate to Guest Access page.
cy.visit('/admin_console/authentication/guest_access');
cy.url().should('include', '/admin_console/authentication/guest_access');
// # Enable guest accounts.
cy.findByTestId('GuestAccountsSettings.Enabletrue').check();
@ -144,20 +147,20 @@ describe('Guest Accounts', () => {
// # Create an account with Email and Password.
cy.get('#input_name').type(username);
cy.get('#input_password-input').type(username);
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * When MFA is enforced for Guest Access, guest user should be forced to configure MFA while creating an account.
cy.url().should('include', 'mfa/setup');
cy.get('#mfa').wait(TIMEOUTS.HALF_SEC).find('.col-sm-12').then((p) => {
cy.get('#mfa').wait(TIMEOUTS.HALF_SEC).find('p.col-sm-12 span').then((p) => {
const secretp = p.text();
const secret = secretp.split(' ')[1];
const token = authenticator.generateToken(secret);
cy.get('#mfa').find('.form-control').type(token);
cy.get('#mfa').find('.btn.btn-primary').click();
cy.findByPlaceholderText('MFA Code').type(token);
cy.findByText('Save').click();
cy.wait(TIMEOUTS.ONE_SEC);
cy.get('#mfa').find('.btn.btn-primary').click();
cy.findByText('Okay').click();
});
cy.apiLogout();
});

View file

@ -106,7 +106,7 @@ describe('Guest Account - Member Invitation Flow', () => {
cy.get('#input_email').type(email);
cy.get('#input_name').type(username);
cy.get('#input_password-input').type('Testing123');
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// * Verify if user is added to the invited team
cy.uiGetLHSHeader().findByText(testTeam.display_name);

View file

@ -65,8 +65,19 @@ describe('Incoming webhook', () => {
cy.visit(`/${testTeam.name}/channels/${testChannel.name}`);
// # Post webhook and wait for attachment to render
cy.postIncomingWebhook({url: incomingWebhook.url, data: payload});
// # Verify the post appears in the channel with attachment
cy.getLastPost().within(() => {
cy.get('.attachment__body').should('be.visible').should('contain', 'Findme.');
});
// # Explicitly wait to give Elasticsearch time to index before searching
// Using a longer wait time since Elasticsearch indexing can be slow in test environments
cy.wait(TIMEOUTS.THREE_SEC);
// # Search for text in the attachment
cy.uiGetSearchContainer().click();
cy.uiGetSearchBox().
wait(TIMEOUTS.HALF_SEC).

View file

@ -14,17 +14,24 @@ import * as TIMEOUTS from '../../../../fixtures/timeouts';
describe('Archived channels', () => {
let testChannel;
let testPrivateChannel;
before(() => {
cy.apiRequireLicense();
cy.apiInitSetup({
channelPrefix: {name: '000-archive', displayName: '000 Archive Test'},
}).then(({channel}) => {
}).then(({channel, team}) => {
testChannel = channel;
// # Archive the channel
// # Archive the public channel
cy.apiDeleteChannel(testChannel.id);
// # Create and archive a private channel with a prefix to ensure proper sorting
cy.apiCreateChannel(team.id, '000-private-archive', '000 Private Archive Test', 'P').then(({channel: privateChannel}) => {
testPrivateChannel = privateChannel;
cy.apiDeleteChannel(privateChannel.id);
});
});
});
@ -83,4 +90,26 @@ describe('Archived channels', () => {
expect(channel.delete_at).to.eq(0);
});
});
it('display archive icon for public archived channels in channel list', () => {
// # Go to the channels list view
cy.visit('/admin_console/user_management/channels');
// * Verify the archived public channel is visible
cy.findByText(testChannel.display_name, {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
// * Verify the archive icon is displayed
cy.findByTestId(`${testChannel.name}-archive-icon`).should('be.visible');
});
it('display archive-lock icon for private archived channels in channel list', () => {
// # Go to the channels list view
cy.visit('/admin_console/user_management/channels');
// * Verify the archived private channel is visible
cy.findByText(testPrivateChannel.display_name, {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
// * Verify the archive icon is displayed for private channel
cy.findByTestId(`${testPrivateChannel.name}-archive-icon`).should('be.visible');
});
});

View file

@ -92,18 +92,6 @@ describe('Upload Files - Image', () => {
testImage(properties);
});
it('MM-T2264_6 - PSD', () => {
const properties = {
filePath: 'mm_file_testing/Images/PSD.psd',
fileName: 'PSD.psd',
originalWidth: 400,
originalHeight: 479,
mimeType: 'application/psd',
};
testImage(properties);
});
it('MM-T2264_7 - WEBP', () => {
const properties = {
filePath: 'mm_file_testing/Images/WEBP.webp',

View file

@ -81,6 +81,13 @@ describe('Messaging', () => {
});
function writeLinesToPostTextBox(lines) {
let previousHeight;
// Get the initial previous height from the alias
cy.get('@previousHeight').then((height) => {
previousHeight = height;
});
Cypress._.forEach(lines, (line, i) => {
// # Add the text
cy.uiGetPostTextBox().type(line, {delay: TIMEOUTS.ONE_HUNDRED_MILLIS}).wait(TIMEOUTS.HALF_SEC);
@ -91,11 +98,14 @@ function writeLinesToPostTextBox(lines) {
// * Verify new height
cy.uiGetPostTextBox().invoke('height').then((height) => {
const currentHeight = parseInt(height, 10);
// * Verify previous height should be lower than the current height
cy.get('@previousHeight').should('be.lessThan', parseInt(height, 10));
expect(previousHeight).to.be.lessThan(currentHeight);
// # Store the current height as the previous height for the next loop
cy.wrap(parseInt(height, 10)).as('previousHeight');
previousHeight = currentHeight;
cy.wrap(currentHeight).as('previousHeight');
});
}
});

View file

@ -61,7 +61,7 @@ describe('Multi-user group header', () => {
cy.get('#editChannelHeaderModalLabel').should('be.visible').wait(TIMEOUTS.ONE_SEC);
// # Add the header in the modal
cy.findByPlaceholderText('Edit the Channel Header...').should('be.visible').type(`${header}{enter}`);
cy.findByPlaceholderText('Enter the Channel Header').should('be.visible').type(`${header}{enter}`);
// # Wait for modal to disappear
cy.waitUntil(() => cy.get('#editChannelHeaderModalLabel').should('not.be.visible'));

View file

@ -32,7 +32,7 @@ function signupWithEmail(name, pw) {
cy.get('#input_password-input').type(pw);
// # Click on Create Account button
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
}
describe('Cloud Onboarding', () => {

View file

@ -91,7 +91,7 @@ describe('Onboarding', () => {
cy.get('#name').should('be.visible').type(usernameTwo);
cy.get('#password').should('be.visible').type(password);
// # Attempt to create an account by clicking on the 'Create Account' button
// # Attempt to create an account by clicking on the 'Create account' button
cy.get('#createAccountButton').click();
// * Ensure that since the invite was invalidated, the correct error message should be shown
@ -99,7 +99,7 @@ describe('Onboarding', () => {
});
function inviteNewUser(email) {
cy.findByRole('textbox', {name: 'Add or Invite People'}).
cy.findByRole('textbox', {name: 'Invite People'}).
typeWithForce(email).wait(TIMEOUTS.HALF_SEC).
typeWithForce('{enter}');
cy.findByTestId('inviteButton').click();

View file

@ -71,7 +71,7 @@ describe('Onboarding', () => {
cy.get('#input_email').should('be.focused').and('be.visible').type(email);
cy.get('#input_name').should('be.visible').type(username);
cy.get('#input_password-input').should('be.visible').type(password);
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
cy.findByText('Youre almost done!').should('be.visible');

View file

@ -71,8 +71,8 @@ describe('Onboarding', () => {
cy.get('#input_name').should('be.visible').type(username);
cy.get('#input_password-input').should('be.visible').type(password);
// # Attempt to create an account by clicking on the 'Create Account' button
cy.findByText('Create Account').click();
// # Attempt to create an account by clicking on the 'Create account' button
cy.findByText('Create account').click();
cy.wait(TIMEOUTS.HALF_SEC);

View file

@ -91,7 +91,7 @@ describe('Signin/Authentication', () => {
cy.url().should('contain', '/login?extra=password_change');
// * Should show that the password is updated successfully
cy.get('.AlertBanner.success').should('be.visible').and('have.text', ' Password updated successfully');
cy.get('.AlertBanner.success').should('be.visible').and('have.text', 'Password updated successfully');
// # Type email and new password, then click login button
cy.get('#input_loginId').should('be.visible').type(testUser.username);

View file

@ -54,11 +54,6 @@ describe('Signup Email page', () => {
});
it('should match elements, body', () => {
const {
PRIVACY_POLICY_LINK,
TERMS_OF_SERVICE_LINK,
} = FixedCloudConfig.SupportSettings;
// * Check elements in the body
cy.get('.signup-body').should('be.visible');
cy.get('.header-logo-link').should('be.visible');
@ -78,11 +73,15 @@ describe('Signup Email page', () => {
cy.findByText('Your password must be 5-72 characters long.').should('be.visible');
cy.get('#saveSetting').scrollIntoView().should('be.visible');
cy.get('#saveSetting').should('contain', 'Create Account');
cy.get('#saveSetting').should('contain', 'Create account');
cy.get('.signup-body-card-agreement').should('contain', `By proceeding to create your account and use ${config.TeamSettings.SiteName}, you agree to our Terms of Use and Privacy Policy. If you do not agree, you cannot use ${config.TeamSettings.SiteName}.`);
cy.get(`.signup-body-card-agreement > span > [href="${config.SupportSettings.TermsOfServiceLink || TERMS_OF_SERVICE_LINK}"]`).should('be.visible');
cy.get(`.signup-body-card-agreement > span > [href="${config.SupportSettings.PrivacyPolicyLink || PRIVACY_POLICY_LINK}"]`).should('be.visible');
// * Check newsletter subscription checkbox text and links
cy.findByText('I would like to receive Mattermost security updates via newsletter.').should('be.visible');
cy.findByText(/By subscribing, I consent to receive emails from Mattermost with product updates, promotions, and company news\./).should('be.visible');
cy.findByText(/I have read the/).parent().within(() => {
cy.findByRole('link', {name: 'Privacy Policy'}).should('be.visible').and('have.attr', 'href').and('include', 'mattermost.com/pl/privacy-policy/');
cy.findByRole('link', {name: 'unsubscribe'}).should('be.visible').and('have.attr', 'href').and('include', 'forms.mattermost.com/UnsubscribePage.html');
});
});
it('should match elements, footer', () => {

View file

@ -85,7 +85,7 @@ describe('Team Settings', () => {
cy.get('.InviteAs').findByTestId('inviteMembersLink').click();
}
cy.findByRole('combobox', {name: 'Add or Invite People'}).type(email, {force: true}).wait(TIMEOUTS.HALF_SEC).type('{enter}', {force: true});
cy.findByRole('combobox', {name: 'Invite People'}).type(email, {force: true}).wait(TIMEOUTS.HALF_SEC).type('{enter}', {force: true});
cy.findByTestId('inviteButton').click();
// # Wait for a while to ensure that email notification is sent and logout from sysadmin account
@ -115,7 +115,7 @@ describe('Team Settings', () => {
cy.get('#input_password-input').type(password);
// # Attempt to create an account by clicking on the 'Create Account' button
cy.findByText('Create Account').click();
cy.findByText('Create account').click();
// # Close the onboarding tutorial
cy.uiCloseOnboardingTaskList();

View file

@ -36,7 +36,7 @@ export const inviteUserByEmail = (email) => {
// # Wait half a second to ensure that the modal has been fully loaded
cy.wait(TIMEOUTS.HALF_SEC);
cy.findByRole('textbox', {name: 'Add or Invite People'}).
cy.findByRole('textbox', {name: 'Invite People'}).
typeWithForce(email).
wait(TIMEOUTS.HALF_SEC).
typeWithForce('{enter}');
@ -71,7 +71,7 @@ export const signupAndVerifyTutorial = (username, password, teamDisplayName) =>
cy.get('#name', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').type(username);
cy.get('#password').should('be.visible').type(password);
// # Attempt to create an account by clicking on the 'Create Account' button
// # Attempt to create an account by clicking on the 'Create account' button
cy.get('#createAccountButton').click();
// # Close the onboarding tutorial

View file

@ -49,11 +49,14 @@ describe('Team Settings', () => {
// # Set 'sample.mattermost.com' as the only allowed email domain and save
cy.get('#allowedDomains').click().type(emailDomain).type(' ');
cy.findByText('Save').should('be.visible').click();
// # Close the modal
cy.get('#teamSettingsModalLabel').find('button').should('be.visible').click();
});
// # Close the modal
cy.findByLabelText('Close').click();
// * Wait for modal to be closed
cy.get('#teamSettingsModal').should('not.exist');
// # Open team menu and click 'Invite People'
cy.uiOpenTeamMenu('Invite people');
@ -63,7 +66,7 @@ describe('Team Settings', () => {
// * Assert that the user has successfully been invited to the team
cy.get('.invitation-modal-confirm--sent').should('be.visible').within(() => {
cy.get('.username-or-icon').find('span').eq(0).should('have.text', userDetailsString);
cy.get('.InviteResultRow').find('div').eq(1).should('have.text', inviteSuccessMessage);
cy.get('.InviteResultRow').find('.reason').should('have.text', inviteSuccessMessage);
});
// # Click on the 'Invite More People button'
@ -75,14 +78,14 @@ describe('Team Settings', () => {
// * Assert that the invite failed and the correct error message is shown
cy.get('.invitation-modal-confirm--not-sent').should('be.visible').within(() => {
cy.get('.username-or-icon').find('span').eq(1).should('have.text', invalidEmail);
cy.get('.InviteResultRow').find('div').eq(1).should('have.text', inviteFailedMessage);
cy.get('.InviteResultRow').find('.reason').should('have.text', inviteFailedMessage);
});
});
function inviteNewMemberToTeam(email) {
cy.wait(TIMEOUTS.HALF_SEC);
cy.findByRole('combobox', {name: 'Add or Invite People'}).
cy.findByRole('combobox', {name: 'Invite People'}).
typeWithForce(email).
wait(TIMEOUTS.HALF_SEC).
typeWithForce('{enter}');

View file

@ -87,8 +87,8 @@ describe('Team Settings', () => {
cy.get('#input_name').type(username);
cy.get('#input_password-input').type(password);
// # Attempt to create an account by clicking on the 'Create Account' button
cy.findByText('Create Account').click();
// # Attempt to create an account by clicking on the 'Create account' button
cy.findByText('Create account').click();
// * Assert that the expected error message from creating an account with an email not from the allowed email domain exists and is visible
cy.findByText(errorMessage).should('be.visible');

View file

@ -1,13 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Functions here are expected to work with MySQL and PostgreSQL (known as dialect).
* When updating this file, make sure to test in both dialect.
* You'll find table and columns names are being converted to lowercase. Reason being is that
* in MySQL, first letter is capitalized.
*/
const mapKeys = require('lodash.mapkeys');
function convertKeysToLowercase(obj) {
@ -33,12 +26,12 @@ const dbGetActiveUserSessions = async ({dbConfig, params: {username, userId, lim
try {
let user;
if (username) {
user = await knexClient(toLowerCase(dbConfig, 'Users')).where('username', username).first();
user = await knexClient('users').where('username', username).first();
user = convertKeysToLowercase(user);
}
const now = Date.now();
const sessions = await knexClient(toLowerCase(dbConfig, 'Sessions')).
const sessions = await knexClient('sessions').
where('userid', user ? user.id : userId).
where('expiresat', '>', now).
orderBy('lastactivityat', 'desc').
@ -60,7 +53,7 @@ const dbGetUser = async ({dbConfig, params: {username}}) => {
}
try {
const user = await knexClient(toLowerCase(dbConfig, 'Users')).where('username', username).first();
const user = await knexClient('users').where('username', username).first();
return {user: convertKeysToLowercase(user)};
} catch (error) {
@ -75,7 +68,7 @@ const dbGetUserSession = async ({dbConfig, params: {sessionId}}) => {
}
try {
const session = await knexClient(toLowerCase(dbConfig, 'Sessions')).
const session = await knexClient('sessions').
where('id', '=', sessionId).
first();
@ -92,7 +85,7 @@ const dbUpdateUserSession = async ({dbConfig, params: {sessionId, userId, fields
}
try {
let user = await knexClient(toLowerCase(dbConfig, 'Users')).where('id', userId).first();
let user = await knexClient('users').where('id', userId).first();
if (!user) {
return {errorMessage: `No user found with id: ${userId}.`};
}
@ -102,12 +95,12 @@ const dbUpdateUserSession = async ({dbConfig, params: {sessionId, userId, fields
user = convertKeysToLowercase(user);
await knexClient(toLowerCase(dbConfig, 'Sessions')).
await knexClient('sessions').
where('id', '=', sessionId).
where('userid', '=', user.id).
update(fieldsToUpdate);
const session = await knexClient(toLowerCase(dbConfig, 'Sessions')).
const session = await knexClient('sessions').
where('id', '=', sessionId).
where('userid', '=', user.id).
first();
@ -119,27 +112,11 @@ const dbUpdateUserSession = async ({dbConfig, params: {sessionId, userId, fields
}
};
function toLowerCase(config, name) {
if (config.client === 'mysql') {
return name;
}
return name.toLowerCase();
}
const dbRefreshPostStats = async ({dbConfig}) => {
if (!knexClient) {
knexClient = getKnexClient(dbConfig);
}
// Only run for PostgreSQL
if (dbConfig.client !== 'postgres') {
return {
skipped: true,
message: 'Refresh post stats is only supported for PostgreSQL',
};
}
try {
await knexClient.raw('REFRESH MATERIALIZED VIEW posts_by_team_day;');
await knexClient.raw('REFRESH MATERIALIZED VIEW bot_posts_by_team_day;');

View file

@ -6,7 +6,7 @@ import * as TIMEOUTS from '../../fixtures/timeouts';
Cypress.Commands.add('uiEnableComplianceExport', (exportFormat = 'csv') => {
// * Verify that the page is loaded
cy.findByText('Enable Compliance Export:').should('be.visible');
cy.findByText('Compliance Export time:').should('be.visible');
cy.findByText('Compliance Export Time:').should('be.visible');
cy.findByText('Export Format:').should('be.visible');
// # Enable compliance export

View file

@ -241,7 +241,7 @@ export const adminConsoleNavigation = [
},
{
type: ['team', 'e20', 'cloud_enterprise'],
header: 'Email Authentication',
header: 'Email',
sidebar: 'Email',
url: 'admin_console/authentication/email',
},

View file

@ -150,7 +150,7 @@ test(
Change to the `./` project directory, then run the docker container. (See https://playwright.dev/docs/docker for reference.)
```bash
docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.56.0-noble /bin/bash
docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.57.0-noble /bin/bash
```
#### 2. Inside the docker container

View file

@ -1,6 +1,6 @@
{
"name": "@mattermost/playwright-lib",
"version": "11.0.0",
"version": "11.3.0",
"description": "A comprehensive end-to-end testing library for Mattermost web, desktop and plugin applications using Playwright",
"repository": {
"type": "git",
@ -42,27 +42,26 @@
"access": "public"
},
"dependencies": {
"@axe-core/playwright": "4.10.2",
"@axe-core/playwright": "4.11.0",
"@mattermost/client": "file:../../../webapp/platform/client",
"@mattermost/types": "file:../../../webapp/platform/types",
"@percy/cli": "1.31.3",
"@percy/playwright": "1.0.9",
"async-wait-until": "2.0.30",
"@percy/cli": "1.31.5",
"@percy/playwright": "1.0.10",
"async-wait-until": "2.0.31",
"axe-core": "4.11.0",
"deepmerge": "4.3.1",
"dotenv": "17.2.3",
"mime-types": "3.0.1",
"mime-types": "3.0.2",
"uuid": "13.0.0"
},
"devDependencies": {
"@rollup/plugin-typescript": "12.1.4",
"@rollup/plugin-typescript": "12.3.0",
"@types/mime-types": "3.0.1",
"@types/node": "24.7.2",
"@types/react": "19.2.2",
"rollup": "4.52.4",
"@types/node": "25.0.3",
"rollup": "4.53.5",
"rollup-plugin-copy": "3.5.0"
},
"peerDependencies": {
"@playwright/test": "1.56.0"
"@playwright/test": "1.57.0"
}
}

View file

@ -6,7 +6,7 @@ import {PluginManifest} from '@mattermost/types/plugins';
import {PreferenceType} from '@mattermost/types/preferences';
import {UserProfile} from '@mattermost/types/users';
import {createRandomTeam, getAdminClient, getDefaultAdminUser, makeClient} from './server';
import {createNewTeam, getAdminClient, getDefaultAdminUser, makeClient} from './server';
import {testConfig} from './test_config';
import {defaultTeam} from './util';
@ -45,7 +45,7 @@ async function sysadminSetup(client: Client4, user: UserProfile | null) {
const myTeams = await client.getMyTeams();
const myDefaultTeam = myTeams && myTeams.length > 0 && myTeams.find((team) => team.name === defaultTeam.name);
if (!myDefaultTeam) {
await client.createTeam(await createRandomTeam(defaultTeam.name, defaultTeam.displayName, 'O', false));
await createNewTeam(client, {name: defaultTeam.name, displayName: defaultTeam.displayName});
} else if (myDefaultTeam && testConfig.resetBeforeTest) {
await Promise.all(
myTeams.filter((team) => team.name !== defaultTeam.name).map((team) => client.deleteTeam(team.id)),
@ -163,6 +163,8 @@ async function savePreferences(client: Client4, userId: UserProfile['id']) {
const preferences: PreferenceType[] = [
{user_id: userId, category: 'tutorial_step', name: userId, value: '999'},
{user_id: userId, category: 'crt_thread_pane_step', name: userId, value: '999'},
{user_id: userId, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
{user_id: userId, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
];
await client.savePreferences(userId, preferences);

View file

@ -86,7 +86,7 @@ const onPremServerConfig = (): Partial<TestAdminConfig> => {
};
// Should be based only from the generated default config from ./server via "make config-reset"
// Based on v11.0 server
// Based on v11.3 server
const defaultServerConfig: AdminConfig = {
ServiceSettings: {
SiteURL: '',
@ -110,6 +110,7 @@ const defaultServerConfig: AdminConfig = {
MaximumLoginAttempts: 10,
GoroutineHealthThreshold: -1,
EnableOAuthServiceProvider: true,
EnableDynamicClientRegistration: false,
EnableIncomingWebhooks: true,
EnableOutgoingWebhooks: true,
EnableOutgoingOAuthConnections: false,
@ -187,6 +188,10 @@ const defaultServerConfig: AdminConfig = {
PersistentNotificationIntervalMinutes: 5,
PersistentNotificationMaxCount: 6,
PersistentNotificationMaxRecipients: 5,
EnableBurnOnRead: false,
BurnOnReadDurationSeconds: 600,
BurnOnReadMaximumTimeToLiveSeconds: 604800,
BurnOnReadSchedulerFrequencySeconds: 600,
EnableAPIChannelDeletion: false,
EnableLocalMode: false,
LocalModeSocketLocation: '/var/tmp/mattermost_local.socket',
@ -599,6 +604,7 @@ const defaultServerConfig: AdminConfig = {
ClientSideUserIds: [],
},
ExperimentalSettings: {
ClientSideCertEnable: false,
LinkMetadataTimeoutMilliseconds: 5000,
RestrictSystemAdmin: false,
EnableSharedChannels: false,
@ -765,12 +771,13 @@ const defaultServerConfig: AdminConfig = {
ExperimentalAuditSettingsSystemConsoleUI: true,
CustomProfileAttributes: true,
AttributeBasedAccessControl: true,
ContentFlagging: false,
ContentFlagging: true,
InteractiveDialogAppsForm: true,
EnableMattermostEntry: true,
ChannelAdminManageABACRules: false,
MobileSSOCodeExchange: true,
AutoTranslation: false,
BurnOnRead: false,
EnableAIPluginBridge: false,
},
ImportSettings: {
Directory: './import',
@ -836,14 +843,14 @@ const defaultServerConfig: AdminConfig = {
AutoTranslationSettings: {
Enable: false,
Provider: '',
LibreTranslate: {
URL: '',
APIKey: '',
},
TimeoutMs: {
TimeoutsMs: {
NewPost: 800,
Fetch: 2000,
Notification: 300,
},
LibreTranslate: {
URL: '',
APIKey: '',
},
},
};

View file

@ -6,5 +6,5 @@ export {createRandomChannel} from './channel';
export {getOnPremServerConfig, mergeWithOnPremServerConfig} from './default_config';
export {initSetup, getAdminClient} from './init';
export {createRandomPost} from './post';
export {createRandomTeam} from './team';
export {createNewTeam, createRandomTeam} from './team';
export {createNewUserProfile, createRandomUser, getDefaultAdminUser, isOutsideRemoteUserHour} from './user';

View file

@ -2,21 +2,27 @@
// See LICENSE.txt for license information.
import {expect} from '@playwright/test';
import {PreferenceType} from '@mattermost/types/preferences';
import {TeamType} from '@mattermost/types/teams';
import {makeClient} from './client';
import {getOnPremServerConfig} from './default_config';
import {createRandomTeam} from './team';
import {createRandomUser} from './user';
import {createNewTeam} from './team';
import {createNewUserProfile} from './user';
import {getFileFromCommonAsset} from '@/file';
import {testConfig} from '@/test_config';
type InitSetupOptions = {
userOptions?: Partial<Parameters<typeof createNewUserProfile>[1]>;
teamsOptions?: Partial<Parameters<typeof createNewTeam>[1]>;
withDefaultProfileImage?: boolean;
};
export async function initSetup({
userPrefix = 'user',
teamPrefix = {name: 'team', displayName: 'Team'},
userOptions = {prefix: 'user', disableTutorial: true, disableOnboarding: true},
teamsOptions = {name: 'team', displayName: 'Team', type: 'O' as TeamType, unique: true},
withDefaultProfileImage = true,
} = {}) {
}: Partial<InitSetupOptions> = {}) {
try {
// Login the admin user via API
const {adminClient, adminUser} = await getAdminClient();
@ -33,12 +39,10 @@ export async function initSetup({
const adminConfig = await adminClient.updateConfig(getOnPremServerConfig() as any);
// Create new team
const team = await adminClient.createTeam(await createRandomTeam(teamPrefix.name, teamPrefix.displayName));
const team = await createNewTeam(adminClient, teamsOptions);
// Create new user and add to newly created team
const randomUser = await createRandomUser(userPrefix);
const user = await adminClient.createUser(randomUser, '', '');
user.password = randomUser.password;
const user = await createNewUserProfile(adminClient, userOptions);
await adminClient.addToTeam(team.id, user.id);
// Log in new user via API
@ -49,13 +53,6 @@ export async function initSetup({
await userClient.uploadProfileImage(user.id, file);
}
// Update user preference
const preferences: PreferenceType[] = [
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
{user_id: user.id, category: 'crt_thread_pane_step', name: user.id, value: '999'},
];
await userClient.savePreferences(user.id, preferences);
return {
adminClient,
adminUser,

View file

@ -1,10 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@mattermost/client';
import {Team, TeamType} from '@mattermost/types/teams';
import {getRandomId} from '@/util';
export async function createNewTeam(
client: Client4,
options: {name?: string; displayName?: string; type?: TeamType; unique?: boolean} = {
name: 'team',
displayName: 'Team',
type: 'O' as TeamType,
unique: true,
},
) {
const randomTeam = await createRandomTeam(options.name, options.displayName, options.type, options.unique);
const newTeam = await client.createTeam(randomTeam);
return newTeam;
}
export async function createRandomTeam(
name = 'team',
displayName = 'Team',

View file

@ -9,12 +9,34 @@ import {getRandomId} from '@/util';
import {testConfig} from '@/test_config';
import {REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY, REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY} from '@/constant';
export async function createNewUserProfile(client: Client4, prefix = 'user') {
const randomUser = await createRandomUser(prefix);
export async function createNewUserProfile(
client: Client4,
options: {prefix?: string; disableTutorial?: boolean; disableOnboarding?: boolean} = {
prefix: 'user',
disableTutorial: true,
disableOnboarding: true,
},
) {
const randomUser = await createRandomUser(options.prefix);
const newUser = await client.createUser(randomUser, '', '');
// Set password to the created user profile so it can be used to login later
newUser.password = randomUser.password;
if (options.disableTutorial) {
await client.savePreferences(newUser.id, [
{user_id: newUser.id, category: 'tutorial_step', name: newUser.id, value: '999'},
{user_id: newUser.id, category: 'crt_thread_pane_step', name: newUser.id, value: '999'},
]);
}
if (options.disableOnboarding) {
await client.savePreferences(newUser.id, [
{user_id: newUser.id, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
{user_id: newUser.id, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
]);
}
return newUser;
}

View file

@ -19,6 +19,7 @@ import {
import {getBlobFromAsset, getFileFromAsset} from './file';
import {
createNewUserProfile,
createNewTeam,
createRandomChannel,
createRandomPost,
createRandomTeam,
@ -102,6 +103,7 @@ export class PlaywrightExtended {
// ./server
readonly createNewUserProfile;
readonly createNewTeam;
readonly isOutsideRemoteUserHour;
readonly makeClient;
@ -167,6 +169,7 @@ export class PlaywrightExtended {
// ./server
this.createNewUserProfile = createNewUserProfile;
this.createNewTeam = createNewTeam;
this.makeClient = makeClient;
// ./visual

View file

@ -85,4 +85,12 @@ export default class ChannelsPost {
async toContainText(text: string) {
await expect(this.container).toContainText(text);
}
/**
* `toNotContainText` verifies if the post does not contain the specified text.
* @param text Text to be verified not in the post
*/
async toNotContainText(text: string) {
await expect(this.container).not.toContainText(text);
}
}

View file

@ -86,6 +86,14 @@ export default class ChannelsPage {
await this.centerView.toBeVisible();
}
/**
* `toNotContainText` verifies if the page does not contain the specified text.
* @param text Text to be verified not in the page
*/
async toNotContainText(text: string) {
await expect(this.page.locator('body')).not.toContainText(text);
}
async getLastPost() {
return this.centerView.getLastPost();
}

View file

@ -41,7 +41,7 @@ export default class SignupPage {
this.usernameInput = page.locator('#input_name');
this.passwordInput = page.locator('#input_password-input');
this.passwordToggleButton = page.locator('#password_toggle');
this.createAccountButton = page.locator('button:has-text("Create Account")');
this.createAccountButton = page.locator('button:has-text("Create account")');
this.emailError = page.locator('text=Please enter a valid email address');
this.usernameError = page.locator(
'text=Usernames have to begin with a lowercase letter and be 3-22 characters long. You can use lowercase letters, numbers, periods, dashes, and underscores.',

File diff suppressed because it is too large Load diff

View file

@ -32,19 +32,19 @@
"@mattermost/types": "file:../../webapp/platform/types"
},
"devDependencies": {
"@playwright/test": "1.56.0",
"@playwright/test": "1.57.0",
"@types/luxon": "3.7.1",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/eslint-plugin": "8.50.0",
"cross-env": "10.1.0",
"dayjs": "1.11.18",
"eslint": "9.37.0",
"dayjs": "1.11.19",
"eslint": "9.39.2",
"eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-header": "3.1.1",
"eslint-plugin-import": "2.32.0",
"glob": "11.0.3",
"glob": "13.0.0",
"luxon": "3.7.2",
"prettier": "3.6.2",
"prettier": "3.7.4",
"typescript": "5.9.3",
"zod": "4.1.12"
"zod": "4.2.1"
}
}

View file

@ -76,7 +76,7 @@ test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
// Initialize with admin client
({team, user, adminClient, userClient} = await pw.initSetup({userPrefix: 'cpa-test-'}));
({team, user, adminClient, userClient} = await pw.initSetup({userOptions: {prefix: 'cpa-test-'}}));
const channel = pw.random.channel({
teamId: team.id,
name: `test-channel`,
@ -85,7 +85,7 @@ test.beforeEach(async ({pw}) => {
testChannel = await adminClient.createChannel(channel);
// Create another user to test profile popover
otherUser = await pw.createNewUserProfile(adminClient, 'cpa-other-');
otherUser = await pw.createNewUserProfile(adminClient, {prefix: 'cpa-other-'});
await adminClient.addToTeam(team.id, otherUser.id);
await adminClient.addToChannel(otherUser.id, testChannel.id);

View file

@ -85,7 +85,7 @@ test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
// Initialize with admin client
({team, user, adminClient, userClient} = await pw.initSetup({userPrefix: 'cpa-test-'}));
({team, user, adminClient, userClient} = await pw.initSetup({userOptions: {prefix: 'cpa-test-'}}));
const channel = pw.random.channel({
teamId: team.id,
name: `test-channel`,
@ -94,7 +94,7 @@ test.beforeEach(async ({pw}) => {
testChannel = await adminClient.createChannel(channel);
// Create another user to test profile popover
otherUser = await pw.createNewUserProfile(adminClient, 'cpa-other-');
otherUser = await pw.createNewUserProfile(adminClient, {prefix: 'cpa-other-'});
await adminClient.addToTeam(team.id, otherUser.id);
await adminClient.addToChannel(otherUser.id, testChannel.id);

View file

@ -3,7 +3,7 @@
import {Page} from '@playwright/test';
import {expect, test} from '@mattermost/playwright-lib';
import {test} from '@mattermost/playwright-lib';
test('MM-T5654_1 should be able to add attachments while editing a post', async ({pw}) => {
const originalMessage = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit';
@ -107,8 +107,8 @@ test('MM-T5654_2 should be able to add attachments while editing a threaded post
updatedReplyPost = await channelsPage.sidebarRight.getLastPost();
await updatedReplyPost.toBeVisible();
await updatedReplyPost.toContainText('Edited reply message with files');
expect(updatedReplyPost).not.toContain('sample_text_file.txt');
expect(updatedReplyPost).not.toContain('mattermost.png');
await updatedReplyPost.toNotContainText('sample_text_file.txt');
await updatedReplyPost.toNotContainText('mattermost.png');
});
test('MM-T5654_3 should be able to edit post message originally containing files', async ({pw}) => {
@ -217,7 +217,7 @@ test('MM-5654_5 should be able to remove attachments while editing a post', asyn
await updatedPost.toContainText(originalMessage);
await updatedPost.toContainText('mattermost.png');
await updatedPost.toContainText('archive.zip');
expect(updatedPost).not.toContain('archive.zip');
await updatedPost.toNotContainText('sample_text_file.txt');
});
test('MM-T5655_1 removing message content and files should delete the post', async ({pw}) => {
@ -250,8 +250,8 @@ test('MM-T5655_1 removing message content and files should delete the post', asy
await channelsPage.centerView.postEdit.deleteConfirmationDialog.confirmDeletion();
await channelsPage.centerView.postEdit.deleteConfirmationDialog.notToBeVisible();
expect(channelsPage).not.toContain(originalMessage);
expect(channelsPage).not.toContain('sample_text_file.txt');
await channelsPage.toNotContainText(originalMessage);
await channelsPage.toNotContainText('sample_text_file.txt');
});
test('MM-T5655_2 should be able to remove all files when editing a post', async ({pw}) => {
@ -287,9 +287,9 @@ test('MM-T5655_2 should be able to remove all files when editing a post', async
const updatedPost = await channelsPage.getLastPost();
await updatedPost.toBeVisible();
await updatedPost.toContainText(originalMessage);
expect(updatedPost).not.toContain('archive.zip');
expect(updatedPost).not.toContain('mattermost.png');
expect(updatedPost).not.toContain('sample_text_file.txt');
await updatedPost.toNotContainText('archive.zip');
await updatedPost.toNotContainText('mattermost.png');
await updatedPost.toNotContainText('sample_text_file.txt');
});
test('MM-T5656_1 should be able to restore previously edited post version that contains attachments', async ({pw}) => {
@ -322,7 +322,7 @@ test('MM-T5656_1 should be able to restore previously edited post version that c
const updatedPost = await channelsPage.getLastPost();
await updatedPost.toBeVisible();
await updatedPost.toContainText(newMessage);
expect(updatedPost).not.toContain('sample_text_file.txt');
await updatedPost.toNotContainText('sample_text_file.txt');
const postID = await channelsPage.centerView.getLastPostID();
await channelsPage.centerView.clickOnLastEditedPost(postID);
@ -338,7 +338,7 @@ test('MM-T5656_1 should be able to restore previously edited post version that c
const restoredPost = await channelsPage.getLastPost();
await restoredPost.toBeVisible();
expect(restoredPost.toContainText('sample_text_file.txt'));
await restoredPost.toContainText('sample_text_file.txt');
});
async function moveMouseToCenter(page: Page) {

View file

@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify that pressing Shift+Up in the textbox in center channel opens the thread for the last post in RHS
* and correctly focuses the reply textbox, even when there are large messages with attachments from other users.
*/
test(
'Keyboard shortcuts Shift+Up on center textbox opens the last post in the RHS and correctly focuses the reply textbox',
{tag: '@keyboard_shortcuts'},
async ({pw}, testInfo) => {
const ROOT_MESSAGE = 'The root message for testing Shift+Up keyboard shortcut';
const NUMBER_OF_REPLIES = 10;
const ATTACHMENT_FILES = ['mattermost.png', 'sample_text_file.txt', 'archive.zip'];
test.skip(testInfo.project.name === 'ipad', 'Skipping test on iPad');
// # Initialize setup with admin and user
const {adminUser, user, team} = await pw.initSetup();
// # Log in as admin in one browser session
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, 'town-square');
await adminChannelsPage.toBeVisible();
// # Have admin post the root message for the thread
await adminChannelsPage.centerView.postCreate.postMessage(ROOT_MESSAGE);
// # Have admin open the thread and post multiple replies with attachments
const rootPost = await adminChannelsPage.getLastPost();
await rootPost.hover();
await rootPost.postMenu.toBeVisible();
await rootPost.postMenu.reply();
// * Verify RHS is visible for admin
await adminChannelsPage.sidebarRight.toBeVisible();
// # Firstly let admin create a series of random replies to the root message
for (let i = 1; i <= NUMBER_OF_REPLIES; i++) {
await adminChannelsPage.sidebarRight.postCreate.postMessage(`Random replies number ${i}`.repeat(40));
}
// # Secondly let admin create a series of random replies to the root message with attachments
for (const file of ATTACHMENT_FILES) {
await adminChannelsPage.sidebarRight.postCreate.postMessage(
`Random replies number with attachment: ${file}`,
[file],
);
}
// # Admin closes the RHS
await adminChannelsPage.sidebarRight.close();
// # Log in as regular user in a separate browser session
const {channelsPage: userChannelsPage, page: userPage} = await pw.testBrowser.login(user);
await userChannelsPage.goto(team.name, 'town-square');
await userChannelsPage.toBeVisible();
// # Bring focus to the post textbox in center channel
await userChannelsPage.centerView.postCreate.input.focus();
// * Verify the post textbox in center channel is focused
await expect(userChannelsPage.centerView.postCreate.input).toBeFocused();
// # Press Shift+Up to open the latest thread in the channel in the RHS
await userPage.keyboard.press('Shift+ArrowUp');
// * Verify RHS is visible
await userChannelsPage.sidebarRight.toBeVisible();
// * Verify the correct thread (admin's root message) is shown in RHS
await userChannelsPage.sidebarRight.toContainText(ROOT_MESSAGE);
// * Verify RHS reply textbox is focused only
await expect(userChannelsPage.sidebarRight.postCreate.input).toBeFocused();
// # Type a message to verify the textbox can receive input immediately
await userPage.keyboard.type('Reply typed after Shift+Up');
// * Verify the message was typed into the RHS textbox
const inputValue = await userChannelsPage.sidebarRight.postCreate.getInputValue();
expect(inputValue).toBe('Reply typed after Shift+Up');
// # Clear the input and close RHS
await userChannelsPage.sidebarRight.postCreate.input.clear();
await userChannelsPage.sidebarRight.close();
},
);

View file

@ -14,13 +14,7 @@ test('displays multiple mentions correctly in Recent Mentions panel', {tag: '@me
const MENTION_COUNT = 20;
// # Initialize the first user who will create the mentions
const {
team,
user: mentioningUser,
userClient,
} = await pw.initSetup({
userPrefix: 'mentioner',
});
const {team, user: mentioningUser, userClient} = await pw.initSetup({userOptions: {prefix: 'mentioner'}});
// # Create a second user to be mentioned
const {adminClient} = await pw.getAdminClient();

View file

@ -173,5 +173,12 @@ test('MM-63378 System Manager without team access permissions cannot view team d
await expect(teamStatsHeading).toBeVisible();
// Verify the user has no API access to the otherTeam.
await expect(systemManagerClient.getTeam(otherTeam.id)).rejects.toThrow();
let apiError: Error | null = null;
try {
await systemManagerClient.getTeam(otherTeam.id);
} catch (error) {
apiError = error as Error;
}
expect(apiError).not.toBeNull();
expect(apiError?.message).toContain('You do not have the appropriate permissions');
});

View file

@ -99,7 +99,7 @@ test.describe('System Console - Admin User Profile Editing', () => {
({team, adminUser, adminClient} = await pw.initSetup());
// Create test user to edit
testUser = await pw.createNewUserProfile(adminClient, 'admin-edit-target-');
testUser = await pw.createNewUserProfile(adminClient, {prefix: 'admin-edit-target-'});
await adminClient.addToTeam(team.id, testUser.id);
// Set up custom user attribute fields

View file

@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify archived channel icons display correctly for public and private channels in various UI contexts
*/
test(
'displays archive icons for public and private channels in sidebar',
{tag: ['@visual', '@archived_channels', '@snapshots']},
async ({pw, browserName, viewport}, testInfo) => {
// # Initialize setup and create test channels
const {team, user, adminClient} = await pw.initSetup();
// # Create public and private channels
const publicChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'public-to-archive',
displayName: 'Public Archive Test',
type: 'O',
}),
);
const privateChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'private-to-archive',
displayName: 'Private Archive Test',
type: 'P',
}),
);
// # Archive both channels
await adminClient.deleteChannel(publicChannel.id);
await adminClient.deleteChannel(privateChannel.id);
// # Log in user
const {page, channelsPage} = await pw.testBrowser.login(user);
// # Visit town square to ensure we're in a stable state
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
// # Open browse channels modal to show archived channels
await page.keyboard.press('Control+K');
await page.waitForTimeout(500);
// # Type to search for archived channels
await page.keyboard.type('archive');
await page.waitForTimeout(500);
// # Hide dynamic content
await pw.hideDynamicChannelsContent(page);
// * Verify channel switcher shows both archived channels with correct icons
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
},
);
/**
* @objective Verify archived channel icons display correctly in admin console channel list
*/
test(
'displays archive icons in admin console channel list',
{tag: ['@visual', '@archived_channels', '@admin_console', '@snapshots']},
async ({pw, browserName, viewport}, testInfo) => {
// # Initialize setup with admin user
const {team, adminUser, adminClient} = await pw.initSetup();
// # Create public and private channels
const publicChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'admin-public-archive',
displayName: 'Admin Public Archive',
type: 'O',
}),
);
const privateChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'admin-private-archive',
displayName: 'Admin Private Archive',
type: 'P',
}),
);
// # Archive both channels
await adminClient.deleteChannel(publicChannel.id);
await adminClient.deleteChannel(privateChannel.id);
// # Log in as admin
const {page} = await pw.testBrowser.login(adminUser);
// # Navigate to admin console channels list
await page.goto('/admin_console/user_management/channels');
await page.waitForTimeout(1000);
// # Wait for channel list to load
await expect(page.locator('.DataGrid')).toBeVisible({timeout: 10000});
// # Search for our test channels to bring them into view
await page.fill('[data-testid="searchInput"]', 'Admin');
await page.waitForTimeout(500);
// * Verify both archived channels are visible with correct icons
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
},
);
/**
* @objective Verify archived private channel icon displays in channel header when viewing archived channel
*/
test(
'displays archive icon in channel header for archived private channel',
{tag: ['@visual', '@archived_channels', '@channel_header', '@snapshots']},
async ({pw, browserName, viewport}, testInfo) => {
// # Initialize setup
const {team, adminUser, adminClient} = await pw.initSetup();
// # Create a private channel
const privateChannel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'private-header-test',
displayName: 'Private Header Test',
type: 'P',
}),
);
// # Archive the channel
await adminClient.deleteChannel(privateChannel.id);
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
// # Visit the archived channel directly
await channelsPage.goto(team.name, privateChannel.name);
// # Wait for channel header to load (archived channels don't have post-create)
await expect(page.locator('.channel-header')).toBeVisible();
// # Verify archived channel message is visible
await expect(page.locator('#channelArchivedMessage')).toBeVisible();
// # Hide dynamic content
await pw.hideDynamicChannelsContent(page);
// # Focus on channel header area for snapshot
const headerElement = page.locator('.channel-header');
await expect(headerElement).toBeVisible();
// * Verify channel header shows archive-lock icon for private archived channel
const testArgs = {page, browserName, viewport};
await pw.matchSnapshot(testInfo, testArgs);
},
);

16
enable-claude-docs.sh Executable file
View file

@ -0,0 +1,16 @@
#!/bin/bash
# This script enables the optional Claude documentation by copying
# CLAUDE.OPTIONAL.md files to CLAUDE.md.
# CLAUDE.md files are gitignored, so they act as local-only documentation.
echo "Enabling Claude documentation..."
find . -name "CLAUDE.OPTIONAL.md" -not -path "*/node_modules/*" | while read -r file; do
target_file="${file%.OPTIONAL.md}.md"
echo "Copying $file to $target_file"
cp "$file" "$target_file"
done
echo "Done! CLAUDE.md files are now active (and ignored by git). *NOTE: Re-running this script will overwrite any changes you'd made to the CLAUDE.md files. If you have an improvement, please change the relevant CLAUDE.OPTIONAL.md file instead, and submit a PR."

View file

@ -1 +1 @@
1.24.6
1.24.11

View file

@ -41,9 +41,6 @@ linters:
- linters:
- misspell
path: platform/shared/markdown/html_entities.go
- linters:
- unqueryvet
path: channels/store/sqlstore/post_store.go
- linters:
- staticcheck
text: SA1019

View file

@ -155,12 +155,12 @@ PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:)
PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.0
PLUGIN_PACKAGES += mattermost-plugin-github-v2.5.0
PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.11.0
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.5.0
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.5.1
PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.6.1
PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0
PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.11.0
PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.1
PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.2
PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1
PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.5.0
PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.3.0
@ -789,7 +789,18 @@ endif
vet: ## Run mattermost go vet specific checks
$(GO) install github.com/mattermost/mattermost-govet/v2@7d8db289e508999dfcac47b97c9490a0fec12d66
$(GO) vet -vettool=$(GOBIN)/mattermost-govet -structuredLogging -inconsistentReceiverName -emptyStrCmp -tFatal -configtelemetry -errorAssertions -requestCtxNaming -license -inconsistentReceiverName.ignore=session_serial_gen.go,team_member_serial_gen.go,user_serial_gen.go,utils_serial_gen.go ./...
$(GO) vet -vettool=$(GOBIN)/mattermost-govet \
-structuredLogging \
-inconsistentReceiverName \
-emptyStrCmp \
-tFatal \
-configtelemetry \
-errorAssertions \
-requestCtxNaming \
-license \
-inconsistentReceiverName.ignore=session_serial_gen.go,team_member_serial_gen.go,user_serial_gen.go,utils_serial_gen.go \
-noSelectStar \
./...
ifeq ($(BUILD_ENTERPRISE_READY),true)
ifneq ($(MM_NO_ENTERPRISE_LINT),true)
$(GO) vet -vettool=$(GOBIN)/mattermost-govet -structuredLogging -inconsistentReceiverName -emptyStrCmp -tFatal -configtelemetry -errorAssertions -requestCtxNaming -enterpriseLicense $(BUILD_ENTERPRISE_DIR)/...

View file

@ -1,4 +1,4 @@
FROM golang:1.24.6-bullseye@sha256:cf78ce8205287fdb2ca403aac77d68965c75734749e560c577c00e20ecb11954
FROM mattermost/golang-bullseye:1.24.11@sha256:648e6d4bd76751787cf8eb2674942f931a01043872ce15ac9501382dabcefbe8
ARG NODE_VERSION=20.11.1
RUN apt-get update && apt-get install -y make git apt-transport-https ca-certificates curl software-properties-common build-essential zip xmlsec1 jq pgloader gnupg

View file

@ -1,4 +1,4 @@
FROM cgr.dev/mattermost.com/go-msft-fips:1.24.6-dev@sha256:53d076b1cfa53f8189c4723d813d711d92107c2e8b140805c71e39f4a06dc9cc
FROM cgr.dev/mattermost.com/go-msft-fips:1.24.11-dev@sha256:181a7db41bbff8cf0e522bd5f951a44f2a39a5f58ca930930dfbecdc6b690272
ARG NODE_VERSION=20.11.1
RUN apk add curl ca-certificates mailcap unrtf wv poppler-utils tzdata gpg xmlsec

View file

@ -1,5 +1,5 @@
# First stage - FIPS dev image with dependencies for building
FROM cgr.dev/mattermost.com/glibc-openssl-fips:15-dev@sha256:9223f9245fb026a3c255ce9b7028a069fe11432aa7710713a331eaa36f44851c AS builder
FROM cgr.dev/mattermost.com/glibc-openssl-fips:15-dev@sha256:ab5285209fff77fbe56e58aeed6d7f557cf74c6f90d1d8ee26053003f039b419 AS builder
# Setting bash as our shell, and enabling pipefail option
SHELL ["/bin/bash", "-o", "pipefail", "-c"]

View file

@ -64,7 +64,7 @@ func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Reques
return
}
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, policy.ID, model.PermissionManageChannelAccessRules)
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, policy.ID, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
@ -197,7 +197,7 @@ func checkExpression(c *Context, w http.ResponseWriter, r *http.Request) {
}
// SECURE: Check specific channel permission
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
@ -245,7 +245,7 @@ func testExpression(c *Context, w http.ResponseWriter, r *http.Request) {
}
// SECURE: Check specific channel permission
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
@ -321,7 +321,7 @@ func validateExpressionAgainstRequester(c *Context, w http.ResponseWriter, r *ht
}
// SECURE: Check specific channel permission
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
@ -450,7 +450,7 @@ func setActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) {
hasManageSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem)
if !hasManageSystemPermission {
for _, entry := range list.Entries {
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, entry.ID, model.PermissionManageChannelAccessRules)
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, entry.ID, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
@ -661,7 +661,7 @@ func getFieldsAutocomplete(c *Context, w http.ResponseWriter, r *http.Request) {
}
// SECURE: Check specific channel permission
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return
@ -735,7 +735,7 @@ func convertToVisualAST(c *Context, w http.ResponseWriter, r *http.Request) {
}
// SECURE: Check specific channel permission
hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules)
if !hasChannelPermission {
c.SetPermissionError(model.PermissionManageChannelAccessRules)
return

View file

@ -101,6 +101,8 @@ type Routes struct {
Jobs *mux.Router // 'api/v4/jobs'
Recaps *mux.Router // 'api/v4/recaps'
Preferences *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/preferences'
License *mux.Router // 'api/v4/license'
@ -256,6 +258,7 @@ func Init(srv *app.Server) (*API, error) {
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.Elasticsearch = api.BaseRoutes.APIRoot.PathPrefix("/elasticsearch").Subrouter()
api.BaseRoutes.DataRetention = api.BaseRoutes.APIRoot.PathPrefix("/data_retention").Subrouter()
@ -337,6 +340,7 @@ func Init(srv *app.Server) (*API, error) {
api.InitDataRetention()
api.InitBrand()
api.InitJob()
api.InitRecap()
api.InitCommand()
api.InitStatus()
api.InitWebSocket()

View file

@ -166,13 +166,13 @@ func updateChannel(c *Context, w http.ResponseWriter, r *http.Request) {
switch oldChannel.Type {
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelProperties) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelProperties); !ok {
c.SetPermissionError(model.PermissionManagePublicChannelProperties)
return
}
case model.ChannelTypePrivate:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelProperties) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelProperties); !ok {
c.SetPermissionError(model.PermissionManagePrivateChannelProperties)
return
}
@ -277,14 +277,18 @@ func updateChannelPrivacy(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec.AddEventPriorState(channel)
if model.ChannelType(privacy) == model.ChannelTypeOpen && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionConvertPrivateChannelToPublic) {
c.SetPermissionError(model.PermissionConvertPrivateChannelToPublic)
return
if model.ChannelType(privacy) == model.ChannelTypeOpen {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionConvertPrivateChannelToPublic); !ok {
c.SetPermissionError(model.PermissionConvertPrivateChannelToPublic)
return
}
}
if model.ChannelType(privacy) == model.ChannelTypePrivate && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionConvertPublicChannelToPrivate) {
c.SetPermissionError(model.PermissionConvertPublicChannelToPrivate)
return
if model.ChannelType(privacy) == model.ChannelTypePrivate {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionConvertPublicChannelToPrivate); !ok {
c.SetPermissionError(model.PermissionConvertPublicChannelToPrivate)
return
}
}
if channel.Name == model.DefaultChannelName && model.ChannelType(privacy) == model.ChannelTypePrivate {
@ -342,13 +346,13 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
switch oldChannel.Type {
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelProperties) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelProperties); !ok {
c.SetPermissionError(model.PermissionManagePublicChannelProperties)
return
}
case model.ChannelTypePrivate:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelProperties) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelProperties); !ok {
c.SetPermissionError(model.PermissionManagePrivateChannelProperties)
return
}
@ -664,15 +668,15 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) {
if !isContentReviewer {
if channel.Type == model.ChannelTypeOpen {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionReadPublicChannel) && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
c.SetPermissionError(model.PermissionReadPublicChannel)
return
}
} else {
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
c.SetPermissionError(model.PermissionReadChannel)
return
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionReadPublicChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
}
} else if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
}
@ -698,7 +702,7 @@ func getChannelUnread(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
@ -723,7 +727,7 @@ func getChannelStats(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
@ -813,7 +817,8 @@ func getPinnedPosts(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = err
return
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
var hasPermission, isMember bool
if hasPermission, isMember = c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !hasPermission {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -829,7 +834,7 @@ func getPinnedPosts(c *Context, w http.ResponseWriter, r *http.Request) {
}
clientPostList := c.App.PreparePostListForClient(c.AppContext, posts)
clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
@ -839,6 +844,14 @@ func getPinnedPosts(c *Context, w http.ResponseWriter, r *http.Request) {
if err := clientPostList.EncodeJSON(w); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelMemberRoles, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
if !isMember || !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
}
func getAllChannels(c *Context, w http.ResponseWriter, r *http.Request) {
@ -1035,7 +1048,7 @@ func getPublicChannelsByIdsForTeam(c *Context, w http.ResponseWriter, r *http.Re
if session := c.AppContext.Session(); session.IsGuest() {
for _, channel := range channels {
if !c.App.SessionHasPermissionToChannel(c.AppContext, *session, channel.Id, model.PermissionReadChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *session, channel.Id, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
@ -1387,14 +1400,18 @@ func deleteChannel(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if channel.Type == model.ChannelTypeOpen && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionDeletePublicChannel) {
c.SetPermissionError(model.PermissionDeletePublicChannel)
return
if channel.Type == model.ChannelTypeOpen {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionDeletePublicChannel); !ok {
c.SetPermissionError(model.PermissionDeletePublicChannel)
return
}
}
if channel.Type == model.ChannelTypePrivate && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionDeletePrivateChannel) {
c.SetPermissionError(model.PermissionDeletePrivateChannel)
return
if channel.Type == model.ChannelTypePrivate {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionDeletePrivateChannel); !ok {
c.SetPermissionError(model.PermissionDeletePrivateChannel)
return
}
}
if c.Params.Permanent {
@ -1437,16 +1454,19 @@ func getChannelByName(c *Context, w http.ResponseWriter, r *http.Request) {
}
if channel.Type == model.ChannelTypeOpen {
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionReadPublicChannel) && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel) {
c.SetPermissionError(model.PermissionReadPublicChannel)
return
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionReadPublicChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadPublicChannel)
return
}
}
} else {
// allows team admins to access private channel
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) &&
!c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel) {
c.Err = model.NewAppError("getChannelByName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound)
return
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel); !ok {
c.Err = model.NewAppError("getChannelByName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound)
return
}
}
}
@ -1474,7 +1494,7 @@ func getChannelByNameForTeamName(c *Context, w http.ResponseWriter, r *http.Requ
return
}
channelOk := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel)
channelOk, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel)
if channel.Type == model.ChannelTypeOpen {
teamOk := c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionReadPublicChannel)
if !teamOk && !channelOk {
@ -1506,7 +1526,7 @@ func getChannelMembers(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
@ -1534,7 +1554,7 @@ func getChannelMembersTimezones(c *Context, w http.ResponseWriter, r *http.Reque
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
@ -1565,7 +1585,7 @@ func getChannelMembersByIds(c *Context, w http.ResponseWriter, r *http.Request)
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
@ -1593,7 +1613,7 @@ func getChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
@ -1746,7 +1766,7 @@ func updateChannelMemberRoles(c *Context, w http.ResponseWriter, r *http.Request
model.AddEventParameterToAuditRec(auditRec, "props", props)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelRoles) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelRoles); !ok {
c.SetPermissionError(model.PermissionManageChannelRoles)
return
}
@ -1778,7 +1798,7 @@ func updateChannelMemberSchemeRoles(c *Context, w http.ResponseWriter, r *http.R
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
model.AddEventParameterAuditableToAuditRec(auditRec, "roles", &schemeRoles)
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelRoles) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelRoles); !ok {
c.SetPermissionError(model.PermissionManageChannelRoles)
return
}
@ -1889,7 +1909,7 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
// Security check: if the user is a guest, they must have access to the channel
// to view its members
if c.AppContext.Session().IsGuest() {
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
if hasPermission, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !hasPermission {
c.SetPermissionError(model.PermissionReadChannel)
return
}
@ -1917,13 +1937,13 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionJoinPublicChannels) {
canAddSelf = true
}
if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePublicChannelMembers) {
if hasPermission, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePublicChannelMembers); hasPermission {
canAddOthers = true
}
}
if channel.Type == model.ChannelTypePrivate {
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers) {
if hasPermission, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers); !hasPermission {
c.SetPermissionError(model.PermissionManagePrivateChannelMembers)
return
}
@ -2086,14 +2106,18 @@ func removeChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
}
if c.Params.UserId != c.AppContext.Session().UserId {
if channel.Type == model.ChannelTypeOpen && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePublicChannelMembers) {
c.SetPermissionError(model.PermissionManagePublicChannelMembers)
return
if channel.Type == model.ChannelTypeOpen {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePublicChannelMembers); !ok {
c.SetPermissionError(model.PermissionManagePublicChannelMembers)
return
}
}
if channel.Type == model.ChannelTypePrivate && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers) {
c.SetPermissionError(model.PermissionManagePrivateChannelMembers)
return
if channel.Type == model.ChannelTypePrivate {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers); !ok {
c.SetPermissionError(model.PermissionManagePrivateChannelMembers)
return
}
}
}
@ -2235,7 +2259,7 @@ func channelMemberCountsByGroup(c *Context, w http.ResponseWriter, r *http.Reque
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
@ -2457,7 +2481,7 @@ func getDirectOrGroupMessageMembersCommonTeams(c *Context, w http.ResponseWriter
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}
@ -2531,12 +2555,12 @@ func canEditChannelBanner(c *Context, originalChannel *model.Channel) {
switch originalChannel.Type {
case model.ChannelTypePrivate:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelBanner) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelBanner); !ok {
c.SetPermissionError(model.PermissionManagePrivateChannelBanner)
return
}
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelBanner) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelBanner); !ok {
c.SetPermissionError(model.PermissionManagePublicChannelBanner)
return
}
@ -2551,7 +2575,7 @@ func getChannelAccessControlAttributes(c *Context, w http.ResponseWriter, r *htt
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
c.SetPermissionError(model.PermissionReadChannel)
return
}

View file

@ -59,13 +59,13 @@ func createChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
switch channel.Type {
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPublicChannel) {
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 !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPrivateChannel) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPrivateChannel); !ok {
c.SetPermissionError(model.PermissionAddBookmarkPrivateChannel)
return
}
@ -158,18 +158,23 @@ func updateChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
isMember := false
switch channel.Type {
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPublicChannel) {
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:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPrivateChannel) {
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
@ -178,6 +183,7 @@ func updateChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
isMember = true
user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
@ -201,6 +207,10 @@ func updateChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
auditRec.Success()
auditRec.AddEventResultState(updateChannelBookmarkResponse)
auditRec.AddEventObjectType("updateChannelBookmarkResponse")
@ -250,19 +260,22 @@ func updateChannelBookmarkSortOrder(c *Context, w http.ResponseWriter, r *http.R
return
}
isMember := false
switch channel.Type {
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPublicChannel) {
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:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPrivateChannel) {
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 {
@ -270,6 +283,7 @@ func updateChannelBookmarkSortOrder(c *Context, w http.ResponseWriter, r *http.R
return
}
isMember = true
user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
@ -292,6 +306,10 @@ func updateChannelBookmarkSortOrder(c *Context, w http.ResponseWriter, r *http.R
return
}
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
for _, b := range bookmarks {
if b.Id == c.Params.ChannelBookmarkId {
auditRec.AddEventResultState(b)
@ -335,19 +353,22 @@ func deleteChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
isMember := false
switch channel.Type {
case model.ChannelTypeOpen:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPublicChannel) {
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:
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPrivateChannel) {
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 {
@ -355,6 +376,7 @@ func deleteChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
isMember = true
user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId)
if gAppErr != nil {
c.Err = gAppErr
@ -390,6 +412,10 @@ func deleteChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
auditRec.Success()
auditRec.AddEventResultState(bookmark)
c.LogAudit("bookmark=" + bookmark.DisplayName)
@ -416,7 +442,8 @@ func listChannelBookmarksForChannel(c *Context, w http.ResponseWriter, r *http.R
return
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
hasPermission, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if !hasPermission {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -427,6 +454,13 @@ func listChannelBookmarksForChannel(c *Context, w http.ResponseWriter, r *http.R
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))
}

View file

@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"testing"
"time"
@ -256,7 +255,7 @@ func TestCreateChannel(t *testing.T) {
require.NoError(t, err)
// Verify that the guest user can access the private channel they were added to
_, _, err = guestClient.GetChannel(context.Background(), private.Id, "")
_, _, err = guestClient.GetChannel(context.Background(), private.Id)
require.NoError(t, err)
// Verify that the guest user cannot add members to the private channel
@ -269,7 +268,7 @@ func TestCreateChannel(t *testing.T) {
require.NoError(t, err)
// Verify that the guest user can access the public channel they were added to
_, _, err = guestClient.GetChannel(context.Background(), public.Id, "")
_, _, err = guestClient.GetChannel(context.Background(), public.Id)
require.NoError(t, err)
// Verify that the guest user cannot add members to the public channel
@ -1625,50 +1624,50 @@ func TestGetChannel(t *testing.T) {
th := Setup(t).InitBasic(t)
client := th.Client
channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id, "")
channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id)
require.NoError(t, err)
require.Equal(t, th.BasicChannel.Id, channel.Id, "ids did not match")
_, err = client.RemoveUserFromChannel(context.Background(), th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
_, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id, "")
_, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id)
require.NoError(t, err)
channel, _, err = client.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "")
channel, _, err = client.GetChannel(context.Background(), th.BasicPrivateChannel.Id)
require.NoError(t, err)
require.Equal(t, th.BasicPrivateChannel.Id, channel.Id, "ids did not match")
_, err = client.RemoveUserFromChannel(context.Background(), th.BasicPrivateChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
_, resp, err := client.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "")
_, resp, err := client.GetChannel(context.Background(), th.BasicPrivateChannel.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
_, resp, err = client.GetChannel(context.Background(), model.NewId(), "")
_, resp, err = client.GetChannel(context.Background(), model.NewId())
require.Error(t, err)
CheckNotFoundStatus(t, resp)
_, err = client.Logout(context.Background())
require.NoError(t, err)
_, resp, err = client.GetChannel(context.Background(), th.BasicChannel.Id, "")
_, resp, err = client.GetChannel(context.Background(), th.BasicChannel.Id)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
user := th.CreateUser(t)
_, _, err = client.Login(context.Background(), user.Email, user.Password)
require.NoError(t, err)
_, resp, err = client.GetChannel(context.Background(), th.BasicChannel.Id, "")
_, resp, err = client.GetChannel(context.Background(), th.BasicChannel.Id)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
_, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id, "")
_, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id)
require.NoError(t, err)
_, _, err = client.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "")
_, _, err = client.GetChannel(context.Background(), th.BasicPrivateChannel.Id)
require.NoError(t, err)
_, resp, err = client.GetChannel(context.Background(), th.BasicUser.Id, "")
_, resp, err = client.GetChannel(context.Background(), th.BasicUser.Id)
require.Error(t, err)
CheckNotFoundStatus(t, resp)
})
@ -1930,21 +1929,22 @@ func TestGetPublicChannelsByIdsForTeam(t *testing.T) {
t.Run("should return 2 channels", func(t *testing.T) {
input := []string{th.BasicChannel.Id}
output := []string{th.BasicChannel.DisplayName}
expectedDisplayNames := []string{th.BasicChannel.DisplayName}
input = append(input, GenerateTestID())
input = append(input, th.BasicChannel2.Id)
input = append(input, th.BasicPrivateChannel.Id)
output = append(output, th.BasicChannel2.DisplayName)
sort.Strings(output)
expectedDisplayNames = append(expectedDisplayNames, th.BasicChannel2.DisplayName)
channels, _, err := client.GetPublicChannelsByIdsForTeam(context.Background(), teamId, input)
require.NoError(t, err)
require.Len(t, channels, 2, "should return 2 channels")
actualDisplayNames := make([]string, len(channels))
for i, c := range channels {
require.Equal(t, output[i], c.DisplayName, "missing channel")
actualDisplayNames[i] = c.DisplayName
}
require.ElementsMatch(t, expectedDisplayNames, actualDisplayNames, "missing channel")
})
t.Run("forbidden for invalid team", func(t *testing.T) {
@ -3644,7 +3644,7 @@ func TestViewChannel(t *testing.T) {
member, _, err := client.GetChannelMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "")
require.NoError(t, err)
channel, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id, "")
channel, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id)
require.NoError(t, err)
require.Equal(t, channel.TotalMsgCount, member.MsgCount, "should match message counts")
require.Equal(t, int64(0), member.MentionCount, "should have no mentions")
@ -3679,9 +3679,9 @@ func TestReadMultipleChannels(t *testing.T) {
user := th.BasicUser
t.Run("Should successfully mark public channels as read for self", func(t *testing.T) {
channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id, "")
channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id)
require.NoError(t, err)
channel2, _, err := client.GetChannel(context.Background(), th.BasicChannel2.Id, "")
channel2, _, err := client.GetChannel(context.Background(), th.BasicChannel2.Id)
require.NoError(t, err)
channelResponse, _, err := client.ReadMultipleChannels(context.Background(), user.Id, []string{channel.Id, channel2.Id})
@ -3692,7 +3692,7 @@ func TestReadMultipleChannels(t *testing.T) {
})
t.Run("Should successfully mark private channels as read for self", func(t *testing.T) {
channel, _, err := client.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "")
channel, _, err := client.GetChannel(context.Background(), th.BasicPrivateChannel.Id)
require.NoError(t, err)
// private channel without membership should be ignored
@ -3704,7 +3704,7 @@ func TestReadMultipleChannels(t *testing.T) {
})
t.Run("Should fail marking public/private channels for other user", func(t *testing.T) {
channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id, "")
channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id)
require.NoError(t, err)
_, _, err = client.ReadMultipleChannels(context.Background(), th.BasicUser2.Id, []string{channel.Id})
@ -3713,9 +3713,9 @@ func TestReadMultipleChannels(t *testing.T) {
t.Run("Admin should succeed in marking public/private channels for other user", func(t *testing.T) {
adminClient := th.SystemAdminClient
channel, _, err := adminClient.GetChannel(context.Background(), th.BasicChannel.Id, "")
channel, _, err := adminClient.GetChannel(context.Background(), th.BasicChannel.Id)
require.NoError(t, err)
privateChannel, _, err := adminClient.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "")
privateChannel, _, err := adminClient.GetChannel(context.Background(), th.BasicPrivateChannel.Id)
require.NoError(t, err)
channelResponse, _, err := adminClient.ReadMultipleChannels(context.Background(), th.BasicUser2.Id, []string{channel.Id, privateChannel.Id})
@ -3729,9 +3729,9 @@ func TestReadMultipleChannels(t *testing.T) {
th.LoginSystemManager(t)
sysMgrClient := th.SystemManagerClient
channel, _, err := sysMgrClient.GetChannel(context.Background(), th.BasicChannel.Id, "")
channel, _, err := sysMgrClient.GetChannel(context.Background(), th.BasicChannel.Id)
require.NoError(t, err)
privateChannel, _, err := sysMgrClient.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "")
privateChannel, _, err := sysMgrClient.GetChannel(context.Background(), th.BasicPrivateChannel.Id)
require.NoError(t, err)
_, _, err = sysMgrClient.ReadMultipleChannels(context.Background(), th.BasicUser2.Id, []string{channel.Id, privateChannel.Id})

View file

@ -371,7 +371,7 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) {
model.AddEventParameterAuditableToAuditRec(auditRec, "command_args", &commandArgs)
// Checks that user is a member of the specified channel, and that they have permission to create a post in it.
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionCreatePost) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionCreatePost); !ok {
c.SetPermissionError(model.PermissionCreatePost)
return
}

View file

@ -159,6 +159,9 @@ func updateConfig(c *Context, w http.ResponseWriter, r *http.Request) {
// modifications to the slice.
cfg.PluginSettings.SignaturePublicKeyFiles = appCfg.PluginSettings.SignaturePublicKeyFiles
// Do not allow import directory to be changed through the API
*cfg.ImportSettings.Directory = *appCfg.ImportSettings.Directory
// Do not allow marketplace URL to be toggled through the API if EnableUploads are disabled.
if cfg.PluginSettings.EnableUploads != nil && !*appCfg.PluginSettings.EnableUploads {
*cfg.PluginSettings.MarketplaceURL = *appCfg.PluginSettings.MarketplaceURL
@ -305,6 +308,12 @@ func patchConfig(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
// Do not allow import directory to be changed through the API
if cfg.ImportSettings.Directory != nil && *cfg.ImportSettings.Directory != *appCfg.ImportSettings.Directory {
c.Err = model.NewAppError("patchConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "ImportSettings.Directory"}, "", http.StatusForbidden)
return
}
// Do not allow marketplace URL to be toggled if plugin uploads are disabled.
if cfg.PluginSettings.MarketplaceURL != nil && cfg.PluginSettings.EnableUploads != nil {
// Breaking it down to 2 conditions to make it simple.

View file

@ -305,6 +305,43 @@ func TestUpdateConfig(t *testing.T) {
CheckForbiddenStatus(t, resp)
})
t.Run("Should not be able to modify ImportSettings.Directory", func(t *testing.T) {
t.Run("sysadmin", func(t *testing.T) {
oldDirectory := *th.App.Config().ImportSettings.Directory
cfg2 := th.App.Config().Clone()
*cfg2.ImportSettings.Directory = "./new-import-dir"
cfg2, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg2)
require.NoError(t, err)
assert.Equal(t, oldDirectory, *cfg2.ImportSettings.Directory)
assert.Equal(t, oldDirectory, *th.App.Config().ImportSettings.Directory)
cfg2.ImportSettings.Directory = nil
cfg2, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg2)
require.NoError(t, err)
assert.Equal(t, oldDirectory, *cfg2.ImportSettings.Directory)
assert.Equal(t, oldDirectory, *th.App.Config().ImportSettings.Directory)
})
t.Run("local mode", func(t *testing.T) {
oldDirectory := *th.App.Config().ImportSettings.Directory
cfg2 := th.App.Config().Clone()
newDirectory := "./new-import-dir"
*cfg2.ImportSettings.Directory = newDirectory
cfg2, _, err = th.LocalClient.UpdateConfig(context.Background(), cfg2)
require.NoError(t, err)
assert.Equal(t, newDirectory, *cfg2.ImportSettings.Directory)
assert.Equal(t, newDirectory, *th.App.Config().ImportSettings.Directory)
cfg2.ImportSettings.Directory = nil
cfg2, _, err = th.LocalClient.UpdateConfig(context.Background(), cfg2)
require.NoError(t, err)
assert.Equal(t, oldDirectory, *cfg2.ImportSettings.Directory)
assert.Equal(t, oldDirectory, *th.App.Config().ImportSettings.Directory)
})
})
t.Run("System Admin should not be able to clear Site URL", func(t *testing.T) {
siteURL := cfg.ServiceSettings.SiteURL
defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = siteURL })
@ -819,6 +856,30 @@ func TestPatchConfig(t *testing.T) {
CheckForbiddenStatus(t, resp)
}
})
t.Run("not allowing to change import directory via api, unless local mode", func(t *testing.T) {
oldDirectory := *th.App.Config().ImportSettings.Directory
config := model.Config{ImportSettings: model.ImportSettings{
Directory: model.NewPointer("./new-import-dir"),
}}
updatedConfig, resp, err := client.PatchConfig(context.Background(), &config)
if client == th.LocalClient {
require.NoError(t, err)
CheckOKStatus(t, resp)
assert.Equal(t, "./new-import-dir", *updatedConfig.ImportSettings.Directory)
} else {
require.Error(t, err)
CheckForbiddenStatus(t, resp)
}
// Reset for local mode
if client == th.LocalClient {
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ImportSettings.Directory = oldDirectory
})
}
})
})
t.Run("Should not be able to modify PluginSettings.MarketplaceURL if EnableUploads is disabled", func(t *testing.T) {

View file

@ -169,7 +169,7 @@ func flagPost(c *Context, w http.ResponseWriter, r *http.Request) {
model.AddEventParameterToAuditRec(auditRec, "postId", postId)
model.AddEventParameterToAuditRec(auditRec, "userId", userId)
post, appErr := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
post, appErr, _ := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
if appErr != nil {
c.Err = appErr
return
@ -341,7 +341,7 @@ func getFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
}
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true, RetainContent: true, IncludeDeleted: true})
post, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
post, isMemberForPreviews, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
@ -352,6 +352,14 @@ func getFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !isMemberForPreviews {
previewPost := post.GetPreviewPost()
if previewPost != nil {
model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id)
}
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
auditRec.Success()
}

View file

@ -38,7 +38,7 @@ func upsertDraft(c *Context, w http.ResponseWriter, r *http.Request) {
hasPermission := false
if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), draft.ChannelId, model.PermissionCreatePost) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), draft.ChannelId, model.PermissionCreatePost); ok {
hasPermission = true
} else if channel, err := c.App.GetChannel(c.AppContext, draft.ChannelId); err == nil {
// Temporary permission check method until advanced permissions, please do not copy

View file

@ -142,7 +142,7 @@ func uploadFileSimple(c *Context, r *http.Request, timestamp time.Time) *model.F
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionUploadFile) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionUploadFile); !ok {
c.SetPermissionError(model.PermissionUploadFile)
return nil
}
@ -316,7 +316,7 @@ NextPart:
if c.Err != nil {
return nil
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionUploadFile) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionUploadFile); !ok {
c.SetPermissionError(model.PermissionUploadFile)
return nil
}
@ -429,7 +429,7 @@ func uploadFileMultipartLegacy(c *Context, mr *multipart.Reader,
if c.Err != nil {
return nil
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionUploadFile) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionUploadFile); !ok {
c.SetPermissionError(model.PermissionUploadFile)
return nil
}
@ -570,8 +570,8 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
model.AddEventParameterAuditableToAuditRec(auditRec, "file", fileInfo)
perm, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if !isContentReviewer {
perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if fileInfo.CreatorId == model.BookmarkFileOwner {
if !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
@ -594,6 +594,10 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec.Success()
web.WriteFileResponse(fileInfo.Name, fileInfo.MimeType, fileInfo.Size, time.Unix(0, fileInfo.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r)
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
}
func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) {
@ -615,7 +619,7 @@ func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = err
return
}
perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
perm, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if info.CreatorId == model.BookmarkFileOwner {
if !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
@ -640,6 +644,13 @@ func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) {
defer fileReader.Close()
web.WriteFileResponse(info.Name, ThumbnailImageType, 0, time.Unix(0, info.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r)
auditRec := c.MakeAuditRecord(model.AuditEventGetFileThumbnail, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "file_id", c.Params.FileId)
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
}
func getFileLink(c *Context, w http.ResponseWriter, r *http.Request) {
@ -669,7 +680,7 @@ func getFileLink(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = err
return
}
perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
perm, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if info.CreatorId == model.BookmarkFileOwner {
if !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
@ -685,6 +696,10 @@ func getFileLink(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
resp := make(map[string]string)
link := c.App.GeneratePublicLink(c.GetSiteURLHeader(), info)
resp["link"] = link
@ -715,7 +730,7 @@ func getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = err
return
}
perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
perm, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if info.CreatorId == model.BookmarkFileOwner {
if !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
@ -740,6 +755,13 @@ func getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) {
defer fileReader.Close()
web.WriteFileResponse(info.Name, PreviewImageType, 0, time.Unix(0, info.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r)
auditRec := c.MakeAuditRecord(model.AuditEventGetFilePreview, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "file_id", c.Params.FileId)
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
}
func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
@ -760,7 +782,7 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = err
return
}
perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
perm, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if info.CreatorId == model.BookmarkFileOwner {
if !perm {
c.SetPermissionError(model.PermissionReadChannelContent)
@ -775,6 +797,14 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(info); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
auditRec := c.MakeAuditRecord(model.AuditEventGetFileInfo, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "file_id", c.Params.FileId)
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
}
func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) {
@ -879,7 +909,7 @@ func searchFiles(c *Context, w http.ResponseWriter, r *http.Request, teamID stri
startTime := time.Now()
results, err := c.App.SearchFilesInTeamForUser(c.AppContext, terms, c.AppContext.Session().UserId, teamID, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage)
results, allFilesHaveMembership, err := c.App.SearchFilesInTeamForUser(c.AppContext, terms, c.AppContext.Session().UserId, teamID, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage)
elapsedTime := float64(time.Since(startTime)) / float64(time.Second)
metrics := c.App.Metrics()
@ -897,6 +927,16 @@ func searchFiles(c *Context, w http.ResponseWriter, r *http.Request, teamID stri
if err := json.NewEncoder(w).Encode(results); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
auditRec := c.MakeAuditRecord(model.AuditEventSearchFiles, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterAuditableToAuditRec(auditRec, "search_params", params)
if !allFilesHaveMembership {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
auditRec.Success()
}
func setInaccessibleFileHeader(w http.ResponseWriter, appErr *model.AppError) {

View file

@ -697,7 +697,7 @@ func verifyLinkUnlinkPermission(c *Context, syncableType model.GroupSyncableType
permission = model.PermissionManagePublicChannelMembers
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), syncableID, permission) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), syncableID, permission); !ok {
return model.MakePermissionError(c.AppContext.Session(), []*model.Permission{permission})
}
}
@ -972,7 +972,7 @@ func getGroupsByChannelCommon(c *Context, r *http.Request) ([]byte, *model.AppEr
} else {
permission = model.PermissionReadPublicChannelGroups
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, permission) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, permission); !ok {
return nil, model.MakePermissionError(c.AppContext.Session(), []*model.Permission{permission})
}
@ -1138,7 +1138,7 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) {
} else {
permission = model.PermissionManagePublicChannelMembers
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), NotAssociatedToChannelID, permission) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), NotAssociatedToChannelID, permission); !ok {
c.SetPermissionError(permission)
return
}
@ -1157,7 +1157,7 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) {
} else {
permission = model.PermissionManagePublicChannelMembers
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), ChannelIDForMemberCount, permission) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), ChannelIDForMemberCount, permission); !ok {
c.SetPermissionError(permission)
return
}

View file

@ -2052,16 +2052,8 @@ func TestGetGroups(t *testing.T) {
t.Run("not associated to channel", func(t *testing.T) {
opts := baseOpts
resp, err := th.SystemAdminClient.UpdateChannelRoles(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "")
require.NoError(t, err)
CheckOKStatus(t, resp)
opts.NotAssociatedToChannel = th.BasicChannel.Id
resp, err = th.SystemAdminClient.UpdateChannelRoles(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "channel_user channel_admin")
require.NoError(t, err)
CheckOKStatus(t, resp)
groups, resp, err := th.SystemAdminClient.GetGroups(context.Background(), opts)
require.NoError(t, err)
CheckOKStatus(t, resp)
@ -2070,16 +2062,8 @@ func TestGetGroups(t *testing.T) {
t.Run("not associated to team", func(t *testing.T) {
opts := baseOpts
resp, err := th.SystemAdminClient.UpdateTeamMemberRoles(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "")
require.NoError(t, err)
CheckOKStatus(t, resp)
opts.NotAssociatedToTeam = th.BasicTeam.Id
resp, err = th.SystemAdminClient.UpdateTeamMemberRoles(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "team_user team_admin")
require.NoError(t, err)
CheckOKStatus(t, resp)
groups, resp, err := th.SystemAdminClient.GetGroups(context.Background(), opts)
require.NoError(t, err)
CheckOKStatus(t, resp)

View file

@ -67,12 +67,12 @@ func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = err
return
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
} else {
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) {
if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -136,7 +136,7 @@ func submitDialog(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = err
return
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -189,7 +189,7 @@ func lookupDialog(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = err
return
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}

View file

@ -68,6 +68,13 @@ func createPostChecks(where string, c *Context, post *model.Post) {
return
}
if len(post.FileIds) > 0 {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionUploadFile); !ok {
c.SetPermissionError(model.PermissionUploadFile)
return
}
}
postHardenedModeCheckWithContext(where, c, post.GetProps())
if c.Err != nil {
return
@ -110,7 +117,7 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
rp, err := c.App.CreatePostAsUser(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), c.AppContext.Session().Id, setOnlineBool)
rp, isMemberForPreviews, err := c.App.CreatePostAsUser(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), c.AppContext.Session().Id, setOnlineBool)
if err != nil {
c.Err = err
return
@ -119,6 +126,14 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec.AddEventResultState(rp)
auditRec.AddEventObjectType("post")
if !isMemberForPreviews {
previewPost := rp.GetPreviewPost()
if previewPost != nil {
model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id)
}
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
if setOnlineBool {
c.App.SetStatusOnline(c.AppContext.Session().UserId, false)
}
@ -182,12 +197,13 @@ func createEphemeralPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
rp := c.App.SendEphemeralPost(c.AppContext, ephRequest.UserID, c.App.PostWithProxyRemovedFromImageURLs(ephRequest.Post))
// We prepare again the post here, so we can ignore the isMemberForPreviews return value from SendEphemeralPost
rp, _ := c.App.SendEphemeralPost(c.AppContext, ephRequest.UserID, c.App.PostWithProxyRemovedFromImageURLs(ephRequest.Post))
w.WriteHeader(http.StatusCreated)
rp = model.AddPostActionCookies(rp, c.App.PostActionCookieSecret())
rp = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, rp, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true})
rp, err := c.App.SanitizePostMetadataForUser(c.AppContext, rp, c.AppContext.Session().UserId)
rp, isMemberForPreviews, err := c.App.SanitizePostMetadataForUser(c.AppContext, rp, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
@ -195,6 +211,19 @@ func createEphemeralPost(c *Context, w http.ResponseWriter, r *http.Request) {
if err := rp.EncodeJSON(w); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateEphemeralPost, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "post_id", rp.Id)
if !isMemberForPreviews {
previewPost := rp.GetPreviewPost()
if previewPost != nil {
model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id)
}
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
auditRec.Success()
}
func getPostsForChannel(c *Context, w http.ResponseWriter, r *http.Request) {
@ -243,7 +272,8 @@ func getPostsForChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.Err = err
return
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
hasPermission, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if !hasPermission {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -294,7 +324,7 @@ func getPostsForChannel(c *Context, w http.ResponseWriter, r *http.Request) {
// to ensure they only reference posts that are actually in the response
c.App.AddCursorIdsForPostList(clientPostList, afterPost, beforePost, since, page, perPage, collapsedThreads)
clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
@ -303,6 +333,16 @@ func getPostsForChannel(c *Context, w http.ResponseWriter, r *http.Request) {
if err := clientPostList.EncodeJSON(w); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
auditRec := c.MakeAuditRecord(model.AuditEventGetPostsForChannel, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", channelId)
if !isMember || !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
if !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access_on_previews", true)
}
}
}
func getPostsForChannelAroundLastUnread(c *Context, w http.ResponseWriter, r *http.Request) {
@ -323,7 +363,8 @@ func getPostsForChannelAroundLastUnread(c *Context, w http.ResponseWriter, r *ht
c.Err = err
return
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
hasPermission, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if !hasPermission {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -364,7 +405,7 @@ func getPostsForChannelAroundLastUnread(c *Context, w http.ResponseWriter, r *ht
// to ensure they only reference posts that are actually in the response
clientPostList.NextPostId = c.App.GetNextPostIdFromPostList(clientPostList, collapsedThreads)
clientPostList.PrevPostId = c.App.GetPrevPostIdFromPostList(clientPostList, collapsedThreads)
clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
@ -376,6 +417,17 @@ func getPostsForChannelAroundLastUnread(c *Context, w http.ResponseWriter, r *ht
if err := clientPostList.EncodeJSON(w); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
auditRec := c.MakeAuditRecord(model.AuditEventGetPostsForChannelAroundLastUnread, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", channelId)
if !isMember || !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
if !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access_on_previews", true)
}
}
}
func getFlaggedPostsForUser(c *Context, w http.ResponseWriter, r *http.Request) {
@ -423,6 +475,7 @@ func getFlaggedPostsForUser(c *Context, w http.ResponseWriter, r *http.Request)
pl := model.NewPostList()
channelReadPermission := make(map[string]bool)
isMemberForAllPosts := true
for _, post := range posts.Posts {
allowed, ok := channelReadPermission[post.ChannelId]
@ -434,8 +487,11 @@ func getFlaggedPostsForUser(c *Context, w http.ResponseWriter, r *http.Request)
if !ok {
continue
}
if c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
hasPermission, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if hasPermission {
allowed = true
isMemberForAllPosts = isMemberForAllPosts && isMember
}
channelReadPermission[post.ChannelId] = allowed
@ -451,11 +507,23 @@ func getFlaggedPostsForUser(c *Context, w http.ResponseWriter, r *http.Request)
pl.SortByCreateAt()
clientPostList := c.App.PreparePostListForClient(c.AppContext, pl)
clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetFlaggedPosts, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", channelId)
if !isMemberForAllPosts || !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
if !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access_on_previews", true)
}
}
if err := clientPostList.EncodeJSON(w); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
@ -474,7 +542,7 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
post, err := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), includeDeleted)
post, err, isMember := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), includeDeleted)
if err != nil {
c.Err = err
@ -487,7 +555,7 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) {
}
post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true})
post, err = c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
post, previewIsMember, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
@ -501,6 +569,20 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) {
if err := post.EncodeJSON(w); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
auditRec := c.MakeAuditRecord(model.AuditEventGetPost, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "post_id", c.Params.PostId)
if !isMember || !previewIsMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
if !previewIsMember {
previewPost := post.GetPreviewPost()
if previewPost != nil {
model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id)
}
}
}
}
// getPostsByIds also sets a header to indicate, if posts were truncated as per the cloud plan's limit.
@ -540,16 +622,20 @@ func getPostsByIds(c *Context, w http.ResponseWriter, r *http.Request) {
}
var posts = []*model.Post{}
isMemberForAllPosts := true
for _, post := range postsList {
channel, ok := channelMap[post.ChannelId]
if !ok {
continue
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
hasPermission, isMemberForCurrentPost := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if !hasPermission {
continue
}
isMemberForAllPosts = isMemberForAllPosts && isMemberForCurrentPost
post = c.App.PreparePostForClient(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true})
post.StripActionIntegrations()
posts = append(posts, post)
@ -560,6 +646,14 @@ func getPostsByIds(c *Context, w http.ResponseWriter, r *http.Request) {
if err := json.NewEncoder(w).Encode(posts); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
auditRec := c.MakeAuditRecord(model.AuditEventGetPostsByIds, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "post_ids", postIDs)
if !isMemberForAllPosts {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
}
func getEditHistoryForPost(c *Context, w http.ResponseWriter, r *http.Request) {
@ -574,7 +668,8 @@ func getEditHistoryForPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditPost) {
ok, isMember := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditPost)
if !ok {
c.SetPermissionError(model.PermissionEditPost)
return
}
@ -590,6 +685,14 @@ func getEditHistoryForPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetEditHistoryForPost, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "post_id", c.Params.PostId)
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
if err := json.NewEncoder(w).Encode(postsList); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
@ -629,12 +732,12 @@ func deletePost(c *Context, w http.ResponseWriter, _ *http.Request) {
auditRec.AddEventObjectType("post")
if c.AppContext.Session().UserId == post.UserId {
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionDeletePost) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionDeletePost); !ok {
c.SetPermissionError(model.PermissionDeletePost)
return
}
} else {
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionDeleteOthersPosts) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionDeleteOthersPosts); !ok {
c.SetPermissionError(model.PermissionDeleteOthersPosts)
return
}
@ -760,7 +863,8 @@ func getPostThread(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if _, err = c.App.GetPostIfAuthorized(c.AppContext, post.Id, c.AppContext.Session(), false); err != nil {
var isMember bool
if _, err, isMember = c.App.GetPostIfAuthorized(c.AppContext, post.Id, c.AppContext.Session(), false); err != nil {
c.Err = err
return
}
@ -770,7 +874,7 @@ func getPostThread(c *Context, w http.ResponseWriter, r *http.Request) {
}
clientPostList := c.App.PreparePostListForClient(c.AppContext, list)
clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
@ -781,6 +885,17 @@ func getPostThread(c *Context, w http.ResponseWriter, r *http.Request) {
if err := clientPostList.EncodeJSON(w); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
auditRec := c.MakeAuditRecord(model.AuditEventGetPostThread, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "post_id", c.Params.PostId)
if !isMember || !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
if !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access_on_previews", true)
}
}
}
func searchPostsInTeam(c *Context, w http.ResponseWriter, r *http.Request) {
@ -845,7 +960,7 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request, teamId stri
startTime := time.Now()
results, err := c.App.SearchPostsForUser(c.AppContext, terms, c.AppContext.Session().UserId, teamId, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage)
results, allPostHaveMembership, err := c.App.SearchPostsForUser(c.AppContext, terms, c.AppContext.Session().UserId, teamId, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage)
elapsedTime := float64(time.Since(startTime)) / float64(time.Second)
metrics := c.App.Metrics()
@ -860,12 +975,19 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request, teamId stri
}
clientPostList := c.App.PreparePostListForClient(c.AppContext, results.PostList)
clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
}
if !allPostHaveMembership || !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
if !isMemberForAllPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access_on_previews", true)
}
}
results = model.MakePostSearchResults(clientPostList, results.Matches)
model.AddEventParameterAuditableToAuditRec(auditRec, "search_results", results)
auditRec.Success()
@ -909,7 +1031,8 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditPost) {
ok, isMember := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditPost)
if !ok {
c.SetPermissionError(model.PermissionEditPost)
return
}
@ -923,8 +1046,15 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
post.FileIds = originalPost.FileIds
}
// Check upload_file permission only if update is adding NEW files (not just keeping existing ones)
checkUploadFilePermissionForNewFiles(c, post.FileIds, originalPost)
if c.Err != nil {
return
}
if c.AppContext.Session().UserId != originalPost.UserId {
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditOthersPosts) {
// We don't need to check the member here, since we already checked it above
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditOthersPosts); !ok {
c.SetPermissionError(model.PermissionEditOthersPosts)
return
}
@ -937,12 +1067,22 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
rpost, err := c.App.UpdatePost(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), &model.UpdatePostOptions{SafeUpdate: false})
rpost, isMemberForPreviews, err := c.App.UpdatePost(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), &model.UpdatePostOptions{SafeUpdate: false})
if err != nil {
c.Err = err
return
}
if !isMember || !isMemberForPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
if !isMemberForPreviews {
previewPost := rpost.GetPreviewPost()
if previewPost != nil {
model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id)
}
}
}
auditRec.Success()
auditRec.AddEventResultState(rpost)
@ -975,17 +1115,34 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
postPatchChecks(c, auditRec, post.Message)
isMember := postPatchChecks(c, auditRec, post.Message)
if c.Err != nil {
return
}
patchedPost, err := c.App.PatchPost(c.AppContext, c.Params.PostId, c.App.PostPatchWithProxyRemovedFromImageURLs(&post), nil)
originalPost, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false)
if err != nil {
c.SetPermissionError(model.PermissionEditPost)
return
}
if post.FileIds != nil {
checkUploadFilePermissionForNewFiles(c, *post.FileIds, originalPost)
if c.Err != nil {
return
}
}
patchedPost, isMemberForPReviews, err := c.App.PatchPost(c.AppContext, c.Params.PostId, c.App.PostPatchWithProxyRemovedFromImageURLs(&post), nil)
if err != nil {
c.Err = err
return
}
if !isMember || !isMemberForPReviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
auditRec.Success()
auditRec.AddEventResultState(patchedPost)
@ -994,11 +1151,11 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) {
func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) bool {
originalPost, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false)
if err != nil {
c.SetPermissionError(model.PermissionEditPost)
return
return false
}
auditRec.AddEventPriorState(originalPost)
auditRec.AddEventObjectType("post")
@ -1011,15 +1168,18 @@ func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) {
permission = model.PermissionEditOthersPosts
}
if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, permission) {
ok, isMember := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, permission)
if !ok {
c.SetPermissionError(permission)
return
return false
}
if *c.App.Config().ServiceSettings.PostEditTimeLimit != -1 && model.GetMillis() > originalPost.CreateAt+int64(*c.App.Config().ServiceSettings.PostEditTimeLimit*1000) && message != nil {
c.Err = model.NewAppError("patchPost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest)
return
return isMember
}
return isMember
}
func setPostUnread(c *Context, w http.ResponseWriter, r *http.Request) {
@ -1035,7 +1195,7 @@ func setPostUnread(c *Context, w http.ResponseWriter, r *http.Request) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) {
if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -1060,7 +1220,7 @@ func setPostReminder(c *Context, w http.ResponseWriter, r *http.Request) {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) {
if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -1103,7 +1263,8 @@ func saveIsPinnedPost(c *Context, w http.ResponseWriter, isPinned bool) {
c.Err = err
return
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
ok, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)
if !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -1111,11 +1272,22 @@ func saveIsPinnedPost(c *Context, w http.ResponseWriter, isPinned bool) {
patch := &model.PostPatch{}
patch.IsPinned = model.NewPointer(isPinned)
patchedPost, err := c.App.PatchPost(c.AppContext, c.Params.PostId, patch, nil)
patchedPost, isMemberForPreviews, err := c.App.PatchPost(c.AppContext, c.Params.PostId, patch, nil)
if err != nil {
c.Err = err
return
}
if !isMember || !isMemberForPreviews {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
if !isMemberForPreviews {
previewPost := patchedPost.GetPreviewPost()
if previewPost != nil {
model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id)
}
}
}
auditRec.AddEventResultState(patchedPost)
auditRec.Success()
@ -1147,7 +1319,7 @@ func acknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) {
if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -1186,7 +1358,7 @@ func unacknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) {
if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -1265,7 +1437,7 @@ func moveThread(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
sourcePost, err := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), false)
sourcePost, err, _ := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), false)
if err != nil {
c.Err = err
if err.Id == "app.post.cloud.get.app_error" {
@ -1292,7 +1464,8 @@ func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) {
ok, isMember := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId)
if !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -1319,6 +1492,14 @@ func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetFileInfosForPost, model.AuditStatusSuccess)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "post_id", c.Params.PostId)
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
w.Header().Set("Cache-Control", "max-age=2592000, private")
w.Header().Set(model.HeaderEtagServer, model.GetEtagForFileInfos(infos))
if _, err := w.Write(js); err != nil {
@ -1332,7 +1513,86 @@ func getPostInfo(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
info, appErr := c.App.GetPostInfo(c.AppContext, c.Params.PostId)
userID := c.AppContext.Session().UserId
post, appErr := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false)
if appErr != nil {
c.Err = appErr
return
}
channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
notFoundError := model.NewAppError("GetPostInfo", "app.post.get.app_error", nil, "", http.StatusNotFound)
var team *model.Team
hasPermissionToAccessTeam := false
if channel.TeamId != "" {
team, appErr = c.App.GetTeam(channel.TeamId)
if appErr != nil {
c.Err = appErr
return
}
var teamMember *model.TeamMember
teamMember, appErr = c.App.GetTeamMember(c.AppContext, channel.TeamId, userID)
if appErr != nil && appErr.StatusCode != http.StatusNotFound {
c.Err = appErr
return
}
if appErr == nil {
if teamMember.DeleteAt == 0 {
hasPermissionToAccessTeam = true
}
}
if !hasPermissionToAccessTeam {
if team.AllowOpenInvite {
hasPermissionToAccessTeam = c.App.HasPermissionToTeam(c.AppContext, userID, team.Id, model.PermissionJoinPublicTeams)
} else {
hasPermissionToAccessTeam = c.App.HasPermissionToTeam(c.AppContext, userID, team.Id, model.PermissionJoinPrivateTeams)
}
}
} else {
// This happens in case of DMs and GMs.
hasPermissionToAccessTeam = true
}
if !hasPermissionToAccessTeam {
c.Err = notFoundError
return
}
hasPermissionToAccessChannel := false
hasJoinedChannel := false
_, channelMemberErr := c.App.GetChannelMember(c.AppContext, channel.Id, userID)
if channelMemberErr == nil {
hasPermissionToAccessChannel = true
hasJoinedChannel = true
}
if !hasPermissionToAccessChannel {
if channel.Type == model.ChannelTypeOpen {
hasPermissionToAccessChannel = true
} else if channel.Type == model.ChannelTypePrivate {
hasPermissionToAccessChannel, _ = c.App.HasPermissionToChannel(c.AppContext, userID, channel.Id, model.PermissionManagePrivateChannelMembers)
} else if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup {
hasPermissionToAccessChannel, _ = c.App.HasPermissionToReadChannel(c.AppContext, userID, channel)
}
}
if !hasPermissionToAccessChannel {
c.Err = notFoundError
return
}
info, appErr := c.App.GetPostInfo(c.AppContext, c.Params.PostId, channel, team, userID, hasJoinedChannel)
if appErr != nil {
c.Err = appErr
return
@ -1379,17 +1639,27 @@ func restorePostVersion(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
postPatchChecks(c, auditRec, &toRestorePost.Message)
isMember := postPatchChecks(c, auditRec, &toRestorePost.Message)
if c.Err != nil {
return
}
updatedPost, appErr := c.App.RestorePostVersion(c.AppContext, c.AppContext.Session().UserId, c.Params.PostId, restoreVersionId)
updatedPost, isMemberForPreview, appErr := c.App.RestorePostVersion(c.AppContext, c.AppContext.Session().UserId, c.Params.PostId, restoreVersionId)
if appErr != nil {
c.Err = appErr
return
}
if !isMember || !isMemberForPreview {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
if !isMemberForPreview {
previewPost := updatedPost.GetPreviewPost()
if previewPost != nil {
model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id)
}
}
}
auditRec.Success()
auditRec.AddEventResultState(updatedPost)
@ -1469,7 +1739,7 @@ func revealPost(c *Context, w http.ResponseWriter, r *http.Request) {
model.AddEventParameterToAuditRec(auditRec, "post_id", postId)
model.AddEventParameterToAuditRec(auditRec, "user_id", userId)
post, err := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
post, err, isMember := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
if err != nil {
c.Err = err
if err.Id == "app.post.cloud.get.app_error" {
@ -1502,6 +1772,10 @@ func revealPost(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !isMember {
model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true)
}
auditRec.Success()
auditRec.AddEventResultState(revealedPost)
@ -1526,7 +1800,7 @@ func burnPost(c *Context, w http.ResponseWriter, r *http.Request) {
model.AddEventParameterToAuditRec(auditRec, "post_id", postId)
model.AddEventParameterToAuditRec(auditRec, "user_id", userId)
post, err := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
post, err, _ := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false)
if err != nil {
c.Err = err
if err.Id == "app.post.cloud.get.app_error" {

View file

@ -274,6 +274,8 @@ func TestCreatePost(t *testing.T) {
})
t.Run("not logged in", func(t *testing.T) {
defer th.LoginBasic(t)
resp, err := client.Logout(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
@ -285,6 +287,46 @@ func TestCreatePost(t *testing.T) {
assert.Nil(t, rpost)
})
t.Run("should prevent creating post with files when user lacks upload_file permission in target channel", func(t *testing.T) {
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), th.BasicChannel.Id, "test-file.txt")
require.NoError(t, err)
CheckCreatedStatus(t, resp)
fileId := fileResp.FileInfos[0].Id
th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
defer func() {
th.AddPermissionToRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
}()
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "Test post with file",
FileIds: model.StringArray{fileId},
}
rpost, resp, err := client.CreatePost(context.Background(), post)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
assert.Nil(t, rpost)
})
t.Run("should allow creating post with files when user has upload_file permission", func(t *testing.T) {
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), th.BasicChannel.Id, "test-file.txt")
require.NoError(t, err)
CheckCreatedStatus(t, resp)
fileId := fileResp.FileInfos[0].Id
post := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "Test post with file",
FileIds: model.StringArray{fileId},
}
rpost, resp, err := client.CreatePost(context.Background(), post)
require.NoError(t, err)
CheckCreatedStatus(t, resp)
require.NotNil(t, rpost)
assert.Contains(t, rpost.FileIds, fileId)
})
t.Run("CreateAt should match the one provided in the request", func(t *testing.T) {
post := basicPost()
post.CreateAt = 123
@ -311,6 +353,17 @@ func TestCreatePost(t *testing.T) {
require.Nil(t, appErr)
require.Zero(t, *createdPost.RemoteId)
})
t.Run("not logged in", func(t *testing.T) {
resp, err := client.Logout(context.Background())
require.NoError(t, err)
CheckOKStatus(t, resp)
post := basicPost()
rpost, resp, err := client.CreatePost(context.Background(), post)
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
assert.Nil(t, rpost)
})
}
func TestCreatePostForPriority(t *testing.T) {
@ -1424,7 +1477,7 @@ func TestUpdatePost(t *testing.T) {
fileIds[i] = fileResp.FileInfos[0].Id
}
rpost, appErr := th.App.CreatePost(th.Context, &model.Post{
rpost, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "zz" + model.NewId() + "a",
@ -1457,7 +1510,7 @@ func TestUpdatePost(t *testing.T) {
t.Run("join/leave post", func(t *testing.T) {
var rpost2 *model.Post
rpost2, appErr = th.App.CreatePost(th.Context, &model.Post{
rpost2, _, appErr = th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
Message: "zz" + model.NewId() + "a",
Type: model.PostTypeJoinLeave,
@ -1475,7 +1528,7 @@ func TestUpdatePost(t *testing.T) {
CheckBadRequestStatus(t, resp)
})
rpost3, appErr := th.App.CreatePost(th.Context, &model.Post{
rpost3, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
Message: "zz" + model.NewId() + "a",
UserId: th.BasicUser.Id,
@ -1507,7 +1560,7 @@ func TestUpdatePost(t *testing.T) {
*cfg.ServiceSettings.PostEditTimeLimit = -1
})
rpost4, appErr := th.App.CreatePost(th.Context, &model.Post{
rpost4, _, appErr := th.App.CreatePost(th.Context, &model.Post{
ChannelId: channel.Id,
Message: "zz" + model.NewId() + "a",
UserId: th.BasicUser.Id,
@ -1545,6 +1598,62 @@ func TestUpdatePost(t *testing.T) {
CheckBadRequestStatus(t, resp)
})
t.Run("should prevent updating post with files when user lacks upload_file permission in target channel", func(t *testing.T) {
postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "Post without files",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), channel.Id, "test-file.txt")
require.NoError(t, err)
CheckCreatedStatus(t, resp)
fileId := fileResp.FileInfos[0].Id
th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
defer func() {
th.AddPermissionToRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
}()
updatePost := &model.Post{
Id: postWithoutFiles.Id,
ChannelId: channel.Id,
Message: "Updated post with file",
FileIds: model.StringArray{fileId},
}
updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
assert.Nil(t, updatedPost)
})
t.Run("should allow updating post with files when user has upload_file permission", func(t *testing.T) {
postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "Post without files",
}, channel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), channel.Id, "test-file.txt")
require.NoError(t, err)
CheckCreatedStatus(t, resp)
fileId := fileResp.FileInfos[0].Id
updatePost := &model.Post{
Id: postWithoutFiles.Id,
ChannelId: channel.Id,
Message: "Updated post with file",
FileIds: model.StringArray{fileId},
}
updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost)
require.NoError(t, err)
CheckOKStatus(t, resp)
require.NotNil(t, updatedPost)
assert.Contains(t, updatedPost.FileIds, fileId)
})
t.Run("logged out", func(t *testing.T) {
_, err := client.Logout(context.Background())
require.NoError(t, err)
@ -1587,7 +1696,7 @@ func TestUpdatePost(t *testing.T) {
fileInfo := fileResponse.FileInfos[0]
// create new post
post, appErr := th.App.CreatePost(th.Context, &model.Post{
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "zz" + model.NewId() + "a",
@ -1623,7 +1732,7 @@ func TestUpdatePost(t *testing.T) {
fileInfo := fileResponse.FileInfos[0]
// create new post
post, appErr := th.App.CreatePost(th.Context, &model.Post{
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "zz" + model.NewId() + "a",
@ -1661,7 +1770,7 @@ func TestUpdatePost(t *testing.T) {
fileInfo := fileResponse.FileInfos[0]
// create new post
post, appErr := th.App.CreatePost(th.Context, &model.Post{
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "zz" + model.NewId() + "a",
@ -1707,7 +1816,7 @@ func TestUpdatePost(t *testing.T) {
fileInfo2 := fileResponse2.FileInfos[0]
// create new post
post, appErr := th.App.CreatePost(th.Context, &model.Post{
post, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
Message: "zz" + model.NewId() + "a",
@ -4320,13 +4429,13 @@ func TestSetChannelUnread(t *testing.T) {
t.Run("Unread on a direct channel in a thread", func(t *testing.T) {
dc := th.CreateDmChannel(t, th.CreateUser(t))
rootPost, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: u1.Id, CreateAt: now, ChannelId: dc.Id, Message: "root"}, dc, model.CreatePostFlags{})
rootPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: u1.Id, CreateAt: now, ChannelId: dc.Id, Message: "root"}, dc, model.CreatePostFlags{})
require.Nil(t, appErr)
_, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 10, ChannelId: dc.Id, Message: "reply 1"}, dc, model.CreatePostFlags{})
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 10, ChannelId: dc.Id, Message: "reply 1"}, dc, model.CreatePostFlags{})
require.Nil(t, appErr)
reply2, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 20, ChannelId: dc.Id, Message: "reply 2"}, dc, model.CreatePostFlags{})
reply2, _, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 20, ChannelId: dc.Id, Message: "reply 2"}, dc, model.CreatePostFlags{})
require.Nil(t, appErr)
_, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 30, ChannelId: dc.Id, Message: "reply 3"}, dc, model.CreatePostFlags{})
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 30, ChannelId: dc.Id, Message: "reply 3"}, dc, model.CreatePostFlags{})
require.Nil(t, appErr)
// Ensure that post have been read
@ -4425,19 +4534,19 @@ func TestSetPostUnreadWithoutCollapsedThreads(t *testing.T) {
// user1: a root post
// user2: Another root mention @u1
user1Mention := " @" + th.BasicUser.Username
rootPost1, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "first root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{})
rootPost1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "first root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hello"}, th.BasicChannel, model.CreatePostFlags{})
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hello"}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
replyPost1, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{})
replyPost1, _, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another reply"}, th.BasicChannel, model.CreatePostFlags{})
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another reply"}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{})
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "a root post"}, th.BasicChannel, model.CreatePostFlags{})
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "a root post"}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
_, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{})
_, _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
t.Run("Mark reply post as unread", func(t *testing.T) {
@ -4541,7 +4650,7 @@ func TestGetEditHistoryForPost(t *testing.T) {
UserId: th.BasicUser.Id,
}
rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, err)
time.Sleep(1 * time.Millisecond)
@ -4612,7 +4721,7 @@ func TestGetEditHistoryForPost(t *testing.T) {
FileIds: []string{fileInfo1.Id, fileInfo2.Id},
}
createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
require.Contains(t, createdPost.FileIds, fileInfo1.Id)
require.Contains(t, createdPost.FileIds, fileInfo2.Id)
@ -4766,7 +4875,7 @@ func TestCreatePostNotificationsWithCRT(t *testing.T) {
require.NoError(t, err)
// post a reply on the thread
_, appErr := th.App.CreatePostAsUser(th.Context, tc.post, th.Context.Session().Id, false)
_, _, appErr := th.App.CreatePostAsUser(th.Context, tc.post, th.Context.Session().Id, false)
require.Nil(t, appErr)
var caught bool

View file

@ -10,7 +10,7 @@ import (
func userCreatePostPermissionCheckWithContext(c *Context, channelId string) {
hasPermission := false
if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionCreatePost) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionCreatePost); ok {
hasPermission = true
} else if channel, err := c.App.GetChannel(c.AppContext, channelId); err == nil {
// Temporary permission check method until advanced permissions, please do not copy
@ -41,3 +41,31 @@ func postPriorityCheckWithContext(where string, c *Context, priority *model.Post
c.Err = appErr
}
}
// checkUploadFilePermissionForNewFiles checks upload_file permission only when
// adding new files to a post, preventing permission bypass via cross-channel file attachments.
func checkUploadFilePermissionForNewFiles(c *Context, newFileIds []string, originalPost *model.Post) {
if len(newFileIds) == 0 {
return
}
originalFileIDsMap := make(map[string]bool, len(originalPost.FileIds))
for _, fileID := range originalPost.FileIds {
originalFileIDsMap[fileID] = true
}
hasNewFiles := false
for _, fileID := range newFileIds {
if !originalFileIDsMap[fileID] {
hasNewFiles = true
break
}
}
if hasNewFiles {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionUploadFile); !ok {
c.SetPermissionError(model.PermissionUploadFile)
return
}
}
}

View file

@ -131,7 +131,7 @@ func updatePreferences(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) {
if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}

View file

@ -57,7 +57,7 @@ func getReactions(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) {
if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}
@ -117,7 +117,7 @@ func getBulkReactions(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
for _, postId := range postIds {
if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), postId, model.PermissionReadChannelContent) {
if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), postId); !ok {
c.SetPermissionError(model.PermissionReadChannelContent)
return
}

View file

@ -0,0 +1,286 @@
// 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) InitRecap() {
api.BaseRoutes.Recaps.Handle("", api.APISessionRequired(createRecap)).Methods(http.MethodPost)
api.BaseRoutes.Recaps.Handle("", api.APISessionRequired(getRecaps)).Methods(http.MethodGet)
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}", api.APISessionRequired(getRecap)).Methods(http.MethodGet)
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}/read", api.APISessionRequired(markRecapAsRead)).Methods(http.MethodPost)
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}/regenerate", api.APISessionRequired(regenerateRecap)).Methods(http.MethodPost)
api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteRecap)).Methods(http.MethodDelete)
}
func requireRecapsEnabled(c *Context) {
if !c.App.Config().FeatureFlags.EnableAIRecaps {
c.Err = model.NewAppError("requireRecapsEnabled", "api.recap.disabled.app_error", nil, "", http.StatusNotImplemented)
return
}
}
// addRecapChannelIDsToAuditRec extracts channel IDs from a recap and adds them to the audit record.
// This logs which channels' content was accessed through the recap operation.
func addRecapChannelIDsToAuditRec(auditRec *model.AuditRecord, recap *model.Recap) {
if len(recap.Channels) == 0 {
return
}
channelIDs := make([]string, 0, len(recap.Channels))
for _, channel := range recap.Channels {
channelIDs = append(channelIDs, channel.ChannelId)
}
model.AddEventParameterToAuditRec(auditRec, "channel_ids", channelIDs)
}
func createRecap(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
var req model.CreateRecapRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
c.SetInvalidParamWithErr("body", err)
return
}
if len(req.ChannelIds) == 0 {
c.SetInvalidParam("channel_ids")
return
}
if req.Title == "" {
c.SetInvalidParam("title")
return
}
if req.AgentID == "" {
c.SetInvalidParam("agent_id")
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateRecap, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
auditRec.AddEventObjectType("recap")
model.AddEventParameterToAuditRec(auditRec, "channel_ids", req.ChannelIds)
model.AddEventParameterToAuditRec(auditRec, "title", req.Title)
model.AddEventParameterToAuditRec(auditRec, "agent_id", req.AgentID)
recap, err := c.App.CreateRecap(c.AppContext, req.Title, req.ChannelIds, req.AgentID)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(recap)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(recap); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func getRecap(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
c.RequireRecapId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetRecap, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
auditRec.AddEventObjectType("recap")
model.AddEventParameterToAuditRec(auditRec, "recap_id", c.Params.RecapId)
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
if err != nil {
c.Err = err
return
}
if recap.UserId != c.AppContext.Session().UserId {
c.Err = model.NewAppError("getRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden)
return
}
// Log channel IDs accessed through viewing this recap summary
addRecapChannelIDsToAuditRec(auditRec, recap)
auditRec.Success()
auditRec.AddEventResultState(recap)
if err := json.NewEncoder(w).Encode(recap); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func getRecaps(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventGetRecaps, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelAPI)
model.AddEventParameterToAuditRec(auditRec, "page", c.Params.Page)
model.AddEventParameterToAuditRec(auditRec, "per_page", c.Params.PerPage)
recaps, err := c.App.GetRecapsForUser(c.AppContext, c.Params.Page, c.Params.PerPage)
if err != nil {
c.Err = err
return
}
auditRec.Success()
if len(recaps) > 0 {
auditRec.AddMeta("recap_count", len(recaps))
}
if err := json.NewEncoder(w).Encode(recaps); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func markRecapAsRead(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
c.RequireRecapId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventMarkRecapAsRead, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
auditRec.AddEventObjectType("recap")
model.AddEventParameterToAuditRec(auditRec, "recap_id", c.Params.RecapId)
// Check permissions
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
if err != nil {
c.Err = err
return
}
if recap.UserId != c.AppContext.Session().UserId {
c.Err = model.NewAppError("markRecapAsRead", "api.recap.permission_denied", nil, "", http.StatusForbidden)
return
}
auditRec.AddEventPriorState(recap)
updatedRecap, err := c.App.MarkRecapAsRead(c.AppContext, recap)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(updatedRecap)
if err := json.NewEncoder(w).Encode(updatedRecap); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func regenerateRecap(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
c.RequireRecapId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventRegenerateRecap, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
auditRec.AddEventObjectType("recap")
model.AddEventParameterToAuditRec(auditRec, "recap_id", c.Params.RecapId)
// Check permissions
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
if err != nil {
c.Err = err
return
}
if recap.UserId != c.AppContext.Session().UserId {
c.Err = model.NewAppError("regenerateRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden)
return
}
// Log channel IDs that will be re-summarized
addRecapChannelIDsToAuditRec(auditRec, recap)
auditRec.AddEventPriorState(recap)
updatedRecap, err := c.App.RegenerateRecap(c.AppContext, c.AppContext.Session().UserId, recap)
if err != nil {
c.Err = err
return
}
auditRec.Success()
auditRec.AddEventResultState(updatedRecap)
if err := json.NewEncoder(w).Encode(updatedRecap); err != nil {
c.Logger.Warn("Error encoding response", mlog.Err(err))
}
}
func deleteRecap(c *Context, w http.ResponseWriter, r *http.Request) {
requireRecapsEnabled(c)
if c.Err != nil {
return
}
c.RequireRecapId()
if c.Err != nil {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventDeleteRecap, model.AuditStatusFail)
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
auditRec.AddEventObjectType("recap")
model.AddEventParameterToAuditRec(auditRec, "recap_id", c.Params.RecapId)
// Check permissions
recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId)
if err != nil {
c.Err = err
return
}
if recap.UserId != c.AppContext.Session().UserId {
c.Err = model.NewAppError("deleteRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden)
return
}
auditRec.AddEventPriorState(recap)
if err := c.App.DeleteRecap(c.AppContext, c.Params.RecapId); err != nil {
c.Err = err
return
}
auditRec.Success()
ReturnStatusOK(w)
}

View file

@ -205,7 +205,7 @@ func TestGetPostsForReporting(t *testing.T) {
CreateAt: baseTime + (int64(i) * 1000), // 1 second apart
UpdateAt: baseTime + (int64(i) * 1000),
}
createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
testPosts = append(testPosts, createdPost)
}
@ -596,7 +596,7 @@ func TestGetPostsForReporting(t *testing.T) {
CreateAt: baseTime + (int64(20+i) * 1000), // After all test posts
UpdateAt: baseTime + (int64(20+i) * 1000),
}
_, appErr := th.App.CreatePost(th.Context, systemPost, th.BasicChannel, model.CreatePostFlags{})
_, _, appErr := th.App.CreatePost(th.Context, systemPost, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, appErr)
}

View file

@ -6,7 +6,6 @@ package api4
import (
"context"
"fmt"
"sort"
"strings"
"testing"
@ -299,9 +298,8 @@ func TestPatchRole(t *testing.T) {
assert.Equal(t, received.Name, role.Name)
assert.Equal(t, received.DisplayName, role.DisplayName)
assert.Equal(t, received.Description, role.Description)
perms := []string{"create_direct_channel", "create_public_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"}
sort.Strings(perms)
assert.EqualValues(t, received.Permissions, perms)
expectedPermissions := []string{"create_direct_channel", "create_public_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"}
assert.ElementsMatch(t, expectedPermissions, received.Permissions)
assert.Equal(t, received.SchemeManaged, role.SchemeManaged)
// Check a no-op patch succeeds.
@ -333,9 +331,8 @@ func TestPatchRole(t *testing.T) {
assert.Equal(t, received.Name, role.Name)
assert.Equal(t, received.DisplayName, role.DisplayName)
assert.Equal(t, received.Description, role.Description)
perms := []string{"create_direct_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"}
sort.Strings(perms)
assert.EqualValues(t, received.Permissions, perms)
expectedPermissions := []string{"create_direct_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"}
assert.ElementsMatch(t, expectedPermissions, received.Permissions)
assert.Equal(t, received.SchemeManaged, role.SchemeManaged)
t.Run("Check guest permissions editing without E20 license", func(t *testing.T) {

View file

@ -74,6 +74,13 @@ func createSchedulePost(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterAuditableToAuditRec(auditRec, "scheduledPost", &scheduledPost)
if len(scheduledPost.FileIds) > 0 {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), scheduledPost.ChannelId, model.PermissionUploadFile); !ok {
c.SetPermissionError(model.PermissionUploadFile)
return
}
}
scheduledPostChecks("Api4.createSchedulePost", c, &scheduledPost)
if c.Err != nil {
return
@ -169,12 +176,38 @@ func updateScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) {
defer c.LogAuditRecWithLevel(auditRec, app.LevelContent)
model.AddEventParameterAuditableToAuditRec(auditRec, "scheduledPost", &scheduledPost)
userId := c.AppContext.Session().UserId
existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(scheduledPost.Id)
if err != nil {
c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.get_scheduled_post.error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if existingScheduledPost == nil {
c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.existing_scheduled_post.not_exist", nil, "", http.StatusNotFound)
return
}
if existingScheduledPost.UserId != userId {
c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.update_permission.error", nil, "", http.StatusForbidden)
return
}
if len(scheduledPost.FileIds) > 0 {
originalPost, err := existingScheduledPost.ToPost()
if err != nil {
c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.convert_to_post.error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
checkUploadFilePermissionForNewFiles(c, scheduledPost.FileIds, originalPost)
if c.Err != nil {
return
}
}
scheduledPostChecks("Api4.updateScheduledPost", c, &scheduledPost)
if c.Err != nil {
return
}
userId := c.AppContext.Session().UserId
updatedScheduledPost, appErr := c.App.UpdateScheduledPost(c.AppContext, userId, &scheduledPost, connectionID)
if appErr != nil {
c.Err = appErr
@ -209,6 +242,21 @@ func deleteScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) {
model.AddEventParameterToAuditRec(auditRec, "scheduledPostId", scheduledPostId)
userId := c.AppContext.Session().UserId
existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(scheduledPostId)
if err != nil {
c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.get_scheduled_post.error", nil, "", http.StatusInternalServerError).Wrap(err)
return
}
if existingScheduledPost == nil {
c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.existing_scheduled_post.not_exist", nil, "", http.StatusNotFound)
return
}
if existingScheduledPost.UserId != userId {
c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.delete_permission.error", nil, "", http.StatusForbidden)
return
}
connectionID := r.Header.Get(model.ConnectionId)
deletedScheduledPost, appErr := c.App.DeleteScheduledPost(c.AppContext, userId, scheduledPostId, connectionID)
if appErr != nil {

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