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 */}
+
+

+
+
+ {/* 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 && (
+
+ )}
+
+
+
+ {/* File chips inside the comment box */}
+ {totalFiles > 0 && (
+
+ {uploadedIds.map((docId, idx) => (
+
+ ))}
+ {previews.map(preview => (
+
+ ))}
+
+ )}
+
+ {isDragging && (
+
+ Drop images here
+
+ )}
+
+
+ {/* Error message display */}
+ {Object.keys(errors).length > 0 && (
+
+ {Object.values(errors)[0]}
+
+ )}
+
+ {/* Render children (typically the action row) */}
+ {children}
+
+
+ )
+})
+
+const CommentWithUpload = forwardRef(function CommentWithUpload (props, ref) {
+ const { onDocumentsChange, documents = [], maxFiles = 3, children, ...textAreaProps } = props
+
+ return (
+ {
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
+ return allowedTypes.includes(file.type)
+ }}
+ >
+
+ {children}
+
+
+ )
+})
+
+export default CommentWithUpload
diff --git a/components/DocumentGallery.js b/components/DocumentGallery.js
new file mode 100644
index 000000000..de8d10389
--- /dev/null
+++ b/components/DocumentGallery.js
@@ -0,0 +1,91 @@
+/* eslint-disable @next/next/no-img-element */
+import { useState, useEffect } from 'react'
+import { FiTrash2 } from 'react-icons/fi'
+import Modal from './Modal'
+import { useMutateApi } from '../utils'
+
+function DocumentItem ({ document, onDelete, canDelete }) {
+ const [confirmDelete, setConfirmDelete] = useState(false)
+ const { load, loading, data, errors } = useMutateApi({
+ query: `mutation deleteDocument($id: ID!) {
+ deleteDocument(id: $id) {
+ id
+ }
+ }`
+ })
+
+ useEffect(() => {
+ if (!data) return
+ if (data?.deleteDocument?.id) {
+ setConfirmDelete(false)
+ if (typeof onDelete === 'function') {
+ onDelete(document.id)
+ }
+ }
+ }, [data])
+
+ const handleDelete = async () => {
+ await load({ id: document.id })
+ }
+
+ const imageUrl = `${process.env.BASE_PATH || ''}/api/documents/${document.id}`
+
+ return (
+ <>
+
+
+
+
+ {canDelete && (
+
+ )}
+
+
+ setConfirmDelete(false)}
+ loading={loading}
+ >
+ Are you sure you want to delete this image?
+ This action cannot be undone.
+ {errors && {errors[0]?.message}
}
+
+ >
+ )
+}
+
+export default function DocumentGallery ({ documents = [], onDelete, canDelete = false }) {
+ if (!documents || documents.length === 0) return null
+
+ return (
+
+ {documents.map(doc => (
+
+ ))}
+
+ )
+}
diff --git a/components/InputCharCounter.js b/components/InputCharCounter.js
index e498221c3..afdb7e4b8 100644
--- a/components/InputCharCounter.js
+++ b/components/InputCharCounter.js
@@ -1,18 +1,18 @@
export default function InputCharCounter ({ currentLength = 0, minLength = 0, maxLength }) {
if (currentLength === 0 && minLength > 0) {
return (
- Enter at least {minLength} characters
+ Min {minLength} chars
)
}
if (currentLength < minLength) {
return (
- {minLength - currentLength} more to go...
+ {minLength - currentLength} more to go
)
}
if (currentLength >= minLength) {
return (
- {maxLength - currentLength} characters left
+ {maxLength - currentLength} left
)
}
}
diff --git a/components/Modal.js b/components/Modal.js
index c9300b098..5ef054075 100644
--- a/components/Modal.js
+++ b/components/Modal.js
@@ -29,6 +29,7 @@ export default function Modal ({ open = false, containerClassName = '', title, c
{confirmButton &&
diff --git a/components/appeals/PlayerAppealCommentList.js b/components/appeals/PlayerAppealCommentList.js
index 54fa7df4d..dec596593 100644
--- a/components/appeals/PlayerAppealCommentList.js
+++ b/components/appeals/PlayerAppealCommentList.js
@@ -2,6 +2,7 @@ import Link from 'next/link'
import Avatar from '../Avatar'
import Loader from '../Loader'
import { useApi, useUser } from '../../utils'
+import { useScrollToHash } from '../../utils/useScrollToHash'
import PlayerAppealComment from './PlayerAppealComment'
import PlayerCommentForm from '../PlayerCommentForm'
import PlayerAppealCommentAssigned from './PlayerAppealCommentAssigned'
@@ -21,6 +22,14 @@ const createCommentQuery = `mutation createAppealComment($id: ID!, $input: Appea
acl {
delete
}
+ documents {
+ id
+ filename
+ mimeType
+ acl {
+ delete
+ }
+ }
}
}`
const query = `
@@ -55,16 +64,43 @@ query listPlayerAppealComments($id: ID!, $actor: UUID, $order: OrderByInput) {
acl {
delete
}
+ documents {
+ id
+ filename
+ mimeType
+ acl {
+ delete
+ }
+ }
}
}
}`
export default function PlayerAppealCommentList ({ appeal, showReply }) {
- const { user } = useUser()
+ const { user, hasServerPermission } = useUser()
const { loading, data, mutate } = useApi({ query, variables: { id: appeal.id, actor: null, order: 'created_ASC' } })
+ const canUpload = hasServerPermission('player.appeals', 'attachment.create', appeal.server?.id)
+
+ useScrollToHash(!loading && !!data)
if (loading) return
+ const handleDocumentDelete = (commentId, documentId) => {
+ const records = data.listPlayerAppealComments.records.map(c => {
+ if (c.id === commentId && c.documents) {
+ return { ...c, documents: c.documents.filter(d => d.id !== documentId) }
+ }
+ return c
+ })
+ mutate({ ...data, listPlayerAppealComments: { ...data.listPlayerAppealComments, records } }, false)
+ }
+
+ const handleInitialDocumentDelete = (documentId) => {
+ const updatedInitialDocuments = appeal.initialDocuments?.filter(d => d.id !== documentId) || []
+
+ appeal.initialDocuments = updatedInitialDocuments
+ }
+
const items = data?.listPlayerAppealComments?.records
? data.listPlayerAppealComments.records.map(comment => {
switch (comment.type) {
@@ -78,6 +114,7 @@ export default function PlayerAppealCommentList ({ appeal, showReply }) {
mutate({ ...data, listPlayerAppealComments: { total: data.listPlayerAppealComments.total - 1, records } }, false)
}}
+ onDocumentDelete={(docId) => handleDocumentDelete(comment.id, docId)}
/>
)
case 'state':
@@ -99,7 +136,7 @@ export default function PlayerAppealCommentList ({ appeal, showReply }) {
return (
<>
@@ -120,6 +157,7 @@ export default function PlayerAppealCommentList ({ appeal, showReply }) {
mutate({ listPlayerAppealComments: { total: data.listPlayerAppealComments.total + 1, records } }, true)
}}
query={createCommentQuery}
+ canUpload={canUpload}
/>
diff --git a/components/appeals/PlayerAppealSidebar.js b/components/appeals/PlayerAppealSidebar.js
index 7947afd3c..423212572 100644
--- a/components/appeals/PlayerAppealSidebar.js
+++ b/components/appeals/PlayerAppealSidebar.js
@@ -1,4 +1,5 @@
import { useMatchMutate } from '../../utils'
+import SidebarDocuments from '../SidebarDocuments'
import PlayerAppealActions from './PlayerAppealActions'
import PlayerAppealAssign from './PlayerAppealAssign'
import PlayerAppealNotifications from './PlayerAppealNotifications'
@@ -65,6 +66,10 @@ export default function PlayerAppealSidebar ({ data, canUpdateState, canAssign,
}}
/>
}
+ {appeal.documents?.length > 0 &&
+
+
+ }
{!!user && appeal.state.id < 3 &&
setOpen(false)
return (
-
diff --git a/components/reports/PlayerReportCommentList.js b/components/reports/PlayerReportCommentList.js
index 925b3272c..c5f28d68a 100644
--- a/components/reports/PlayerReportCommentList.js
+++ b/components/reports/PlayerReportCommentList.js
@@ -2,6 +2,7 @@ import Link from 'next/link'
import Avatar from '../Avatar'
import Loader from '../Loader'
import { useApi, useUser } from '../../utils'
+import { useScrollToHash } from '../../utils/useScrollToHash'
import PlayerReportComment from './PlayerReportComment'
import PlayerCommentForm from '../PlayerCommentForm'
@@ -17,6 +18,14 @@ const createCommentQuery = `mutation createReportComment($report: ID!, $serverId
acl {
delete
}
+ documents {
+ id
+ filename
+ mimeType
+ size
+ width
+ height
+ }
}
}`
const query = `
@@ -34,13 +43,24 @@ query listPlayerReportComments($report: ID!, $serverId: ID!, $actor: UUID, $orde
acl {
delete
}
+ documents {
+ id
+ filename
+ mimeType
+ size
+ width
+ height
+ }
}
}
}`
export default function PlayerReportCommentList ({ serverId, report, showReply }) {
- const { user } = useUser()
+ const { user, hasServerPermission } = useUser()
const { loading, data, mutate } = useApi({ query, variables: { serverId, report, actor: null, order: 'created_ASC' } })
+ const canUpload = hasServerPermission('player.reports', 'attachment.create', serverId)
+
+ useScrollToHash(!loading && !!data)
if (loading) return
@@ -76,7 +96,7 @@ export default function PlayerReportCommentList ({ serverId, report, showReply }
({ report, serverId, input: { comment: input.comment } })}
+ parseVariables={(input, documentIds) => ({ report, serverId, input: { comment: input.comment, documents: documentIds } })}
onFinish={({ createReportComment }) => {
const records = data.listPlayerReportComments.records.slice()
@@ -84,6 +104,7 @@ export default function PlayerReportCommentList ({ serverId, report, showReply }
mutate({ listPlayerReportComments: { total: data.listPlayerReportComments.total + 1, records } }, true)
}}
query={createCommentQuery}
+ canUpload={canUpload}
/>
diff --git a/components/reports/PlayerReportSidebar.js b/components/reports/PlayerReportSidebar.js
index 779f1c7a3..12205457c 100644
--- a/components/reports/PlayerReportSidebar.js
+++ b/components/reports/PlayerReportSidebar.js
@@ -1,3 +1,4 @@
+import SidebarDocuments from '../SidebarDocuments'
import PlayerReportActions from './PlayerReportActions'
import PlayerReportAssign from './PlayerReportAssign'
import PlayerReportCommand from './PlayerReportCommand'
@@ -72,6 +73,10 @@ export default function PlayerReportSidebar ({ data, canUpdateState, canAssign,
}
+ {report.documents?.length > 0 &&
+
+
+ }
{((!!user && report.state.id < 3 && !report?.commands?.length) || !!report?.commands?.length) &&
{!!report?.commands?.length &&
diff --git a/cypress.config.js b/cypress.config.js
new file mode 100644
index 000000000..ae144ffa9
--- /dev/null
+++ b/cypress.config.js
@@ -0,0 +1,20 @@
+const { defineConfig } = require('cypress')
+require('dotenv').config()
+
+const port = process.env.PORT || 3000
+
+module.exports = defineConfig({
+ e2e: {
+ baseUrl: `http://localhost:${port}`,
+ specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
+ supportFile: 'cypress/support/e2e.js',
+ setupNodeEvents (on, config) {
+ // Set defaults, but CYPRESS_* env vars take precedence (already in config.env)
+ // Default password must match cypress/setup.js
+ config.env.admin_username = config.env.admin_username || process.env.ADMIN_USERNAME || 'admin@banmanagement.com'
+ config.env.admin_password = config.env.admin_password || process.env.ADMIN_PASSWORD || 'xK9mQp2LvR7nS4jT'
+ config.env.session_name = config.env.session_name || process.env.SESSION_NAME || 'bm-webui-sess'
+ return config
+ }
+ }
+})
diff --git a/cypress.json b/cypress.json
deleted file mode 100644
index 18893b6de..000000000
--- a/cypress.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "baseUrl": "http://localhost:3000"
-}
diff --git a/cypress/integration/pages/account/email.spec.js b/cypress/e2e/pages/account/email.spec.js
similarity index 76%
rename from cypress/integration/pages/account/email.spec.js
rename to cypress/e2e/pages/account/email.spec.js
index 0f608b215..c7b688db3 100644
--- a/cypress/integration/pages/account/email.spec.js
+++ b/cypress/e2e/pages/account/email.spec.js
@@ -1,7 +1,6 @@
describe('Account/Email', () => {
- before(() => {
+ beforeEach(() => {
cy.login(Cypress.env('admin_username'), Cypress.env('admin_password'))
-
cy.visit('/account/email')
})
@@ -10,9 +9,6 @@ describe('Account/Email', () => {
})
it('errors if incorrect current password', () => {
- cy.login(Cypress.env('admin_username'), Cypress.env('admin_password'))
- cy.get('form').then($element => $element[0].reset())
-
cy.get('[data-cy=email]').type('aaa@aaa.com')
cy.get('[data-cy=currentPassword]').type('aaaaaa')
@@ -22,9 +18,6 @@ describe('Account/Email', () => {
})
it('errors email used', () => {
- cy.login(Cypress.env('admin_username'), Cypress.env('admin_password'))
- cy.get('form').then($element => $element[0].reset())
-
cy.get('[data-cy=email]').type(Cypress.env('admin_username'))
cy.get('[data-cy=currentPassword]').type(Cypress.env('admin_password'))
@@ -34,9 +27,6 @@ describe('Account/Email', () => {
})
it('successfully changes email', () => {
- cy.login(Cypress.env('admin_username'), Cypress.env('admin_password'))
- cy.get('form').then($element => $element[0].reset())
-
cy.get('[data-cy=email]').type('test@banmanagement.com')
cy.get('[data-cy=currentPassword]').type(Cypress.env('admin_password'))
diff --git a/cypress/integration/pages/account/index.spec.js b/cypress/e2e/pages/account/index.spec.js
similarity index 100%
rename from cypress/integration/pages/account/index.spec.js
rename to cypress/e2e/pages/account/index.spec.js
diff --git a/cypress/integration/pages/account/password.spec.js b/cypress/e2e/pages/account/password.spec.js
similarity index 68%
rename from cypress/integration/pages/account/password.spec.js
rename to cypress/e2e/pages/account/password.spec.js
index 5299d61d3..fe98620fe 100644
--- a/cypress/integration/pages/account/password.spec.js
+++ b/cypress/e2e/pages/account/password.spec.js
@@ -1,7 +1,6 @@
describe('Account/Password', () => {
- before(() => {
+ beforeEach(() => {
cy.login(Cypress.env('admin_username'), Cypress.env('admin_password'))
-
cy.visit('/account/password')
})
@@ -10,9 +9,6 @@ describe('Account/Password', () => {
})
it('errors if incorrect current password', () => {
- cy.login(Cypress.env('admin_username'), Cypress.env('admin_password'))
- cy.get('form').then($element => $element[0].reset())
-
cy.get('[data-cy=currentPassword]').type('aaaaaa')
cy.get('[data-cy=newPassword]').type('aaaaaa')
cy.get('[data-cy=confirmPassword]').type('aaaaaa')
@@ -23,9 +19,6 @@ describe('Account/Password', () => {
})
it('errors if new password weak', () => {
- cy.login(Cypress.env('admin_username'), Cypress.env('admin_password'))
- cy.get('form').then($element => $element[0].reset())
-
cy.get('[data-cy=currentPassword]').type(Cypress.env('admin_password'))
cy.get('[data-cy=newPassword]').type('aaaaaa')
cy.get('[data-cy=confirmPassword]').type('aaaaaa')
@@ -36,12 +29,10 @@ describe('Account/Password', () => {
})
it('successfully changes password', () => {
- cy.login(Cypress.env('admin_username'), Cypress.env('admin_password'))
- cy.get('form').then($element => $element[0].reset())
-
+ const originalPassword = Cypress.env('admin_password')
const newPassword = 'kb^5L^$CxViyPS4G'
- cy.get('[data-cy=currentPassword]').type(Cypress.env('admin_password'))
+ cy.get('[data-cy=currentPassword]').type(originalPassword)
cy.get('[data-cy=newPassword]').type(newPassword)
cy.get('[data-cy=confirmPassword]').type(newPassword)
@@ -49,11 +40,11 @@ describe('Account/Password', () => {
cy.title().should('eq', 'Account')
- // Reset it
+ // Reset back to original password so other tests still work
cy.visit('/account/password')
cy.get('[data-cy=currentPassword]').type(newPassword)
- cy.get('[data-cy=newPassword]').type(Cypress.env('admin_password'))
- cy.get('[data-cy=confirmPassword]').type(Cypress.env('admin_password'))
+ cy.get('[data-cy=newPassword]').type(originalPassword)
+ cy.get('[data-cy=confirmPassword]').type(originalPassword)
cy.get('[data-cy=submit-password-change]').click()
diff --git a/cypress/e2e/pages/documents.spec.js b/cypress/e2e/pages/documents.spec.js
new file mode 100644
index 000000000..21594f18a
--- /dev/null
+++ b/cypress/e2e/pages/documents.spec.js
@@ -0,0 +1,255 @@
+describe('Document Uploads', () => {
+ let serverId
+ let banId
+
+ before(() => {
+ // Load test fixture data
+ cy.fixture('e2e-data.json').then((data) => {
+ serverId = data.serverId
+ banId = data.banId
+ })
+ })
+
+ beforeEach(() => {
+ cy.login(Cypress.env('admin_username'), Cypress.env('admin_password'))
+ })
+
+ describe('File Selection', () => {
+ beforeEach(function () {
+ cy.visit(`/appeal/punishment/${serverId}/ban/${banId}`)
+ cy.get('[data-cy=upload-dropzone]').should('be.visible')
+ })
+
+ it('uploads file via attach button', () => {
+ // Click attach button to trigger file input
+ cy.get('[data-cy=attach-button]').click()
+
+ // Use Cypress selectFile on the hidden file input (created by @rpldy/uploady)
+ cy.get('input[type="file"]').selectFile('cypress/fixtures/test-image.jpg', { force: true })
+
+ // Should show file chip while uploading
+ cy.get('[data-cy=file-chip]').should('be.visible')
+
+ // Wait for upload to complete (chip should still be visible after upload)
+ cy.get('[data-cy=file-chip]').should('have.length.at.least', 1)
+ })
+
+ it('shows file chip during upload', () => {
+ cy.get('[data-cy=attach-button]').click()
+ cy.get('input[type="file"]').selectFile('cypress/fixtures/test-image.png', { force: true })
+
+ // File chip should appear
+ cy.get('[data-cy=file-chip]').should('be.visible')
+ })
+
+ it('respects max file limit', () => {
+ // Upload files up to the max (5 for appeals)
+ for (let i = 0; i < 5; i++) {
+ cy.get('[data-cy=attach-button]').click()
+ cy.get('input[type="file"]').selectFile('cypress/fixtures/test-image.jpg', { force: true })
+ // Wait for file chip to appear before uploading next
+ cy.get('[data-cy=file-chip]').should('have.length', i + 1)
+ }
+
+ // Should have 5 file chips
+ cy.get('[data-cy=file-chip]').should('have.length', 5)
+
+ // Attach button should be disabled
+ cy.get('[data-cy=attach-button]').should('be.disabled')
+ })
+ })
+
+ describe('Drag and Drop', () => {
+ beforeEach(function () {
+ cy.visit(`/appeal/punishment/${serverId}/ban/${banId}`)
+ cy.get('[data-cy=upload-dropzone]').should('be.visible')
+ })
+
+ it('shows drop zone highlight on drag enter', () => {
+ cy.fixture('test-image.jpg', 'base64').then(content => {
+ const blob = Cypress.Blob.base64StringToBlob(content, 'image/jpeg')
+ const file = new File([blob], 'test-image.jpg', { type: 'image/jpeg' })
+ const dataTransfer = new DataTransfer()
+ dataTransfer.items.add(file)
+
+ cy.get('[data-cy=upload-dropzone]')
+ .trigger('dragenter', { dataTransfer, force: true })
+
+ // Check for visual indication (ring class)
+ cy.get('[data-cy=upload-dropzone]').should('have.class', 'ring-2')
+ })
+ })
+
+ it('uploads file on drop', () => {
+ cy.get('[data-cy=upload-dropzone]').dropFile('test-image.jpg', 'image/jpeg')
+
+ // Should show file chip after drop
+ cy.get('[data-cy=file-chip]').should('be.visible')
+ })
+
+ it('handles multiple files', () => {
+ // Drop first file
+ cy.get('[data-cy=upload-dropzone]').dropFile('test-image.jpg', 'image/jpeg')
+ cy.get('[data-cy=file-chip]').should('have.length.at.least', 1)
+
+ // Drop second file
+ cy.get('[data-cy=upload-dropzone]').dropFile('test-image.png', 'image/png')
+ cy.get('[data-cy=file-chip]').should('have.length.at.least', 2)
+ })
+ })
+
+ describe('Paste Upload', () => {
+ beforeEach(function () {
+ cy.visit(`/appeal/punishment/${serverId}/ban/${banId}`)
+ cy.get('[data-cy=upload-dropzone]').should('be.visible')
+ })
+
+ it('uploads image pasted into textarea', () => {
+ // Focus the textarea first
+ cy.get('[data-cy=upload-dropzone] textarea').focus()
+
+ // Simulate paste event with image
+ cy.get('[data-cy=upload-dropzone]').pasteFile('test-image.jpg', 'image/jpeg')
+
+ // Should show file chip after paste
+ cy.get('[data-cy=file-chip]').should('be.visible')
+ })
+ })
+
+ describe('File Removal', () => {
+ beforeEach(function () {
+ cy.visit(`/appeal/punishment/${serverId}/ban/${banId}`)
+ cy.get('[data-cy=upload-dropzone]').should('be.visible')
+ })
+
+ it('removes uploaded file from form', () => {
+ // Upload a file first
+ cy.get('[data-cy=attach-button]').click()
+ cy.get('input[type="file"]').selectFile('cypress/fixtures/test-image.jpg', { force: true })
+
+ // Wait for file chip to appear
+ cy.get('[data-cy=file-chip]').should('be.visible')
+
+ // Click remove button
+ cy.get('[data-cy=file-chip-remove]').first().click()
+
+ // File chip should be removed
+ cy.get('[data-cy=file-chip]').should('not.exist')
+ })
+ })
+
+ describe('Error Handling', () => {
+ beforeEach(function () {
+ cy.visit(`/appeal/punishment/${serverId}/ban/${banId}`)
+ cy.get('[data-cy=upload-dropzone]').should('be.visible')
+ })
+
+ it('rejects invalid file types', () => {
+ // Create a text file (not allowed)
+ cy.get('[data-cy=attach-button]').click()
+
+ // The file filter in Uploady should reject non-image files
+ // This tests that the file input only accepts images
+ cy.get('input[type="file"]').should('have.attr', 'accept', 'image/*')
+ })
+ })
+
+ describe('Document Deletion (After Submission)', () => {
+ // These tests share a single appeal since we can only appeal a ban once
+ let appealUrl
+
+ it('creates appeal with documents and can delete them', () => {
+ cy.visit(`/appeal/punishment/${serverId}/ban/${banId}`)
+ cy.get('[data-cy=upload-dropzone]').should('be.visible')
+
+ // Upload a file
+ cy.get('[data-cy=attach-button]').click()
+ cy.get('input[type="file"]').selectFile('cypress/fixtures/test-image.jpg', { force: true })
+
+ // Wait for upload to complete (file chip shows and is stable)
+ cy.get('[data-cy=file-chip]').should('be.visible')
+ cy.get('[data-cy=file-chip]').should('have.length', 1)
+
+ // Type appeal reason (minimum 20 characters)
+ cy.get('[data-cy=upload-dropzone] textarea').type('This is a test appeal reason with enough characters to meet the minimum requirement.')
+
+ // Submit the appeal
+ cy.get('[data-cy=submit-appeal]').should('not.be.disabled')
+ cy.get('[data-cy=submit-appeal]').click()
+
+ // Should redirect to the appeal page (with longer timeout for server processing)
+ cy.url({ timeout: 10000 }).should('include', '/appeals/')
+
+ // Store the appeal URL for the next test
+ cy.url().then(url => {
+ appealUrl = url
+ })
+
+ // Should see the uploaded document
+ cy.get('[data-cy=document-item]', { timeout: 10000 }).should('be.visible')
+
+ // Click delete button (need to hover first to make it visible)
+ cy.get('[data-cy=document-item]').first().trigger('mouseover')
+ cy.get('[data-cy=document-delete]').first().click({ force: true })
+
+ // Confirmation modal should appear
+ cy.get('[data-cy=modal-confirm]').should('be.visible')
+
+ // Confirm deletion
+ cy.get('[data-cy=modal-confirm]').click()
+
+ // Wait for modal to close
+ cy.get('[data-cy=modal-confirm]').should('not.exist')
+
+ // Reload the page to verify document was actually deleted from server
+ cy.reload()
+ cy.get('[data-cy=document-item]', { timeout: 10000 }).should('not.exist')
+ })
+
+ it('cancels deletion on modal cancel', function () {
+ // Skip if previous test didn't create an appeal
+ if (!appealUrl) {
+ this.skip()
+ }
+
+ // Visit the existing appeal and add a comment with a document
+ cy.visit(appealUrl)
+
+ // Wait for page to load
+ cy.get('[data-cy=submit-report-comment-form]', { timeout: 10000 }).should('be.visible')
+
+ // Upload a file via the comment form
+ cy.get('[data-cy=attach-button]').click()
+ cy.get('input[type="file"]').selectFile('cypress/fixtures/test-image.jpg', { force: true })
+
+ // Wait for upload to complete (file chip shows and is stable)
+ cy.get('[data-cy=file-chip]').should('be.visible')
+ cy.get('[data-cy=file-chip]').should('have.length', 1)
+
+ // Type a comment
+ cy.get('[data-cy=upload-dropzone] textarea').type('Adding a comment with a document for testing.')
+
+ // Submit the comment
+ cy.get('[data-cy=submit-report-comment-form]').click()
+
+ // Wait for comment to appear with document
+ cy.get('[data-cy=document-item]', { timeout: 10000 }).should('be.visible')
+
+ // Click delete button on the document
+ cy.get('[data-cy=document-item]').first().trigger('mouseover')
+ cy.get('[data-cy=document-delete]').first().click({ force: true })
+
+ // Confirmation modal should appear
+ cy.get('[data-cy=modal-cancel]').should('be.visible')
+
+ // Cancel deletion
+ cy.get('[data-cy=modal-cancel]').click()
+
+ // Modal should close
+ cy.get('[data-cy=modal-cancel]').should('not.exist')
+
+ // Document should still exist
+ cy.get('[data-cy=document-item]').should('be.visible')
+ })
+ })
+})
diff --git a/cypress/integration/pages/home.spec.js b/cypress/e2e/pages/home.spec.js
similarity index 100%
rename from cypress/integration/pages/home.spec.js
rename to cypress/e2e/pages/home.spec.js
diff --git a/cypress/integration/pages/login.spec.js b/cypress/e2e/pages/login.spec.js
similarity index 84%
rename from cypress/integration/pages/login.spec.js
rename to cypress/e2e/pages/login.spec.js
index c91b6d0c8..e934adc77 100644
--- a/cypress/integration/pages/login.spec.js
+++ b/cypress/e2e/pages/login.spec.js
@@ -1,5 +1,5 @@
describe('Login', () => {
- before(() => {
+ beforeEach(() => {
cy.visit('/login')
})
@@ -8,8 +8,6 @@ describe('Login', () => {
})
it('shows error for invalid passwords', () => {
- cy.get('form').then($element => $element[0].reset())
-
cy.get('[data-cy=email]').type('doesnotexis@banmanagement.com')
cy.get('[data-cy=password]').type('aaa')
@@ -19,8 +17,6 @@ describe('Login', () => {
})
it('shows error when account does not exist', () => {
- cy.get('form').then($element => $element[0].reset())
-
cy.get('[data-cy=email]').type('doesnotexist@banmanagement.com')
cy.get('[data-cy=password]').type('aaaaaa')
@@ -30,8 +26,6 @@ describe('Login', () => {
})
it('logs in via email', () => {
- cy.get('form').then($element => $element[0].reset())
-
cy.get('[data-cy=email]').type(Cypress.env('admin_username'))
cy.get('[data-cy=password]').type(Cypress.env('admin_password'))
diff --git a/cypress/integration/pages/player.spec.js b/cypress/e2e/pages/player.spec.js
similarity index 100%
rename from cypress/integration/pages/player.spec.js
rename to cypress/e2e/pages/player.spec.js
diff --git a/cypress/fixtures/e2e-data.json b/cypress/fixtures/e2e-data.json
new file mode 100644
index 000000000..b66c6c787
--- /dev/null
+++ b/cypress/fixtures/e2e-data.json
@@ -0,0 +1,4 @@
+{
+ "serverId": "41791bb0",
+ "banId": 1
+}
\ No newline at end of file
diff --git a/cypress/fixtures/test-image.jpg b/cypress/fixtures/test-image.jpg
new file mode 100644
index 000000000..8ce8f5f73
Binary files /dev/null and b/cypress/fixtures/test-image.jpg differ
diff --git a/cypress/fixtures/test-image.png b/cypress/fixtures/test-image.png
new file mode 100644
index 000000000..08cd6f2bf
Binary files /dev/null and b/cypress/fixtures/test-image.png differ
diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js
deleted file mode 100644
index eeb0a3f6c..000000000
--- a/cypress/plugins/index.js
+++ /dev/null
@@ -1,9 +0,0 @@
-require('dotenv').config()
-
-module.exports = (_on, config) => {
- config.env.admin_username = process.env.ADMIN_USERNAME || 'admin@banmanagement.com'
- config.env.admin_password = process.env.ADMIN_PASSWORD || 'testing'
- config.env.session_name = process.env.SESSION_NAME || 'bm-webui-sess'
-
- return config
-}
diff --git a/cypress/setup.js b/cypress/setup.js
index cb47f66a4..554faabca 100644
--- a/cypress/setup.js
+++ b/cypress/setup.js
@@ -1,11 +1,13 @@
require('dotenv').config()
const path = require('path')
+const fs = require('fs')
const DBMigrate = require('db-migrate')
const { parse } = require('uuid-parse')
const { setupPool } = require('../server/connections')
-const { createServer, createPlayer } = require('../server/test/fixtures')
+const { createServer, createPlayer, createBan } = require('../server/test/fixtures')
const { hash } = require('../server/data/hash')
+const { encrypt } = require('../server/data/crypto')
;(async () => { // eslint-disable-line max-statements
const dbName = 'bm_e2e_tests'
@@ -19,6 +21,7 @@ const { hash } = require('../server/data/hash')
}
let dbPool = await setupPool(dbConfig)
+ await dbPool.raw(`DROP DATABASE IF EXISTS ${dbName}`)
await dbPool.raw(`CREATE DATABASE ${dbName}`)
await dbPool.destroy()
@@ -31,14 +34,33 @@ const { hash } = require('../server/data/hash')
dbPool = await setupPool(dbConfig)
// Run migrations, then 'test' migrations
- let dbmOpts = { throwUncatched: true, config: { dev: { ...dbConfig, driver: { require: '@confuser/db-migrate-mysql' } } }, cmdOptions: { 'migrations-dir': path.join(__dirname, '..', 'server', 'data', 'migrations') } }
+ const dbmConfig = {
+ connectionLimit: 1,
+ host: dbConfig.host,
+ port: parseInt(dbConfig.port, 10) || 3306,
+ user: dbConfig.user,
+ password: dbConfig.password,
+ database: dbConfig.database,
+ multipleStatements: true,
+ driver: { require: '@confuser/db-migrate-mysql' }
+ }
+
+ let dbmOpts = {
+ throwUncatched: true,
+ config: { dev: dbmConfig },
+ cmdOptions: { 'migrations-dir': path.join(__dirname, '..', 'server', 'data', 'migrations') }
+ }
let dbm = DBMigrate.getInstance(true, dbmOpts)
dbm.silence(true)
await dbm.up()
- dbmOpts = { throwUncatched: true, config: { dev: { ...dbConfig, driver: { require: '@confuser/db-migrate-mysql' } } }, cmdOptions: { 'migrations-dir': path.join(__dirname, '..', 'server', 'test', 'migrations') } }
+ dbmOpts = {
+ throwUncatched: true,
+ config: { dev: dbmConfig },
+ cmdOptions: { 'migrations-dir': path.join(__dirname, '..', 'server', 'test', 'migrations') }
+ }
dbm = DBMigrate.getInstance(true, dbmOpts)
dbm.silence(true)
@@ -62,16 +84,45 @@ const { hash } = require('../server/data/hash')
const updated = Math.floor(Date.now() / 1000)
+ // E2E test admin password - must match cypress.config.js default
+ const e2eAdminPassword = 'xK9mQp2LvR7nS4jT'
+ const e2eAdminEmail = 'admin@banmanagement.com'
+
await dbPool('bm_web_users').insert([
{ player_id: guestUser.id, email: 'guest@banmanagement.com', password: await hash('testing'), updated },
{ player_id: loggedInUser.id, email: 'user@banmanagement.com', password: await hash('testing'), updated },
- { player_id: adminUser.id, email: process.env.ADMIN_USERNAME || 'admin@banmanagement.com', password: await hash(process.env.ADMIN_PASSWORD || 'testing'), updated }
+ { player_id: adminUser.id, email: e2eAdminEmail, password: await hash(e2eAdminPassword), updated }
])
// Create a server
const server = await createServer(playerConsole.id, dbName)
+ // Encrypt the server password using the encryption key
+ if (server.password && process.env.ENCRYPTION_KEY) {
+ server.password = await encrypt(process.env.ENCRYPTION_KEY, server.password)
+ }
+
await dbPool('bm_web_servers').insert(server)
+ // Create a ban for the admin user (to enable appeal testing with document uploads)
+ const adminBan = createBan(adminUser, playerConsole)
+ await dbPool('bm_player_bans').insert(adminBan)
+
+ // Get the ban ID that was inserted (it's auto-incremented)
+ const [banResult] = await dbPool('bm_player_bans').select('id').where({ player_id: adminUser.id }).limit(1)
+ const banId = banResult.id
+
+ // Write test fixture data to a JSON file for Cypress tests to use
+ const fixtureData = {
+ serverId: server.id,
+ banId: banId
+ }
+ fs.writeFileSync(
+ path.join(__dirname, 'fixtures', 'e2e-data.json'),
+ JSON.stringify(fixtureData, null, 2)
+ )
+
+ console.log('E2E test data:', fixtureData)
+
await dbPool.destroy()
})().catch(error => console.error(error))
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 1efd45b0d..b2cdace55 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -13,3 +13,39 @@ Cypress.Commands.add(
cy.getCookie(Cypress.env('session_name')).should('exist')
})
})
+
+// Drop file(s) onto an element (simulates drag and drop)
+Cypress.Commands.add('dropFile', { prevSubject: 'element' }, (subject, fixture, mimeType = 'image/jpeg') => {
+ return cy.fixture(fixture, 'base64').then(content => {
+ const blob = Cypress.Blob.base64StringToBlob(content, mimeType)
+ const file = new File([blob], fixture, { type: mimeType })
+ const dataTransfer = new DataTransfer()
+ dataTransfer.items.add(file)
+
+ // Dispatch drag events in sequence - use separate cy commands to avoid unsafe chaining
+ cy.wrap(subject).trigger('dragenter', { dataTransfer, force: true })
+ cy.wrap(subject).trigger('dragover', { dataTransfer, force: true })
+ cy.wrap(subject).trigger('drop', { dataTransfer, force: true })
+ })
+})
+
+// Paste file into an element (simulates clipboard paste)
+Cypress.Commands.add('pasteFile', { prevSubject: 'element' }, (subject, fixture, mimeType = 'image/jpeg') => {
+ return cy.fixture(fixture, 'base64').then(content => {
+ const blob = Cypress.Blob.base64StringToBlob(content, mimeType)
+ const file = new File([blob], fixture, { type: mimeType })
+
+ // Dispatch paste event with clipboardData containing the file
+ cy.wrap(subject).then($el => {
+ const event = new Event('paste', { bubbles: true })
+ Object.defineProperty(event, 'clipboardData', {
+ value: {
+ files: [file],
+ items: [{ kind: 'file', type: mimeType, getAsFile: () => file }],
+ types: ['Files']
+ }
+ })
+ $el[0].dispatchEvent(event)
+ })
+ })
+})
diff --git a/cypress/support/index.js b/cypress/support/e2e.js
similarity index 100%
rename from cypress/support/index.js
rename to cypress/support/e2e.js
diff --git a/package-lock.json b/package-lock.json
index 0b97f3816..789447bf2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@graphql-tools/utils": "10.8.6",
"@headlessui/react": "2.2.0",
"@koa/cors": "^5.0.0",
+ "@koa/multer": "^4.0.0",
"@koa/router": "13.1.0",
"@leichtgewicht/ip-codec": "2.0.5",
"@nateradebaugh/react-datetime": "4.6.0",
@@ -28,6 +29,11 @@
"@oclif/core": "4.2.10",
"@oclif/plugin-help": "6.2.27",
"@odiffey/discord-markdown": "^3.3.0",
+ "@rpldy/upload-button": "^1.13.0",
+ "@rpldy/upload-drop-zone": "^1.13.0",
+ "@rpldy/upload-paste": "^1.13.0",
+ "@rpldy/upload-preview": "^1.13.0",
+ "@rpldy/uploady": "^1.13.0",
"@universemc/react-palette": "1.0.3",
"argon2": "0.29.1",
"autoprefixer": "10.4.21",
@@ -39,6 +45,7 @@
"dotenv": "16.4.7",
"edit-dotenv": "1.0.4",
"es6-dynamic-template": "2.0.0",
+ "filesize-parser": "^1.5.1",
"git-revision-webpack-plugin": "5.0.0",
"graphql": "16.10.0",
"graphql-constraint-directive": "5.4.3",
@@ -61,6 +68,7 @@
"koa-session": "6.4.0",
"lodash-es": "4.17.21",
"memoizee": "0.4.17",
+ "multer": "^2.0.2",
"mysql2": "3.14.0",
"nanoid": "3.3.11",
"next": "14.2.35",
@@ -100,7 +108,7 @@
"@next/eslint-plugin-next": "14.2.26",
"@oclif/test": "4.1.15",
"coveralls-next": "4.2.2",
- "cypress": "9.7.0",
+ "cypress": "15.8.0",
"eslint-plugin-cypress": "4.2.0",
"eslint-plugin-react": "7.37.5",
"faker": "5.5.3",
@@ -1017,9 +1025,9 @@
}
},
"node_modules/@cypress/request": {
- "version": "2.88.12",
- "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz",
- "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==",
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.9.tgz",
+ "integrity": "sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==",
"dev": true,
"dependencies": {
"aws-sign2": "~0.7.0",
@@ -1028,16 +1036,16 @@
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
- "form-data": "~2.3.2",
- "http-signature": "~1.3.6",
+ "form-data": "~4.0.4",
+ "http-signature": "~1.4.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19",
"performance-now": "^2.1.0",
- "qs": "~6.10.3",
+ "qs": "6.14.0",
"safe-buffer": "^5.1.2",
- "tough-cookie": "^4.1.3",
+ "tough-cookie": "^5.0.0",
"tunnel-agent": "^0.6.0",
"uuid": "^8.3.2"
},
@@ -1046,12 +1054,12 @@
}
},
"node_modules/@cypress/request/node_modules/qs": {
- "version": "6.10.4",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz",
- "integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dev": true,
"dependencies": {
- "side-channel": "^1.0.4"
+ "side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
@@ -2466,6 +2474,18 @@
"node": ">= 14.0.0"
}
},
+ "node_modules/@koa/multer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@koa/multer/-/multer-4.0.0.tgz",
+ "integrity": "sha512-BY6hys3WVX1yL/gcfKWu94z1fJ6ayG1DEEw/s82DnulkaTbumwjF6XqSfNLKFcs8lnJb2QfMJ4DyK4bmF1NDZw==",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "koa": ">=2",
+ "multer": "*"
+ }
+ },
"node_modules/@koa/router": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/@koa/router/-/router-13.1.0.tgz",
@@ -3531,6 +3551,196 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
}
},
+ "node_modules/@rpldy/abort": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/abort/-/abort-1.13.0.tgz",
+ "integrity": "sha512-gPzbKWCgSSM72LtQbytbyIuUBwpwHLQ80xbl6QlSltsPKhWGp/wuTxnrP5fm+rI7kkDoEuxuVG247wZb5C4afw==",
+ "dependencies": {
+ "@rpldy/raw-uploader": "1.13.0",
+ "@rpldy/shared": "1.13.0",
+ "@rpldy/simple-state": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ }
+ },
+ "node_modules/@rpldy/life-events": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/life-events/-/life-events-1.13.0.tgz",
+ "integrity": "sha512-5hrTeC17Vb7jVFH+8YXhDkmM00jJyguJPHOX0kYxYnR1LZqVFnR/owA/8unC1VmixtCBhs4fojK7rXe+axXBJw==",
+ "dependencies": {
+ "@rpldy/shared": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ }
+ },
+ "node_modules/@rpldy/raw-uploader": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/raw-uploader/-/raw-uploader-1.13.0.tgz",
+ "integrity": "sha512-iHvJB1Afeev6My/1NDJIZDwR3Ng4NHEfTX72IwdMJgdcv20+LE093Pt1+v3haYT036RPhkQwE2ta7+Ux7NdRFQ==",
+ "dependencies": {
+ "@rpldy/life-events": "1.13.0",
+ "@rpldy/shared": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ }
+ },
+ "node_modules/@rpldy/sender": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/sender/-/sender-1.13.0.tgz",
+ "integrity": "sha512-cnImc4A/upy6FGM6eYd7yumv7hfbg/UDEyqkMovgyEgtBx2M6jBlQWvImjrwaH45bh8dSt/60N/QVcQUn1ghbA==",
+ "dependencies": {
+ "@rpldy/shared": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ }
+ },
+ "node_modules/@rpldy/shared": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/shared/-/shared-1.13.0.tgz",
+ "integrity": "sha512-WF9LkjWLGeAs888cLbhneNDnbRUFrwIu/CQJ2E1eiY8jgcXN6PyFyHK7KzVdhM/t3By/BTS+/GlthjM1BQuBcQ==",
+ "dependencies": {
+ "invariant": "^2.2.4",
+ "just-throttle": "^4.2.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ }
+ },
+ "node_modules/@rpldy/shared-ui": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/shared-ui/-/shared-ui-1.13.0.tgz",
+ "integrity": "sha512-/Lm1+783iLtlRsqCL6ZS+deD7NiSWw3xyFogIEpYfsAq040dQiIuVEZ7L8JWyZ5mnqqRQSuZuTjbbgDaf+XE7Q==",
+ "dependencies": {
+ "@rpldy/shared": "1.13.0",
+ "@rpldy/uploader": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/@rpldy/simple-state": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/simple-state/-/simple-state-1.13.0.tgz",
+ "integrity": "sha512-qAermR75birh9gObMDEkna3z8l3wZ8GGFV6/FbdyIXomnR8q1CwsUgma2gwKPtFAPBsdMCkz1m/yez6RwNLx+A==",
+ "dependencies": {
+ "@rpldy/shared": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ }
+ },
+ "node_modules/@rpldy/upload-button": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/upload-button/-/upload-button-1.13.0.tgz",
+ "integrity": "sha512-f5gvmRv6qCCT4bp/AwhLNf2PU/kvhw7szQqBRVXpfJE3tY7xi4RjBbErQnGN6a9FWl4HCaINhf4yAewzDKGmzg==",
+ "dependencies": {
+ "@rpldy/shared-ui": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/@rpldy/upload-drop-zone": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/upload-drop-zone/-/upload-drop-zone-1.13.0.tgz",
+ "integrity": "sha512-DO+HjRViBm4cOj6RCZBRHYNS1HwokDmDgtqM0XQTEQapZ1SsAnN3JwIljpg43Rn5QrDXTdhlg7J5jguCCTyM+A==",
+ "dependencies": {
+ "@rpldy/shared-ui": "1.13.0",
+ "html-dir-content": "^0.3.2"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/@rpldy/upload-paste": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/upload-paste/-/upload-paste-1.13.0.tgz",
+ "integrity": "sha512-MTCd7wvw2MfVVxjMPVfkNys0lcQC1wj2NkJVIqvrHKWHNzxFLOwBPqvGDZA6SdreJ/1CKXacqTo/jg0b6RvUog==",
+ "dependencies": {
+ "@rpldy/shared-ui": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/@rpldy/upload-preview": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/upload-preview/-/upload-preview-1.13.0.tgz",
+ "integrity": "sha512-YOOzoPBLetpFrXztDCpuIoYRg0SaFjwv8qP5uSy3EA+wBTLnhYnn+1dr+nzXz52uNtEzh+bwJTNNxSL+jBiWpQ==",
+ "dependencies": {
+ "@rpldy/shared": "1.13.0",
+ "@rpldy/shared-ui": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/@rpldy/uploader": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/uploader/-/uploader-1.13.0.tgz",
+ "integrity": "sha512-8nALd8lM+rFu2X5S0edRRSO/9z2U/gWSgIVQOsXYMixETe0OF/Arr19GvudrlcRv13nPxgr9x1AFUScT+6BZBw==",
+ "dependencies": {
+ "@rpldy/abort": "1.13.0",
+ "@rpldy/life-events": "1.13.0",
+ "@rpldy/raw-uploader": "1.13.0",
+ "@rpldy/sender": "1.13.0",
+ "@rpldy/shared": "1.13.0",
+ "@rpldy/simple-state": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ }
+ },
+ "node_modules/@rpldy/uploady": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/uploady/-/uploady-1.13.0.tgz",
+ "integrity": "sha512-OAq7BDe74HaAu2zZrDhGQZXeVlDhBzxjayN1YGlS6vCYL7C7hi9xWHOihcO2MCPulR7XlSeM/72RfKA65hNy4A==",
+ "dependencies": {
+ "@rpldy/life-events": "1.13.0",
+ "@rpldy/shared": "1.13.0",
+ "@rpldy/shared-ui": "1.13.0",
+ "@rpldy/uploader": "1.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-uploady"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -3828,21 +4038,6 @@
"form-data": "^4.0.0"
}
},
- "node_modules/@types/node-fetch/node_modules/form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -3932,6 +4127,12 @@
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true
},
+ "node_modules/@types/tmp": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz",
+ "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==",
+ "dev": true
+ },
"node_modules/@types/yargs": {
"version": "17.0.26",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.26.tgz",
@@ -4160,6 +4361,11 @@
"node": ">= 8"
}
},
+ "node_modules/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
+ },
"node_modules/aproba": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
@@ -4413,7 +4619,7 @@
"node_modules/assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
- "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
+ "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"dev": true,
"engines": {
"node": ">=0.8"
@@ -4526,7 +4732,7 @@
"node_modules/aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
- "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=",
+ "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
"dev": true,
"engines": {
"node": "*"
@@ -4541,9 +4747,9 @@
}
},
"node_modules/aws4": {
- "version": "1.11.0",
- "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz",
- "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==",
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
+ "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==",
"dev": true
},
"node_modules/babel-jest": {
@@ -4912,8 +5118,7 @@
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "dev": true
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/busboy": {
"version": "1.6.0",
@@ -5047,7 +5252,7 @@
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
- "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
+ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"dev": true
},
"node_modules/centra": {
@@ -5089,15 +5294,6 @@
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
},
- "node_modules/check-more-types": {
- "version": "2.24.0",
- "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz",
- "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=",
- "dev": true,
- "engines": {
- "node": ">= 0.8.0"
- }
- },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -5364,9 +5560,9 @@
}
},
"node_modules/commander": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
- "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
+ "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
"dev": true,
"engines": {
"node": ">= 6"
@@ -5395,6 +5591,20 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@@ -5649,52 +5859,52 @@
}
},
"node_modules/cypress": {
- "version": "9.7.0",
- "resolved": "https://registry.npmjs.org/cypress/-/cypress-9.7.0.tgz",
- "integrity": "sha512-+1EE1nuuuwIt/N1KXRR2iWHU+OiIt7H28jJDyyI4tiUftId/DrXYEwoDa5+kH2pki1zxnA0r6HrUGHV5eLbF5Q==",
+ "version": "15.8.0",
+ "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.8.0.tgz",
+ "integrity": "sha512-/k/KT8IIvcxarRSNb5AIhT1Yxx1pXsNIrL96Ht/c0pBOO/XcsjgjD4ZlG16V/08dRmvU/gT7PW8FBz5YV+ahsA==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
- "@cypress/request": "^2.88.10",
+ "@cypress/request": "^3.0.9",
"@cypress/xvfb": "^1.2.4",
- "@types/node": "^14.14.31",
"@types/sinonjs__fake-timers": "8.1.1",
"@types/sizzle": "^2.3.2",
+ "@types/tmp": "^0.2.3",
"arch": "^2.2.0",
"blob-util": "^2.0.2",
"bluebird": "^3.7.2",
- "buffer": "^5.6.0",
+ "buffer": "^5.7.1",
"cachedir": "^2.3.0",
"chalk": "^4.1.0",
- "check-more-types": "^2.24.0",
+ "ci-info": "^4.1.0",
"cli-cursor": "^3.1.0",
- "cli-table3": "~0.6.1",
- "commander": "^5.1.0",
+ "cli-table3": "0.6.1",
+ "commander": "^6.2.1",
"common-tags": "^1.8.0",
"dayjs": "^1.10.4",
- "debug": "^4.3.2",
+ "debug": "^4.3.4",
"enquirer": "^2.3.6",
- "eventemitter2": "^6.4.3",
+ "eventemitter2": "6.4.7",
"execa": "4.1.0",
"executable": "^4.1.1",
"extract-zip": "2.0.1",
"figures": "^3.2.0",
"fs-extra": "^9.1.0",
- "getos": "^3.2.1",
- "is-ci": "^3.0.0",
+ "hasha": "5.2.2",
"is-installed-globally": "~0.4.0",
- "lazy-ass": "^1.6.0",
"listr2": "^3.8.3",
"lodash": "^4.17.21",
"log-symbols": "^4.0.0",
- "minimist": "^1.2.6",
+ "minimist": "^1.2.8",
"ospath": "^1.2.2",
"pretty-bytes": "^5.6.0",
+ "process": "^0.11.10",
"proxy-from-env": "1.0.0",
"request-progress": "^3.0.0",
- "semver": "^7.3.2",
"supports-color": "^8.1.1",
- "tmp": "~0.2.1",
+ "systeminformation": "5.27.7",
+ "tmp": "~0.2.4",
+ "tree-kill": "1.2.2",
"untildify": "^4.0.0",
"yauzl": "^2.10.0"
},
@@ -5702,7 +5912,22 @@
"cypress": "bin/cypress"
},
"engines": {
- "node": ">=12.0.0"
+ "node": "^20.1.0 || ^22.0.0 || >=24.0.0"
+ }
+ },
+ "node_modules/cypress/node_modules/ci-info": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz",
+ "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "engines": {
+ "node": ">=8"
}
},
"node_modules/cypress/node_modules/fs-extra": {
@@ -5883,7 +6108,7 @@
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
- "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
+ "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
"dev": true,
"dependencies": {
"assert-plus": "^1.0.0"
@@ -6317,7 +6542,7 @@
"node_modules/ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
- "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
+ "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
"dev": true,
"dependencies": {
"jsbn": "~0.1.0",
@@ -7495,9 +7720,9 @@
}
},
"node_modules/eventemitter2": {
- "version": "6.4.5",
- "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.5.tgz",
- "integrity": "sha512-bXE7Dyc1i6oQElDG0jMRZJrRAn9QR2xyyFGmBdZleNmyQX0FqGYmhZIrIrpPfm/w//LTo4tVQGOGQcGCb5q9uw==",
+ "version": "6.4.7",
+ "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz",
+ "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==",
"dev": true
},
"node_modules/execa": {
@@ -7753,7 +7978,7 @@
"node_modules/extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
- "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
+ "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
"dev": true,
"engines": [
"node >=0.6.0"
@@ -7913,6 +8138,11 @@
"node": ">=10"
}
},
+ "node_modules/filesize-parser": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/filesize-parser/-/filesize-parser-1.5.1.tgz",
+ "integrity": "sha512-wRjdlQ5JM3WHZp6xpakIHQbkcGig8ANglYQDPcQSgZUN5kcDGOgmAwB0396BxzHxcl+kr+GLuusxBnsjdO6x9A=="
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -8076,24 +8306,25 @@
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
- "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=",
+ "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
"dev": true,
"engines": {
"node": "*"
}
},
"node_modules/form-data": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
- "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
- "dev": true,
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dependencies": {
"asynckit": "^0.4.0",
- "combined-stream": "^1.0.6",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
- "node": ">= 0.12"
+ "node": ">= 6"
}
},
"node_modules/formidable": {
@@ -8358,19 +8589,10 @@
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz",
"integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA=="
},
- "node_modules/getos": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz",
- "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==",
- "dev": true,
- "dependencies": {
- "async": "^3.2.0"
- }
- },
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
- "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
+ "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
"dev": true,
"dependencies": {
"assert-plus": "^1.0.0"
@@ -8729,6 +8951,31 @@
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
},
+ "node_modules/hasha": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz",
+ "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==",
+ "dev": true,
+ "dependencies": {
+ "is-stream": "^2.0.0",
+ "type-fest": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/hasha/node_modules/type-fest": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+ "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -8780,6 +9027,11 @@
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true
},
+ "node_modules/html-dir-content": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/html-dir-content/-/html-dir-content-0.3.2.tgz",
+ "integrity": "sha512-a1EJZbvBGmmFwk9VxFhEgaHkyXUXKTkw0jr0FCvXKCqgzO1H0wbFQbbzRA6FhR3twxAyjqVc80bzGHEmKrYsSw=="
+ },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -8821,14 +9073,14 @@
}
},
"node_modules/http-signature": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz",
- "integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==",
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz",
+ "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==",
"dev": true,
"dependencies": {
"assert-plus": "^1.0.0",
"jsprim": "^2.0.2",
- "sshpk": "^1.14.1"
+ "sshpk": "^1.18.0"
},
"engines": {
"node": ">=0.10"
@@ -9082,6 +9334,14 @@
"node": ">= 0.10"
}
},
+ "node_modules/invariant": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -9181,18 +9441,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/is-ci": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
- "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==",
- "dev": true,
- "dependencies": {
- "ci-info": "^3.2.0"
- },
- "bin": {
- "is-ci": "bin.js"
- }
- },
"node_modules/is-class-hotfix": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/is-class-hotfix/-/is-class-hotfix-0.0.6.tgz",
@@ -9538,7 +9786,7 @@
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
- "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
+ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"dev": true
},
"node_modules/is-unicode-supported": {
@@ -10385,7 +10633,7 @@
"node_modules/jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
- "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=",
+ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
"dev": true
},
"node_modules/jsesc": {
@@ -10487,6 +10735,11 @@
"node": ">=4.0"
}
},
+ "node_modules/just-throttle": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/just-throttle/-/just-throttle-4.2.0.tgz",
+ "integrity": "sha512-/iAZv1953JcExpvsywaPKjSzfTiCLqeguUTE6+VmK15mOcwxBx7/FHrVvS4WEErMR03TRazH8kcBSHqMagYIYg=="
+ },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -10719,15 +10972,6 @@
"node": ">= 0.8"
}
},
- "node_modules/lazy-ass": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
- "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=",
- "dev": true,
- "engines": {
- "node": "> 0.8"
- }
- },
"node_modules/lcov-parse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz",
@@ -11272,11 +11516,11 @@
}
},
"node_modules/mkdirp": {
- "version": "0.5.5",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
- "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dependencies": {
- "minimist": "^1.2.5"
+ "minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
@@ -11301,6 +11545,23 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
+ "node_modules/multer": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
+ "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.6.0",
+ "concat-stream": "^2.0.0",
+ "mkdirp": "^0.5.6",
+ "object-assign": "^4.1.1",
+ "type-is": "^1.6.18",
+ "xtend": "^4.0.2"
+ },
+ "engines": {
+ "node": ">= 10.16.0"
+ }
+ },
"node_modules/mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
@@ -12227,7 +12488,7 @@
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
- "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"dev": true
},
"node_modules/pg-connection-string": {
@@ -12842,12 +13103,6 @@
"integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=",
"dev": true
},
- "node_modules/psl": {
- "version": "1.9.0",
- "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
- "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
- "dev": true
- },
"node_modules/pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -12899,12 +13154,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/querystringify": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
- "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
- "dev": true
- },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -13348,12 +13597,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/requires-port": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
- "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
- "dev": true
- },
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@@ -14112,9 +14355,9 @@
}
},
"node_modules/sshpk": {
- "version": "1.17.0",
- "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz",
- "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==",
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
+ "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
"dev": true,
"dependencies": {
"asn1": "~0.2.3",
@@ -14799,6 +15042,32 @@
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/systeminformation": {
+ "version": "5.27.7",
+ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.7.tgz",
+ "integrity": "sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==",
+ "dev": true,
+ "os": [
+ "darwin",
+ "linux",
+ "win32",
+ "freebsd",
+ "openbsd",
+ "netbsd",
+ "sunos",
+ "android"
+ ],
+ "bin": {
+ "systeminformation": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "Buy me a coffee",
+ "url": "https://www.buymeacoffee.com/systeminfo"
+ }
+ },
"node_modules/tabbable": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz",
@@ -15050,6 +15319,24 @@
"node": "*"
}
},
+ "node_modules/tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "dependencies": {
+ "tldts-core": "^6.1.86"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true
+ },
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@@ -15152,27 +15439,24 @@
}
},
"node_modules/tough-cookie": {
- "version": "4.1.4",
- "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
- "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"dependencies": {
- "psl": "^1.1.33",
- "punycode": "^2.1.1",
- "universalify": "^0.2.0",
- "url-parse": "^1.5.3"
+ "tldts": "^6.1.32"
},
"engines": {
- "node": ">=6"
+ "node": ">=16"
}
},
- "node_modules/tough-cookie/node_modules/punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
- "engines": {
- "node": ">=6"
+ "bin": {
+ "tree-kill": "cli.js"
}
},
"node_modules/ts-interface-checker": {
@@ -15229,7 +15513,7 @@
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
- "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dev": true,
"dependencies": {
"safe-buffer": "^5.0.1"
@@ -15376,6 +15660,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
+ },
"node_modules/typescript": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
@@ -15420,15 +15709,6 @@
"node": ">=18.17"
}
},
- "node_modules/universalify": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
- "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
- "dev": true,
- "engines": {
- "node": ">= 4.0.0"
- }
- },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -15543,16 +15823,6 @@
"url": "https://opencollective.com/webpack"
}
},
- "node_modules/url-parse": {
- "version": "1.5.10",
- "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
- "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
- "dev": true,
- "dependencies": {
- "querystringify": "^2.1.1",
- "requires-port": "^1.0.0"
- }
- },
"node_modules/url/node_modules/punycode": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
@@ -15755,7 +16025,7 @@
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
- "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
+ "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
"dev": true,
"engines": [
"node >=0.6.0"
@@ -15769,7 +16039,7 @@
"node_modules/verror/node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
- "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
+ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"dev": true
},
"node_modules/walker": {
@@ -16860,9 +17130,9 @@
}
},
"@cypress/request": {
- "version": "2.88.12",
- "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.12.tgz",
- "integrity": "sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA==",
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.9.tgz",
+ "integrity": "sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==",
"dev": true,
"requires": {
"aws-sign2": "~0.7.0",
@@ -16871,27 +17141,27 @@
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
- "form-data": "~2.3.2",
- "http-signature": "~1.3.6",
+ "form-data": "~4.0.4",
+ "http-signature": "~1.4.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19",
"performance-now": "^2.1.0",
- "qs": "~6.10.3",
+ "qs": "6.14.0",
"safe-buffer": "^5.1.2",
- "tough-cookie": "^4.1.3",
+ "tough-cookie": "^5.0.0",
"tunnel-agent": "^0.6.0",
"uuid": "^8.3.2"
},
"dependencies": {
"qs": {
- "version": "6.10.4",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz",
- "integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==",
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dev": true,
"requires": {
- "side-channel": "^1.0.4"
+ "side-channel": "^1.1.0"
}
},
"uuid": {
@@ -17877,6 +18147,11 @@
"vary": "^1.1.2"
}
},
+ "@koa/multer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@koa/multer/-/multer-4.0.0.tgz",
+ "integrity": "sha512-BY6hys3WVX1yL/gcfKWu94z1fJ6ayG1DEEw/s82DnulkaTbumwjF6XqSfNLKFcs8lnJb2QfMJ4DyK4bmF1NDZw=="
+ },
"@koa/router": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/@koa/router/-/router-13.1.0.tgz",
@@ -18657,6 +18932,125 @@
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.23.1.tgz",
"integrity": "sha512-5d+3HbFDxGZjhbMBeFHRQhexMFt4pUce3okyRtUVKbbedQFUrtXSBg9VszgF2RTeQDKDkMCIQDtz5ccP/Lk1gw=="
},
+ "@rpldy/abort": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/abort/-/abort-1.13.0.tgz",
+ "integrity": "sha512-gPzbKWCgSSM72LtQbytbyIuUBwpwHLQ80xbl6QlSltsPKhWGp/wuTxnrP5fm+rI7kkDoEuxuVG247wZb5C4afw==",
+ "requires": {
+ "@rpldy/raw-uploader": "1.13.0",
+ "@rpldy/shared": "1.13.0",
+ "@rpldy/simple-state": "1.13.0"
+ }
+ },
+ "@rpldy/life-events": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/life-events/-/life-events-1.13.0.tgz",
+ "integrity": "sha512-5hrTeC17Vb7jVFH+8YXhDkmM00jJyguJPHOX0kYxYnR1LZqVFnR/owA/8unC1VmixtCBhs4fojK7rXe+axXBJw==",
+ "requires": {
+ "@rpldy/shared": "1.13.0"
+ }
+ },
+ "@rpldy/raw-uploader": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/raw-uploader/-/raw-uploader-1.13.0.tgz",
+ "integrity": "sha512-iHvJB1Afeev6My/1NDJIZDwR3Ng4NHEfTX72IwdMJgdcv20+LE093Pt1+v3haYT036RPhkQwE2ta7+Ux7NdRFQ==",
+ "requires": {
+ "@rpldy/life-events": "1.13.0",
+ "@rpldy/shared": "1.13.0"
+ }
+ },
+ "@rpldy/sender": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/sender/-/sender-1.13.0.tgz",
+ "integrity": "sha512-cnImc4A/upy6FGM6eYd7yumv7hfbg/UDEyqkMovgyEgtBx2M6jBlQWvImjrwaH45bh8dSt/60N/QVcQUn1ghbA==",
+ "requires": {
+ "@rpldy/shared": "1.13.0"
+ }
+ },
+ "@rpldy/shared": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/shared/-/shared-1.13.0.tgz",
+ "integrity": "sha512-WF9LkjWLGeAs888cLbhneNDnbRUFrwIu/CQJ2E1eiY8jgcXN6PyFyHK7KzVdhM/t3By/BTS+/GlthjM1BQuBcQ==",
+ "requires": {
+ "invariant": "^2.2.4",
+ "just-throttle": "^4.2.0"
+ }
+ },
+ "@rpldy/shared-ui": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/shared-ui/-/shared-ui-1.13.0.tgz",
+ "integrity": "sha512-/Lm1+783iLtlRsqCL6ZS+deD7NiSWw3xyFogIEpYfsAq040dQiIuVEZ7L8JWyZ5mnqqRQSuZuTjbbgDaf+XE7Q==",
+ "requires": {
+ "@rpldy/shared": "1.13.0",
+ "@rpldy/uploader": "1.13.0"
+ }
+ },
+ "@rpldy/simple-state": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/simple-state/-/simple-state-1.13.0.tgz",
+ "integrity": "sha512-qAermR75birh9gObMDEkna3z8l3wZ8GGFV6/FbdyIXomnR8q1CwsUgma2gwKPtFAPBsdMCkz1m/yez6RwNLx+A==",
+ "requires": {
+ "@rpldy/shared": "1.13.0"
+ }
+ },
+ "@rpldy/upload-button": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/upload-button/-/upload-button-1.13.0.tgz",
+ "integrity": "sha512-f5gvmRv6qCCT4bp/AwhLNf2PU/kvhw7szQqBRVXpfJE3tY7xi4RjBbErQnGN6a9FWl4HCaINhf4yAewzDKGmzg==",
+ "requires": {
+ "@rpldy/shared-ui": "1.13.0"
+ }
+ },
+ "@rpldy/upload-drop-zone": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/upload-drop-zone/-/upload-drop-zone-1.13.0.tgz",
+ "integrity": "sha512-DO+HjRViBm4cOj6RCZBRHYNS1HwokDmDgtqM0XQTEQapZ1SsAnN3JwIljpg43Rn5QrDXTdhlg7J5jguCCTyM+A==",
+ "requires": {
+ "@rpldy/shared-ui": "1.13.0",
+ "html-dir-content": "^0.3.2"
+ }
+ },
+ "@rpldy/upload-paste": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/upload-paste/-/upload-paste-1.13.0.tgz",
+ "integrity": "sha512-MTCd7wvw2MfVVxjMPVfkNys0lcQC1wj2NkJVIqvrHKWHNzxFLOwBPqvGDZA6SdreJ/1CKXacqTo/jg0b6RvUog==",
+ "requires": {
+ "@rpldy/shared-ui": "1.13.0"
+ }
+ },
+ "@rpldy/upload-preview": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/upload-preview/-/upload-preview-1.13.0.tgz",
+ "integrity": "sha512-YOOzoPBLetpFrXztDCpuIoYRg0SaFjwv8qP5uSy3EA+wBTLnhYnn+1dr+nzXz52uNtEzh+bwJTNNxSL+jBiWpQ==",
+ "requires": {
+ "@rpldy/shared": "1.13.0",
+ "@rpldy/shared-ui": "1.13.0"
+ }
+ },
+ "@rpldy/uploader": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/uploader/-/uploader-1.13.0.tgz",
+ "integrity": "sha512-8nALd8lM+rFu2X5S0edRRSO/9z2U/gWSgIVQOsXYMixETe0OF/Arr19GvudrlcRv13nPxgr9x1AFUScT+6BZBw==",
+ "requires": {
+ "@rpldy/abort": "1.13.0",
+ "@rpldy/life-events": "1.13.0",
+ "@rpldy/raw-uploader": "1.13.0",
+ "@rpldy/sender": "1.13.0",
+ "@rpldy/shared": "1.13.0",
+ "@rpldy/simple-state": "1.13.0"
+ }
+ },
+ "@rpldy/uploady": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/@rpldy/uploady/-/uploady-1.13.0.tgz",
+ "integrity": "sha512-OAq7BDe74HaAu2zZrDhGQZXeVlDhBzxjayN1YGlS6vCYL7C7hi9xWHOihcO2MCPulR7XlSeM/72RfKA65hNy4A==",
+ "requires": {
+ "@rpldy/life-events": "1.13.0",
+ "@rpldy/shared": "1.13.0",
+ "@rpldy/shared-ui": "1.13.0",
+ "@rpldy/uploader": "1.13.0"
+ }
+ },
"@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -18920,20 +19314,6 @@
"requires": {
"@types/node": "*",
"form-data": "^4.0.0"
- },
- "dependencies": {
- "form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
- "requires": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
- }
- }
}
},
"@types/parse-json": {
@@ -19023,6 +19403,12 @@
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
"dev": true
},
+ "@types/tmp": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz",
+ "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==",
+ "dev": true
+ },
"@types/yargs": {
"version": "17.0.26",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.26.tgz",
@@ -19189,6 +19575,11 @@
"picomatch": "^2.0.4"
}
},
+ "append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="
+ },
"aproba": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
@@ -19377,7 +19768,7 @@
"assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
- "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
+ "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"dev": true
},
"assertion-error": {
@@ -19445,7 +19836,7 @@
"aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
- "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=",
+ "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
"dev": true
},
"aws-ssl-profiles": {
@@ -19454,9 +19845,9 @@
"integrity": "sha512-+H+kuK34PfMaI9PNU/NSjBKL5hh/KDM9J72kwYeYEm0A8B1AC4fuCy3qsjnA7lxklgyXsB68yn8Z2xoZEjgwCQ=="
},
"aws4": {
- "version": "1.11.0",
- "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz",
- "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==",
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
+ "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==",
"dev": true
},
"babel-jest": {
@@ -19725,8 +20116,7 @@
"buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
- "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
- "dev": true
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"busboy": {
"version": "1.6.0",
@@ -19809,7 +20199,7 @@
"caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
- "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
+ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"dev": true
},
"centra": {
@@ -19840,12 +20230,6 @@
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
},
- "check-more-types": {
- "version": "2.24.0",
- "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz",
- "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=",
- "dev": true
- },
"chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -20040,9 +20424,9 @@
}
},
"commander": {
- "version": "5.1.0",
- "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
- "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
+ "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
"dev": true
},
"common-tags": {
@@ -20062,6 +20446,17 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
+ "concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "requires": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
"console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@@ -20253,55 +20648,61 @@
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
},
"cypress": {
- "version": "9.7.0",
- "resolved": "https://registry.npmjs.org/cypress/-/cypress-9.7.0.tgz",
- "integrity": "sha512-+1EE1nuuuwIt/N1KXRR2iWHU+OiIt7H28jJDyyI4tiUftId/DrXYEwoDa5+kH2pki1zxnA0r6HrUGHV5eLbF5Q==",
+ "version": "15.8.0",
+ "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.8.0.tgz",
+ "integrity": "sha512-/k/KT8IIvcxarRSNb5AIhT1Yxx1pXsNIrL96Ht/c0pBOO/XcsjgjD4ZlG16V/08dRmvU/gT7PW8FBz5YV+ahsA==",
"dev": true,
"requires": {
- "@cypress/request": "^2.88.10",
+ "@cypress/request": "^3.0.9",
"@cypress/xvfb": "^1.2.4",
- "@types/node": "^14.14.31",
"@types/sinonjs__fake-timers": "8.1.1",
"@types/sizzle": "^2.3.2",
+ "@types/tmp": "^0.2.3",
"arch": "^2.2.0",
"blob-util": "^2.0.2",
"bluebird": "^3.7.2",
- "buffer": "^5.6.0",
+ "buffer": "^5.7.1",
"cachedir": "^2.3.0",
"chalk": "^4.1.0",
- "check-more-types": "^2.24.0",
+ "ci-info": "^4.1.0",
"cli-cursor": "^3.1.0",
- "cli-table3": "~0.6.1",
- "commander": "^5.1.0",
+ "cli-table3": "0.6.1",
+ "commander": "^6.2.1",
"common-tags": "^1.8.0",
"dayjs": "^1.10.4",
- "debug": "^4.3.2",
+ "debug": "^4.3.4",
"enquirer": "^2.3.6",
- "eventemitter2": "^6.4.3",
+ "eventemitter2": "6.4.7",
"execa": "4.1.0",
"executable": "^4.1.1",
"extract-zip": "2.0.1",
"figures": "^3.2.0",
"fs-extra": "^9.1.0",
- "getos": "^3.2.1",
- "is-ci": "^3.0.0",
+ "hasha": "5.2.2",
"is-installed-globally": "~0.4.0",
- "lazy-ass": "^1.6.0",
"listr2": "^3.8.3",
"lodash": "^4.17.21",
"log-symbols": "^4.0.0",
- "minimist": "^1.2.6",
+ "minimist": "^1.2.8",
"ospath": "^1.2.2",
"pretty-bytes": "^5.6.0",
+ "process": "^0.11.10",
"proxy-from-env": "1.0.0",
"request-progress": "^3.0.0",
- "semver": "^7.3.2",
"supports-color": "^8.1.1",
- "tmp": "~0.2.1",
+ "systeminformation": "5.27.7",
+ "tmp": "~0.2.4",
+ "tree-kill": "1.2.2",
"untildify": "^4.0.0",
"yauzl": "^2.10.0"
},
"dependencies": {
+ "ci-info": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz",
+ "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==",
+ "dev": true
+ },
"fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -20437,7 +20838,7 @@
"dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
- "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
+ "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
"dev": true,
"requires": {
"assert-plus": "^1.0.0"
@@ -20756,7 +21157,7 @@
"ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
- "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
+ "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
"dev": true,
"requires": {
"jsbn": "~0.1.0",
@@ -21645,9 +22046,9 @@
}
},
"eventemitter2": {
- "version": "6.4.5",
- "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.5.tgz",
- "integrity": "sha512-bXE7Dyc1i6oQElDG0jMRZJrRAn9QR2xyyFGmBdZleNmyQX0FqGYmhZIrIrpPfm/w//LTo4tVQGOGQcGCb5q9uw==",
+ "version": "6.4.7",
+ "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz",
+ "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==",
"dev": true
},
"execa": {
@@ -21839,7 +22240,7 @@
"extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
- "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
+ "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
"dev": true
},
"faker": {
@@ -21974,6 +22375,11 @@
}
}
},
+ "filesize-parser": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/filesize-parser/-/filesize-parser-1.5.1.tgz",
+ "integrity": "sha512-wRjdlQ5JM3WHZp6xpakIHQbkcGig8ANglYQDPcQSgZUN5kcDGOgmAwB0396BxzHxcl+kr+GLuusxBnsjdO6x9A=="
+ },
"fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -22093,17 +22499,18 @@
"forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
- "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=",
+ "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
"dev": true
},
"form-data": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
- "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
- "dev": true,
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"requires": {
"asynckit": "^0.4.0",
- "combined-stream": "^1.0.6",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
"mime-types": "^2.1.12"
}
},
@@ -22285,19 +22692,10 @@
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz",
"integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA=="
},
- "getos": {
- "version": "3.2.1",
- "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz",
- "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==",
- "dev": true,
- "requires": {
- "async": "^3.2.0"
- }
- },
"getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
- "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
+ "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
"dev": true,
"requires": {
"assert-plus": "^1.0.0"
@@ -22533,6 +22931,24 @@
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
},
+ "hasha": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz",
+ "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==",
+ "dev": true,
+ "requires": {
+ "is-stream": "^2.0.0",
+ "type-fest": "^0.8.0"
+ },
+ "dependencies": {
+ "type-fest": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+ "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+ "dev": true
+ }
+ }
+ },
"hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -22576,6 +22992,11 @@
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true
},
+ "html-dir-content": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/html-dir-content/-/html-dir-content-0.3.2.tgz",
+ "integrity": "sha512-a1EJZbvBGmmFwk9VxFhEgaHkyXUXKTkw0jr0FCvXKCqgzO1H0wbFQbbzRA6FhR3twxAyjqVc80bzGHEmKrYsSw=="
+ },
"html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -22608,14 +23029,14 @@
}
},
"http-signature": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz",
- "integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==",
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz",
+ "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==",
"dev": true,
"requires": {
"assert-plus": "^1.0.0",
"jsprim": "^2.0.2",
- "sshpk": "^1.14.1"
+ "sshpk": "^1.18.0"
}
},
"https-proxy-agent": {
@@ -22795,6 +23216,14 @@
"resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw=="
},
+ "invariant": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+ "requires": {
+ "loose-envify": "^1.0.0"
+ }
+ },
"ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -22857,15 +23286,6 @@
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="
},
- "is-ci": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
- "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==",
- "dev": true,
- "requires": {
- "ci-info": "^3.2.0"
- }
- },
"is-class-hotfix": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/is-class-hotfix/-/is-class-hotfix-0.0.6.tgz",
@@ -23089,7 +23509,7 @@
"is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
- "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
+ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"dev": true
},
"is-unicode-supported": {
@@ -23728,7 +24148,7 @@
"jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
- "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=",
+ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
"dev": true
},
"jsesc": {
@@ -23809,6 +24229,11 @@
"object.assign": "^4.1.2"
}
},
+ "just-throttle": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/just-throttle/-/just-throttle-4.2.0.tgz",
+ "integrity": "sha512-/iAZv1953JcExpvsywaPKjSzfTiCLqeguUTE6+VmK15mOcwxBx7/FHrVvS4WEErMR03TRazH8kcBSHqMagYIYg=="
+ },
"jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -23985,12 +24410,6 @@
}
}
},
- "lazy-ass": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
- "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=",
- "dev": true
- },
"lcov-parse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz",
@@ -24390,11 +24809,11 @@
}
},
"mkdirp": {
- "version": "0.5.5",
- "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
- "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"requires": {
- "minimist": "^1.2.5"
+ "minimist": "^1.2.6"
}
},
"mockdate": {
@@ -24413,6 +24832,20 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
+ "multer": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
+ "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
+ "requires": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.6.0",
+ "concat-stream": "^2.0.0",
+ "mkdirp": "^0.5.6",
+ "object-assign": "^4.1.1",
+ "type-is": "^1.6.18",
+ "xtend": "^4.0.2"
+ }
+ },
"mute-stream": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
@@ -25079,7 +25512,7 @@
"performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
- "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
+ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"dev": true
},
"pg-connection-string": {
@@ -25524,12 +25957,6 @@
"integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=",
"dev": true
},
- "psl": {
- "version": "1.9.0",
- "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
- "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==",
- "dev": true
- },
"pstree.remy": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -25565,12 +25992,6 @@
"side-channel": "^1.0.6"
}
},
- "querystringify": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
- "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
- "dev": true
- },
"queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -25863,12 +26284,6 @@
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true
},
- "requires-port": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
- "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
- "dev": true
- },
"resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
@@ -26442,9 +26857,9 @@
}
},
"sshpk": {
- "version": "1.17.0",
- "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz",
- "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==",
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
+ "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
"dev": true,
"requires": {
"asn1": "~0.2.3",
@@ -26901,6 +27316,12 @@
"use-sync-external-store": "^1.4.0"
}
},
+ "systeminformation": {
+ "version": "5.27.7",
+ "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.7.tgz",
+ "integrity": "sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==",
+ "dev": true
+ },
"tabbable": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz",
@@ -27107,6 +27528,21 @@
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz",
"integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="
},
+ "tldts": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dev": true,
+ "requires": {
+ "tldts-core": "^6.1.86"
+ }
+ },
+ "tldts-core": {
+ "version": "6.1.86",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
+ "dev": true
+ },
"tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@@ -27170,25 +27606,20 @@
"dev": true
},
"tough-cookie": {
- "version": "4.1.4",
- "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
- "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"requires": {
- "psl": "^1.1.33",
- "punycode": "^2.1.1",
- "universalify": "^0.2.0",
- "url-parse": "^1.5.3"
- },
- "dependencies": {
- "punycode": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
- "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
- "dev": true
- }
+ "tldts": "^6.1.32"
}
},
+ "tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true
+ },
"ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -27236,7 +27667,7 @@
"tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
- "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dev": true,
"requires": {
"safe-buffer": "^5.0.1"
@@ -27349,6 +27780,11 @@
"reflect.getprototypeof": "^1.0.6"
}
},
+ "typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
+ },
"typescript": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
@@ -27377,12 +27813,6 @@
"resolved": "https://registry.npmjs.org/undici/-/undici-6.22.0.tgz",
"integrity": "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="
},
- "universalify": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
- "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
- "dev": true
- },
"unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -27461,16 +27891,6 @@
}
}
},
- "url-parse": {
- "version": "1.5.10",
- "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
- "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
- "dev": true,
- "requires": {
- "querystringify": "^2.1.1",
- "requires-port": "^1.0.0"
- }
- },
"use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
@@ -27589,7 +28009,7 @@
"verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
- "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
+ "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
"dev": true,
"requires": {
"assert-plus": "^1.0.0",
@@ -27600,7 +28020,7 @@
"core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
- "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
+ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"dev": true
}
}
diff --git a/package.json b/package.json
index 336ab6398..a57197a5e 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@graphql-tools/utils": "10.8.6",
"@headlessui/react": "2.2.0",
"@koa/cors": "^5.0.0",
+ "@koa/multer": "^4.0.0",
"@koa/router": "13.1.0",
"@leichtgewicht/ip-codec": "2.0.5",
"@nateradebaugh/react-datetime": "4.6.0",
@@ -29,6 +30,11 @@
"@oclif/core": "4.2.10",
"@oclif/plugin-help": "6.2.27",
"@odiffey/discord-markdown": "^3.3.0",
+ "@rpldy/upload-button": "^1.13.0",
+ "@rpldy/upload-drop-zone": "^1.13.0",
+ "@rpldy/upload-paste": "^1.13.0",
+ "@rpldy/upload-preview": "^1.13.0",
+ "@rpldy/uploady": "^1.13.0",
"@universemc/react-palette": "1.0.3",
"argon2": "0.29.1",
"autoprefixer": "10.4.21",
@@ -40,6 +46,7 @@
"dotenv": "16.4.7",
"edit-dotenv": "1.0.4",
"es6-dynamic-template": "2.0.0",
+ "filesize-parser": "^1.5.1",
"git-revision-webpack-plugin": "5.0.0",
"graphql": "16.10.0",
"graphql-constraint-directive": "5.4.3",
@@ -62,6 +69,7 @@
"koa-session": "6.4.0",
"lodash-es": "4.17.21",
"memoizee": "0.4.17",
+ "multer": "^2.0.2",
"mysql2": "3.14.0",
"nanoid": "3.3.11",
"next": "14.2.35",
@@ -98,7 +106,7 @@
"@next/eslint-plugin-next": "14.2.26",
"@oclif/test": "4.1.15",
"coveralls-next": "4.2.2",
- "cypress": "9.7.0",
+ "cypress": "15.8.0",
"eslint-plugin-cypress": "4.2.0",
"eslint-plugin-react": "7.37.5",
"faker": "5.5.3",
diff --git a/pages/admin/documents.js b/pages/admin/documents.js
new file mode 100644
index 000000000..32eb4ff21
--- /dev/null
+++ b/pages/admin/documents.js
@@ -0,0 +1,99 @@
+import { useState } from 'react'
+import Loader from '../../components/Loader'
+import ErrorLayout from '../../components/ErrorLayout'
+import AdminLayout from '../../components/AdminLayout'
+import { useApi } from '../../utils'
+import AdminHeader from '../../components/admin/AdminHeader'
+import DocumentsTable from '../../components/admin/DocumentsTable'
+import EmptyState from '../../components/EmptyState'
+import Pagination from '../../components/Pagination'
+import { FiImage } from 'react-icons/fi'
+
+const LIMIT = 25
+
+export default function Page () {
+ const [tableState, setTableState] = useState({ offset: 0, player: null, dateStart: null, dateEnd: null })
+ const { loading, data, errors, mutate } = useApi({
+ query: `query listDocuments($limit: Int, $offset: Int, $player: UUID, $dateStart: Timestamp, $dateEnd: Timestamp) {
+ listDocuments(limit: $limit, offset: $offset, player: $player, dateStart: $dateStart, dateEnd: $dateEnd) {
+ total
+ records {
+ id
+ filename
+ mimeType
+ size
+ width
+ height
+ created
+ player {
+ id
+ name
+ }
+ usages {
+ type
+ id
+ commentId
+ serverId
+ label
+ }
+ acl {
+ delete
+ }
+ }
+ }
+ }`,
+ variables: { limit: LIMIT, ...tableState }
+ })
+
+ const handlePageChange = (page) => {
+ setTableState(prev => ({ ...prev, offset: (page - 1) * LIMIT }))
+ }
+
+ const handleDelete = (docId) => {
+ const records = data.listDocuments.records.filter(d => d.id !== docId)
+ mutate({ ...data, listDocuments: { ...data.listDocuments, total: data.listDocuments.total - 1, records } }, false)
+ }
+
+ if (loading) return
+ if (errors || !data) return
+
+ const total = data?.listDocuments?.total || 0
+ const records = data?.listDocuments?.records || []
+ const totalPages = Math.ceil(total / LIMIT)
+ const currentPage = Math.floor(tableState.offset / LIMIT) + 1
+
+ return (
+
+
+
+ {total} document{total === 1 ? '' : 's'} uploaded
+
+
+
+ {records.length > 0
+ ? (
+ <>
+
+ {totalPages > 1 && (
+
+ )}
+ >
+ )
+ : (
+
+
+
+ )}
+
+
+ )
+}
diff --git a/pages/appeal/punishment/[serverId]/ban/[id].js b/pages/appeal/punishment/[serverId]/ban/[id].js
index 4f29c5ff7..084373c64 100644
--- a/pages/appeal/punishment/[serverId]/ban/[id].js
+++ b/pages/appeal/punishment/[serverId]/ban/[id].js
@@ -11,10 +11,10 @@ import Button from '../../../../../components/Button'
export default function Page () {
const router = useRouter()
-
- useUser({ redirectIfFound: false, redirectTo: '/' })
+ const { hasServerPermission } = useUser({ redirectIfFound: false, redirectTo: '/' })
const { id, serverId } = router.query
+ const canUpload = hasServerPermission('player.appeals', 'attachment.create', serverId)
const { loading, data, errors } = useApi({
query: !serverId || !id
? null
@@ -66,6 +66,7 @@ export default function Page () {
{data?.playerBan && ({ input: { reason: input.reason, type: 'PlayerBan', serverId, punishmentId: id } })}
onFinished={({ createAppeal }) => {
router.push(`/appeals/${createAppeal.id}`)
diff --git a/pages/appeal/punishment/[serverId]/mute/[id].js b/pages/appeal/punishment/[serverId]/mute/[id].js
index 52c47a621..ae09ae0bc 100644
--- a/pages/appeal/punishment/[serverId]/mute/[id].js
+++ b/pages/appeal/punishment/[serverId]/mute/[id].js
@@ -11,10 +11,10 @@ import Button from '../../../../../components/Button'
export default function Page () {
const router = useRouter()
-
- useUser({ redirectIfFound: false, redirectTo: '/' })
+ const { hasServerPermission } = useUser({ redirectIfFound: false, redirectTo: '/' })
const { id, serverId } = router.query
+ const canUpload = hasServerPermission('player.appeals', 'attachment.create', serverId)
const { loading, data, errors } = useApi({
query: !serverId || !id
? null
@@ -66,6 +66,7 @@ export default function Page () {
{data?.playerMute && ({ input: { reason: input.reason, type: 'PlayerMute', serverId, punishmentId: id } })}
onFinished={({ createAppeal }) => {
router.push(`/appeals/${createAppeal.id}`)
diff --git a/pages/appeal/punishment/[serverId]/warning/[id].js b/pages/appeal/punishment/[serverId]/warning/[id].js
index 5111900e0..710236937 100644
--- a/pages/appeal/punishment/[serverId]/warning/[id].js
+++ b/pages/appeal/punishment/[serverId]/warning/[id].js
@@ -11,10 +11,10 @@ import Button from '../../../../../components/Button'
export default function Page () {
const router = useRouter()
-
- useUser({ redirectIfFound: false, redirectTo: '/' })
+ const { hasServerPermission } = useUser({ redirectIfFound: false, redirectTo: '/' })
const { id, serverId } = router.query
+ const canUpload = hasServerPermission('player.appeals', 'attachment.create', serverId)
const { loading, data, errors } = useApi({
query: !serverId || !id
? null
@@ -66,6 +66,7 @@ export default function Page () {
{data?.playerWarning && ({ input: { reason: input.reason, type: 'PlayerWarning', serverId, punishmentId: id } })}
onFinished={({ createAppeal }) => {
router.push(`/appeals/${createAppeal.id}`)
diff --git a/pages/appeals/[id].js b/pages/appeals/[id].js
index 58a1c7c3b..aafb04782 100644
--- a/pages/appeals/[id].js
+++ b/pages/appeals/[id].js
@@ -62,6 +62,17 @@ export default function Page () {
viewerSubscription {
state
}
+ documents {
+ id
+ }
+ initialDocuments {
+ id
+ filename
+ mimeType
+ acl {
+ delete
+ }
+ }
}
}`
})
diff --git a/pages/reports/[serverId]/[id].js b/pages/reports/[serverId]/[id].js
index dc89d13cc..748a49ef7 100644
--- a/pages/reports/[serverId]/[id].js
+++ b/pages/reports/[serverId]/[id].js
@@ -89,6 +89,9 @@ export default function Page () {
viewerSubscription {
state
}
+ documents {
+ id
+ }
}
}`
})
diff --git a/server.js b/server.js
index cd7ca93ae..39e2389ec 100644
--- a/server.js
+++ b/server.js
@@ -24,6 +24,7 @@ const logger = require('pino')(
})
const createApp = require('./server/app')
const { setupPool, setupServersPool } = require('./server/connections')
+const { cleanupOrphanDocuments } = require('./server/data/cleanup-documents')
const port = process.env.PORT || 3000
const dbConfig = {
host: process.env.DB_HOST,
@@ -40,6 +41,10 @@ const dbConfig = {
const serversPool = await setupServersPool({ dbPool, logger })
const app = await createApp({ dbPool, logger, serversPool, disableUI: process.env.DISABLE_UI === 'true' })
+ // Cleanup orphan documents on startup and every 6 hours
+ cleanupOrphanDocuments(dbPool, logger)
+ setInterval(() => cleanupOrphanDocuments(dbPool, logger), 6 * 60 * 60 * 1000)
+
if (process.env.HOSTNAME) {
app.listen(port, process.env.HOSTNAME, () => logger.info(`Listening on ${process.env.HOSTNAME}:${port}`))
} else {
diff --git a/server/data/cleanup-documents.js b/server/data/cleanup-documents.js
new file mode 100644
index 000000000..8a6d523f7
--- /dev/null
+++ b/server/data/cleanup-documents.js
@@ -0,0 +1,70 @@
+const path = require('path')
+const fs = require('fs').promises
+
+const UPLOAD_PATH = process.env.UPLOAD_PATH || './uploads/documents'
+const CLEANUP_AGE_HOURS = parseInt(process.env.DOCUMENT_CLEANUP_AGE_HOURS || '24', 10)
+
+async function cleanupOrphanDocuments (dbPool, logger) {
+ try {
+ const cutoffTime = Math.floor(Date.now() / 1000) - (CLEANUP_AGE_HOURS * 60 * 60)
+
+ // Find orphan documents: not linked to any appeal or report comment, older than cutoff
+ const orphanDocuments = await dbPool('bm_web_documents as d')
+ .leftJoin('bm_web_appeal_documents as ad', 'd.id', 'ad.document_id')
+ .leftJoin('bm_web_report_comment_documents as rcd', 'd.id', 'rcd.document_id')
+ .whereNull('ad.document_id')
+ .whereNull('rcd.document_id')
+ .where('d.created', '<', cutoffTime)
+ .select('d.id', 'd.content_hash')
+
+ if (orphanDocuments.length === 0) {
+ return
+ }
+
+ logger.info(`Cleaning up ${orphanDocuments.length} orphan document(s)`)
+
+ // Collect content hashes before deleting documents
+ const contentHashes = [...new Set(orphanDocuments.map(d => d.content_hash))]
+
+ // Delete orphan document records
+ const orphanIds = orphanDocuments.map(d => d.id)
+ await dbPool('bm_web_documents').whereIn('id', orphanIds).del()
+
+ // Check which content records are now orphaned (no remaining document references)
+ for (const contentHash of contentHashes) {
+ const [{ count }] = await dbPool('bm_web_documents')
+ .where({ content_hash: contentHash })
+ .count('* as count')
+
+ if (count === 0) {
+ // Content has no more references - delete file and content record
+ const [content] = await dbPool('bm_web_document_contents')
+ .where({ content_hash: contentHash })
+ .select('path')
+
+ if (content) {
+ const relativePath = content.path.replace('uploads/documents/', '').split('/').join(path.sep)
+ const fullPath = path.join(UPLOAD_PATH, relativePath)
+
+ try {
+ await fs.unlink(fullPath)
+ } catch (err) {
+ if (err.code !== 'ENOENT') {
+ logger.warn({ err, path: fullPath }, 'Failed to delete orphan document file')
+ }
+ }
+
+ await dbPool('bm_web_document_contents')
+ .where({ content_hash: contentHash })
+ .del()
+ }
+ }
+ }
+
+ logger.info(`Cleaned up ${orphanDocuments.length} orphan document(s)`)
+ } catch (err) {
+ logger.error({ err }, 'Failed to cleanup orphan documents')
+ }
+}
+
+module.exports = { cleanupOrphanDocuments }
diff --git a/server/data/hash-content.js b/server/data/hash-content.js
new file mode 100644
index 000000000..a42778ed2
--- /dev/null
+++ b/server/data/hash-content.js
@@ -0,0 +1,26 @@
+const crypto = require('crypto')
+
+/**
+ * Computes SHA-256 hash of a buffer and returns it as a hex string.
+ * Used for content-addressable storage deduplication.
+ * @param {Buffer} buffer - The buffer to hash
+ * @returns {string} 64-character hex string
+ */
+function hashContent (buffer) {
+ return crypto.createHash('sha256').update(buffer).digest('hex')
+}
+
+/**
+ * Generates a content-addressed file path from a hash.
+ * Uses first 4 characters as subdirectories to distribute files.
+ * @param {string} contentHash - 64-character SHA-256 hex string
+ * @param {string} extension - File extension (e.g., 'jpg', 'png')
+ * @returns {string} Path like 'ab/cd/abcdef...hash.jpg'
+ */
+function getContentPath (contentHash, extension) {
+ const dir1 = contentHash.slice(0, 2)
+ const dir2 = contentHash.slice(2, 4)
+ return `${dir1}/${dir2}/${contentHash}.${extension}`
+}
+
+module.exports = { hashContent, getContentPath }
diff --git a/server/data/migrations/20251216120000-documents.js b/server/data/migrations/20251216120000-documents.js
new file mode 100644
index 000000000..4016abd39
--- /dev/null
+++ b/server/data/migrations/20251216120000-documents.js
@@ -0,0 +1,175 @@
+const aclHelper = require('./lib/acl')
+
+exports.up = async function (db) {
+ await db.createTable('bm_web_document_contents', {
+ columns: {
+ content_hash: { type: 'char', length: 64, notNull: true, primaryKey: true },
+ path: { type: 'string', length: 255, notNull: true },
+ mime_type: { type: 'string', length: 100, notNull: true },
+ size: { type: 'int', unsigned: true, notNull: true },
+ width: { type: 'smallint', unsigned: true, notNull: false },
+ height: { type: 'smallint', unsigned: true, notNull: false }
+ },
+ charset: 'utf8'
+ })
+
+ await db.createTable('bm_web_documents', {
+ columns: {
+ id: { type: 'string', length: 36, notNull: true, primaryKey: true },
+ player_id: { type: 'binary', length: 16, notNull: true },
+ filename: { type: 'string', length: 255, notNull: true },
+ content_hash: {
+ type: 'char',
+ length: 64,
+ notNull: true,
+ foreignKey: {
+ name: 'bm_web_documents_content_hash_fk',
+ table: 'bm_web_document_contents',
+ mapping: 'content_hash',
+ rules: {
+ onDelete: 'RESTRICT',
+ onUpdate: 'CASCADE'
+ }
+ }
+ },
+ created: { type: 'int', length: 10, unsigned: true, notNull: true }
+ },
+ charset: 'utf8'
+ })
+
+ await db.addIndex('bm_web_documents', 'bm_web_documents_player_idx', ['player_id'])
+ await db.addIndex('bm_web_documents', 'bm_web_documents_created_idx', ['created'])
+ await db.addIndex('bm_web_documents', 'bm_web_documents_content_hash_idx', ['content_hash'])
+
+ await db.createTable('bm_web_appeal_documents', {
+ columns: {
+ appeal_id: {
+ type: 'int',
+ notNull: true,
+ primaryKey: true,
+ foreignKey: {
+ name: 'bm_web_appeal_documents_appeal_id_fk',
+ table: 'bm_web_appeals',
+ mapping: 'id',
+ rules: {
+ onDelete: 'CASCADE',
+ onUpdate: 'CASCADE'
+ }
+ }
+ },
+ comment_id: {
+ type: 'bigint',
+ length: 20,
+ notNull: true,
+ primaryKey: true,
+ defaultValue: 0
+ },
+ document_id: {
+ type: 'string',
+ length: 36,
+ notNull: true,
+ primaryKey: true,
+ foreignKey: {
+ name: 'bm_web_appeal_documents_document_id_fk',
+ table: 'bm_web_documents',
+ mapping: 'id',
+ rules: {
+ onDelete: 'CASCADE',
+ onUpdate: 'CASCADE'
+ }
+ }
+ }
+ },
+ charset: 'utf8'
+ })
+
+ await db.createTable('bm_web_report_comment_documents', {
+ columns: {
+ server_id: {
+ type: 'string',
+ length: 255,
+ notNull: true,
+ primaryKey: true,
+ foreignKey: {
+ name: 'bm_web_report_comment_documents_server_id_fk',
+ table: 'bm_web_servers',
+ mapping: 'id',
+ rules: {
+ onDelete: 'CASCADE',
+ onUpdate: 'CASCADE'
+ }
+ }
+ },
+ comment_id: {
+ type: 'bigint',
+ length: 20,
+ notNull: true,
+ primaryKey: true
+ },
+ document_id: {
+ type: 'string',
+ length: 36,
+ notNull: true,
+ primaryKey: true,
+ foreignKey: {
+ name: 'bm_web_report_comment_documents_document_id_fk',
+ table: 'bm_web_documents',
+ mapping: 'id',
+ rules: {
+ onDelete: 'CASCADE',
+ onUpdate: 'CASCADE'
+ }
+ }
+ }
+ },
+ charset: 'utf8'
+ })
+
+ const { addPermission, attachPermission } = aclHelper(db)
+
+ await addPermission('player.appeals',
+ 'attachment.view',
+ 'attachment.create',
+ 'attachment.delete.own',
+ 'attachment.delete.any',
+ 'attachment.ratelimit.bypass'
+ )
+
+ await addPermission('player.reports',
+ 'attachment.view',
+ 'attachment.create',
+ 'attachment.delete.own',
+ 'attachment.delete.any',
+ 'attachment.ratelimit.bypass'
+ )
+
+ await attachPermission('player.appeals', 2, 'attachment.view')
+ await attachPermission('player.reports', 2, 'attachment.view')
+
+ await attachPermission('player.appeals', 3,
+ 'attachment.view',
+ 'attachment.create',
+ 'attachment.delete.own',
+ 'attachment.delete.any',
+ 'attachment.ratelimit.bypass'
+ )
+
+ await attachPermission('player.reports', 3,
+ 'attachment.view',
+ 'attachment.create',
+ 'attachment.delete.own',
+ 'attachment.delete.any',
+ 'attachment.ratelimit.bypass'
+ )
+}
+
+exports.down = async function (db) {
+ await db.dropTable('bm_web_report_comment_documents')
+ await db.dropTable('bm_web_appeal_documents')
+ await db.dropTable('bm_web_documents')
+ await db.dropTable('bm_web_document_contents')
+}
+
+exports._meta = {
+ version: 1
+}
diff --git a/server/data/migrations/lib/acl.js b/server/data/migrations/lib/acl.js
index c38088b40..6588ea706 100644
--- a/server/data/migrations/lib/acl.js
+++ b/server/data/migrations/lib/acl.js
@@ -12,6 +12,13 @@ module.exports = (db) => {
let i = result.i
for (const name of permissionNames) {
+ // Check if permission already exists
+ const existing = await db.runSql(
+ 'SELECT 1 FROM bm_web_resource_permissions WHERE resource_id = ? AND name = ?',
+ [resourceId, name]
+ )
+ if (existing && existing.length > 0) continue
+
const value = Math.pow(2, i++)
await db.insert('bm_web_resource_permissions', ['resource_id', 'name', 'value'], [resourceId, name, value])
@@ -19,10 +26,20 @@ module.exports = (db) => {
},
async attachPermission (resourceName, roleId, ...permissionNames) {
const resourceId = await getResourceId(resourceName)
- const permissions = await db.runSql('SELECT * FROM bm_web_resource_permissions WHERE resource_id = ? AND name IN(?)', [resourceId, permissionNames])
+ // Build placeholders for IN clause - one ? for each permission name
+ const placeholders = permissionNames.map(() => '?').join(', ')
+ const permissions = await db.runSql(
+ `SELECT * FROM bm_web_resource_permissions WHERE resource_id = ? AND name IN(${placeholders})`,
+ [resourceId, ...permissionNames]
+ )
+
+ // Use bitwise OR to avoid issues with duplicate additions
return Promise.all(permissions.map((permission) => {
- return db.runSql(`UPDATE bm_web_role_resources SET value = value + ${permission.value} WHERE role_id = ? AND resource_id = ?`, [roleId, resourceId])
+ return db.runSql(
+ 'UPDATE bm_web_role_resources SET value = value | ? WHERE role_id = ? AND resource_id = ?',
+ [permission.value, roleId, resourceId]
+ )
}))
},
async addResource (name) {
diff --git a/server/graphql/resolvers/mutations/create-appeal-comment.js b/server/graphql/resolvers/mutations/create-appeal-comment.js
index 6a962e643..bcb7b3e3c 100644
--- a/server/graphql/resolvers/mutations/create-appeal-comment.js
+++ b/server/graphql/resolvers/mutations/create-appeal-comment.js
@@ -5,29 +5,59 @@ const { getNotificationType } = require('../../../data/notification')
const { subscribeAppeal, notifyAppeal } = require('../../../data/notification/appeal')
module.exports = async function createAppealComment (obj, { id: appealId, input }, { session, state }, info) {
- const [data] = await state.dbPool('bm_web_appeals')
+ const [appeal] = await state.dbPool('bm_web_appeals')
.where({ id: appealId })
- if (!data) throw new ExposedError(`Appeal ${appealId} does not exist`)
+ if (!appeal) throw new ExposedError(`Appeal ${appealId} does not exist`)
- const hasPermission = state.acl.hasServerPermission(data.server_id, 'player.appeals', 'comment.any') ||
- (state.acl.hasServerPermission(data.server_id, 'player.appeals', 'comment.own') && state.acl.owns(data.actor_id)) ||
- (state.acl.hasServerPermission(data.server_id, 'player.appeals', 'comment.assigned') && state.acl.owns(data.assignee_id))
+ const hasPermission = state.acl.hasServerPermission(appeal.server_id, 'player.appeals', 'comment.any') ||
+ (state.acl.hasServerPermission(appeal.server_id, 'player.appeals', 'comment.own') && state.acl.owns(appeal.actor_id)) ||
+ (state.acl.hasServerPermission(appeal.server_id, 'player.appeals', 'comment.assigned') && state.acl.owns(appeal.assignee_id))
if (!hasPermission) throw new ExposedError('You do not have permission to perform this action, please contact your server administrator')
- if (data.state_id > 2) throw new ExposedError('You cannot comment on a closed appeal')
+ if (appeal.state_id > 2) throw new ExposedError('You cannot comment on a closed appeal')
- const [id] = await state.dbPool('bm_web_appeal_comments').insert({
- appeal_id: appealId,
- actor_id: session.playerId,
- content: input.content,
- type: getAppealCommentType('comment'),
- created: state.dbPool.raw('UNIX_TIMESTAMP()'),
- updated: state.dbPool.raw('UNIX_TIMESTAMP()')
- }, ['id'])
+ const hasDocuments = input.documents && input.documents.length > 0
+
+ if (hasDocuments) {
+ const hasAttachPermission = state.acl.hasServerPermission(appeal.server_id, 'player.appeals', 'attachment.create')
+ if (!hasAttachPermission) {
+ throw new ExposedError('You do not have permission to attach files')
+ }
+ }
+
+ const commentId = await state.dbPool.transaction(async (trx) => {
+ const [id] = await trx('bm_web_appeal_comments').insert({
+ appeal_id: appealId,
+ actor_id: session.playerId,
+ content: input.content,
+ type: getAppealCommentType('comment'),
+ created: trx.raw('UNIX_TIMESTAMP()'),
+ updated: trx.raw('UNIX_TIMESTAMP()')
+ }, ['id'])
+
+ if (!hasDocuments) return id
+
+ const validDocs = await trx('bm_web_documents')
+ .whereIn('id', input.documents)
+ .where('player_id', session.playerId)
+ .select('id')
+
+ if (validDocs.length === 0) return id
+
+ const links = validDocs.map(doc => ({
+ appeal_id: appealId,
+ comment_id: id,
+ document_id: doc.id
+ }))
+
+ await trx('bm_web_appeal_documents').insert(links)
+
+ return id
+ })
await subscribeAppeal(state.dbPool, appealId, session.playerId)
- await notifyAppeal(state.dbPool, getNotificationType('appealComment'), appealId, data.server_id, id, session.playerId, state)
+ await notifyAppeal(state.dbPool, getNotificationType('appealComment'), appealId, appeal.server_id, commentId, session.playerId, state)
- return appealComment(obj, { id }, { state }, info)
+ return appealComment(obj, { id: commentId }, { state }, info)
}
diff --git a/server/graphql/resolvers/mutations/create-appeal.js b/server/graphql/resolvers/mutations/create-appeal.js
index d389dee1e..cd866097a 100644
--- a/server/graphql/resolvers/mutations/create-appeal.js
+++ b/server/graphql/resolvers/mutations/create-appeal.js
@@ -4,7 +4,7 @@ const { notifyRuleGroups, subscribeAppeal } = require('../../../data/notificatio
const { triggerWebhook } = require('../../../data/webhook')
const { unparse } = require('uuid-parse')
-module.exports = async function createAppeal (obj, { input: { serverId, punishmentId, type, reason } }, { log, state, session }, info) {
+module.exports = async function createAppeal (obj, { input: { serverId, punishmentId, type, reason, documents } }, { log, state, session }, info) {
if (!state.serversPool.has(serverId)) throw new ExposedError('Server does not exist')
const exists = await state.dbPool('bm_web_appeals')
@@ -92,6 +92,25 @@ module.exports = async function createAppeal (obj, { input: { serverId, punishme
const [id] = await state.dbPool('bm_web_appeals').insert(appeal, ['id'])
+ if (documents && documents.length > 0) {
+ const hasAttachPermission = state.acl.hasServerPermission(serverId, 'player.appeals', 'attachment.create')
+ if (hasAttachPermission) {
+ const validDocs = await state.dbPool('bm_web_documents')
+ .whereIn('id', documents)
+ .where('player_id', session.playerId)
+ .select('id')
+
+ if (validDocs.length > 0) {
+ const links = validDocs.map(doc => ({
+ appeal_id: id,
+ comment_id: 0,
+ document_id: doc.id
+ }))
+ await state.dbPool('bm_web_appeal_documents').insert(links)
+ }
+ }
+ }
+
await subscribeAppeal(state.dbPool, id, session.playerId)
await notifyRuleGroups(state.dbPool, 'APPEAL_CREATED', id, server.config.id, null, session.playerId, state)
diff --git a/server/graphql/resolvers/mutations/create-report-comment.js b/server/graphql/resolvers/mutations/create-report-comment.js
index 2fc97febd..3ecac530a 100644
--- a/server/graphql/resolvers/mutations/create-report-comment.js
+++ b/server/graphql/resolvers/mutations/create-report-comment.js
@@ -21,6 +21,15 @@ module.exports = async function createReportComment (obj, { report: reportId, se
if (!hasPermission) throw new ExposedError('You do not have permission to perform this action, please contact your server administrator')
if (data.state_id > 2) throw new ExposedError('You cannot comment on a closed report')
+ const hasDocuments = input.documents && input.documents.length > 0
+
+ if (hasDocuments) {
+ const hasAttachPermission = state.acl.hasServerPermission(serverId, 'player.reports', 'attachment.create')
+ if (!hasAttachPermission) {
+ throw new ExposedError('You do not have permission to attach files')
+ }
+ }
+
const [id] = await server.pool(server.config.tables.playerReportComments).insert({
report_id: reportId,
actor_id: session.playerId,
@@ -29,6 +38,23 @@ module.exports = async function createReportComment (obj, { report: reportId, se
updated: server.pool.raw('UNIX_TIMESTAMP()')
}, ['id'])
+ if (hasDocuments) {
+ const validDocs = await state.dbPool('bm_web_documents')
+ .whereIn('id', input.documents)
+ .where('player_id', session.playerId)
+ .select('id')
+
+ if (validDocs.length > 0) {
+ const links = validDocs.map(doc => ({
+ server_id: serverId,
+ comment_id: id,
+ document_id: doc.id
+ }))
+
+ await state.dbPool('bm_web_report_comment_documents').insert(links)
+ }
+ }
+
await subscribeReport(state.dbPool, reportId, serverId, session.playerId)
await notifyReport(state.dbPool, getNotificationType('reportComment'), reportId, server, id, session.playerId, state)
diff --git a/server/graphql/resolvers/mutations/delete-document.js b/server/graphql/resolvers/mutations/delete-document.js
new file mode 100644
index 000000000..f58c2eb19
--- /dev/null
+++ b/server/graphql/resolvers/mutations/delete-document.js
@@ -0,0 +1,114 @@
+const fs = require('fs').promises
+const path = require('path')
+const { unparse } = require('uuid-parse')
+const ExposedError = require('../../../data/exposed-error')
+
+const UPLOAD_PATH = process.env.UPLOAD_PATH || './uploads/documents'
+
+async function getDocumentContext (dbPool, documentId) {
+ // Check if attached to an appeal (either directly or via comment)
+ const [appealDoc] = await dbPool('bm_web_appeal_documents')
+ .where({ document_id: documentId })
+ .select('appeal_id')
+
+ if (appealDoc) {
+ const [appeal] = await dbPool('bm_web_appeals')
+ .where({ id: appealDoc.appeal_id })
+ .select('server_id')
+
+ if (!appeal) throw new ExposedError('Parent appeal not found')
+ return { serverId: appeal.server_id, resource: 'player.appeals' }
+ }
+
+ // Check if attached to a report comment
+ const [reportCommentDoc] = await dbPool('bm_web_report_comment_documents')
+ .where({ document_id: documentId })
+ .select('server_id')
+
+ if (reportCommentDoc) {
+ return { serverId: reportCommentDoc.server_id, resource: 'player.reports' }
+ }
+
+ return null
+}
+
+module.exports = async function deleteDocument (obj, { id }, { log, session, state }) {
+ // Join documents with contents to get all fields
+ const [document] = await state.dbPool('bm_web_documents as d')
+ .join('bm_web_document_contents as c', 'd.content_hash', 'c.content_hash')
+ .where('d.id', id)
+ .select(
+ 'd.id',
+ 'd.player_id',
+ 'd.filename',
+ 'd.content_hash',
+ 'd.created',
+ 'c.path',
+ 'c.mime_type',
+ 'c.size',
+ 'c.width',
+ 'c.height'
+ )
+
+ if (!document) {
+ throw new ExposedError(`Document ${id} does not exist`)
+ }
+
+ const playerId = unparse(document.player_id)
+ const isOwner = session.playerId && unparse(session.playerId) === playerId
+ const context = await getDocumentContext(state.dbPool, id)
+
+ if (context) {
+ const { serverId, resource } = context
+ const canDeleteAny = state.acl.hasServerPermission(serverId, resource, 'attachment.delete.any')
+ const canDeleteOwn = state.acl.hasServerPermission(serverId, resource, 'attachment.delete.own') && isOwner
+
+ if (!canDeleteAny && !canDeleteOwn) {
+ throw new ExposedError('You do not have permission to delete this document')
+ }
+ } else {
+ // Unlinked document - only admins can delete
+ if (!state.acl.hasPermission('servers', 'manage')) {
+ throw new ExposedError('You do not have permission to delete this document')
+ }
+ }
+
+ const contentHash = document.content_hash
+
+ // Delete document record - junction tables cascade automatically
+ await state.dbPool('bm_web_documents').where({ id }).delete()
+
+ // Check if any other documents still reference this content
+ const [{ count }] = await state.dbPool('bm_web_documents')
+ .where({ content_hash: contentHash })
+ .count('* as count')
+
+ if (count === 0) {
+ // No more references - delete content record and file
+ const relativePath = document.path.replace('uploads/documents/', '').split('/').join(path.sep)
+ const fullPath = path.join(UPLOAD_PATH, relativePath)
+
+ try {
+ await fs.unlink(fullPath)
+ } catch (err) {
+ if (err.code !== 'ENOENT') {
+ log.error({ err, path: fullPath }, 'Failed to delete document file')
+ }
+ }
+
+ await state.dbPool('bm_web_document_contents')
+ .where({ content_hash: contentHash })
+ .delete()
+ }
+
+ return {
+ id: document.id,
+ filename: document.filename,
+ mimeType: document.mime_type,
+ size: document.size,
+ width: document.width,
+ height: document.height,
+ created: document.created,
+ acl: { delete: true }
+ }
+}
diff --git a/server/graphql/resolvers/queries/admin-navigation.js b/server/graphql/resolvers/queries/admin-navigation.js
index 8704aaf19..b26063f3f 100644
--- a/server/graphql/resolvers/queries/admin-navigation.js
+++ b/server/graphql/resolvers/queries/admin-navigation.js
@@ -2,8 +2,10 @@ module.exports = async function adminNavigation (obj, info, { state }) {
const { rolesCount } = await state.dbPool('bm_web_roles').count({ rolesCount: '*' }).first()
const { notificationRulesCount } = await state.dbPool('bm_web_notification_rules').count({ notificationRulesCount: '*' }).first()
const { webHooksCount } = await state.dbPool('bm_web_webhooks').count({ webHooksCount: '*' }).first()
+ const { documentsCount } = await state.dbPool('bm_web_documents').count({ documentsCount: '*' }).first()
const left = [
+ { name: 'Documents', label: documentsCount, href: '/admin/documents' },
{ name: 'Roles', label: rolesCount, href: '/admin/roles' },
{ name: 'Servers', label: state.serversPool.size, href: '/admin/servers' },
{ name: 'Notification Rules', label: notificationRulesCount, href: '/admin/notification-rules' },
diff --git a/server/graphql/resolvers/queries/list-documents.js b/server/graphql/resolvers/queries/list-documents.js
new file mode 100644
index 000000000..f7aa51767
--- /dev/null
+++ b/server/graphql/resolvers/queries/list-documents.js
@@ -0,0 +1,49 @@
+const { unparse } = require('uuid-parse')
+
+module.exports = async function listDocuments (obj, { limit, offset, player, dateStart, dateEnd }, { state }) {
+ let query = state.dbPool('bm_web_documents as d')
+ .join('bm_web_document_contents as c', 'd.content_hash', 'c.content_hash')
+ .select(
+ 'd.id',
+ 'd.player_id',
+ 'd.filename',
+ 'd.created',
+ 'c.path',
+ 'c.mime_type',
+ 'c.size',
+ 'c.width',
+ 'c.height'
+ )
+ .orderBy('d.created', 'desc')
+
+ let countQuery = state.dbPool('bm_web_documents as d')
+ .join('bm_web_document_contents as c', 'd.content_hash', 'c.content_hash')
+
+ if (player) {
+ // player is already a Buffer from the UUID scalar parseValue
+ query = query.where('d.player_id', player)
+ countQuery = countQuery.where('d.player_id', player)
+ }
+
+ if (dateStart) {
+ query = query.where('d.created', '>=', dateStart)
+ countQuery = countQuery.where('d.created', '>=', dateStart)
+ }
+
+ if (dateEnd) {
+ query = query.where('d.created', '<=', dateEnd)
+ countQuery = countQuery.where('d.created', '<=', dateEnd)
+ }
+
+ const [{ total }] = await countQuery.count('* as total')
+ const records = await query.limit(limit).offset(offset)
+
+ return {
+ total,
+ records: records.map(record => ({
+ ...record,
+ playerId: unparse(record.player_id),
+ mimeType: record.mime_type
+ }))
+ }
+}
diff --git a/server/graphql/resolvers/queries/list-reports-comments.js b/server/graphql/resolvers/queries/list-reports-comments.js
index a46110041..7b50094af 100644
--- a/server/graphql/resolvers/queries/list-reports-comments.js
+++ b/server/graphql/resolvers/queries/list-reports-comments.js
@@ -54,14 +54,14 @@ module.exports = async function listPlayerReportComments (obj, { serverId, repor
data.records = results
- if (calculateAcl) {
- for (const result of results) {
- const acl = {
+ for (const result of results) {
+ result.serverId = serverId
+
+ if (calculateAcl) {
+ result.acl = {
delete: state.acl.hasServerPermission(serverId, 'player.reports', 'comment.delete.any') ||
(state.acl.hasServerPermission(serverId, 'player.reports', 'comment.delete.own') && state.acl.owns(result.actor_id))
}
-
- result.acl = acl
}
}
}
diff --git a/server/graphql/resolvers/queries/report-comment.js b/server/graphql/resolvers/queries/report-comment.js
index 85389ab2c..9dbf365dd 100644
--- a/server/graphql/resolvers/queries/report-comment.js
+++ b/server/graphql/resolvers/queries/report-comment.js
@@ -49,5 +49,9 @@ module.exports = async function reportComment (obj, { id, serverId }, { state },
}
}
+ if (data) {
+ data.serverId = serverId
+ }
+
return data
}
diff --git a/server/graphql/resolvers/queries/report.js b/server/graphql/resolvers/queries/report.js
index 716c53282..2cb24d9df 100644
--- a/server/graphql/resolvers/queries/report.js
+++ b/server/graphql/resolvers/queries/report.js
@@ -43,6 +43,9 @@ module.exports = async function report (obj, { id, serverId }, { session, state
if (!data) throw new ExposedError('Report not found')
+ // Add serverId to data for document resolver
+ data.serverId = serverId
+
if (fields.fieldsByTypeName.PlayerReport.viewerSubscription && session?.playerId) {
data.viewerSubscription = await getReportSubscription(state.dbPool, id, serverId, session.playerId)
}
diff --git a/server/graphql/resolvers/scalars/document.js b/server/graphql/resolvers/scalars/document.js
new file mode 100644
index 000000000..83f7e1b92
--- /dev/null
+++ b/server/graphql/resolvers/scalars/document.js
@@ -0,0 +1,237 @@
+const { unparse } = require('uuid-parse')
+
+// Helper to fetch documents by IDs with content join
+async function getDocumentsWithContent (dbPool, documentIds) {
+ if (!documentIds.length) return []
+
+ const documents = await dbPool('bm_web_documents as d')
+ .join('bm_web_document_contents as c', 'd.content_hash', 'c.content_hash')
+ .whereIn('d.id', documentIds)
+ .select(
+ 'd.id',
+ 'd.player_id',
+ 'd.filename',
+ 'd.created',
+ 'c.mime_type',
+ 'c.size',
+ 'c.width',
+ 'c.height'
+ )
+
+ return documents.map(doc => ({
+ ...doc,
+ playerId: unparse(doc.player_id),
+ mimeType: doc.mime_type
+ }))
+}
+
+module.exports = {
+ Document: {
+ player: {
+ async resolve (obj, args, { state }, info) {
+ const playerId = obj.player_id || obj.playerId
+ if (!playerId) return null
+
+ try {
+ return await state.loaders.player.load({ id: playerId, fields: ['id', 'name'] })
+ } catch (e) {
+ return { id: typeof playerId === 'string' ? playerId : unparse(playerId) }
+ }
+ }
+ },
+ usages: {
+ async resolve (obj, args, { state }) {
+ const documentId = obj.id
+ const usages = []
+
+ // Check appeal documents (both initial appeal and comments)
+ const appealLinks = await state.dbPool('bm_web_appeal_documents as ad')
+ .join('bm_web_appeals as a', 'ad.appeal_id', 'a.id')
+ .where('ad.document_id', documentId)
+ .select('a.id as appeal_id', 'a.server_id', 'ad.comment_id')
+
+ for (const link of appealLinks) {
+ if (link.comment_id === 0) {
+ usages.push({
+ type: 'appeal',
+ id: String(link.appeal_id),
+ commentId: null,
+ serverId: link.server_id,
+ label: `Appeal #${link.appeal_id}`
+ })
+ } else {
+ usages.push({
+ type: 'appeal_comment',
+ id: String(link.appeal_id),
+ commentId: String(link.comment_id),
+ serverId: link.server_id,
+ label: `Appeal #${link.appeal_id} Comment`
+ })
+ }
+ }
+
+ // Check report comment documents - need to look up report_id from the comment
+ const reportLinks = await state.dbPool('bm_web_report_comment_documents')
+ .where({ document_id: documentId })
+ .select('server_id', 'comment_id')
+
+ for (const link of reportLinks) {
+ // Get report_id from the comment on the appropriate server
+ const server = state.serversPool.get(link.server_id)
+ if (!server) continue
+
+ const [comment] = await server.pool(server.config.tables.playerReportComments)
+ .where({ id: link.comment_id })
+ .select('report_id')
+
+ if (!comment) continue
+
+ usages.push({
+ type: 'report_comment',
+ id: String(comment.report_id),
+ commentId: String(link.comment_id),
+ serverId: link.server_id,
+ label: `Report #${comment.report_id} Comment`
+ })
+ }
+
+ return usages
+ }
+ },
+ acl: {
+ resolve (obj, args, { session, state }) {
+ const playerId = obj.player_id || obj.playerId
+ const playerIdStr = typeof playerId === 'string' ? playerId : unparse(playerId)
+ const isOwner = session.playerId && unparse(session.playerId) === playerIdStr
+
+ // Check delete permissions - we'll need to know which resource it belongs to
+ const canDeleteAnyAppeals = state.acl.hasPermission('player.appeals', 'attachment.delete.any')
+ const canDeleteOwnAppeals = state.acl.hasPermission('player.appeals', 'attachment.delete.own') && isOwner
+ const canDeleteAnyReports = state.acl.hasPermission('player.reports', 'attachment.delete.any')
+ const canDeleteOwnReports = state.acl.hasPermission('player.reports', 'attachment.delete.own') && isOwner
+ const canManage = state.acl.hasPermission('servers', 'manage')
+
+ return {
+ delete: canDeleteAnyAppeals || canDeleteOwnAppeals || canDeleteAnyReports || canDeleteOwnReports || canManage
+ }
+ }
+ }
+ },
+
+ PlayerAppeal: {
+ documents: {
+ async resolve (obj, args, { state }) {
+ const appealId = obj.id
+ const serverId = obj.server_id
+
+ const canViewAttachments = state.acl.hasServerPermission(serverId, 'player.appeals', 'attachment.view')
+ if (!canViewAttachments) return []
+
+ const documentLinks = await state.dbPool('bm_web_appeal_documents')
+ .where({ appeal_id: appealId })
+ .select('document_id')
+
+ const documentIds = documentLinks.map(link => link.document_id)
+ return getDocumentsWithContent(state.dbPool, documentIds)
+ }
+ },
+ initialDocuments: {
+ async resolve (obj, args, { state }) {
+ const appealId = obj.id
+ const serverId = obj.server_id
+
+ const canViewAttachments = state.acl.hasServerPermission(serverId, 'player.appeals', 'attachment.view')
+ if (!canViewAttachments) return []
+
+ const documentLinks = await state.dbPool('bm_web_appeal_documents')
+ .where({ appeal_id: appealId, comment_id: 0 })
+ .select('document_id')
+
+ const documentIds = documentLinks.map(link => link.document_id)
+ return getDocumentsWithContent(state.dbPool, documentIds)
+ }
+ }
+ },
+
+ PlayerAppealComment: {
+ documents: {
+ async resolve (obj, args, { state }) {
+ const commentId = obj.id
+
+ const [comment] = await state.dbPool('bm_web_appeal_comments')
+ .where({ id: commentId })
+ .select('appeal_id')
+
+ if (!comment) return []
+
+ const [appeal] = await state.dbPool('bm_web_appeals')
+ .where({ id: comment.appeal_id })
+ .select('server_id')
+
+ if (!appeal) return []
+
+ const canViewAttachments = state.acl.hasServerPermission(appeal.server_id, 'player.appeals', 'attachment.view')
+ if (!canViewAttachments) return []
+
+ const documentLinks = await state.dbPool('bm_web_appeal_documents')
+ .where({ appeal_id: comment.appeal_id, comment_id: commentId })
+ .select('document_id')
+
+ const documentIds = documentLinks.map(link => link.document_id)
+ return getDocumentsWithContent(state.dbPool, documentIds)
+ }
+ }
+ },
+
+ PlayerReport: {
+ documents: {
+ async resolve (obj, args, { state }) {
+ const reportId = obj.id
+ const serverId = obj.serverId || obj.server_id
+
+ if (!serverId) return []
+
+ const canViewAttachments = state.acl.hasServerPermission(serverId, 'player.reports', 'attachment.view')
+ if (!canViewAttachments) return []
+
+ const server = state.serversPool.get(serverId)
+ if (!server) return []
+
+ const commentIds = await server.pool(server.config.tables.playerReportComments)
+ .where({ report_id: reportId })
+ .select('id')
+
+ if (!commentIds.length) return []
+
+ const documentLinks = await state.dbPool('bm_web_report_comment_documents')
+ .where({ server_id: serverId })
+ .whereIn('comment_id', commentIds.map(c => c.id))
+ .select('document_id')
+
+ const documentIds = documentLinks.map(link => link.document_id)
+ return getDocumentsWithContent(state.dbPool, documentIds)
+ }
+ }
+ },
+
+ PlayerReportComment: {
+ documents: {
+ async resolve (obj, args, { state }) {
+ const commentId = obj.id
+ const serverId = obj.serverId || obj.server_id
+
+ if (!serverId) return []
+
+ const canViewAttachments = state.acl.hasServerPermission(serverId, 'player.reports', 'attachment.view')
+ if (!canViewAttachments) return []
+
+ const documentLinks = await state.dbPool('bm_web_report_comment_documents')
+ .where({ server_id: serverId, comment_id: commentId })
+ .select('document_id')
+
+ const documentIds = documentLinks.map(link => link.document_id)
+ return getDocumentsWithContent(state.dbPool, documentIds)
+ }
+ }
+ }
+}
diff --git a/server/graphql/types.js b/server/graphql/types.js
index 4760be970..2a12ce56a 100644
--- a/server/graphql/types.js
+++ b/server/graphql/types.js
@@ -248,6 +248,7 @@ type PlayerReport @sqlTable(name: "playerReports") {
acl: PlayerReportACL!
serverLogs: [PlayerReportServerLog!] @sqlRelation(field: "id", table: "playerReportLogs", whereKey: "report_id") @allowIf(resource: "player.reports", permission: "view.serverlogs", serverSrc: "id")
commands: [PlayerReportCommand!] @sqlRelation(field: "id", table: "playerReportCommands", whereKey: "report_id") @allowIf(resource: "player.reports", permission: "view.commands", serverSrc: "id")
+ documents: [Document!]
viewerSubscription: Subscription @allowIfLoggedIn
}
@@ -283,6 +284,7 @@ type PlayerReportComment @sqlTable(name: "playerReportComments") {
created: Timestamp!
updated: Timestamp!
acl: EntityACL!
+ documents: [Document!]
}
type PlayerReportServerLog @sqlTable(name: "playerReportLogs") {
@@ -318,6 +320,8 @@ type PlayerAppeal @sqlTable(name: "appeals") {
updated: Timestamp!
state: PlayerAppealState! @sqlRelation(joinOn: "id", field: "state_id", table: "appealStates")
acl: PlayerAppealACL!
+ documents: [Document!]
+ initialDocuments: [Document!]
viewerSubscription: Subscription @allowIfLoggedIn
}
@@ -356,6 +360,7 @@ type PlayerAppealComment @sqlTable(name: "appealComments") {
created: Timestamp!
updated: Timestamp!
acl: EntityACL!
+ documents: [Document!]
}
type EntityTypeACL {
@@ -595,6 +600,36 @@ type WebhookDeliveryList {
records: [WebhookDelivery!]!
}
+type Document @sqlTable(name: "documents") {
+ id: ID!
+ player: Player! @sqlRelation(joinOn: "id", field: "player_id", table: "players")
+ filename: String!
+ mimeType: String! @sqlColumn(name: "mime_type")
+ size: Int!
+ width: Int
+ height: Int
+ created: Timestamp!
+ usages: [DocumentUsage!]!
+ acl: DocumentACL!
+}
+
+type DocumentUsage {
+ type: String!
+ id: ID!
+ commentId: ID
+ serverId: ID
+ label: String!
+}
+
+type DocumentACL {
+ delete: Boolean!
+}
+
+type DocumentList {
+ total: Int!
+ records: [Document!]!
+}
+
type Query {
searchPlayers(name: String!, limit: Int = 10): [Player!]
player(player: UUID!): Player
@@ -666,6 +701,8 @@ type Query {
webhook(id: ID!): Webhook! @allowIf(resource: "servers", permission: "manage")
listWebhookDeliveries(webhookId: ID!, limit: Int = 10, offset: Int = 0): WebhookDeliveryList! @allowIf(resource: "servers", permission: "manage")
webhookExamplePayload(type: WebhookType!): JSONObject! @allowIf(resource: "servers", permission: "manage")
+
+ listDocuments(limit: Int = 25, offset: Int = 0, player: UUID, dateStart: Timestamp, dateEnd: Timestamp): DocumentList! @allowIf(resource: "servers", permission: "manage")
}
input CreatePlayerNoteInput {
@@ -770,14 +807,17 @@ input CreateAppealInput {
reason: String! @constraint(minLength: 20, maxLength: 65535)
soft: Boolean
points: Float
+ documents: [ID!] @constraint(maxItems: 5)
}
input AppealCommentInput {
content: String! @constraint(minLength: 2, maxLength: 255)
+ documents: [ID!] @constraint(maxItems: 3)
}
input ReportCommentInput {
comment: String! @constraint(minLength: 2, maxLength: 255)
+ documents: [ID!] @constraint(maxItems: 3)
}
input RoleInput {
@@ -890,5 +930,7 @@ type Mutation {
deleteWebhook(id: ID!): Webhook! @allowIf(resource: "servers", permission: "manage")
sendTestWebhook(id: ID!, variables: JSONObject!): WebhookDelivery! @allowIf(resource: "servers", permission: "manage")
+
+ deleteDocument(id: ID!): Document! @allowIfLoggedIn
}
`
diff --git a/server/routes/documents.js b/server/routes/documents.js
new file mode 100644
index 000000000..de17fdfb8
--- /dev/null
+++ b/server/routes/documents.js
@@ -0,0 +1,125 @@
+const path = require('path')
+const send = require('koa-send')
+
+const UPLOAD_PATH = process.env.UPLOAD_PATH || './uploads/documents'
+
+module.exports = function documentsRoute (dbPool) {
+ return async (ctx) => {
+ const { id } = ctx.params
+ const { acl } = ctx.state
+
+ // Look up document with content join
+ const [document] = await dbPool('bm_web_documents as d')
+ .join('bm_web_document_contents as c', 'd.content_hash', 'c.content_hash')
+ .where('d.id', id)
+ .select('d.id', 'c.path', 'c.mime_type')
+
+ if (!document) {
+ ctx.status = 404
+ ctx.body = { error: 'Document not found' }
+ return
+ }
+
+ // Check if document is attached to an appeal (directly or via comment)
+ const [appealDoc] = await dbPool('bm_web_appeal_documents')
+ .where({ document_id: id })
+ .select('appeal_id', 'comment_id')
+
+ if (appealDoc) {
+ // Get appeal to check permissions
+ const [appeal] = await dbPool('bm_web_appeals')
+ .where({ id: appealDoc.appeal_id })
+ .select('actor_id', 'assignee_id', 'server_id')
+
+ if (!appeal) {
+ ctx.status = 404
+ ctx.body = { error: 'Parent appeal not found' }
+ return
+ }
+
+ // Check attachment.view permission
+ const canViewAttachments = acl.hasServerPermission(appeal.server_id, 'player.appeals', 'attachment.view')
+ if (!canViewAttachments) {
+ ctx.status = 403
+ ctx.body = { error: 'You do not have permission to view attachments' }
+ return
+ }
+
+ // Check view permission on parent appeal
+ const canViewAny = acl.hasServerPermission(appeal.server_id, 'player.appeals', 'view.any')
+ const canViewOwn = acl.hasServerPermission(appeal.server_id, 'player.appeals', 'view.own') && acl.owns(appeal.actor_id)
+ const canViewAssigned = acl.hasServerPermission(appeal.server_id, 'player.appeals', 'view.assigned') && acl.owns(appeal.assignee_id)
+
+ if (!canViewAny && !canViewOwn && !canViewAssigned) {
+ ctx.status = 403
+ ctx.body = { error: 'You do not have permission to view this document' }
+ return
+ }
+
+ // If attached to a comment, also check view.comments permission
+ if (appealDoc.comment_id !== 0) {
+ const canViewComments = acl.hasServerPermission(appeal.server_id, 'player.appeals', 'view.comments')
+ if (!canViewComments) {
+ ctx.status = 403
+ ctx.body = { error: 'You do not have permission to view this document' }
+ return
+ }
+ }
+ } else {
+ // Check if document is attached to a report comment
+ const [reportCommentDoc] = await dbPool('bm_web_report_comment_documents')
+ .where({ document_id: id })
+ .select('server_id', 'comment_id')
+
+ if (reportCommentDoc) {
+ // Check attachment.view permission
+ const canViewAttachments = acl.hasServerPermission(reportCommentDoc.server_id, 'player.reports', 'attachment.view')
+ if (!canViewAttachments) {
+ ctx.status = 403
+ ctx.body = { error: 'You do not have permission to view attachments' }
+ return
+ }
+
+ // Check view.comments permission
+ const canViewComments = acl.hasServerPermission(reportCommentDoc.server_id, 'player.reports', 'view.comments')
+ if (!canViewComments) {
+ ctx.status = 403
+ ctx.body = { error: 'You do not have permission to view this document' }
+ return
+ }
+ } else {
+ // Document not linked to anything - only admins can view unlinked documents
+ const canManage = acl.hasPermission('servers', 'manage')
+ if (!canManage) {
+ ctx.status = 403
+ ctx.body = { error: 'You do not have permission to view this document' }
+ return
+ }
+ }
+ }
+
+ // Serve the file - convert stored forward slashes to platform-specific separator
+ const relativePath = document.path.replace('uploads/documents/', '').split('/').join(path.sep)
+
+ try {
+ // Security headers for user-uploaded content
+ ctx.set('Cache-Control', 'private, max-age=86400')
+ ctx.set('Content-Type', document.mime_type)
+ ctx.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox")
+ ctx.set('X-Content-Type-Options', 'nosniff')
+ ctx.set('X-Frame-Options', 'DENY')
+ ctx.set('Content-Disposition', 'inline')
+
+ await send(ctx, relativePath, { root: UPLOAD_PATH })
+ } catch (err) {
+ if (err.status === 404) {
+ ctx.status = 404
+ ctx.body = { error: 'Document file not found' }
+ } else {
+ ctx.log.error(err, 'Failed to serve document')
+ ctx.status = 500
+ ctx.body = { error: 'Failed to serve document' }
+ }
+ }
+ }
+}
diff --git a/server/routes/index.js b/server/routes/index.js
index cdf4a5686..d26b9aceb 100644
--- a/server/routes/index.js
+++ b/server/routes/index.js
@@ -1,5 +1,7 @@
const conditional = require('koa-conditional-get')
const etag = require('koa-etag')
+const multer = require('@koa/multer')
+const filesizeParser = require('filesize-parser')
const logoutRoute = require('./logout')
const sessionRoute = require('./session')
const registerRoute = require('./register')
@@ -7,6 +9,15 @@ const playerOpenGraphRoute = require('./opengraph/player')
const notificationsRoute = require('./notifications')
const subscribeRoute = require('./subscribe')
const unsubscribeRoute = require('./unsubscribe')
+const uploadRoute = require('./upload')
+const documentsRoute = require('./documents')
+
+const upload = multer({
+ storage: multer.memoryStorage(),
+ limits: {
+ fileSize: filesizeParser(process.env.UPLOAD_MAX_SIZE || '10MB')
+ }
+})
module.exports = (router, dbPool) => {
router
@@ -17,4 +28,6 @@ module.exports = (router, dbPool) => {
.post('/api/notifications/subscribe', subscribeRoute)
.post('/api/notifications/unsubscribe', unsubscribeRoute)
.get('/api/notifications/:id', notificationsRoute)
+ .post('/api/upload', upload.single('file'), uploadRoute(dbPool))
+ .get('/api/documents/:id', documentsRoute(dbPool))
}
diff --git a/server/routes/upload.js b/server/routes/upload.js
new file mode 100644
index 000000000..d45c496f5
--- /dev/null
+++ b/server/routes/upload.js
@@ -0,0 +1,201 @@
+const path = require('path')
+const fs = require('fs').promises
+const { nanoid } = require('nanoid')
+const sharp = require('sharp')
+const filesizeParser = require('filesize-parser')
+const requestIp = require('request-ip')
+const { RateLimiterMySQL } = require('rate-limiter-flexible')
+const { valid } = require('../data/session')
+const { hashContent, getContentPath } = require('../data/hash-content')
+
+const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
+const MAX_FILE_SIZE = filesizeParser(process.env.UPLOAD_MAX_SIZE || '10MB')
+const UPLOAD_PATH = process.env.UPLOAD_PATH || './uploads/documents'
+
+function getMaxDimension () {
+ return parseInt(process.env.UPLOAD_MAX_DIMENSION || '8192', 10)
+}
+
+const MIME_TO_EXT = {
+ 'image/jpeg': 'jpg',
+ 'image/png': 'png',
+ 'image/gif': 'gif',
+ 'image/webp': 'webp'
+}
+
+async function ensureUploadDir (uploadPath) {
+ try {
+ await fs.access(uploadPath)
+ } catch {
+ await fs.mkdir(uploadPath, { recursive: true })
+ }
+}
+
+module.exports = function uploadRoute (dbPool) {
+ // Rate limiter: 20 uploads per hour per IP
+ const uploadLimiter = new RateLimiterMySQL({
+ storeType: 'knex',
+ storeClient: dbPool,
+ dbName: dbPool.client.config.connection.database,
+ tableName: 'bm_web_rate_limits',
+ tableCreated: true,
+ keyPrefix: 'upload_ip',
+ points: 20,
+ duration: 60 * 60, // 1 hour
+ blockDuration: 60 * 60 // Block for 1 hour if exceeded
+ })
+
+ return async (ctx) => {
+ const { acl } = ctx.state
+ const session = ctx.session
+
+ if (!valid(session)) {
+ ctx.status = 401
+ ctx.body = { error: 'You must be logged in to upload files' }
+ return
+ }
+
+ // Check rate limit (skip for users with bypass permission)
+ const canBypassRateLimit = acl.hasPermission('player.appeals', 'attachment.ratelimit.bypass') ||
+ acl.hasPermission('player.reports', 'attachment.ratelimit.bypass')
+
+ if (!canBypassRateLimit) {
+ const ipAddr = requestIp.getClientIp(ctx.request)
+ try {
+ await uploadLimiter.consume(ipAddr)
+ } catch (rateLimitError) {
+ if (rateLimitError instanceof Error) throw rateLimitError
+
+ const retrySecs = Math.round(rateLimitError.msBeforeNext / 1000) || 1
+ ctx.set('Retry-After', String(retrySecs))
+ ctx.status = 429
+ ctx.body = { error: 'Too many uploads. Please try again later.' }
+ return
+ }
+ }
+
+ // Check if user has attachment.create permission for appeals OR reports
+ const hasAppealsPermission = acl.hasPermission('player.appeals', 'attachment.create')
+ const hasReportsPermission = acl.hasPermission('player.reports', 'attachment.create')
+
+ if (!hasAppealsPermission && !hasReportsPermission) {
+ ctx.status = 403
+ ctx.body = { error: 'You do not have permission to upload files' }
+ return
+ }
+
+ const file = ctx.request.file
+
+ if (!file) {
+ ctx.status = 400
+ ctx.body = { error: 'No file provided' }
+ return
+ }
+
+ if (file.size > MAX_FILE_SIZE) {
+ ctx.status = 400
+ ctx.body = { error: `File size exceeds maximum allowed (${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB)` }
+ return
+ }
+
+ // Validate MIME type from file buffer (magic bytes)
+ let metadata
+ try {
+ metadata = await sharp(file.buffer).metadata()
+ } catch (err) {
+ ctx.status = 400
+ ctx.body = { error: 'Invalid image file' }
+ return
+ }
+
+ const detectedMime = `image/${metadata.format}`
+ if (!ALLOWED_MIME_TYPES.includes(detectedMime)) {
+ ctx.status = 400
+ ctx.body = { error: `File type not allowed. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}` }
+ return
+ }
+
+ // Check image dimensions to prevent pixel bombs
+ const maxDimension = getMaxDimension()
+ if (metadata.width > maxDimension || metadata.height > maxDimension) {
+ ctx.status = 400
+ ctx.body = { error: `Image dimensions too large. Maximum ${maxDimension}x${maxDimension} pixels.` }
+ return
+ }
+
+ try {
+ // Re-encode image to strip EXIF metadata and validate it's a real image
+ let processedBuffer
+ if (metadata.format === 'gif') {
+ // For GIFs, keep the original to preserve animation
+ processedBuffer = file.buffer
+ } else {
+ processedBuffer = await sharp(file.buffer)
+ .rotate() // Auto-rotate based on EXIF orientation before stripping
+ .toFormat(metadata.format, { quality: 90 })
+ .toBuffer()
+ }
+
+ // Get dimensions after processing
+ const processedMetadata = await sharp(processedBuffer).metadata()
+
+ // Compute content hash for deduplication
+ const contentHash = hashContent(processedBuffer)
+ const ext = MIME_TO_EXT[detectedMime] || metadata.format
+
+ // Check if content already exists
+ const [existingContent] = await dbPool('bm_web_document_contents')
+ .where({ content_hash: contentHash })
+ .select('content_hash', 'size', 'width', 'height')
+
+ if (!existingContent) {
+ // Content doesn't exist - write file and create content record
+ const relativePath = getContentPath(contentHash, ext)
+ const fullPath = path.join(UPLOAD_PATH, relativePath)
+ const dir = path.dirname(fullPath)
+
+ await ensureUploadDir(dir)
+ await fs.writeFile(fullPath, processedBuffer)
+
+ // Insert content record (use INSERT IGNORE for race condition safety)
+ await dbPool.raw(
+ `INSERT IGNORE INTO bm_web_document_contents
+ (content_hash, path, mime_type, size, width, height)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [
+ contentHash,
+ 'uploads/documents/' + relativePath,
+ detectedMime,
+ processedBuffer.length,
+ processedMetadata.width || null,
+ processedMetadata.height || null
+ ]
+ )
+ }
+
+ // Create document record referencing the content
+ const documentId = nanoid()
+ await dbPool('bm_web_documents').insert({
+ id: documentId,
+ player_id: session.playerId,
+ filename: file.originalname || `${documentId}.${ext}`,
+ content_hash: contentHash,
+ created: Math.floor(Date.now() / 1000)
+ })
+
+ ctx.status = 200
+ ctx.body = {
+ id: documentId,
+ filename: file.originalname || `${documentId}.${ext}`,
+ mimeType: detectedMime,
+ size: existingContent ? existingContent.size : processedBuffer.length,
+ width: existingContent ? existingContent.width : processedMetadata.width,
+ height: existingContent ? existingContent.height : processedMetadata.height
+ }
+ } catch (err) {
+ ctx.log.error(err, 'Failed to process upload')
+ ctx.status = 500
+ ctx.body = { error: 'Failed to process upload' }
+ }
+ }
+}
diff --git a/server/test/adminNavigation.query.test.js b/server/test/adminNavigation.query.test.js
index 17310e03d..05cd24102 100644
--- a/server/test/adminNavigation.query.test.js
+++ b/server/test/adminNavigation.query.test.js
@@ -42,10 +42,11 @@ describe('Query adminNavigation', () => {
assert(body)
assert.deepStrictEqual(body.data.adminNavigation.left, [
- { id: '1', name: 'Notification Rules', label: 0, href: '/admin/notification-rules' },
- { id: '2', name: 'Roles', label: 3, href: '/admin/roles' },
- { id: '3', name: 'Servers', label: 1, href: '/admin/servers' },
- { id: '4', name: 'Webhooks', label: 0, href: '/admin/webhooks' }
+ { id: '1', name: 'Documents', label: 0, href: '/admin/documents' },
+ { id: '2', name: 'Notification Rules', label: 0, href: '/admin/notification-rules' },
+ { id: '3', name: 'Roles', label: 3, href: '/admin/roles' },
+ { id: '4', name: 'Servers', label: 1, href: '/admin/servers' },
+ { id: '5', name: 'Webhooks', label: 0, href: '/admin/webhooks' }
])
})
})
diff --git a/server/test/appeal.query.test.js b/server/test/appeal.query.test.js
index ad61e4c04..8a83c0c89 100644
--- a/server/test/appeal.query.test.js
+++ b/server/test/appeal.query.test.js
@@ -4,6 +4,7 @@ const supertest = require('supertest')
const createApp = require('../app')
const { createSetup, getAccount, getAuthPassword } = require('./lib')
const { createPlayer, createBan, createAppeal } = require('./fixtures')
+const { createDocumentWithContent, insertContentIgnore } = require('./fixtures/document')
describe('Query appeal', () => {
let setup
@@ -195,4 +196,88 @@ describe('Query appeal', () => {
acl: { comment: true, assign: true, state: true, delete: true }
})
})
+
+ test('should return documents attached to appeal', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+
+ const player = createPlayer()
+ const actor = createPlayer()
+ const punishment = createBan(player, actor)
+
+ await pool('bm_players').insert([player, actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appealData = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, player)
+ const [appealId] = await pool('bm_web_appeals').insert(appealData, ['id'])
+
+ const { content: content1, document: doc1 } = createDocumentWithContent(account)
+ const { content: content2, document: doc2 } = createDocumentWithContent(account)
+
+ await insertContentIgnore(setup.dbPool, content1)
+ await insertContentIgnore(setup.dbPool, content2)
+ await setup.dbPool('bm_web_documents').insert([doc1, doc2])
+ await setup.dbPool('bm_web_appeal_documents').insert([
+ { appeal_id: appealId, comment_id: 0, document_id: doc1.id },
+ { appeal_id: appealId, comment_id: 0, document_id: doc2.id }
+ ])
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `query appeal {
+ appeal(id: "${appealId}") {
+ id
+ documents {
+ id
+ }
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert(body.data.appeal.documents)
+ assert.strictEqual(body.data.appeal.documents.length, 2)
+ assert(body.data.appeal.documents.some(d => d.id === doc1.id))
+ assert(body.data.appeal.documents.some(d => d.id === doc2.id))
+ })
+
+ test('should return empty documents array when no documents attached', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const { config: server, pool } = setup.serversPool.values().next().value
+
+ const player = createPlayer()
+ const actor = createPlayer()
+ const punishment = createBan(player, actor)
+
+ await pool('bm_players').insert([player, actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appealData = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, player)
+ const [appealId] = await pool('bm_web_appeals').insert(appealData, ['id'])
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `query appeal {
+ appeal(id: "${appealId}") {
+ id
+ documents {
+ id
+ }
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert(body.data.appeal.documents)
+ assert.strictEqual(body.data.appeal.documents.length, 0)
+ })
})
diff --git a/server/test/createAppealComment.mutation.test.js b/server/test/createAppealComment.mutation.test.js
index 4fbcfd3f9..22b5c2d65 100644
--- a/server/test/createAppealComment.mutation.test.js
+++ b/server/test/createAppealComment.mutation.test.js
@@ -3,6 +3,7 @@ const supertest = require('supertest')
const createApp = require('../app')
const { createSetup, getAuthPassword, getAccount, setTempRole } = require('./lib')
const { createPlayer, createAppeal, createBan } = require('./fixtures')
+const { createDocumentWithContent, insertContentIgnore } = require('./fixtures/document')
const { getUnreadNotificationsCount } = require('../data/notification')
const { getAppealWatchers, subscribeAppeal } = require('../data/notification/appeal')
@@ -253,4 +254,236 @@ describe('Mutation createAppealComment', () => {
assert(body)
assert.strictEqual(body.errors[0].message, 'You cannot comment on a closed appeal')
})
+
+ test('should error when attaching documents without attachment.create permission', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+ const player = createPlayer()
+ const actor = createPlayer()
+ const punishment = createBan(player, actor)
+
+ await pool('bm_players').insert([player, actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appeal = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, account)
+ const [appealId] = await pool('bm_web_appeals').insert(appeal, ['id'])
+
+ const { content, document } = createDocumentWithContent(account)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ // Only comment.any permission, no attachment.create
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'comment.any', 'view.any')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation createAppealComment($documents: [ID!]) {
+ createAppealComment(id: ${appealId} input: { content: "test", documents: $documents }) {
+ content
+ }
+ }`,
+ variables: { documents: [document.id] }
+ })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.errors)
+ assert.strictEqual(body.errors[0].message, 'You do not have permission to attach files')
+ })
+
+ test('should link documents when user has attachment.create permission', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+ const player = createPlayer()
+ const actor = createPlayer()
+ const punishment = createBan(player, actor)
+
+ await pool('bm_players').insert([player, actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appeal = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, account)
+ const [appealId] = await pool('bm_web_appeals').insert(appeal, ['id'])
+
+ const { content, document } = createDocumentWithContent(account)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'comment.any', 'view.any', 'attachment.create')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation createAppealComment($documents: [ID!]) {
+ createAppealComment(id: ${appealId} input: { content: "test with attachment", documents: $documents }) {
+ id
+ content
+ documents {
+ id
+ }
+ }
+ }`,
+ variables: { documents: [document.id] }
+ })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert.strictEqual(body.data.createAppealComment.content, 'test with attachment')
+ assert.strictEqual(body.data.createAppealComment.documents.length, 1)
+ assert.strictEqual(body.data.createAppealComment.documents[0].id, document.id)
+
+ // Verify database link was created
+ const [link] = await setup.dbPool('bm_web_appeal_documents')
+ .where({ appeal_id: appealId, comment_id: body.data.createAppealComment.id, document_id: document.id })
+ assert(link)
+ })
+
+ test('should not link documents belonging to another user', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+ const player = createPlayer()
+ const actor = createPlayer()
+ const otherPlayer = createPlayer()
+ const punishment = createBan(player, actor)
+
+ await pool('bm_players').insert([player, actor, otherPlayer])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appeal = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, account)
+ const [appealId] = await pool('bm_web_appeals').insert(appeal, ['id'])
+
+ // Create document belonging to another player
+ const { content, document } = createDocumentWithContent(otherPlayer)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'comment.any', 'view.any', 'attachment.create')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation createAppealComment($documents: [ID!]) {
+ createAppealComment(id: ${appealId} input: { content: "test", documents: $documents }) {
+ id
+ content
+ documents {
+ id
+ }
+ }
+ }`,
+ variables: { documents: [document.id] }
+ })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ // Comment should be created but no documents linked
+ assert.strictEqual(body.data.createAppealComment.content, 'test')
+ assert.strictEqual(body.data.createAppealComment.documents.length, 0)
+ })
+
+ test('should ignore non-existent document IDs', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+ const player = createPlayer()
+ const actor = createPlayer()
+ const punishment = createBan(player, actor)
+
+ await pool('bm_players').insert([player, actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appeal = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, account)
+ const [appealId] = await pool('bm_web_appeals').insert(appeal, ['id'])
+
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'comment.any', 'view.any', 'attachment.create')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation createAppealComment($documents: [ID!]) {
+ createAppealComment(id: ${appealId} input: { content: "test", documents: $documents }) {
+ id
+ content
+ documents {
+ id
+ }
+ }
+ }`,
+ variables: { documents: ['nonexistent-doc-id-1', 'nonexistent-doc-id-2'] }
+ })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ // Comment should be created but no documents linked
+ assert.strictEqual(body.data.createAppealComment.content, 'test')
+ assert.strictEqual(body.data.createAppealComment.documents.length, 0)
+ })
+
+ test('should error when attaching more than 3 documents', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+ const player = createPlayer()
+ const actor = createPlayer()
+ const punishment = createBan(player, actor)
+
+ await pool('bm_players').insert([player, actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appeal = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, account)
+ const [appealId] = await pool('bm_web_appeals').insert(appeal, ['id'])
+
+ // Create 4 documents
+ const docs = [
+ createDocumentWithContent(account),
+ createDocumentWithContent(account),
+ createDocumentWithContent(account),
+ createDocumentWithContent(account)
+ ]
+ for (const { content, document } of docs) {
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+ }
+ const documents = docs.map(d => d.document)
+
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'comment.any', 'view.any', 'attachment.create')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation createAppealComment($documents: [ID!]) {
+ createAppealComment(id: ${appealId} input: { content: "test", documents: $documents }) {
+ id
+ content
+ }
+ }`,
+ variables: { documents: documents.map(d => d.id) }
+ })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 400)
+ assert(body.errors)
+ assert(body.errors[0].message.includes('3'))
+ })
})
diff --git a/server/test/createReportComment.mutation.test.js b/server/test/createReportComment.mutation.test.js
index 641882883..38c232321 100644
--- a/server/test/createReportComment.mutation.test.js
+++ b/server/test/createReportComment.mutation.test.js
@@ -3,6 +3,7 @@ const supertest = require('supertest')
const createApp = require('../app')
const { createSetup, getAuthPassword, getAccount, setTempRole } = require('./lib')
const { createPlayer, createReport } = require('./fixtures')
+const { createDocumentWithContent, insertContentIgnore } = require('./fixtures/document')
const { getUnreadNotificationsCount } = require('../data/notification')
const { getReportWatchers, subscribeReport } = require('../data/notification/report')
@@ -299,4 +300,176 @@ describe('Mutation createReportComment', () => {
assert(body)
assert.strictEqual(body.errors[0].message, 'You cannot comment on a closed report')
})
+
+ test('should error when attaching documents without attachment.create permission', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+ const player = createPlayer()
+ const report = createReport(player, player)
+
+ await pool('bm_players').insert(player)
+
+ const [inserted] = await pool('bm_player_reports').insert(report, ['id'])
+
+ const { content, document } = createDocumentWithContent(account)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ // Only comment.any permission, no attachment.create
+ const role = await setTempRole(setup.dbPool, account, 'player.reports', 'comment.any', 'view.any')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation createReportComment($documents: [ID!]) {
+ createReportComment(serverId: "${server.id}", report: ${inserted}, input: { comment: "test", documents: $documents }) {
+ comment
+ }
+ }`,
+ variables: { documents: [document.id] }
+ })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.errors)
+ assert.strictEqual(body.errors[0].message, 'You do not have permission to attach files')
+ })
+
+ test('should link documents when user has attachment.create permission', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+ const player = createPlayer()
+ const report = createReport(player, player)
+
+ await pool('bm_players').insert(player)
+
+ const [inserted] = await pool('bm_player_reports').insert(report, ['id'])
+
+ const { content, document } = createDocumentWithContent(account)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ const role = await setTempRole(setup.dbPool, account, 'player.reports', 'comment.any', 'view.any', 'attachment.create', 'attachment.view')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation createReportComment($documents: [ID!]) {
+ createReportComment(serverId: "${server.id}", report: ${inserted}, input: { comment: "test with attachment", documents: $documents }) {
+ id
+ comment
+ documents {
+ id
+ filename
+ }
+ }
+ }`,
+ variables: { documents: [document.id] }
+ })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert.strictEqual(body.data.createReportComment.comment, 'test with attachment')
+ assert.strictEqual(body.data.createReportComment.documents.length, 1)
+ assert.strictEqual(body.data.createReportComment.documents[0].id, document.id)
+ })
+
+ test('should not link documents owned by another player', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+ const player = createPlayer()
+ const otherPlayer = createPlayer()
+ const report = createReport(player, player)
+
+ await pool('bm_players').insert([player, otherPlayer])
+
+ const [inserted] = await pool('bm_player_reports').insert(report, ['id'])
+
+ // Create document belonging to another player
+ const { content, document } = createDocumentWithContent(otherPlayer)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ const role = await setTempRole(setup.dbPool, account, 'player.reports', 'comment.any', 'view.any', 'attachment.create', 'attachment.view')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation createReportComment($documents: [ID!]) {
+ createReportComment(serverId: "${server.id}", report: ${inserted}, input: { comment: "test", documents: $documents }) {
+ id
+ comment
+ documents {
+ id
+ }
+ }
+ }`,
+ variables: { documents: [document.id] }
+ })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert.strictEqual(body.data.createReportComment.documents.length, 0)
+ })
+
+ test('should error when attaching more than 3 documents', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+ const player = createPlayer()
+ const report = createReport(player, player)
+
+ await pool('bm_players').insert(player)
+
+ const [inserted] = await pool('bm_player_reports').insert(report, ['id'])
+
+ // Create 4 documents
+ const docs = [
+ createDocumentWithContent(account),
+ createDocumentWithContent(account),
+ createDocumentWithContent(account),
+ createDocumentWithContent(account)
+ ]
+ for (const { content, document } of docs) {
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+ }
+ const documents = docs.map(d => d.document)
+
+ const role = await setTempRole(setup.dbPool, account, 'player.reports', 'comment.any', 'view.any', 'attachment.create')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation createReportComment($documents: [ID!]) {
+ createReportComment(serverId: "${server.id}", report: ${inserted}, input: { comment: "test", documents: $documents }) {
+ id
+ comment
+ }
+ }`,
+ variables: { documents: documents.map(d => d.id) }
+ })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 400)
+ assert(body.errors)
+ assert(body.errors[0].message.includes('3'))
+ })
})
diff --git a/server/test/deleteDocument.mutation.test.js b/server/test/deleteDocument.mutation.test.js
new file mode 100644
index 000000000..899c13dba
--- /dev/null
+++ b/server/test/deleteDocument.mutation.test.js
@@ -0,0 +1,287 @@
+const assert = require('assert')
+const supertest = require('supertest')
+const createApp = require('../app')
+const { createSetup, getAuthPassword, getAccount, setTempRole } = require('./lib')
+const { createPlayer, createAppeal, createBan } = require('./fixtures')
+const { createDocumentWithContent, insertContentIgnore } = require('./fixtures/document')
+
+describe('Mutation deleteDocument', () => {
+ let setup
+ let request
+
+ beforeAll(async () => {
+ setup = await createSetup()
+ const app = await createApp({ ...setup, disableUI: true })
+
+ request = supertest(app.callback())
+ }, 20000)
+
+ afterAll(async () => {
+ await setup.teardown()
+ })
+
+ test('should error if unauthenticated', async () => {
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation deleteDocument {
+ deleteDocument(id: "test-id") {
+ id
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.errors)
+ assert.strictEqual(body.errors[0].message,
+ 'You do not have permission to perform this action, please contact your server administrator')
+ })
+
+ test('should error if document does not exist', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation deleteDocument {
+ deleteDocument(id: "nonexistent-id") {
+ id
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.errors)
+ assert.strictEqual(body.errors[0].message, 'Document nonexistent-id does not exist')
+ })
+
+ test('should error without delete permission on unlinked document', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { content, document } = createDocumentWithContent(account)
+
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation deleteDocument {
+ deleteDocument(id: "${document.id}") {
+ id
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.errors)
+ assert.strictEqual(body.errors[0].message, 'You do not have permission to delete this document')
+ })
+
+ test('should allow admin to delete any document', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ const userCookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const userAccount = await getAccount(request, userCookie)
+
+ const { content, document } = createDocumentWithContent(userAccount)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation deleteDocument {
+ deleteDocument(id: "${document.id}") {
+ id
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert.strictEqual(body.data.deleteDocument.id, document.id)
+
+ // Verify document deleted
+ const [deleted] = await setup.dbPool('bm_web_documents').where({ id: document.id })
+ assert(!deleted)
+
+ // Verify content also deleted (no more references)
+ const [deletedContent] = await setup.dbPool('bm_web_document_contents').where({ content_hash: content.content_hash })
+ assert(!deletedContent)
+ })
+
+ test('should allow attachment.delete.own on appeal document', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+
+ const actor = createPlayer()
+ const punishment = createBan(account, actor)
+
+ await pool('bm_players').insert([actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appealData = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, account)
+ const [appealId] = await setup.dbPool('bm_web_appeals').insert(appealData, ['id'])
+
+ const { content, document } = createDocumentWithContent(account)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+ await setup.dbPool('bm_web_appeal_documents').insert({ appeal_id: appealId, comment_id: 0, document_id: document.id })
+
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'attachment.delete.own')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation deleteDocument {
+ deleteDocument(id: "${document.id}") {
+ id
+ }
+ }`
+ })
+
+ await role.reset()
+
+ // Clean up ban to avoid unique constraint violation in next test
+ await pool('bm_player_bans').where({ id: banId }).del()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert.strictEqual(body.data.deleteDocument.id, document.id)
+ })
+
+ test('should not allow attachment.delete.own on other user document', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+
+ const adminCookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const adminAccount = await getAccount(request, adminCookie)
+
+ const { config: server, pool } = setup.serversPool.values().next().value
+
+ const actor = createPlayer()
+ const punishment = createBan(account, actor)
+
+ await pool('bm_players').insert([actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appealData = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, account)
+ const [appealId] = await setup.dbPool('bm_web_appeals').insert(appealData, ['id'])
+
+ // Document uploaded by admin
+ const { content, document } = createDocumentWithContent(adminAccount)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+ await setup.dbPool('bm_web_appeal_documents').insert({ appeal_id: appealId, comment_id: 0, document_id: document.id })
+
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'attachment.delete.own')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation deleteDocument {
+ deleteDocument(id: "${document.id}") {
+ id
+ }
+ }`
+ })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.errors)
+ assert.strictEqual(body.errors[0].message, 'You do not have permission to delete this document')
+ })
+
+ test('should remove document from junction table', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const adminAccount = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+
+ const actor = createPlayer()
+ const punishment = createBan(adminAccount, actor)
+
+ await pool('bm_players').insert([actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appealData = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, adminAccount)
+ const [appealId] = await setup.dbPool('bm_web_appeals').insert(appealData, ['id'])
+
+ const { content, document } = createDocumentWithContent(adminAccount)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+ await setup.dbPool('bm_web_appeal_documents').insert({ appeal_id: appealId, comment_id: 0, document_id: document.id })
+
+ await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation deleteDocument {
+ deleteDocument(id: "${document.id}") {
+ id
+ }
+ }`
+ })
+
+ // Verify junction table entry removed
+ const [link] = await setup.dbPool('bm_web_appeal_documents').where({ document_id: document.id })
+ assert(!link)
+ })
+
+ test('should keep content when other documents still reference it', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const adminAccount = await getAccount(request, cookie)
+
+ // Create shared content with two documents
+ const { content, document: doc1 } = createDocumentWithContent(adminAccount, { filename: 'doc1.jpg' })
+ const doc2 = {
+ id: require('nanoid').nanoid(),
+ player_id: adminAccount.id,
+ filename: 'doc2.jpg',
+ content_hash: content.content_hash,
+ created: Math.floor(Date.now() / 1000)
+ }
+
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert([doc1, doc2])
+
+ // Delete first document
+ await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `mutation deleteDocument {
+ deleteDocument(id: "${doc1.id}") {
+ id
+ }
+ }`
+ })
+
+ // Verify first document deleted
+ const [deletedDoc] = await setup.dbPool('bm_web_documents').where({ id: doc1.id })
+ assert(!deletedDoc)
+
+ // Verify content still exists (second doc still references it)
+ const [contentExists] = await setup.dbPool('bm_web_document_contents').where({ content_hash: content.content_hash })
+ assert(contentExists)
+
+ // Verify second document still exists
+ const [doc2Exists] = await setup.dbPool('bm_web_documents').where({ id: doc2.id })
+ assert(doc2Exists)
+ })
+})
diff --git a/server/test/documents.test.js b/server/test/documents.test.js
new file mode 100644
index 000000000..3c9e47421
--- /dev/null
+++ b/server/test/documents.test.js
@@ -0,0 +1,183 @@
+const assert = require('assert')
+const supertest = require('supertest')
+const createApp = require('../app')
+const { createSetup, getAuthPassword, getAccount, setTempRole } = require('./lib')
+const { createPlayer, createAppeal, createBan } = require('./fixtures')
+const { createDocumentWithContent, insertContentIgnore } = require('./fixtures/document')
+
+describe('GET /api/documents/:id', () => {
+ let setup
+ let request
+
+ beforeAll(async () => {
+ setup = await createSetup()
+ const app = await createApp({ ...setup, disableUI: true })
+
+ request = supertest(app.callback())
+ }, 20000)
+
+ afterAll(async () => {
+ await setup.teardown()
+ })
+
+ test('should error if document not found', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ const { statusCode, body } = await request
+ .get('/api/documents/nonexistent-id')
+ .set('Cookie', cookie)
+
+ assert.strictEqual(statusCode, 404)
+ assert.strictEqual(body.error, 'Document not found')
+ })
+
+ test('should error if unlinked document and not admin', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { content, document } = createDocumentWithContent(account)
+
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ const { statusCode, body } = await request
+ .get(`/api/documents/${document.id}`)
+ .set('Cookie', cookie)
+
+ assert.strictEqual(statusCode, 403)
+ assert.strictEqual(body.error, 'You do not have permission to view this document')
+ })
+
+ test('should allow admin to view unlinked document', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { content, document } = createDocumentWithContent(account)
+
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ const { statusCode } = await request
+ .get(`/api/documents/${document.id}`)
+ .set('Cookie', cookie)
+
+ // Will be 404 because file doesn't actually exist on disk, but permission check passed
+ assert(statusCode === 200 || statusCode === 404)
+ })
+
+ test('should error without view permission on appeal', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+
+ const player = createPlayer()
+ const actor = createPlayer()
+ const punishment = createBan(player, actor)
+
+ await pool('bm_players').insert([player, actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appealData = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, player)
+ const [appealId] = await setup.dbPool('bm_web_appeals').insert(appealData, ['id'])
+
+ const { content, document } = createDocumentWithContent(account)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+ await setup.dbPool('bm_web_appeal_documents').insert({ appeal_id: appealId, comment_id: 0, document_id: document.id })
+
+ const { statusCode, body } = await request
+ .get(`/api/documents/${document.id}`)
+ .set('Cookie', cookie)
+
+ assert.strictEqual(statusCode, 403)
+ assert.strictEqual(body.error, 'You do not have permission to view this document')
+ })
+
+ test('should error without attachment.view permission even with view.own', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+
+ const actor = createPlayer()
+ const punishment = createBan(account, actor)
+
+ await pool('bm_players').insert([actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appealData = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, account)
+ const [appealId] = await setup.dbPool('bm_web_appeals').insert(appealData, ['id'])
+
+ const { content, document } = createDocumentWithContent(account)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+ await setup.dbPool('bm_web_appeal_documents').insert({ appeal_id: appealId, comment_id: 0, document_id: document.id })
+
+ // Remove user from role 2 (Logged In) which has attachment.view by default
+ await setup.dbPool('bm_web_player_roles').where({ player_id: account.id, role_id: 2 }).del()
+
+ // Only view.own, no attachment.view
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'view.own')
+
+ const { statusCode, body } = await request
+ .get(`/api/documents/${document.id}`)
+ .set('Cookie', cookie)
+
+ await role.reset()
+
+ // Restore user to role 2
+ await setup.dbPool('bm_web_player_roles').insert({ player_id: account.id, role_id: 2 })
+
+ // Clean up ban to avoid unique constraint violation in next test
+ await pool('bm_player_bans').where({ id: banId }).del()
+
+ assert.strictEqual(statusCode, 403)
+ assert.strictEqual(body.error, 'You do not have permission to view attachments')
+ })
+
+ test('should allow view.own with attachment.view to see own appeal document', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+
+ const actor = createPlayer()
+ const punishment = createBan(account, actor)
+
+ await pool('bm_players').insert([actor])
+
+ const [banId] = await pool('bm_player_bans').insert(punishment, ['id'])
+ const appealData = createAppeal({ ...punishment, id: banId }, 'PlayerBan', server, account)
+ const [appealId] = await setup.dbPool('bm_web_appeals').insert(appealData, ['id'])
+
+ const { content, document } = createDocumentWithContent(account)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+ await setup.dbPool('bm_web_appeal_documents').insert({ appeal_id: appealId, comment_id: 0, document_id: document.id })
+
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'view.own', 'attachment.view')
+
+ const { statusCode } = await request
+ .get(`/api/documents/${document.id}`)
+ .set('Cookie', cookie)
+
+ await role.reset()
+
+ // Will be 404 because file doesn't exist on disk, but permission check passed
+ assert(statusCode === 200 || statusCode === 404)
+ })
+
+ test('should include Cache-Control: private header', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { content, document } = createDocumentWithContent(account)
+
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ const { headers } = await request
+ .get(`/api/documents/${document.id}`)
+ .set('Cookie', cookie)
+
+ // Even if the file doesn't exist, if we got past permission check, header should be set
+ if (headers['cache-control']) {
+ assert(headers['cache-control'].includes('private'))
+ }
+ })
+})
diff --git a/server/test/fixtures/document.js b/server/test/fixtures/document.js
new file mode 100644
index 000000000..ff16fbf7c
--- /dev/null
+++ b/server/test/fixtures/document.js
@@ -0,0 +1,60 @@
+const { nanoid } = require('nanoid')
+const crypto = require('crypto')
+
+// Generate a consistent content hash for fixtures (based on options or random)
+function generateContentHash (options = {}) {
+ const seed = options.contentHash || options.id || nanoid()
+ return crypto.createHash('sha256').update(seed).digest('hex')
+}
+
+// Creates a document content record
+function createDocumentContent (options = {}) {
+ const contentHash = options.contentHash || generateContentHash(options)
+ return {
+ content_hash: contentHash,
+ path: options.path || `uploads/documents/${contentHash.slice(0, 2)}/${contentHash.slice(2, 4)}/${contentHash}.jpg`,
+ mime_type: options.mimeType || 'image/jpeg',
+ size: options.size || 1024,
+ width: options.width || 800,
+ height: options.height || 600
+ }
+}
+
+// Creates a document record - requires content to be inserted first or provided
+function createDocument (player, options = {}) {
+ const contentHash = options.contentHash || generateContentHash(options)
+ return {
+ id: options.id || nanoid(),
+ player_id: player.id,
+ filename: options.filename || 'test-image.jpg',
+ content_hash: contentHash,
+ created: options.created || Math.floor(Date.now() / 1000)
+ }
+}
+
+// Helper to create both content and document records for tests
+function createDocumentWithContent (player, options = {}) {
+ const contentHash = generateContentHash(options)
+ const contentOptions = { ...options, contentHash }
+ return {
+ content: createDocumentContent(contentOptions),
+ document: createDocument(player, contentOptions)
+ }
+}
+
+// Helper to insert content record (uses INSERT IGNORE for MySQL compatibility)
+async function insertContentIgnore (dbPool, content) {
+ await dbPool.raw(
+ `INSERT IGNORE INTO bm_web_document_contents
+ (content_hash, path, mime_type, size, width, height)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ [content.content_hash, content.path, content.mime_type, content.size, content.width, content.height]
+ )
+}
+
+module.exports = createDocument
+module.exports.createDocument = createDocument
+module.exports.createDocumentContent = createDocumentContent
+module.exports.createDocumentWithContent = createDocumentWithContent
+module.exports.generateContentHash = generateContentHash
+module.exports.insertContentIgnore = insertContentIgnore
diff --git a/server/test/fixtures/index.js b/server/test/fixtures/index.js
index 06410298b..adbc967b1 100644
--- a/server/test/fixtures/index.js
+++ b/server/test/fixtures/index.js
@@ -10,5 +10,6 @@ module.exports = {
createReport: require('./report'),
createReportComment: require('./report-comment'),
createWarning: require('./warning'),
- createAppeal: require('./appeal')
+ createAppeal: require('./appeal'),
+ createDocument: require('./document')
}
diff --git a/server/test/listDocuments.query.test.js b/server/test/listDocuments.query.test.js
new file mode 100644
index 000000000..9a37d1efc
--- /dev/null
+++ b/server/test/listDocuments.query.test.js
@@ -0,0 +1,206 @@
+const assert = require('assert')
+const { unparse } = require('uuid-parse')
+const supertest = require('supertest')
+const createApp = require('../app')
+const { createSetup, getAuthPassword, getAccount } = require('./lib')
+const { createDocumentWithContent, insertContentIgnore } = require('./fixtures/document')
+
+describe('Query listDocuments', () => {
+ let setup
+ let request
+
+ beforeAll(async () => {
+ setup = await createSetup()
+ const app = await createApp({ ...setup, disableUI: true })
+
+ request = supertest(app.callback())
+ }, 20000)
+
+ afterAll(async () => {
+ await setup.teardown()
+ })
+
+ test('should error without servers.manage permission', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `query listDocuments {
+ listDocuments {
+ total
+ records {
+ id
+ }
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.errors)
+ assert.strictEqual(body.errors[0].message,
+ 'You do not have permission to perform this action, please contact your server administrator')
+ })
+
+ test('should return paginated results', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const account = await getAccount(request, cookie)
+
+ // Create test documents
+ const docs = [
+ createDocumentWithContent(account, { created: Math.floor(Date.now() / 1000) - 100 }),
+ createDocumentWithContent(account, { created: Math.floor(Date.now() / 1000) - 50 }),
+ createDocumentWithContent(account, { created: Math.floor(Date.now() / 1000) })
+ ]
+
+ for (const { content, document } of docs) {
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+ }
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `query listDocuments {
+ listDocuments(limit: 2) {
+ total
+ records {
+ id
+ filename
+ mimeType
+ size
+ }
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert(body.data.listDocuments.total >= 3)
+ assert.strictEqual(body.data.listDocuments.records.length, 2)
+ })
+
+ test('should filter by player', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const account = await getAccount(request, cookie)
+
+ const userCookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const userAccount = await getAccount(request, userCookie)
+
+ // Create documents for different users
+ const { content: adminContent, document: adminDoc } = createDocumentWithContent(account)
+ const { content: userContent, document: userDoc } = createDocumentWithContent(userAccount)
+
+ await insertContentIgnore(setup.dbPool, adminContent)
+ await insertContentIgnore(setup.dbPool, userContent)
+ await setup.dbPool('bm_web_documents').insert([adminDoc, userDoc])
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `query listDocuments($player: UUID!) {
+ listDocuments(player: $player) {
+ total
+ records {
+ id
+ player {
+ id
+ }
+ }
+ }
+ }`,
+ variables: { player: unparse(userAccount.id) }
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert(body.data.listDocuments.total >= 1)
+
+ // All returned documents should belong to user
+ body.data.listDocuments.records.forEach(doc => {
+ assert.strictEqual(doc.player.id, unparse(userAccount.id))
+ })
+ })
+
+ test('should filter by date range', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const account = await getAccount(request, cookie)
+
+ const now = Math.floor(Date.now() / 1000)
+ const hourAgo = now - 3600
+ const twoHoursAgo = now - 7200
+
+ // Create documents with different timestamps
+ const { content: oldContent, document: oldDoc } = createDocumentWithContent(account, { created: twoHoursAgo })
+ const { content: newContent, document: newDoc } = createDocumentWithContent(account, { created: now })
+
+ await insertContentIgnore(setup.dbPool, oldContent)
+ await insertContentIgnore(setup.dbPool, newContent)
+ await setup.dbPool('bm_web_documents').insert([oldDoc, newDoc])
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `query listDocuments($dateStart: Timestamp!, $dateEnd: Timestamp!) {
+ listDocuments(dateStart: $dateStart, dateEnd: $dateEnd) {
+ total
+ records {
+ id
+ created
+ }
+ }
+ }`,
+ variables: { dateStart: hourAgo, dateEnd: now + 60 }
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+
+ // All returned documents should be within date range
+ body.data.listDocuments.records.forEach(doc => {
+ assert(doc.created >= hourAgo)
+ assert(doc.created <= now + 60)
+ })
+ })
+
+ test('should include player information', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const account = await getAccount(request, cookie)
+
+ const { content, document } = createDocumentWithContent(account)
+ await insertContentIgnore(setup.dbPool, content)
+ await setup.dbPool('bm_web_documents').insert(document)
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `query listDocuments {
+ listDocuments(limit: 1) {
+ records {
+ id
+ player {
+ id
+ name
+ }
+ }
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert(body.data.listDocuments.records.length > 0)
+ assert(body.data.listDocuments.records[0].player)
+ assert(body.data.listDocuments.records[0].player.id)
+ })
+})
diff --git a/server/test/report.query.test.js b/server/test/report.query.test.js
index b59e90408..a8bcfa209 100644
--- a/server/test/report.query.test.js
+++ b/server/test/report.query.test.js
@@ -4,6 +4,7 @@ const supertest = require('supertest')
const createApp = require('../app')
const { createSetup, getAccount, getAuthPassword } = require('./lib')
const { createPlayer, createReport } = require('./fixtures')
+const { createDocumentWithContent, insertContentIgnore } = require('./fixtures/document')
describe('Query report', () => {
let setup
@@ -284,4 +285,93 @@ describe('Query report', () => {
]
})
})
+
+ test('should return documents attached to report comments', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const { config: server, pool } = setup.serversPool.values().next().value
+
+ const player = createPlayer()
+ const actor = createPlayer()
+
+ await pool('bm_players').insert([player, actor])
+
+ const reportData = createReport(player, actor)
+ const [reportId] = await pool('bm_player_reports').insert(reportData, ['id'])
+
+ // Create a comment on the report
+ const [commentId] = await pool('bm_player_report_comments').insert({
+ report_id: reportId,
+ actor_id: account.id,
+ comment: 'Test comment with attachments',
+ created: Math.floor(Date.now() / 1000),
+ updated: Math.floor(Date.now() / 1000)
+ }, ['id'])
+
+ const { content: content1, document: doc1 } = createDocumentWithContent(account)
+ const { content: content2, document: doc2 } = createDocumentWithContent(account)
+
+ await insertContentIgnore(setup.dbPool, content1)
+ await insertContentIgnore(setup.dbPool, content2)
+ await setup.dbPool('bm_web_documents').insert([doc1, doc2])
+ await setup.dbPool('bm_web_report_comment_documents').insert([
+ { server_id: server.id, comment_id: commentId, document_id: doc1.id },
+ { server_id: server.id, comment_id: commentId, document_id: doc2.id }
+ ])
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `query report {
+ report(serverId: "${server.id}", id: "${reportId}") {
+ id
+ documents {
+ id
+ }
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert(body.data.report.documents)
+ assert.strictEqual(body.data.report.documents.length, 2)
+ assert(body.data.report.documents.some(d => d.id === doc1.id))
+ assert(body.data.report.documents.some(d => d.id === doc2.id))
+ })
+
+ test('should return empty documents array when no documents attached', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const { config: server, pool } = setup.serversPool.values().next().value
+
+ const player = createPlayer()
+ const actor = createPlayer()
+
+ await pool('bm_players').insert([player, actor])
+
+ const reportData = createReport(player, actor)
+ const [reportId] = await pool('bm_player_reports').insert(reportData, ['id'])
+
+ const { body, statusCode } = await request
+ .post('/graphql')
+ .set('Cookie', cookie)
+ .set('Accept', 'application/json')
+ .send({
+ query: `query report {
+ report(serverId: "${server.id}", id: "${reportId}") {
+ id
+ documents {
+ id
+ }
+ }
+ }`
+ })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.data)
+ assert(body.data.report.documents)
+ assert.strictEqual(body.data.report.documents.length, 0)
+ })
})
diff --git a/server/test/upload.test.js b/server/test/upload.test.js
new file mode 100644
index 000000000..b1947a8e2
--- /dev/null
+++ b/server/test/upload.test.js
@@ -0,0 +1,371 @@
+const assert = require('assert')
+const os = require('os')
+const path = require('path')
+const fs = require('fs').promises
+const supertest = require('supertest')
+const createApp = require('../app')
+const { createSetup, getAuthPassword, getAccount, setTempRole } = require('./lib')
+
+describe('POST /api/upload', () => {
+ let setup
+ let request
+ let tempUploadDir
+ let originalUploadPath
+ let originalMaxDimension
+
+ beforeAll(async () => {
+ // Create temp directory for uploads
+ tempUploadDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bm-upload-test-'))
+ originalUploadPath = process.env.UPLOAD_PATH
+ originalMaxDimension = process.env.UPLOAD_MAX_DIMENSION
+ process.env.UPLOAD_PATH = tempUploadDir
+
+ setup = await createSetup()
+ const app = await createApp({ ...setup, disableUI: true })
+
+ request = supertest(app.callback())
+ }, 20000)
+
+ afterAll(async () => {
+ await setup.teardown()
+
+ // Clean up temp upload directory
+ try {
+ await fs.rm(tempUploadDir, { recursive: true, force: true })
+ } catch (err) {
+ console.error('Failed to clean up temp upload dir:', err)
+ }
+
+ // Restore env vars
+ if (originalUploadPath) {
+ process.env.UPLOAD_PATH = originalUploadPath
+ } else {
+ delete process.env.UPLOAD_PATH
+ }
+ if (originalMaxDimension) {
+ process.env.UPLOAD_MAX_DIMENSION = originalMaxDimension
+ } else {
+ delete process.env.UPLOAD_MAX_DIMENSION
+ }
+ })
+
+ // Helper to create a valid 1x1 JPEG
+ const createJpegBuffer = () => Buffer.from([
+ 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
+ 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
+ 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
+ 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
+ 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
+ 0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
+ 0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32,
+ 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
+ 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00,
+ 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
+ 0x09, 0x0A, 0x0B, 0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03,
+ 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D,
+ 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06,
+ 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
+ 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72,
+ 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28,
+ 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45,
+ 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59,
+ 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
+ 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
+ 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3,
+ 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6,
+ 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9,
+ 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
+ 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4,
+ 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01,
+ 0x00, 0x00, 0x3F, 0x00, 0xFB, 0xD5, 0xDB, 0x20, 0xA8, 0xF1, 0x7F, 0xFF,
+ 0xD9
+ ])
+
+ // Helper to create a valid 1x1 PNG
+ const createPngBuffer = () => Buffer.from([
+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
+ 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
+ 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00,
+ 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xFF, 0xFF, 0x3F,
+ 0x00, 0x05, 0xFE, 0x02, 0xFE, 0xDC, 0xCC, 0x59, 0xE7, 0x00, 0x00, 0x00,
+ 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82
+ ])
+
+ describe('authentication and permissions', () => {
+ test('should error if unauthenticated', async () => {
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .attach('file', createJpegBuffer(), { filename: 'test.jpg', contentType: 'image/jpeg' })
+
+ assert.strictEqual(statusCode, 401)
+ assert.strictEqual(body.error, 'You must be logged in to upload files')
+ })
+
+ test('should error without attachment.create permission', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', createJpegBuffer(), { filename: 'test.jpg', contentType: 'image/jpeg' })
+
+ assert.strictEqual(statusCode, 403)
+ assert.strictEqual(body.error, 'You do not have permission to upload files')
+ })
+
+ test('should allow upload with attachment.create permission on appeals', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'attachment.create')
+
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', createPngBuffer(), { filename: 'test.png', contentType: 'image/png' })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.id)
+ assert.strictEqual(body.mimeType, 'image/png')
+ })
+
+ test('should allow upload with attachment.create permission on reports', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const role = await setTempRole(setup.dbPool, account, 'player.reports', 'attachment.create')
+
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', createPngBuffer(), { filename: 'test.png', contentType: 'image/png' })
+
+ await role.reset()
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.id)
+ })
+ })
+
+ describe('file validation', () => {
+ test('should error if no file provided', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+
+ assert.strictEqual(statusCode, 400)
+ assert.strictEqual(body.error, 'No file provided')
+ })
+
+ test('should reject non-image files', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', Buffer.from('invalid data'), { filename: 'test.txt', contentType: 'text/plain' })
+
+ assert.strictEqual(statusCode, 400)
+ assert.strictEqual(body.error, 'Invalid image file')
+ })
+
+ test('should reject files with fake image extension but invalid content', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', Buffer.from('not a real image'), { filename: 'fake.jpg', contentType: 'image/jpeg' })
+
+ assert.strictEqual(statusCode, 400)
+ assert.strictEqual(body.error, 'Invalid image file')
+ })
+ })
+
+ describe('successful uploads', () => {
+ test('should process and store valid JPEG images', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', createJpegBuffer(), { filename: 'test.jpg', contentType: 'image/jpeg' })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.id)
+ assert.strictEqual(body.filename, 'test.jpg')
+ assert.strictEqual(body.mimeType, 'image/jpeg')
+ assert(body.size > 0)
+ assert(body.width > 0)
+ assert(body.height > 0)
+ })
+
+ test('should process and store valid PNG images', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', createPngBuffer(), { filename: 'test.png', contentType: 'image/png' })
+
+ assert.strictEqual(statusCode, 200)
+ assert(body.id)
+ assert.strictEqual(body.mimeType, 'image/png')
+ })
+
+ test('should store document record in database', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', createJpegBuffer(), { filename: 'db-test.jpg', contentType: 'image/jpeg' })
+
+ assert.strictEqual(statusCode, 200)
+
+ // Check document record
+ const [doc] = await setup.dbPool('bm_web_documents').where({ id: body.id })
+ assert(doc)
+ assert.strictEqual(doc.filename, 'db-test.jpg')
+ assert(doc.content_hash)
+ assert.strictEqual(doc.content_hash.length, 64)
+
+ // Check content record
+ const [content] = await setup.dbPool('bm_web_document_contents').where({ content_hash: doc.content_hash })
+ assert(content)
+ assert.strictEqual(content.mime_type, 'image/jpeg')
+ assert(content.path.includes('uploads/documents'))
+ })
+ })
+
+ describe('deduplication', () => {
+ test('should reuse existing content when uploading duplicate file', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+ const imageBuffer = createPngBuffer()
+
+ // Upload the same file twice
+ const { statusCode: status1, body: body1 } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', imageBuffer, { filename: 'first.png', contentType: 'image/png' })
+
+ const { statusCode: status2, body: body2 } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', imageBuffer, { filename: 'second.png', contentType: 'image/png' })
+
+ assert.strictEqual(status1, 200)
+ assert.strictEqual(status2, 200)
+
+ // Different document IDs
+ assert.notStrictEqual(body1.id, body2.id)
+
+ // Same content hash
+ const [doc1] = await setup.dbPool('bm_web_documents').where({ id: body1.id })
+ const [doc2] = await setup.dbPool('bm_web_documents').where({ id: body2.id })
+ assert.strictEqual(doc1.content_hash, doc2.content_hash)
+
+ // Only one content record
+ const contentCount = await setup.dbPool('bm_web_document_contents')
+ .where({ content_hash: doc1.content_hash })
+ .count('* as count')
+ assert.strictEqual(contentCount[0].count, 1)
+ })
+
+ test('should create separate content for different files', async () => {
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ const { statusCode: status1, body: body1 } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', createJpegBuffer(), { filename: 'unique1.jpg', contentType: 'image/jpeg' })
+
+ const { statusCode: status2, body: body2 } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', createPngBuffer(), { filename: 'unique2.png', contentType: 'image/png' })
+
+ assert.strictEqual(status1, 200)
+ assert.strictEqual(status2, 200)
+
+ const [doc1] = await setup.dbPool('bm_web_documents').where({ id: body1.id })
+ const [doc2] = await setup.dbPool('bm_web_documents').where({ id: body2.id })
+ assert.notStrictEqual(doc1.content_hash, doc2.content_hash)
+ })
+ })
+
+ describe('dimension limits', () => {
+ test('should reject images exceeding max dimension', async () => {
+ // Set a very low max dimension for testing
+ process.env.UPLOAD_MAX_DIMENSION = '1'
+
+ const cookie = await getAuthPassword(request, 'admin@banmanagement.com')
+
+ // Create a 2x2 PNG which exceeds our 1px limit
+ const largePng = Buffer.from([
+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D,
+ 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02,
+ 0x08, 0x02, 0x00, 0x00, 0x00, 0xFD, 0xD4, 0x9A, 0x73, 0x00, 0x00, 0x00,
+ 0x14, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0x0F, 0x00, 0x00,
+ 0x01, 0x01, 0x00, 0x05, 0x18, 0xD8, 0x4D, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82
+ ])
+
+ const { statusCode, body } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', largePng, { filename: 'large.png', contentType: 'image/png' })
+
+ // Restore default
+ process.env.UPLOAD_MAX_DIMENSION = '8192'
+
+ assert.strictEqual(statusCode, 400)
+ assert(body.error.includes('Image dimensions too large'))
+ })
+ })
+
+ describe('rate limiting', () => {
+ test('should apply rate limit to regular users', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+ const role = await setTempRole(setup.dbPool, account, 'player.appeals', 'attachment.create')
+
+ // Make many requests to trigger rate limit
+ // Note: Rate limit is 20/hour, so we need to exceed that
+ // For testing purposes, we'll just verify the rate limiter is being called
+ // A full rate limit test would require mocking time or waiting
+
+ const { statusCode } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', createJpegBuffer(), { filename: 'rate-test.jpg', contentType: 'image/jpeg' })
+
+ await role.reset()
+
+ // First request should succeed
+ assert.strictEqual(statusCode, 200)
+ })
+
+ test('should bypass rate limit with attachment.ratelimit.bypass permission', async () => {
+ const cookie = await getAuthPassword(request, 'user@banmanagement.com')
+ const account = await getAccount(request, cookie)
+
+ // Grant both create and bypass permissions
+ const createRole = await setTempRole(setup.dbPool, account, 'player.appeals', 'attachment.create')
+ const bypassRole = await setTempRole(setup.dbPool, account, 'player.appeals', 'attachment.ratelimit.bypass')
+
+ const { statusCode } = await request
+ .post('/api/upload')
+ .set('Cookie', cookie)
+ .attach('file', createJpegBuffer(), { filename: 'bypass-test.jpg', contentType: 'image/jpeg' })
+
+ await createRole.reset()
+ await bypassRole.reset()
+
+ assert.strictEqual(statusCode, 200)
+ })
+ })
+})
diff --git a/uploads/documents/.gitkeep b/uploads/documents/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/utils/index.js b/utils/index.js
index ab7bce40e..55c5916d5 100644
--- a/utils/index.js
+++ b/utils/index.js
@@ -276,6 +276,17 @@ export const numberFormatter = (num, digits) => {
return item ? (num / item.value).toFixed(digits).replace(rx, '$1') + item.symbol : '0'
}
+export const formatBytes = (bytes, decimals = 2) => {
+ if (bytes === 0) return '0 Bytes'
+
+ const k = 1024
+ const dm = decimals < 0 ? 0 : decimals
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
+}
+
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => {
diff --git a/utils/useScrollToHash.js b/utils/useScrollToHash.js
new file mode 100644
index 000000000..d3dd191b3
--- /dev/null
+++ b/utils/useScrollToHash.js
@@ -0,0 +1,28 @@
+import { useEffect } from 'react'
+import { useRouter } from 'next/router'
+
+/**
+ * Scrolls to an element matching the URL hash after data loads.
+ * Uses double requestAnimationFrame to ensure DOM is fully painted.
+ * @param {boolean} isReady - Whether the data/content is ready to scroll to
+ */
+export function useScrollToHash (isReady) {
+ const router = useRouter()
+
+ useEffect(() => {
+ if (!isReady) return
+
+ const hash = router.asPath.split('#')[1]
+ if (!hash) return
+
+ // Wait for DOM to fully paint before scrolling
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ const element = document.getElementById(hash)
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ }
+ })
+ })
+ }, [isReady, router.asPath])
+}