From aa7336398fc3053ae1fcfaf8b92dabfd7d77cd30 Mon Sep 17 00:00:00 2001 From: James Mortemore Date: Wed, 17 Dec 2025 11:36:55 +0000 Subject: [PATCH 01/12] feat: allow uploading images for appeals and reports --- .env.example | 13 + .gitignore | 3 + Dockerfile | 2 + components/AdminLayout.js | 3 +- components/CommentWithUpload.js | 371 ++++++++++++++ components/DocumentGallery.js | 88 ++++ components/InputCharCounter.js | 6 +- components/PlayerCommentForm.js | 87 +++- components/SidebarDocuments.js | 29 ++ components/admin/DocumentsTable.js | 193 +++++++ components/appeal/PlayerAppealForm.js | 77 ++- components/appeals/PlayerAppealComment.js | 11 +- components/appeals/PlayerAppealCommentList.js | 36 +- components/appeals/PlayerAppealSidebar.js | 5 + components/reports/PlayerReportComment.js | 8 +- components/reports/PlayerReportCommentList.js | 25 +- components/reports/PlayerReportSidebar.js | 5 + package-lock.json | 478 +++++++++++++++++- package.json | 8 + pages/admin/documents.js | 99 ++++ .../appeal/punishment/[serverId]/ban/[id].js | 5 +- .../appeal/punishment/[serverId]/mute/[id].js | 5 +- .../punishment/[serverId]/warning/[id].js | 5 +- pages/appeals/[id].js | 11 + pages/reports/[serverId]/[id].js | 3 + server.js | 5 + server/data/cleanup-documents.js | 70 +++ server/data/hash-content.js | 26 + .../migrations/20251216120000-documents.js | 175 +++++++ server/data/migrations/lib/acl.js | 21 +- .../mutations/create-appeal-comment.js | 62 ++- .../resolvers/mutations/create-appeal.js | 21 +- .../mutations/create-report-comment.js | 26 + .../resolvers/mutations/delete-document.js | 114 +++++ .../resolvers/queries/admin-navigation.js | 2 + .../resolvers/queries/list-documents.js | 49 ++ .../queries/list-reports-comments.js | 10 +- .../resolvers/queries/report-comment.js | 4 + server/graphql/resolvers/queries/report.js | 3 + server/graphql/resolvers/scalars/document.js | 237 +++++++++ server/graphql/types.js | 42 ++ server/routes/documents.js | 125 +++++ server/routes/index.js | 13 + server/routes/upload.js | 201 ++++++++ server/test/appeal.query.test.js | 85 ++++ .../test/createAppealComment.mutation.test.js | 233 +++++++++ .../test/createReportComment.mutation.test.js | 173 +++++++ server/test/deleteDocument.mutation.test.js | 287 +++++++++++ server/test/documents.test.js | 183 +++++++ server/test/fixtures/document.js | 60 +++ server/test/fixtures/index.js | 3 +- server/test/listDocuments.query.test.js | 206 ++++++++ server/test/report.query.test.js | 90 ++++ server/test/upload.test.js | 371 ++++++++++++++ uploads/documents/.gitkeep | 0 utils/index.js | 11 + utils/useScrollToHash.js | 28 + 57 files changed, 4425 insertions(+), 87 deletions(-) create mode 100644 components/CommentWithUpload.js create mode 100644 components/DocumentGallery.js create mode 100644 components/SidebarDocuments.js create mode 100644 components/admin/DocumentsTable.js create mode 100644 pages/admin/documents.js create mode 100644 server/data/cleanup-documents.js create mode 100644 server/data/hash-content.js create mode 100644 server/data/migrations/20251216120000-documents.js create mode 100644 server/graphql/resolvers/mutations/delete-document.js create mode 100644 server/graphql/resolvers/queries/list-documents.js create mode 100644 server/graphql/resolvers/scalars/document.js create mode 100644 server/routes/documents.js create mode 100644 server/routes/upload.js create mode 100644 server/test/deleteDocument.mutation.test.js create mode 100644 server/test/documents.test.js create mode 100644 server/test/fixtures/document.js create mode 100644 server/test/listDocuments.query.test.js create mode 100644 server/test/upload.test.js create mode 100644 uploads/documents/.gitkeep create mode 100644 utils/useScrollToHash.js 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/.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..9d4268697 --- /dev/null +++ b/components/CommentWithUpload.js @@ -0,0 +1,371 @@ +/* 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 && ( + + )} +
+