1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-04-07 21:18:42 +02:00

Merge branch 'main' into GH2520

This commit is contained in:
Mattermod 2022-07-25 23:34:58 +03:00 committed by GitHub
commit 343c9a99a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
579 changed files with 56300 additions and 31932 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
website/** linguist-documentation
server/swagger/** linguist-generated

View File

@ -22,12 +22,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: "Test server: ${{matrix['db']}}"
run: make server-test-${{matrix['db']}}
@ -37,21 +37,20 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: npm ci
run: cd webapp; npm ci
- name: ESLint
run: cd webapp; npm run check
run: |
cd webapp && npm ci && cd -
cd mattermost-plugin/webapp && npm ci
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 16.1.0
@ -67,11 +66,8 @@ jobs:
name: focalboard-server-linux-amd64.tar.gz
path: ${{ github.workspace }}/dist/focalboard-server-linux-amd64.tar.gz
- name: "Test webapp: Jest"
run: cd webapp; npm run test
- name: "Test webapp: Cypress"
run: "cd webapp; npm run cypress:ci"
- name: Lint & test webapp
run: make webapp-ci
ci-windows-server:
runs-on: windows-2022
@ -83,12 +79,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: "Test server (minimum): ${{matrix['db']}}"
run: make server-test-mini-${{matrix['db']}}
@ -103,12 +99,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: "Test server (minimum): ${{matrix['db']}}"
run: make server-test-mini-${{matrix['db']}}

View File

@ -40,14 +40,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
uses: github/codeql-action/analyze@v2

View File

@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Replace token 1 server
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
@ -32,12 +32,12 @@ jobs:
run: cd webapp; npm ci --no-optional
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 16.1.0
@ -56,13 +56,13 @@ jobs:
BUILD_NUMBER: ${{ github.run_id }}
- name: Upload server package
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: focalboard-server-linux-amd64.tar.gz
path: ${{ github.workspace }}/dist/focalboard-server-linux-amd64.tar.gz
- name: Upload app package
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: focalboard-linux.tar.gz
path: ${{ github.workspace }}/linux/dist/focalboard-linux.tar.gz
@ -73,7 +73,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Replace token 1 server
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
@ -91,9 +91,9 @@ jobs:
run: cd webapp; npm ci --no-optional
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: List Xcode versions
run: ls -n /Applications/ | grep Xcode*
@ -105,7 +105,7 @@ jobs:
BUILD_NUMBER: ${{ github.run_id }}
- name: Upload macOS package
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: focalboard-mac.zip
path: ${{ github.workspace }}/mac/dist/focalboard-mac.zip
@ -115,7 +115,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Replace token 1 server
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
@ -130,15 +130,15 @@ jobs:
run: sed -i -e "s,placeholder_rudder_key,${{ secrets.RUDDER_DEV_KEY }},g" ${{ github.workspace }}/mattermost-plugin/webapp/src/index.tsx
- name: Add msbuild to PATH
uses: microsoft/setup-msbuild@v1.0.2
uses: microsoft/setup-msbuild@v1.1
- name: npm ci
run: cd webapp; npm ci --no-optional
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: Setup NuGet
uses: nuget/setup-nuget@v1
@ -154,13 +154,13 @@ jobs:
BUILD_NUMBER: ${{ github.run_id }}
- name: Upload app msix package
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: focalboard.msix
path: ${{ github.workspace }}/win-wpf/focalboard.msix
- name: Upload app zip package
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: focalboard-win.zip
path: ${{ github.workspace }}/win-wpf/dist/focalboard-win.zip
@ -170,7 +170,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Replace token 1 server
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
@ -188,12 +188,12 @@ jobs:
run: cd webapp; npm ci --no-optional
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: Set up Node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 16.1.0
@ -212,7 +212,7 @@ jobs:
run: cd mattermost-plugin/dist; mv focalboard-*.tar.gz mattermost-plugin-focalboard.tar.gz
- name: Upload plugin artifact
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: mattermost-plugin-focalboard.tar.gz
path: ${{ github.workspace }}/mattermost-plugin/dist/mattermost-plugin-focalboard.tar.gz

View File

@ -1,23 +0,0 @@
name: golangci-lint
on:
push:
branches: [ main, release-** ]
pull_request:
branches: [ main, release-** ]
jobs:
golangci:
name: plugin
runs-on: ubuntu-18.04
steps:
- uses: actions/setup-go@v2
with:
go-version: 1.16
- uses: actions/checkout@v2
- name: plugin-lint
uses: golangci/golangci-lint-action@v2
with:
version: v1.41
working-directory: mattermost-plugin
skip-go-installation: true

View File

@ -5,20 +5,18 @@ on:
branches: [ main, release-** ]
pull_request:
branches: [ main, release-** ]
workflow_dispatch:
jobs:
golangci:
name: server
name: plugin
runs-on: ubuntu-18.04
steps:
- uses: actions/setup-go@v2
- uses: actions/setup-go@v3
with:
go-version: 1.16
- uses: actions/checkout@v2
- run: make templates-archive
- name: server-lint
uses: golangci/golangci-lint-action@v2
with:
version: v1.41
working-directory: server
skip-go-installation: true
go-version: 1.18.1
- uses: actions/checkout@v3
- name: set up golangci-lint
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.46.2
- name: lint
run: make server-lint

View File

@ -9,7 +9,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Replace token 1 server
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
@ -27,12 +27,12 @@ jobs:
run: cd webapp; npm ci --no-optional
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: Setup Node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 16.1.0
@ -51,13 +51,13 @@ jobs:
BUILD_NUMBER: ${{ github.run_id }}
- name: Upload server package
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: focalboard-server-linux-amd64.tar.gz
path: ${{ github.workspace }}/dist/focalboard-server-linux-amd64.tar.gz
- name: Upload app package
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: focalboard-linux.tar.gz
path: ${{ github.workspace }}/linux/dist/focalboard-linux.tar.gz
@ -68,7 +68,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Replace token 1 server
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
@ -86,9 +86,9 @@ jobs:
run: cd webapp; npm ci --no-optional
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: List Xcode versions
run: ls -n /Applications/ | grep Xcode*
@ -100,7 +100,7 @@ jobs:
BUILD_NUMBER: ${{ github.run_id }}
- name: Upload macOS package
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v2
with:
name: focalboard-mac.zip
path: ${{ github.workspace }}/mac/dist/focalboard-mac.zip
@ -110,7 +110,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Replace token 1 server
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
@ -125,15 +125,15 @@ jobs:
run: sed -i -e "s,placeholder_rudder_key,${{ secrets.RUDDER_PROD_KEY }},g" ${{ github.workspace }}/mattermost-plugin/webapp/src/index.tsx
- name: Add msbuild to PATH
uses: microsoft/setup-msbuild@v1.0.2
uses: microsoft/setup-msbuild@v1.1
- name: npm ci
run: cd webapp; npm ci --no-optional
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: Setup NuGet
uses: nuget/setup-nuget@v1
@ -149,13 +149,13 @@ jobs:
BUILD_NUMBER: ${{ github.run_id }}
- name: Upload app msix package
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: focalboard.msix
path: ${{ github.workspace }}/win-wpf/focalboard.msix
- name: Upload app zip package
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: focalboard-win.zip
path: ${{ github.workspace }}/win-wpf/dist/focalboard-win.zip
@ -165,7 +165,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Replace token 1 server
run: sed -i -e "s,placeholder_rudder_dataplane_url,${{ secrets.RUDDER_DATAPLANE_URL }},g" ${{ github.workspace }}/server/services/telemetry/telemetry.go
@ -183,12 +183,12 @@ jobs:
run: cd webapp; npm ci --no-optional
- name: Set up Go
uses: actions/setup-go@v2
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18.1
- name: Set up Node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 16.1.0
@ -207,7 +207,7 @@ jobs:
run: cd mattermost-plugin/dist; mv focalboard-*.tar.gz mattermost-plugin-focalboard.tar.gz
- name: Upload plugin artifact
uses: actions/upload-artifact@v1
uses: actions/upload-artifact@v3
with:
name: mattermost-plugin-focalboard.tar.gz
path: ${{ github.workspace }}/mattermost-plugin/dist/mattermost-plugin-focalboard.tar.gz

6
.gitignore vendored
View File

@ -18,6 +18,10 @@ pids
.vscode
*.code-workspace
# golang
go.work
go.work.sum
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
@ -67,3 +71,5 @@ mattermost-plugin/dist
.idea
docker/certs
docker/data
server/**/*.coverage
mattermost-plugin/**/*.coverage

View File

@ -4,7 +4,7 @@ stages:
variables:
BUILD: "yes"
IMAGE_BUILDER: $CI_REGISTRY/mattermost/ci/images/builder:go-1.16.5-node-16.3.0
IMAGE_BUILDER: $CI_REGISTRY/mattermost/ci/images/builder:go-1.18.1-node-16.15.0-1
include:
- project: mattermost/ci/focalboard

1
.gitpod.yml Normal file
View File

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

View File

@ -16,7 +16,7 @@ RUN npm install --no-optional
RUN npm run pack
# build backend and package
FROM golang:1.16.5@sha256:3ba07778b0a48cef0820fe630220089b74ac9bd06a92ac1cf7b2f1abceffcdaa AS backend
FROM golang:1.18.3@sha256:b203dc573d81da7b3176264bfa447bd7c10c9347689be40540381838d75eebef AS backend
COPY . .
COPY --from=frontend /webapp/pack webapp/pack
@ -26,7 +26,7 @@ RUN make server-linux
RUN make server-linux-package-docker
# just hold the packages to output later
FROM alpine:3.12@sha256:d9459083f962de6bd980ae6a05be2a4cf670df6a1d898157bceb420342bec280 AS dist
FROM alpine:3.12@sha256:c75ac27b49326926b803b9ed43bf088bc220d22556de1bc5f72d742c91398f69 AS dist
WORKDIR /dist

View File

@ -18,6 +18,12 @@ LDFLAGS += -X "github.com/mattermost/focalboard/server/model.BuildNumber=$(BUILD
LDFLAGS += -X "github.com/mattermost/focalboard/server/model.BuildDate=$(BUILD_DATE)"
LDFLAGS += -X "github.com/mattermost/focalboard/server/model.BuildHash=$(BUILD_HASH)"
RACE = -race
ifeq ($(OS),Windows_NT)
RACE := ''
endif
# MAC cpu architecture
ifeq ($(shell uname -m),arm64)
MAC_GO_ARCH := arm64
@ -29,13 +35,14 @@ all: webapp server ## Build server and webapp.
prebuild: ## Run prebuild actions (install dependencies etc.).
cd webapp; npm install
cd mattermost-plugin/webapp; npm install
ci: server-test
cd webapp; npm run check
cd webapp; npm run test
cd webapp; npm run cypress:ci
ci: webapp-ci server-test ## Simulate CI, locally.
templates-archive: ## Build templates archive file
setup-go-work: ## Sets up a go.work file
go run ./mattermost-plugin/build/gowork/main.go
templates-archive: setup-go-work ## Build templates archive file
cd server/assets/build-template-archive; go run -tags '$(BUILD_TAGS)' main.go --dir="../templates-boardarchive" --out="../templates.boardarchive"
server: templates-archive ## Build server for local environment.
@ -45,7 +52,12 @@ server: templates-archive ## Build server for local environment.
server-mac: templates-archive ## Build server for Mac.
mkdir -p bin/mac
$(eval LDFLAGS += -X "github.com/mattermost/focalboard/server/model.Edition=mac")
ifeq ($(FB_PROD),)
cd server; env GOOS=darwin GOARCH=$(MAC_GO_ARCH) go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -o ../bin/mac/focalboard-server ./main
else
# Always build x86 for production, to work on both Apple Silicon and legacy Macs
cd server; env GOOS=darwin GOARCH=amd64 CGO_ENABLED=1 go build -ldflags '$(LDFLAGS)' -tags '$(BUILD_TAGS)' -o ../bin/mac/focalboard-server ./main
endif
server-linux: templates-archive ## Build server for Linux.
mkdir -p bin/linux
@ -115,41 +127,55 @@ watch-server-test: modd-precheck ## Run server tests watching for changes
server-test: server-test-sqlite server-test-mysql server-test-postgres ## Run server tests
server-test-sqlite: export FB_UNIT_TESTING=1
server-test-sqlite: export FOCALBOARD_UNIT_TESTING=1
server-test-sqlite: templates-archive ## Run server tests using sqlite
cd server; go test -tags '$(BUILD_TAGS)' -race -v -count=1 -timeout=30m ./...
cd server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=server-sqlite-profile.coverage -count=1 -timeout=30m ./...
cd server; go tool cover -func server-sqlite-profile.coverage
server-test-mini-sqlite: export FB_UNIT_TESTING=1
server-test-mini-sqlite: export FOCALBOARD_UNIT_TESTING=1
server-test-mini-sqlite: templates-archive ## Run server tests using sqlite
cd server/integrationtests; go test -tags '$(BUILD_TAGS)' -race -v -count=1 -timeout=30m ./...
cd server/integrationtests; go test -tags '$(BUILD_TAGS)' $(RACE) -v -count=1 -timeout=30m ./...
server-test-mysql: export FB_UNIT_TESTING=1
server-test-mysql: export FB_STORE_TEST_DB_TYPE=mysql
server-test-mysql: export FB_STORE_TEST_DOCKER_PORT=44445
server-test-mysql: export FOCALBOARD_UNIT_TESTING=1
server-test-mysql: export FOCALBOARD_STORE_TEST_DB_TYPE=mysql
server-test-mysql: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44445
server-test-mysql: templates-archive ## Run server tests using mysql
@echo Starting docker container for mysql
docker-compose -f ./docker-testing/docker-compose-mysql.yml down -v --remove-orphans
docker-compose -f ./docker-testing/docker-compose-mysql.yml run start_dependencies
cd server; go test -tags '$(BUILD_TAGS)' -race -v -count=1 -timeout=30m ./...
cd server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=server-mysql-profile.coverage -count=1 -timeout=30m ./...
cd server; go tool cover -func server-mysql-profile.coverage
cd mattermost-plugin/server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=plugin-mysql-profile.coverage -count=1 -timeout=30m ./...
cd mattermost-plugin/server; go tool cover -func plugin-mysql-profile.coverage
docker-compose -f ./docker-testing/docker-compose-mysql.yml down -v --remove-orphans
server-test-postgres: export FB_UNIT_TESTING=1
server-test-postgres: export FB_STORE_TEST_DB_TYPE=postgres
server-test-postgres: export FB_STORE_TEST_DOCKER_PORT=44446
server-test-postgres: export FOCALBOARD_UNIT_TESTING=1
server-test-postgres: export FOCALBOARD_STORE_TEST_DB_TYPE=postgres
server-test-postgres: export FOCALBOARD_STORE_TEST_DOCKER_PORT=44446
server-test-postgres: templates-archive ## Run server tests using postgres
@echo Starting docker container for postgres
docker-compose -f ./docker-testing/docker-compose-postgres.yml down -v --remove-orphans
docker-compose -f ./docker-testing/docker-compose-postgres.yml run start_dependencies
cd server; go test -tags '$(BUILD_TAGS)' -race -v -count=1 -timeout=30m ./...
cd server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=server-postgres-profile.coverage -count=1 -timeout=30m ./...
cd server; go tool cover -func server-postgres-profile.coverage
cd mattermost-plugin/server; go test -tags '$(BUILD_TAGS)' -race -v -coverpkg=./... -coverprofile=plugin-postgres-profile.coverage -count=1 -timeout=30m ./...
cd mattermost-plugin/server; go tool cover -func plugin-postgres-profile.coverage
docker-compose -f ./docker-testing/docker-compose-postgres.yml down -v --remove-orphans
webapp: ## Build webapp.
cd webapp; npm run pack
webapp-ci: ## Webapp CI: linting & testing.
cd webapp; npm run check
cd mattermost-plugin/webapp; npm run lint
cd webapp; npm run test
cd mattermost-plugin/webapp; npm run test
cd webapp; npm run cypress:ci
webapp-test: ## jest tests for webapp
cd webapp; npm run test

View File

@ -16,28 +16,28 @@ Like what you see? :eyes: Give us a GitHub Star! :star:
It helps define, organize, track and manage work across individuals and teams. Focalboard comes in two main editions:
* **[Personal Desktop](https://www.focalboard.com/download/personal-edition/desktop/)**: A standalone, single-user Mac, Windows, or Linux desktop app for your own todos and personal projects.
* **[Mattermost Boards](https://www.focalboard.com/download/mattermost/)**: A self-hosted or **[free cloud server](https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboard)** for your team to plan and collaborate.
* **[Mattermost Boards](https://www.focalboard.com/download/mattermost/)**: A self-hosted or cloud server for your team to plan and collaborate.
* **[Personal Desktop](https://www.focalboard.com/download/personal-edition/desktop/)**: A standalone, single-user [Mac](https://apps.apple.com/app/apple-store/id1556908618?pt=2114704&ct=website&mt=8), [Windows](https://www.microsoft.com/store/apps/9NLN2T0SX9VF?cid=website), or [Linux](https://www.focalboard.com/download/personal-edition/desktop/#linux-desktop) desktop app for your own todos and personal projects.
Focalboard can also be installed as a standalone **[Personal Server](https://www.focalboard.com/download/personal-edition/ubuntu/)** for development and personal use.
## Try Focalboard
### Mattermost Boards - [now available as a free cloud server](https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboard)
**Mattermost Boards** combines project management tools with messaging and collaboration for teams of all sizes. To access and use **Mattermost Boards**, install or upgrade to Mattermost v6.0 or later as a [self-hosted server](https://docs.mattermost.com/guides/deployment.html?utm_source=focalboard&utm_campaign=focalboard) or [Cloud server](https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboard). After logging into Mattermost, select the menu in the top left corner and select **Boards**.
***Mattermost Boards** is installed and enabled by default in Mattermost v6.0 and later.*
See the [plugin setup guide](https://www.focalboard.com/download/mattermost/) for more details.
### Personal Desktop (Windows, Mac or Linux Desktop)
* **Windows**: Download from the [Windows App Store](https://www.microsoft.com/store/productId/9NLN2T0SX9VF) or download `focalboard-win.zip` from the [latest release](https://github.com/mattermost/focalboard/releases), unpack, and run `Focalboard.exe`.
* **Mac**: Download from the [Mac App Store](https://apps.apple.com/us/app/focalboard-insiders/id1556908618?mt=12).
* **Linux Desktop**: Download `focalboard-linux.tar.gz` from the [latest release](https://github.com/mattermost/focalboard/releases), unpack, and open `focalboard-app`.
### Mattermost Boards
**Mattermost Boards** is the Mattermost plugin version of Focalboard that combines project management tools with messaging and collaboration for teams of all sizes. To access and use **Mattermost Boards**, install or upgrade to Mattermost v6.0 or later as a [self-hosted server](https://docs.mattermost.com/guides/deployment.html?utm_source=focalboard&utm_campaign=focalboard) or [Cloud server](https://mattermost.com/get-started/?utm_source=focalboard&utm_campaign=focalboard). After logging into Mattermost, select the menu in the top left corner and select **Boards**.
***Mattermost Boards** is installed and enabled by default in Mattermost v6.0 and later.*
See the [plugin setup guide](https://www.focalboard.com/download/mattermost/) for more details.
### Personal Server
**Ubuntu**: You can download and run the compiled Focalboard **Personal Server** on Ubuntu by following [our latest install guide](https://www.focalboard.com/download/personal-edition/ubuntu/).
@ -74,18 +74,27 @@ You can build standalone apps that package the server to run locally against SQL
* **Windows**:
* *Requires Windows 10, [Windows 10 SDK](https://developer.microsoft.com/en-us/windows/downloads/sdk-archive/) 10.0.19041.0, and .NET 4.8 developer pack*
* Open a `git-bash` prompt.
* Run `make prebuild`
* The above prebuild step needs to be run only when you make changes to or want to install your npm dependencies, etc.
* Once the prebuild is completed, you can keep repeating the below steps to build the app & see the changes.
* Run `make win-wpf-app`
* Run `cd win-wpf/msix && focalboard.exe`
* **Mac**:
* *Requires macOS 11.3+ and Xcode 13.2.1+*
* `make mac-app`
* `open mac/dist/Focalboard.app`
* Run `make prebuild`
* The above prebuild step needs to be run only when you make changes to or want to install your npm dependencies, etc.
* Once the prebuild is completed, you can keep repeating the below steps to build the app & see the changes.
* Run `make mac-app`
* Run `open mac/dist/Focalboard.app`
* **Linux**:
* *Tested on Ubuntu 18.04*
* Install `webgtk` dependencies
* `sudo apt-get install libgtk-3-dev`
* `sudo apt-get install libwebkit2gtk-4.0-dev`
* `make linux-app`
* Run `sudo apt-get install libgtk-3-dev`
* Run `sudo apt-get install libwebkit2gtk-4.0-dev`
* Run `make prebuild`
* The above prebuild step needs to be run only when you make changes to or want to install your npm dependencies, etc.
* Once the prebuild is completed, you can keep repeating the below steps to build the app & see the changes.
* Run `make linux-app`
* Uncompress `linux/dist/focalboard-linux.tar.gz` to a directory of your choice
* Run `focalboard-app` from the directory you have chosen
* **Docker**:

View File

@ -8,7 +8,7 @@ RUN npm install --no-optional && \
npm run pack
### Go build
FROM golang:1.16.5@sha256:3ba07778b0a48cef0820fe630220089b74ac9bd06a92ac1cf7b2f1abceffcdaa as gobuild
FROM golang:1.18.3@sha256:b203dc573d81da7b3176264bfa447bd7c10c9347689be40540381838d75eebef AS gobuild
WORKDIR /go/src/focalboard
ADD . /go/src/focalboard

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,110 @@
module github.com/mattermost/focalboard/linux
go 1.16
go 1.18
replace github.com/mattermost/focalboard/server => ../server
require (
github.com/google/uuid v1.3.0
github.com/mattermost/focalboard/server v0.0.0-20220325164658-33557093b00d
github.com/mattermost/mattermost-server/v6 v6.5.0
github.com/mattermost/focalboard/server v0.0.0-00010101000000-000000000000
github.com/mattermost/mattermost-server/v6 v6.0.0-20220711175838-7ee7523729e6
github.com/webview/webview v0.0.0-20220314230258-a2b7746141c3
)
require (
github.com/Masterminds/squirrel v1.5.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/graph-gophers/graphql-go v1.4.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.15.6 // indirect
github.com/klauspost/cpuid/v2 v2.0.13 // indirect
github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lib/pq v1.10.6 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect
github.com/mattermost/logr/v2 v2.0.15 // indirect
github.com/mattermost/mattermost-plugin-api v0.0.28-0.20220623051512-0afd85e854d4 // indirect
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.28 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/pborman/uuid v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/philhofer/fwd v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.33.0 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/rs/xid v1.4.0 // indirect
github.com/rudderlabs/analytics-go v3.3.2+incompatible // indirect
github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/afero v1.8.2 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.10.1 // indirect
github.com/stretchr/testify v1.7.2 // indirect
github.com/subosito/gotenv v1.2.0 // indirect
github.com/tidwall/gjson v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tinylib/msgp v1.1.6 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wiggin77/merror v1.0.3 // indirect
github.com/wiggin77/srslog v1.0.1 // indirect
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
github.com/yuin/goldmark v1.4.12 // indirect
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.0.0-20220614195744-fb05da6f9022 // indirect
golang.org/x/sys v0.0.0-20220614162138-6c1b26c55098 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.11 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/ini.v1 v1.66.6 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.35.24 // indirect
modernc.org/ccgo/v3 v3.15.17 // indirect
modernc.org/libc v1.14.12 // indirect
modernc.org/mathutil v1.4.1 // indirect
modernc.org/memory v1.0.7 // indirect
modernc.org/opt v0.1.1 // indirect
modernc.org/sqlite v1.15.3 // indirect
modernc.org/strutil v1.1.1 // indirect
modernc.org/token v1.0.0 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -61,7 +61,8 @@ func runServer(port int) (*server.Server, error) {
AuthMode: "native",
}
db, err := server.NewStore(config, logger)
singleUser := len(sessionToken) > 0
db, err := server.NewStore(config, singleUser, logger)
if err != nil {
fmt.Println("ERROR INITIALIZING THE SERVER STORE", err)
return nil, err

View File

@ -160,7 +160,7 @@
80D6DEB3252E13CB00AEED9E /* Sources */,
80D6DEB4252E13CB00AEED9E /* Frameworks */,
80D6DEB5252E13CB00AEED9E /* Resources */,
80D6DF1D25324A8100AEED9E /* ShellScript */,
80D6DF1D25324A8100AEED9E /* Run Script */,
);
buildRules = (
);
@ -278,7 +278,7 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
80D6DF1D25324A8100AEED9E /* ShellScript */ = {
80D6DF1D25324A8100AEED9E /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -287,6 +287,7 @@
);
inputPaths = (
);
name = "Run Script";
outputFileListPaths = (
);
outputPaths = (
@ -480,7 +481,7 @@
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = HFP57A3MYB;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Focalboard/Info.plist;
@ -488,6 +489,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 7.3;
PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.focalboard;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -504,7 +506,7 @@
CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Manual;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 33;
DEVELOPMENT_TEAM = HFP57A3MYB;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = Focalboard/Info.plist;
@ -512,6 +514,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 7.3;
PRODUCT_BUNDLE_IDENTIFIER = com.mattermost.focalboard;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -66,6 +66,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
vc.showWindow(self)
}
@IBAction func getCloudServer(_: AnyObject) {
Globals.openGetCloudServerUrl()
}
private func webFolder() -> URL {
let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return url.appendingPathComponent("Focalboard").appendingPathComponent("server")

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="19529" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="20037" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" initialViewController="B8D-0N-5wS">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="19529"/>
<plugIn identifier="com.apple.WebKit2IBPlugin" version="19529"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="20037"/>
<plugIn identifier="com.apple.WebKit2IBPlugin" version="20037"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
@ -29,6 +29,12 @@
<action selector="showWhatsNew:" target="Ady-hI-5gd" id="dlB-6q-7bK"/>
</connections>
</menuItem>
<menuItem title="Get Mattermost Boards server" id="8YW-NP-PAh">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="getCloudServer:" target="Ady-hI-5gd" id="OvY-fG-Tqt"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
<menuItem isSeparatorItem="YES" id="wFC-TO-SCJ"/>
@ -942,17 +948,17 @@ Thanks so much,
<autoresizingMask key="autoresizingMask"/>
</scroller>
</scrollView>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="eyp-ee-dxf">
<rect key="frame" x="7" y="7" width="289" height="32"/>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="WZS-cI-X3m">
<rect key="frame" x="13" y="7" width="314" height="32"/>
<constraints>
<constraint firstAttribute="width" constant="275" id="Zzi-QI-UrX"/>
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="300" id="tZN-oK-pDS"/>
</constraints>
<buttonCell key="cell" type="push" title="Rate Focalboard" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="7Kt-FU-UP3">
<buttonCell key="cell" type="push" title="Setup free Mattermost Boards server" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="XJY-Vn-Z2s">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="rateButtonClicked:" target="aYX-zT-OJj" id="QqZ-am-cnJ"/>
<action selector="cloudButtonClicked:" target="Fag-zV-Usm" id="ju3-LN-Zq9"/>
</connections>
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="dYB-Yp-UHf">
@ -968,13 +974,28 @@ Thanks so much,
<action selector="closeButtonClicked:" target="aYX-zT-OJj" id="run-tB-6Tb"/>
</connections>
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="eyp-ee-dxf">
<rect key="frame" x="325" y="7" width="164" height="32"/>
<constraints>
<constraint firstAttribute="width" constant="150" id="Zzi-QI-UrX"/>
</constraints>
<buttonCell key="cell" type="push" title="Rate Focalboard" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="7Kt-FU-UP3">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="rateButtonClicked:" target="aYX-zT-OJj" id="QqZ-am-cnJ"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="WZS-cI-X3m" firstAttribute="top" secondItem="baY-7A-lBA" secondAttribute="bottom" constant="20" symbolic="YES" id="29y-HQ-Ik9"/>
<constraint firstItem="WZS-cI-X3m" firstAttribute="leading" secondItem="fy7-95-Eie" secondAttribute="leading" constant="20" symbolic="YES" id="EMs-BU-zTE"/>
<constraint firstAttribute="bottom" secondItem="eyp-ee-dxf" secondAttribute="bottom" constant="14" id="Kvc-VM-O1W"/>
<constraint firstAttribute="trailing" secondItem="dYB-Yp-UHf" secondAttribute="trailing" constant="32" id="SQ6-Hs-a1n"/>
<constraint firstItem="eyp-ee-dxf" firstAttribute="leading" secondItem="fy7-95-Eie" secondAttribute="leading" constant="14" id="TTF-mb-cZY"/>
<constraint firstItem="eyp-ee-dxf" firstAttribute="top" secondItem="baY-7A-lBA" secondAttribute="bottom" constant="20" symbolic="YES" id="UIA-ji-xOe"/>
<constraint firstAttribute="trailing" secondItem="baY-7A-lBA" secondAttribute="trailing" constant="20" id="cRi-Tx-jtT"/>
<constraint firstItem="eyp-ee-dxf" firstAttribute="leading" secondItem="WZS-cI-X3m" secondAttribute="trailing" constant="12" symbolic="YES" id="eKo-Qk-lxl"/>
<constraint firstAttribute="bottom" secondItem="dYB-Yp-UHf" secondAttribute="bottom" constant="14" id="grP-Cj-Euz"/>
<constraint firstItem="baY-7A-lBA" firstAttribute="leading" secondItem="fy7-95-Eie" secondAttribute="leading" constant="20" id="mxh-wg-iXP"/>
<constraint firstItem="baY-7A-lBA" firstAttribute="top" secondItem="fy7-95-Eie" secondAttribute="top" constant="20" id="oAR-aZ-gNJ"/>
@ -982,6 +1003,7 @@ Thanks so much,
</constraints>
</view>
<connections>
<outlet property="cloudButton" destination="WZS-cI-X3m" id="4ry-4a-zGW"/>
<outlet property="rateButton" destination="eyp-ee-dxf" id="cvh-x3-TPU"/>
<outlet property="textView" destination="f3s-Q5-6IG" id="nbt-zY-bAo"/>
</connections>

View File

@ -2,13 +2,19 @@
// See LICENSE.txt for license information.
import Foundation
import Cocoa
class Globals {
static let ProductVersion = 01600
static let WhatsNewVersion = 01100
static let ProductVersion = 70000
static let WhatsNewVersion = 70000
static var currentWhatsNewVersion: Int {
get { return UserDefaults.standard.integer(forKey: "whatsNewVersion") }
set { UserDefaults.standard.setValue(newValue, forKey: "whatsNewVersion") }
}
static func openGetCloudServerUrl() {
let url = URL(string: "https://mattermost.com/sign-up/?utm_source=focalboard&utm_campaign=focalboardapp")!
NSWorkspace.shared.open(url)
}
}

View File

@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.16.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@ -7,6 +7,7 @@ class WhatsNewViewController:
NSViewController {
@IBOutlet var textView: NSTextView!
@IBOutlet var rateButton: NSButton!
@IBOutlet var cloudButton: NSButton!
override func viewDidLoad() {
super.viewDidLoad()
@ -28,6 +29,10 @@ class WhatsNewViewController:
view.window?.close()
}
@IBAction func cloudButtonClicked(_ sender: Any) {
Globals.openGetCloudServerUrl()
}
@IBAction func closeButtonClicked(_ sender: Any) {
view.window?.close()
}

View File

@ -1,6 +1,12 @@
Welcome to Focalboard v0.11.0!
Welcome to Focalboard v7.2!
Thank you contributors! There are a number of improvements in this release, and one major new feature:
Mattermost Boards is now availalbe as a Cloud service. Set up your free server via the button below to collaborate with your team.
Mattermost Boards combines all the features of Focalboard with real-time collaboration, calls, and playbooks.
You can export boards from Focalboard and import them into Mattermost Boards, and pick up where you left off.
Thank you contributors! Recent improvements include:
* Calendar view. Thanks @sbishel!

View File

@ -67,6 +67,7 @@ linters:
- unconvert
- unused
- whitespace
- gocyclo
issues:
exclude-rules:

View File

@ -60,6 +60,9 @@ all: check-style test dist
apply:
./build/bin/manifest apply
setup-go-work: ## Sets up a go.work file
cd ..; go run ./mattermost-plugin/build/gowork/main.go
## Runs eslint and golangci-lint
.PHONY: check-style
check-style: webapp/node_modules
@ -80,7 +83,7 @@ ifneq ($(HAS_SERVER),)
golangci-lint run ./...
endif
templates-archive: ## Build templates archive file
templates-archive: setup-go-work ## Build templates archive file
cd ../server/assets/build-template-archive; go run -tags '$(BUILD_TAGS)' main.go --dir="../templates-boardarchive" --out="../templates.boardarchive"
## Builds the server, if it exists, for all supported architectures.
@ -214,7 +217,7 @@ detach: setup-attach
## Runs any lints and unit tests defined for the server and webapp, if they exist.
.PHONY: test
test: export FB_UNIT_TESTING=1
test: export FOCALBOARD_UNIT_TESTING=1
test: webapp/node_modules
ifneq ($(HAS_SERVER),)
$(GO) test -v $(GO_TEST_FLAGS) ./server/...

View File

@ -1,11 +1,63 @@
module github.com/mattermost/mattermost-plugin-starter-template/build
go 1.12
go 1.18
require (
github.com/go-git/go-git/v5 v5.1.0
github.com/mattermost/mattermost-server/v6 v6.0.0-20210817091833-04b27ce93c02
github.com/mattermost/mattermost-server/v6 v6.0.0-20220705131644-b99bd0d04915
github.com/pkg/errors v0.9.1
github.com/stretchr/testify v1.7.0
github.com/stretchr/testify v1.7.2
sigs.k8s.io/yaml v1.2.0
)
require (
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect
github.com/emirpasic/gods v1.12.0 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
github.com/go-git/go-billy/v5 v5.0.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/graph-gophers/graphql-go v1.4.0 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd // indirect
github.com/klauspost/compress v1.15.6 // indirect
github.com/klauspost/cpuid/v2 v2.0.13 // indirect
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect
github.com/mattermost/logr/v2 v2.0.15 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.28 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pborman/uuid v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/philhofer/fwd v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/xid v1.4.0 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/tinylib/msgp v1.1.6 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wiggin77/merror v1.0.3 // indirect
github.com/wiggin77/srslog v1.0.1 // indirect
github.com/xanzy/ssh-agent v0.2.1 // indirect
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
golang.org/x/net v0.0.0-20220614195744-fb05da6f9022 // indirect
golang.org/x/sys v0.0.0-20220614162138-6c1b26c55098 // indirect
golang.org/x/text v0.3.7 // indirect
gopkg.in/ini.v1 v1.66.6 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,93 @@
package main
import (
"fmt"
"os"
"strings"
)
const (
filename = "go.work"
)
func main() {
force := false
if len(os.Args) == 2 && strings.ToLower(os.Args[1]) == "-f" {
force = true
}
if _, err := os.Stat(filename); err == nil && !force {
// go.work already exists and force flag not specified
fmt.Fprintln(os.Stdout, "go.work already exists and -f (force) not specified; nothing to do.")
os.Exit(0)
}
f, err := os.Create(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "error creating %s: %s", filename, err.Error())
os.Exit(-1)
}
defer f.Close()
isCI := isCI()
content := makeGoWork(isCI)
_, err = f.WriteString(content)
if err != nil {
fmt.Fprintf(os.Stderr, "error writing %s: %s", filename, err.Error())
os.Exit(-1)
}
fmt.Fprintln(os.Stdout, "go.work written successfully.")
}
func makeGoWork(ci bool) string {
var b strings.Builder
b.WriteString("go 1.18\n\n")
b.WriteString("use ./mattermost-plugin\n")
b.WriteString("use ./server\n")
if ci {
b.WriteString("use ./linux\n")
} else {
b.WriteString("use ../mattermost-server\n")
b.WriteString("use ../enterprise\n")
}
return b.String()
}
func isCI() bool {
vars := map[string]bool{
// var name: must_be_true (false means being defined is enough)
"CIRCLECI": true,
"GITHUB_ACTIONS": true,
"GITLAB_CI": false,
"TRAVIS": true,
}
for name, mustBeTrue := range vars {
if isEnvVarTrue(name, mustBeTrue) {
return true
}
}
return false
}
func isEnvVarTrue(name string, mustBeTrue bool) bool {
val, ok := os.LookupEnv(name)
if !ok {
return false
}
if !mustBeTrue {
return true
}
switch strings.ToLower(val) {
case "t", "1", "true", "y", "yes":
return true
}
return false
}

View File

@ -1,14 +1,202 @@
module github.com/mattermost/focalboard/mattermost-plugin
go 1.16
replace github.com/mattermost/focalboard/server => ../server
replace github.com/mattermost/focalboard/server/server => ../server/server
go 1.18
require (
github.com/golang/mock v1.6.0
github.com/mattermost/focalboard/server v0.0.0-20220325164658-33557093b00d
github.com/mattermost/mattermost-plugin-api v0.0.27
github.com/mattermost/mattermost-server/v6 v6.5.0
github.com/stretchr/testify v1.7.1
github.com/mattermost/mattermost-plugin-api v0.0.28-0.20220623051512-0afd85e854d4
github.com/stretchr/testify v1.7.2
)
require (
code.sajari.com/docconv v1.2.0 // indirect
github.com/JalfResi/justext v0.0.0-20170829062021-c0282dea7198 // indirect
github.com/Masterminds/semver/v3 v3.1.1 // indirect
github.com/Masterminds/squirrel v1.5.2 // indirect
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/RoaringBitmap/roaring v1.2.1 // indirect
github.com/advancedlogic/GoOse v0.0.0-20210820140952-9d5822d4a625 // indirect
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
github.com/avct/uasurfer v0.0.0-20191028135549-26b5daa857f1 // indirect
github.com/aws/aws-sdk-go v1.44.34 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.2.2 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/blevesearch/bleve/v2 v2.3.2 // indirect
github.com/blevesearch/bleve_index_api v1.0.2 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.1.0 // indirect
github.com/blevesearch/segment v0.9.0 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.1 // indirect
github.com/blevesearch/vellum v1.0.8 // indirect
github.com/blevesearch/zapx/v11 v11.3.4 // indirect
github.com/blevesearch/zapx/v12 v12.3.4 // indirect
github.com/blevesearch/zapx/v13 v13.3.4 // indirect
github.com/blevesearch/zapx/v14 v14.3.4 // indirect
github.com/blevesearch/zapx/v15 v15.3.4 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
github.com/dgryski/dgoogauth v0.0.0-20190221195224-5a805980a5f3 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/fatih/set v0.2.1 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/francoispqt/gojay v1.2.13 // indirect
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/getsentry/sentry-go v0.13.0 // indirect
github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/go-resty/resty/v2 v2.7.0 // indirect
github.com/go-sql-driver/mysql v1.6.0 // indirect
github.com/golang-migrate/migrate/v4 v4.15.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/gorilla/handlers v1.5.1 // indirect
github.com/gorilla/mux v1.8.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/graph-gophers/graphql-go v1.4.0 // indirect
github.com/h2non/go-is-svg v0.0.0-20160927212452-35e8c4b0612c // indirect
github.com/hashicorp/go-hclog v1.2.1 // indirect
github.com/hashicorp/go-plugin v1.4.4 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jmoiron/sqlx v1.3.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.15.6 // indirect
github.com/klauspost/cpuid/v2 v2.0.13 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 // indirect
github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 // indirect
github.com/lib/pq v1.10.6 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect
github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect
github.com/mattermost/logr/v2 v2.0.15 // indirect
github.com/mattermost/mattermost-server/v6 v6.0.0-20220711175838-7ee7523729e6 // indirect
github.com/mattermost/morph v0.0.0-20220401091636-39f834798da8 // indirect
github.com/mattermost/rsc v0.0.0-20160330161541-bbaefb05eaa0 // indirect
github.com/mattermost/squirrel v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mholt/archiver/v3 v3.5.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.18 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.28 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/mapstructure v1.4.3 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/otiai10/gosseract/v2 v2.3.1 // indirect
github.com/pborman/uuid v1.2.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/philhofer/fwd v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.12.1 // indirect
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.33.0 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/reflog/dateconstraints v0.2.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.3 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rs/cors v1.8.2 // indirect
github.com/rs/xid v1.4.0 // indirect
github.com/rudderlabs/analytics-go v3.3.2+incompatible // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/spf13/afero v1.8.2 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.10.1 // indirect
github.com/splitio/go-client/v6 v6.1.0 // indirect
github.com/splitio/go-split-commons/v3 v3.1.0 // indirect
github.com/splitio/go-toolkit/v4 v4.2.0 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/stretchr/objx v0.4.0 // indirect
github.com/subosito/gotenv v1.2.0 // indirect
github.com/throttled/throttled v2.2.5+incompatible // indirect
github.com/tidwall/gjson v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tinylib/msgp v1.1.6 // indirect
github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
github.com/uber/jaeger-lib v2.4.1+incompatible // indirect
github.com/ulikunitz/xz v0.5.10 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/wiggin77/merror v1.0.3 // indirect
github.com/wiggin77/srslog v1.0.1 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
github.com/yuin/goldmark v1.4.12 // indirect
go.etcd.io/bbolt v1.3.6 // indirect
go.uber.org/atomic v1.9.0 // indirect
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
golang.org/x/image v0.0.0-20220601225756-64ec528b34cd // indirect
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
golang.org/x/net v0.0.0-20220614195744-fb05da6f9022 // indirect
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f // indirect
golang.org/x/sys v0.0.0-20220614162138-6c1b26c55098 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/tools v0.1.11 // indirect
google.golang.org/genproto v0.0.0-20220614165028-45ed7f3ff16e // indirect
google.golang.org/grpc v1.47.0 // indirect
google.golang.org/protobuf v1.28.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/ini.v1 v1.66.6 // indirect
gopkg.in/mail.v2 v2.3.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.35.24 // indirect
modernc.org/ccgo/v3 v3.15.17 // indirect
modernc.org/libc v1.14.12 // indirect
modernc.org/mathutil v1.4.1 // indirect
modernc.org/memory v1.0.7 // indirect
modernc.org/opt v0.1.1 // indirect
modernc.org/sqlite v1.15.3 // indirect
modernc.org/strutil v1.1.1 // indirect
modernc.org/token v1.0.0 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,8 @@
"support_url": "https://github.com/mattermost/focalboard/issues",
"release_notes_url": "https://github.com/mattermost/focalboard/releases",
"icon_path": "assets/starter-template-icon.svg",
"version": "0.16.0",
"min_server_version": "6.0.0",
"version": "7.3.0",
"min_server_version": "7.0.0",
"server": {
"executables": {
"linux-amd64": "server/dist/plugin-linux-amd64",

View File

@ -0,0 +1,209 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package product
import (
"database/sql"
"github.com/mattermost/mattermost-server/v6/app/request"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/mattermost/focalboard/server/model"
)
// normalizeAppError returns a truly nil error if appErr is nil
// See https://golang.org/doc/faq#nil_error for more details.
func normalizeAppErr(appErr *mm_model.AppError) error {
if appErr == nil {
return nil
}
return appErr
}
// serviceAPIAdapter is an adapter that flattens the APIs provided by suite services so they can
// be used as per the Plugin API.
// Note: when supporting a plugin build is no longer needed this adapter may be removed as the Boards app
// can be modified to use the services in modular fashion.
type serviceAPIAdapter struct {
api *boardsProduct
ctx *request.Context
}
func newServiceAPIAdapter(api *boardsProduct) *serviceAPIAdapter {
return &serviceAPIAdapter{
api: api,
ctx: &request.Context{},
}
}
//
// Channels service.
//
func (a *serviceAPIAdapter) GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) {
channel, appErr := a.api.channelService.GetDirectChannel(userID1, userID2)
return channel, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetChannelByID(channelID string) (*mm_model.Channel, error) {
channel, appErr := a.api.channelService.GetChannelByID(channelID)
return channel, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) {
member, appErr := a.api.channelService.GetChannelMember(channelID, userID)
return member, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetChannelsForTeamForUser(teamID string, userID string, includeDeleted bool) (mm_model.ChannelList, error) {
opts := &mm_model.ChannelSearchOpts{
IncludeDeleted: includeDeleted,
}
channels, appErr := a.api.channelService.GetChannelsForTeamForUser(teamID, userID, opts)
return channels, normalizeAppErr(appErr)
}
//
// Post service.
//
func (a *serviceAPIAdapter) CreatePost(post *mm_model.Post) (*mm_model.Post, error) {
post, appErr := a.api.postService.CreatePost(a.ctx, post)
return post, normalizeAppErr(appErr)
}
//
// User service.
//
func (a *serviceAPIAdapter) GetUserByID(userID string) (*mm_model.User, error) {
user, appErr := a.api.userService.GetUser(userID)
return user, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetUserByUsername(name string) (*mm_model.User, error) {
user, appErr := a.api.userService.GetUserByUsername(name)
return user, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetUserByEmail(email string) (*mm_model.User, error) {
user, appErr := a.api.userService.GetUserByEmail(email)
return user, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) UpdateUser(user *mm_model.User) (*mm_model.User, error) {
user, appErr := a.api.userService.UpdateUser(user, true)
return user, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error) {
user, appErr := a.api.userService.GetUsersFromProfiles(options)
return user, normalizeAppErr(appErr)
}
//
// Team service.
//
func (a *serviceAPIAdapter) GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) {
member, appErr := a.api.teamService.GetMember(teamID, userID)
return member, normalizeAppErr(appErr)
}
func (a *serviceAPIAdapter) CreateMember(teamID string, userID string) (*mm_model.TeamMember, error) {
member, appErr := a.api.teamService.CreateMember(a.ctx, teamID, userID)
return member, normalizeAppErr(appErr)
}
//
// Permissions service.
//
func (a *serviceAPIAdapter) HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool {
return a.api.permissionsService.HasPermissionToTeam(userID, teamID, permission)
}
func (a *serviceAPIAdapter) HasPermissionToChannel(askingUserID string, channelID string, permission *mm_model.Permission) bool {
return a.api.permissionsService.HasPermissionToChannel(askingUserID, channelID, permission)
}
//
// Bot service.
//
func (a *serviceAPIAdapter) EnsureBot(bot *mm_model.Bot) (string, error) {
return a.api.botService.EnsureBot(a.ctx, boardsProductID, bot)
}
//
// License service.
//
func (a *serviceAPIAdapter) GetLicense() *mm_model.License {
return a.api.licenseService.GetLicense()
}
//
// FileInfoStore service.
//
func (a *serviceAPIAdapter) GetFileInfo(fileID string) (*mm_model.FileInfo, error) {
fi, appErr := a.api.fileInfoStoreService.GetFileInfo(fileID)
return fi, normalizeAppErr(appErr)
}
//
// Cluster store.
//
func (a *serviceAPIAdapter) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mm_model.WebsocketBroadcast) {
a.api.clusterService.PublishWebSocketEvent(boardsProductID, event, payload, broadcast)
}
func (a *serviceAPIAdapter) PublishPluginClusterEvent(ev mm_model.PluginClusterEvent, opts mm_model.PluginClusterEventSendOptions) error {
return a.api.clusterService.PublishPluginClusterEvent(boardsProductID, ev, opts)
}
//
// Cloud service.
//
func (a *serviceAPIAdapter) GetCloudLimits() (*mm_model.ProductLimits, error) {
return a.api.cloudService.GetCloudLimits()
}
//
// Config service.
//
func (a *serviceAPIAdapter) GetConfig() *mm_model.Config {
return a.api.configService.Config()
}
//
// Logger service.
//
func (a *serviceAPIAdapter) GetLogger() mlog.LoggerIFace {
return a.api.logger
}
//
// KVStore service.
//
func (a *serviceAPIAdapter) KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, error) {
b, appErr := a.api.kvStoreService.SetPluginKeyWithOptions(boardsProductID, key, value, options)
return b, normalizeAppErr(appErr)
}
//
// Store service.
//
func (a *serviceAPIAdapter) GetMasterDB() (*sql.DB, error) {
return a.api.storeService.GetMasterDB(), nil
}
//
// System service.
//
func (a *serviceAPIAdapter) GetDiagnosticID() string {
return a.api.systemService.GetDiagnosticId()
}
// Ensure the adapter implements ServicesAPI.
var _ model.ServicesAPI = &serviceAPIAdapter{}

View File

@ -0,0 +1,287 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package product
import (
"errors"
"fmt"
"github.com/mattermost/focalboard/mattermost-plugin/server/boards"
"github.com/mattermost/mattermost-server/v6/app"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/product"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
const (
boardsProductName = "boards"
boardsProductID = "com.mattermost.boards"
)
var errServiceTypeAssert = errors.New("type assertion failed")
func init() {
app.RegisterProduct("boards", app.ProductManifest{
Initializer: newBoardsProduct,
Dependencies: map[app.ServiceKey]struct{}{
app.TeamKey: {},
app.ChannelKey: {},
app.UserKey: {},
app.PostKey: {},
app.BotKey: {},
app.ClusterKey: {},
app.ConfigKey: {},
app.LogKey: {},
app.LicenseKey: {},
app.FilestoreKey: {},
app.FileInfoStoreKey: {},
app.RouterKey: {},
app.CloudKey: {},
app.KVStoreKey: {},
app.StoreKey: {},
app.SystemKey: {},
},
})
}
type boardsProduct struct {
teamService product.TeamService
channelService product.ChannelService
userService product.UserService
postService product.PostService
permissionsService product.PermissionService
botService product.BotService
clusterService product.ClusterService
configService product.ConfigService
logger mlog.LoggerIFace
licenseService product.LicenseService
filestoreService product.FilestoreService
fileInfoStoreService product.FileInfoStoreService
routerService product.RouterService
cloudService product.CloudService
kvStoreService product.KVStoreService
storeService product.StoreService
systemService product.SystemService
boardsApp *boards.BoardsApp
}
//nolint:gocyclo
func newBoardsProduct(_ *app.Server, services map[app.ServiceKey]interface{}) (app.Product, error) {
boards := &boardsProduct{}
for key, service := range services {
switch key {
case app.TeamKey:
teamService, ok := service.(product.TeamService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.teamService = teamService
case app.ChannelKey:
channelService, ok := service.(product.ChannelService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.channelService = channelService
case app.UserKey:
userService, ok := service.(product.UserService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.userService = userService
case app.PostKey:
postService, ok := service.(product.PostService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.postService = postService
case app.PermissionsKey:
permissionsService, ok := service.(product.PermissionService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.permissionsService = permissionsService
case app.BotKey:
botService, ok := service.(product.BotService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.botService = botService
case app.ClusterKey:
clusterService, ok := service.(product.ClusterService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.clusterService = clusterService
case app.ConfigKey:
configService, ok := service.(product.ConfigService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.configService = configService
case app.LogKey:
logger, ok := service.(mlog.LoggerIFace)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.logger = logger.With(mlog.String("product", boardsProductName))
case app.LicenseKey:
licenseService, ok := service.(product.LicenseService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.licenseService = licenseService
case app.FilestoreKey:
filestoreService, ok := service.(product.FilestoreService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.filestoreService = filestoreService
case app.FileInfoStoreKey:
fileInfoStoreService, ok := service.(product.FileInfoStoreService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.fileInfoStoreService = fileInfoStoreService
case app.RouterKey:
routerService, ok := service.(product.RouterService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.routerService = routerService
case app.CloudKey:
cloudService, ok := service.(product.CloudService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.cloudService = cloudService
case app.KVStoreKey:
kvStoreService, ok := service.(product.KVStoreService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.kvStoreService = kvStoreService
case app.StoreKey:
storeService, ok := service.(product.StoreService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.storeService = storeService
case app.SystemKey:
systemService, ok := service.(product.SystemService)
if !ok {
return nil, fmt.Errorf("invalid service key '%s': %w", key, errServiceTypeAssert)
}
boards.systemService = systemService
case app.HooksKey: // not needed
}
}
return boards, nil
}
func (bp *boardsProduct) Start() error {
if !bp.configService.Config().FeatureFlags.BoardsProduct {
bp.logger.Info("Boards product disabled via feature flag")
return nil
}
bp.logger.Info("Starting boards service")
adapter := newServiceAPIAdapter(bp)
boardsApp, err := boards.NewBoardsApp(adapter)
if err != nil {
return fmt.Errorf("failed to create Boards service: %w", err)
}
bp.boardsApp = boardsApp
if err := bp.boardsApp.Start(); err != nil {
return fmt.Errorf("failed to start Boards service: %w", err)
}
return nil
}
func (bp *boardsProduct) Stop() error {
bp.logger.Info("Stopping boards service")
if bp.boardsApp == nil {
return nil
}
if err := bp.boardsApp.Stop(); err != nil {
return fmt.Errorf("error while stopping Boards service: %w", err)
}
return nil
}
//
// These callbacks are called by the suite automatically
//
func (bp *boardsProduct) OnConfigurationChange() error {
if bp.boardsApp == nil {
return nil
}
return bp.boardsApp.OnConfigurationChange()
}
func (bp *boardsProduct) OnWebSocketConnect(webConnID, userID string) {
if bp.boardsApp == nil {
return
}
bp.boardsApp.OnWebSocketConnect(webConnID, userID)
}
func (bp *boardsProduct) OnWebSocketDisconnect(webConnID, userID string) {
if bp.boardsApp == nil {
return
}
bp.boardsApp.OnWebSocketDisconnect(webConnID, userID)
}
func (bp *boardsProduct) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mm_model.WebSocketRequest) {
if bp.boardsApp == nil {
return
}
bp.boardsApp.WebSocketMessageHasBeenPosted(webConnID, userID, req)
}
func (bp *boardsProduct) OnPluginClusterEvent(ctx *plugin.Context, ev mm_model.PluginClusterEvent) {
if bp.boardsApp == nil {
return
}
bp.boardsApp.OnPluginClusterEvent(ctx, ev)
}
func (bp *boardsProduct) MessageWillBePosted(ctx *plugin.Context, post *mm_model.Post) (*mm_model.Post, string) {
if bp.boardsApp == nil {
return post, ""
}
return bp.boardsApp.MessageWillBePosted(ctx, post)
}
func (bp *boardsProduct) MessageWillBeUpdated(ctx *plugin.Context, newPost, oldPost *mm_model.Post) (*mm_model.Post, string) {
if bp.boardsApp == nil {
return newPost, ""
}
return bp.boardsApp.MessageWillBeUpdated(ctx, newPost, oldPost)
}
func (bp *boardsProduct) OnCloudLimitsUpdated(limits *mm_model.ProductLimits) {
if bp.boardsApp == nil {
return
}
bp.boardsApp.OnCloudLimitsUpdated(limits)
}
func (bp *boardsProduct) RunDataRetention(nowTime, batchSize int64) (int64, error) {
if bp.boardsApp == nil {
return 0, nil
}
return bp.boardsApp.RunDataRetention(nowTime, batchSize)
}

View File

@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imports
import (
// Needed to ensure the init() method in the FocalBoard product is run.
// This file is copied to the mmserver imports package via makefile.
_ "github.com/mattermost/focalboard/mattermost-plugin/product"
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

@ -0,0 +1,212 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"database/sql"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/mattermost-server/v6/plugin"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
type storeService interface {
GetMasterDB() (*sql.DB, error)
}
// normalizeAppError returns a truly nil error if appErr is nil
// See https://golang.org/doc/faq#nil_error for more details.
func normalizeAppErr(appErr *mm_model.AppError) error {
if appErr == nil {
return nil
}
return appErr
}
// pluginAPIAdapter is an adapter that ensures all Plugin API methods have the same signature as the
// services API.
// Note: this will be removed when plugin builds are no longer needed.
type pluginAPIAdapter struct {
api plugin.API
storeService storeService
logger mlog.LoggerIFace
}
func newServiceAPIAdapter(api plugin.API, storeService storeService, logger mlog.LoggerIFace) *pluginAPIAdapter {
return &pluginAPIAdapter{
api: api,
storeService: storeService,
logger: logger,
}
}
//
// Channels service.
//
func (a *pluginAPIAdapter) GetDirectChannel(userID1, userID2 string) (*mm_model.Channel, error) {
channel, appErr := a.api.GetDirectChannel(userID1, userID2)
return channel, normalizeAppErr(appErr)
}
func (a *pluginAPIAdapter) GetChannelByID(channelID string) (*mm_model.Channel, error) {
channel, appErr := a.api.GetChannel(channelID)
return channel, normalizeAppErr(appErr)
}
func (a *pluginAPIAdapter) GetChannelMember(channelID string, userID string) (*mm_model.ChannelMember, error) {
member, appErr := a.api.GetChannelMember(channelID, userID)
return member, normalizeAppErr(appErr)
}
func (a *pluginAPIAdapter) GetChannelsForTeamForUser(teamID string, userID string, includeDeleted bool) (mm_model.ChannelList, error) {
channels, appErr := a.api.GetChannelsForTeamForUser(teamID, userID, includeDeleted)
return channels, normalizeAppErr(appErr)
}
//
// Post service.
//
func (a *pluginAPIAdapter) CreatePost(post *mm_model.Post) (*mm_model.Post, error) {
post, appErr := a.api.CreatePost(post)
return post, normalizeAppErr(appErr)
}
//
// User service.
//
func (a *pluginAPIAdapter) GetUserByID(userID string) (*mm_model.User, error) {
user, appErr := a.api.GetUser(userID)
return user, normalizeAppErr(appErr)
}
func (a *pluginAPIAdapter) GetUserByUsername(name string) (*mm_model.User, error) {
user, appErr := a.api.GetUserByUsername(name)
return user, normalizeAppErr(appErr)
}
func (a *pluginAPIAdapter) GetUserByEmail(email string) (*mm_model.User, error) {
user, appErr := a.api.GetUserByEmail(email)
return user, normalizeAppErr(appErr)
}
func (a *pluginAPIAdapter) UpdateUser(user *mm_model.User) (*mm_model.User, error) {
user, appErr := a.api.UpdateUser(user)
return user, normalizeAppErr(appErr)
}
func (a *pluginAPIAdapter) GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error) {
users, appErr := a.api.GetUsers(options)
return users, normalizeAppErr(appErr)
}
//
// Team service.
//
func (a *pluginAPIAdapter) GetTeamMember(teamID string, userID string) (*mm_model.TeamMember, error) {
member, appErr := a.api.GetTeamMember(teamID, userID)
return member, normalizeAppErr(appErr)
}
func (a *pluginAPIAdapter) CreateMember(teamID string, userID string) (*mm_model.TeamMember, error) {
member, appErr := a.api.CreateTeamMember(teamID, userID)
return member, normalizeAppErr(appErr)
}
//
// Permissions service.
//
func (a *pluginAPIAdapter) HasPermissionToTeam(userID, teamID string, permission *mm_model.Permission) bool {
return a.api.HasPermissionToTeam(userID, teamID, permission)
}
func (a *pluginAPIAdapter) HasPermissionToChannel(askingUserID string, channelID string, permission *mm_model.Permission) bool {
return a.api.HasPermissionToChannel(askingUserID, channelID, permission)
}
//
// Bot service.
//
func (a *pluginAPIAdapter) EnsureBot(bot *mm_model.Bot) (string, error) {
return a.api.EnsureBotUser(bot)
}
//
// License service.
//
func (a *pluginAPIAdapter) GetLicense() *mm_model.License {
return a.api.GetLicense()
}
//
// FileInfoStore service.
//
func (a *pluginAPIAdapter) GetFileInfo(fileID string) (*mm_model.FileInfo, error) {
fi, appErr := a.api.GetFileInfo(fileID)
return fi, normalizeAppErr(appErr)
}
//
// Cluster store.
//
func (a *pluginAPIAdapter) PublishWebSocketEvent(event string, payload map[string]interface{}, broadcast *mm_model.WebsocketBroadcast) {
a.api.PublishWebSocketEvent(event, payload, broadcast)
}
func (a *pluginAPIAdapter) PublishPluginClusterEvent(ev mm_model.PluginClusterEvent, opts mm_model.PluginClusterEventSendOptions) error {
return a.api.PublishPluginClusterEvent(ev, opts)
}
//
// Cloud service.
//
func (a *pluginAPIAdapter) GetCloudLimits() (*mm_model.ProductLimits, error) {
return a.api.GetCloudLimits()
}
//
// Config service.
//
func (a *pluginAPIAdapter) GetConfig() *mm_model.Config {
return a.api.GetUnsanitizedConfig()
}
//
// Logger service.
//
func (a *pluginAPIAdapter) GetLogger() mlog.LoggerIFace {
return a.logger
}
//
// KVStore service.
//
func (a *pluginAPIAdapter) KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, error) {
b, appErr := a.api.KVSetWithOptions(key, value, options)
return b, normalizeAppErr(appErr)
}
//
// Store service.
//
func (a *pluginAPIAdapter) GetMasterDB() (*sql.DB, error) {
return a.storeService.GetMasterDB()
}
//
// System service.
//
func (a *pluginAPIAdapter) GetDiagnosticID() string {
return a.api.GetDiagnosticId()
}
// Ensure the adapter implements ServicesAPI.
var _ model.ServicesAPI = &pluginAPIAdapter{}

View File

@ -0,0 +1,218 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package boards
import (
"fmt"
"net/http"
"sync"
"github.com/mattermost/focalboard/server/auth"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/server"
"github.com/mattermost/focalboard/server/services/notify"
"github.com/mattermost/focalboard/server/services/permissions/mmpermissions"
"github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
"github.com/mattermost/focalboard/server/services/store/sqlstore"
"github.com/mattermost/focalboard/server/utils"
"github.com/mattermost/focalboard/server/ws"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/mattermost/mattermost-plugin-api/cluster"
)
const (
boardsFeatureFlagName = "BoardsFeatureFlags"
PluginName = "focalboard"
SharedBoardsName = "enablepublicsharedboards"
notifyFreqCardSecondsKey = "notify_freq_card_seconds"
notifyFreqBoardSecondsKey = "notify_freq_board_seconds"
)
type BoardsEmbed struct {
OriginalPath string `json:"originalPath"`
TeamID string `json:"teamID"`
ViewID string `json:"viewID"`
BoardID string `json:"boardID"`
CardID string `json:"cardID"`
ReadToken string `json:"readToken,omitempty"`
}
type BoardsApp struct {
// configurationLock synchronizes access to the configuration.
configurationLock sync.RWMutex
// configuration is the active plugin configuration. Consult getConfiguration and
// setConfiguration for usage.
configuration *configuration
server *server.Server
wsPluginAdapter ws.PluginAdapterInterface
servicesAPI model.ServicesAPI
logger mlog.LoggerIFace
}
func NewBoardsApp(api model.ServicesAPI) (*BoardsApp, error) {
mmconfig := api.GetConfig()
logger := api.GetLogger()
baseURL := ""
if mmconfig.ServiceSettings.SiteURL != nil {
baseURL = *mmconfig.ServiceSettings.SiteURL
}
serverID := api.GetDiagnosticID()
cfg := createBoardsConfig(*mmconfig, baseURL, serverID)
sqlDB, err := api.GetMasterDB()
if err != nil {
return nil, fmt.Errorf("cannot access database while initializing Boards: %w", err)
}
storeParams := sqlstore.Params{
DBType: cfg.DBType,
ConnectionString: cfg.DBConfigString,
TablePrefix: cfg.DBTablePrefix,
Logger: logger,
DB: sqlDB,
IsPlugin: true,
NewMutexFn: func(name string) (*cluster.Mutex, error) {
return cluster.NewMutex(&mutexAPIAdapter{api: api}, name)
},
ServicesAPI: api,
}
var db store.Store
db, err = sqlstore.New(storeParams)
if err != nil {
return nil, fmt.Errorf("error initializing the DB: %w", err)
}
if cfg.AuthMode == server.MattermostAuthMod {
layeredStore, err2 := mattermostauthlayer.New(cfg.DBType, sqlDB, db, logger, api, storeParams.TablePrefix)
if err2 != nil {
return nil, fmt.Errorf("error initializing the DB: %w", err2)
}
db = layeredStore
}
permissionsService := mmpermissions.New(db, api, logger)
wsPluginAdapter := ws.NewPluginAdapter(api, auth.New(cfg, db, permissionsService), db, logger)
backendParams := notifyBackendParams{
cfg: cfg,
servicesAPI: api,
appAPI: &appAPI{store: db},
permissions: permissionsService,
serverRoot: baseURL + "/boards",
logger: logger,
}
var notifyBackends []notify.Backend
mentionsBackend, err := createMentionsNotifyBackend(backendParams)
if err != nil {
return nil, fmt.Errorf("error creating mention notifications backend: %w", err)
}
notifyBackends = append(notifyBackends, mentionsBackend)
subscriptionsBackend, err2 := createSubscriptionsNotifyBackend(backendParams)
if err2 != nil {
return nil, fmt.Errorf("error creating subscription notifications backend: %w", err2)
}
notifyBackends = append(notifyBackends, subscriptionsBackend)
mentionsBackend.AddListener(subscriptionsBackend)
params := server.Params{
Cfg: cfg,
SingleUserToken: "",
DBStore: db,
Logger: logger,
ServerID: serverID,
WSAdapter: wsPluginAdapter,
NotifyBackends: notifyBackends,
PermissionsService: permissionsService,
IsPlugin: true,
}
server, err := server.New(params)
if err != nil {
return nil, fmt.Errorf("error initializing the server: %w", err)
}
backendParams.appAPI.init(db, server.App())
if utils.IsCloudLicense(api.GetLicense()) {
limits, err := api.GetCloudLimits()
if err != nil {
return nil, fmt.Errorf("error fetching cloud limits when starting Boards: %w", err)
}
if err := server.App().SetCloudLimits(limits); err != nil {
return nil, fmt.Errorf("error setting cloud limits when starting Boards: %w", err)
}
}
return &BoardsApp{
server: server,
wsPluginAdapter: wsPluginAdapter,
servicesAPI: api,
logger: logger,
}, nil
}
func (b *BoardsApp) Start() error {
if err := b.server.Start(); err != nil {
return fmt.Errorf("error starting Boards server: %w", err)
}
return nil
}
func (b *BoardsApp) Stop() error {
return b.server.Shutdown()
}
//
// These callbacks are called automatically by the suite server.
//
func (b *BoardsApp) MessageWillBePosted(_ *plugin.Context, post *mm_model.Post) (*mm_model.Post, string) {
return postWithBoardsEmbed(post), ""
}
func (b *BoardsApp) MessageWillBeUpdated(_ *plugin.Context, newPost, _ *mm_model.Post) (*mm_model.Post, string) {
return postWithBoardsEmbed(newPost), ""
}
func (b *BoardsApp) OnWebSocketConnect(webConnID, userID string) {
b.wsPluginAdapter.OnWebSocketConnect(webConnID, userID)
}
func (b *BoardsApp) OnWebSocketDisconnect(webConnID, userID string) {
b.wsPluginAdapter.OnWebSocketDisconnect(webConnID, userID)
}
func (b *BoardsApp) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mm_model.WebSocketRequest) {
b.wsPluginAdapter.WebSocketMessageHasBeenPosted(webConnID, userID, req)
}
func (b *BoardsApp) OnPluginClusterEvent(_ *plugin.Context, ev mm_model.PluginClusterEvent) {
b.wsPluginAdapter.HandleClusterEvent(ev)
}
func (b *BoardsApp) OnCloudLimitsUpdated(limits *mm_model.ProductLimits) {
if err := b.server.App().SetCloudLimits(limits); err != nil {
b.logger.Error("Error setting the cloud limits for Boards", mlog.Err(err))
}
}
// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world.
func (b *BoardsApp) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) {
router := b.server.GetRootRouter()
router.ServeHTTP(w, r)
}

View File

@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package boards
import (
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
func TestSetConfiguration(t *testing.T) {
boolTrue := true
stringRef := ""
baseFeatureFlags := &model.FeatureFlags{}
basePluginSettings := &model.PluginSettings{
Directory: &stringRef,
}
driverName := "testDriver"
dataSource := "testDirectory"
baseSQLSettings := &model.SqlSettings{
DriverName: &driverName,
DataSource: &dataSource,
}
directory := "testDirectory"
baseFileSettings := &model.FileSettings{
DriverName: &driverName,
Directory: &directory,
MaxFileSize: model.NewInt64(1024 * 1024),
}
days := 365
baseDataRetentionSettings := &model.DataRetentionSettings{
BoardsRetentionDays: &days,
}
usernameRef := "username"
baseTeamSettings := &model.TeamSettings{
TeammateNameDisplay: &usernameRef,
}
baseConfig := &model.Config{
FeatureFlags: baseFeatureFlags,
PluginSettings: *basePluginSettings,
SqlSettings: *baseSQLSettings,
FileSettings: *baseFileSettings,
DataRetentionSettings: *baseDataRetentionSettings,
TeamSettings: *baseTeamSettings,
}
t.Run("test enable telemetry", func(t *testing.T) {
logSettings := &model.LogSettings{
EnableDiagnostics: &boolTrue,
}
mmConfig := baseConfig
mmConfig.LogSettings = *logSettings
config := createBoardsConfig(*mmConfig, "", "testId")
assert.Equal(t, true, config.Telemetry)
assert.Equal(t, "testId", config.TelemetryID)
})
t.Run("test enable shared boards", func(t *testing.T) {
mmConfig := baseConfig
mmConfig.PluginSettings.Plugins = make(map[string]map[string]interface{})
mmConfig.PluginSettings.Plugins[PluginName] = make(map[string]interface{})
mmConfig.PluginSettings.Plugins[PluginName][SharedBoardsName] = true
config := createBoardsConfig(*mmConfig, "", "")
assert.Equal(t, true, config.EnablePublicSharedBoards)
})
t.Run("test boards feature flags", func(t *testing.T) {
featureFlags := &model.FeatureFlags{
TestFeature: "test",
TestBoolFeature: boolTrue,
BoardsFeatureFlags: "hello_world-myTest",
}
mmConfig := baseConfig
mmConfig.FeatureFlags = featureFlags
config := createBoardsConfig(*mmConfig, "", "")
assert.Equal(t, "true", config.FeatureFlags["TestBoolFeature"])
assert.Equal(t, "test", config.FeatureFlags["TestFeature"])
assert.Equal(t, "true", config.FeatureFlags["hello_world"])
assert.Equal(t, "true", config.FeatureFlags["myTest"])
})
}
func TestServeHTTP(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
b := &BoardsApp{
server: th.Server,
logger: mlog.CreateConsoleTestLogger(true, mlog.LvlError),
}
assert := assert.New(t)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/hello", nil)
b.ServeHTTP(nil, w, r)
result := w.Result()
assert.NotNil(result)
defer result.Body.Close()
bodyBytes, err := ioutil.ReadAll(result.Body)
assert.Nil(err)
bodyString := string(bodyBytes)
assert.Equal("Hello", bodyString)
}

View File

@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package boards
import (
"math"
"path"
"strings"
"github.com/mattermost/focalboard/server/services/config"
mm_model "github.com/mattermost/mattermost-server/v6/model"
)
func createBoardsConfig(mmconfig mm_model.Config, baseURL string, serverID string) *config.Configuration {
filesS3Config := config.AmazonS3Config{}
if mmconfig.FileSettings.AmazonS3AccessKeyId != nil {
filesS3Config.AccessKeyID = *mmconfig.FileSettings.AmazonS3AccessKeyId
}
if mmconfig.FileSettings.AmazonS3SecretAccessKey != nil {
filesS3Config.SecretAccessKey = *mmconfig.FileSettings.AmazonS3SecretAccessKey
}
if mmconfig.FileSettings.AmazonS3Bucket != nil {
filesS3Config.Bucket = *mmconfig.FileSettings.AmazonS3Bucket
}
if mmconfig.FileSettings.AmazonS3PathPrefix != nil {
filesS3Config.PathPrefix = *mmconfig.FileSettings.AmazonS3PathPrefix
}
if mmconfig.FileSettings.AmazonS3Region != nil {
filesS3Config.Region = *mmconfig.FileSettings.AmazonS3Region
}
if mmconfig.FileSettings.AmazonS3Endpoint != nil {
filesS3Config.Endpoint = *mmconfig.FileSettings.AmazonS3Endpoint
}
if mmconfig.FileSettings.AmazonS3SSL != nil {
filesS3Config.SSL = *mmconfig.FileSettings.AmazonS3SSL
}
if mmconfig.FileSettings.AmazonS3SignV2 != nil {
filesS3Config.SignV2 = *mmconfig.FileSettings.AmazonS3SignV2
}
if mmconfig.FileSettings.AmazonS3SSE != nil {
filesS3Config.SSE = *mmconfig.FileSettings.AmazonS3SSE
}
if mmconfig.FileSettings.AmazonS3Trace != nil {
filesS3Config.Trace = *mmconfig.FileSettings.AmazonS3Trace
}
enableTelemetry := false
if mmconfig.LogSettings.EnableDiagnostics != nil {
enableTelemetry = *mmconfig.LogSettings.EnableDiagnostics
}
enablePublicSharedBoards := false
if mmconfig.PluginSettings.Plugins[PluginName][SharedBoardsName] == true {
enablePublicSharedBoards = true
}
enableBoardsDeletion := false
if mmconfig.DataRetentionSettings.EnableBoardsDeletion != nil {
enableBoardsDeletion = true
}
featureFlags := parseFeatureFlags(mmconfig.FeatureFlags.ToMap())
return &config.Configuration{
ServerRoot: baseURL + "/plugins/focalboard",
Port: -1,
DBType: *mmconfig.SqlSettings.DriverName,
DBConfigString: *mmconfig.SqlSettings.DataSource,
DBTablePrefix: "focalboard_",
UseSSL: false,
SecureCookie: true,
WebPath: path.Join(*mmconfig.PluginSettings.Directory, "focalboard", "pack"),
FilesDriver: *mmconfig.FileSettings.DriverName,
FilesPath: *mmconfig.FileSettings.Directory,
FilesS3Config: filesS3Config,
MaxFileSize: *mmconfig.FileSettings.MaxFileSize,
Telemetry: enableTelemetry,
TelemetryID: serverID,
WebhookUpdate: []string{},
SessionExpireTime: 2592000,
SessionRefreshTime: 18000,
LocalOnly: false,
EnableLocalMode: false,
LocalModeSocketLocation: "",
AuthMode: "mattermost",
EnablePublicSharedBoards: enablePublicSharedBoards,
FeatureFlags: featureFlags,
NotifyFreqCardSeconds: getPluginSettingInt(mmconfig, notifyFreqCardSecondsKey, 120),
NotifyFreqBoardSeconds: getPluginSettingInt(mmconfig, notifyFreqBoardSecondsKey, 86400),
EnableDataRetention: enableBoardsDeletion,
DataRetentionDays: *mmconfig.DataRetentionSettings.BoardsRetentionDays,
TeammateNameDisplay: *mmconfig.TeamSettings.TeammateNameDisplay,
}
}
func getPluginSetting(mmConfig mm_model.Config, key string) (interface{}, bool) {
plugin, ok := mmConfig.PluginSettings.Plugins[PluginName]
if !ok {
return nil, false
}
val, ok := plugin[key]
if !ok {
return nil, false
}
return val, true
}
func getPluginSettingInt(mmConfig mm_model.Config, key string, def int) int {
val, ok := getPluginSetting(mmConfig, key)
if !ok {
return def
}
valFloat, ok := val.(float64)
if !ok {
return def
}
return int(math.Round(valFloat))
}
func parseFeatureFlags(configFeatureFlags map[string]string) map[string]string {
featureFlags := make(map[string]string)
for key, value := range configFeatureFlags {
// Break out FeatureFlags and pass remaining
if key == boardsFeatureFlagName {
for _, flag := range strings.Split(value, "-") {
featureFlags[flag] = "true"
}
} else {
featureFlags[key] = value
}
}
return featureFlags
}

View File

@ -1,4 +1,4 @@
package main
package boards
import (
"reflect"
@ -29,15 +29,15 @@ func (c *configuration) Clone() *configuration {
// getConfiguration retrieves the active configuration under lock, making it safe to use
// concurrently. The active configuration may change underneath the client of this method, but
// the struct returned by this API call is considered immutable.
func (p *Plugin) getConfiguration() *configuration {
p.configurationLock.RLock()
defer p.configurationLock.RUnlock()
func (b *BoardsApp) getConfiguration() *configuration {
b.configurationLock.RLock()
defer b.configurationLock.RUnlock()
if p.configuration == nil {
if b.configuration == nil {
return &configuration{}
}
return p.configuration
return b.configuration
}
// setConfiguration replaces the active configuration under lock.
@ -49,11 +49,11 @@ func (p *Plugin) getConfiguration() *configuration {
// This method panics if setConfiguration is called with the existing configuration. This almost
// certainly means that the configuration was modified without being cloned and may result in
// an unsafe access.
func (p *Plugin) setConfiguration(configuration *configuration) {
p.configurationLock.Lock()
defer p.configurationLock.Unlock()
func (b *BoardsApp) setConfiguration(configuration *configuration) {
b.configurationLock.Lock()
defer b.configurationLock.Unlock()
if configuration != nil && p.configuration == configuration {
if configuration != nil && b.configuration == configuration {
// Ignore assignment if the configuration struct is empty. Go will optimize the
// allocation for same to point at the same memory address, breaking the check
// above.
@ -64,36 +64,41 @@ func (p *Plugin) setConfiguration(configuration *configuration) {
panic("setConfiguration called with the existing configuration")
}
p.configuration = configuration
b.configuration = configuration
}
// OnConfigurationChange is invoked when configuration changes may have been made.
func (p *Plugin) OnConfigurationChange() error { //nolint
func (b *BoardsApp) OnConfigurationChange() error {
// Have we been setup by OnActivate?
if p.wsPluginAdapter == nil {
if b.server == nil {
return nil
}
mmconfig := p.API.GetConfig()
mmconfig := b.servicesAPI.GetConfig()
// handle plugin configuration settings
enableShareBoards := false
if mmconfig.PluginSettings.Plugins[pluginName][sharedBoardsName] == true {
if mmconfig.PluginSettings.Plugins[PluginName][SharedBoardsName] == true {
enableShareBoards = true
}
configuration := &configuration{
EnablePublicSharedBoards: enableShareBoards,
}
p.setConfiguration(configuration)
p.server.Config().EnablePublicSharedBoards = enableShareBoards
b.setConfiguration(configuration)
b.server.Config().EnablePublicSharedBoards = enableShareBoards
// handle feature flags
p.server.Config().FeatureFlags = parseFeatureFlags(mmconfig.FeatureFlags.ToMap())
b.server.Config().FeatureFlags = parseFeatureFlags(mmconfig.FeatureFlags.ToMap())
// handle Data Retention settings
p.server.Config().EnableDataRetention = *mmconfig.DataRetentionSettings.EnableBoardsDeletion
p.server.Config().DataRetentionDays = *mmconfig.DataRetentionSettings.BoardsRetentionDays
enableBoardsDeletion := false
if mmconfig.DataRetentionSettings.EnableBoardsDeletion != nil {
enableBoardsDeletion = true
}
b.server.Config().EnableDataRetention = enableBoardsDeletion
b.server.Config().DataRetentionDays = *mmconfig.DataRetentionSettings.BoardsRetentionDays
b.server.Config().TeammateNameDisplay = *mmconfig.TeamSettings.TeammateNameDisplay
p.server.UpdateAppConfig()
p.wsPluginAdapter.BroadcastConfigChange(*p.server.App().GetClientConfig())
b.server.UpdateAppConfig()
b.wsPluginAdapter.BroadcastConfigChange(*b.server.App().GetClientConfig())
return nil
}

View File

@ -0,0 +1,117 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package boards
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/mattermost/focalboard/server/integrationtests"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/server"
"github.com/mattermost/focalboard/server/ws"
mockservicesapi "github.com/mattermost/focalboard/server/model/mocks"
serverModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type TestHelper struct {
Server *server.Server
}
func SetupTestHelper(t *testing.T) (*TestHelper, func()) {
th := &TestHelper{}
th.Server = newTestServer()
err := th.Server.Start()
require.NoError(t, err, "Server start should not error")
tearDown := func() {
err := th.Server.Shutdown()
require.NoError(t, err, "Server shutdown should not error")
}
return th, tearDown
}
func newTestServer() *server.Server {
return integrationtests.NewTestServerPluginMode()
}
func TestConfigurationNullConfiguration(t *testing.T) {
boardsApp := &BoardsApp{}
assert.NotNil(t, boardsApp.getConfiguration())
}
func TestOnConfigurationChange(t *testing.T) {
stringRef := ""
basePlugins := make(map[string]map[string]interface{})
basePlugins[PluginName] = make(map[string]interface{})
basePlugins[PluginName][SharedBoardsName] = true
baseFeatureFlags := &serverModel.FeatureFlags{
BoardsFeatureFlags: "Feature1-Feature2",
}
basePluginSettings := &serverModel.PluginSettings{
Directory: &stringRef,
Plugins: basePlugins,
}
intRef := 365
baseDataRetentionSettings := &serverModel.DataRetentionSettings{
BoardsRetentionDays: &intRef,
}
usernameRef := "username"
baseTeamSettings := &serverModel.TeamSettings{
TeammateNameDisplay: &usernameRef,
}
baseConfig := &serverModel.Config{
FeatureFlags: baseFeatureFlags,
PluginSettings: *basePluginSettings,
DataRetentionSettings: *baseDataRetentionSettings,
TeamSettings: *baseTeamSettings,
}
t.Run("Test Load Plugin Success", func(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
ctrl := gomock.NewController(t)
api := mockservicesapi.NewMockServicesAPI(ctrl)
api.EXPECT().GetConfig().Return(baseConfig)
b := &BoardsApp{
server: th.Server,
wsPluginAdapter: &FakePluginAdapter{},
servicesAPI: api,
logger: mlog.CreateConsoleTestLogger(true, mlog.LvlError),
}
err := b.OnConfigurationChange()
assert.NoError(t, err)
assert.Equal(t, 1, count)
// make sure both App and Server got updated
assert.True(t, b.server.Config().EnablePublicSharedBoards)
assert.True(t, b.server.App().GetClientConfig().EnablePublicSharedBoards)
assert.Equal(t, "true", b.server.Config().FeatureFlags["Feature1"])
assert.Equal(t, "true", b.server.Config().FeatureFlags["Feature2"])
assert.Equal(t, "", b.server.Config().FeatureFlags["Feature3"])
})
}
var count = 0
type FakePluginAdapter struct {
ws.PluginAdapter
}
func (c *FakePluginAdapter) BroadcastConfigChange(clientConfig model.ClientConfig) {
count++
}

View File

@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package boards
import (
"errors"
"time"
)
var ErrInsufficientLicense = errors.New("appropriate license required")
func (b *BoardsApp) RunDataRetention(nowTime, batchSize int64) (int64, error) {
b.logger.Debug("Boards RunDataRetention")
license := b.server.Store().GetLicense()
if license == nil || !(*license.Features.DataRetention) {
return 0, ErrInsufficientLicense
}
if b.server.Config().EnableDataRetention {
boardsRetentionDays := b.server.Config().DataRetentionDays
endTimeBoards := convertDaysToCutoff(boardsRetentionDays, time.Unix(nowTime/1000, 0))
return b.server.Store().RunDataRetention(endTimeBoards, batchSize)
}
return 0, nil
}
func convertDaysToCutoff(days int, now time.Time) int64 {
upToStartOfDay := now.AddDate(0, 0, -days)
cutoffDate := time.Date(upToStartOfDay.Year(), upToStartOfDay.Month(), upToStartOfDay.Day(), 0, 0, 0, 0, time.Local)
return cutoffDate.UnixNano() / int64(time.Millisecond)
}

View File

@ -0,0 +1,142 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package boards
import (
"os"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/mattermost/focalboard/server/server"
"github.com/mattermost/focalboard/server/services/config"
"github.com/mattermost/focalboard/server/services/permissions/localpermissions"
"github.com/mattermost/focalboard/server/services/store/mockstore"
"github.com/stretchr/testify/assert"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
type TestHelperMockStore struct {
Server *server.Server
Store *mockstore.MockStore
}
func SetupTestHelperMockStore(t *testing.T) (*TestHelperMockStore, func()) {
th := &TestHelperMockStore{}
origUnitTesting := os.Getenv("FOCALBOARD_UNIT_TESTING")
os.Setenv("FOCALBOARD_UNIT_TESTING", "1")
ctrl := gomock.NewController(t)
mockStore := mockstore.NewMockStore(ctrl)
tearDown := func() {
defer ctrl.Finish()
os.Setenv("FOCALBOARD_UNIT_TESTING", origUnitTesting)
}
th.Server = newTestServerMock(mockStore)
th.Store = mockStore
return th, tearDown
}
func newTestServerMock(mockStore *mockstore.MockStore) *server.Server {
config := &config.Configuration{
EnableDataRetention: false,
DataRetentionDays: 10,
FilesDriver: "local",
FilesPath: "./files",
WebPath: "/",
}
logger := mlog.CreateConsoleTestLogger(true, mlog.LvlDebug)
mockStore.EXPECT().GetTeam(gomock.Any()).Return(nil, nil).AnyTimes()
mockStore.EXPECT().UpsertTeamSignupToken(gomock.Any()).AnyTimes()
mockStore.EXPECT().GetSystemSettings().AnyTimes()
mockStore.EXPECT().SetSystemSetting(gomock.Any(), gomock.Any()).AnyTimes()
permissionsService := localpermissions.New(mockStore, logger)
srv, err := server.New(server.Params{
Cfg: config,
DBStore: mockStore,
Logger: logger,
PermissionsService: permissionsService,
})
if err != nil {
panic(err)
}
return srv
}
func TestRunDataRetention(t *testing.T) {
th, tearDown := SetupTestHelperMockStore(t)
defer tearDown()
b := &BoardsApp{
server: th.Server,
logger: mlog.CreateConsoleTestLogger(true, mlog.LvlError),
}
now := time.Now().UnixNano()
t.Run("test null license", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(nil)
_, err := b.RunDataRetention(now, 10)
assert.NotNil(t, err)
assert.Equal(t, ErrInsufficientLicense, err)
})
t.Run("test invalid license", func(t *testing.T) {
falseValue := false
th.Store.EXPECT().GetLicense().Return(
&model.License{
Features: &model.Features{
DataRetention: &falseValue,
},
},
)
_, err := b.RunDataRetention(now, 10)
assert.NotNil(t, err)
assert.Equal(t, ErrInsufficientLicense, err)
})
t.Run("test valid license, invalid config", func(t *testing.T) {
trueValue := true
th.Store.EXPECT().GetLicense().Return(
&model.License{
Features: &model.Features{
DataRetention: &trueValue,
},
})
count, err := b.RunDataRetention(now, 10)
assert.Nil(t, err)
assert.Equal(t, int64(0), count)
})
t.Run("test valid license, valid config", func(t *testing.T) {
trueValue := true
th.Store.EXPECT().GetLicense().Return(
&model.License{
Features: &model.Features{
DataRetention: &trueValue,
},
})
th.Store.EXPECT().RunDataRetention(gomock.Any(), int64(10)).Return(int64(100), nil)
b.server.Config().EnableDataRetention = true
count, err := b.RunDataRetention(now, 10)
assert.Nil(t, err)
assert.Equal(t, int64(100), count)
})
}

View File

@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package boards
import (
"errors"
"net/http"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
"github.com/mattermost/focalboard/server/model"
)
type mutexAPIAdapter struct {
api model.ServicesAPI
}
func (m *mutexAPIAdapter) KVSetWithOptions(key string, value []byte, options mm_model.PluginKVSetOptions) (bool, *mm_model.AppError) {
b, err := m.api.KVSetWithOptions(key, value, options)
var appErr *mm_model.AppError
if err != nil {
if !errors.As(err, &appErr) {
appErr = mm_model.NewAppError("KVSetWithOptions", "", nil, "", http.StatusInternalServerError)
}
}
return b, appErr
}
func (m *mutexAPIAdapter) LogError(msg string, keyValuePairs ...interface{}) {
m.api.GetLogger().Error(msg, mlog.Array("kvpairs", keyValuePairs))
}

View File

@ -0,0 +1,147 @@
package boards
import (
"fmt"
"time"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/config"
"github.com/mattermost/focalboard/server/services/notify/notifymentions"
"github.com/mattermost/focalboard/server/services/notify/notifysubscriptions"
"github.com/mattermost/focalboard/server/services/notify/plugindelivery"
"github.com/mattermost/focalboard/server/services/permissions"
"github.com/mattermost/focalboard/server/services/store"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
const (
botUsername = "boards"
botDisplayname = "Boards"
botDescription = "Created by Boards plugin."
)
type notifyBackendParams struct {
cfg *config.Configuration
servicesAPI model.ServicesAPI
permissions permissions.PermissionsService
appAPI *appAPI
serverRoot string
logger mlog.LoggerIFace
}
func createMentionsNotifyBackend(params notifyBackendParams) (*notifymentions.Backend, error) {
delivery, err := createDelivery(params.servicesAPI, params.serverRoot)
if err != nil {
return nil, err
}
backendParams := notifymentions.BackendParams{
AppAPI: params.appAPI,
Permissions: params.permissions,
Delivery: delivery,
Logger: params.logger,
}
backend := notifymentions.New(backendParams)
return backend, nil
}
func createSubscriptionsNotifyBackend(params notifyBackendParams) (*notifysubscriptions.Backend, error) {
delivery, err := createDelivery(params.servicesAPI, params.serverRoot)
if err != nil {
return nil, err
}
backendParams := notifysubscriptions.BackendParams{
ServerRoot: params.serverRoot,
AppAPI: params.appAPI,
Permissions: params.permissions,
Delivery: delivery,
Logger: params.logger,
NotifyFreqCardSeconds: params.cfg.NotifyFreqCardSeconds,
NotifyFreqBoardSeconds: params.cfg.NotifyFreqBoardSeconds,
}
backend := notifysubscriptions.New(backendParams)
return backend, nil
}
func createDelivery(servicesAPI model.ServicesAPI, serverRoot string) (*plugindelivery.PluginDelivery, error) {
bot := &mm_model.Bot{
Username: botUsername,
DisplayName: botDisplayname,
Description: botDescription,
}
botID, err := servicesAPI.EnsureBot(bot)
if err != nil {
return nil, fmt.Errorf("failed to ensure %s bot: %w", botDisplayname, err)
}
return plugindelivery.New(botID, serverRoot, servicesAPI), nil
}
type appIface interface {
CreateSubscription(sub *model.Subscription) (*model.Subscription, error)
AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error)
}
// appAPI provides app and store APIs for notification services. Where appropriate calls are made to the
// app layer to leverage the additional websocket notification logic present there, and other times the
// store APIs are called directly.
type appAPI struct {
store store.Store
app appIface
}
func (a *appAPI) init(store store.Store, app appIface) {
a.store = store
a.app = app
}
func (a *appAPI) GetBlockHistory(blockID string, opts model.QueryBlockHistoryOptions) ([]model.Block, error) {
return a.store.GetBlockHistory(blockID, opts)
}
func (a *appAPI) GetSubTree2(boardID, blockID string, opts model.QuerySubtreeOptions) ([]model.Block, error) {
return a.store.GetSubTree2(boardID, blockID, opts)
}
func (a *appAPI) GetBoardAndCardByID(blockID string) (board *model.Board, card *model.Block, err error) {
return a.store.GetBoardAndCardByID(blockID)
}
func (a *appAPI) GetUserByID(userID string) (*model.User, error) {
return a.store.GetUserByID(userID)
}
func (a *appAPI) CreateSubscription(sub *model.Subscription) (*model.Subscription, error) {
return a.app.CreateSubscription(sub)
}
func (a *appAPI) GetSubscribersForBlock(blockID string) ([]*model.Subscriber, error) {
return a.store.GetSubscribersForBlock(blockID)
}
func (a *appAPI) UpdateSubscribersNotifiedAt(blockID string, notifyAt int64) error {
return a.store.UpdateSubscribersNotifiedAt(blockID, notifyAt)
}
func (a *appAPI) UpsertNotificationHint(hint *model.NotificationHint, notificationFreq time.Duration) (*model.NotificationHint, error) {
return a.store.UpsertNotificationHint(hint, notificationFreq)
}
func (a *appAPI) GetNextNotificationHint(remove bool) (*model.NotificationHint, error) {
return a.store.GetNextNotificationHint(remove)
}
func (a *appAPI) GetMemberForBoard(boardID, userID string) (*model.BoardMember, error) {
return a.store.GetMemberForBoard(boardID, userID)
}
func (a *appAPI) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error) {
return a.app.AddMemberToBoard(member)
}

View File

@ -0,0 +1,163 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package boards
import (
"encoding/json"
"fmt"
"net/url"
"strings"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/markdown"
)
func postWithBoardsEmbed(post *mm_model.Post) *mm_model.Post {
if _, ok := post.GetProps()["boards"]; ok {
post.AddProp("boards", nil)
}
firstLink, newPostMessage := getFirstLinkAndShortenAllBoardsLink(post.Message)
post.Message = newPostMessage
if firstLink == "" {
return post
}
u, err := url.Parse(firstLink)
if err != nil {
return post
}
// Trim away the first / because otherwise after we split the string, the first element in the array is a empty element
urlPath := u.Path
urlPath = strings.TrimPrefix(urlPath, "/")
urlPath = strings.TrimSuffix(urlPath, "/")
pathSplit := strings.Split(strings.ToLower(urlPath), "/")
queryParams := u.Query()
if len(pathSplit) == 0 {
return post
}
teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
if teamID != "" && boardID != "" && viewID != "" && cardID != "" {
b, _ := json.Marshal(BoardsEmbed{
TeamID: teamID,
BoardID: boardID,
ViewID: viewID,
CardID: cardID,
ReadToken: queryParams.Get("r"),
OriginalPath: u.RequestURI(),
})
BoardsPostEmbed := &mm_model.PostEmbed{
Type: mm_model.PostEmbedBoards,
Data: string(b),
}
if post.Metadata == nil {
post.Metadata = &mm_model.PostMetadata{}
}
post.Metadata.Embeds = []*mm_model.PostEmbed{BoardsPostEmbed}
post.AddProp("boards", string(b))
}
return post
}
func getFirstLinkAndShortenAllBoardsLink(postMessage string) (firstLink, newPostMessage string) {
newPostMessage = postMessage
seenLinks := make(map[string]bool)
markdown.Inspect(postMessage, func(blockOrInline interface{}) bool {
if autoLink, ok := blockOrInline.(*markdown.Autolink); ok {
link := autoLink.Destination()
if firstLink == "" {
firstLink = link
}
if seen := seenLinks[link]; !seen && isBoardsLink(link) {
// TODO: Make sure that <Jump To Card> is Internationalized and translated to the Users Language preference
markdownFormattedLink := fmt.Sprintf("[%s](%s)", "<Jump To Card>", link)
newPostMessage = strings.ReplaceAll(newPostMessage, link, markdownFormattedLink)
seenLinks[link] = true
}
}
if inlineLink, ok := blockOrInline.(*markdown.InlineLink); ok {
if link := inlineLink.Destination(); firstLink == "" {
firstLink = link
}
}
return true
})
return firstLink, newPostMessage
}
func returnBoardsParams(pathArray []string) (teamID, boardID, viewID, cardID string) {
// The reason we are doing this search for the first instance of boards or plugins is to take into account URL subpaths
index := -1
for i := 0; i < len(pathArray); i++ {
if pathArray[i] == "boards" || pathArray[i] == "plugins" {
index = i
break
}
}
if index == -1 {
return teamID, boardID, viewID, cardID
}
// If at index, the parameter in the path is boards,
// then we've copied this directly as logged in user of that board
// If at index, the parameter in the path is plugins,
// then we've copied this from a shared board
// For card links copied on a non-shared board, the path looks like {...Mattermost Url}.../boards/team/teamID/boardID/viewID/cardID
// For card links copied on a shared board, the path looks like
// {...Mattermost Url}.../plugins/focalboard/team/teamID/shared/boardID/viewID/cardID?r=read_token
// This is a non-shared board card link
if len(pathArray)-index == 6 && pathArray[index] == "boards" && pathArray[index+1] == "team" {
teamID = pathArray[index+2]
boardID = pathArray[index+3]
viewID = pathArray[index+4]
cardID = pathArray[index+5]
} else if len(pathArray)-index == 8 && pathArray[index] == "plugins" &&
pathArray[index+1] == "focalboard" &&
pathArray[index+2] == "team" &&
pathArray[index+4] == "shared" { // This is a shared board card link
teamID = pathArray[index+3]
boardID = pathArray[index+5]
viewID = pathArray[index+6]
cardID = pathArray[index+7]
}
return teamID, boardID, viewID, cardID
}
func isBoardsLink(link string) bool {
u, err := url.Parse(link)
if err != nil {
return false
}
urlPath := u.Path
urlPath = strings.TrimPrefix(urlPath, "/")
urlPath = strings.TrimSuffix(urlPath, "/")
pathSplit := strings.Split(strings.ToLower(urlPath), "/")
if len(pathSplit) == 0 {
return false
}
teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
return teamID != "" && boardID != "" && viewID != "" && cardID != ""
}

View File

@ -1,82 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"testing"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/ws"
serverModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest"
"github.com/stretchr/testify/assert"
)
func TestConfigurationNullConfiguration(t *testing.T) {
plugin := &Plugin{}
assert.NotNil(t, plugin.getConfiguration())
}
func TestOnConfigurationChange(t *testing.T) {
stringRef := ""
basePlugins := make(map[string]map[string]interface{})
basePlugins[pluginName] = make(map[string]interface{})
basePlugins[pluginName][sharedBoardsName] = true
baseFeatureFlags := &serverModel.FeatureFlags{
BoardsFeatureFlags: "Feature1-Feature2",
}
basePluginSettings := &serverModel.PluginSettings{
Directory: &stringRef,
Plugins: basePlugins,
}
falseRef := false
intRef := 365
baseDataRetentionSettings := &serverModel.DataRetentionSettings{
EnableBoardsDeletion: &falseRef,
BoardsRetentionDays: &intRef,
}
baseConfig := &serverModel.Config{
FeatureFlags: baseFeatureFlags,
PluginSettings: *basePluginSettings,
DataRetentionSettings: *baseDataRetentionSettings,
}
t.Run("Test Load Plugin Success", func(t *testing.T) {
th := SetupTestHelper(t)
api := &plugintest.API{}
api.On("GetUnsanitizedConfig").Return(baseConfig)
api.On("GetConfig").Return(baseConfig)
p := Plugin{}
p.SetAPI(api)
p.server = th.Server
p.wsPluginAdapter = &FakePluginAdapter{}
err := p.OnConfigurationChange()
assert.NoError(t, err)
assert.Equal(t, 1, count)
// make sure both App and Server got updated
assert.True(t, p.server.Config().EnablePublicSharedBoards)
assert.True(t, p.server.App().GetClientConfig().EnablePublicSharedBoards)
assert.Equal(t, "true", p.server.Config().FeatureFlags["Feature1"])
assert.Equal(t, "true", p.server.Config().FeatureFlags["Feature2"])
assert.Equal(t, "", p.server.Config().FeatureFlags["Feature3"])
})
}
var count = 0
type FakePluginAdapter struct {
ws.PluginAdapter
}
func (c *FakePluginAdapter) BroadcastConfigChange(clientConfig model.ClientConfig) {
count++
}

View File

@ -1,68 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"testing"
"github.com/golang/mock/gomock"
"github.com/mattermost/focalboard/server/server"
"github.com/mattermost/focalboard/server/services/config"
"github.com/mattermost/focalboard/server/services/permissions/localpermissions"
"github.com/mattermost/focalboard/server/services/store/mockstore"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
type TestHelper struct {
Server *server.Server
Store *mockstore.MockStore
}
func SetupTestHelper(t *testing.T) *TestHelper {
th := &TestHelper{}
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := mockstore.NewMockStore(ctrl)
th.Server = newTestServer(t, mockStore)
th.Store = mockStore
return th
}
func newTestServer(t *testing.T, mockStore *mockstore.MockStore) *server.Server {
config := &config.Configuration{
EnableDataRetention: false,
DataRetentionDays: 10,
FilesDriver: "local",
FilesPath: "./files",
WebPath: "/",
}
logger, err := mlog.NewLogger()
if err = logger.Configure("", config.LoggingCfgJSON, nil); err != nil {
panic(err)
}
mockStore.EXPECT().GetTeam(gomock.Any()).Return(nil, nil).AnyTimes()
mockStore.EXPECT().UpsertTeamSignupToken(gomock.Any()).AnyTimes()
mockStore.EXPECT().GetSystemSettings().AnyTimes()
mockStore.EXPECT().SetSystemSetting(gomock.Any(), gomock.Any()).AnyTimes()
permissionsService := localpermissions.New(mockStore, logger)
srv, err := server.New(server.Params{
Cfg: config,
DBStore: mockStore,
Logger: logger,
PermissionsService: permissionsService,
SkipTemplateInit: true,
})
if err != nil {
panic(err)
}
return srv
}

View File

@ -20,8 +20,8 @@ const manifestStr = `
"support_url": "https://github.com/mattermost/focalboard/issues",
"release_notes_url": "https://github.com/mattermost/focalboard/releases",
"icon_path": "assets/starter-template-icon.svg",
"version": "0.16.0",
"min_server_version": "6.0.0",
"version": "7.3.0",
"min_server_version": "7.0.0",
"server": {
"executables": {
"darwin-amd64": "server/dist/plugin-darwin-amd64",

View File

@ -1,129 +0,0 @@
package main
import (
"errors"
"fmt"
"github.com/mattermost/focalboard/server/services/config"
"github.com/mattermost/focalboard/server/services/notify/notifymentions"
"github.com/mattermost/focalboard/server/services/notify/notifysubscriptions"
"github.com/mattermost/focalboard/server/services/notify/plugindelivery"
"github.com/mattermost/focalboard/server/services/permissions"
"github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/ws"
pluginapi "github.com/mattermost/mattermost-plugin-api"
apierrors "github.com/mattermost/mattermost-plugin-api/errors"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
const (
botUsername = "boards"
botDisplayname = "Boards"
botDescription = "Created by Boards plugin."
)
type notifyBackendParams struct {
cfg *config.Configuration
client *pluginapi.Client
permissions permissions.PermissionsService
store store.Store
wsAdapter ws.Adapter
serverRoot string
logger *mlog.Logger
}
func createMentionsNotifyBackend(params notifyBackendParams) (*notifymentions.Backend, error) {
delivery, err := createDelivery(params.client, params.serverRoot)
if err != nil {
return nil, err
}
backendParams := notifymentions.BackendParams{
Store: params.store,
Permissions: params.permissions,
Delivery: delivery,
WSAdapter: params.wsAdapter,
Logger: params.logger,
}
backend := notifymentions.New(backendParams)
return backend, nil
}
func createSubscriptionsNotifyBackend(params notifyBackendParams) (*notifysubscriptions.Backend, error) {
delivery, err := createDelivery(params.client, params.serverRoot)
if err != nil {
return nil, err
}
backendParams := notifysubscriptions.BackendParams{
ServerRoot: params.serverRoot,
Store: params.store,
Permissions: params.permissions,
Delivery: delivery,
WSAdapter: params.wsAdapter,
Logger: params.logger,
NotifyFreqCardSeconds: params.cfg.NotifyFreqCardSeconds,
NotifyFreqBoardSeconds: params.cfg.NotifyFreqBoardSeconds,
}
backend := notifysubscriptions.New(backendParams)
return backend, nil
}
func createDelivery(client *pluginapi.Client, serverRoot string) (*plugindelivery.PluginDelivery, error) {
bot := &model.Bot{
Username: botUsername,
DisplayName: botDisplayname,
Description: botDescription,
}
botID, err := client.Bot.EnsureBot(bot)
if err != nil {
return nil, fmt.Errorf("failed to ensure %s bot: %w", botDisplayname, err)
}
pluginAPI := &pluginAPIAdapter{client: client}
return plugindelivery.New(botID, serverRoot, pluginAPI), nil
}
type pluginAPIAdapter struct {
client *pluginapi.Client
}
func (da *pluginAPIAdapter) GetDirectChannel(userID1, userID2 string) (*model.Channel, error) {
return da.client.Channel.GetDirect(userID1, userID2)
}
func (da *pluginAPIAdapter) CreatePost(post *model.Post) error {
return da.client.Post.CreatePost(post)
}
func (da *pluginAPIAdapter) GetUserByID(userID string) (*model.User, error) {
return da.client.User.Get(userID)
}
func (da *pluginAPIAdapter) GetUserByUsername(name string) (*model.User, error) {
return da.client.User.GetByUsername(name)
}
func (da *pluginAPIAdapter) GetTeamMember(teamID string, userID string) (*model.TeamMember, error) {
return da.client.Team.GetMember(teamID, userID)
}
func (da *pluginAPIAdapter) GetChannelByID(channelID string) (*model.Channel, error) {
return da.client.Channel.Get(channelID)
}
func (da *pluginAPIAdapter) GetChannelMember(channelID string, userID string) (*model.ChannelMember, error) {
return da.client.Channel.GetMember(channelID, userID)
}
func (da *pluginAPIAdapter) IsErrNotFound(err error) bool {
return errors.Is(err, apierrors.ErrNotFound)
}

View File

@ -1,79 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
"github.com/mattermost/focalboard/server/auth"
"github.com/mattermost/focalboard/server/server"
"github.com/mattermost/focalboard/server/services/config"
"github.com/mattermost/focalboard/server/services/notify"
"github.com/mattermost/focalboard/server/services/permissions/mmpermissions"
"github.com/mattermost/focalboard/server/services/store"
"github.com/mattermost/focalboard/server/services/store/mattermostauthlayer"
"github.com/mattermost/focalboard/server/services/store/sqlstore"
"github.com/mattermost/focalboard/server/ws"
"github.com/mattermost/focalboard/mattermost-plugin/server/boards"
pluginapi "github.com/mattermost/mattermost-plugin-api"
"github.com/mattermost/mattermost-plugin-api/cluster"
mmModel "github.com/mattermost/mattermost-server/v6/model"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/shared/markdown"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
const (
boardsFeatureFlagName = "BoardsFeatureFlags"
pluginName = "focalboard"
sharedBoardsName = "enablepublicsharedboards"
notifyFreqCardSecondsKey = "notify_freq_card_seconds"
notifyFreqBoardSecondsKey = "notify_freq_board_seconds"
)
var ErrInsufficientLicense = errors.New("appropriate license required")
type BoardsEmbed struct {
OriginalPath string `json:"originalPath"`
TeamID string `json:"teamID"`
ViewID string `json:"viewID"`
BoardID string `json:"boardID"`
CardID string `json:"cardID"`
ReadToken string `json:"readToken,omitempty"`
}
var ErrPluginNotAllowed = errors.New("boards plugin not allowed while Boards product enabled")
// Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes.
type Plugin struct {
plugin.MattermostPlugin
// configurationLock synchronizes access to the configuration.
configurationLock sync.RWMutex
// configuration is the active plugin configuration. Consult getConfiguration and
// setConfiguration for usage.
configuration *configuration
server *server.Server
wsPluginAdapter ws.PluginAdapterInterface
boardsApp *boards.BoardsApp
}
func (p *Plugin) OnActivate() error {
mmconfig := p.API.GetUnsanitizedConfig()
if p.API.GetConfig().FeatureFlags.BoardsProduct {
p.API.LogError(ErrPluginNotAllowed.Error())
return ErrPluginNotAllowed
}
client := pluginapi.NewClient(p.API, p.Driver)
sqlDB, err := client.Store.GetMasterDB()
if err != nil {
return fmt.Errorf("error initializing the DB: %w", err)
}
logger, _ := mlog.NewLogger()
pluginTargetFactory := newPluginTargetFactory(&client.Log)
@ -81,234 +40,71 @@ func (p *Plugin) OnActivate() error {
TargetFactory: pluginTargetFactory.createTarget,
}
cfgJSON := defaultLoggingConfig()
err = logger.Configure("", cfgJSON, factories)
err := logger.Configure("", cfgJSON, factories)
if err != nil {
return err
}
baseURL := ""
if mmconfig.ServiceSettings.SiteURL != nil {
baseURL = *mmconfig.ServiceSettings.SiteURL
}
serverID := client.System.GetDiagnosticID()
cfg := p.createBoardsConfig(*mmconfig, baseURL, serverID)
adapter := newServiceAPIAdapter(p.API, client.Store, logger)
storeParams := sqlstore.Params{
DBType: cfg.DBType,
ConnectionString: cfg.DBConfigString,
TablePrefix: cfg.DBTablePrefix,
Logger: logger,
DB: sqlDB,
IsPlugin: true,
NewMutexFn: func(name string) (*cluster.Mutex, error) {
return cluster.NewMutex(p.API, name)
},
PluginAPI: &p.API,
}
var db store.Store
db, err = sqlstore.New(storeParams)
boardsApp, err := boards.NewBoardsApp(adapter)
if err != nil {
return fmt.Errorf("error initializing the DB: %w", err)
}
if cfg.AuthMode == server.MattermostAuthMod {
layeredStore, err2 := mattermostauthlayer.New(cfg.DBType, sqlDB, db, logger, p.API)
if err2 != nil {
return fmt.Errorf("error initializing the DB: %w", err2)
}
db = layeredStore
return fmt.Errorf("cannot activate plugin: %w", err)
}
permissionsService := mmpermissions.New(db, p.API)
p.wsPluginAdapter = ws.NewPluginAdapter(p.API, auth.New(cfg, db, permissionsService), db, logger)
backendParams := notifyBackendParams{
cfg: cfg,
client: client,
store: db,
permissions: permissionsService,
wsAdapter: p.wsPluginAdapter,
serverRoot: baseURL + "/boards",
logger: logger,
}
var notifyBackends []notify.Backend
mentionsBackend, err := createMentionsNotifyBackend(backendParams)
if err != nil {
return fmt.Errorf("error creating mention notifications backend: %w", err)
}
notifyBackends = append(notifyBackends, mentionsBackend)
subscriptionsBackend, err2 := createSubscriptionsNotifyBackend(backendParams)
if err2 != nil {
return fmt.Errorf("error creating subscription notifications backend: %w", err2)
}
notifyBackends = append(notifyBackends, subscriptionsBackend)
mentionsBackend.AddListener(subscriptionsBackend)
params := server.Params{
Cfg: cfg,
SingleUserToken: "",
DBStore: db,
Logger: logger,
ServerID: serverID,
WSAdapter: p.wsPluginAdapter,
NotifyBackends: notifyBackends,
PermissionsService: permissionsService,
}
server, err := server.New(params)
if err != nil {
fmt.Println("ERROR INITIALIZING THE SERVER", err)
return err
}
p.server = server
return server.Start()
p.boardsApp = boardsApp
return p.boardsApp.Start()
}
func (p *Plugin) createBoardsConfig(mmconfig mmModel.Config, baseURL string, serverID string) *config.Configuration {
filesS3Config := config.AmazonS3Config{}
if mmconfig.FileSettings.AmazonS3AccessKeyId != nil {
filesS3Config.AccessKeyID = *mmconfig.FileSettings.AmazonS3AccessKeyId
}
if mmconfig.FileSettings.AmazonS3SecretAccessKey != nil {
filesS3Config.SecretAccessKey = *mmconfig.FileSettings.AmazonS3SecretAccessKey
}
if mmconfig.FileSettings.AmazonS3Bucket != nil {
filesS3Config.Bucket = *mmconfig.FileSettings.AmazonS3Bucket
}
if mmconfig.FileSettings.AmazonS3PathPrefix != nil {
filesS3Config.PathPrefix = *mmconfig.FileSettings.AmazonS3PathPrefix
}
if mmconfig.FileSettings.AmazonS3Region != nil {
filesS3Config.Region = *mmconfig.FileSettings.AmazonS3Region
}
if mmconfig.FileSettings.AmazonS3Endpoint != nil {
filesS3Config.Endpoint = *mmconfig.FileSettings.AmazonS3Endpoint
}
if mmconfig.FileSettings.AmazonS3SSL != nil {
filesS3Config.SSL = *mmconfig.FileSettings.AmazonS3SSL
}
if mmconfig.FileSettings.AmazonS3SignV2 != nil {
filesS3Config.SignV2 = *mmconfig.FileSettings.AmazonS3SignV2
}
if mmconfig.FileSettings.AmazonS3SSE != nil {
filesS3Config.SSE = *mmconfig.FileSettings.AmazonS3SSE
}
if mmconfig.FileSettings.AmazonS3Trace != nil {
filesS3Config.Trace = *mmconfig.FileSettings.AmazonS3Trace
// OnConfigurationChange is invoked when configuration changes may have been made.
func (p *Plugin) OnConfigurationChange() error {
// Have we been setup by OnActivate?
if p.boardsApp == nil {
return nil
}
enableTelemetry := false
if mmconfig.LogSettings.EnableDiagnostics != nil {
enableTelemetry = *mmconfig.LogSettings.EnableDiagnostics
}
enablePublicSharedBoards := false
if mmconfig.PluginSettings.Plugins[pluginName][sharedBoardsName] == true {
enablePublicSharedBoards = true
}
featureFlags := parseFeatureFlags(mmconfig.FeatureFlags.ToMap())
return &config.Configuration{
ServerRoot: baseURL + "/plugins/focalboard",
Port: -1,
DBType: *mmconfig.SqlSettings.DriverName,
DBConfigString: *mmconfig.SqlSettings.DataSource,
DBTablePrefix: "focalboard_",
UseSSL: false,
SecureCookie: true,
WebPath: path.Join(*mmconfig.PluginSettings.Directory, "focalboard", "pack"),
FilesDriver: *mmconfig.FileSettings.DriverName,
FilesPath: *mmconfig.FileSettings.Directory,
FilesS3Config: filesS3Config,
MaxFileSize: *mmconfig.FileSettings.MaxFileSize,
Telemetry: enableTelemetry,
TelemetryID: serverID,
WebhookUpdate: []string{},
SessionExpireTime: 2592000,
SessionRefreshTime: 18000,
LocalOnly: false,
EnableLocalMode: false,
LocalModeSocketLocation: "",
AuthMode: "mattermost",
EnablePublicSharedBoards: enablePublicSharedBoards,
FeatureFlags: featureFlags,
NotifyFreqCardSeconds: getPluginSettingInt(mmconfig, notifyFreqCardSecondsKey, 120),
NotifyFreqBoardSeconds: getPluginSettingInt(mmconfig, notifyFreqBoardSecondsKey, 86400),
EnableDataRetention: *mmconfig.DataRetentionSettings.EnableBoardsDeletion,
DataRetentionDays: *mmconfig.DataRetentionSettings.BoardsRetentionDays,
}
}
func getPluginSetting(mmConfig mmModel.Config, key string) (interface{}, bool) {
plugin, ok := mmConfig.PluginSettings.Plugins[pluginName]
if !ok {
return nil, false
}
val, ok := plugin[key]
if !ok {
return nil, false
}
return val, true
}
func getPluginSettingInt(mmConfig mmModel.Config, key string, def int) int {
val, ok := getPluginSetting(mmConfig, key)
if !ok {
return def
}
valFloat, ok := val.(float64)
if !ok {
return def
}
return int(math.Round(valFloat))
}
func parseFeatureFlags(configFeatureFlags map[string]string) map[string]string {
featureFlags := make(map[string]string)
for key, value := range configFeatureFlags {
// Break out FeatureFlags and pass remaining
if key == boardsFeatureFlagName {
for _, flag := range strings.Split(value, "-") {
featureFlags[flag] = "true"
}
} else {
featureFlags[key] = value
}
}
return featureFlags
return p.boardsApp.OnConfigurationChange()
}
func (p *Plugin) OnWebSocketConnect(webConnID, userID string) {
p.wsPluginAdapter.OnWebSocketConnect(webConnID, userID)
p.boardsApp.OnWebSocketConnect(webConnID, userID)
}
func (p *Plugin) OnWebSocketDisconnect(webConnID, userID string) {
p.wsPluginAdapter.OnWebSocketDisconnect(webConnID, userID)
p.boardsApp.OnWebSocketDisconnect(webConnID, userID)
}
func (p *Plugin) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mmModel.WebSocketRequest) {
p.wsPluginAdapter.WebSocketMessageHasBeenPosted(webConnID, userID, req)
func (p *Plugin) WebSocketMessageHasBeenPosted(webConnID, userID string, req *mm_model.WebSocketRequest) {
p.boardsApp.WebSocketMessageHasBeenPosted(webConnID, userID, req)
}
func (p *Plugin) OnDeactivate() error {
return p.server.Shutdown()
return p.boardsApp.Stop()
}
func (p *Plugin) OnPluginClusterEvent(_ *plugin.Context, ev mmModel.PluginClusterEvent) {
p.wsPluginAdapter.HandleClusterEvent(ev)
func (p *Plugin) OnPluginClusterEvent(ctx *plugin.Context, ev mm_model.PluginClusterEvent) {
p.boardsApp.OnPluginClusterEvent(ctx, ev)
}
func (p *Plugin) MessageWillBePosted(ctx *plugin.Context, post *mm_model.Post) (*mm_model.Post, string) {
return p.boardsApp.MessageWillBePosted(ctx, post)
}
func (p *Plugin) MessageWillBeUpdated(ctx *plugin.Context, newPost, oldPost *mm_model.Post) (*mm_model.Post, string) {
return p.boardsApp.MessageWillBeUpdated(ctx, newPost, oldPost)
}
func (p *Plugin) OnCloudLimitsUpdated(limits *mm_model.ProductLimits) {
p.boardsApp.OnCloudLimitsUpdated(limits)
}
func (p *Plugin) RunDataRetention(nowTime, batchSize int64) (int64, error) {
return p.boardsApp.RunDataRetention(nowTime, batchSize)
}
// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world.
func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Request) {
router := p.server.GetRootRouter()
router.ServeHTTP(w, r)
func (p *Plugin) ServeHTTP(ctx *plugin.Context, w http.ResponseWriter, r *http.Request) {
p.boardsApp.ServeHTTP(ctx, w, r)
}
func defaultLoggingConfig() string {
@ -351,181 +147,3 @@ func defaultLoggingConfig() string {
}
}`
}
func (p *Plugin) MessageWillBePosted(_ *plugin.Context, post *mmModel.Post) (*mmModel.Post, string) {
return postWithBoardsEmbed(post), ""
}
func (p *Plugin) MessageWillBeUpdated(_ *plugin.Context, newPost, _ *mmModel.Post) (*mmModel.Post, string) {
return postWithBoardsEmbed(newPost), ""
}
func postWithBoardsEmbed(post *mmModel.Post) *mmModel.Post {
if _, ok := post.GetProps()["boards"]; ok {
post.AddProp("boards", nil)
}
firstLink, newPostMessage := getFirstLinkAndShortenAllBoardsLink(post.Message)
post.Message = newPostMessage
if firstLink == "" {
return post
}
u, err := url.Parse(firstLink)
if err != nil {
return post
}
// Trim away the first / because otherwise after we split the string, the first element in the array is a empty element
urlPath := u.Path
urlPath = strings.TrimPrefix(urlPath, "/")
urlPath = strings.TrimSuffix(urlPath, "/")
pathSplit := strings.Split(strings.ToLower(urlPath), "/")
queryParams := u.Query()
if len(pathSplit) == 0 {
return post
}
teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
if teamID != "" && boardID != "" && viewID != "" && cardID != "" {
b, _ := json.Marshal(BoardsEmbed{
TeamID: teamID,
BoardID: boardID,
ViewID: viewID,
CardID: cardID,
ReadToken: queryParams.Get("r"),
OriginalPath: u.RequestURI(),
})
BoardsPostEmbed := &mmModel.PostEmbed{
Type: mmModel.PostEmbedBoards,
Data: string(b),
}
if post.Metadata == nil {
post.Metadata = &mmModel.PostMetadata{}
}
post.Metadata.Embeds = []*mmModel.PostEmbed{BoardsPostEmbed}
post.AddProp("boards", string(b))
}
return post
}
func getFirstLinkAndShortenAllBoardsLink(postMessage string) (firstLink, newPostMessage string) {
newPostMessage = postMessage
seenLinks := make(map[string]bool)
markdown.Inspect(postMessage, func(blockOrInline interface{}) bool {
if autoLink, ok := blockOrInline.(*markdown.Autolink); ok {
link := autoLink.Destination()
if firstLink == "" {
firstLink = link
}
if seen := seenLinks[link]; !seen && isBoardsLink(link) {
// TODO: Make sure that <Jump To Card> is Internationalized and translated to the Users Language preference
markdownFormattedLink := fmt.Sprintf("[%s](%s)", "<Jump To Card>", link)
newPostMessage = strings.ReplaceAll(newPostMessage, link, markdownFormattedLink)
seenLinks[link] = true
}
}
if inlineLink, ok := blockOrInline.(*markdown.InlineLink); ok {
if link := inlineLink.Destination(); firstLink == "" {
firstLink = link
}
}
return true
})
return firstLink, newPostMessage
}
func returnBoardsParams(pathArray []string) (teamID, boardID, viewID, cardID string) {
// The reason we are doing this search for the first instance of boards or plugins is to take into account URL subpaths
index := -1
for i := 0; i < len(pathArray); i++ {
if pathArray[i] == "boards" || pathArray[i] == "plugins" {
index = i
break
}
}
if index == -1 {
return teamID, boardID, viewID, cardID
}
// If at index, the parameter in the path is boards,
// then we've copied this directly as logged in user of that board
// If at index, the parameter in the path is plugins,
// then we've copied this from a shared board
// For card links copied on a non-shared board, the path looks like {...Mattermost Url}.../boards/team/teamID/boardID/viewID/cardID
// For card links copied on a shared board, the path looks like
// {...Mattermost Url}.../plugins/focalboard/team/teamID/shared/boardID/viewID/cardID?r=read_token
// This is a non-shared board card link
if len(pathArray)-index == 6 && pathArray[index] == "boards" && pathArray[index+1] == "team" {
teamID = pathArray[index+2]
boardID = pathArray[index+3]
viewID = pathArray[index+4]
cardID = pathArray[index+5]
} else if len(pathArray)-index == 8 && pathArray[index] == "plugins" &&
pathArray[index+1] == "focalboard" &&
pathArray[index+2] == "team" &&
pathArray[index+4] == "shared" { // This is a shared board card link
teamID = pathArray[index+3]
boardID = pathArray[index+5]
viewID = pathArray[index+6]
cardID = pathArray[index+7]
}
return teamID, boardID, viewID, cardID
}
func isBoardsLink(link string) bool {
u, err := url.Parse(link)
if err != nil {
return false
}
urlPath := u.Path
urlPath = strings.TrimPrefix(urlPath, "/")
urlPath = strings.TrimSuffix(urlPath, "/")
pathSplit := strings.Split(strings.ToLower(urlPath), "/")
if len(pathSplit) == 0 {
return false
}
teamID, boardID, viewID, cardID := returnBoardsParams(pathSplit)
return teamID != "" && boardID != "" && viewID != "" && cardID != ""
}
func (p *Plugin) RunDataRetention(nowTime, batchSize int64) (int64, error) {
p.server.Logger().Debug("Boards RunDataRetention")
license := p.server.Store().GetLicense()
if license == nil || !(*license.Features.DataRetention) {
return 0, ErrInsufficientLicense
}
if p.server.Config().EnableDataRetention {
boardsRetentionDays := p.server.Config().DataRetentionDays
endTimeBoards := convertDaysToCutoff(boardsRetentionDays, time.Unix(nowTime/1000, 0))
return p.server.Store().RunDataRetention(endTimeBoards, batchSize)
}
return 0, nil
}
func convertDaysToCutoff(days int, now time.Time) int64 {
upToStartOfDay := now.AddDate(0, 0, -days)
cutoffDate := time.Date(upToStartOfDay.Year(), upToStartOfDay.Month(), upToStartOfDay.Day(), 0, 0, 0, 0, time.Local)
return cutoffDate.UnixNano() / int64(time.Millisecond)
}

View File

@ -1,193 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/stretchr/testify/assert"
)
func testHandler(w http.ResponseWriter, r *http.Request) {
// A very simple health check.
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
// In the future we could report back on the status of our DB, or our cache
// (e.g. Redis) by performing a simple PING, and include them in the response.
io.WriteString(w, "Hello, world!")
}
func TestServeHTTP(t *testing.T) {
assert := assert.New(t)
th := SetupTestHelper(t)
plugin := Plugin{}
testHandler := http.HandlerFunc(testHandler)
th.Server.GetRootRouter().Handle("/", testHandler)
plugin.server = th.Server
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/", nil)
// plugin.server.
plugin.ServeHTTP(nil, w, r)
result := w.Result()
assert.NotNil(result)
defer result.Body.Close()
bodyBytes, err := ioutil.ReadAll(result.Body)
assert.Nil(err)
bodyString := string(bodyBytes)
assert.Equal("Hello, world!", bodyString)
}
func TestSetConfiguration(t *testing.T) {
plugin := Plugin{}
boolTrue := true
boolFalse := false
stringRef := ""
baseFeatureFlags := &model.FeatureFlags{}
basePluginSettings := &model.PluginSettings{
Directory: &stringRef,
}
driverName := "testDriver"
dataSource := "testDirectory"
baseSQLSettings := &model.SqlSettings{
DriverName: &driverName,
DataSource: &dataSource,
}
directory := "testDirectory"
maxFileSize := int64(1048576000)
baseFileSettings := &model.FileSettings{
DriverName: &driverName,
Directory: &directory,
MaxFileSize: &maxFileSize,
}
days := 365
baseDataRetentionSettings := &model.DataRetentionSettings{
EnableBoardsDeletion: &boolFalse,
BoardsRetentionDays: &days,
}
baseConfig := &model.Config{
FeatureFlags: baseFeatureFlags,
PluginSettings: *basePluginSettings,
SqlSettings: *baseSQLSettings,
FileSettings: *baseFileSettings,
DataRetentionSettings: *baseDataRetentionSettings,
}
t.Run("test enable telemetry", func(t *testing.T) {
logSettings := &model.LogSettings{
EnableDiagnostics: &boolTrue,
}
mmConfig := baseConfig
mmConfig.LogSettings = *logSettings
config := plugin.createBoardsConfig(*mmConfig, "", "testId")
assert.Equal(t, true, config.Telemetry)
assert.Equal(t, "testId", config.TelemetryID)
})
t.Run("test enable shared boards", func(t *testing.T) {
mmConfig := baseConfig
mmConfig.PluginSettings.Plugins = make(map[string]map[string]interface{})
mmConfig.PluginSettings.Plugins[pluginName] = make(map[string]interface{})
mmConfig.PluginSettings.Plugins[pluginName][sharedBoardsName] = true
config := plugin.createBoardsConfig(*mmConfig, "", "")
assert.Equal(t, true, config.EnablePublicSharedBoards)
})
t.Run("test boards feature flags", func(t *testing.T) {
featureFlags := &model.FeatureFlags{
TestFeature: "test",
TestBoolFeature: boolTrue,
BoardsFeatureFlags: "hello_world-myTest",
}
mmConfig := baseConfig
mmConfig.FeatureFlags = featureFlags
config := plugin.createBoardsConfig(*mmConfig, "", "")
assert.Equal(t, "true", config.FeatureFlags["TestBoolFeature"])
assert.Equal(t, "test", config.FeatureFlags["TestFeature"])
assert.Equal(t, "true", config.FeatureFlags["hello_world"])
assert.Equal(t, "true", config.FeatureFlags["myTest"])
})
}
func TestRunDataRetention(t *testing.T) {
th := SetupTestHelper(t)
plugin := Plugin{}
plugin.server = th.Server
now := time.Now().UnixNano()
t.Run("test null license", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(nil)
_, err := plugin.RunDataRetention(now, 10)
assert.NotNil(t, err)
assert.Equal(t, ErrInsufficientLicense, err)
})
t.Run("test invalid license", func(t *testing.T) {
falseValue := false
th.Store.EXPECT().GetLicense().Return(
&model.License{
Features: &model.Features{
DataRetention: &falseValue,
},
},
)
_, err := plugin.RunDataRetention(now, 10)
assert.NotNil(t, err)
assert.Equal(t, ErrInsufficientLicense, err)
})
t.Run("test valid license, invalid config", func(t *testing.T) {
trueValue := true
th.Store.EXPECT().GetLicense().Return(
&model.License{
Features: &model.Features{
DataRetention: &trueValue,
},
})
count, err := plugin.RunDataRetention(now, 10)
assert.Nil(t, err)
assert.Equal(t, int64(0), count)
})
t.Run("test valid license, valid config", func(t *testing.T) {
trueValue := true
th.Store.EXPECT().GetLicense().Return(
&model.License{
Features: &model.Features{
DataRetention: &trueValue,
},
})
th.Store.EXPECT().RunDataRetention(gomock.Any(), int64(10)).Return(int64(100), nil)
plugin.server.Config().EnableDataRetention = true
count, err := plugin.RunDataRetention(now, 10)
assert.Nil(t, err)
assert.Equal(t, int64(100), count)
})
}

View File

@ -1,12 +1,12 @@
{
"extends": [
"plugin:mattermost/react",
"plugin:react/recommended",
"plugin:cypress/recommended",
"plugin:jquery/deprecated"
],
"plugins": [
"react",
"babel",
"mattermost",
"import",
"cypress",
"jquery",

File diff suppressed because it is too large Load Diff

View File

@ -26,8 +26,11 @@
"@babel/preset-typescript": "7.16.7",
"@babel/runtime": "7.17.8",
"@formatjs/ts-transformer": "3.9.2",
"@testing-library/react": "11.2.7",
"@testing-library/user-event": "14.2.1",
"@types/enzyme": "3.10.11",
"@types/jest": "27.4.1",
"@types/lodash": "4.14.182",
"@types/node": "17.0.23",
"@types/react": "17.0.42",
"@types/react-dom": "17.0.14",
@ -35,6 +38,7 @@
"@types/react-redux": "7.1.23",
"@types/react-router-dom": "5.3.3",
"@types/react-transition-group": "4.4.4",
"@types/redux-mock-store": "1.0.3",
"@typescript-eslint/eslint-plugin": "5.16.0",
"@typescript-eslint/parser": "5.16.0",
"babel-eslint": "10.1.0",
@ -49,7 +53,7 @@
"eslint-plugin-header": "3.1.1",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-jquery": "1.5.1",
"eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#070ce792d105482ffb2b27cfc0b7e78b3d20acee",
"eslint-plugin-mattermost": "github:mattermost/eslint-plugin-mattermost#46ad99355644a719bf32082f472048f526605181",
"eslint-plugin-no-only-tests": "2.6.0",
"eslint-plugin-react": "7.29.4",
"eslint-plugin-react-hooks": "4.3.0",
@ -62,9 +66,12 @@
"imagemin-pngquant": "^9.0.2",
"imagemin-svgo": "^10.0.1",
"imagemin-webp": "7.0.0",
"isomorphic-fetch": "3.0.0",
"jest": "27.5.1",
"jest-canvas-mock": "2.3.1",
"jest-junit": "13.0.0",
"jest-mock": "27.5.1",
"redux-mock-store": "1.5.4",
"sass": "1.49.9",
"sass-loader": "12.6.0",
"style-loader": "3.3.1",
@ -78,14 +85,15 @@
"glob-parent": "6.0.2",
"marked": ">=4.0.12",
"mattermost-redux": "5.33.1",
"react": "^17.0.2",
"react-dom": "17.0.2",
"react-intl": "^5.24.7",
"react-redux": "^7.2.8",
"react-router-dom": "5.2.0",
"trim-newlines": "4.0.2"
},
"jest": {
"snapshotSerializers": [
"<rootDir>/node_modules/enzyme-to-json/serializer"
],
"testEnvironment": "jsdom",
"testPathIgnorePatterns": [
"/node_modules/",
"/non_npm_dependencies/"
@ -100,9 +108,12 @@
],
"moduleNameMapper": {
"^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "identity-obj-proxy",
"^.+\\.(css|less|scss)$": "identity-obj-proxy",
"^.+\\.(scss|css)$": "<rootDir>/tests/style_mock.json",
"^.*i18n.*\\.(json)$": "<rootDir>/tests/i18n_mock.json",
"^bundle-loader\\?lazy\\!(.*)$": "$1"
"^bundle-loader\\?lazy\\!(.*)$": "$1",
"^react$": "<rootDir>/../../webapp/node_modules/react",
"^react-redux$": "<rootDir>/../../webapp/node_modules/react-redux",
"^react-intl$": "<rootDir>/../../webapp/node_modules/react-intl"
},
"moduleDirectories": [
"",

View File

@ -0,0 +1,383 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/boardSelector renders with no results 1`] = `
<div>
<div
class="focalboard-body"
>
<div
class="Dialog dialog-back BoardSelector"
>
<div
class="backdrop"
/>
<div
class="wrapper"
>
<div
class="dialog"
role="dialog"
>
<div
class="toolbar"
>
<button
aria-label="Close dialog"
class="IconButton size--medium"
title="Close dialog"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="toolbar--right"
/>
</div>
<div
class="BoardSelectorBody"
>
<div
class="head"
>
<div
class="heading"
>
<h3
class="text-heading4"
>
Link boards
</h3>
<button
class="Button emphasis--secondary"
type="button"
>
<span>
Create a board
</span>
</button>
</div>
<div
class="queryWrapper"
>
<i
class="CompassIcon icon-magnify MagnifyIcon"
/>
<input
class="searchQuery"
maxlength="100"
placeholder="Search for boards"
type="text"
/>
</div>
</div>
<div
class="searchResults"
>
<div
class="noResults"
>
<div
class="iconWrapper"
>
<i
class="CompassIcon icon-magnify MagnifyIcon"
/>
</div>
<h4
class="text-heading4"
>
No results for "test"
</h4>
<span>
Check the spelling or try another search.
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`components/boardSelector renders with some results 1`] = `
<div>
<div
class="focalboard-body"
>
<div
class="Dialog dialog-back BoardSelector"
>
<div
class="backdrop"
/>
<div
class="wrapper"
>
<div
class="dialog"
role="dialog"
>
<div
class="toolbar"
>
<button
aria-label="Close dialog"
class="IconButton size--medium"
title="Close dialog"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="toolbar--right"
/>
</div>
<div
class="BoardSelectorBody"
>
<div
class="head"
>
<div
class="heading"
>
<h3
class="text-heading4"
>
Link boards
</h3>
<button
class="Button emphasis--secondary"
type="button"
>
<span>
Create a board
</span>
</button>
</div>
<div
class="queryWrapper"
>
<i
class="CompassIcon icon-magnify MagnifyIcon"
/>
<input
class="searchQuery"
maxlength="100"
placeholder="Search for boards"
type="text"
/>
</div>
</div>
<div
class="searchResults"
>
<div
class="BoardSelectorItem"
>
<span
class="icon"
/>
<div
class="resultLine"
>
<div
class="resultTitle"
>
Untitled board
</div>
<div
class="resultDescription"
/>
</div>
<div
class="linkUnlinkButton"
>
<button
class="Button emphasis--primary"
type="button"
>
<span>
Link
</span>
</button>
</div>
</div>
<div
class="BoardSelectorItem"
>
<span
class="icon"
/>
<div
class="resultLine"
>
<div
class="resultTitle"
>
Untitled board
</div>
<div
class="resultDescription"
/>
</div>
<div
class="linkUnlinkButton"
>
<button
class="Button emphasis--primary"
type="button"
>
<span>
Link
</span>
</button>
</div>
</div>
<div
class="BoardSelectorItem"
>
<span
class="icon"
/>
<div
class="resultLine"
>
<div
class="resultTitle"
>
Untitled board
</div>
<div
class="resultDescription"
/>
</div>
<div
class="linkUnlinkButton"
>
<button
class="Button emphasis--primary"
type="button"
>
<span>
Link
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`components/boardSelector renders without start searching 1`] = `
<div>
<div
class="focalboard-body"
>
<div
class="Dialog dialog-back BoardSelector"
>
<div
class="backdrop"
/>
<div
class="wrapper"
>
<div
class="dialog"
role="dialog"
>
<div
class="toolbar"
>
<button
aria-label="Close dialog"
class="IconButton size--medium"
title="Close dialog"
type="button"
>
<i
class="CompassIcon icon-close CloseIcon"
/>
</button>
<div
class="toolbar--right"
/>
</div>
<div
class="BoardSelectorBody"
>
<div
class="head"
>
<div
class="heading"
>
<h3
class="text-heading4"
>
Link boards
</h3>
<button
class="Button emphasis--secondary"
type="button"
>
<span>
Create a board
</span>
</button>
</div>
<div
class="queryWrapper"
>
<i
class="CompassIcon icon-magnify MagnifyIcon"
/>
<input
class="searchQuery"
maxlength="100"
placeholder="Search for boards"
type="text"
/>
</div>
</div>
<div
class="searchResults"
>
<div
class="noResults introScreen"
>
<div
class="iconWrapper"
>
<i
class="CompassIcon icon-magnify MagnifyIcon"
/>
</div>
<h4
class="text-heading4"
>
Search for boards
</h4>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,109 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/boardSelectorItem renders board without title 1`] = `
<div>
<div
class="BoardSelectorItem"
>
<span
class="icon"
/>
<div
class="resultLine"
>
<div
class="resultTitle"
>
Untitled board
</div>
<div
class="resultDescription"
/>
</div>
<div
class="linkUnlinkButton"
>
<button
class="Button emphasis--secondary"
type="button"
>
<span>
Unlink
</span>
</button>
</div>
</div>
</div>
`;
exports[`components/boardSelectorItem renders linked board 1`] = `
<div>
<div
class="BoardSelectorItem"
>
<span
class="icon"
/>
<div
class="resultLine"
>
<div
class="resultTitle"
>
Test title
</div>
<div
class="resultDescription"
/>
</div>
<div
class="linkUnlinkButton"
>
<button
class="Button emphasis--secondary"
type="button"
>
<span>
Unlink
</span>
</button>
</div>
</div>
</div>
`;
exports[`components/boardSelectorItem renders not linked board 1`] = `
<div>
<div
class="BoardSelectorItem"
>
<span
class="icon"
/>
<div
class="resultLine"
>
<div
class="resultTitle"
>
Test title
</div>
<div
class="resultDescription"
/>
</div>
<div
class="linkUnlinkButton"
>
<button
class="Button emphasis--primary"
type="button"
>
<span>
Link
</span>
</button>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,146 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/rhsChannelBoardItem render board 1`] = `
<div>
<div
class="RHSChannelBoardItem"
>
<div
class="board-info"
>
<span
class="title"
>
Test board
</span>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
<div
class="description"
/>
<div
class="date"
>
Last update at: July 08, 8:10 PM
</div>
</div>
</div>
`;
exports[`components/rhsChannelBoardItem render board with menu open 1`] = `
<div>
<div
class="RHSChannelBoardItem"
>
<div
class="board-info"
>
<span
class="title"
>
Test board
</span>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
<div
class="Menu noselect left fixed"
>
<div
class="menu-contents"
>
<div
class="menu-options"
>
<div>
<div
aria-label="Unlink board"
class="MenuOption TextOption menu-option"
role="button"
>
<div
class="d-flex"
>
<i
class="CompassIcon icon-trash-can-outline DeleteIcon trash-can-outline"
/>
</div>
<div
class="menu-name"
>
Unlink board
</div>
<div
class="noicon"
/>
</div>
</div>
</div>
<div
class="menu-spacer hideOnWidescreen"
/>
<div
class="menu-options hideOnWidescreen"
>
<div
aria-label="Cancel"
class="MenuOption TextOption menu-option menu-cancel"
role="button"
>
<div
class="d-flex"
>
<div
class="noicon"
/>
</div>
<div
class="menu-name"
>
Cancel
</div>
<div
class="noicon"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="description"
/>
<div
class="date"
>
Last update at: July 08, 8:10 PM
</div>
</div>
</div>
`;

View File

@ -0,0 +1,146 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/rhsChannelBoards renders the RHS for channel boards 1`] = `
<div>
<div
class="focalboard-body"
>
<div
class="RHSChannelBoards"
>
<div
class="rhs-boards-header"
>
<span
class="linked-boards"
>
Linked boards
</span>
<button
class="Button emphasis--primary"
type="button"
>
<i
class="CompassIcon icon-plus AddIcon"
/>
<span>
Add
</span>
</button>
</div>
<div
class="rhs-boards-list"
>
<div
class="RHSChannelBoardItem"
>
<div
class="board-info"
>
<span
class="title"
>
Untitled board
</span>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
<div
class="description"
/>
<div
class="date"
>
Last update at: July 08, 8:10 PM
</div>
</div>
<div
class="RHSChannelBoardItem"
>
<div
class="board-info"
>
<span
class="title"
>
Untitled board
</span>
<div
aria-label="menuwrapper"
class="MenuWrapper"
role="button"
>
<button
class="IconButton"
type="button"
>
<i
class="CompassIcon icon-dots-horizontal OptionsIcon"
/>
</button>
</div>
</div>
<div
class="description"
/>
<div
class="date"
>
Last update at: July 08, 8:10 PM
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`components/rhsChannelBoards renders with empty list of boards 1`] = `
<div>
<div
class="focalboard-body"
>
<div
class="RHSChannelBoards empty"
>
<h2>
No boards are linked to Channel Name yet
</h2>
<div
class="empty-paragraph"
>
Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.
</div>
<div
class="boards-screenshots"
>
<img
src="undefined/public/boards-screenshots.png"
/>
</div>
<button
class="Button emphasis--primary size--medium"
type="button"
>
<span>
Link boards to Channel Name
</span>
</button>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/rhsChannelBoardsHeader renders the header 1`] = `
<div>
<div>
<img
class="boards-rhs-header-logo"
src="undefined/public/app-bar-icon.png"
/>
<span>
Boards
</span>
<span
class="style--none sidebar--right__title__subtitle"
>
Channel Name
</span>
</div>
</div>
`;

View File

@ -0,0 +1,166 @@
.BoardSelector {
color: rgba(var(--center-channel-color-rgb));
.dialog {
.toolbar {
flex-direction: row-reverse;
}
}
.heading {
display: flex;
align-items: center;
margin-right: 35px;
margin-top: 5px;
.text-heading4 {
flex-grow: 1;
}
}
.wrapper {
.dialog {
position: relative;
width: 600px;
height: 450px;
.toolbar {
flex-direction: row-reverse;
padding: 0;
position: absolute;
right: 18px;
top: 18px;
}
}
.confirmation-dialog-box {
.dialog {
position: fixed;
width: 500px;
height: auto;
}
}
}
.BoardSelectorBody {
display: flex;
flex-direction: column;
height: 100%;
.head {
margin-top: 6px;
}
.head,
.searchResults {
padding: 0 32px 32px;
}
.searchResults {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
margin-bottom: 18px;
border-top: solid 1px rgba(var(--center-channel-color-rgb), 0.16);
.searchResult {
height: 40px;
justify-content: flex-start;
align-items: center;
display: flex;
padding: 0 24px;
cursor: pointer;
overflow: hidden;
&.freesize {
height: unset;
}
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.08);
}
}
.iconWrapper {
width: 120px;
height: 120px;
background: rgba(var(--center-channel-color-rgb), 0.08);
border-radius: 50%;
display: flex;
/*! align-content: center; */
justify-content: center;
flex-direction: column;
align-items: center;
}
.CompassIcon.icon-magnify.MagnifyIcon {
font-size: 72px !important;
color: var(--button-bg);
display: inline-block;
}
.noResults {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 500px;
margin: 0 auto;
overflow: hidden;
word-wrap: anywhere;
margin-top: 30px;
.text-heading4 {
line-height: 120%;
}
&.introScreen {
margin-top: 48px;
}
}
}
.text-heading1 {
font-size: 12px !important;
}
h5 {
font-size: 12px;
margin-top: 0;
}
.queryWrapper {
position: relative;
display: flex;
flex-direction: row;
margin-top: 24px;
.MagnifyIcon {
position: absolute;
left: 13px;
font-size: 18px;
top: 14px;
width: 20px;
height: 20px;
opacity: 0.48;
}
.searchQuery {
height: 48px;
font-size: 14px;
border-radius: 4px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
background: var(--center-channel-bg);
color: var(--center-channel-color);
padding: 0 40px;
flex: 1;
transition: border 0.15s ease-in;
&:focus {
border-color: var(--button-bg);
box-shadow: inset 0 0 0 1px var(--button-bg);
}
}
}
}
}

View File

@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render, screen, act} from '@testing-library/react'
import {mocked} from 'jest-mock'
import userEvent from '@testing-library/user-event'
import octoClient from '../../../../webapp/src/octoClient'
import {mockStateStore} from '../../../../webapp/src/testUtils'
import {createBoard} from '../../../../webapp/src/blocks/board'
import {wrapIntl} from '../../../../webapp/src/testUtils'
import BoardSelector from './boardSelector'
jest.mock('../../../../webapp/src/octoClient')
const mockedOctoClient = mocked(octoClient, true)
const wait = (ms: number) => {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}
describe('components/boardSelector', () => {
const team = {
id: 'team-id',
name: 'team',
display_name: 'Team name',
}
const state = {
teams: {
allTeams: [team],
current: team,
currentId: team.id,
},
language: {
value: 'en',
},
boards: {
linkToChannel: 'channel-id',
},
}
it('renders without start searching', async () => {
const store = mockStateStore([], state)
const {container} = render(wrapIntl(
<ReduxProvider store={store}>
<BoardSelector/>
</ReduxProvider>
))
expect(container).toMatchSnapshot()
})
it('renders with no results', async () => {
mockedOctoClient.search.mockResolvedValueOnce([])
const store = mockStateStore([], state)
const {container} = render(wrapIntl(
<ReduxProvider store={store}>
<BoardSelector/>
</ReduxProvider>
))
await act(async () => {
const inputElement = screen.getByPlaceholderText('Search for boards')
await userEvent.type(inputElement, 'test')
await wait(300)
})
expect(container).toMatchSnapshot()
})
it('renders with some results', async () => {
mockedOctoClient.search.mockResolvedValueOnce([createBoard(), createBoard(), createBoard()])
const store = mockStateStore([], state)
const {container} = render(wrapIntl(
<ReduxProvider store={store}>
<BoardSelector/>
</ReduxProvider>
))
await act(async () => {
const inputElement = screen.getByPlaceholderText('Search for boards')
await userEvent.type(inputElement, 'test')
await wait(300)
})
expect(container).toMatchSnapshot()
})
})

View File

@ -0,0 +1,225 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useMemo, useCallback} from 'react'
import {IntlProvider, useIntl, FormattedMessage} from 'react-intl'
import debounce from 'lodash/debounce'
import {getMessages} from '../../../../webapp/src/i18n'
import {getLanguage} from '../../../../webapp/src/store/language'
import {useWebsockets} from '../../../../webapp/src/hooks/websockets'
import octoClient from '../../../../webapp/src/octoClient'
import mutator from '../../../../webapp/src/mutator'
import {getCurrentTeamId, getAllTeams, Team} from '../../../../webapp/src/store/teams'
import {createBoard, BoardsAndBlocks, Board} from '../../../../webapp/src/blocks/board'
import {createBoardView} from '../../../../webapp/src/blocks/boardView'
import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks'
import {EmptySearch, EmptyResults} from '../../../../webapp/src/components/searchDialog/searchDialog'
import ConfirmationDialog from '../../../../webapp/src/components/confirmationDialogBox'
import Dialog from '../../../../webapp/src/components/dialog'
import SearchIcon from '../../../../webapp/src/widgets/icons/search'
import Button from '../../../../webapp/src/widgets/buttons/button'
import {getCurrentLinkToChannel, setLinkToChannel} from '../../../../webapp/src/store/boards'
import TelemetryClient, {TelemetryCategory, TelemetryActions} from '../../../../webapp/src/telemetry/telemetryClient'
import {WSClient} from '../../../../webapp/src/wsclient'
import BoardSelectorItem from './boardSelectorItem'
import './boardSelector.scss'
const BoardSelector = () => {
const teamsById:Record<string, Team> = {}
useAppSelector(getAllTeams).forEach((t) => {
teamsById[t.id] = t
})
const intl = useIntl()
const teamId = useAppSelector(getCurrentTeamId)
const currentChannel = useAppSelector(getCurrentLinkToChannel)
const dispatch = useAppDispatch()
const [results, setResults] = useState<Array<Board>>([])
const [isSearching, setIsSearching] = useState<boolean>(false)
const [searchQuery, setSearchQuery] = useState<string>('')
const [showLinkBoardConfirmation, setShowLinkBoardConfirmation] = useState<Board|null>(null)
const searchHandler = useCallback(async (query: string): Promise<void> => {
setSearchQuery(query)
if (query.trim().length === 0 || !teamId) {
return
}
const items = await octoClient.search(teamId, query)
setResults(items)
setIsSearching(false)
}, [teamId])
const debouncedSearchHandler = useMemo(() => debounce(searchHandler, 200), [searchHandler])
const emptyResult = results.length === 0 && !isSearching && searchQuery
useWebsockets(teamId, (wsClient: WSClient) => {
const onChangeBoardHandler = (_: WSClient, boards: Board[]): void => {
const newResults = [...results]
let updated = false
results.forEach((board, idx) => {
for (const newBoard of boards) {
if (newBoard.id == board.id) {
newResults[idx] = newBoard
updated = true
}
}
})
if (updated) {
setResults(newResults)
}
}
wsClient.addOnChange(onChangeBoardHandler, 'board')
return () => {
wsClient.removeOnChange(onChangeBoardHandler, 'board')
}
}, [results])
if (!teamId) {
return null
}
if (!currentChannel) {
return null
}
const linkBoard = async (board: Board, confirmed?: boolean): Promise<void> => {
if (!confirmed) {
setShowLinkBoardConfirmation(board)
return
}
const newBoard = createBoard({...board, channelId: currentChannel})
await mutator.updateBoard(newBoard, board, 'linked channel')
setShowLinkBoardConfirmation(null)
}
const unlinkBoard = async (board: Board): Promise<void> => {
const newBoard = createBoard({...board, channelId: ''})
await mutator.updateBoard(newBoard, board, 'unlinked channel')
}
const newLinkedBoard = async (): Promise<void> => {
const board = {...createBoard(), teamId, channelId: currentChannel}
const view = createBoardView()
view.fields.viewType = 'board'
view.parentId = board.id
view.boardId = board.id
view.title = intl.formatMessage({id: 'View.NewBoardTitle', defaultMessage: 'Board view'})
await mutator.createBoardsAndBlocks(
{boards: [board], blocks: [view]},
'add linked board',
async (bab: BoardsAndBlocks): Promise<void> => {
const windowAny: any = window
const newBoard = bab.boards[0]
// TODO: Maybe create a new event for create linked board
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.CreateBoard, {board: newBoard?.id})
windowAny.WebappUtils.browserHistory.push(`/boards/team/${teamId}/${newBoard.id}`)
dispatch(setLinkToChannel(''))
},
async () => {return},
)
}
return (
<div className='focalboard-body'>
<Dialog
className='BoardSelector'
onClose={() => {
dispatch(setLinkToChannel(''))
setResults([])
setIsSearching(false)
setSearchQuery('')
setShowLinkBoardConfirmation(null)
}}
>
{showLinkBoardConfirmation &&
<ConfirmationDialog
dialogBox={{
heading: intl.formatMessage({id: 'boardSelector.confirm-link-board', defaultMessage: 'Link board to channel'}),
subText: intl.formatMessage({
id: 'boardSelector.confirm-link-board-subtext',
defaultMessage: 'Linking the "{boardName}" board to this channel would give all members of this channel "Editor" access to the board. Are you sure you want to link it?'
}, {boardName: showLinkBoardConfirmation.title}),
confirmButtonText: intl.formatMessage({id: 'boardSelector.confirm-link-board-button', defaultMessage: 'Yes, link board'}),
onConfirm: () => linkBoard(showLinkBoardConfirmation, true),
onClose: () => setShowLinkBoardConfirmation(null),
}}
/>}
<div className='BoardSelectorBody'>
<div className='head'>
<div className='heading'>
<h3 className='text-heading4'>
<FormattedMessage
id='boardSelector.title'
defaultMessage='Link boards'
/>
</h3>
<Button
onClick={() => newLinkedBoard()}
emphasis='secondary'
>
<FormattedMessage
id='boardSelector.create-a-board'
defaultMessage='Create a board'
/>
</Button>
</div>
<div className='queryWrapper'>
<SearchIcon/>
<input
className='searchQuery'
placeholder={intl.formatMessage({id: 'boardSelector.search-for-boards', defaultMessage:'Search for boards'})}
type='text'
onChange={(e) => debouncedSearchHandler(e.target.value)}
autoFocus={true}
maxLength={100}
/>
</div>
</div>
<div className='searchResults'>
{/*When there are results to show*/}
{searchQuery && results.length > 0 &&
results.map((result) => (<BoardSelectorItem
key={result.id}
item={result}
linkBoard={linkBoard}
unlinkBoard={unlinkBoard}
currentChannel={currentChannel}
/>))}
{/*when user searched for something and there were no results*/}
{emptyResult && <EmptyResults query={searchQuery}/>}
{/*default state, when user didn't search for anything. This is the initial screen*/}
{!emptyResult && !searchQuery && <EmptySearch/>}
</div>
</div>
</Dialog>
</div>
)
}
const IntlBoardSelector = () => {
const language = useAppSelector<string>(getLanguage)
return (
<IntlProvider
locale={language.split(/[_]/)[0]}
messages={getMessages(language)}
>
<BoardSelector/>
</IntlProvider>
)
}
export default IntlBoardSelector

View File

@ -0,0 +1,38 @@
.BoardSelectorItem {
display: flex;
overflow: hidden;
flex-direction: row;
padding: 10px 0;
margin: 0 35px;
.icon {
align-items: flex-start;
margin-right: 10px;
}
.resultLine {
flex-grow: 1;
width: 80%;
align-self: center;
}
.resultTitle {
overflow: hidden;
max-width: 60%;
text-overflow: ellipsis;
white-space: nowrap;
}
.resultDescription {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.7;
}
.linkUnlinkButton {
display: flex;
align-self: center;
align-items: center;
}
}

View File

@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {createBoard} from '../../../../webapp/src/blocks/board'
import {wrapIntl} from '../../../../webapp/src/testUtils'
import BoardSelectorItem from './boardSelectorItem'
describe('components/boardSelectorItem', () => {
it('renders board without title', async () => {
const board = createBoard()
board.title = ""
const {container} = render(wrapIntl(
<BoardSelectorItem
item={board}
currentChannel={board.channelId || ''}
linkBoard={jest.fn()}
unlinkBoard={jest.fn()}
/>,
))
expect(container).toMatchSnapshot()
})
it('renders linked board', async () => {
const board = createBoard()
board.title = "Test title"
const {container} = render(wrapIntl(
<BoardSelectorItem
item={board}
currentChannel={board.channelId || ''}
linkBoard={jest.fn()}
unlinkBoard={jest.fn()}
/>,
))
expect(container).toMatchSnapshot()
})
it('renders not linked board', async () => {
const board = createBoard()
board.title = "Test title"
const {container} = render(wrapIntl(
<BoardSelectorItem
item={board}
currentChannel={'other-channel'}
linkBoard={jest.fn()}
unlinkBoard={jest.fn()}
/>,
))
expect(container).toMatchSnapshot()
})
it('call handler on link', async () => {
const board = createBoard()
const linkBoard = jest.fn()
const unlinkBoard = jest.fn()
render(wrapIntl(
<BoardSelectorItem
item={board}
currentChannel={'other-channel'}
linkBoard={linkBoard}
unlinkBoard={unlinkBoard}
/>,
))
const buttonElement = screen.getByRole('button')
await userEvent.click(buttonElement)
expect(linkBoard).toBeCalledWith(board)
expect(unlinkBoard).not.toBeCalled()
})
it('call handler on unlink', async () => {
const board = createBoard()
const linkBoard = jest.fn()
const unlinkBoard = jest.fn()
render(wrapIntl(
<BoardSelectorItem
item={board}
currentChannel={board.channelId || ''}
linkBoard={linkBoard}
unlinkBoard={unlinkBoard}
/>,
))
const buttonElement = screen.getByRole('button')
await userEvent.click(buttonElement)
expect(unlinkBoard).toBeCalledWith(board)
expect(linkBoard).not.toBeCalled()
})
})

View File

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {useIntl, FormattedMessage} from 'react-intl'
import {Board} from '../../../../webapp/src/blocks/board'
import Button from '../../../../webapp/src/widgets/buttons/button'
import './boardSelectorItem.scss'
type Props = {
item: Board
currentChannel: string
linkBoard: (board: Board) => void
unlinkBoard: (board: Board) => void
}
const BoardSelectorItem = (props: Props) => {
const {item, currentChannel} = props
const intl = useIntl()
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})
const resultTitle = item.title || untitledBoardTitle
return (
<div className='BoardSelectorItem'>
<span className='icon'>{item.icon}</span>
<div className='resultLine'>
<div className='resultTitle'>{resultTitle}</div>
<div className='resultDescription'>{item.description}</div>
</div>
<div className='linkUnlinkButton'>
{item.channelId === currentChannel &&
<Button
onClick={() => props.unlinkBoard(item)}
emphasis='secondary'
>
<FormattedMessage
id='boardSelector.unlink'
defaultMessage='Unlink'
/>
</Button>}
{item.channelId !== currentChannel &&
<Button
onClick={() => props.linkBoard(item)}
emphasis='primary'
>
<FormattedMessage
id='boardSelector.link'
defaultMessage='Link'
/>
</Button>}
</div>
</div>
)
}
export default BoardSelectorItem

View File

@ -0,0 +1,98 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/boardsUnfurl/BoardsUnfurl renders normally 1`] = `
<div>
<a
class="FocalboardUnfurl"
href="http://localhost:8065/test"
rel="noopener noreferrer"
target="_blank"
>
<div
class="header"
>
<span
class="icon"
/>
<div
class="information"
>
<span
class="card_title"
>
test card
</span>
<span
class="board_title"
>
test board
</span>
</div>
</div>
<div
class="body"
>
<div />
</div>
<div
class="footer"
>
<div
class="avatar"
>
</div>
<div
class="timestamp_properties"
>
<div
class="properties"
/>
<span
class="post-preview__time"
>
Updated January 01, 1970, 12:00 AM
</span>
</div>
</div>
</a>
</div>
`;
exports[`components/boardsUnfurl/BoardsUnfurl renders when limited 1`] = `
<div>
<a
class="FocalboardUnfurl"
href="http://localhost:8065/test"
rel="noopener noreferrer"
target="_blank"
>
<div
class="header"
>
<span
class="icon"
/>
<div
class="information"
>
<span
class="card_title"
>
test card
</span>
<span
class="board_title"
>
test board
</span>
</div>
</div>
<p
class="limited"
>
Additional details are hidden due to the card being archived
</p>
</a>
</div>
`;

View File

@ -71,6 +71,12 @@
}
}
.limited {
font-size: 14px;
color: rgba(var(--center-channel-color-rgb), 0.6);
margin-top: 12px;
}
.footer {
display: flex;
align-items: center;

View File

@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {act, render} from '@testing-library/react'
import configureStore from 'redux-mock-store'
import {Provider as ReduxProvider} from 'react-redux'
import {mocked} from 'jest-mock'
import {Utils} from '../../../../../webapp/src/utils'
import {createCard} from '../../../../../webapp/src/blocks/card'
import {createBoard} from '../../../../../webapp/src/blocks/board'
import octoClient from '../../../../../webapp/src/octoClient'
import {wrapIntl} from '../../../../../webapp/src/testUtils'
import BoardsUnfurl from './boardsUnfurl'
jest.mock('../../../../../webapp/src/octoClient')
jest.mock('../../../../../webapp/src/utils')
const mockedOctoClient = mocked(octoClient, true)
const mockedUtils = mocked(Utils, true)
mockedUtils.createGuid = jest.requireActual('../../../../../webapp/src/utils').Utils.createGuid
mockedUtils.blockTypeToIDType = jest.requireActual('../../../../../webapp/src/utils').Utils.blockTypeToIDType
mockedUtils.displayDateTime = jest.requireActual('../../../../../webapp/src/utils').Utils.displayDateTime
describe('components/boardsUnfurl/BoardsUnfurl', () => {
const team = {
id: 'team-id',
name: 'team',
display_name: 'Team name',
}
beforeEach(() => {
// This is done to the websocket not to try to connect directly
mockedUtils.isFocalboardPlugin.mockReturnValue(true)
jest.clearAllMocks()
})
it('renders normally', async () => {
const mockStore = configureStore([])
const store = mockStore({
language: {
value: 'en',
},
teams: {
allTeams: [team],
current: team,
},
})
const cards = [{...createCard(), title: 'test card', updateAt: 12345}]
const board = {...createBoard(), title: 'test board'}
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
mockedOctoClient.getBoard.mockResolvedValueOnce(board)
const component = (
<ReduxProvider store={store}>
{wrapIntl(
<BoardsUnfurl
embed={{data: JSON.stringify({workspaceID: "foo", cardID: cards[0].id, boardID: board.id, readToken: "abc", originalPath: "/test"})}}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(mockedOctoClient.getBoard).toBeCalledWith(board.id)
expect(mockedOctoClient.getBlocksWithBlockID).toBeCalledWith(cards[0].id, board.id, "abc")
expect(container).toMatchSnapshot()
})
it('renders when limited', async () => {
const mockStore = configureStore([])
const store = mockStore({
language: {
value: 'en',
},
teams: {
allTeams: [team],
current: team,
},
})
const cards = [{...createCard(), title: 'test card', limited: true, updateAt: 12345}]
const board = {...createBoard(), title: 'test board'}
mockedOctoClient.getBlocksWithBlockID.mockResolvedValueOnce(cards)
mockedOctoClient.getBoard.mockResolvedValueOnce(board)
const component = (
<ReduxProvider store={store}>
{wrapIntl(
<BoardsUnfurl
embed={{data: JSON.stringify({workspaceID: "foo", cardID: cards[0].id, boardID: board.id, readToken: "abc", originalPath: "/test"})}}
/>,
)}
</ReduxProvider>
)
let container: Element | DocumentFragment | null = null
await act(async () => {
const result = render(component)
container = result.container
})
expect(container).toMatchSnapshot()
})
})

View File

@ -1,22 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react'
import {IntlProvider, FormattedMessage} from 'react-intl'
import {connect} from 'react-redux'
import {IntlProvider, FormattedMessage, useIntl} from 'react-intl'
import {GlobalState} from 'mattermost-redux/types/store'
import {getCurrentUserLocale} from 'mattermost-redux/selectors/entities/i18n'
import WithWebSockets from '../../../../../webapp/src/components/withWebSockets'
import {useWebsockets} from '../../../../../webapp/src/hooks/websockets'
import {getLanguage} from '../../../../../webapp/src/store/language'
import {useAppSelector} from '../../../../../webapp/src/store/hooks'
import {getCurrentTeamId} from '../../../../../webapp/src/store/teams'
import {WSClient, MMWebSocketClient} from '../../../../../webapp/src/wsclient'
import manifest from '../../manifest'
import {getMessages} from './../../../../../webapp/src/i18n'
import {Utils} from './../../../../../webapp/src/utils'
import {Block} from './../../../../../webapp/src/blocks/block'
import {Card} from './../../../../../webapp/src/blocks/card'
import {Board} from './../../../../../webapp/src/blocks/board'
import {ContentBlock} from './../../../../../webapp/src/blocks/contentBlock'
import octoClient from './../../../../../webapp/src/octoClient'
const Avatar = (window as any).Components.Avatar
const Timestamp = (window as any).Components.Timestamp
const imageURLForUser = (window as any).Components.imageURLForUser
const noop = () => ''
const Avatar = (window as any).Components?.Avatar || noop
const imageURLForUser = (window as any).Components?.imageURLForUser || noop
import './boardsUnfurl.scss'
import '../../../../../webapp/src/styles/labels.scss'
@ -25,15 +33,7 @@ type Props = {
embed: {
data: string,
},
locale: string,
}
function mapStateToProps(state: GlobalState) {
const locale = getCurrentUserLocale(state)
return {
locale,
}
webSocketClient?: MMWebSocketClient,
}
class FocalboardEmbeddedData {
@ -53,13 +53,16 @@ class FocalboardEmbeddedData {
}
}
const BoardsUnfurl = (props: Props): JSX.Element => {
export const BoardsUnfurl = (props: Props): JSX.Element => {
if (!props.embed || !props.embed.data) {
return <></>
}
const {embed, locale} = props
const intl = useIntl()
const {embed, webSocketClient} = props
const focalboardInformation: FocalboardEmbeddedData = new FocalboardEmbeddedData(embed.data)
const currentTeamId = useAppSelector(getCurrentTeamId)
const {teamID, cardID, boardID, readToken, originalPath} = focalboardInformation
const baseURL = window.location.origin
@ -110,6 +113,26 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
fetchData()
}, [originalPath])
useWebsockets(currentTeamId, (wsClient: WSClient) => {
const onChangeHandler = (_: WSClient, blocks: Block[]): void => {
const cardBlock: Block|undefined = blocks.find(b => b.id === cardID)
if (cardBlock && !cardBlock.deleteAt) {
setCard(cardBlock as Card)
}
const contentBlock: Block|undefined = blocks.find(b => b.id === content?.id)
if (contentBlock && !contentBlock.deleteAt) {
setContent(contentBlock)
}
}
wsClient.addOnChange(onChangeHandler, 'block')
return () => {
wsClient.removeOnChange(onChangeHandler, 'block')
}
}, [cardID, content?.id])
let remainder = 0
let html = ''
const propertiesToDisplay: Array<Record<string, string>> = []
@ -159,10 +182,7 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
}
return (
<IntlProvider
messages={getMessages(locale)}
locale={locale}
>
<WithWebSockets manifest={manifest} webSocketClient={webSocketClient}>
{!loading && (!card || !board) && <></>}
{!loading && card && board &&
<a
@ -182,7 +202,7 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
</div>
{/* Body of the Card*/}
{html !== '' &&
{!card.limited && html !== '' &&
<div className='body'>
<div
dangerouslySetInnerHTML={{__html: html}}
@ -190,69 +210,79 @@ const BoardsUnfurl = (props: Props): JSX.Element => {
</div>
}
{/* Footer of the Card*/}
<div className='footer'>
<div className='avatar'>
<Avatar
size={'md'}
url={imageURLForUser(card.createdBy)}
className={'avatar-post-preview'}
{card.limited &&
<p className='limited'>
<FormattedMessage
id='BoardsUnfurl.Limited'
defaultMessage={'Additional details are hidden due to the card being archived'}
/>
</div>
<div className='timestamp_properties'>
<div className='properties'>
{propertiesToDisplay.map((property) => (
<div
key={property.optionValue}
className={`property ${property.optionValueColour}`}
title={`${property.optionName}`}
style={{maxWidth: `${(1 / propertiesToDisplay.length) * 100}%`}}
>
{property.optionValue}
</div>
))}
{remainder > 0 &&
<span className='remainder'>
<FormattedMessage
id='BoardsUnfurl.Remainder'
defaultMessage='+{remainder} more'
values={{
remainder,
}}
/>
</span>
}
</div>
<span className='post-preview__time'>
<FormattedMessage
id='BoardsUnfurl.Updated'
defaultMessage='Updated {time}'
values={{
time: (
<Timestamp
value={card.updateAt}
units={[
'now',
'minute',
'hour',
'day',
]}
useTime={false}
day={'numeric'}
/>
),
}}
</p>}
{/* Footer of the Card*/}
{!card.limited &&
<div className='footer'>
<div className='avatar'>
<Avatar
size={'md'}
url={imageURLForUser(card.createdBy)}
className={'avatar-post-preview'}
/>
</span>
</div>
</div>
</div>
<div className='timestamp_properties'>
<div className='properties'>
{propertiesToDisplay.map((property) => (
<div
key={property.optionValue}
className={`property ${property.optionValueColour}`}
title={`${property.optionName}`}
style={{maxWidth: `${(1 / propertiesToDisplay.length) * 100}%`}}
>
{property.optionValue}
</div>
))}
{remainder > 0 &&
<span className='remainder'>
<FormattedMessage
id='BoardsUnfurl.Remainder'
defaultMessage='+{remainder} more'
values={{
remainder,
}}
/>
</span>
}
</div>
<span className='post-preview__time'>
<FormattedMessage
id='BoardsUnfurl.Updated'
defaultMessage='Updated {time}'
values={{
time: Utils.displayDateTime(new Date(card.updateAt), intl)
}}
/>
</span>
</div>
</div>}
</a>
}
{loading &&
<div style={{height: '302px'}}/>
}
</WithWebSockets>
)
}
const IntlBoardsUnfurl = (props: Props) => {
const language = useAppSelector<string>(getLanguage)
return (
<IntlProvider
locale={language.split(/[_]/)[0]}
messages={getMessages(language)}
>
<BoardsUnfurl {...props}/>
</IntlProvider>
)
}
export default connect(mapStateToProps)(BoardsUnfurl)
export default IntlBoardsUnfurl

View File

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Post} from 'mattermost-redux/types/posts'
const PostTypeCloudUpgradeNudge = (props: {post: Post}): JSX.Element => {
const ctaHandler = (e: React.MouseEvent) => {
e.preventDefault()
const windowAny = (window as any)
windowAny?.openPricingModal()()
}
// custom post type doesn't support styling via CSS stylesheet.
// Only styled components or react styles work there.
const ctaContainerStyle = {
padding: '12px',
borderRadius: '0 4px 4px 0',
border: '1px solid rgba(63, 67, 80, 0.16)',
borderLeft: '6px solid var(--link-color)',
width: 'max-content',
margin: '10px 0',
}
const ctaBtnStyle = {
background: 'var(--link-color)',
color: 'var(--center-channel-bg)',
border: 'none',
borderRadius: '4px',
padding: '8px 20px',
fontWeight: 600,
}
return (
<div className='PostTypeCloudUpgradeNudge'>
<span>{props.post.message}</span>
<div
style={ctaContainerStyle}
>
<button
onClick={ctaHandler}
style={ctaBtnStyle}
>
{'View upgrade options'}
</button>
</div>
</div>
)
}
export default PostTypeCloudUpgradeNudge

View File

@ -0,0 +1,36 @@
.RHSChannelBoardItem {
padding: 15px;
text-align: left;
border: 1px solid #cccccc;
border-radius: 5px;
cursor: pointer;
color: rgb(var(--center-channel-color-rgb));
.date {
color: #cccccc;
}
.board-info {
display: flex;
font-size: 16px;
.icon {
margin-right: 10px;
}
.title {
font-weight: 600;
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.description {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}

View File

@ -0,0 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {createBoard} from '../../../../webapp/src/blocks/board'
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
import RHSChannelBoardItem from './rhsChannelBoardItem'
describe('components/rhsChannelBoardItem', () => {
it('render board', async () => {
const state = {
teams: {
current: {
id: 'team-id',
name: 'team',
display_name: 'Team name',
},
},
}
const board = createBoard()
board.updateAt = 1657311058157
board.title = 'Test board'
const store = mockStateStore([], state)
const {container} = render(wrapIntl(
<ReduxProvider store={store}>
<RHSChannelBoardItem board={board} />
</ReduxProvider>
))
expect(container).toMatchSnapshot()
})
it('render board with menu open', async () => {
const state = {
teams: {
current: {
id: 'team-id',
name: 'team',
display_name: 'Team name',
},
},
}
const board = createBoard()
board.updateAt = 1657311058157
board.title = 'Test board'
const store = mockStateStore([], state)
const {container} = render(wrapIntl(
<ReduxProvider store={store}>
<RHSChannelBoardItem board={board} />
</ReduxProvider>
))
const buttonElement = screen.getByRole('button', {name: 'menuwrapper'})
await userEvent.click(buttonElement)
expect(container).toMatchSnapshot()
})
})

View File

@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage, useIntl} from 'react-intl'
import mutator from '../../../../webapp/src/mutator'
import {Utils} from '../../../../webapp/src/utils'
import {getCurrentTeam} from '../../../../webapp/src/store/teams'
import {createBoard, Board} from '../../../../webapp/src/blocks/board'
import {useAppSelector} from '../../../../webapp/src/store/hooks'
import IconButton from '../../../../webapp/src/widgets/buttons/iconButton'
import OptionsIcon from '../../../../webapp/src/widgets/icons/options'
import DeleteIcon from '../../../../webapp/src/widgets/icons/delete'
import Menu from '../../../../webapp/src/widgets/menu'
import MenuWrapper from '../../../../webapp/src/widgets/menuWrapper'
import './rhsChannelBoardItem.scss'
type Props = {
board: Board
}
const RHSChannelBoardItem = (props: Props) => {
const intl = useIntl()
const board = props.board
const team = useAppSelector(getCurrentTeam)
if (!team) {
return null
}
const handleBoardClicked = (boardID: string) => {
const windowAny: any = window
windowAny.WebappUtils.browserHistory.push(`/boards/team/${team.id}/${boardID}`)
}
const onUnlinkBoard = async (board: Board) => {
const newBoard = createBoard(board)
newBoard.channelId = ''
mutator.updateBoard(newBoard, board, 'unlinked channel')
}
const untitledBoardTitle = intl.formatMessage({id: 'ViewTitle.untitled-board', defaultMessage: 'Untitled board'})
return (
<div
onClick={() => handleBoardClicked(board.id)}
className='RHSChannelBoardItem'
>
<div className='board-info'>
{board.icon && <span className='icon'>{board.icon}</span>}
<span className='title'>{board.title || untitledBoardTitle}</span>
<MenuWrapper stopPropagationOnToggle={true}>
<IconButton icon={<OptionsIcon/>}/>
<Menu
fixed={true}
position='left'
>
<Menu.Text
key={`unlinkBoard-${board.id}`}
id='unlinkBoard'
name={intl.formatMessage({id: 'rhs-boards.unlink-board', defaultMessage: 'Unlink board'})}
icon={<DeleteIcon/>}
onClick={() => {
onUnlinkBoard(board)
}}
/>
</Menu>
</MenuWrapper>
</div>
<div className='description'>{board.description}</div>
<div className='date'>
<FormattedMessage
id='rhs-boards.last-update-at'
defaultMessage='Last update at: {datetime}'
values={{datetime: Utils.displayDateTime(new Date(board.updateAt), intl as any)}}
/>
</div>
</div>
)
}
export default RHSChannelBoardItem

View File

@ -0,0 +1,63 @@
.RHSChannelBoards {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
&.empty {
display: flex;
justify-content: center;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
padding: 60px;
}
.rhs-boards-header {
display: flex;
align-items: center;
min-height: 40px;
}
>h2 {
text-align: center;
word-wrap: anywhere;
}
.empty-paragraph {
text-align: justify;
text-align-last: center;
}
.boards-screenshots {
margin: 24px 0;
}
.linked-boards {
flex-grow: 1;
font-size: 16px;
font-weight: 600;
}
.rhs-boards-list {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.Button {
width: auto;
align-self: center;
max-width: 100%;
span {
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}

View File

@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render} from '@testing-library/react'
import {mocked} from 'jest-mock'
import thunk from 'redux-thunk'
import octoClient from '../../../../webapp/src/octoClient'
import {createBoard} from '../../../../webapp/src/blocks/board'
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
import RHSChannelBoards from './rhsChannelBoards'
jest.mock('../../../../webapp/src/octoClient')
const mockedOctoClient = mocked(octoClient, true)
describe('components/rhsChannelBoards', () => {
const board1 = createBoard()
board1.updateAt = 1657311058157
const board2 = createBoard()
const board3 = createBoard()
board3.updateAt = 1657311058157
board1.channelId = 'channel-id'
board3.channelId = 'channel-id'
const team = {
id: 'team-id',
name: 'team',
display_name: 'Team name',
}
const state = {
teams: {
allTeams: [team],
current: team,
currentId: team.id,
},
language: {
value: 'en',
},
boards: {
boards: {
[board1.id]: board1,
[board2.id]: board2,
[board3.id]: board3,
},
myBoardMemberships: {
[board1.id]: {boardId: board1.id, userId: 'user-id'},
[board2.id]: {boardId: board2.id, userId: 'user-id'},
[board3.id]: {boardId: board3.id, userId: 'user-id'},
},
},
channels: {
current: {
id: 'channel-id',
name: 'channel',
display_name: 'Channel Name',
type: 'O',
},
},
}
beforeEach(() => {
mockedOctoClient.getBoards.mockResolvedValue([board1, board2, board3])
jest.clearAllMocks()
})
it('renders the RHS for channel boards', async () => {
const store = mockStateStore([thunk], state)
const {container} = render(wrapIntl(
<ReduxProvider store={store}>
<RHSChannelBoards/>
</ReduxProvider>
))
expect(container).toMatchSnapshot()
})
it('renders with empty list of boards', async () => {
const localState = {...state, boards: {...state.boards, boards: {}}}
const store = mockStateStore([thunk], localState)
const {container} = render(wrapIntl(
<ReduxProvider store={store}>
<RHSChannelBoards/>
</ReduxProvider>
))
expect(container).toMatchSnapshot()
})
})

View File

@ -0,0 +1,158 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react'
import {FormattedMessage, IntlProvider, useIntl} from 'react-intl'
import {getMessages} from '../../../../webapp/src/i18n'
import {getLanguage} from '../../../../webapp/src/store/language'
import {useWebsockets} from '../../../../webapp/src/hooks/websockets'
import {Board, BoardMember} from '../../../../webapp/src/blocks/board'
import {getCurrentTeamId} from '../../../../webapp/src/store/teams'
import {loadBoards} from '../../../../webapp/src/store/initialLoad'
import {getCurrentChannel} from '../../../../webapp/src/store/channels'
import {getMySortedBoards, setLinkToChannel, updateBoards, updateMembers} from '../../../../webapp/src/store/boards'
import {useAppSelector, useAppDispatch} from '../../../../webapp/src/store/hooks'
import AddIcon from '../../../../webapp/src/widgets/icons/add'
import Button from '../../../../webapp/src/widgets/buttons/button'
import {WSClient} from '../../../../webapp/src/wsclient'
import RHSChannelBoardItem from './rhsChannelBoardItem'
import './rhsChannelBoards.scss'
const boardsScreenshots = (window as any).baseURL + '/public/boards-screenshots.png'
const RHSChannelBoards = () => {
const boards = useAppSelector(getMySortedBoards)
const teamId = useAppSelector(getCurrentTeamId)
const currentChannel = useAppSelector(getCurrentChannel)
const dispatch = useAppDispatch()
const intl = useIntl()
useEffect(() => {
dispatch(loadBoards())
}, [])
useWebsockets(teamId || '', (wsClient: WSClient) => {
const onChangeBoardHandler = (_: WSClient, boards: Board[]): void => {
dispatch(updateBoards(boards))
}
const onChangeMemberHandler = (_: WSClient, members: BoardMember[]): void => {
dispatch(updateMembers(members))
}
wsClient.addOnChange(onChangeBoardHandler, 'board')
wsClient.addOnChange(onChangeMemberHandler, 'boardMembers')
return () => {
wsClient.removeOnChange(onChangeBoardHandler, 'board')
wsClient.removeOnChange(onChangeMemberHandler, 'boardMembers')
}
}, [])
if (!boards) {
return null
}
if (!teamId) {
return null
}
if (!currentChannel) {
return null
}
const channelBoards = boards.filter((b) => b.channelId === currentChannel.id)
let channelName = currentChannel.display_name
let headerChannelName = currentChannel.display_name
if (currentChannel.type === 'D') {
channelName = intl.formatMessage({id: 'rhs-boards.dm', defaultMessage: 'DM'})
headerChannelName = intl.formatMessage({id: 'rhs-boards.header.dm', defaultMessage: 'this Direct Message'})
} else if (currentChannel.type === 'G') {
channelName = intl.formatMessage({id: 'rhs-boards.gm', defaultMessage: 'GM'})
headerChannelName = intl.formatMessage({id: 'rhs-boards.header.gm', defaultMessage: 'this Group Message'})
}
if (channelBoards.length === 0) {
return (
<div className='focalboard-body'>
<div className='RHSChannelBoards empty'>
<h2>
<FormattedMessage
id='rhs-boards.no-boards-linked-to-channel'
defaultMessage='No boards are linked to {channelName} yet'
values={{channelName: headerChannelName}}
/>
</h2>
<div className='empty-paragraph'>
<FormattedMessage
id='rhs-boards.no-boards-linked-to-channel-description'
defaultMessage='Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view.'
/>
</div>
<div className='boards-screenshots'><img src={boardsScreenshots}/></div>
<Button
onClick={() => dispatch(setLinkToChannel(currentChannel.id))}
emphasis='primary'
size='medium'
>
<FormattedMessage
id='rhs-boards.link-boards-to-channel'
defaultMessage='Link boards to {channelName}'
values={{channelName: channelName}}
/>
</Button>
</div>
</div>
)
}
return (
<div className='focalboard-body'>
<div className='RHSChannelBoards'>
<div className='rhs-boards-header'>
<span className='linked-boards'>
<FormattedMessage
id='rhs-boards.linked-boards'
defaultMessage='Linked boards'
/>
</span>
<Button
onClick={() => dispatch(setLinkToChannel(currentChannel.id))}
icon={<AddIcon/>}
emphasis='primary'
>
<FormattedMessage
id='rhs-boards.add'
defaultMessage='Add'
/>
</Button>
</div>
<div className='rhs-boards-list'>
{channelBoards.map((b) => (
<RHSChannelBoardItem
key={b.id}
board={b}
/>))}
</div>
</div>
</div>
)
}
const IntlRHSChannelBoards = () => {
const language = useAppSelector<string>(getLanguage)
return (
<IntlProvider
locale={language.split(/[_]/)[0]}
messages={getMessages(language)}
>
<RHSChannelBoards/>
</IntlProvider>
)
}
export default IntlRHSChannelBoards

View File

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {Provider as ReduxProvider} from 'react-redux'
import {render} from '@testing-library/react'
import {mockStateStore, wrapIntl} from '../../../../webapp/src/testUtils'
import RHSChannelBoardsHeader from './rhsChannelBoardsHeader'
describe('components/rhsChannelBoardsHeader', () => {
it('renders the header', async () => {
const state = {
language: {
value: 'en',
},
channels: {
current: {
id: 'channel-id',
name: 'channel',
display_name: 'Channel Name',
type: 'O',
},
},
}
const store = mockStateStore([], state)
const {container} = render(wrapIntl(
<ReduxProvider store={store}>
<RHSChannelBoardsHeader/>
</ReduxProvider>
))
expect(container).toMatchSnapshot()
})
})

View File

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react'
import {FormattedMessage, IntlProvider} from 'react-intl'
import {getMessages} from '../../../../webapp/src/i18n'
import {getLanguage} from '../../../../webapp/src/store/language'
import {getCurrentChannel} from '../../../../webapp/src/store/channels'
import {useAppSelector} from '../../../../webapp/src/store/hooks'
const RHSChannelBoardsHeader = () => {
const appBarIconURL = (window as any).baseURL + '/public/app-bar-icon.png'
const currentChannel = useAppSelector(getCurrentChannel)
const language = useAppSelector<string>(getLanguage)
if (!currentChannel) {
return null
}
return (
<IntlProvider
locale={language.split(/[_]/)[0]}
messages={getMessages(language)}
>
<div>
<img
className='boards-rhs-header-logo'
src={appBarIconURL}
/>
<span>
<FormattedMessage
id='rhs-channel-boards-header.title'
defaultMessage='Boards'
/>
</span>
<span className='style--none sidebar--right__title__subtitle'>{currentChannel.display_name}</span>
</div>
</IntlProvider>
)
}
export default RHSChannelBoardsHeader

View File

@ -12,6 +12,8 @@ import {GlobalState} from 'mattermost-redux/types/store'
import {selectTeam} from 'mattermost-redux/actions/teams'
import {SuiteWindow} from '../../../webapp/src/types/index'
import {UserSettings} from '../../../webapp/src/userSettings'
const windowAny = (window as SuiteWindow)
windowAny.baseURL = '/plugins/focalboard'
@ -21,6 +23,9 @@ windowAny.isFocalboardPlugin = true
import App from '../../../webapp/src/app'
import store from '../../../webapp/src/store'
import {setTeam} from '../../../webapp/src/store/teams'
import WithWebSockets from '../../../webapp/src/components/withWebSockets'
import {setChannel} from '../../../webapp/src/store/channels'
import {initialLoad} from '../../../webapp/src/store/initialLoad'
import {Utils} from '../../../webapp/src/utils'
import GlobalHeader from '../../../webapp/src/components/globalHeader/globalHeader'
import FocalboardIcon from '../../../webapp/src/widgets/icons/logo'
@ -34,12 +39,18 @@ import '../../../webapp/src/styles/labels.scss'
import octoClient from '../../../webapp/src/octoClient'
import BoardsUnfurl from './components/boardsUnfurl/boardsUnfurl'
import RHSChannelBoards from './components/rhsChannelBoards'
import RHSChannelBoardsHeader from './components/rhsChannelBoardsHeader'
import BoardSelector from './components/boardSelector'
import wsClient, {
MMWebSocketClient,
ACTION_UPDATE_BLOCK,
ACTION_UPDATE_CLIENT_CONFIG,
ACTION_UPDATE_SUBSCRIPTION,
ACTION_UPDATE_CATEGORY, ACTION_UPDATE_BOARD_CATEGORY, ACTION_UPDATE_BOARD,
ACTION_UPDATE_CARD_LIMIT_TIMESTAMP,
ACTION_UPDATE_CATEGORY,
ACTION_UPDATE_BOARD_CATEGORY,
ACTION_UPDATE_BOARD,
} from './../../../webapp/src/wsclient'
import manifest from './manifest'
@ -49,6 +60,7 @@ import ErrorBoundary from './error_boundary'
import {PluginRegistry} from './types/mattermost-webapp'
import './plugin.scss'
import CloudUpgradeNudge from "./components/cloudUpgradeNudge/cloudUpgradeNudge"
function getSubpath(siteURL: string): string {
const url = new URL(siteURL)
@ -87,12 +99,12 @@ function customHistory() {
}
const pathName = event.data.message?.pathName
if (!pathName || !pathName.startsWith(windowAny.frontendBaseURL)) {
if (!pathName || !pathName.startsWith('/boards')) {
return
}
Utils.log(`Navigating Boards to ${pathName}`)
history.replace(pathName.replace(windowAny.frontendBaseURL, ''))
history.replace(pathName.replace('/boards', ''))
})
}
return {
@ -118,8 +130,6 @@ function customHistory() {
let browserHistory: History<unknown>
const MainApp = (props: Props) => {
wsClient.initPlugin(manifest.id, manifest.version, props.webSocketClient)
useEffect(() => {
document.body.classList.add('focalboard-body')
document.body.classList.add('app__body')
@ -140,10 +150,12 @@ const MainApp = (props: Props) => {
return (
<ErrorBoundary>
<ReduxProvider store={store}>
<div id='focalboard-app'>
<App history={browserHistory}/>
</div>
<div id='focalboard-root-portal'/>
<WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
<div id='focalboard-app'>
<App history={browserHistory}/>
</div>
<div id='focalboard-root-portal'/>
</WithWebSockets>
</ReduxProvider>
</ErrorBoundary>
)
@ -159,6 +171,8 @@ const HeaderComponent = () => {
export default class Plugin {
channelHeaderButtonId?: string
rhsId?: string
boardSelectorId?: string
registry?: PluginRegistry
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
@ -171,16 +185,54 @@ export default class Plugin {
this.registry = registry
UserSettings.nameFormat = mmStore.getState().entities.preferences?.myPreferences['display_settings--name_format']?.value || null
let theme = mmStore.getState().entities.preferences.myPreferences.theme
setMattermostTheme(theme)
// register websocket handlers
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD}`, (e: any) => wsClient.updateHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CARD_LIMIT_TIMESTAMP}`, (e: any) => wsClient.updateCardLimitTimestampHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data))
this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data))
this.registry?.registerPostTypeComponent('custom_cloud_upgrade_nudge', CloudUpgradeNudge)
this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => {
let preferences
try {
preferences = JSON.parse(e.data.preferences)
} catch {
preferences = []
}
if (preferences) {
for (const preference of preferences) {
if (preference.category === 'theme' && theme !== preference.value) {
setMattermostTheme(JSON.parse(preference.value))
theme = preference.value
}
if(preference.category === 'display_settings' && preference.name === 'name_format'){
UserSettings.nameFormat = preference.value
}
}
}
})
let lastViewedChannel = mmStore.getState().entities.channels.currentChannelId
let prevTeamID: string
const currentChannel = mmStore.getState().entities.channels.currentChannelId
const currentChannelObj = mmStore.getState().entities.channels.channels[currentChannel]
store.dispatch(setChannel(currentChannelObj))
mmStore.subscribe(() => {
const currentUserId = mmStore.getState().entities.users.currentUserId
const currentChannel = mmStore.getState().entities.channels.currentChannelId
if (lastViewedChannel !== currentChannel && currentChannel) {
localStorage.setItem('focalboardLastViewedChannel:' + currentUserId, currentChannel)
lastViewedChannel = currentChannel
const currentChannelObj = mmStore.getState().entities.channels.channels[lastViewedChannel]
store.dispatch(setChannel(currentChannelObj))
}
// Watch for change in active team.
@ -188,24 +240,37 @@ export default class Plugin {
const currentTeamID = mmStore.getState().entities.teams.currentTeamId
if (currentTeamID && currentTeamID !== prevTeamID) {
if (prevTeamID && window.location.pathname.startsWith(windowAny.frontendBaseURL || '')) {
console.log("REDIRECTING HERE")
browserHistory.push(`/team/${currentTeamID}`)
wsClient.subscribeToTeam(currentTeamID)
}
prevTeamID = currentTeamID
store.dispatch(setTeam(currentTeamID))
octoClient.teamId = currentTeamID
store.dispatch(initialLoad())
}
})
if (this.registry.registerProduct) {
windowAny.frontendBaseURL = subpath + '/boards'
const goToFocalboard = () => {
const currentTeam = mmStore.getState().entities.teams.currentTeamId
TelemetryClient.trackEvent(TelemetryCategory, TelemetryActions.ClickChannelHeader, {teamID: currentTeam})
window.open(`${windowAny.frontendBaseURL}/team/${currentTeam}`, '_blank', 'noopener')
}
this.channelHeaderButtonId = registry.registerChannelHeaderButtonAction(<FocalboardIcon/>, goToFocalboard, 'Boards', 'Boards')
const {rhsId, toggleRHSPlugin} = this.registry.registerRightHandSidebarComponent(
(props: {webSocketClient: MMWebSocketClient}) => (
<ReduxProvider store={store}>
<WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
<RHSChannelBoards/>
</WithWebSockets>
</ReduxProvider>
),
<ErrorBoundary>
<ReduxProvider store={store}>
<RHSChannelBoardsHeader/>
</ReduxProvider>
</ErrorBoundary>
,
)
this.rhsId = rhsId
this.channelHeaderButtonId = registry.registerChannelHeaderButtonAction(<FocalboardIcon/>, () => mmStore.dispatch(toggleRHSPlugin), 'Boards', 'Boards')
this.registry.registerProduct(
'/boards',
'product-boards',
@ -229,12 +294,31 @@ export default class Plugin {
if (this.registry.registerAppBarComponent) {
const appBarIconURL = windowAny.baseURL + '/public/app-bar-icon.png'
this.registry.registerAppBarComponent(appBarIconURL, goToFocalboard, 'Open Boards')
this.registry.registerAppBarComponent(appBarIconURL, () => mmStore.dispatch(toggleRHSPlugin), 'Boards')
}
this.registry.registerPostWillRenderEmbedComponent((embed) => embed.type === 'boards', BoardsUnfurl, false)
this.registry.registerPostWillRenderEmbedComponent(
(embed) => embed.type === 'boards',
(props: {embed: {data: string}, webSocketClient: MMWebSocketClient}) => (
<ReduxProvider store={store}>
<BoardsUnfurl
embed={props.embed}
webSocketClient={props.webSocketClient}
/>
</ReduxProvider>
),
false
)
}
this.boardSelectorId = this.registry.registerRootComponent((props: {webSocketClient: MMWebSocketClient}) => (
<ReduxProvider store={store}>
<WithWebSockets manifest={manifest} webSocketClient={props.webSocketClient}>
<BoardSelector/>
</WithWebSockets>
</ReduxProvider>
))
const config = await octoClient.getClientConfig()
if (config?.telemetry) {
let rudderKey = TELEMETRY_RUDDER_KEY
@ -270,29 +354,6 @@ export default class Plugin {
}
}
// register websocket handlers
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD}`, (e: any) => wsClient.updateHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_BOARD_CATEGORY}`, (e: any) => wsClient.updateHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_CLIENT_CONFIG}`, (e: any) => wsClient.updateClientConfigHandler(e.data))
this.registry?.registerWebSocketEventHandler(`custom_${manifest.id}_${ACTION_UPDATE_SUBSCRIPTION}`, (e: any) => wsClient.updateSubscriptionHandler(e.data))
this.registry?.registerWebSocketEventHandler('plugin_statuses_changed', (e: any) => wsClient.pluginStatusesChangedHandler(e.data))
this.registry?.registerWebSocketEventHandler('preferences_changed', (e: any) => {
let preferences
try {
preferences = JSON.parse(e.data.preferences)
} catch {
preferences = []
}
if (preferences) {
for (const preference of preferences) {
if (preference.category === 'theme' && theme !== preference.value) {
setMattermostTheme(JSON.parse(preference.value))
theme = preference.value
}
}
}
})
windowAny.setTeamInSidebar = (teamID: string) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@ -309,6 +370,12 @@ export default class Plugin {
if (this.channelHeaderButtonId) {
this.registry?.unregisterComponent(this.channelHeaderButtonId)
}
if (this.rhsId) {
this.registry?.unregisterComponent(this.rhsId)
}
if (this.boardSelectorId) {
this.registry?.unregisterComponent(this.boardSelectorId)
}
// unregister websocket handlers
this.registry?.unregisterWebSocketEventHandler(wsClient.clientPrefix + ACTION_UPDATE_BLOCK)

View File

@ -13,9 +13,18 @@
background: rgba(var(--center-channel-color-rgb), 0.08);
div {
color: var(--link-color-rgb);
color: rgb(var(--link-color-rgb));
}
}
}
}
}
img.boards-rhs-header-logo {
color: white;
background: var(--button-bg);
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 8px;
}

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Channel, ChannelMembership} from 'mattermost-redux/types/channels';
import {Channel, ChannelMembership} from 'mattermost-redux/types/channels'
export interface PluginRegistry {
registerPostTypeComponent(typeName: string, component: React.ElementType)
@ -15,6 +15,8 @@ export interface PluginRegistry {
registerWebSocketEventHandler(event: string, handler: (e: any) => void)
unregisterWebSocketEventHandler(event: string)
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode)
registerRightHandSidebarComponent(component: React.ElementType, title: React.Element)
registerRootComponent(component: React.ElementType)
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
}

View File

@ -1,4 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import 'mattermost-webapp/tests/setup'
// This won't exist locally when running in CI
// eslint-disable-next-line no-process-env
if (!process.env.CI) {
require('mattermost-webapp/tests/setup')
}

View File

@ -0,0 +1 @@
{}

View File

@ -61,6 +61,7 @@ module.exports = {
],
alias: {
moment: path.resolve(__dirname, '../../webapp/node_modules/moment/'),
'react-intl': path.resolve(__dirname, '../../webapp/node_modules/react-intl/'),
},
extensions: ['*', '.js', '.jsx', '.ts', '.tsx'],
},
@ -126,6 +127,7 @@ module.exports = {
'mm-react-router-dom': 'ReactRouterDom',
'prop-types': 'PropTypes',
'react-bootstrap': 'ReactBootstrap',
},
output: {
devtoolNamespace: PLUGIN_ID,

28
noticegen/Readme.md Normal file
View File

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

14
noticegen/config.yaml Normal file
View File

@ -0,0 +1,14 @@
---
title: "Mattermost Focalboard"
copyright: "©2015-present Mattermost,Inc. All Rights Reserved. See LICENSE for license information."
description: "This document includes a list of open source components used in Mattermost Focalboard, including those that have been modified."
search:
- "mattermost-plugin/go.mod"
- "mattermost-plugin/build/go.mod"
- "server/go.mod"
- "linux/go.mod"
dependencies: []
devDependencies: []
...

View File

@ -65,3 +65,4 @@ linters:
- unconvert
- unused
- whitespace
- gocyclo

View File

@ -27,6 +27,7 @@ const (
HeaderRequestedWith = "X-Requested-With"
HeaderRequestedWithXML = "XMLHttpRequest"
UploadFormFileKey = "file"
True = "true"
)
const (
@ -34,6 +35,8 @@ const (
ErrorNoTeamMessage = "No team"
)
var errAPINotSupportedInStandaloneMode = errors.New("API not supported in standalone mode")
type PermissionError struct {
msg string
}
@ -51,12 +54,20 @@ type API struct {
permissions permissions.PermissionsService
singleUserToken string
MattermostAuth bool
logger *mlog.Logger
logger mlog.LoggerIFace
audit *audit.Audit
isPlugin bool
}
func NewAPI(app *app.App, singleUserToken string, authService string, permissions permissions.PermissionsService,
logger *mlog.Logger, audit *audit.Audit) *API {
func NewAPI(
app *app.App,
singleUserToken string,
authService string,
permissions permissions.PermissionsService,
logger mlog.LoggerIFace,
audit *audit.Audit,
isPlugin bool,
) *API {
return &API{
app: app,
singleUserToken: singleUserToken,
@ -64,6 +75,7 @@ func NewAPI(app *app.App, singleUserToken string, authService string, permission
permissions: permissions,
logger: logger,
audit: audit,
isPlugin: isPlugin,
}
}
@ -72,11 +84,22 @@ func (a *API) RegisterRoutes(r *mux.Router) {
apiv2.Use(a.panicHandler)
apiv2.Use(a.requireCSRFToken)
// personal-server specific routes. These are not needed in plugin mode.
if !a.isPlugin {
apiv2.HandleFunc("/login", a.handleLogin).Methods("POST")
apiv2.HandleFunc("/logout", a.sessionRequired(a.handleLogout)).Methods("POST")
apiv2.HandleFunc("/register", a.handleRegister).Methods("POST")
apiv2.HandleFunc("/teams/{teamID}/regenerate_signup_token", a.sessionRequired(a.handlePostTeamRegenerateSignupToken)).Methods("POST")
}
// Board APIs
apiv2.HandleFunc("/teams/{teamID}/channels", a.sessionRequired(a.handleSearchMyChannels)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/channels/{channelID}", a.sessionRequired(a.handleGetChannel)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/boards", a.sessionRequired(a.handleGetBoards)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/boards/search", a.sessionRequired(a.handleSearchBoards)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/templates", a.sessionRequired(a.handleGetTemplates)).Methods("GET")
apiv2.HandleFunc("/boards", a.sessionRequired(a.handleCreateBoard)).Methods("POST")
apiv2.HandleFunc("/boards/search", a.sessionRequired(a.handleSearchAllBoards)).Methods("GET")
apiv2.HandleFunc("/boards/{boardID}", a.attachSession(a.handleGetBoard, false)).Methods("GET")
apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handlePatchBoard)).Methods("PATCH")
apiv2.HandleFunc("/boards/{boardID}", a.sessionRequired(a.handleDeleteBoard)).Methods("DELETE")
@ -106,7 +129,6 @@ func (a *API) RegisterRoutes(r *mux.Router) {
// Team APIs
apiv2.HandleFunc("/teams", a.sessionRequired(a.handleGetTeams)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}", a.sessionRequired(a.handleGetTeam)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/regenerate_signup_token", a.sessionRequired(a.handlePostTeamRegenerateSignupToken)).Methods("POST")
apiv2.HandleFunc("/teams/{teamID}/users", a.sessionRequired(a.handleGetTeamUsers)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/archive/export", a.sessionRequired(a.handleArchiveExportTeam)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/{boardID}/files", a.sessionRequired(a.handleUploadFile)).Methods("POST")
@ -124,9 +146,6 @@ func (a *API) RegisterRoutes(r *mux.Router) {
apiv2.HandleFunc("/boards-and-blocks", a.sessionRequired(a.handleDeleteBoardsAndBlocks)).Methods("DELETE")
// Auth APIs
apiv2.HandleFunc("/login", a.handleLogin).Methods("POST")
apiv2.HandleFunc("/logout", a.sessionRequired(a.handleLogout)).Methods("POST")
apiv2.HandleFunc("/register", a.handleRegister).Methods("POST")
apiv2.HandleFunc("/clientConfig", a.getClientConfig).Methods("GET")
// Category APIs
@ -141,17 +160,24 @@ func (a *API) RegisterRoutes(r *mux.Router) {
// Get Files API
apiv2.HandleFunc("/files/teams/{teamID}/{boardID}/{filename}", a.attachSession(a.handleServeFile, false)).Methods("GET")
// Subscriptions
// Subscription APIs
apiv2.HandleFunc("/subscriptions", a.sessionRequired(a.handleCreateSubscription)).Methods("POST")
apiv2.HandleFunc("/subscriptions/{blockID}/{subscriberID}", a.sessionRequired(a.handleDeleteSubscription)).Methods("DELETE")
apiv2.HandleFunc("/subscriptions/{subscriberID}", a.sessionRequired(a.handleGetSubscriptions)).Methods("GET")
// onboarding tour endpoints
// Onboarding tour endpoints APIs
apiv2.HandleFunc("/teams/{teamID}/onboard", a.sessionRequired(a.handleOnboard)).Methods(http.MethodPost)
// archives
// Archive APIs
apiv2.HandleFunc("/boards/{boardID}/archive/export", a.sessionRequired(a.handleArchiveExportBoard)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/archive/import", a.sessionRequired(a.handleArchiveImport)).Methods("POST")
// limits
apiv2.HandleFunc("/limits", a.sessionRequired(a.handleCloudLimits)).Methods("GET")
apiv2.HandleFunc("/teams/{teamID}/notifyadminupgrade", a.sessionRequired(a.handleNotifyAdminUpgrade)).Methods(http.MethodPost)
// System APIs
r.HandleFunc("/hello", a.handleHello).Methods("GET")
}
func (a *API) RegisterAdminRoutes(r *mux.Router) {
@ -372,6 +398,13 @@ func (a *API) handleGetBlocks(w http.ResponseWriter, r *http.Request) {
mlog.Int("block_count", len(blocks)),
)
var bErr error
blocks, bErr = a.app.ApplyCloudLimits(blocks)
if bErr != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", bErr)
return
}
json, err := json.Marshal(blocks)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
@ -778,6 +811,11 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
// description: Board ID
// required: true
// type: string
// - name: disable_notify
// in: query
// description: Disables notifications (for bulk data inserting)
// required: false
// type: bool
// - name: Body
// in: body
// description: array of blocks to insert or update
@ -803,6 +841,9 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
boardID := mux.Vars(r)["boardID"]
userID := getUserID(r)
val := r.URL.Query().Get("disable_notify")
disableNotify := val == True
// in phase 1 we use "manage_board_cards", but we would have to
// check on specific actions for phase 2
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardCards) {
@ -855,6 +896,7 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
auditRec := a.makeAuditRecord(r, "postBlocks", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
auditRec.AddMeta("disable_notify", disableNotify)
ctx := r.Context()
session := ctx.Value(sessionContextKey).(*model.Session)
@ -870,13 +912,21 @@ func (a *API) handlePostBlocks(w http.ResponseWriter, r *http.Request) {
}
}
newBlocks, err := a.app.InsertBlocks(blocks, session.UserID, true)
newBlocks, err := a.app.InsertBlocks(blocks, session.UserID, !disableNotify)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
if errors.Is(err, app.ErrViewsLimitReached) {
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, err.Error(), err)
} else {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
}
return
}
a.logger.Debug("POST Blocks", mlog.Int("block_count", len(blocks)))
a.logger.Debug("POST Blocks",
mlog.Int("block_count", len(blocks)),
mlog.Bool("disable_notify", disableNotify),
)
json, err := json.Marshal(newBlocks)
if err != nil {
@ -1042,12 +1092,13 @@ func (a *API) handleGetMe(w http.ResponseWriter, r *http.Request) {
defer a.audit.LogRecord(audit.LevelRead, auditRec)
if userID == model.SingleUser {
ws, _ := a.app.GetRootTeam()
now := utils.GetMillis()
user = &model.User{
ID: model.SingleUser,
Username: model.SingleUser,
Email: model.SingleUser,
CreateAt: now,
CreateAt: ws.UpdateAt,
UpdateAt: now,
}
} else {
@ -1063,7 +1114,6 @@ func (a *API) handleGetMe(w http.ResponseWriter, r *http.Request) {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
jsonBytesResponse(w, http.StatusOK, userData)
auditRec.AddMeta("userID", user.ID)
@ -1404,6 +1454,10 @@ func (a *API) handlePatchBlock(w http.ResponseWriter, r *http.Request) {
auditRec.AddMeta("blockID", blockID)
err = a.app.PatchBlock(blockID, patch, userID)
if errors.Is(err, app.ErrPatchUpdatesLimitedCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err)
return
}
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
@ -1485,6 +1539,10 @@ func (a *API) handlePatchBlocks(w http.ResponseWriter, r *http.Request) {
}
err = a.app.PatchBlocks(teamID, patches, userID)
if errors.Is(err, app.ErrPatchUpdatesLimitedCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err)
return
}
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
@ -1545,7 +1603,7 @@ func (a *API) handleGetSharing(w http.ResponseWriter, r *http.Request) {
return
}
if sharing == nil {
jsonStringResponse(w, http.StatusOK, "{}")
jsonStringResponse(w, http.StatusOK, "")
return
}
@ -1826,7 +1884,7 @@ func (a *API) handlePostTeamRegenerateSignupToken(w http.ResponseWriter, r *http
// File upload
func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET "api/v2/files/teams/{teamID}/{boardID}/{filename} getFile
// swagger:operation GET /files/teams/{teamID}/{boardID}/{filename} getFile
//
// Returns the contents of an uploaded file
//
@ -1909,6 +1967,31 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentType)
fileInfo, err := a.app.GetFileInfo(filename)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
if fileInfo != nil && fileInfo.Archived {
fileMetadata := map[string]interface{}{
"archived": true,
"name": fileInfo.Name,
"size": fileInfo.Size,
"extension": fileInfo.Extension,
}
data, jsonErr := json.Marshal(fileMetadata)
if jsonErr != nil {
a.logger.Error("failed to marshal archived file metadata", mlog.String("filename", filename), mlog.Err(jsonErr))
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", jsonErr)
return
}
jsonBytesResponse(w, http.StatusBadRequest, data)
return
}
fileReader, err := a.app.GetFileReader(board.TeamID, boardID, filename)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
@ -2102,6 +2185,168 @@ func (a *API) handleGetTeamUsers(w http.ResponseWriter, r *http.Request) {
auditRec.Success()
}
func (a *API) handleSearchMyChannels(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /teams/{teamID}/channels searchMyChannels
//
// Returns the user available channels
//
// ---
// produces:
// - application/json
// parameters:
// - name: teamID
// in: path
// description: Team ID
// required: true
// type: string
// - name: search
// in: query
// description: string to filter channels list
// required: false
// type: string
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// type: array
// items:
// "$ref": "#/definitions/Channel"
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
if !a.MattermostAuth {
a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in standalone mode", nil)
return
}
query := r.URL.Query()
searchQuery := query.Get("search")
teamID := mux.Vars(r)["teamID"]
userID := getUserID(r)
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to team"})
return
}
auditRec := a.makeAuditRecord(r, "searchMyChannels", audit.Fail)
defer a.audit.LogRecord(audit.LevelRead, auditRec)
auditRec.AddMeta("teamID", teamID)
channels, err := a.app.SearchUserChannels(teamID, userID, searchQuery)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
a.logger.Debug("GetUserChannels",
mlog.String("teamID", teamID),
mlog.Int("channelsCount", len(channels)),
)
data, err := json.Marshal(channels)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
// response
jsonBytesResponse(w, http.StatusOK, data)
auditRec.AddMeta("channelsCount", len(channels))
auditRec.Success()
}
func (a *API) handleGetChannel(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /teams/{teamID}/channels/{channelID} getChannel
//
// Returns the requested channel
//
// ---
// produces:
// - application/json
// parameters:
// - name: teamID
// in: path
// description: Team ID
// required: true
// type: string
// - name: channelID
// in: path
// description: Channel ID
// required: true
// type: string
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// type: array
// items:
// "$ref": "#/definitions/Channel"
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
if !a.MattermostAuth {
a.errorResponse(w, r.URL.Path, http.StatusNotImplemented, "not permitted in standalone mode", nil)
return
}
teamID := mux.Vars(r)["teamID"]
channelID := mux.Vars(r)["channelID"]
userID := getUserID(r)
if !a.permissions.HasPermissionToTeam(userID, teamID, model.PermissionViewTeam) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to team"})
return
}
if !a.permissions.HasPermissionToChannel(userID, channelID, model.PermissionReadChannel) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to channel"})
return
}
auditRec := a.makeAuditRecord(r, "getChannel", audit.Fail)
defer a.audit.LogRecord(audit.LevelRead, auditRec)
auditRec.AddMeta("teamID", teamID)
auditRec.AddMeta("channelID", teamID)
channel, err := a.app.GetChannel(teamID, channelID)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
a.logger.Debug("GetChannel",
mlog.String("teamID", teamID),
mlog.String("channelID", channelID),
)
if channel.TeamId != teamID {
a.errorResponse(w, r.URL.Path, http.StatusNotFound, "", nil)
return
}
data, err := json.Marshal(channel)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
// response
jsonBytesResponse(w, http.StatusOK, data)
auditRec.Success()
}
func (a *API) handleGetBoards(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /teams/{teamID}/boards getBoards
//
@ -2755,12 +3000,18 @@ func (a *API) handlePatchBoard(w http.ResponseWriter, r *http.Request) {
return
}
if patch.Type != nil {
if patch.Type != nil || patch.MinimumRole != nil {
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardType) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to modifying board type"})
return
}
}
if patch.ChannelID != nil {
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardRoles) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to modifying board access"})
return
}
}
auditRec := a.makeAuditRecord(r, "patchBoard", audit.Fail)
defer a.audit.LogRecord(audit.LevelModify, auditRec)
@ -2905,6 +3156,11 @@ func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) {
return
}
if toTeam != "" && !a.permissions.HasPermissionToTeam(userID, toTeam, model.PermissionViewTeam) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to team"})
return
}
if board.IsTemplate && board.Type == model.BoardTypeOpen {
if board.TeamID != model.GlobalTeamID && !a.permissions.HasPermissionToTeam(userID, board.TeamID, model.PermissionViewTeam) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to board"})
@ -2925,7 +3181,7 @@ func (a *API) handleDuplicateBoard(w http.ResponseWriter, r *http.Request) {
mlog.String("boardID", boardID),
)
boardsAndBlocks, _, err := a.app.DuplicateBoard(boardID, userID, toTeam, asTemplate == "true")
boardsAndBlocks, _, err := a.app.DuplicateBoard(boardID, userID, toTeam, asTemplate == True)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, err.Error(), err)
return
@ -3028,7 +3284,7 @@ func (a *API) handleDuplicateBlock(w http.ResponseWriter, r *http.Request) {
mlog.String("blockID", blockID),
)
blocks, err := a.app.DuplicateBlock(boardID, blockID, userID, asTemplate == "true")
blocks, err := a.app.DuplicateBlock(boardID, blockID, userID, asTemplate == True)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, err.Error(), err)
return
@ -3124,7 +3380,7 @@ func (a *API) handleGetBoardMetadata(w http.ResponseWriter, r *http.Request) {
func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /teams/{teamID}/boards/search searchBoards
//
// Returns the boards that match with a search term
// Returns the boards that match with a search term in the team
//
// ---
// produces:
@ -3173,7 +3429,7 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
auditRec.AddMeta("teamID", teamID)
// retrieve boards list
boards, err := a.app.SearchBoardsForUser(term, userID, teamID)
boards, err := a.app.SearchBoardsForUserInTeam(teamID, term, userID)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
@ -3197,6 +3453,69 @@ func (a *API) handleSearchBoards(w http.ResponseWriter, r *http.Request) {
auditRec.Success()
}
func (a *API) handleSearchAllBoards(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /boards/search searchBoards
//
// Returns the boards that match with a search term
//
// ---
// produces:
// - application/json
// parameters:
// - name: q
// in: query
// description: The search term. Must have at least one character
// required: true
// type: string
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// type: array
// items:
// "$ref": "#/definitions/Board"
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
term := r.URL.Query().Get("q")
userID := getUserID(r)
if len(term) == 0 {
jsonStringResponse(w, http.StatusOK, "[]")
return
}
auditRec := a.makeAuditRecord(r, "searchAllBoards", audit.Fail)
defer a.audit.LogRecord(audit.LevelRead, auditRec)
// retrieve boards list
boards, err := a.app.SearchBoardsForUser(term, userID)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
a.logger.Debug("SearchAllBoards",
mlog.Int("boardsCount", len(boards)),
)
data, err := json.Marshal(boards)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
// response
jsonBytesResponse(w, http.StatusOK, data)
auditRec.AddMeta("boardsCount", len(boards))
auditRec.Success()
}
func (a *API) handleGetMembersForBoard(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /boards/{boardID}/members getMembersForBoard
//
@ -3420,12 +3739,13 @@ func (a *API) handleJoinBoard(w http.ResponseWriter, r *http.Request) {
return
}
// currently all memberships are created as editors by default
// TODO: Support different public roles
newBoardMember := &model.BoardMember{
UserID: userID,
BoardID: boardID,
SchemeEditor: true,
UserID: userID,
BoardID: boardID,
SchemeAdmin: board.MinimumRole == model.BoardRoleAdmin,
SchemeEditor: board.MinimumRole == model.BoardRoleNone || board.MinimumRole == model.BoardRoleEditor,
SchemeCommenter: board.MinimumRole == model.BoardRoleCommenter,
SchemeViewer: board.MinimumRole == model.BoardRoleViewer,
}
auditRec := a.makeAuditRecord(r, "joinBoard", audit.Fail)
@ -3913,7 +4233,7 @@ func (a *API) handlePatchBoardsAndBlocks(w http.ResponseWriter, r *http.Request)
return
}
if patch.Type != nil {
if patch.Type != nil || patch.MinimumRole != nil {
if !a.permissions.HasPermissionToBoard(userID, boardID, model.PermissionManageBoardType) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", PermissionError{"access denied to modifying board type"})
return
@ -3967,6 +4287,10 @@ func (a *API) handlePatchBoardsAndBlocks(w http.ResponseWriter, r *http.Request)
auditRec.AddMeta("blocksCount", len(pbab.BlockIDs))
bab, err := a.app.PatchBoardsAndBlocks(pbab, userID)
if errors.Is(err, app.ErrPatchUpdatesLimitedCards) {
a.errorResponse(w, r.URL.Path, http.StatusForbidden, "", err)
return
}
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
@ -4106,6 +4430,86 @@ func (a *API) handleDeleteBoardsAndBlocks(w http.ResponseWriter, r *http.Request
auditRec.Success()
}
func (a *API) handleCloudLimits(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /limits cloudLimits
//
// Fetches the cloud limits of the server.
//
// ---
// produces:
// - application/json
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// schema:
// "$ref": "#/definitions/BoardsCloudLimits"
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
boardsCloudLimits, err := a.app.GetBoardsCloudLimits()
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
data, err := json.Marshal(boardsCloudLimits)
if err != nil {
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
return
}
jsonBytesResponse(w, http.StatusOK, data)
}
func (a *API) handleHello(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /hello hello
//
// Responds with `Hello` if the web service is running.
//
// ---
// produces:
// - text/plain
// responses:
// '200':
// description: success
stringResponse(w, "Hello")
}
func (a *API) handleNotifyAdminUpgrade(w http.ResponseWriter, r *http.Request) {
// swagger:operation GET /api/v2/teams/{teamID}/notifyadminupgrade handleNotifyAdminUpgrade
//
// Notifies admins for upgrade request.
//
// ---
// produces:
// - application/json
// security:
// - BearerAuth: []
// responses:
// '200':
// description: success
// default:
// description: internal error
// schema:
// "$ref": "#/definitions/ErrorResponse"
if !a.MattermostAuth {
a.errorResponse(w, r.URL.Path, http.StatusNotFound, "", errAPINotSupportedInStandaloneMode)
return
}
vars := mux.Vars(r)
teamID := vars["teamID"]
if err := a.app.NotifyPortalAdminsUpgradeRequest(teamID); err != nil {
jsonStringResponse(w, http.StatusOK, "{}")
}
}
// Response helpers
func (a *API) errorResponse(w http.ResponseWriter, api string, code int, message string, sourceError error) {
@ -4134,13 +4538,18 @@ func (a *API) errorResponse(w http.ResponseWriter, api string, code int, message
_, _ = w.Write(data)
}
func jsonStringResponse(w http.ResponseWriter, code int, message string) { //nolint:unparam
func stringResponse(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/plain")
_, _ = fmt.Fprint(w, message)
}
func jsonStringResponse(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
fmt.Fprint(w, message)
}
func jsonBytesResponse(w http.ResponseWriter, code int, json []byte) { //nolint:unparam
func jsonBytesResponse(w http.ResponseWriter, code int, json []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write(json)

View File

@ -8,7 +8,7 @@ import (
)
// makeAuditRecord creates an audit record pre-populated with data from the request.
func (a *API) makeAuditRecord(r *http.Request, event string, initialStatus string) *audit.Record { //nolint:unparam
func (a *API) makeAuditRecord(r *http.Request, event string, initialStatus string) *audit.Record {
ctx := r.Context()
var sessionID string
var userID string

View File

@ -1,6 +1,8 @@
package app
import (
"io"
"sync"
"time"
"github.com/mattermost/focalboard/server/auth"
@ -13,27 +15,43 @@ import (
"github.com/mattermost/focalboard/server/utils"
"github.com/mattermost/focalboard/server/ws"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
mm_model "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/shared/filestore"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
const (
blockChangeNotifierQueueSize = 100
blockChangeNotifierQueueSize = 1000
blockChangeNotifierPoolSize = 10
blockChangeNotifierShutdownTimeout = time.Second * 10
)
type servicesAPI interface {
GetUsersFromProfiles(options *mm_model.UserGetOptions) ([]*mm_model.User, error)
}
type ReadCloseSeeker = filestore.ReadCloseSeeker
type fileBackend interface {
Reader(path string) (ReadCloseSeeker, error)
FileExists(path string) (bool, error)
CopyFile(oldPath, newPath string) error
MoveFile(oldPath, newPath string) error
WriteFile(fr io.Reader, path string) (int64, error)
RemoveFile(path string) error
}
type Services struct {
Auth *auth.Auth
Store store.Store
FilesBackend filestore.FileBackend
FilesBackend fileBackend
Webhook *webhook.Client
Metrics *metrics.Metrics
Notifications *notify.Service
Logger *mlog.Logger
Logger mlog.LoggerIFace
Permissions permissions.PermissionsService
SkipTemplateInit bool
ServicesAPI servicesAPI
}
type App struct {
@ -41,12 +59,16 @@ type App struct {
store store.Store
auth *auth.Auth
wsAdapter ws.Adapter
filesBackend filestore.FileBackend
filesBackend fileBackend
webhook *webhook.Client
metrics *metrics.Metrics
notifications *notify.Service
logger *mlog.Logger
logger mlog.LoggerIFace
blockChangeNotifier *utils.CallbackQueue
servicesAPI servicesAPI
cardLimitMux sync.RWMutex
cardLimit int
}
func (a *App) SetConfig(config *config.Configuration) {
@ -69,7 +91,20 @@ func New(config *config.Configuration, wsAdapter ws.Adapter, services Services)
notifications: services.Notifications,
logger: services.Logger,
blockChangeNotifier: utils.NewCallbackQueue("blockChangeNotifier", blockChangeNotifierQueueSize, blockChangeNotifierPoolSize, services.Logger),
servicesAPI: services.ServicesAPI,
}
app.initialize(services.SkipTemplateInit)
return app
}
func (a *App) CardLimit() int {
a.cardLimitMux.RLock()
defer a.cardLimitMux.RUnlock()
return a.cardLimit
}
func (a *App) SetCardLimit(cardLimit int) {
a.cardLimitMux.Lock()
defer a.cardLimitMux.Unlock()
a.cardLimit = cardLimit
}

View File

@ -1,7 +1,6 @@
package app
import (
"fmt"
"testing"
"github.com/golang/mock/gomock"
@ -114,7 +113,6 @@ func TestRegisterUser(t *testing.T) {
for _, test := range testcases {
t.Run(test.title, func(t *testing.T) {
fmt.Println(test.email)
err := th.App.RegisterUser(test.userName, test.email, test.password)
if test.isError {
require.Error(t, err)

View File

@ -13,6 +13,8 @@ import (
)
var ErrBlocksFromMultipleBoards = errors.New("the block set contain blocks from multiple boards")
var ErrViewsLimitReached = errors.New("views limit reached for board")
var ErrPatchUpdatesLimitedCards = errors.New("patch updates cards that are limited")
func (a *App) GetBlocks(boardID, parentID string, blockType string) ([]model.Block, error) {
if boardID == "" {
@ -50,6 +52,16 @@ func (a *App) DuplicateBlock(boardID string, blockID string, userID string, asTe
}
return nil
})
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed duplicating a block",
mlog.Err(uErr),
)
}
}()
return blocks, err
}
@ -60,7 +72,17 @@ func (a *App) GetBlocksWithBoardID(boardID string) ([]model.Block, error) {
func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedByID string) error {
oldBlock, err := a.store.GetBlock(blockID)
if err != nil {
return nil
return err
}
if a.IsCloudLimited() {
containsLimitedBlocks, lErr := a.ContainsLimitedBlocks([]model.Block{*oldBlock})
if lErr != nil {
return lErr
}
if containsLimitedBlocks {
return ErrPatchUpdatesLimitedCards
}
}
board, err := a.store.GetBoard(oldBlock.BoardID)
@ -76,7 +98,7 @@ func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedB
a.metrics.IncrementBlocksPatched(1)
block, err := a.store.GetBlock(blockID)
if err != nil {
return nil
return err
}
a.blockChangeNotifier.Enqueue(func() error {
// broadcast on websocket
@ -93,17 +115,22 @@ func (a *App) PatchBlock(blockID string, blockPatch *model.BlockPatch, modifiedB
}
func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, modifiedByID string) error {
oldBlocks := make([]model.Block, 0, len(blockPatches.BlockIDs))
for _, blockID := range blockPatches.BlockIDs {
oldBlock, err := a.store.GetBlock(blockID)
if err != nil {
return nil
}
oldBlocks = append(oldBlocks, *oldBlock)
oldBlocks, err := a.store.GetBlocksByIDs(blockPatches.BlockIDs)
if err != nil {
return err
}
err := a.store.PatchBlocks(blockPatches, modifiedByID)
if err != nil {
if a.IsCloudLimited() {
containsLimitedBlocks, err := a.ContainsLimitedBlocks(oldBlocks)
if err != nil {
return err
}
if containsLimitedBlocks {
return ErrPatchUpdatesLimitedCards
}
}
if err := a.store.PatchBlocks(blockPatches, modifiedByID); err != nil {
return err
}
@ -112,7 +139,7 @@ func (a *App) PatchBlocks(teamID string, blockPatches *model.BlockPatchBatch, mo
for i, blockID := range blockPatches.BlockIDs {
newBlock, err := a.store.GetBlock(blockID)
if err != nil {
return nil
return err
}
a.wsAdapter.BroadcastBlockChange(teamID, *newBlock)
a.webhook.NotifyUpdate(*newBlock)
@ -136,12 +163,45 @@ func (a *App) InsertBlock(block model.Block, modifiedByID string) error {
a.metrics.IncrementBlocksInserted(1)
a.webhook.NotifyUpdate(block)
a.notifyBlockChanged(notify.Add, &block, nil, modifiedByID)
return nil
})
}
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after inserting a block",
mlog.Err(uErr),
)
}
}()
return err
}
func (a *App) isWithinViewsLimit(boardID string, block model.Block) (bool, error) {
limits, err := a.GetBoardsCloudLimits()
if err != nil {
return false, err
}
if limits.Views == model.LimitUnlimited {
return true, nil
}
views, err := a.store.GetBlocksWithParentAndType(boardID, block.ParentID, model.TypeView)
if err != nil {
return false, err
}
// < rather than <= because we'll be creating new view if this
// check passes. When that view is created, the limit will be reached.
// That's why we need to check for if existing + the being-created
// view doesn't exceed the limit.
return len(views) < limits.Views, nil
}
func (a *App) InsertBlocks(blocks []model.Block, modifiedByID string, allowNotifications bool) ([]model.Block, error) {
if len(blocks) == 0 {
return []model.Block{}, nil
@ -162,6 +222,20 @@ func (a *App) InsertBlocks(blocks []model.Block, modifiedByID string, allowNotif
needsNotify := make([]model.Block, 0, len(blocks))
for i := range blocks {
// this check is needed to whitelist inbuilt template
// initialization. They do contain more than 5 views per board.
if boardID != "0" && blocks[i].Type == model.TypeView {
withinLimit, err := a.isWithinViewsLimit(board.ID, blocks[i])
if err != nil {
return nil, err
}
if !withinLimit {
a.logger.Info("views limit reached on board", mlog.String("board_id", blocks[i].ParentID), mlog.String("team_id", board.TeamID))
return nil, ErrViewsLimitReached
}
}
err := a.store.InsertBlock(&blocks[i], modifiedByID)
if err != nil {
return nil, err
@ -180,55 +254,77 @@ func (a *App) InsertBlocks(blocks []model.Block, modifiedByID string, allowNotif
a.notifyBlockChanged(notify.Add, &block, nil, modifiedByID)
}
}
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after inserting blocks",
mlog.Err(err),
)
}
}()
return blocks, nil
}
func (a *App) CopyCardFiles(sourceBoardID string, blocks []model.Block) error {
func (a *App) CopyCardFiles(sourceBoardID string, copiedBlocks []model.Block) error {
// Images attached in cards have a path comprising the card's board ID.
// When we create a template from this board, we need to copy the files
// with the new board ID in path.
// Not doing so causing images in templates (and boards created from this
// template) to fail to load.
// look up ID of source board, which may be different than the blocks.
board, err := a.GetBlockByID(sourceBoardID)
if err != nil || board == nil {
return fmt.Errorf("cannot fetch board %s for CopyCardFiles: %w", sourceBoardID, err)
// look up ID of source sourceBoard, which may be different than the blocks.
sourceBoard, err := a.GetBoard(sourceBoardID)
if err != nil || sourceBoard == nil {
return fmt.Errorf("cannot fetch source board %s for CopyCardFiles: %w", sourceBoardID, err)
}
for i := range blocks {
block := blocks[i]
var destTeamID string
var destBoardID string
for i := range copiedBlocks {
block := copiedBlocks[i]
fileName, ok := block.Fields["fileId"]
if block.Type == model.TypeImage && ok {
// create unique filename in case we are copying cards within the same board.
ext := filepath.Ext(fileName.(string))
destFilename := utils.NewID(utils.IDTypeNone) + ext
if !ok || fileName == "" {
continue // doesn't have a file attachment
}
sourceFilePath := filepath.Join(sourceBoardID, fileName.(string))
destinationFilePath := filepath.Join(block.BoardID, destFilename)
// create unique filename in case we are copying cards within the same board.
ext := filepath.Ext(fileName.(string))
destFilename := utils.NewID(utils.IDTypeNone) + ext
a.logger.Debug(
"Copying card file",
if destBoardID == "" || block.BoardID != destBoardID {
destBoardID = block.BoardID
destBoard, err := a.GetBoard(destBoardID)
if err != nil {
return fmt.Errorf("cannot fetch destination board %s for CopyCardFiles: %w", sourceBoardID, err)
}
destTeamID = destBoard.TeamID
}
sourceFilePath := filepath.Join(sourceBoard.TeamID, sourceBoard.ID, fileName.(string))
destinationFilePath := filepath.Join(destTeamID, block.BoardID, destFilename)
a.logger.Debug(
"Copying card file",
mlog.String("sourceFilePath", sourceFilePath),
mlog.String("destinationFilePath", destinationFilePath),
)
if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil {
a.logger.Error(
"CopyCardFiles failed to copy file",
mlog.String("sourceFilePath", sourceFilePath),
mlog.String("destinationFilePath", destinationFilePath),
mlog.Err(err),
)
if err := a.filesBackend.CopyFile(sourceFilePath, destinationFilePath); err != nil {
a.logger.Error(
"CopyCardFiles failed to copy file",
mlog.String("sourceFilePath", sourceFilePath),
mlog.String("destinationFilePath", destinationFilePath),
mlog.Err(err),
)
return err
}
block.Fields["fileId"] = destFilename
}
block.Fields["fileId"] = destFilename
}
return nil
@ -277,8 +373,19 @@ func (a *App) DeleteBlock(blockID string, modifiedBy string) error {
a.wsAdapter.BroadcastBlockDelete(board.TeamID, blockID, block.BoardID)
a.metrics.IncrementBlocksDeleted(1)
a.notifyBlockChanged(notify.Delete, block, block, modifiedBy)
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after deleting a block",
mlog.Err(err),
)
}
}()
return nil
}
@ -329,9 +436,19 @@ func (a *App) UndeleteBlock(blockID string, modifiedBy string) (*model.Block, er
a.metrics.IncrementBlocksInserted(1)
a.webhook.NotifyUpdate(*block)
a.notifyBlockChanged(notify.Add, block, nil, modifiedBy)
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after undeleting a block",
mlog.Err(err),
)
}
}()
return block, nil
}

View File

@ -1,12 +1,17 @@
package app
import (
"database/sql"
"testing"
"github.com/golang/mock/gomock"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/assert"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
mmModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/focalboard/server/model"
)
type blockError struct {
@ -21,7 +26,7 @@ func TestInsertBlock(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success scenerio", func(t *testing.T) {
t.Run("success scenario", func(t *testing.T) {
boardID := testBoardID
block := model.Block{BoardID: boardID}
board := &model.Board{ID: boardID}
@ -32,7 +37,7 @@ func TestInsertBlock(t *testing.T) {
require.NoError(t, err)
})
t.Run("error scenerio", func(t *testing.T) {
t.Run("error scenario", func(t *testing.T) {
boardID := testBoardID
block := model.Block{BoardID: boardID}
board := &model.Board{ID: boardID}
@ -47,18 +52,64 @@ func TestPatchBlocks(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("patchBlocks success scenerio", func(t *testing.T) {
blockPatches := model.BlockPatchBatch{}
t.Run("patchBlocks success scenario", func(t *testing.T) {
blockPatches := model.BlockPatchBatch{
BlockIDs: []string{"block1"},
BlockPatches: []model.BlockPatch{
{Title: mmModel.NewString("new title")},
},
}
block1 := model.Block{ID: "block1"}
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]model.Block{block1}, nil)
th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(nil)
th.Store.EXPECT().GetBlock("block1").Return(&block1, nil)
// this call comes from the WS server notification
th.Store.EXPECT().GetMembersForBoard(gomock.Any()).Times(1)
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
require.NoError(t, err)
})
t.Run("patchBlocks error scenerio", func(t *testing.T) {
blockPatches := model.BlockPatchBatch{}
th.Store.EXPECT().PatchBlocks(gomock.Eq(&blockPatches), gomock.Eq("user-id-1")).Return(blockError{"error"})
t.Run("patchBlocks error scenario", func(t *testing.T) {
blockPatches := model.BlockPatchBatch{BlockIDs: []string{}}
th.Store.EXPECT().GetBlocksByIDs([]string{}).Return(nil, sql.ErrNoRows)
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
require.Error(t, err, "error")
require.ErrorIs(t, err, sql.ErrNoRows)
})
t.Run("cloud limit error scenario", func(t *testing.T) {
th.App.SetCardLimit(5)
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
}
blockPatches := model.BlockPatchBatch{
BlockIDs: []string{"block1"},
BlockPatches: []model.BlockPatch{
{Title: mmModel.NewString("new title")},
},
}
block1 := model.Block{
ID: "block1",
Type: model.TypeCard,
ParentID: "board-id",
BoardID: "board-id",
UpdateAt: 100,
}
board1 := &model.Board{
ID: "board-id",
Type: model.BoardTypeOpen,
}
th.Store.EXPECT().GetBlocksByIDs([]string{"block1"}).Return([]model.Block{block1}, nil)
th.Store.EXPECT().GetBoard("board-id").Return(board1, nil)
th.Store.EXPECT().GetLicense().Return(fakeLicense)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(150), nil)
err := th.App.PatchBlocks("team-id", &blockPatches, "user-id-1")
require.ErrorIs(t, err, ErrPatchUpdatesLimitedCards)
})
}
@ -66,7 +117,7 @@ func TestDeleteBlock(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success scenerio", func(t *testing.T) {
t.Run("success scenario", func(t *testing.T) {
boardID := testBoardID
board := &model.Board{ID: boardID}
block := model.Block{
@ -81,7 +132,7 @@ func TestDeleteBlock(t *testing.T) {
require.NoError(t, err)
})
t.Run("error scenerio", func(t *testing.T) {
t.Run("error scenario", func(t *testing.T) {
boardID := testBoardID
board := &model.Board{ID: boardID}
block := model.Block{
@ -100,7 +151,7 @@ func TestUndeleteBlock(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success scenerio", func(t *testing.T) {
t.Run("success scenario", func(t *testing.T) {
boardID := testBoardID
board := &model.Board{ID: boardID}
block := model.Block{
@ -119,7 +170,7 @@ func TestUndeleteBlock(t *testing.T) {
require.NoError(t, err)
})
t.Run("error scenerio", func(t *testing.T) {
t.Run("error scenario", func(t *testing.T) {
block := model.Block{
ID: "block-id",
}
@ -128,8 +179,228 @@ func TestUndeleteBlock(t *testing.T) {
gomock.Eq(model.QueryBlockHistoryOptions{Limit: 1, Descending: true}),
).Return([]model.Block{block}, nil)
th.Store.EXPECT().UndeleteBlock(gomock.Eq("block-id"), gomock.Eq("user-id-1")).Return(blockError{"error"})
th.Store.EXPECT().GetBlock(gomock.Eq("block-id")).Return(&block, nil)
_, err := th.App.UndeleteBlock("block-id", "user-id-1")
require.Error(t, err, "error")
})
}
func TestIsWithinViewsLimit(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
}
t.Run("within views limit", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mmModel.ProductLimits{
Boards: &mmModel.BoardsLimits{
Views: mmModel.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{{}}, nil)
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
assert.NoError(t, err)
assert.True(t, withinLimits)
})
t.Run("view limit exactly reached", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mmModel.ProductLimits{
Boards: &mmModel.BoardsLimits{
Views: mmModel.NewInt(1),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{{}}, nil)
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
assert.NoError(t, err)
assert.False(t, withinLimits)
})
t.Run("view limit already exceeded", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mmModel.ProductLimits{
Boards: &mmModel.BoardsLimits{
Views: mmModel.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{{}, {}, {}}, nil)
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
assert.NoError(t, err)
assert.False(t, withinLimits)
})
t.Run("creating first view", func(t *testing.T) {
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mmModel.ProductLimits{
Boards: &mmModel.BoardsLimits{
Views: mmModel.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("board_id", "parent_id", "view").Return([]model.Block{}, nil)
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
assert.NoError(t, err)
assert.True(t, withinLimits)
})
t.Run("is not a cloud SKU so limits don't apply", func(t *testing.T) {
nonCloudLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(false)},
}
th.Store.EXPECT().GetLicense().Return(nonCloudLicense)
withinLimits, err := th.App.isWithinViewsLimit("board_id", model.Block{ParentID: "parent_id"})
assert.NoError(t, err)
assert.True(t, withinLimits)
})
}
func TestInsertBlocks(t *testing.T) {
th, tearDown := SetupTestHelper(t)
defer tearDown()
t.Run("success scenario", func(t *testing.T) {
boardID := testBoardID
block := model.Block{BoardID: boardID}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().InsertBlock(&block, "user-id-1").Return(nil)
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
require.NoError(t, err)
})
t.Run("error scenario", func(t *testing.T) {
boardID := testBoardID
block := model.Block{BoardID: boardID}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().InsertBlock(&block, "user-id-1").Return(blockError{"error"})
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
require.Error(t, err, "error")
})
t.Run("create view within limits", func(t *testing.T) {
boardID := testBoardID
block := model.Block{
Type: model.TypeView,
ParentID: "parent_id",
BoardID: boardID,
}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().InsertBlock(&block, "user-id-1").Return(nil)
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil)
// setting up mocks for limits
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mmModel.ProductLimits{
Boards: &mmModel.BoardsLimits{
Views: mmModel.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]model.Block{{}}, nil)
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
require.NoError(t, err)
})
t.Run("create view exceeding limits", func(t *testing.T) {
boardID := testBoardID
block := model.Block{
Type: model.TypeView,
ParentID: "parent_id",
BoardID: boardID,
}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
// setting up mocks for limits
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense)
cloudLimit := &mmModel.ProductLimits{
Boards: &mmModel.BoardsLimits{
Views: mmModel.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil)
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]model.Block{{}, {}}, nil)
_, err := th.App.InsertBlocks([]model.Block{block}, "user-id-1", false)
require.Error(t, err)
})
t.Run("creating multiple views, reaching limit in the process", func(t *testing.T) {
t.Skipf("Will be fixed soon")
boardID := testBoardID
view1 := model.Block{
Type: model.TypeView,
ParentID: "parent_id",
BoardID: boardID,
}
view2 := model.Block{
Type: model.TypeView,
ParentID: "parent_id",
BoardID: boardID,
}
board := &model.Board{ID: boardID}
th.Store.EXPECT().GetBoard(boardID).Return(board, nil)
th.Store.EXPECT().InsertBlock(&view1, "user-id-1").Return(nil).Times(2)
th.Store.EXPECT().GetMembersForBoard(boardID).Return([]*model.BoardMember{}, nil).Times(2)
// setting up mocks for limits
fakeLicense := &mmModel.License{
Features: &mmModel.Features{Cloud: mmModel.NewBool(true)},
}
th.Store.EXPECT().GetLicense().Return(fakeLicense).Times(2)
cloudLimit := &mmModel.ProductLimits{
Boards: &mmModel.BoardsLimits{
Views: mmModel.NewInt(2),
},
}
th.Store.EXPECT().GetCloudLimits().Return(cloudLimit, nil).Times(2)
th.Store.EXPECT().GetUsedCardsCount().Return(1, nil).Times(2)
th.Store.EXPECT().GetCardLimitTimestamp().Return(int64(1), nil).Times(2)
th.Store.EXPECT().GetBlocksWithParentAndType("test-board-id", "parent_id", "view").Return([]model.Block{{}}, nil).Times(2)
_, err := th.App.InsertBlocks([]model.Block{view1, view2}, "user-id-1", false)
require.Error(t, err)
})
}

View File

@ -1,12 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"database/sql"
"errors"
"fmt"
"github.com/mattermost/focalboard/server/model"
"github.com/mattermost/focalboard/server/services/notify"
"github.com/mattermost/focalboard/server/utils"
"github.com/mattermost/mattermost-server/v6/shared/mlog"
)
var (
@ -17,7 +22,7 @@ var (
func (a *App) GetBoard(boardID string) (*model.Board, error) {
board, err := a.store.GetBoard(boardID)
if errors.Is(err, sql.ErrNoRows) {
if model.IsErrNotFound(err) {
return nil, nil
}
if err != nil {
@ -145,19 +150,70 @@ func (a *App) DuplicateBoard(boardID, userID, toTeam string, asTemplate bool) (*
if err != nil {
return nil, nil, err
}
go func() {
// copy any file attachments from the duplicated blocks.
if err = a.CopyCardFiles(boardID, bab.Blocks); err != nil {
a.logger.Error("Could not copy files while duplicating board", mlog.String("BoardID", boardID), mlog.Err(err))
}
// bab.Blocks now has updated file ids for any blocks containing files. We need to store them.
blockIDs := make([]string, 0)
blockPatches := make([]model.BlockPatch, 0)
for _, block := range bab.Blocks {
if fileID, ok := block.Fields["fileId"]; ok {
blockIDs = append(blockIDs, block.ID)
blockPatches = append(blockPatches, model.BlockPatch{
UpdatedFields: map[string]interface{}{
"fileId": fileID,
},
})
}
}
a.logger.Debug("Duplicate boards patching file IDs", mlog.Int("count", len(blockIDs)))
if len(blockIDs) != 0 {
patches := &model.BlockPatchBatch{
BlockIDs: blockIDs,
BlockPatches: blockPatches,
}
if err = a.store.PatchBlocks(patches, userID); err != nil {
dbab := model.NewDeleteBoardsAndBlocksFromBabs(bab)
if err = a.store.DeleteBoardsAndBlocks(dbab, userID); err != nil {
a.logger.Error("Cannot delete board after duplication error when updating block's file info", mlog.String("boardID", bab.Boards[0].ID), mlog.Err(err))
}
return nil, nil, fmt.Errorf("could not patch file IDs while duplicating board %s: %w", boardID, err)
}
}
a.blockChangeNotifier.Enqueue(func() error {
teamID := ""
for _, board := range bab.Boards {
teamID = board.TeamID
a.wsAdapter.BroadcastBoardChange(teamID, board)
}
for _, block := range bab.Blocks {
a.wsAdapter.BroadcastBlockChange(teamID, block)
blk := block
a.wsAdapter.BroadcastBlockChange(teamID, blk)
a.notifyBlockChanged(notify.Add, &blk, nil, userID)
}
for _, member := range members {
a.wsAdapter.BroadcastMemberChange(teamID, member.BoardID, member)
}
}()
return nil
})
if len(bab.Blocks) != 0 {
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after duplicating a board",
mlog.Err(uErr),
)
}
}()
}
return bab, members, err
}
@ -188,13 +244,14 @@ func (a *App) CreateBoard(board *model.Board, userID string, addMember bool) (*m
return nil, err
}
go func() {
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBoardChange(newBoard.TeamID, newBoard)
if addMember {
a.wsAdapter.BroadcastMemberChange(newBoard.TeamID, newBoard.ID, member)
}
}()
return nil
})
return newBoard, nil
}
@ -205,16 +262,17 @@ func (a *App) PatchBoard(patch *model.BoardPatch, boardID, userID string) (*mode
return nil, err
}
go func() {
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBoardChange(updatedBoard.TeamID, updatedBoard)
}()
return nil
})
return updatedBoard, nil
}
func (a *App) DeleteBoard(boardID, userID string) error {
board, err := a.store.GetBoard(boardID)
if errors.Is(err, sql.ErrNoRows) {
if model.IsErrNotFound(err) {
return nil
}
if err != nil {
@ -225,8 +283,18 @@ func (a *App) DeleteBoard(boardID, userID string) error {
return err
}
go func() {
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastBoardDelete(board.TeamID, boardID)
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after deleting a board",
mlog.Err(err),
)
}
}()
return nil
@ -246,7 +314,7 @@ func (a *App) GetMemberForBoard(boardID string, userID string) (*model.BoardMemb
func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, error) {
board, err := a.store.GetBoard(member.BoardID)
if errors.Is(err, sql.ErrNoRows) {
if model.IsErrNotFound(err) {
return nil, nil
}
if err != nil {
@ -254,7 +322,7 @@ func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, e
}
existingMembership, err := a.store.GetMemberForBoard(member.BoardID, member.UserID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
if err != nil && !model.IsErrNotFound(err) {
return nil, err
}
@ -267,16 +335,17 @@ func (a *App) AddMemberToBoard(member *model.BoardMember) (*model.BoardMember, e
return nil, err
}
go func() {
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member)
}()
return nil
})
return newMember, nil
}
func (a *App) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember, error) {
board, bErr := a.store.GetBoard(member.BoardID)
if errors.Is(bErr, sql.ErrNoRows) {
if model.IsErrNotFound(bErr) {
return nil, nil
}
if bErr != nil {
@ -284,7 +353,7 @@ func (a *App) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember,
}
oldMember, err := a.store.GetMemberForBoard(member.BoardID, member.UserID)
if errors.Is(err, sql.ErrNoRows) {
if model.IsErrNotFound(err) {
return nil, nil
}
if err != nil {
@ -308,9 +377,10 @@ func (a *App) UpdateBoardMember(member *model.BoardMember) (*model.BoardMember,
return nil, err
}
go func() {
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastMemberChange(board.TeamID, member.BoardID, member)
}()
return nil
})
return newMember, nil
}
@ -331,7 +401,7 @@ func (a *App) isLastAdmin(userID, boardID string) (bool, error) {
func (a *App) DeleteBoardMember(boardID, userID string) error {
board, bErr := a.store.GetBoard(boardID)
if errors.Is(bErr, sql.ErrNoRows) {
if model.IsErrNotFound(bErr) {
return nil
}
if bErr != nil {
@ -339,7 +409,7 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
}
oldMember, err := a.store.GetMemberForBoard(boardID, userID)
if errors.Is(err, sql.ErrNoRows) {
if model.IsErrNotFound(err) {
return nil
}
if err != nil {
@ -362,15 +432,20 @@ func (a *App) DeleteBoardMember(boardID, userID string) error {
return err
}
go func() {
a.blockChangeNotifier.Enqueue(func() error {
a.wsAdapter.BroadcastMemberDelete(board.TeamID, boardID, userID)
}()
return nil
})
return nil
}
func (a *App) SearchBoardsForUser(term, userID, teamID string) ([]*model.Board, error) {
return a.store.SearchBoardsForUser(term, userID, teamID)
func (a *App) SearchBoardsForUser(term, userID string) ([]*model.Board, error) {
return a.store.SearchBoardsForUser(term, userID)
}
func (a *App) SearchBoardsForUserInTeam(teamID, term, userID string) ([]*model.Board, error) {
return a.store.SearchBoardsForUserInTeam(teamID, term, userID)
}
func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
@ -404,5 +479,14 @@ func (a *App) UndeleteBoard(boardID string, modifiedBy string) error {
return nil
})
go func() {
if err := a.UpdateCardLimitTimestamp(); err != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after undeleting a board",
mlog.Err(err),
)
}
}()
return nil
}

View File

@ -44,17 +44,39 @@ func (a *App) CreateBoardsAndBlocks(bab *model.BoardsAndBlocks, userID string, a
}
}
if len(newBab.Blocks) != 0 {
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after creating boards and blocks",
mlog.Err(uErr),
)
}
}()
}
return newBab, nil
}
func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID string) (*model.BoardsAndBlocks, error) {
oldBlocksMap := map[string]*model.Block{}
for _, blockID := range pbab.BlockIDs {
block, err := a.store.GetBlock(blockID)
if err != nil {
return nil, err
oldBlocks, err := a.store.GetBlocksByIDs(pbab.BlockIDs)
if err != nil {
return nil, err
}
if a.IsCloudLimited() {
containsLimitedBlocks, cErr := a.ContainsLimitedBlocks(oldBlocks)
if cErr != nil {
return nil, cErr
}
oldBlocksMap[blockID] = block
if containsLimitedBlocks {
return nil, ErrPatchUpdatesLimitedCards
}
}
oldBlocksMap := map[string]model.Block{}
for _, block := range oldBlocks {
oldBlocksMap[block.ID] = block
}
bab, err := a.store.PatchBoardsAndBlocks(pbab, userID)
@ -76,7 +98,7 @@ func (a *App) PatchBoardsAndBlocks(pbab *model.PatchBoardsAndBlocks, userID stri
a.metrics.IncrementBlocksPatched(1)
a.wsAdapter.BroadcastBlockChange(teamID, b)
a.webhook.NotifyUpdate(b)
a.notifyBlockChanged(notify.Update, &b, oldBlock, userID)
a.notifyBlockChanged(notify.Update, &b, &oldBlock, userID)
}
for _, board := range bab.Boards {
@ -122,5 +144,16 @@ func (a *App) DeleteBoardsAndBlocks(dbab *model.DeleteBoardsAndBlocks, userID st
return nil
})
if len(dbab.Blocks) != 0 {
go func() {
if uErr := a.UpdateCardLimitTimestamp(); uErr != nil {
a.logger.Error(
"UpdateCardLimitTimestamp failed after deleting boards and blocks",
mlog.Err(uErr),
)
}
}()
}
return nil
}

View File

@ -9,6 +9,7 @@ func (a *App) GetClientConfig() *model.ClientConfig {
Telemetry: a.config.Telemetry,
TelemetryID: a.config.TelemetryID,
EnablePublicSharedBoards: a.config.EnablePublicSharedBoards,
TeammateNameDisplay: a.config.TeammateNameDisplay,
FeatureFlags: a.config.FeatureFlags,
}
}

View File

@ -19,6 +19,7 @@ func TestGetClientConfig(t *testing.T) {
newConfiguration.FeatureFlags = make(map[string]string)
newConfiguration.FeatureFlags["BoardsFeature1"] = "true"
newConfiguration.FeatureFlags["BoardsFeature2"] = "true"
newConfiguration.TeammateNameDisplay = "username"
th.App.SetConfig(&newConfiguration)
clientConfig := th.App.GetClientConfig()
@ -26,5 +27,6 @@ func TestGetClientConfig(t *testing.T) {
require.True(t, clientConfig.Telemetry)
require.Equal(t, "abcde", clientConfig.TelemetryID)
require.Equal(t, 2, len(clientConfig.FeatureFlags))
require.Equal(t, "username", clientConfig.TeammateNameDisplay)
})
}

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