added command with interactive dialog
Some checks failed
ci / plugin-ci (push) Has been cancelled

This commit is contained in:
Felipe M 2024-08-08 15:52:29 +02:00
parent 09c4f13f2c
commit 2d962d18d2
Signed by: fmartingr
GPG key ID: CCFBC5637D4000A8
16 changed files with 520 additions and 89 deletions

93
server/api.go Normal file
View file

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

66
server/commands.go Normal file
View file

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

37
server/permissions.go Normal file
View file

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

View file

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

53
server/sqlstore/file.go Normal file
View file

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

51
server/sqlstore/post.go Normal file
View file

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

79
server/sqlstore/store.go Normal file
View file

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