Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4023,7 +4023,7 @@ Adding a prefix to all Redis cache keys reduces key collisions, simplifies debug

<Note>

- When an alternate filestore target is configured, Mattermost Cloud admins can generate a presigned download URL for exports using the `/exportlink [job-id|zip file|latest]` slash command. On Amazon S3 this is an S3 presigned URL; on Azure Blob Storage it's a Shared Access Signature (SAS) URL. The lifetimes of these URLs are controlled, respectively, by `ExportAmazonS3PresignExpiresSeconds` or `ExportAzurePresignExpiresSeconds`. See the [Mattermost data migration](/administration-guide/manage/cloud-data-export#create-the-export) documentation for details. Alternatively, Cloud and self-hosted admins can use the [mmctl export generate-presigned-url](/administration-guide/manage/mmctl-command-line-tool#mmctl-export-generate-presigned-url) command to generate a presigned URL directly from mmctl.
- Generating a presigned URL requires the feature flag `EnableExportDirectDownload` to be set to `true`, the storage must support presigned links (Amazon S3 or Azure Blob Storage), and this experimental configuration setting must be set to `true`. Presigned URLs for exports aren't supported for systems with shared storage.
- When an alternate filestore target is configured, Mattermost Cloud admins can generate a presigned download URL for exports using the `/exportlink [job-id|zip file|latest]` slash command. On Amazon S3 this is an S3 presigned URL; on Azure Blob Storage it's a Shared Access Signature (SAS) URL. The lifetimes of these URLs are controlled, respectively, by `ExportAmazonS3PresignExpiresSeconds` or `ExportAzurePresignExpiresSeconds`. See the [Mattermost data migration](/administration-guide/manage/cloud-data-export#create-the-export) documentation for details. Cloud admins can also use the [mmctl export generate-presigned-url](/administration-guide/manage/mmctl-command-line-tool#mmctl-export-generate-presigned-url) command to generate a presigned URL directly from mmctl.
- Generating a presigned URL is only available in Mattermost Cloud, and requires storage that supports presigned links (Amazon S3 or Azure Blob Storage) and this dedicated export store setting to be set to `true`. Presigned URLs for exports aren't supported for systems with shared storage.

</Note>
Original file line number Diff line number Diff line change
Expand Up @@ -2978,7 +2978,7 @@ $ mmctl export download sample_export.zip

Generate a pre-signed URL for an export file in cases where a Mattermost Cloud export is large and challenging to download from the Mattermost server.

Requires the `EnableExportDirectDownload` feature flag to be set to `true`.
Only available in Mattermost Cloud, and requires `FileSettings` > `DedicatedExportStore` to be set to `true`, with export storage that supports presigned links (Amazon S3 or Azure Blob Storage).

**Format**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,11 +245,21 @@ describe('Channels', () => {
cy.wrap(el).should('contain', channelType.all);
});

// * Users should be able to type and search
// * Hide Archived is on by default, so search only returns active matches
cy.get('#hideArchivedPreferenceCheckbox').should('be.visible').and('have.attr', 'aria-checked', 'true');
cy.get('#searchChannelsTextbox').should('be.visible').type('iv').wait(TIMEOUTS.HALF_SEC);
cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 1);
cy.get('#moreChannelsList').should('be.visible').within(() => {
cy.findByText(newChannel.display_name).should('be.visible');
cy.findByText(testArchivedChannel.display_name).should('not.exist');
});

// # Uncheck Hide Archived so archived matches appear in All search results
cy.get('#hideArchivedPreferenceCheckbox').click();
cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 2);
cy.get('#moreChannelsList').should('be.visible').within(() => {
cy.findByText(newChannel.display_name).should('be.visible');
cy.findByText(testArchivedChannel.display_name).should('be.visible');
});

cy.get('#browseChannelsModal').should('be.visible');
Expand Down
1 change: 0 additions & 1 deletion e2e-tests/playwright/lib/src/server/default_config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,6 @@
AppsEnabled: false,
NormalizeLdapDNs: false,
WysiwygEditor: false,
EnableExportDirectDownload: false,
MoveThreadsEnabled: false,
NotificationMonitoring: true,
AttributeValueMasking: false,
Expand All @@ -816,7 +815,7 @@
CJKSearch: true,
AggregatePluginMetrics: false,
ManagedChannelCategories: false,
SessionAttributes: false,

Check warning on line 818 in e2e-tests/playwright/lib/src/server/default_config.ts

View workflow job for this annotation

GitHub Actions / check

File has too many lines (908). Maximum allowed is 800
DiscoverableChannels: false,
MobileEphemeralMode: false,
PropertyFieldRank: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default class BrowseChannelsModal {
readonly container: Locator;

readonly createNewChannelButton: Locator;
readonly hideArchivedCheckbox: Locator;
readonly hideJoinedCheckbox: Locator;
readonly searchInput: Locator;

Expand All @@ -17,6 +18,7 @@ export default class BrowseChannelsModal {
this.container = container;

this.createNewChannelButton = container.getByRole('button', {name: 'Create New Channel'});
this.hideArchivedCheckbox = container.getByRole('checkbox', {name: 'Hide Archived'});
this.hideJoinedCheckbox = container.getByRole('checkbox', {name: 'Hide Joined'});
this.searchInput = container.getByRole('textbox', {name: 'Search channels'});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,15 @@ test(
await hideJoinedCheckbox.click();

// # Focus on Create Channel button and tab through elements
// Tab order: Close → Search → Channel type filter → Hide Archived → Hide Joined → first channel
const createChannelButton = dialog.createNewChannelButton;
await createChannelButton.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');

// * Verify channel name is highlighted and has proper aria-label
await dialog.toHaveChannelAsNthResult(channel1.name, 0);
Expand Down Expand Up @@ -116,6 +118,7 @@ test(
- /status: \\d+ Results/
- status: Channel type filter set to All
- button "Channel type filter"
- checkbox "Hide archived channels" [checked]: Hide Archived
- checkbox "Hide joined channels": Hide Joined
- search
`);
Expand Down
2 changes: 1 addition & 1 deletion server/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ TEMPLATES_DIR=templates

# Plugins Packages
PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:)
PLUGIN_PACKAGES += mattermost-plugin-calls-v1.12.2
PLUGIN_PACKAGES += mattermost-plugin-calls-v1.12.3
PLUGIN_PACKAGES += mattermost-plugin-github-v2.8.0
PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.13.0
PLUGIN_PACKAGES += mattermost-plugin-jira-v4.8.0
Expand Down
109 changes: 109 additions & 0 deletions server/channels/api4/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"

"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"

"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -229,3 +234,107 @@ func BenchmarkDownloadExport(b *testing.B) {
require.NoError(b, err)
}
}

func TestGeneratePresignedURL(t *testing.T) {
mainHelper.Parallel(t)

t.Run("no permissions", func(t *testing.T) {
th := Setup(t)
_, _, err := th.Client.GeneratePresignedURL(context.Background(), "export.zip")
require.Error(t, err)
CheckErrorID(t, err, "api.context.permissions.app_error")
})

t.Run("blocked when not running in Cloud", func(t *testing.T) {
th := Setup(t)
th.App.Srv().SetLicense(model.NewTestLicense())

_, resp, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), "export.zip")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
CheckErrorID(t, err, "app.export.generate_presigned_url.direct_download.app_error")
})

t.Run("blocked without a license", func(t *testing.T) {
th := Setup(t)
th.App.Srv().SetLicense(nil)

_, resp, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), "export.zip")
require.Error(t, err)
CheckForbiddenStatus(t, resp)
CheckErrorID(t, err, "app.export.generate_presigned_url.direct_download.app_error")
})

t.Run("passes gate when Cloud, then requires a dedicated export store", func(t *testing.T) {
th := Setup(t)
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.FileSettings.DedicatedExportStore = false
})

_, _, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), "export.zip")
require.Error(t, err)
CheckErrorID(t, err, "app.export.generate_presigned_url.config.app_error")
})

// The full happy path against a real presign-capable (S3/minio) export store: a
// Cloud server with a dedicated export store returns a working presigned URL over
// the API. Skipped when minio isn't reachable.
t.Run("succeeds against a presign-capable export store", func(t *testing.T) {
s3Host := os.Getenv("CI_MINIO_HOST")
if s3Host == "" {
s3Host = "localhost"
}
s3Port := os.Getenv("CI_MINIO_PORT")
if s3Port == "" {
s3Port = "9000"
}
s3Endpoint := net.JoinHostPort(s3Host, s3Port)

conn, err := net.DialTimeout("tcp", s3Endpoint, 2*time.Second)
if err != nil {
t.Skipf("minio not available at %s: %v", s3Endpoint, err)
}
conn.Close()

// Use a fresh bucket per run so MakeBucket is unambiguous.
bucket := model.NewId()

// The dedicated export filestore is built once at startup, so the export-store
// configuration must be applied before the server starts, not via UpdateConfig.
th := SetupConfig(t, func(cfg *model.Config) {
*cfg.FileSettings.DedicatedExportStore = true
*cfg.FileSettings.ExportDriverName = model.ImageDriverS3
*cfg.FileSettings.ExportAmazonS3AccessKeyId = model.MinioAccessKey
*cfg.FileSettings.ExportAmazonS3SecretAccessKey = model.MinioSecretKey
*cfg.FileSettings.ExportAmazonS3Bucket = bucket
*cfg.FileSettings.ExportAmazonS3Endpoint = s3Endpoint
*cfg.FileSettings.ExportAmazonS3Region = ""
*cfg.FileSettings.ExportAmazonS3SSL = false
})
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))

backend, ok := th.App.ExportFileBackend().(*filestore.S3FileBackend)
require.True(t, ok, "expected a dedicated S3 export backend")
require.NoError(t, backend.MakeBucket())

exportName := "job_export.zip"
payload := []byte("export-payload")
_, appErr := th.App.WriteExportFile(bytes.NewReader(payload), filepath.Join(*th.App.Config().ExportSettings.Directory, exportName))
require.Nil(t, appErr)

resp, _, err := th.SystemAdminClient.GeneratePresignedURL(context.Background(), exportName)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotEmpty(t, resp.URL)

// The presigned URL should serve the exported file directly.
httpResp, err := (&http.Client{Timeout: 30 * time.Second}).Get(resp.URL)
require.NoError(t, err)
defer httpResp.Body.Close()
require.Equal(t, http.StatusOK, httpResp.StatusCode)
body, err := io.ReadAll(httpResp.Body)
require.NoError(t, err)
require.Equal(t, payload, body)
})
}
14 changes: 7 additions & 7 deletions server/channels/app/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -1260,32 +1260,32 @@ func (a *App) ListExports() ([]string, *model.AppError) {
}

func (a *App) GeneratePresignURLForExport(name string) (*model.PresignURLResponse, *model.AppError) {
if !a.Config().FeatureFlags.EnableExportDirectDownload {
return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.featureflag.app_error", nil, "", http.StatusInternalServerError)
if !a.License().IsCloud() {
return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.direct_download.app_error", nil, "", http.StatusForbidden)
}

if !*a.Config().FileSettings.DedicatedExportStore {
return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.config.app_error", nil, "", http.StatusInternalServerError)
return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.config.app_error", nil, "", http.StatusInternalServerError)
}

b := a.ExportFileBackend()
backend, ok := b.(filestore.FileBackendWithLinkGenerator)
if !ok {
return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.driver.app_error", nil, "", http.StatusInternalServerError)
return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.driver.app_error", nil, "", http.StatusInternalServerError)
}

p := path.Join(*a.Config().ExportSettings.Directory, filepath.Base(name))
found, err := b.FileExists(p)
if err != nil {
return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.fileexist.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.fileexist.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if !found {
return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.notfound.app_error", nil, "", http.StatusInternalServerError)
return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.notfound.app_error", nil, "", http.StatusInternalServerError)
}

link, exp, err := backend.GeneratePublicLink(p)
if err != nil {
return nil, model.NewAppError("GeneratePresignURLForExport", "app.eport.generate_presigned_url.link.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
return nil, model.NewAppError("GeneratePresignURLForExport", "app.export.generate_presigned_url.link.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}

return &model.PresignURLResponse{
Expand Down
105 changes: 105 additions & 0 deletions server/channels/app/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
Expand All @@ -25,6 +28,7 @@ import (
"github.com/mattermost/mattermost/server/v8/channels/app/imports"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
"github.com/mattermost/mattermost/server/v8/platform/shared/filestore"
)

func TestReactionsOfPost(t *testing.T) {
Expand Down Expand Up @@ -1728,3 +1732,104 @@ func TestExportDeactivatedUserDMs(t *testing.T) {
require.True(t, foundThreadedReplyInImport,
"Threaded reply from deactivated user should be imported")
}

func TestGeneratePresignURLForExport(t *testing.T) {
mainHelper.Parallel(t)

t.Run("blocked when not running in Cloud", func(t *testing.T) {
th := Setup(t)
th.App.Srv().SetLicense(model.NewTestLicense())

resp, appErr := th.App.GeneratePresignURLForExport("export.zip")
assert.Nil(t, resp)
require.NotNil(t, appErr)
assert.Equal(t, "app.export.generate_presigned_url.direct_download.app_error", appErr.Id)
})

t.Run("blocked without a license", func(t *testing.T) {
th := Setup(t)
th.App.Srv().SetLicense(nil)

resp, appErr := th.App.GeneratePresignURLForExport("export.zip")
assert.Nil(t, resp)
require.NotNil(t, appErr)
assert.Equal(t, "app.export.generate_presigned_url.direct_download.app_error", appErr.Id)
})

t.Run("passes gate when Cloud, then requires a dedicated export store", func(t *testing.T) {
th := Setup(t)
// The Cloud gate is checked before the dedicated export store requirement,
// so a Cloud license advances past it and fails on the (disabled) dedicated
// export store instead.
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.FileSettings.DedicatedExportStore = false
})

resp, appErr := th.App.GeneratePresignURLForExport("export.zip")
assert.Nil(t, resp)
require.NotNil(t, appErr)
assert.Equal(t, "app.export.generate_presigned_url.config.app_error", appErr.Id)
})

// The full happy path against a real presign-capable (S3/minio) export store: a
// Cloud server with a dedicated export store returns a working presigned URL.
// Skipped when minio isn't reachable.
t.Run("succeeds against a presign-capable export store", func(t *testing.T) {
s3Host := os.Getenv("CI_MINIO_HOST")
if s3Host == "" {
s3Host = "localhost"
}
s3Port := os.Getenv("CI_MINIO_PORT")
if s3Port == "" {
s3Port = "9000"
}
s3Endpoint := net.JoinHostPort(s3Host, s3Port)

conn, err := net.DialTimeout("tcp", s3Endpoint, 2*time.Second)
if err != nil {
t.Skipf("minio not available at %s: %v", s3Endpoint, err)
}
conn.Close()

// Use a fresh bucket per run so MakeBucket is unambiguous.
bucket := model.NewId()

// The dedicated export filestore is built once at startup, so the export-store
// configuration must be applied before the server starts, not via UpdateConfig.
th := SetupConfig(t, func(cfg *model.Config) {
*cfg.FileSettings.DedicatedExportStore = true
*cfg.FileSettings.ExportDriverName = model.ImageDriverS3
*cfg.FileSettings.ExportAmazonS3AccessKeyId = model.MinioAccessKey
*cfg.FileSettings.ExportAmazonS3SecretAccessKey = model.MinioSecretKey
*cfg.FileSettings.ExportAmazonS3Bucket = bucket
*cfg.FileSettings.ExportAmazonS3Endpoint = s3Endpoint
*cfg.FileSettings.ExportAmazonS3Region = ""
*cfg.FileSettings.ExportAmazonS3SSL = false
})
th.App.Srv().SetLicense(model.NewTestLicense("cloud"))

backend, ok := th.App.ExportFileBackend().(*filestore.S3FileBackend)
require.True(t, ok, "expected a dedicated S3 export backend")
require.NoError(t, backend.MakeBucket())

exportName := "job_export.zip"
payload := []byte("export-payload")
_, appErr := th.App.WriteExportFile(bytes.NewReader(payload), filepath.Join(*th.App.Config().ExportSettings.Directory, exportName))
require.Nil(t, appErr)

resp, appErr := th.App.GeneratePresignURLForExport(exportName)
require.Nil(t, appErr)
require.NotNil(t, resp)
require.NotEmpty(t, resp.URL)

// The presigned URL should serve the exported file directly.
httpResp, err := (&http.Client{Timeout: 30 * time.Second}).Get(resp.URL)
require.NoError(t, err)
defer httpResp.Body.Close()
assert.Equal(t, http.StatusOK, httpResp.StatusCode)
body, err := io.ReadAll(httpResp.Body)
require.NoError(t, err)
assert.Equal(t, payload, body)
})
}
2 changes: 1 addition & 1 deletion server/channels/app/slashcommands/command_exportlink.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (*ExportLinkProvider) GetTrigger() string {
}

func (*ExportLinkProvider) GetCommand(a *app.App, T i18n.TranslateFunc) *model.Command {
if !a.Config().FeatureFlags.EnableExportDirectDownload {
if !a.License().IsCloud() {
return nil
}

Expand Down
Loading
Loading