diff --git a/.env.example b/.env.example index 771099d21..9caa25540 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,16 @@ LOG_LEVEL=info # Set to 'production' for production builds NODE_ENV=development + +# File upload configuration +# Maximum upload size (supports human-readable formats: 5MB, 10MB, 1GB, etc.) +UPLOAD_MAX_SIZE=10MB + +# Path where uploaded documents are stored +UPLOAD_PATH=./uploads/documents + +# Maximum image dimension (width or height) in pixels to prevent pixel bombs +UPLOAD_MAX_DIMENSION=8192 + +# How long before unattached documents are considered orphaned (in hours) +DOCUMENT_CLEANUP_AGE_HOURS=24 diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f107a3097..2dda4cace 100755 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -32,15 +32,17 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - - name: Cache Node.js modules + - name: Cache Node.js modules and Next.js build uses: actions/cache@v4 with: - # npm cache files are stored in `~/.npm` on Linux/macOS - path: ~/.npm - key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} + path: | + ~/.npm + ${{ github.workspace }}/.next/cache + # Generate a new cache whenever packages or source files change + key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} + # If source files changed but packages didn't, rebuild from a prior cache restore-keys: | - ${{ runner.OS }}-node- - ${{ runner.OS }}- + ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}- - run: npm ci - name: Set up MySQL run: | @@ -54,16 +56,27 @@ jobs: LOG_LEVEL: warn PORT: 3001 + - name: Setup E2E database + run: node cypress/setup.js + env: + DB_PASSWORD: + DB_NAME: bm_e2e_tests + + - name: Build for E2E + run: npm run build + env: + DB_PASSWORD: + DB_NAME: bm_e2e_tests + NODE_ENV: production + - name: Cypress run - uses: cypress-io/github-action@v2 + uses: cypress-io/github-action@v6 env: DB_PASSWORD: DB_NAME: bm_e2e_tests LOG_LEVEL: warn PORT: 3000 NODE_ENV: production - ADMIN_USERNAME: "admin@banmanagement.com" - ADMIN_PASSWORD: "P%@#fjdVJ3Y%pdGR" # Keep this as is to avoid HIBP failing checks CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CYPRESS_PROJECT_ID: ${{ secrets.PROJECT_ID }} @@ -72,7 +85,6 @@ jobs: NOTIFICATION_VAPID_PRIVATE_KEY: ${{ secrets.NOTIFICATION_VAPID_PRIVATE_KEY }} with: install: false - build: npm run e2e:build start: npm start wait-on: "http://127.0.0.1:3000" record: true diff --git a/.gitignore b/.gitignore index 14e9b86eb..b2c82b58f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ bundles .travis/*.key .travis/*.key.pub public/images/opengraph/cache + +uploads/**/* +!uploads/documents/.gitkeep diff --git a/Dockerfile b/Dockerfile index 565951033..09047a0cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,11 +36,13 @@ RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs RUN mkdir -p /app/.next/cache/images && chown nextjs:nodejs /app/.next/cache/images +RUN mkdir -p /app/uploads/documents && chown nextjs:nodejs /app/uploads/documents COPY --from=builder --chown=nextjs:nodejs /app ./ VOLUME /app/.next/cache/images VOLUME /app/public/images/opengraph/cache +VOLUME /app/uploads/documents USER nextjs diff --git a/components/AdminLayout.js b/components/AdminLayout.js index cd3e4b5d3..a51067caf 100644 --- a/components/AdminLayout.js +++ b/components/AdminLayout.js @@ -3,7 +3,7 @@ import Link from 'next/link' import { useRouter, withRouter } from 'next/router' import clsx from 'clsx' import { BiServer } from 'react-icons/bi' -import { MdOutlineGroups, MdOutlineExitToApp, MdOutlineNotifications, MdLogout, MdSettings, MdWebhook } from 'react-icons/md' +import { MdOutlineGroups, MdOutlineExitToApp, MdOutlineNotifications, MdLogout, MdSettings, MdWebhook, MdOutlineImage } from 'react-icons/md' import Avatar from './Avatar' import Loader from './Loader' import ErrorLayout from './ErrorLayout' @@ -12,6 +12,7 @@ import SessionNavProfile from './SessionNavProfile' import { useApi, useUser } from '../utils' const icons = { + Documents: , Roles: , Servers: , 'Notification Rules': , diff --git a/components/CommentWithUpload.js b/components/CommentWithUpload.js new file mode 100644 index 000000000..195ab303c --- /dev/null +++ b/components/CommentWithUpload.js @@ -0,0 +1,375 @@ +/* eslint-disable @next/next/no-img-element */ +import { useState, useRef, useCallback, forwardRef, createContext, useContext, useEffect } from 'react' +import Uploady, { useUploady, useBatchAddListener, useItemProgressListener, useItemFinishListener, useItemErrorListener } from '@rpldy/uploady' +import { usePasteUpload } from '@rpldy/upload-paste' +import { FiPaperclip, FiX } from 'react-icons/fi' +import clsx from 'clsx' + +const UploadContext = createContext(null) + +function FileChip ({ id, url, name, onRemove, progress, error }) { + const isUploading = progress !== undefined && progress < 100 + const displayName = name.length > 20 ? name.slice(0, 17) + '...' : name + + return ( +
+ {/* Thumbnail */} +
+ {name} +
+ + {/* Filename */} + + {error ? 'Failed' : isUploading ? 'Uploading...' : displayName} + + + {/* Delete button with proper touch target */} + + + {/* Progress bar */} + {isUploading && ( +
+
+
+ )} +
+ ) +} + +export function AttachButton ({ disabled }) { + const ctx = useContext(UploadContext) + if (!ctx) return null + + const { onAttach, totalFiles, maxFiles } = ctx + const canUploadMore = totalFiles < maxFiles + + return ( +
+ + {maxFiles > 0 && totalFiles > 0 && ( + + {totalFiles}/{maxFiles} + + )} +
+ ) +} + +const TextAreaWithUpload = forwardRef(function TextAreaWithUpload (props, ref) { + const { + onDocumentsChange, + documents = [], + maxFiles = 3, + placeholder = 'Add your comment here...', + maxLength = 250, + minLength, + required = false, + value, + onChange, + disabled = false, + label, + rows, + className, + children, + ...rest + } = props + + const containerRef = useRef(null) + const uploady = useUploady() + const [isDragging, setIsDragging] = useState(false) + const [previews, setPreviews] = useState([]) + const [uploadedIds, setUploadedIds] = useState(documents) + const [progress, setProgress] = useState({}) + const [errors, setErrors] = useState({}) + const dragCounter = useRef(0) + + // Sync with external documents prop (e.g., when form is reset) + useEffect(() => { + if (documents.length === 0 && uploadedIds.length > 0) { + setUploadedIds([]) + setPreviews([]) + setProgress({}) + setErrors({}) + } + }, [documents]) + + usePasteUpload(containerRef) + + useBatchAddListener((batch) => { + const newPreviews = batch.items.map(item => ({ + id: item.id, + url: URL.createObjectURL(item.file), + name: item.file.name + })) + setPreviews(prev => [...prev, ...newPreviews]) + }) + + useItemProgressListener((item) => { + setProgress(prev => ({ ...prev, [item.id]: item.completed })) + }) + + useItemFinishListener((item) => { + if (item.uploadResponse?.data?.id) { + const docId = item.uploadResponse.data.id + setUploadedIds(prev => { + const newIds = [...prev, docId] + onDocumentsChange(newIds) + return newIds + }) + setPreviews(prev => { + const removed = prev.find(p => p.id === item.id) + if (removed?.url) URL.revokeObjectURL(removed.url) + return prev.filter(p => p.id !== item.id) + }) + setProgress(prev => { + const { [item.id]: _, ...rest } = prev + return rest + }) + } + }) + + useItemErrorListener((item) => { + let errorMessage = 'Upload failed' + try { + const response = item.uploadResponse?.data + if (response?.error) { + errorMessage = response.error + } + } catch (e) { + // Use default error message + } + setErrors(prev => ({ ...prev, [item.id]: errorMessage })) + }) + + const handleRemovePreview = useCallback((previewId) => { + setPreviews(prev => { + const removed = prev.find(p => p.id === previewId) + if (removed?.url) URL.revokeObjectURL(removed.url) + return prev.filter(p => p.id !== previewId) + }) + setProgress(prev => { + const { [previewId]: _, ...rest } = prev + return rest + }) + setErrors(prev => { + const { [previewId]: _, ...rest } = prev + return rest + }) + }, []) + + const handleRemoveUploaded = useCallback((docId) => { + setUploadedIds(prev => { + const newIds = prev.filter(id => id !== docId) + onDocumentsChange(newIds) + return newIds + }) + }, [onDocumentsChange]) + + const handleAttachClick = useCallback(() => { + if (uploady) { + uploady.showFileUpload() + } + }, [uploady]) + + const handleDragEnter = useCallback((e) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current++ + if (e.dataTransfer.types.includes('Files')) { + setIsDragging(true) + } + }, []) + + const handleDragLeave = useCallback((e) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current-- + if (dragCounter.current === 0) { + setIsDragging(false) + } + }, []) + + const handleDragOver = useCallback((e) => { + e.preventDefault() + e.stopPropagation() + }, []) + + const handleDrop = useCallback((e) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current = 0 + setIsDragging(false) + + const files = Array.from(e.dataTransfer.files) + if (files.length > 0 && uploady) { + uploady.upload(files) + } + }, [uploady]) + + const totalFiles = previews.length + uploadedIds.length + + const contextValue = { + onAttach: handleAttachClick, + totalFiles, + maxFiles + } + + return ( + +
+ {label && ( + + )} +
+