Skip to content
Closed
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
2 changes: 1 addition & 1 deletion webapp/src/components/cardDetail/attachment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {Permission} from '../../constants'

type Props = {
attachments: AttachmentBlock[]
onDelete: (block: Block) => void
onDelete: (block: Block) => void | Promise<void>
addAttachment: () => void
}

Expand Down
2 changes: 1 addition & 1 deletion webapp/src/components/cardDetail/cardDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ type Props = {
contents: Array<ContentBlock|ContentBlock[]>
readonly: boolean
onClose: () => void
onDelete: (block: Block) => void
onDelete: (block: Block) => void | Promise<void>
addAttachment: () => void
}

Expand Down
11 changes: 8 additions & 3 deletions webapp/src/components/confirmationDialogBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type ConfirmationDialogBoxProps = {
subText?: string | ReactNode
confirmButtonText?: string
destructive?: boolean
onConfirm: () => void
onConfirm: () => void | Promise<void>
onClose: () => void
}

Expand All @@ -24,8 +24,13 @@ type Props = {
}

export const ConfirmationDialogBox = (props: Props) => {
const handleOnClose = useCallback(props.dialogBox.onClose, [])
const handleOnConfirm = useCallback(props.dialogBox.onConfirm, [])
const {onClose, onConfirm} = props.dialogBox
const handleOnClose = useCallback(() => {
onClose()
}, [onClose])
const handleOnConfirm = useCallback(() => {
onConfirm()
}, [onConfirm])

return (
<Dialog
Expand Down
15 changes: 11 additions & 4 deletions webapp/src/components/content/attachmentElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import Tooltip from './../../widgets/tooltip'

type Props = {
block: AttachmentBlock
onDelete?: (block: Block) => void
onDelete?: (block: Block) => void | Promise<void>
}

const AttachmentElement = (props: Props): JSX.Element|null => {
Expand Down Expand Up @@ -88,9 +88,16 @@ const AttachmentElement = (props: Props): JSX.Element|null => {
}
}, [fileInfo.extension])

const deleteAttachment = () => {
if (onDelete) {
onDelete(block)
const deleteAttachment = async () => {
if (!onDelete) {
setShowConfirmationDialogBox(false)
return
}
try {
await Promise.resolve(onDelete(block))
setShowConfirmationDialogBox(false)
} catch {
// leave dialog open on failure
}
}

Expand Down
1 change: 1 addition & 0 deletions webapp/src/error_boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import React from 'react'

import {Utils} from './utils'
import {Constants} from './constants'

type State = {
hasError: boolean
Expand Down
22 changes: 15 additions & 7 deletions webapp/src/mutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,20 @@ import store from './store'
import {updateBoards} from './store/boards'
import {updateViews} from './store/views'
import {updateCards} from './store/cards'
import {updateAttachments} from './store/attachments'
import {updateAttachments, selectBlocksForAttachmentSliceUpdate} from './store/attachments'
import {updateComments} from './store/comments'
import {updateContents} from './store/contents'
import {addBoardUsers, removeBoardUsersById} from './store/users'

function updateAllBoardsAndBlocks(boards: Board[], blocks: Block[]) {
return batch(() => {
store.dispatch(updateBoards(boards.filter((b: Board) => b.deleteAt !== 0) as Board[]))
store.dispatch(updateViews(blocks.filter((b: Block) => b.type === 'view' || b.deleteAt !== 0) as BoardView[]))
store.dispatch(updateCards(blocks.filter((b: Block) => b.type === 'card' || b.deleteAt !== 0) as Card[]))
store.dispatch(updateAttachments(blocks.filter((b: Block) => b.type === 'attachment' || b.deleteAt !== 0) as AttachmentBlock[]))
store.dispatch(updateComments(blocks.filter((b: Block) => b.type === 'comment' || b.deleteAt !== 0) as CommentBlock[]))
store.dispatch(updateContents(blocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment') as ContentBlock[]))
const attachmentById = store.getState().attachments.attachments
store.dispatch(updateBoards(boards))
store.dispatch(updateViews(blocks.filter((b: Block) => b.type === 'view') as BoardView[]))
store.dispatch(updateCards(blocks.filter((b: Block) => b.type === 'card') as Card[]))
store.dispatch(updateAttachments(selectBlocksForAttachmentSliceUpdate(blocks, attachmentById)))
store.dispatch(updateComments(blocks.filter((b: Block) => b.type === 'comment') as CommentBlock[]))
store.dispatch(updateContents(blocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment' && b.type !== 'attachment') as ContentBlock[]))
})
}

Expand Down Expand Up @@ -174,6 +175,13 @@ class Mutator {
async () => {
await beforeRedo?.()
await octoClient.deleteBlock(block.boardId, block.id)
if (block.type === 'attachment') {
store.dispatch(updateAttachments([{
...block,
type: 'attachment',
deleteAt: block.deleteAt || Date.now(),
} as AttachmentBlock]))
}
},
async () => {
await octoClient.undeleteBlock(block.boardId, block.id)
Expand Down
44 changes: 38 additions & 6 deletions webapp/src/pages/boardPage/boardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {IUser} from '../../user'
import {Block} from '../../blocks/block'
import {ContentBlock} from '../../blocks/contentBlock'
import {CommentBlock} from '../../blocks/commentBlock'
import {AttachmentBlock} from '../../blocks/attachmentBlock'
import {Board, BoardMember} from '../../blocks/board'
import {BoardView} from '../../blocks/boardView'
import {Card} from '../../blocks/card'
Expand All @@ -36,7 +35,8 @@ import {useAppSelector, useAppDispatch} from '../../store/hooks'
import {setTeam} from '../../store/teams'
import {updateCards} from '../../store/cards'
import {updateComments} from '../../store/comments'
import {updateAttachments} from '../../store/attachments'
import store from '../../store'
import {updateAttachments, selectBlocksForAttachmentSliceUpdate} from '../../store/attachments'
import {updateContents} from '../../store/contents'
import {
fetchUserBlockSubscriptions,
Expand Down Expand Up @@ -88,6 +88,37 @@ const BoardPage = (props: Props): JSX.Element => {
const history = useHistory()
const globalError = useAppSelector<string>(getGlobalError)

// Early parameter validation to prevent errors from malformed URLs
// This runs before any data loading or rendering logic
useEffect(() => {
// Validate boardId if present - should be a valid ID format (not contain 'error', 'plugins', etc.)
const boardId = match.params.boardId
if (boardId) {
// Check if boardId looks malformed (contains path segments like 'error', 'plugins', 'boards', etc.)
const malformedPatterns = ['/error', 'plugins/', 'boards/', 'team/']
const isMalformed = malformedPatterns.some(pattern => boardId.includes(pattern))

if (isMalformed) {
Utils.logWarn(`Detected malformed boardId in URL: ${boardId}`)
history.replace('/error?id=unknown')
return
}
}

// Validate viewId if present
const viewIdParam = match.params.viewId
if (viewIdParam) {
const malformedPatterns = ['/error', 'plugins/', 'boards/', 'team/']
const isMalformed = malformedPatterns.some(pattern => viewIdParam.includes(pattern))

if (isMalformed) {
Utils.logWarn(`Detected malformed viewId in URL: ${viewIdParam}`)
history.replace('/error?id=unknown')
return
}
}
}, [match.params.boardId, match.params.viewId, history])

// if we're in a legacy route and not showing a shared board,
// redirect to the new URL schema equivalent
if (Utils.isFocalboardLegacy() && !props.readonly) {
Expand Down Expand Up @@ -124,10 +155,11 @@ const BoardPage = (props: Props): JSX.Element => {
const teamBlocks = blocks

batch(() => {
dispatch(updateViews(teamBlocks.filter((b: Block) => b.type === 'view' || b.deleteAt !== 0) as BoardView[]))
dispatch(updateCards(teamBlocks.filter((b: Block) => b.type === 'card' || b.deleteAt !== 0) as Card[]))
dispatch(updateComments(teamBlocks.filter((b: Block) => b.type === 'comment' || b.deleteAt !== 0) as CommentBlock[]))
dispatch(updateAttachments(teamBlocks.filter((b: Block) => b.type === 'attachment' || b.deleteAt !== 0) as AttachmentBlock[]))
const attachmentById = store.getState().attachments.attachments
dispatch(updateViews(teamBlocks.filter((b: Block) => b.type === 'view') as BoardView[]))
dispatch(updateCards(teamBlocks.filter((b: Block) => b.type === 'card') as Card[]))
dispatch(updateComments(teamBlocks.filter((b: Block) => b.type === 'comment') as CommentBlock[]))
dispatch(updateAttachments(selectBlocksForAttachmentSliceUpdate(teamBlocks, attachmentById)))
dispatch(updateContents(teamBlocks.filter((b: Block) => b.type !== 'card' && b.type !== 'view' && b.type !== 'board' && b.type !== 'comment' && b.type !== 'attachment') as ContentBlock[]))
})
}
Expand Down
2 changes: 1 addition & 1 deletion webapp/src/pages/boardPage/teamToBoardAndViewRedirect.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2020-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {useEffect} from 'react'
import {useEffect, useRef} from 'react'
import {generatePath, useHistory, useRouteMatch} from 'react-router-dom'

import {getBoards, getCurrentBoardId} from '../../store/boards'
Expand Down
7 changes: 7 additions & 0 deletions webapp/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {getFirstTeam, fetchTeams, Team} from './store/teams'
import {getSidebarCategories, CategoryBoards} from './store/sidebar'
import {getMySortedBoards} from './store/boards'
import {UserSettings} from './userSettings'
import {Constants} from './constants'
import FBRoute from './route'

declare let window: IAppWindow
Expand Down Expand Up @@ -92,6 +93,12 @@ function HomeToCurrentTeam(props: {path: string, exact: boolean}) {

const validBoardIds = new Set(myBoards.filter((b) => !b.deleteAt).map((b) => b.id))

// Check if we should skip auto-redirect (e.g., after error page cleared history)
const ignoreStoredUrls = sessionStorage.getItem(Constants.sessionStorageIgnoreStoredUrlsKey) === 'true'
if (ignoreStoredUrls) {
return <Redirect to={`/team/${teamID}`}/>
}

if (UserSettings.lastBoardId) {
const lastBoardID = UserSettings.lastBoardId[teamID]
const lastViewID = UserSettings.lastViewId[lastBoardID]
Expand Down
23 changes: 15 additions & 8 deletions webapp/src/store/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@

import {createSlice, PayloadAction} from '@reduxjs/toolkit'

import {Block} from '../blocks/block'
import {AttachmentBlock} from '../blocks/attachmentBlock'

import {loadBoardData, initialReadOnlyLoad} from './initialLoad'

import {RootState} from './index'

/** Blocks that should be applied to the attachment slice (handles WS tombstones with missing/wrong `type` after hydrate). */
export function selectBlocksForAttachmentSliceUpdate(blocks: readonly Block[], attachmentsById: {[key: string]: AttachmentBlock}): AttachmentBlock[] {
return blocks.filter((b) => b.type === 'attachment' || (b.deleteAt !== 0 && Boolean(attachmentsById[b.id]))) as AttachmentBlock[]
}

type AttachmentsState = {
attachments: {[key: string]: AttachmentBlock}
attachmentsByCard: {[key: string]: AttachmentBlock[]}
Expand All @@ -25,16 +31,14 @@ const attachmentSlice = createSlice({
state.attachments[attachment.id] = attachment
if (!state.attachmentsByCard[attachment.parentId]) {
state.attachmentsByCard[attachment.parentId] = [attachment]
return
}
if (state.attachmentsByCard[attachment.parentId].findIndex((a) => a.id === attachment.id) === -1) {
} else if (state.attachmentsByCard[attachment.parentId].findIndex((a) => a.id === attachment.id) === -1) {
state.attachmentsByCard[attachment.parentId].push(attachment)
}
} else {
const parentId = state.attachments[attachment.id]?.parentId
if (!state.attachmentsByCard[parentId]) {
const parentId = state.attachments[attachment.id]?.parentId || attachment.parentId
if (!parentId || !state.attachmentsByCard[parentId]) {
delete state.attachments[attachment.id]
return
continue
}
for (let i = 0; i < state.attachmentsByCard[parentId].length; i++) {
if (state.attachmentsByCard[parentId][i].id === attachment.id) {
Expand All @@ -46,7 +50,10 @@ const attachmentSlice = createSlice({
}
},
updateUploadPrecent: (state, action: PayloadAction<{blockId: string, uploadPercent: number}>) => {
state.attachments[action.payload.blockId].uploadingPercent = action.payload.uploadPercent
const entry = state.attachments[action.payload.blockId]
if (entry) {
entry.uploadingPercent = action.payload.uploadPercent
}
},
},
extraReducers: (builder) => {
Expand Down Expand Up @@ -88,6 +95,6 @@ export function getCardAttachments(cardId: string): (state: RootState) => Attach

export function getUploadPercent(blockId: string): (state: RootState) => number {
return (state: RootState): number => {
return (state.attachments.attachments[blockId].uploadingPercent)
return state.attachments.attachments[blockId]?.uploadingPercent ?? 0
}
}
Loading