Skip to content
Open
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
8 changes: 8 additions & 0 deletions apps/condo/domains/common/utils/next/apollo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,11 @@ const cacheConfig: InitCacheConfig = (cacheOptions) => {
read: listHelper.getReadFunction('paginate'),
merge: listHelper.mergeLists,
},
allNewsItemFiles: {
keyArgs: ['where'],
read: listHelper.getReadFunction('paginate'),
merge: listHelper.mergeLists,
},
},
},

Expand Down Expand Up @@ -256,6 +261,9 @@ const cacheConfig: InitCacheConfig = (cacheOptions) => {
GetNewsSharingRecipientsOutput: {
timeToLive: 60 * 1000, // 1 minute in milliseconds
},
NewsItemFile: {
timeToLive: 60 * 1000, // 1 minute in milliseconds
},
},
},
}
Expand Down
28 changes: 13 additions & 15 deletions apps/condo/domains/news/access/NewsItemFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,15 @@ async function canReadNewsItemFiles ({ authentication: { item: user }, context }
const permittedOrganizations = await getEmployedOrRelatedOrganizationsByPermissions(context, user, 'canReadNewsItems')

return {
organization: {
id_in: permittedOrganizations,
},
OR:[
{ organization: { id_in: permittedOrganizations } },
{
AND:[
{ organization_is_null: true },
{ createdBy: { id: user.id } },
],
},
],
}
}

Expand All @@ -57,18 +63,9 @@ async function canReadNewsItemFiles ({ authentication: { item: user }, context }
async function canManageNewsItemFiles ({ authentication: { item: user }, originalInput, operation, itemId, itemIds, context }) {
if (!user) return throwAuthenticationError()
if (user.deletedAt) return false
if (user.isAdmin) return true

const isBulkRequest = Array.isArray(originalInput)
const isSoftDeleteOperation = operation === 'update'
&& (
isBulkRequest
? (Array.isArray(itemIds) && originalInput.every(item => isSoftDelete(item?.data)))
: (itemId && isSoftDelete(originalInput))
)

if (operation === 'update' && !isSoftDeleteOperation) return false

if (user.isAdmin) return true

if (user.type === STAFF) {
if (operation === 'create') {
Expand All @@ -81,7 +78,7 @@ async function canManageNewsItemFiles ({ authentication: { item: user }, origina

return await checkPermissionsInEmployedOrRelatedOrganizations(context, user, organizationId, 'canManageNewsItems')
}
return false
return true
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce org permission when creating detached news files

Returning true here lets any authenticated STAFF user create a NewsItemFile without newsItem/organization checks. Because updates on organization-less files are later authorized by createdBy alone in canManageNewsItemFiles, a staff user without canManageNewsItems can create an orphan file and then attach it to a news item ID they know, bypassing organization-level manage permission checks.

Useful? React with 👍 / 👎.

} else if (operation === 'update') {
if (isBulkRequest) {
if (!itemIds || !Array.isArray(itemIds)) return false
Expand All @@ -97,8 +94,9 @@ async function canManageNewsItemFiles ({ authentication: { item: user }, origina
return await checkPermissionsInEmployedOrRelatedOrganizations(context, user, organizationIds, 'canManageNewsItems')
} else {
const newsItemFile = await getById('NewsItemFile', itemId)
const organizationId = newsItemFile?.organization || null
if (!newsItemFile) return false

const organizationId = newsItemFile?.organization || null
if (!organizationId) return newsItemFile?.createdBy === user.id

return await checkPermissionsInEmployedOrRelatedOrganizations(context, user, organizationId, 'canManageNewsItems')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Row, Col } from 'antd'
import React from 'react'

import { Typography } from '@open-condo/ui'


export const DocumentsPreview = ({ files }) => {
return (
<Row gutter={[0, 8]} style={{ width: '100%' }}>
{
files?.map((file, ind) => (
<Col key={file?.id || file?.uid || ind} span={24} style={{ width: '100%' }}>
<div style={{ borderRadius: 12, border: '1px solid #E6E8F1', padding: 12, width: '100%' }}>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get color from ui kit

<Typography.Text>{file?.response?.originalName}</Typography.Text>
</div>
</Col>
))
}
</Row>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import React, { useState } from 'react'

import { useDeepCompareEffect } from '@open-condo/codegen/utils/useDeepCompareEffect'

const getImagePreviewFromUrl = (url: string) => {
return new Promise<string>((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'

img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')

Comment on lines +10 to +13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle potential null canvas context.

getContext('2d') can return null if the context identifier is not supported. Using ctx without a null check can cause runtime errors.

🛡️ Proposed fix to add null checks
 img.onload = () => {
     const canvas = document.createElement('canvas')
     const ctx = canvas.getContext('2d')
+    if (!ctx) {
+        reject(new Error('Canvas 2D context not supported'))
+        return
+    }

     const WIDTH = 1280

Apply similar fix in createVideoPreviewFromUrl:

 const video = document.createElement('video')
 const canvas = document.createElement('canvas')
 const ctx = canvas.getContext('2d')
+if (!ctx) {
+    reject(new Error('Canvas 2D context not supported'))
+    return
+}

Also applies to: 43-46

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/condo/domains/news/components/FilesPreview/ImageOrVideoPreview.tsx`
around lines 10 - 13, The code assumes canvas.getContext('2d') always returns a
CanvasRenderingContext2D (in img.onload and in createVideoPreviewFromUrl) which
can be null; update the img.onload handler and createVideoPreviewFromUrl to
check the result of const ctx = canvas.getContext('2d') and handle the null case
(e.g., abort/return a rejected Promise or throw a descriptive error) before
using ctx, and ensure any downstream code that uses ctx is guarded so no methods
are called on a possibly null value.

const WIDTH = 1280
const HEIGHT = 720

canvas.width = WIDTH
canvas.height = HEIGHT

const iw = img.width
const ih = img.height

const scale = Math.max(WIDTH / iw, HEIGHT / ih)

const drawWidth = iw * scale
const drawHeight = ih * scale

const offsetX = (WIDTH - drawWidth) / 2
const offsetY = (HEIGHT - drawHeight) / 2

ctx.drawImage(img, offsetX, offsetY, drawWidth, drawHeight)

resolve(canvas.toDataURL('image/jpeg', 0.85))
}

img.onerror = reject
img.src = url
})
}

const createVideoPreviewFromUrl = (url: string) => {
return new Promise<string>((resolve, reject) => {
const video = document.createElement('video')
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')

video.preload = 'metadata'
video.src = url
video.crossOrigin = 'anonymous'
video.muted = true
video.playsInline = true

video.onloadedmetadata = () => {
video.currentTime = Math.min(1, video.duration / 2)
}

video.onseeked = () => {
const WIDTH = 1280
const HEIGHT = 720

canvas.width = WIDTH
canvas.height = HEIGHT

const vw = video.videoWidth
const vh = video.videoHeight

const scale = Math.max(WIDTH / vw, HEIGHT / vh)

const drawWidth = vw * scale
const drawHeight = vh * scale

const offsetX = (WIDTH - drawWidth) / 2
const offsetY = (HEIGHT - drawHeight) / 2

ctx.drawImage(video, offsetX, offsetY, drawWidth, drawHeight)

resolve(canvas.toDataURL('image/jpeg', 0.9))
}

video.onerror = reject
})
}


export const ImageOrVideoPreview = ({ file }) => {
const [preview, setPreview] = useState<string>(null)

useDeepCompareEffect(() => {
const process = async () => {
const url = file?.response?.url || file?.url

let thumb = ''
if (file.response?.mimetype?.startsWith('image/')) {
thumb = await getImagePreviewFromUrl(url)
}
if (file.response?.mimetype?.startsWith('video/')) {
thumb = await createVideoPreviewFromUrl(url)
}

setPreview(thumb)
}

process()
}, [file])
Comment on lines +88 to +104
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle errors from async thumbnail generation.

The process() function is async but errors are not caught. If getImagePreviewFromUrl or createVideoPreviewFromUrl rejects, it will cause an unhandled promise rejection.

🛡️ Proposed fix to add error handling
     useDeepCompareEffect(() => {
         const process = async () => {
             const url = file?.response?.url || file?.url
+            if (!url) return

             let thumb = ''
-            if (file.response?.mimetype?.startsWith('image/')) {
-                thumb = await getImagePreviewFromUrl(url)
-            }
-            if (file.response?.mimetype?.startsWith('video/')) {
-                thumb = await createVideoPreviewFromUrl(url)
+            try {
+                if (file.response?.mimetype?.startsWith('image/')) {
+                    thumb = await getImagePreviewFromUrl(url)
+                } else if (file.response?.mimetype?.startsWith('video/')) {
+                    thumb = await createVideoPreviewFromUrl(url)
+                }
+            } catch (error) {
+                console.error('Failed to generate preview:', error)
+                return
             }

             setPreview(thumb)
         }

         process()
     }, [file])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/condo/domains/news/components/FilesPreview/ImageOrVideoPreview.tsx`
around lines 88 - 104, The async process() inside useDeepCompareEffect does not
catch errors from getImagePreviewFromUrl or createVideoPreviewFromUrl; wrap the
body of process() in try/catch (or attach .catch) to handle rejections, log or
otherwise handle the error, and ensure setPreview('') or a fallback is used on
failure; update the process function referenced in useDeepCompareEffect (which
calls getImagePreviewFromUrl/createVideoPreviewFromUrl and setPreview) to
prevent unhandled promise rejections.


return (
<img src={preview} style={{ borderRadius: 12, border: '1px solid #E6E8F1', width: '100%' }} />

Check warning on line 107 in apps/condo/domains/news/components/FilesPreview/ImageOrVideoPreview.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

img elements must have an alt prop, either with meaningful text, or an empty string for decorative images.

See more on https://sonarcloud.io/project/issues?id=open-condo-software_condo&issues=AZ2IMVYv9ReRjOMgddIP&open=AZ2IMVYv9ReRjOMgddIP&pullRequest=7465
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also about color

)
Comment on lines +106 to +108
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add alt attribute for accessibility.

The <img> element is missing an alt attribute, which is required for accessibility. Screen readers and users with images disabled need this information.

🛡️ Proposed fix
-        <img src={preview} style={{ borderRadius: 12, border: '1px solid `#E6E8F1`', width: '100%' }} />
+        <img src={preview} alt="" style={{ borderRadius: 12, border: '1px solid `#E6E8F1`', width: '100%' }} />

Consider passing a meaningful alt text if the file name is available:

-        <img src={preview} style={{ borderRadius: 12, border: '1px solid `#E6E8F1`', width: '100%' }} />
+        <img
+            src={preview}
+            alt={file?.response?.originalName || 'Preview'}
+            style={{ borderRadius: 12, border: '1px solid `#E6E8F1`', width: '100%' }}
+        />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return (
<img src={preview} style={{ borderRadius: 12, border: '1px solid #E6E8F1', width: '100%' }} />
)
return (
<img src={preview} alt="" style={{ borderRadius: 12, border: '1px solid `#E6E8F1`', width: '100%' }} />
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/condo/domains/news/components/FilesPreview/ImageOrVideoPreview.tsx`
around lines 106 - 108, The <img> in ImageOrVideoPreview is missing an alt
attribute; update the JSX in the ImageOrVideoPreview component to include a
meaningful alt value (e.g., use a prop or variable like fileName, file.name, or
other available title) and fall back to an empty string if none is available
(alt={fileName ?? ''} or alt={file?.name || 'Preview image'}) so screen readers
and users with images disabled receive appropriate text.

}
134 changes: 134 additions & 0 deletions apps/condo/domains/news/components/FilesUploadList.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
.upload-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 104px;
padding: 4px;
overflow: hidden;
background: #f5f5f5;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}

.upload-item--with-thumb {
height: 104px;
}

.upload-item--no-thumb {
height: 130px;
}

/* Контент */
.media-wrapper {
position: relative;
width: 100%;
height: 104px;
}

.image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}

.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 48px;
background: #e0e0e0;
border-radius: 6px;
}

/* Название */
.file-name {
width: 100%;
margin-top: 4px;
overflow: hidden;
font-size: 12px;
white-space: nowrap;
text-align: center;
text-overflow: ellipsis;
}

/* Длительность */
.duration {
position: absolute;
right: 4px;
bottom: 4px;
padding: 2px 6px;
color: #fff;
font-size: 10px;
background: rgb(0 0 0 / 70%);
border-radius: 4px;
}

/* Overlay */
.hover-overlay {
position: absolute;
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
background-color: rgb(0 0 0 / 0%);
border-radius: 6px;
transition: background 0.2s;
inset: 0;
}

/* Кнопки */
.action-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
font-size: 16px;
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, transform 0.2s;
}

.eye-button {
color: #000;
background: rgb(255 255 255 / 85%);
}

.delete-button {
color: #fff;
background: rgb(255 0 0 / 85%);

Check warning on line 106 in apps/condo/domains/news/components/FilesUploadList.module.css

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Text does not meet the minimal contrast requirement with its background.

See more on https://sonarcloud.io/project/issues?id=open-condo-software_condo&issues=AZ2IMVZG9ReRjOMgddIX&open=AZ2IMVZG9ReRjOMgddIX&pullRequest=7465
}

/* Hover */
.upload-item:hover .hover-overlay {
background-color: rgb(0 0 0 / 35%);
}

.upload-item:hover .action-button {
transform: scale(1.05);
opacity: 1;
}

.video-icon-wrapper {
position: relative;
width: 100%;
height: 100%;
}

.video-duration {
position: absolute;
right: 4px;
bottom: 4px;
padding: 2px 6px;
color: white;
font-size: 10px;
background: rgb(0 0 0 / 70%);
border-radius: 4px;
}
Loading
Loading