Merge pull request #2664 from jespino/issue-2617

Adding file size limit
This commit is contained in:
Scott Bishel 2022-04-06 10:43:41 -06:00 committed by GitHub
commit dc72e3d3bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 167 additions and 44 deletions

View file

@ -219,6 +219,7 @@ func (p *Plugin) createBoardsConfig(mmconfig mmModel.Config, baseURL string, ser
FilesDriver: *mmconfig.FileSettings.DriverName,
FilesPath: *mmconfig.FileSettings.Directory,
FilesS3Config: filesS3Config,
MaxFileSize: *mmconfig.FileSettings.MaxFileSize,
Telemetry: enableTelemetry,
TelemetryID: serverID,
WebhookUpdate: []string{},

View file

@ -1769,8 +1769,16 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
return
}
if a.app.GetConfig().MaxFileSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, a.app.GetConfig().MaxFileSize)
}
file, handle, err := r.FormFile(UploadFormFileKey)
if err != nil {
if strings.HasSuffix(err.Error(), "http: request body too large") {
a.errorResponse(w, r.URL.Path, http.StatusRequestEntityTooLarge, "", err)
return
}
a.errorResponse(w, r.URL.Path, http.StatusBadRequest, "", err)
return
}

View file

@ -53,6 +53,10 @@ func (a *App) SetConfig(config *config.Configuration) {
a.config = config
}
func (a *App) GetConfig() *config.Configuration {
return a.config
}
func New(config *config.Configuration, wsAdapter ws.Adapter, services Services) *App {
app := &App{
config: config,

View file

@ -384,6 +384,11 @@ func (th *TestHelper) CheckForbidden(r *client.Response) {
require.Error(th.T, r.Error)
}
func (th *TestHelper) CheckRequestEntityTooLarge(r *client.Response) {
require.Equal(th.T, http.StatusRequestEntityTooLarge, r.StatusCode)
require.Error(th.T, r.Error)
}
func (th *TestHelper) CheckNotImplemented(r *client.Response) {
require.Equal(th.T, http.StatusNotImplemented, r.StatusCode)
require.Error(th.T, r.Error)

View file

@ -0,0 +1,71 @@
package integrationtests
import (
"bytes"
"testing"
"github.com/mattermost/focalboard/server/model"
"github.com/stretchr/testify/require"
)
func TestUploadFile(t *testing.T) {
const (
testTeamID = "team-id"
)
t.Run("a non authenticated user should be rejected", func(t *testing.T) {
th := SetupTestHelper(t).InitBasic()
defer th.TearDown()
th.Logout(th.Client)
file, resp := th.Client.TeamUploadFile(testTeamID, "test-board-id", bytes.NewBuffer([]byte("test")))
th.CheckUnauthorized(resp)
require.Nil(t, file)
})
t.Run("upload a file to an existing team and board without permissions", func(t *testing.T) {
th := SetupTestHelper(t).InitBasic()
defer th.TearDown()
file, resp := th.Client.TeamUploadFile(testTeamID, "not-valid-board", bytes.NewBuffer([]byte("test")))
th.CheckForbidden(resp)
require.Nil(t, file)
})
t.Run("upload a file to an existing team and board with permissions", func(t *testing.T) {
th := SetupTestHelper(t).InitBasic()
defer th.TearDown()
testBoard := th.CreateBoard(testTeamID, model.BoardTypeOpen)
file, resp := th.Client.TeamUploadFile(testTeamID, testBoard.ID, bytes.NewBuffer([]byte("test")))
th.CheckOK(resp)
require.NoError(t, resp.Error)
require.NotNil(t, file)
require.NotNil(t, file.FileID)
})
t.Run("upload a file to an existing team and board with permissions but reaching the MaxFileLimit", func(t *testing.T) {
th := SetupTestHelper(t).InitBasic()
defer th.TearDown()
testBoard := th.CreateBoard(testTeamID, model.BoardTypeOpen)
config := th.Server.App().GetConfig()
config.MaxFileSize = 1
th.Server.App().SetConfig(config)
file, resp := th.Client.TeamUploadFile(testTeamID, testBoard.ID, bytes.NewBuffer([]byte("test")))
th.CheckRequestEntityTooLarge(resp)
require.Nil(t, file)
config.MaxFileSize = 100000
th.Server.App().SetConfig(config)
file, resp = th.Client.TeamUploadFile(testTeamID, testBoard.ID, bytes.NewBuffer([]byte("test")))
th.CheckOK(resp)
require.NoError(t, resp.Error)
require.NotNil(t, file)
require.NotNil(t, file.FileID)
})
}

View file

@ -37,6 +37,7 @@ type Configuration struct {
FilesDriver string `json:"filesdriver" mapstructure:"filesdriver"`
FilesS3Config AmazonS3Config `json:"filess3config" mapstructure:"filess3config"`
FilesPath string `json:"filespath" mapstructure:"filespath"`
MaxFileSize int64 `json:"maxfilesize" mapstructure:"mafilesize"`
Telemetry bool `json:"telemetry" mapstructure:"telemetry"`
TelemetryID string `json:"telemetryid" mapstructure:"telemetryid"`
PrometheusAddress string `json:"prometheus_address" mapstructure:"prometheus_address"`

View file

@ -7,6 +7,7 @@ describe('Card badges', () => {
cy.apiResetBoards()
cy.apiGetMe().then((userID) => cy.apiSkipTour(userID))
localStorage.setItem('welcomePageViewed', 'true')
localStorage.setItem('language', 'en')
})
it('Shows and hides card badges', () => {

View file

@ -7,6 +7,7 @@ describe('Card URL Property', () => {
cy.apiResetBoards()
cy.apiGetMe().then((userID) => cy.apiSkipTour(userID))
localStorage.setItem('welcomePageViewed', 'true')
localStorage.setItem('language', 'en')
})
const url = 'https://mattermost.com'

View file

@ -11,6 +11,7 @@ describe('Create and delete board / card', () => {
cy.apiResetBoards()
cy.apiGetMe().then((userID) => cy.apiSkipTour(userID))
localStorage.setItem('welcomePageViewed', 'true')
localStorage.setItem('language', 'en')
})
it('MM-T4274 Create an Empty Board', () => {
@ -29,7 +30,7 @@ describe('Create and delete board / card', () => {
// Create empty board
cy.contains('Create empty board').should('exist').click({force: true})
cy.get('.BoardComponent').should('exist')
cy.get('.Editable.title').invoke('attr', 'placeholder').should('contain', 'Untitled board')
cy.get('.Editable.title').invoke('attr', 'placeholder').should('contain', 'Untitled Board')
// Change Title
cy.get('.Editable.title').

View file

@ -7,6 +7,7 @@ describe('Group board by different properties', () => {
cy.apiResetBoards()
cy.apiGetMe().then((userID) => cy.apiSkipTour(userID))
localStorage.setItem('welcomePageViewed', 'true')
localStorage.setItem('language', 'en')
})
it('MM-T4291 Group by different property', () => {

View file

@ -6,6 +6,10 @@ describe('Login actions', () => {
const email = Cypress.env('email')
const password = Cypress.env('password')
beforeEach(() => {
localStorage.setItem('language', 'en')
})
it('Can perform login/register actions', () => {
// Redirects to login page
cy.log('**Redirects to login page (except plugin mode) **')

View file

@ -7,6 +7,7 @@ describe('Manage groups', () => {
cy.apiResetBoards()
cy.apiGetMe().then((userID) => cy.apiSkipTour(userID))
localStorage.setItem('welcomePageViewed', 'true')
localStorage.setItem('language', 'en')
})
it('MM-T4284 Adding a group', () => {

View file

@ -119,11 +119,11 @@ Cypress.Commands.add('uiCreateNewBoard', (title?: string) => {
cy.log('**Create new empty board**')
cy.uiCreateEmptyBoard()
cy.findByPlaceholderText('Untitled board').should('exist')
cy.findByPlaceholderText('Untitled Board').should('exist')
cy.wait(10)
if (title) {
cy.log('**Rename board**')
cy.findByPlaceholderText('Untitled board').type(`${title}{enter}`)
cy.findByPlaceholderText('Untitled Board').type(`${title}{enter}`)
cy.findByRole('textbox', {name: title}).should('exist')
}
cy.wait(500)

View file

@ -10,6 +10,7 @@
"BoardMember.schemeAdmin": "Admin",
"BoardMember.schemeEditor": "Editor",
"BoardMember.schemeNone": "None",
"BoardMember.schemeViewer": "Viewer",
"BoardPage.newVersion": "A new version of Boards is available, click here to reload.",
"BoardPage.syncFailed": "Board may be deleted or access revoked.",
"BoardTemplateSelector.add-template": "New template",
@ -89,6 +90,7 @@
"Categories.CreateCategoryDialog.CreateText": "Create",
"Categories.CreateCategoryDialog.Placeholder": "Name your category",
"Categories.CreateCategoryDialog.UpdateText": "Update",
"CenterPanel.Login": "Login",
"CenterPanel.Share": "Share",
"ColorOption.selectColor": "Select {color} Color",
"Comment.delete": "Delete",
@ -109,6 +111,10 @@
"ContentBlock.moveDown": "Move down",
"ContentBlock.moveUp": "Move up",
"ContentBlock.text": "text",
"DateRange.clear": "Clear",
"DateRange.empty": "Empty",
"DateRange.endDate": "End date",
"DateRange.today": "Today",
"DeleteBoardDialog.confirm-cancel": "Cancel",
"DeleteBoardDialog.confirm-delete": "Delete",
"DeleteBoardDialog.confirm-info": "Are you sure you want to delete the board “{boardTitle}”? Deleting it will delete all cards in the board.",
@ -133,7 +139,6 @@
"GalleryCard.copyLink": "Copy link",
"GalleryCard.delete": "Delete",
"GalleryCard.duplicate": "Duplicate",
"General.BoardCount": "{count, plural, one {# Board} other {# Boards}}",
"GroupBy.hideEmptyGroups": "Hide {count} empty groups",
"GroupBy.showHiddenGroups": "Show {count} hidden groups",
"GroupBy.ungroup": "Ungroup",
@ -144,6 +149,20 @@
"KanbanCard.untitled": "Untitled",
"Mutator.new-card-from-template": "new card from template",
"Mutator.new-template-from-card": "new template from card",
"OnboardingTour.AddComments.Body": "You can comment on issues, and even @mention your fellow Mattermost users to get their attention.",
"OnboardingTour.AddComments.Title": "Add comments",
"OnboardingTour.AddDescription.Body": "Add a description to your card so your teammates know what the card is about.",
"OnboardingTour.AddDescription.Title": "Add description",
"OnboardingTour.AddProperties.Body": "Add various properties to cards to make them more powerful!",
"OnboardingTour.AddProperties.Title": "Add properties",
"OnboardingTour.AddView.Body": "Go here to create a new view to organise your board using different layouts.",
"OnboardingTour.AddView.Title": "Add a new view",
"OnboardingTour.CopyLink.Body": "You can share your cards with teammates by copying the link and pasting it in a channel, Direct Message, or Group Message.",
"OnboardingTour.CopyLink.Title": "Copy link",
"OnboardingTour.OpenACard.Body": "Open a card to explore the powerful ways that Boards can help you organize your work.",
"OnboardingTour.OpenACard.Title": "Open a card",
"OnboardingTour.ShareBoard.Body": "You can share your board internally, within your team, or publish it publicly for visibility outside of your organization.",
"OnboardingTour.ShareBoard.Title": "Share board",
"PropertyMenu.Delete": "Delete",
"PropertyMenu.changeType": "Change property type",
"PropertyMenu.selectType": "Select property type",
@ -172,6 +191,8 @@
"RegistrationLink.tokenRegenerated": "Registration link regenerated",
"ShareBoard.PublishDescription": "Publish and share a “read only” link with everyone on the web",
"ShareBoard.PublishTitle": "Publish to the web",
"ShareBoard.ShareInternal": "Share internally",
"ShareBoard.ShareInternalDescription": "Users who have permissions will be able to use this link",
"ShareBoard.Title": "Share Board",
"ShareBoard.confirmRegenerateToken": "This will invalidate previously shared links. Continue?",
"ShareBoard.copiedLink": "Copied!",
@ -187,18 +208,20 @@
"Sidebar.changePassword": "Change password",
"Sidebar.delete-board": "Delete board",
"Sidebar.duplicate-board": "Duplicate board",
"Sidebar.template-from-board": "New template from board",
"Sidebar.export-archive": "Export archive",
"Sidebar.import": "Import",
"Sidebar.import-archive": "Import archive",
"Sidebar.invite-users": "Invite users",
"Sidebar.logout": "Log out",
"Sidebar.no-boards-in-category": "No boards inside",
"Sidebar.product-tour": "Product tour",
"Sidebar.random-icons": "Random icons",
"Sidebar.set-language": "Set language",
"Sidebar.set-theme": "Set theme",
"Sidebar.settings": "Settings",
"Sidebar.template-from-board": "New template from board",
"Sidebar.untitled-board": "(Untitled Board)",
"Sidebar.untitled-view": "(Untitled View)",
"SidebarCategories.BlocksMenu.Move": "Move To...",
"SidebarCategories.CategoryMenu.CreateNew": "Create New Category",
"SidebarCategories.CategoryMenu.Delete": "Delete Category",
@ -217,6 +240,9 @@
"TableHeaderMenu.sort-descending": "Sort descending",
"TableRow.open": "Open",
"TopBar.give-feedback": "Give Feedback",
"URLProperty.copiedLink": "Copied!",
"URLProperty.copy": "Copy",
"URLProperty.edit": "Edit",
"ValueSelector.noOptions": "No options. Start typing to add the first one!",
"ValueSelector.valueSelector": "Value selector",
"ValueSelectorLabel.openMenu": "Open menu",
@ -257,49 +283,32 @@
"ViewTitle.random-icon": "Random",
"ViewTitle.remove-icon": "Remove icon",
"ViewTitle.show-description": "show description",
"ViewTitle.untitled-board": "Untitled board",
"ViewTitle.untitled-board": "Untitled Board",
"WelcomePage.Description": "Boards is a project management tool that helps define, organize, track and manage work across teams, using a familiar kanban board view",
"WelcomePage.Explore.Button": "Take a tour",
"WelcomePage.NoThanks.Text": "No thanks, I'll figure it out myself",
"WelcomePage.Heading": "Welcome To Boards",
"WelcomePage.NoThanks.Text": "No thanks, I'll figure it out myself",
"Workspace.editing-board-template": "You're editing a board template.",
"calendar.month": "Month",
"calendar.today": "TODAY",
"calendar.week": "Week",
"createImageBlock.failed": "Unable to upload the file. File size limit reached.",
"default-properties.badges": "Comments and Description",
"default-properties.title": "Title",
"error.relogin": "Log in again",
"error.page.title": "Sorry, something went wrong",
"generic.previous": "Previous",
"imagePaste.upload-failed": "Some files not uploaded. File size limit reached",
"login.log-in-button": "Log in",
"login.log-in-title": "Log in",
"error.workspace-undefined": "Not a valid workspace.",
"error.page.title": "Sorry, something went wrong",
"error.not-logged-in": "Your session may have expired or you're not logged in.",
"error.back-to-home": "Back to Home",
"error.back-to-boards": "Back to boards",
"error.go-login": "Log in",
"error.unknown": "An error occurred.",
"login.register-button": "or create an account if you don't have one",
"register.login-button": "or log in if you already have an account",
"OnboardingTour.OpenACard.Title": "Open a card",
"OnboardingTour.OpenACard.Body": "Open a card to explore the powerful ways that Boards can help you organize your work.",
"OnboardingTour.AddProperties.Title": "Add properties",
"OnboardingTour.AddProperties.Body": "Add various properties to cards to make them more powerful!",
"OnboardingTour.AddComments.Title": "Add comments",
"OnboardingTour.AddComments.Body": "You can comment on issues, and even @mention your fellow Mattermost users to get their attention.",
"OnboardingTour.AddDescription.Title": "Add description",
"OnboardingTour.AddDescription.Body": "Add a description to your card so your teammates know what the card is about.",
"OnboardingTour.AddView.Title": "Add a new view",
"OnboardingTour.AddView.Body": "Go here to create a new view to organise your board using different layouts.",
"OnboardingTour.CopyLink.Title": "Copy link",
"OnboardingTour.CopyLink.Body": "You can share your cards with teammates by copying the link and pasting it in a channel, Direct Message, or Group Message.",
"OnboardingTour.ShareBoard.Title": "Share board",
"OnboardingTour.ShareBoard.Body": "You can share your board internally, within your team, or publish it publicly for visibility outside of your organization.",
"register.signup-title": "Sign up for your account",
"share-board.publish": "Publish",
"share-board.share": "Share",
"shareBoard.lastAdmin": "Boards must have at least one Administrator",
"tutorial_tip.finish_tour": "Done",
"tutorial_tip.got_it": "Got it",
"tutorial_tip.ok": "Next",
"generic.previous": "Previous",
"tutorial_tip.seen": "Seen this before?",
"tutorial_tip.out": "Opt out of these tips",
"register.signup-title": "Sign up for your account",
"shareBoard.lastAdmin": "Boards must have at least one Administrator"
}
"tutorial_tip.out": "Opt out of these tips.",
"tutorial_tip.seen": "Seen this before?"
}

View file

@ -12,7 +12,7 @@
"check": "eslint --ext .tsx,.ts . --quiet --cache && stylelint **/*.scss",
"fix": "eslint --ext .tsx,.ts . --quiet --fix --cache && stylelint --fix **/*.scss",
"fix:scss": "prettier --write './src/**/*.scss'",
"i18n-extract": "formatjs extract ../mattermost-plugin/webapp/src/*/*/*.ts? src/*/*/*.ts? src/*/*.ts? src/*.ts? --out-file i18n/tmp.json; formatjs compile i18n/tmp.json --out-file i18n/en.json; rm i18n/tmp.json",
"i18n-extract": "formatjs extract ../mattermost-plugin/webapp/src/*/*/*.ts? src/*.ts? src/*/*.ts? src/*/*/*.ts? src/*/*/*/*.ts? --out-file i18n/tmp.json; formatjs compile i18n/tmp.json --out-file i18n/en.json; rm i18n/tmp.json",
"runserver-test": "cd cypress && \"../../bin/focalboard-server\"",
"cypress:ci": "start-server-and-test runserver-test http://localhost:8088 cypress:run",
"cypress:run": "cypress run",

View file

@ -37,7 +37,7 @@ const AddContentMenuItem = (props:Props): JSX.Element => {
name={handler.getDisplayText(intl)}
icon={handler.getIcon()}
onClick={async () => {
const newBlock = await handler.createBlock(card.boardId)
const newBlock = await handler.createBlock(card.boardId, intl)
newBlock.parentId = card.id
newBlock.boardId = card.boardId

View file

@ -44,7 +44,7 @@ export const CardDetailProvider = (props: CardDetailProps): ReactElement => {
})
const {card} = props
const addBlock = useCallback(async (handler: ContentHandler, index: number, auto: boolean) => {
const block = await handler.createBlock(card.boardId)
const block = await handler.createBlock(card.boardId, intl)
block.parentId = card.id
block.boardId = card.boardId
const typeName = handler.getDisplayText(intl)

View file

@ -2,12 +2,15 @@
// See LICENSE.txt for license information.
import {useEffect, useCallback} from 'react'
import {useIntl} from 'react-intl'
import {ImageBlock, createImageBlock} from '../../blocks/imageBlock'
import {sendFlashMessage} from '../flashMessages'
import octoClient from '../../octoClient'
import mutator from '../../mutator'
export default function useImagePaste(boardId: string, cardId: string, contentOrder: Array<string | string[]>): void {
const intl = useIntl()
const uploadItems = useCallback(async (items: FileList) => {
let newImage: File|null = null
const uploads: Promise<string|undefined>[] = []
@ -25,8 +28,10 @@ export default function useImagePaste(boardId: string, cardId: string, contentOr
const uploaded = await Promise.all(uploads)
const blocksToInsert: ImageBlock[] = []
let someFilesNotUploaded = false
for (const fileId of uploaded) {
if (!fileId) {
someFilesNotUploaded = true
continue
}
const block = createImageBlock()
@ -36,6 +41,10 @@ export default function useImagePaste(boardId: string, cardId: string, contentOr
blocksToInsert.push(block)
}
if (someFilesNotUploaded) {
sendFlashMessage({content: intl.formatMessage({id: 'imagePaste.upload-failed', defaultMessage: 'Some files not uploaded. File size limit reached'}), severity: 'normal'})
}
mutator.performAsUndoGroup(async () => {
const newContentBlocks = await mutator.insertBlocks(boardId, blocksToInsert, 'pasted images')
const newContentOrder = JSON.parse(JSON.stringify(contentOrder))

View file

@ -11,7 +11,7 @@ export type ContentHandler = {
type: BlockTypes,
getDisplayText: (intl: IntlShape) => string,
getIcon: () => JSX.Element,
createBlock: (boardId: string) => Promise<ContentBlock>,
createBlock: (boardId: string, intl: IntlShape) => Promise<ContentBlock>,
createComponent: (block: ContentBlock, readonly: boolean, onAddElement?: () => void, onDeleteElement?: () => void) => JSX.Element,
}

View file

@ -1,12 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react'
import {IntlShape} from 'react-intl'
import {ContentBlock} from '../../blocks/contentBlock'
import {ImageBlock, createImageBlock} from '../../blocks/imageBlock'
import octoClient from '../../octoClient'
import {Utils} from '../../utils'
import ImageIcon from '../../widgets/icons/image'
import {sendFlashMessage} from '../../components/flashMessages'
import {contentRegistry} from './contentRegistry'
@ -44,17 +46,21 @@ const ImageElement = (props: Props): JSX.Element|null => {
contentRegistry.registerContentType({
type: 'image',
getDisplayText: (intl) => intl.formatMessage({id: 'ContentBlock.image', defaultMessage: 'image'}),
getDisplayText: (intl: IntlShape) => intl.formatMessage({id: 'ContentBlock.image', defaultMessage: 'image'}),
getIcon: () => <ImageIcon/>,
createBlock: async (boardId: string) => {
createBlock: async (boardId: string, intl: IntlShape) => {
return new Promise<ImageBlock>(
(resolve) => {
Utils.selectLocalFile(async (file) => {
const fileId = await octoClient.uploadFile(boardId, file)
const block = createImageBlock()
block.fields.fileId = fileId || ''
resolve(block)
if (fileId) {
const block = createImageBlock()
block.fields.fileId = fileId || ''
resolve(block)
} else {
sendFlashMessage({content: intl.formatMessage({id: 'createImageBlock.failed', defaultMessage: 'Unable to upload the file. File size limit reached.'}), severity: 'normal'})
}
},
'.jpg,.jpeg,.png,.gif')
},