mirror of
https://github.com/mattermost/focalboard.git
synced 2024-12-24 13:43:12 +02:00
Forward-porting fileinfo limits (#3164)
* Updated go version * Generated mocks * lint fix * lint fix * lint fix * backported fileinfo limits * backported fileinfo limits * added tests * synced with main * Server lint fix * used a better name
This commit is contained in:
parent
04223a3f76
commit
f3faf39eaa
@ -1913,6 +1913,31 @@ func (a *API) handleServeFile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
fileInfo, err := a.app.GetFileInfo(filename)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
return
|
||||
}
|
||||
|
||||
if fileInfo != nil && fileInfo.Archived {
|
||||
fileMetadata := map[string]interface{}{
|
||||
"archived": true,
|
||||
"name": fileInfo.Name,
|
||||
"size": fileInfo.Size,
|
||||
"extension": fileInfo.Extension,
|
||||
}
|
||||
|
||||
data, jsonErr := json.Marshal(fileMetadata)
|
||||
if jsonErr != nil {
|
||||
a.logger.Error("failed to marshal archived file metadata", mlog.String("filename", filename), mlog.Err(jsonErr))
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", jsonErr)
|
||||
return
|
||||
}
|
||||
|
||||
jsonBytesResponse(w, http.StatusBadRequest, data)
|
||||
return
|
||||
}
|
||||
|
||||
fileReader, err := a.app.GetFileReader(board.TeamID, boardID, filename)
|
||||
if err != nil {
|
||||
a.errorResponse(w, r.URL.Path, http.StatusInternalServerError, "", err)
|
||||
|
@ -1,18 +1,23 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
|
||||
"github.com/mattermost/mattermost-server/v6/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
const emptyString = "empty"
|
||||
|
||||
var errEmptyFilename = errors.New("IsFileArchived: empty filename not allowed")
|
||||
|
||||
func (a *App) SaveFile(reader io.Reader, teamID, rootID, filename string) (string, error) {
|
||||
// NOTE: File extension includes the dot
|
||||
fileExtension := strings.ToLower(filepath.Ext(filename))
|
||||
@ -20,15 +25,63 @@ func (a *App) SaveFile(reader io.Reader, teamID, rootID, filename string) (strin
|
||||
fileExtension = ".jpg"
|
||||
}
|
||||
|
||||
createdFilename := fmt.Sprintf(`%s%s`, utils.NewID(utils.IDTypeNone), fileExtension)
|
||||
filePath := filepath.Join(teamID, rootID, createdFilename)
|
||||
createdFilename := utils.NewID(utils.IDTypeNone)
|
||||
fullFilename := fmt.Sprintf(`%s%s`, createdFilename, fileExtension)
|
||||
filePath := filepath.Join(teamID, rootID, fullFilename)
|
||||
|
||||
_, appErr := a.filesBackend.WriteFile(reader, filePath)
|
||||
fileSize, appErr := a.filesBackend.WriteFile(reader, filePath)
|
||||
if appErr != nil {
|
||||
return "", fmt.Errorf("unable to store the file in the files storage: %w", appErr)
|
||||
}
|
||||
|
||||
return createdFilename, nil
|
||||
now := utils.GetMillis()
|
||||
|
||||
fileInfo := &mmModel.FileInfo{
|
||||
Id: createdFilename[1:],
|
||||
CreatorId: "boards",
|
||||
PostId: emptyString,
|
||||
ChannelId: emptyString,
|
||||
CreateAt: now,
|
||||
UpdateAt: now,
|
||||
DeleteAt: 0,
|
||||
Path: emptyString,
|
||||
ThumbnailPath: emptyString,
|
||||
PreviewPath: emptyString,
|
||||
Name: filename,
|
||||
Extension: fileExtension,
|
||||
Size: fileSize,
|
||||
MimeType: emptyString,
|
||||
Width: 0,
|
||||
Height: 0,
|
||||
HasPreviewImage: false,
|
||||
MiniPreview: nil,
|
||||
Content: "",
|
||||
RemoteId: nil,
|
||||
}
|
||||
err := a.store.SaveFileInfo(fileInfo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fullFilename, nil
|
||||
}
|
||||
|
||||
func (a *App) GetFileInfo(filename string) (*mmModel.FileInfo, error) {
|
||||
if len(filename) == 0 {
|
||||
return nil, errEmptyFilename
|
||||
}
|
||||
|
||||
// filename is in the format 7<some-alphanumeric-string>.<extension>
|
||||
// we want to extract the <some-alphanumeric-string> part of this as this
|
||||
// will be the fileinfo id.
|
||||
parts := strings.Split(filename, ".")
|
||||
fileInfoID := parts[0][1:]
|
||||
fileInfo, err := a.store.GetFileInfo(fileInfoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
func (a *App) GetFileReader(teamID, rootID, filename string) (filestore.ReadCloseSeeker, error) {
|
||||
|
@ -1,14 +1,17 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/filestore"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/filestore/mocks"
|
||||
@ -19,6 +22,8 @@ const (
|
||||
testBoardID = "test-board-id"
|
||||
)
|
||||
|
||||
var errDummy = errors.New("hello")
|
||||
|
||||
type TestError struct{}
|
||||
|
||||
func (err *TestError) Error() string { return "Mocked File backend error" }
|
||||
@ -186,6 +191,7 @@ func TestSaveFile(t *testing.T) {
|
||||
fileName := "temp-file-name.txt"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
|
||||
|
||||
writeFileFunc := func(reader io.Reader, path string) int64 {
|
||||
paths := strings.Split(path, string(os.PathSeparator))
|
||||
@ -209,6 +215,7 @@ func TestSaveFile(t *testing.T) {
|
||||
fileName := "temp-file-name.jpeg"
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
|
||||
|
||||
writeFileFunc := func(reader io.Reader, path string) int64 {
|
||||
paths := strings.Split(path, string(os.PathSeparator))
|
||||
@ -233,6 +240,7 @@ func TestSaveFile(t *testing.T) {
|
||||
mockedFileBackend := &mocks.FileBackend{}
|
||||
th.App.filesBackend = mockedFileBackend
|
||||
mockedError := &TestError{}
|
||||
th.Store.EXPECT().SaveFileInfo(gomock.Any()).Return(nil)
|
||||
|
||||
writeFileFunc := func(reader io.Reader, path string) int64 {
|
||||
paths := strings.Split(path, string(os.PathSeparator))
|
||||
@ -252,3 +260,48 @@ func TestSaveFile(t *testing.T) {
|
||||
assert.Equal(t, "unable to store the file in the files storage: Mocked File backend error", err.Error())
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetFileInfo(t *testing.T) {
|
||||
th, _ := SetupTestHelper(t)
|
||||
|
||||
t.Run("should return file info", func(t *testing.T) {
|
||||
fileInfo := &mmModel.FileInfo{
|
||||
Id: "file_info_id",
|
||||
Archived: false,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetFileInfo("filename").Return(fileInfo, nil).Times(2)
|
||||
|
||||
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
|
||||
assert.False(t, fetchedFileInfo.Archived)
|
||||
|
||||
fetchedFileInfo, err = th.App.GetFileInfo("Afilename.txt")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
|
||||
assert.False(t, fetchedFileInfo.Archived)
|
||||
})
|
||||
|
||||
t.Run("should return archived file info", func(t *testing.T) {
|
||||
fileInfo := &mmModel.FileInfo{
|
||||
Id: "file_info_id",
|
||||
Archived: true,
|
||||
}
|
||||
|
||||
th.Store.EXPECT().GetFileInfo("filename").Return(fileInfo, nil)
|
||||
|
||||
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file_info_id", fetchedFileInfo.Id)
|
||||
assert.True(t, fetchedFileInfo.Archived)
|
||||
})
|
||||
|
||||
t.Run("should return archived file infoerror", func(t *testing.T) {
|
||||
th.Store.EXPECT().GetFileInfo("filename").Return(nil, errDummy)
|
||||
|
||||
fetchedFileInfo, err := th.App.GetFileInfo("Afilename")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, fetchedFileInfo)
|
||||
})
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package mattermostauthlayer
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/plugin"
|
||||
@ -383,6 +384,84 @@ func mmUserToFbUser(mmUser *mmModel.User) model.User {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetFileInfo(id string) (*mmModel.FileInfo, error) {
|
||||
fileInfo, appErr := s.pluginAPI.GetFileInfo(id)
|
||||
if appErr != nil {
|
||||
// Not finding fileinfo is fine because we don't have data for
|
||||
// any existing files already uploaded in Boards before this code
|
||||
// was deployed.
|
||||
if appErr.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
s.logger.Error("error fetching fileinfo", mlog.String("id", id), mlog.Err(appErr))
|
||||
return nil, appErr
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) SaveFileInfo(fileInfo *mmModel.FileInfo) error {
|
||||
query := s.getQueryBuilder().
|
||||
Insert("FileInfo").
|
||||
Columns(
|
||||
"Id",
|
||||
"CreatorId",
|
||||
"PostId",
|
||||
"CreateAt",
|
||||
"UpdateAt",
|
||||
"DeleteAt",
|
||||
"Path",
|
||||
"ThumbnailPath",
|
||||
"PreviewPath",
|
||||
"Name",
|
||||
"Extension",
|
||||
"Size",
|
||||
"MimeType",
|
||||
"Width",
|
||||
"Height",
|
||||
"HasPreviewImage",
|
||||
"MiniPreview",
|
||||
"Content",
|
||||
"RemoteId",
|
||||
"Archived",
|
||||
).
|
||||
Values(
|
||||
fileInfo.Id,
|
||||
fileInfo.CreatorId,
|
||||
fileInfo.PostId,
|
||||
fileInfo.CreateAt,
|
||||
fileInfo.UpdateAt,
|
||||
fileInfo.DeleteAt,
|
||||
fileInfo.Path,
|
||||
fileInfo.ThumbnailPath,
|
||||
fileInfo.PreviewPath,
|
||||
fileInfo.Name,
|
||||
fileInfo.Extension,
|
||||
fileInfo.Size,
|
||||
fileInfo.MimeType,
|
||||
fileInfo.Width,
|
||||
fileInfo.Height,
|
||||
fileInfo.HasPreviewImage,
|
||||
fileInfo.MiniPreview,
|
||||
fileInfo.Content,
|
||||
fileInfo.RemoteId,
|
||||
false,
|
||||
)
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error(
|
||||
"failed to save fileinfo",
|
||||
mlog.String("file_name", fileInfo.Name),
|
||||
mlog.Int64("size", fileInfo.Size),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MattermostAuthLayer) GetLicense() *mmModel.License {
|
||||
return s.pluginAPI.GetLicense()
|
||||
}
|
||||
|
@ -581,6 +581,21 @@ func (mr *MockStoreMockRecorder) GetCategory(arg0 interface{}) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCategory", reflect.TypeOf((*MockStore)(nil).GetCategory), arg0)
|
||||
}
|
||||
|
||||
// GetFileInfo mocks base method.
|
||||
func (m *MockStore) GetFileInfo(arg0 string) (*model0.FileInfo, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetFileInfo", arg0)
|
||||
ret0, _ := ret[0].(*model0.FileInfo)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetFileInfo indicates an expected call of GetFileInfo.
|
||||
func (mr *MockStoreMockRecorder) GetFileInfo(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFileInfo", reflect.TypeOf((*MockStore)(nil).GetFileInfo), arg0)
|
||||
}
|
||||
|
||||
// GetLicense mocks base method.
|
||||
func (m *MockStore) GetLicense() *model0.License {
|
||||
m.ctrl.T.Helper()
|
||||
@ -1129,6 +1144,20 @@ func (mr *MockStoreMockRecorder) RunDataRetention(arg0, arg1 interface{}) *gomoc
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RunDataRetention", reflect.TypeOf((*MockStore)(nil).RunDataRetention), arg0, arg1)
|
||||
}
|
||||
|
||||
// SaveFileInfo mocks base method.
|
||||
func (m *MockStore) SaveFileInfo(arg0 *model0.FileInfo) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SaveFileInfo", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SaveFileInfo indicates an expected call of SaveFileInfo.
|
||||
func (mr *MockStoreMockRecorder) SaveFileInfo(arg0 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SaveFileInfo", reflect.TypeOf((*MockStore)(nil).SaveFileInfo), arg0)
|
||||
}
|
||||
|
||||
// SaveMember mocks base method.
|
||||
func (m *MockStore) SaveMember(arg0 *model.BoardMember) (*model.BoardMember, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
85
server/services/store/sqlstore/file.go
Normal file
85
server/services/store/sqlstore/file.go
Normal file
@ -0,0 +1,85 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/mattermost/mattermost-server/v6/shared/mlog"
|
||||
)
|
||||
|
||||
func (s *SQLStore) saveFileInfo(db sq.BaseRunner, fileInfo *model.FileInfo) error {
|
||||
query := s.getQueryBuilder(db).
|
||||
Insert(s.tablePrefix+"file_info").
|
||||
Columns(
|
||||
"id",
|
||||
"create_at",
|
||||
"name",
|
||||
"extension",
|
||||
"size",
|
||||
"delete_at",
|
||||
"archived",
|
||||
).
|
||||
Values(
|
||||
fileInfo.Id,
|
||||
fileInfo.CreateAt,
|
||||
fileInfo.Name,
|
||||
fileInfo.Extension,
|
||||
fileInfo.Size,
|
||||
fileInfo.DeleteAt,
|
||||
false,
|
||||
)
|
||||
|
||||
if _, err := query.Exec(); err != nil {
|
||||
s.logger.Error(
|
||||
"failed to save fileinfo",
|
||||
mlog.String("file_name", fileInfo.Name),
|
||||
mlog.Int64("size", fileInfo.Size),
|
||||
mlog.Err(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) getFileInfo(db sq.BaseRunner, id string) (*model.FileInfo, error) {
|
||||
query := s.getQueryBuilder(db).
|
||||
Select(
|
||||
"id",
|
||||
"create_at",
|
||||
"delete_at",
|
||||
"name",
|
||||
"extension",
|
||||
"size",
|
||||
"archived",
|
||||
).
|
||||
From(s.tablePrefix + "file_info").
|
||||
Where(sq.Eq{"Id": id})
|
||||
|
||||
row := query.QueryRow()
|
||||
|
||||
fileInfo := model.FileInfo{}
|
||||
|
||||
err := row.Scan(
|
||||
&fileInfo.Id,
|
||||
&fileInfo.CreateAt,
|
||||
&fileInfo.DeleteAt,
|
||||
&fileInfo.Name,
|
||||
&fileInfo.Extension,
|
||||
&fileInfo.Size,
|
||||
&fileInfo.Archived,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
s.logger.Error("error scanning fileinfo row", mlog.String("id", id), mlog.Err(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &fileInfo, nil
|
||||
}
|
@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS {{.prefix}}file_info;
|
@ -0,0 +1,9 @@
|
||||
CREATE TABLE {{.prefix}}file_info (
|
||||
id varchar(26) NOT NULL,
|
||||
create_at BIGINT NOT NULL,
|
||||
delete_at BIGINT,
|
||||
name TEXT NOT NULL,
|
||||
extension VARCHAR(50) NOT NULL,
|
||||
size BIGINT NOT NULL,
|
||||
archived BOOLEAN
|
||||
) {{if .mysql}}DEFAULT CHARACTER SET utf8mb4{{end}};
|
@ -354,6 +354,11 @@ func (s *SQLStore) GetCategory(id string) (*model.Category, error) {
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetFileInfo(id string) (*mmModel.FileInfo, error) {
|
||||
return s.getFileInfo(s.db, id)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetLicense() *mmModel.License {
|
||||
return s.getLicense(s.db)
|
||||
|
||||
@ -691,6 +696,11 @@ func (s *SQLStore) RunDataRetention(globalRetentionDate int64, batchSize int64)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SaveFileInfo(fileInfo *mmModel.FileInfo) error {
|
||||
return s.saveFileInfo(s.db, fileInfo)
|
||||
|
||||
}
|
||||
|
||||
func (s *SQLStore) SaveMember(bm *model.BoardMember) (*model.BoardMember, error) {
|
||||
return s.saveMember(s.db, bm)
|
||||
|
||||
|
@ -21,4 +21,5 @@ func TestSQLStore(t *testing.T) {
|
||||
t.Run("SubscriptionStore", func(t *testing.T) { storetests.StoreTestSubscriptionsStore(t, SetupTests) })
|
||||
t.Run("NotificationHintStore", func(t *testing.T) { storetests.StoreTestNotificationHintsStore(t, SetupTests) })
|
||||
t.Run("DataRetention", func(t *testing.T) { storetests.StoreTestDataRetention(t, SetupTests) })
|
||||
t.Run("StoreTestFileStore", func(t *testing.T) { storetests.StoreTestFileStore(t, SetupTests) })
|
||||
}
|
||||
|
@ -114,6 +114,9 @@ type Store interface {
|
||||
|
||||
GetUserCategoryBoards(userID, teamID string) ([]model.CategoryBoards, error)
|
||||
|
||||
GetFileInfo(id string) (*mmModel.FileInfo, error)
|
||||
SaveFileInfo(fileInfo *mmModel.FileInfo) error
|
||||
|
||||
// @withTransaction
|
||||
AddUpdateCategoryBoard(userID, categoryID, blockID string) error
|
||||
|
||||
|
38
server/services/store/storetests/files.go
Normal file
38
server/services/store/storetests/files.go
Normal file
@ -0,0 +1,38 @@
|
||||
package storetests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mattermost/focalboard/server/services/store"
|
||||
"github.com/mattermost/focalboard/server/utils"
|
||||
mmModel "github.com/mattermost/mattermost-server/v6/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func StoreTestFileStore(t *testing.T, setup func(t *testing.T) (store.Store, func())) {
|
||||
sqlStore, tearDown := setup(t)
|
||||
defer tearDown()
|
||||
|
||||
t.Run("should save and retrieve fileinfo", func(t *testing.T) {
|
||||
fileInfo := &mmModel.FileInfo{
|
||||
Id: "file_info_1",
|
||||
CreateAt: utils.GetMillis(),
|
||||
Name: "Dunder Mifflin Sales Report 2022",
|
||||
Extension: ".sales",
|
||||
Size: 112233,
|
||||
DeleteAt: 0,
|
||||
}
|
||||
|
||||
err := sqlStore.SaveFileInfo(fileInfo)
|
||||
assert.NoError(t, err)
|
||||
|
||||
retrievedFileInfo, err := sqlStore.GetFileInfo("file_info_1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "file_info_1", retrievedFileInfo.Id)
|
||||
assert.Equal(t, "Dunder Mifflin Sales Report 2022", retrievedFileInfo.Name)
|
||||
assert.Equal(t, ".sales", retrievedFileInfo.Extension)
|
||||
assert.Equal(t, int64(112233), retrievedFileInfo.Size)
|
||||
assert.Equal(t, int64(0), retrievedFileInfo.DeleteAt)
|
||||
assert.False(t, retrievedFileInfo.Archived)
|
||||
})
|
||||
}
|
3914
webapp/package-lock.json
generated
3914
webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -42,6 +42,14 @@ interface Block {
|
||||
deleteAt: number
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
url?: string
|
||||
archived?: boolean
|
||||
extension?: string
|
||||
name?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
function createBlock(block?: Block): Block {
|
||||
const now = Date.now()
|
||||
return {
|
||||
@ -106,5 +114,5 @@ function createPatchesFromBlocks(newBlock: Block, oldBlock: Block): BlockPatch[]
|
||||
]
|
||||
}
|
||||
|
||||
export type {ContentBlockTypes, BlockTypes}
|
||||
export type {ContentBlockTypes, BlockTypes, FileInfo}
|
||||
export {blockTypes, contentBlockTypes, Block, BlockPatch, createBlock, createPatchesFromBlocks}
|
||||
|
@ -116,10 +116,8 @@ exports[`components/calculations/Calculation should match snapshot - option chan
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-2-listbox"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-2-listbox"
|
||||
aria-readonly="true"
|
||||
class="css-mohuvp-dummyInput-DummyInput"
|
||||
id="react-select-2-input"
|
||||
|
@ -28,10 +28,8 @@ exports[`components/calculations/Options should match snapshot 1`] = `
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-2-listbox"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-2-listbox"
|
||||
aria-readonly="true"
|
||||
class="css-mohuvp-dummyInput-DummyInput"
|
||||
id="react-select-2-input"
|
||||
|
@ -1,5 +1,31 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/content/ImageElement archived file 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ArchivedFile"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-file-image-broken-outline BrokenFile"
|
||||
/>
|
||||
<div
|
||||
class="fileMetadata"
|
||||
>
|
||||
<p
|
||||
class="filename"
|
||||
>
|
||||
Filename
|
||||
</p>
|
||||
<p>
|
||||
TXT
|
||||
|
||||
161.1 KiB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`components/content/ImageElement should match snapshot 1`] = `
|
||||
<div>
|
||||
<img
|
||||
|
@ -0,0 +1,27 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/content/archivedFile should match snapshot 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="ArchivedFile"
|
||||
>
|
||||
<i
|
||||
class="CompassIcon icon-file-image-broken-outline BrokenFile"
|
||||
/>
|
||||
<div
|
||||
class="fileMetadata"
|
||||
>
|
||||
<p
|
||||
class="filename"
|
||||
>
|
||||
stuff to put in jell-o
|
||||
</p>
|
||||
<p>
|
||||
TXT
|
||||
|
||||
2.0 KiB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
27
webapp/src/components/content/archivedFile/archivedFile.scss
Normal file
27
webapp/src/components/content/archivedFile/archivedFile.scss
Normal file
@ -0,0 +1,27 @@
|
||||
.ArchivedFile {
|
||||
border: 2px solid rgba(var(--center-channel-color-rgb), 0.1);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 3px rgba(var(--center-channel-color-rgb), 0.1);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.4);
|
||||
|
||||
|
||||
.CompassIcon.icon-file-image-broken-outline.BrokenFile {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.fileMetadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fileMetadata > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import {render} from '@testing-library/react'
|
||||
|
||||
import {FileInfo} from "../../../blocks/block"
|
||||
|
||||
import ArchivedFile from "./archivedFile"
|
||||
|
||||
describe('components/content/archivedFile', () => {
|
||||
it('should match snapshot', () => {
|
||||
const fileInfo: FileInfo = {
|
||||
archived: true,
|
||||
extension: '.txt',
|
||||
name: 'stuff to put in jell-o',
|
||||
size: 2056,
|
||||
}
|
||||
|
||||
const component = (<ArchivedFile fileInfo={fileInfo}/>)
|
||||
const {container} = render(component)
|
||||
expect(container).toMatchSnapshot()
|
||||
})
|
||||
})
|
37
webapp/src/components/content/archivedFile/archivedFile.tsx
Normal file
37
webapp/src/components/content/archivedFile/archivedFile.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {useCallback} from 'react'
|
||||
|
||||
import {FileInfo} from '../../../blocks/block'
|
||||
import BrokenFile from '../../../widgets/icons/brokenFile'
|
||||
import {Utils} from '../../../utils'
|
||||
|
||||
import './archivedFile.scss'
|
||||
|
||||
type Props = {
|
||||
fileInfo: FileInfo
|
||||
}
|
||||
|
||||
const ArchivedFile = (props: Props): JSX.Element => {
|
||||
const fileName = useCallback(() => props.fileInfo.name || 'untitled file', [props.fileInfo.name])
|
||||
|
||||
const fileExtension = useCallback(() => {
|
||||
let extension = props.fileInfo.extension
|
||||
extension = extension?.startsWith('.') ? extension?.substring(1) : extension
|
||||
return extension?.toUpperCase()
|
||||
}, [props.fileInfo.extension])
|
||||
|
||||
const fileSize = useCallback(() => Utils.humanFileSize(props.fileInfo.size || 0), [props.fileInfo.size])
|
||||
|
||||
return (
|
||||
<div className='ArchivedFile'>
|
||||
<BrokenFile/>
|
||||
<div className='fileMetadata'>
|
||||
<p className='filename'>{fileName()}</p>
|
||||
<p>{fileExtension()} {fileSize()}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArchivedFile
|
@ -18,7 +18,7 @@ import ImageElement from './imageElement'
|
||||
|
||||
jest.mock('../../octoClient')
|
||||
const mockedOcto = mocked(octoClient, true)
|
||||
mockedOcto.getFileAsDataUrl.mockResolvedValue('test.jpg')
|
||||
mockedOcto.getFileAsDataUrl.mockResolvedValue({url: 'test.jpg'})
|
||||
|
||||
describe('components/content/ImageElement', () => {
|
||||
const defaultBlock: ImageBlock = {
|
||||
@ -51,4 +51,25 @@ describe('components/content/ImageElement', () => {
|
||||
})
|
||||
expect(imageContainer).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('archived file', async () => {
|
||||
mockedOcto.getFileAsDataUrl.mockResolvedValue({
|
||||
archived: true,
|
||||
name: 'Filename',
|
||||
extension: '.txt',
|
||||
size: 165002,
|
||||
})
|
||||
|
||||
const component = wrapIntl(
|
||||
<ImageElement
|
||||
block={defaultBlock}
|
||||
/>,
|
||||
)
|
||||
let imageContainer: Element | undefined
|
||||
await act(async () => {
|
||||
const {container} = render(component)
|
||||
imageContainer = container
|
||||
})
|
||||
expect(imageContainer).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
@ -10,7 +10,10 @@ import {Utils} from '../../utils'
|
||||
import ImageIcon from '../../widgets/icons/image'
|
||||
import {sendFlashMessage} from '../../components/flashMessages'
|
||||
|
||||
import {FileInfo} from '../../blocks/block'
|
||||
|
||||
import {contentRegistry} from './contentRegistry'
|
||||
import ArchivedFile from './archivedFile/archivedFile'
|
||||
|
||||
type Props = {
|
||||
block: ContentBlock
|
||||
@ -18,18 +21,26 @@ type Props = {
|
||||
|
||||
const ImageElement = (props: Props): JSX.Element|null => {
|
||||
const [imageDataUrl, setImageDataUrl] = useState<string|null>(null)
|
||||
const [fileInfo, setFileInfo] = useState<FileInfo>({})
|
||||
|
||||
const {block} = props
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageDataUrl) {
|
||||
const loadImage = async () => {
|
||||
const url = await octoClient.getFileAsDataUrl(block.boardId, props.block.fields.fileId)
|
||||
setImageDataUrl(url)
|
||||
const fileURL = await octoClient.getFileAsDataUrl(block.boardId, props.block.fields.fileId)
|
||||
setImageDataUrl(fileURL.url || '')
|
||||
setFileInfo(fileURL)
|
||||
}
|
||||
loadImage()
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (fileInfo.archived) {
|
||||
return (
|
||||
<ArchivedFile fileInfo={fileInfo}/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!imageDataUrl) {
|
||||
return null
|
||||
|
@ -34,7 +34,7 @@ describe('components/contentBlock', () => {
|
||||
const mockedOcto = mocked(octoClient, true)
|
||||
|
||||
mockedUtils.createGuid.mockReturnValue('test-id')
|
||||
mockedOcto.getFileAsDataUrl.mockResolvedValue('test.jpg')
|
||||
mockedOcto.getFileAsDataUrl.mockResolvedValue({url: 'test.jpg'})
|
||||
|
||||
const board = TestBlockFactory.createBoard()
|
||||
board.cardProperties = []
|
||||
|
@ -32,7 +32,7 @@ describe('src/components/gallery/GalleryCard', () => {
|
||||
const mockedMutator = mocked(mutator, true)
|
||||
const mockedUtils = mocked(Utils, true)
|
||||
const mockedOcto = mocked(octoClient, true)
|
||||
mockedOcto.getFileAsDataUrl.mockResolvedValue('test.jpg')
|
||||
mockedOcto.getFileAsDataUrl.mockResolvedValue({url: 'test.jpg'})
|
||||
|
||||
const board = TestBlockFactory.createBoard()
|
||||
board.id = 'boardId'
|
||||
|
@ -28,10 +28,8 @@ exports[`components/kanban/calculations/KanbanCalculationOptions base case 1`] =
|
||||
</div>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-2-listbox"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-2-listbox"
|
||||
aria-readonly="true"
|
||||
class="css-mohuvp-dummyInput-DummyInput"
|
||||
id="react-select-2-input"
|
||||
|
@ -36,10 +36,8 @@ exports[`components/properties/user not readonly 1`] = `
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-3-listbox"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-3-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -133,11 +131,9 @@ exports[`components/properties/user not readonly not existing user 1`] = `
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-2-listbox"
|
||||
aria-describedby="react-select-2-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-2-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
|
@ -79,11 +79,9 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-4-listbox"
|
||||
aria-describedby="react-select-4-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-4-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -305,11 +303,9 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Copy l
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-4-listbox"
|
||||
aria-describedby="react-select-4-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-4-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -531,11 +527,9 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Regene
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-5-listbox"
|
||||
aria-describedby="react-select-5-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-5-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -780,11 +774,9 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-10-listbox"
|
||||
aria-describedby="react-select-10-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-10-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -1302,11 +1294,9 @@ exports[`src/components/shareBoard/shareBoard return shareBoard and click Select
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-11-listbox"
|
||||
aria-describedby="react-select-11-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-11-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -1824,11 +1814,9 @@ exports[`src/components/shareBoard/shareBoard return shareBoard, and click switc
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-6-listbox"
|
||||
aria-describedby="react-select-6-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-6-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -2073,11 +2061,9 @@ exports[`src/components/shareBoard/shareBoard return shareBoardComponent and cli
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-7-listbox"
|
||||
aria-describedby="react-select-7-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-7-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -2322,11 +2308,9 @@ exports[`src/components/shareBoard/shareBoard should match snapshot 1`] = `
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-2-listbox"
|
||||
aria-describedby="react-select-2-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-2-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -2548,11 +2532,9 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-3-listbox"
|
||||
aria-describedby="react-select-3-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-3-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -2774,11 +2756,9 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-9-listbox"
|
||||
aria-describedby="react-select-9-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-9-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
@ -3000,11 +2980,9 @@ exports[`src/components/shareBoard/shareBoard should match snapshot with sharing
|
||||
>
|
||||
<input
|
||||
aria-autocomplete="list"
|
||||
aria-controls="react-select-8-listbox"
|
||||
aria-describedby="react-select-8-placeholder"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-owns="react-select-8-listbox"
|
||||
autocapitalize="none"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import {Block, BlockPatch} from './blocks/block'
|
||||
import {Block, BlockPatch, FileInfo} from './blocks/block'
|
||||
import {Board, BoardsAndBlocks, BoardsAndBlocksPatch, BoardPatch, BoardMember} from './blocks/board'
|
||||
import {ISharing} from './blocks/sharing'
|
||||
import {OctoUtils} from './octoUtils'
|
||||
@ -556,18 +556,23 @@ class OctoClient {
|
||||
return undefined
|
||||
}
|
||||
|
||||
async getFileAsDataUrl(boardId: string, fileId: string): Promise<string> {
|
||||
async getFileAsDataUrl(boardId: string, fileId: string): Promise<FileInfo> {
|
||||
let path = '/api/v2/files/teams/' + this.teamId + '/' + boardId + '/' + fileId
|
||||
const readToken = Utils.getReadToken()
|
||||
if (readToken) {
|
||||
path += `?read_token=${readToken}`
|
||||
}
|
||||
const response = await fetch(this.getBaseURL() + path, {headers: this.headers()})
|
||||
if (response.status !== 200) {
|
||||
return ''
|
||||
let fileInfo: FileInfo = {}
|
||||
|
||||
if (response.status === 200) {
|
||||
const blob = await response.blob()
|
||||
fileInfo.url = URL.createObjectURL(blob)
|
||||
} else if (response.status === 400) {
|
||||
fileInfo = await this.getJson(response, {}) as FileInfo
|
||||
}
|
||||
const blob = await response.blob()
|
||||
return URL.createObjectURL(blob)
|
||||
|
||||
return fileInfo
|
||||
}
|
||||
|
||||
async getTeam(): Promise<Team | null> {
|
||||
|
@ -723,6 +723,26 @@ class Utils {
|
||||
const newPath = generatePath(match.path, params)
|
||||
history.push(newPath)
|
||||
}
|
||||
|
||||
static humanFileSize(bytesParam: number, si = false, dp = 1): string {
|
||||
let bytes = bytesParam
|
||||
const thresh = si ? 1000 : 1024
|
||||
|
||||
if (Math.abs(bytes) < thresh) {
|
||||
return bytes + ' B'
|
||||
}
|
||||
|
||||
const units = si ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
|
||||
let u = -1
|
||||
const r = 10 ** dp
|
||||
|
||||
do {
|
||||
bytes /= thresh
|
||||
++u
|
||||
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1)
|
||||
|
||||
return bytes.toFixed(dp) + ' ' + units[u]
|
||||
}
|
||||
}
|
||||
|
||||
export {Utils, IDType}
|
||||
|
15
webapp/src/widgets/icons/brokenFile.tsx
Normal file
15
webapp/src/widgets/icons/brokenFile.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import CompassIcon from './compassIcon'
|
||||
|
||||
export default function BrokenFile(): JSX.Element {
|
||||
return (
|
||||
<CompassIcon
|
||||
icon='file-image-broken-outline'
|
||||
className='BrokenFile'
|
||||
/>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user