This commit is contained in:
parent
09c4f13f2c
commit
2d962d18d2
16 changed files with 520 additions and 89 deletions
93
server/api.go
Normal file
93
server/api.go
Normal 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
66
server/commands.go
Normal 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
37
server/permissions.go
Normal 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 ""
|
||||
}
|
|
@ -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
53
server/sqlstore/file.go
Normal 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
51
server/sqlstore/post.go
Normal 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
79
server/sqlstore/store.go
Normal 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
|
||||
}
|
Reference in a new issue