Skip to content

Commit

Permalink
Support using a different s3 bucket through plugin setting (#46)
Browse files Browse the repository at this point in the history
* add api endpoint to run legal hold job on demand

* fix lint

* PR feedback

* call job in goroutine to avoid blocking ServeHTTP hook

* reorder defer blocks for cleaning up legal hold job

* call context cancel function in defer

* move processAllLegalHolds body back into run() method

* support using a different s3 bucket through plugin setting

* move code around

* update readme

* initial s3 bucket form implementation

* test connection works

* show success/fail connection messages. check connection on plugin startup

* update readme

* disable inputs instead of hiding

* reorder settings in the admin console

* show aws secret key as asterisks

* remove comment from code

---------

Co-authored-by: wiggin77 <[email protected]>
  • Loading branch information
mickmister and wiggin77 authored May 30, 2024
1 parent 93a41b5 commit 5fcd868
Show file tree
Hide file tree
Showing 16 changed files with 575 additions and 10 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ of the System Console in the usual way.
Once the plugin is installed, a new "Legal Hold" section will appear in the System Console UI
in the Plugins section. There are two main settings:

- **Enable Plugin**: controls whether the plugin is enabled. It must be enabled to use it.
- **Time of Day**: this setting controls at what time the delay collection of Legal Hold data
* **Enable Plugin**: controls whether the plugin is enabled. It must be enabled to use it.
* **Amazon S3 Bucket Settings**: optionally use a separate S3 Bucket than the one configured for your Mattermost server.
* **Time of Day**: this setting controls at what time the delay collection of Legal Hold data
should occur. We recommend choosing a quiet time of day to minimise impact on your users. Make
sure to specify the time in the format shown in the example.

Expand Down
5 changes: 5 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"key": "LegalHoldsSettings",
"display_name": "Legal Holds:",
"type": "custom"
},
{
"key": "AmazonS3BucketSettings",
"display_name": "S3 Bucket:",
"type": "custom"
}
]
}
Expand Down
43 changes: 43 additions & 0 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/gorilla/mux"
mattermostModel "github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin"
"github.com/mattermost/mattermost-server/v6/shared/filestore"
"github.com/pkg/errors"

"github.com/mattermost/mattermost-plugin-legal-hold/server/model"
Expand Down Expand Up @@ -43,6 +44,7 @@ func (p *Plugin) ServeHTTP(_ *plugin.Context, w http.ResponseWriter, r *http.Req
router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/release", p.releaseLegalHold).Methods(http.MethodPost)
router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/update", p.updateLegalHold).Methods(http.MethodPost)
router.HandleFunc("/api/v1/legalhold/{legalhold_id:[A-Za-z0-9]+}/download", p.downloadLegalHold).Methods(http.MethodGet)
router.HandleFunc("/api/v1/test_amazon_s3_connection", p.testAmazonS3Connection).Methods(http.MethodPost)

// Other routes
router.HandleFunc("/api/v1/legalhold/run", p.runJobFromAPI).Methods(http.MethodPost)
Expand Down Expand Up @@ -302,6 +304,47 @@ func (p *Plugin) runJobFromAPI(w http.ResponseWriter, _ *http.Request) {
go p.legalHoldJob.RunFromAPI()
}

// testAmazonS3Connection tests the plugin's custom Amazon S3 connection
func (p *Plugin) testAmazonS3Connection(w http.ResponseWriter, _ *http.Request) {
type messageResponse struct {
Message string `json:"message"`
}

var err error

conf := p.getConfiguration()
if !conf.AmazonS3BucketSettings.Enable {
http.Error(w, "Amazon S3 bucket settings are not enabled", http.StatusBadRequest)
return
}

filesBackendSettings := FixedFileSettingsToFileBackendSettings(conf.AmazonS3BucketSettings.Settings, false, true)
filesBackend, err := filestore.NewFileBackend(filesBackendSettings)
if err != nil {
err = errors.Wrap(err, "unable to initialize the file store")
http.Error(w, err.Error(), http.StatusInternalServerError)
p.Client.Log.Error(err.Error())
return
}

if err = filesBackend.TestConnection(); err != nil {
err = errors.Wrap(err, "failed to connect to Amazon S3 bucket")
http.Error(w, err.Error(), http.StatusInternalServerError)
p.Client.Log.Error(err.Error())
return
}

response := messageResponse{
Message: "Successfully connected to Amazon S3 bucket",
}

w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(response)
if err != nil {
p.Client.Log.Error("failed to write http response", err.Error())
}
}

func RequireLegalHoldID(r *http.Request) (string, error) {
props := mux.Vars(r)

Expand Down
61 changes: 60 additions & 1 deletion server/api_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,62 @@
package main

// TODO: Implement me!
import (
"net/http"
"net/http/httptest"
"testing"

pluginapi "github.com/mattermost/mattermost-plugin-api"
"github.com/mattermost/mattermost-plugin-legal-hold/server/config"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestTestAmazonS3Connection(t *testing.T) {
p := &Plugin{}
api := &plugintest.API{}
p.SetDriver(&plugintest.Driver{})
p.SetAPI(api)
p.Client = pluginapi.NewClient(p.API, p.Driver)

api.On("HasPermissionTo", "test_user_id", model.PermissionManageSystem).Return(true)
api.On("LogInfo", mock.Anything).Maybe()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/bucket/" {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusNotFound)
}
}))

defer server.Close()

p.setConfiguration(&config.Configuration{
TimeOfDay: "10:00pm -0500h",
AmazonS3BucketSettings: config.AmazonS3BucketSettings{
Enable: true,
Settings: model.FileSettings{
DriverName: model.NewString("amazons3"),
AmazonS3Bucket: model.NewString("bucket"),
AmazonS3AccessKeyId: model.NewString("access_key_id"),
AmazonS3SecretAccessKey: model.NewString("secret_access_key"),
AmazonS3RequestTimeoutMilliseconds: model.NewInt64(5000),
AmazonS3Endpoint: model.NewString(server.Listener.Addr().String()),
AmazonS3Region: model.NewString("us-east-1"),
AmazonS3SSL: model.NewBool(false),
AmazonS3SSE: model.NewBool(false),
},
},
})

req, err := http.NewRequest(http.MethodPost, "/api/v1/test_amazon_s3_connection", nil)
require.NoError(t, err)

req.Header.Add("Mattermost-User-Id", "test_user_id")

recorder := httptest.NewRecorder()
p.ServeHTTP(nil, recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
}
10 changes: 9 additions & 1 deletion server/config/configuration.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package config

import "github.com/mattermost/mattermost-server/v6/model"

// Configuration captures the plugin's external Configuration as exposed in the Mattermost server
// Configuration, as well as values computed from the Configuration. Any public fields will be
// deserialized from the Mattermost server Configuration in OnConfigurationChange.
Expand All @@ -12,7 +14,13 @@ package config
// If you add non-reference types to your Configuration struct, be sure to rewrite Clone as a deep
// copy appropriate for your types.
type Configuration struct {
TimeOfDay string
TimeOfDay string
AmazonS3BucketSettings AmazonS3BucketSettings
}

type AmazonS3BucketSettings struct {
Enable bool
Settings model.FileSettings
}

// Clone shallow copies the Configuration. Your implementation may require a deep copy if
Expand Down
16 changes: 15 additions & 1 deletion server/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,14 +158,28 @@ func (p *Plugin) Reconfigure() error {
return nil
}

conf := p.getConfiguration()

serverFileSettings := p.Client.Configuration.GetUnsanitizedConfig().FileSettings
if conf.AmazonS3BucketSettings.Enable {
serverFileSettings = conf.AmazonS3BucketSettings.Settings
}

// Reinitialise the filestore backend
// FIXME: Boolean flags shouldn't be hard coded.
filesBackendSettings := FixedFileSettingsToFileBackendSettings(p.Client.Configuration.GetUnsanitizedConfig().FileSettings, false, true)
filesBackendSettings := FixedFileSettingsToFileBackendSettings(serverFileSettings, false, true)
filesBackend, err := filestore.NewFileBackend(filesBackendSettings)
if err != nil {
p.Client.Log.Error("unable to initialize the files storage", "err", err)
return errors.New("unable to initialize the files storage")
}

if err = filesBackend.TestConnection(); err != nil {
err = errors.Wrap(err, "connection test for filestore failed")
p.Client.Log.Error(err.Error())
return err
}

p.FileBackend = filesBackend

// Remove old job if exists
Expand Down
9 changes: 7 additions & 2 deletions webapp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ class APIClient {
return this.doPost(url, data);
};

doGet = async (url: string, headers = {}) => {
testAmazonS3Connection = () => {
const url = `${this.url}/test_amazon_s3_connection`;
return this.doPost(url, {}) as Promise<{message: string}>;
};

private doGet = async (url: string, headers = {}) => {
const options = {
method: 'get',
headers,
Expand All @@ -53,7 +58,7 @@ class APIClient {
});
};

doPost = async (url: string, body: any, headers = {}) => {
private doPost = async (url: string, body: any, headers = {}) => {
const options = {
method: 'post',
body: JSON.stringify(body),
Expand Down
32 changes: 32 additions & 0 deletions webapp/src/components/admin_console_settings/base_setting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';

type Props = React.PropsWithChildren<{
id: string;
name: string;
helpText: string;
}>;

const BaseSetting = (props: Props) => {
return (
<div
id={`legal-hold-admin-console-setting-${props.id}`}
className='form-group'
>
<label
className='control-label col-sm-4'
>
{props.name && `${props.name}:`}
</label>
<div className='col-sm-8'>
{props.children}
<div
className='help-text'
>
<span>{props.helpText}</span>
</div>
</div>
</div>
);
};

export default BaseSetting;
41 changes: 41 additions & 0 deletions webapp/src/components/admin_console_settings/boolean_setting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';

import BaseSetting from './base_setting';

type Props = {
id: string;
name: string;
helpText: string;
onChange: (value: boolean) => void;
value: boolean;
disabled?: boolean;
};

const BooleanSetting = (props: Props) => {
return (
<BaseSetting
{...props}
>
<label className='radio-inline'>
<input
type='radio'
onChange={() => props.onChange(true)}
checked={props.value}
disabled={props.disabled}
/>
<span>{'true'}</span>
</label>
<label className='radio-inline'>
<input
type='radio'
onChange={() => props.onChange(false)}
checked={!props.value}
disabled={props.disabled}
/>
<span>{'false'}</span>
</label>
</BaseSetting>
);
};

export default BooleanSetting;
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React, {useEffect, useRef, useState} from 'react';

import BaseSetting from './base_setting';

type Props = {
id: string;
name: string;
helpText: string;
onChange: (value: string) => void;
value: string;
disabled?: boolean;
};

const SecretTextSetting = (props: Props) => {
const [value, setValue] = useState('');
const mounted = useRef(false);

useEffect(() => {
if (mounted.current) {
setValue(props.value);
return;
}

if (props.value) {
setValue('*'.repeat(32));
}

mounted.current = true;
}, [props.value]);

const handleChange = (newValue: string) => {
setValue(newValue);
};

return (
<BaseSetting
{...props}
>
<input
id={props.id}
className='form-control'
type='text'
value={value}
onChange={(e) => handleChange(e.target.value)}
disabled={props.disabled}
/>
</BaseSetting>
);
};

export default SecretTextSetting;
38 changes: 38 additions & 0 deletions webapp/src/components/admin_console_settings/status_message.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';

type Props = {
state: 'warn' | 'success';
message: string;
}

const StatusMessage = (props: Props) => {
const {state, message} = props;

if (state === 'warn') {
return (
<div>
<div className='alert alert-warning'>
<i
className='fa fa-warning'
title='Warning Icon'
/>
<span>{message}</span>
</div>
</div>
);
}

return (
<div>
<div className='alert alert-success'>
<i
className='fa fa-check'
title='Success Icon'
/>
<span>{message}</span>
</div>
</div>
);
};

export default StatusMessage;
Loading

0 comments on commit 5fcd868

Please sign in to comment.