From 2d962d18d234dd5db309333c4f8a8ed048170709 Mon Sep 17 00:00:00 2001 From: Felipe Martin Date: Thu, 8 Aug 2024 15:52:29 +0200 Subject: [PATCH] added command with interactive dialog --- .golangci.yml | 3 +- Makefile | 2 +- README.md | 76 +-------------- go.mod | 11 ++- go.sum | 24 ++++- plugin.json | 10 +- server/api.go | 93 +++++++++++++++++++ server/commands.go | 66 +++++++++++++ server/permissions.go | 37 ++++++++ server/plugin.go | 37 +++++++- server/sqlstore/file.go | 53 +++++++++++ server/sqlstore/post.go | 51 ++++++++++ server/sqlstore/store.go | 79 ++++++++++++++++ webapp/src/actions.tsx | 45 +++++++++ webapp/src/index.tsx | 18 +++- webapp/src/types/mattermost-webapp/index.d.ts | 4 + 16 files changed, 520 insertions(+), 89 deletions(-) create mode 100644 server/api.go create mode 100644 server/commands.go create mode 100644 server/permissions.go create mode 100644 server/sqlstore/file.go create mode 100644 server/sqlstore/post.go create mode 100644 server/sqlstore/store.go create mode 100644 webapp/src/actions.tsx diff --git a/.golangci.yml b/.golangci.yml index 5987c1c..7c3e6f4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,9 +6,8 @@ linters-settings: gofmt: simplify: true goimports: - local-prefixes: github.com/mattermost/mattermost-starter-template + local-prefixes: github.com/mattermost/mattermost-plugin-attachments-remover govet: - check-shadowing: true enable-all: true disable: - fieldalignment diff --git a/Makefile b/Makefile index 0eed2fe..05c50bd 100644 --- a/Makefile +++ b/Makefile @@ -164,7 +164,7 @@ apply: ## Install go tools install-go-tools: @echo Installing go tools - $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.1 + $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.59.1 $(GO) install gotest.tools/gotestsum@v1.7.0 ## Runs eslint and golangci-lint diff --git a/README.md b/README.md index f83aada..0f6b68e 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,8 @@ -# Plugin Starter Template [![CircleCI branch](https://img.shields.io/circleci/project/github/mattermost/mattermost-plugin-starter-template/master.svg)](https://circleci.com/gh/mattermost/mattermost-plugin-starter-template) +# Plugin Starter Template [![CircleCI branch](https://img.shields.io/circleci/project/github/mattermost/mattermost-plugin-attachments-remover/master.svg)](https://circleci.com/gh/mattermost/mattermost-plugin-attachments-remover) -This plugin serves as a starting point for writing a Mattermost plugin. Feel free to base your own plugin off this repository. +## Features -To learn more about plugins, see [our plugin documentation](https://developers.mattermost.com/extend/plugins/). - -This template requires node v16 and npm v8. You can download and install nvm to manage your node versions by following the instructions [here](https://github.com/nvm-sh/nvm). Once you've setup the project simply run `nvm i` within the root folder to use the suggested version of node. - -## Getting Started -Use GitHub's template feature to make a copy of this repository by clicking the "Use this template" button. - -Alternatively shallow clone the repository matching your plugin name: -``` -git clone --depth 1 https://github.com/mattermost/mattermost-plugin-starter-template com.example.my-plugin -``` - -Note that this project uses [Go modules](https://github.com/golang/go/wiki/Modules). Be sure to locate the project outside of `$GOPATH`. - -Edit the following files: -1. `plugin.json` with your `id`, `name`, and `description`: -```json -{ - "id": "com.example.my-plugin", - "name": "My Plugin", - "description": "A plugin to enhance Mattermost." -} -``` - -2. `go.mod` with your Go module path, following the `//` convention: -``` -module github.com/example/my-plugin -``` - -3. `.golangci.yml` with your Go module path: -```yml -linters-settings: - # [...] - goimports: - local-prefixes: github.com/example/my-plugin -``` - -Build your plugin: -``` -make -``` - -This will produce a single plugin file (with support for multiple architectures) for upload to your Mattermost server: - -``` -dist/com.example.my-plugin.tar.gz -``` +- Allows users and sysadmins to delete attachments from posts via a context menu option ## Development @@ -161,29 +115,5 @@ To trigger a release, follow these steps: ## Q&A -### How do I make a server-only or web app-only plugin? - -Simply delete the `server` or `webapp` folders and remove the corresponding sections from `plugin.json`. The build scripts will skip the missing portions automatically. - -### How do I include assets in the plugin bundle? - -Place them into the `assets` directory. To use an asset at runtime, build the path to your asset and open as a regular file: - -```go -bundlePath, err := p.API.GetBundlePath() -if err != nil { - return errors.Wrap(err, "failed to get bundle path") -} - -profileImage, err := ioutil.ReadFile(filepath.Join(bundlePath, "assets", "profile_image.png")) -if err != nil { - return errors.Wrap(err, "failed to read profile image") -} - -if appErr := p.API.SetProfileImage(userID, profileImage); appErr != nil { - return errors.Wrap(err, "failed to set profile image") -} -``` - ### How do I build the plugin with unminified JavaScript? Setting the `MM_DEBUG` environment variable will invoke the debug builds. The simplist way to do this is to simply include this variable in your calls to `make` (e.g. `make dist MM_DEBUG=1`). diff --git a/go.mod b/go.mod index 81a84b2..16ed41e 100644 --- a/go.mod +++ b/go.mod @@ -1,27 +1,33 @@ -module github.com/mattermost/mattermost-plugin-starter-template +module github.com/mattermost/mattermost-plugin-attachments-remover go 1.21 require ( + github.com/Masterminds/squirrel v1.5.4 + github.com/gorilla/mux v1.8.1 + github.com/jmoiron/sqlx v1.4.0 github.com/mattermost/mattermost/server/public v0.0.14 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.8.4 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dyatlov/go-opengraph/opengraph v0.0.0-20220524092352-606d7b1e5f8a // indirect github.com/fatih/color v1.16.0 // indirect github.com/francoispqt/gojay v1.2.13 // indirect github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect - github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/uuid v1.5.0 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/hashicorp/go-hclog v1.6.2 // indirect github.com/hashicorp/go-plugin v1.6.0 // indirect github.com/hashicorp/yamux v0.1.1 // 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.9 // indirect github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 // indirect @@ -35,6 +41,7 @@ require ( github.com/philhofer/fwd v1.1.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/tinylib/msgp v1.1.9 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index e39a361..95875da 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,12 @@ dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -36,8 +40,8 @@ github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aev github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= -github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= -github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= @@ -63,6 +67,8 @@ github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= @@ -76,6 +82,8 @@ github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbg github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -86,6 +94,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= @@ -107,6 +119,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= @@ -160,11 +174,16 @@ github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1l github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0= +github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -225,6 +244,7 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= diff --git a/plugin.json b/plugin.json index a479d11..7d17ee9 100644 --- a/plugin.json +++ b/plugin.json @@ -1,9 +1,9 @@ { - "id": "com.mattermost.plugin-starter-template", - "name": "Plugin Starter Template", - "description": "This plugin serves as a starting point for writing a Mattermost plugin.", - "homepage_url": "https://github.com/mattermost/mattermost-plugin-starter-template", - "support_url": "https://github.com/mattermost/mattermost-plugin-starter-template/issues", + "id": "com.mattermost.attachments-remover", + "name": "Attachments manager", + "description": "This plugin allows you more ways to manage attachments in Mattermost.", + "homepage_url": "https://github.com/mattermost/mattermost-plugin-attachments-remover", + "support_url": "https://github.com/mattermost/mattermost-plugin-attachments-remover/issues", "icon_path": "assets/starter-template-icon.svg", "min_server_version": "6.2.1", "server": { diff --git a/server/api.go b/server/api.go new file mode 100644 index 0000000..ac15989 --- /dev/null +++ b/server/api.go @@ -0,0 +1,93 @@ +package main + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +type API struct { + plugin *Plugin + router *mux.Router +} + +func (a *API) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.router.ServeHTTP(w, r) +} + +func (a *API) handlerRemoveAttachments(w http.ResponseWriter, r *http.Request) { + // Get post_id from the query parameters + err := r.ParseForm() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + postID := r.FormValue("post_id") + + // Check if the user is the post author or a system admin + userID := r.Header.Get("Mattermost-User-ID") + post, appErr := a.plugin.API.GetPost(postID) + if appErr != nil { + http.Error(w, appErr.Error(), appErr.StatusCode) + return + } + + if errReason := a.plugin.userHasRemovePermissionsToPost(userID, post.ChannelId, postID); errReason != "" { + http.Error(w, errReason, http.StatusForbidden) + return + } + + // Soft-delete the attachments + for _, fileID := range post.FileIds { + if err := a.plugin.SQLStore.DetatchAttachmentFromChannel(fileID); err != nil { + a.plugin.API.LogError("error detaching attachment from channel", "err", err) + http.Error(w, "Internal server error, check logs", http.StatusInternalServerError) + return + } + } + + originalFileIDs := post.FileIds + + // Edit the post to remove the attachments + post.FileIds = []string{} + newPost, appErr := a.plugin.API.UpdatePost(post) + if appErr != nil { + http.Error(w, appErr.Error(), appErr.StatusCode) + return + } + + // Attach the original file IDs to the original post so history is not lost + if err := a.plugin.SQLStore.AttachFileIDsToPost(newPost.OriginalId, originalFileIDs); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +// setupAPI sets up the API for the plugin. +func setupAPI(plugin *Plugin) (*API, error) { + api := &API{ + plugin: plugin, + router: mux.NewRouter(), + } + + group := api.router.PathPrefix("/api/v1").Subrouter() + group.Use(authorizationRequiredMiddleware) + group.HandleFunc("/remove_attachments", api.handlerRemoveAttachments) + + return api, nil +} + +func authorizationRequiredMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID := r.Header.Get("Mattermost-User-ID") + if userID != "" { + next.ServeHTTP(w, r) + return + } + + http.Error(w, "Not authorized", http.StatusUnauthorized) + }) +} diff --git a/server/commands.go b/server/commands.go new file mode 100644 index 0000000..5f1fc67 --- /dev/null +++ b/server/commands.go @@ -0,0 +1,66 @@ +package main + +import ( + "strings" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin" +) + +func (p *Plugin) getCommand() *model.Command { + return &model.Command{ + Trigger: "removeattachments", + DisplayName: "Remove Attachments", + Description: "Remove all attachments from a post", + AutoComplete: false, // Hide from autocomplete + } +} + +func (p *Plugin) createCommandResponse(message string) *model.CommandResponse { + return &model.CommandResponse{ + Text: message, + } +} + +func (p *Plugin) createErrorCommandResponse(errorMessage string) *model.CommandResponse { + return &model.CommandResponse{ + Text: "Can't remove attachments: " + errorMessage, + } +} + +func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { + commandSplit := strings.Split(args.Command, " ") + + // Do not provide command output since it's going to be triggered from the frontend + if len(commandSplit) != 2 { + return p.createErrorCommandResponse("Invalid number of arguments, use `/removeattachments [postID]`."), nil + } + + postID := commandSplit[1] + + // Check if the post ID is a valid ID + if !model.IsValidId(postID) { + return p.createErrorCommandResponse("Invalid post ID"), nil + } + + // Check if the user has permissions to remove attachments from the post + if errReason := p.userHasRemovePermissionsToPost(args.UserId, args.ChannelId, postID); errReason != "" { + return p.createErrorCommandResponse(errReason), nil + } + + // Create an interactive dialog to confirm the action + if err := p.API.OpenInteractiveDialog(model.OpenDialogRequest{ + TriggerId: args.TriggerId, + URL: "/plugins/" + manifest.Id + "/api/v1/remove_attachments?post_id=" + postID, + Dialog: model.Dialog{ + Title: "Remove Attachments", + IntroductionText: "Are you sure you want to remove all attachments from this post?", + SubmitLabel: "Remove", + }, + }); err != nil { + return p.createCommandResponse(err.Error()), nil + } + + // Return nothing, let the dialog/api handle the response + return &model.CommandResponse{}, nil +} diff --git a/server/permissions.go b/server/permissions.go new file mode 100644 index 0000000..7bbef50 --- /dev/null +++ b/server/permissions.go @@ -0,0 +1,37 @@ +package main + +import "github.com/mattermost/mattermost/server/public/model" + +// userHasRemovePermissionsToPost checks if the user has permissions to remove attachments from a post +// based on the post ID, the user ID, and the channel ID. +// Returns an error message if the user does not have permissions, or an empty string if the user has permissions. +func (p *Plugin) userHasRemovePermissionsToPost(userID, channelID, postID string) string { + // Check if the post exists + post, appErr := p.API.GetPost(postID) + if appErr != nil { + return "Post does not exist" + } + + // Check if the post has attachments + if len(post.FileIds) == 0 { + return "Post has no attachments" + } + + // Check if the user is the post author or has permissions to edit others posts + user, appErr := p.API.GetUser(userID) + if appErr != nil { + return "Internal error, check with your system administrator for assistance" + } + + if post.UserId != user.Id && !p.API.HasPermissionToChannel(userID, channelID, model.PermissionEditOthersPosts) { + return "Not authorized" + } + + // Check if the post is editable at this point in time + config := p.API.GetConfig() + if config.ServiceSettings.PostEditTimeLimit != nil && *config.ServiceSettings.PostEditTimeLimit > 0 && model.GetMillis() > post.CreateAt+int64(*config.ServiceSettings.PostEditTimeLimit*1000) { + return "Post is too old to edit" + } + + return "" +} diff --git a/server/plugin.go b/server/plugin.go index a5753a6..2136e44 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -6,6 +6,9 @@ import ( "sync" "github.com/mattermost/mattermost/server/public/plugin" + "github.com/mattermost/mattermost/server/public/pluginapi" + + "github.com/mattermost/mattermost-plugin-attachments-remover/server/sqlstore" ) // Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes. @@ -18,11 +21,39 @@ type Plugin struct { // configuration is the active plugin configuration. Consult getConfiguration and // setConfiguration for usage. configuration *configuration + + // api is the instance of the API struct that handles the plugin's API endpoints. + api *API + + // sqlStore is the instance of the SQLStore struct that handles the plugin's database interactions. + SQLStore *sqlstore.SQLStore } -// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world. func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, "Hello, world!") + p.api.ServeHTTP(w, r) } -// See https://developers.mattermost.com/extend/plugins/server/reference/ +func (p *Plugin) OnActivate() error { + var err error + p.api, err = setupAPI(p) + if err != nil { + return fmt.Errorf("error setting up the API: %w", err) + } + + // Setup direct SQL Store access via the plugin api + papi := pluginapi.NewClient(p.API, p.Driver) + SQLStore, err := sqlstore.New(papi.Store, &papi.Log) + if err != nil { + p.API.LogError("cannot create SQLStore", "err", err) + return err + } + p.SQLStore = SQLStore + + // Register command + if err := p.API.RegisterCommand(p.getCommand()); err != nil { + p.API.LogError("failed to register command", "err", err) + return fmt.Errorf("failed to register command: %w", err) + } + + return nil +} diff --git a/server/sqlstore/file.go b/server/sqlstore/file.go new file mode 100644 index 0000000..5a7334f --- /dev/null +++ b/server/sqlstore/file.go @@ -0,0 +1,53 @@ +package sqlstore + +import ( + "context" + "fmt" + "time" + + sq "github.com/Masterminds/squirrel" + + "github.com/mattermost/mattermost/server/public/model" +) + +const ( + fileInfoTable = "FileInfo" +) + +func (s *SQLStore) DetatchAttachmentFromChannel(fileID string) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + update := map[string]interface{}{ + "ChannelId": "", + "PostId": "", + "DeleteAt": model.GetMillis(), + } + + query := s.masterBuilder. + Update(fileInfoTable). + SetMap(update). + Where(sq.Eq{"Id": fileID}) + + tx, err := s.master.Begin() + if err != nil { + return fmt.Errorf("error starting transaction: %w", err) + } + + q, args, err := query.ToSql() + if err != nil { + return fmt.Errorf("error creating query: %w", err) + } + + _, err = tx.ExecContext(ctx, q, args...) + if err != nil { + s.logger.Error("error detaching attachment from channel", "fileId", fileID, "err", err) + return tx.Rollback() + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("error committing transaction: %w", err) + } + + return nil +} diff --git a/server/sqlstore/post.go b/server/sqlstore/post.go new file mode 100644 index 0000000..c217aa0 --- /dev/null +++ b/server/sqlstore/post.go @@ -0,0 +1,51 @@ +package sqlstore + +import ( + "context" + "encoding/json" + "fmt" + "time" + + sq "github.com/Masterminds/squirrel" +) + +const ( + postsTable = "Posts" +) + +func (s *SQLStore) AttachFileIDsToPost(postID string, fileIDs []string) error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + fileIDsJSON, err := json.Marshal(fileIDs) + if err != nil { + return fmt.Errorf("error marshaling fileIds: %w", err) + } + + query := s.masterBuilder. + Update(postsTable). + Set("FileIds", fileIDsJSON). + Where(sq.Eq{"Id": postID}) + + tx, err := s.master.Begin() + if err != nil { + return fmt.Errorf("error starting transaction: %w", err) + } + + q, args, err := query.ToSql() + if err != nil { + return fmt.Errorf("error creating query: %w", err) + } + + _, err = tx.ExecContext(ctx, q, args...) + if err != nil { + s.logger.Error("error attaching file to post", "postId", postID, "err", err) + return tx.Rollback() + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("error committing transaction: %w", err) + } + + return nil +} diff --git a/server/sqlstore/store.go b/server/sqlstore/store.go new file mode 100644 index 0000000..4a26b0f --- /dev/null +++ b/server/sqlstore/store.go @@ -0,0 +1,79 @@ +package sqlstore + +import ( + "database/sql" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/mattermost/mattermost/server/public/model" +) + +type Source interface { + GetMasterDB() (*sql.DB, error) + GetReplicaDB() (*sql.DB, error) + DriverName() string +} + +type Logger interface { + Error(message string, keyValuePairs ...interface{}) + Warn(message string, keyValuePairs ...interface{}) + Info(message string, keyValuePairs ...interface{}) + Debug(message string, keyValuePairs ...interface{}) +} + +type SQLStore struct { + src Source + master *sqlx.DB + replica *sqlx.DB + masterBuilder sq.StatementBuilderType + replicaBuilder sq.StatementBuilderType + logger Logger +} + +// New constructs a new instance of SQLStore. +func New(src Source, logger Logger) (*SQLStore, error) { + var master, replica *sqlx.DB + + masterDB, err := src.GetMasterDB() + if err != nil { + return nil, err + } + master = sqlx.NewDb(masterDB, src.DriverName()) + + replicaDB, err := src.GetReplicaDB() + if err != nil { + return nil, err + } + replica = sqlx.NewDb(replicaDB, src.DriverName()) + + masterBuilder := sq.StatementBuilder.PlaceholderFormat(sq.Question) + if src.DriverName() == model.DatabaseDriverPostgres { + masterBuilder = masterBuilder.PlaceholderFormat(sq.Dollar) + } + + if src.DriverName() == model.DatabaseDriverMysql { + master.MapperFunc(func(s string) string { return s }) + } + + masterBuilder = masterBuilder.RunWith(master) + + replicaBuilder := sq.StatementBuilder.PlaceholderFormat(sq.Question) + if src.DriverName() == model.DatabaseDriverPostgres { + replicaBuilder = replicaBuilder.PlaceholderFormat(sq.Dollar) + } + + if src.DriverName() == model.DatabaseDriverMysql { + replica.MapperFunc(func(s string) string { return s }) + } + + replicaBuilder = replicaBuilder.RunWith(replica) + + return &SQLStore{ + src, + master, + replica, + masterBuilder, + replicaBuilder, + logger, + }, nil +} diff --git a/webapp/src/actions.tsx b/webapp/src/actions.tsx new file mode 100644 index 0000000..635877e --- /dev/null +++ b/webapp/src/actions.tsx @@ -0,0 +1,45 @@ +import {AnyAction, Dispatch} from 'redux'; +import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {GetStateFunc} from 'mattermost-redux/types/actions'; +import {Client4} from 'mattermost-redux/client'; +import {IntegrationTypes} from 'mattermost-redux/action_types'; + +export function setTriggerId(triggerId: string) { + return { + type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, + data: triggerId, + }; +} + +export function triggerRemoveAttachmentsCommand(postID: string) { + return (dispatch: Dispatch, getState: GetStateFunc) => { + const command = '/removeattachments ' + postID; + clientExecuteCommand(dispatch, getState, command); + + return {data: true}; + }; +} + +export async function clientExecuteCommand(dispatch: Dispatch, getState: GetStateFunc, command: string) { + let currentChannel = getCurrentChannel(getState()); + const currentTeamId = getCurrentTeamId(getState()); + + // Default to town square if there is no current channel (i.e., if Mattermost has not yet loaded) + if (!currentChannel) { + currentChannel = await Client4.getChannelByName(currentTeamId, 'town-square'); + } + + const args = { + channel_id: currentChannel?.id, + team_id: currentTeamId, + }; + + try { + //@ts-ignore Typing in mattermost-redux is wrong + const data = await Client4.executeCommand(command, args); + dispatch(setTriggerId(data?.trigger_id)); + } catch (error) { + console.error(error); //eslint-disable-line no-console + } +} diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index c3fc933..2cc4ba1 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -1,15 +1,31 @@ import {Store, Action} from 'redux'; -import {GlobalState} from '@mattermost/types/lib/store'; +import {GlobalState} from 'mattermost-redux/types/store'; +import {getPost} from 'mattermost-redux/selectors/entities/posts'; import manifest from '@/manifest'; import {PluginRegistry} from '@/types/mattermost-webapp'; +import {triggerRemoveAttachmentsCommand} from './actions'; + export default class Plugin { // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function public async initialize(registry: PluginRegistry, store: Store>>) { // @see https://developers.mattermost.com/extend/plugins/webapp/reference/ + registry.registerPostDropdownMenuAction( + 'Remove attachments', + async (postID) => { + store.dispatch(triggerRemoveAttachmentsCommand(postID) as any); + }, + (postID) => { + const state = store.getState(); + const post = getPost(state, postID); + + // Don't show up if the post has no attachments. Permissions are checked server-side. + return typeof post.file_ids?.length !== 'undefined' && post.file_ids?.length > 0; + }, + ); } } diff --git a/webapp/src/types/mattermost-webapp/index.d.ts b/webapp/src/types/mattermost-webapp/index.d.ts index 906abe1..dcbe2c3 100644 --- a/webapp/src/types/mattermost-webapp/index.d.ts +++ b/webapp/src/types/mattermost-webapp/index.d.ts @@ -1,5 +1,9 @@ +type PostMenuAction = (postID: string) => void; +type PostMenuFilter = (postID: string) => boolean; + export interface PluginRegistry { registerPostTypeComponent(typeName: string, component: React.ElementType) + registerPostDropdownMenuAction(text: string, action?: PostMenuAction, filter?: PostMenuFilter) // Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference }