diff --git a/public/i18n/en.json b/public/i18n/en.json index 68fd1c4..6f66a67 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -149,6 +149,12 @@ "MaidataWillInheritScores": "This upload will inherit previous scores", "MaidataWillInheritHistory": "This upload will inherit previous scores or interactions", "MaidataHashLookupFailed": "Failed to query maidata.txt hash", + "MaidataFileEmpty": "maidata.txt cannot be empty", + "MaidataTitleMissingOrEmpty": "Song title (&title) is missing or empty", + "MaidataArtistMissingOrEmpty": "Artist (&artist) is missing or empty", + "MaidataDesignerMissingOrEmpty": "Chart designer (&des) is missing or empty", + "MaidataFileReadFailed": "Failed to read maidata.txt. Please select the file again.", + "MaidataFileChanged": "maidata.txt was modified after it was selected. Please select the file again.", "View": "View", "ManageYourCharts": "Manage your uploaded charts", "ModifyPersonalInfo": "Modify personal information and settings", @@ -373,4 +379,4 @@ "AllCharts": "All Charts", "RandomRecommend": "Random Recommend", "500UploadErrorAndHint": "Upload failed, please go to the main site to upload" -} \ No newline at end of file +} diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 0608436..878b4c6 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -149,6 +149,12 @@ "MaidataWillInheritScores": "今回のアップロードは以前のスコアを引き継ぎます", "MaidataWillInheritHistory": "今回のアップロードは以前のスコアまたはインタラクションを引き継ぎます", "MaidataHashLookupFailed": "maidata.txt のハッシュ照会に失敗しました", + "MaidataFileEmpty": "maidata.txt を空にすることはできません", + "MaidataTitleMissingOrEmpty": "曲名(&title)が存在しないか、空です", + "MaidataArtistMissingOrEmpty": "アーティスト(&artist)が存在しないか、空です", + "MaidataDesignerMissingOrEmpty": "譜面制作者(&des)が存在しないか、空です", + "MaidataFileReadFailed": "maidata.txt を読み込めませんでした。ファイルを選択し直してください。", + "MaidataFileChanged": "maidata.txt は選択後に変更されました。ファイルを選択し直してください。", "View": "表示", "ManageYourCharts": "アップロードした譜面を管理", "ModifyPersonalInfo": "個人情報と設定を変更", @@ -368,4 +374,4 @@ "CreateFailed": "作成に失敗しました", "Submit": "送信", "500UploadErrorAndHint": "アップロードに失敗しました。主サイトに行ってアップロードしてください" -} \ No newline at end of file +} diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 2fa827b..bd15723 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -149,6 +149,12 @@ "MaidataWillInheritScores": "이번 업로드는 이전 점수를 이어받습니다", "MaidataWillInheritHistory": "이번 업로드는 이전 점수 또는 상호작용을 이어받습니다", "MaidataHashLookupFailed": "maidata.txt 해시 조회에 실패했습니다", + "MaidataFileEmpty": "maidata.txt는 비워 둘 수 없습니다", + "MaidataTitleMissingOrEmpty": "곡명(&title)이 없거나 비어 있습니다", + "MaidataArtistMissingOrEmpty": "아티스트(&artist)가 없거나 비어 있습니다", + "MaidataDesignerMissingOrEmpty": "채보 제작자(&des)가 없거나 비어 있습니다", + "MaidataFileReadFailed": "maidata.txt를 읽지 못했습니다. 파일을 다시 선택해 주세요.", + "MaidataFileChanged": "maidata.txt가 선택된 후 변경되었습니다. 파일을 다시 선택해 주세요.", "View": "보기", "ManageYourCharts": "내가 올린 채보를 관리하세요", "ModifyPersonalInfo": "개인 정보와 설정을 수정하세요", @@ -368,4 +374,4 @@ "CreateFailed": "생성 실패", "Submit": "제출", "500UploadErrorAndHint": "업로드 실패, 주 사이트로 가서 업로드하세요" -} \ No newline at end of file +} diff --git a/public/i18n/zh.json b/public/i18n/zh.json index 48d5e00..165bdaf 100644 --- a/public/i18n/zh.json +++ b/public/i18n/zh.json @@ -151,6 +151,12 @@ "MaidataWillInheritScores": "这次上传会继承之前的分数", "MaidataWillInheritHistory": "这次上传会继承之前的分数或互动", "MaidataHashLookupFailed": "查询 maidata.txt 哈希失败", + "MaidataFileEmpty": "maidata.txt 不能为空", + "MaidataTitleMissingOrEmpty": "歌曲名(&title)缺失或为空", + "MaidataArtistMissingOrEmpty": "艺术家(&artist)缺失或为空", + "MaidataDesignerMissingOrEmpty": "谱师(&des)缺失或为空", + "MaidataFileReadFailed": "无法读取 maidata.txt,请重新选择文件", + "MaidataFileChanged": "maidata内容已修改,请再次选择文件", "View": "查看", "ManageYourCharts": "管理您上传的谱面", "ModifyPersonalInfo": "修改个人信息和设置", @@ -378,4 +384,4 @@ "CreateFailed": "创建失败", "Submit": "提交", "500UploadErrorAndHint": "上传失败, 请前往主站上传" -} \ No newline at end of file +} diff --git a/src/components/user/ChartUploader.tsx b/src/components/user/ChartUploader.tsx index f742f88..062c00a 100644 --- a/src/components/user/ChartUploader.tsx +++ b/src/components/user/ChartUploader.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import axios, { AxiosError } from 'axios'; import { Link } from 'react-router-dom'; @@ -6,6 +6,11 @@ import { endpoints } from '@/config/api'; import { useLoc, useUserContext } from '@/hooks'; import { getDisplayMessage, sleep } from '@/utils'; import { getFileKey, hashCandidatesFromBytes } from '@/utils/maidataHash'; +import { + validateMaidataBytes, + type MaidataValidationResult, + type RequiredMaidataMetadata, +} from '@/utils/maidataValidation'; import { motion } from 'framer-motion'; import { MdOutlineAudioFile, MdOutlineDescription, MdOutlineImage, MdOutlineVideoFile, MdCloudUpload } from 'react-icons/md'; import { LoadingSpinner } from '@/components'; @@ -28,15 +33,98 @@ interface HashStatusResponse { chart?: Song | null; } +type MaidataValidationStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'readError' | 'fileChanged'; + +interface MaidataValidationState { + status: MaidataValidationStatus; + fileKey?: string; + result?: MaidataValidationResult; + contentHash?: string; +} + +const metadataErrorKeys: Record = { + title: 'MaidataTitleMissingOrEmpty', + artist: 'MaidataArtistMissingOrEmpty', + des: 'MaidataDesignerMissingOrEmpty', +}; + +const metadataErrorFallbacks: Record = { + title: '歌曲名(&title)缺失或为空', + artist: '艺术家(&artist)缺失或为空', + des: '谱师(&des)缺失或为空', +}; + export default function ChartUploader() { const loc = useLoc(); const { user, isLoading: isUserLoading } = useUserContext(); const [isUploading, setIsUploading] = useState(false); const [hashLookup, setHashLookup] = useState({ status: 'idle' }); + const [maidataValidation, setMaidataValidation] = useState({ status: 'idle' }); const hashLookupSeq = useRef(0); const hashLookupAbort = useRef(null); + const maidataInputRef = useRef(null); + const maidataFileBeforePicker = useRef(null); + + useEffect(() => { + const input = maidataInputRef.current; + if (!input) { + return; + } + + // 取消文件选择时,恢复选中之前已选的maidata + const handleCancel = () => { + const previousFile = maidataFileBeforePicker.current; + maidataFileBeforePicker.current = null; + + if (!previousFile || typeof DataTransfer === 'undefined') { + return; + } + + try { + const transfer = new DataTransfer(); + transfer.items.add(previousFile); + input.files = transfer.files; + } catch { + hashLookupSeq.current += 1; + hashLookupAbort.current?.abort(); + setMaidataValidation({ status: 'idle' }); + setHashLookup({ status: 'idle' }); + } + }; + + input.addEventListener('cancel', handleCancel); + return () => input.removeEventListener('cancel', handleCancel); + }, []); + + function getMaidataValidationMessages(state: MaidataValidationState) { + if (state.status === 'fileChanged') { + return [loc('MaidataFileChanged', 'maidata.txt was modified after it was selected. Please select the file again.')]; + } + + if (state.status === 'readError') { + return [loc('MaidataFileReadFailed', 'Failed to read maidata.txt. Please select the file again.')]; + } + + if (state.status !== 'invalid' || !state.result) { + return []; + } + + if (state.result.empty) { + return [loc('MaidataFileEmpty', 'maidata.txt cannot be empty')]; + } - async function lookupMaidataHash(file: File) { + return state.result.missingOrEmptyMetadata.map((key) => + loc(metadataErrorKeys[key], metadataErrorFallbacks[key]), + ); + } + + function showMaidataValidationToasts(state: MaidataValidationState) { + for (const message of getMaidataValidationMessages(state)) { + toast.error(message, { autoClose: false }); + } + } + + async function inspectMaidata(file: File, showValidationToast = false) { const fileKey = getFileKey(file); const seq = hashLookupSeq.current + 1; hashLookupSeq.current = seq; @@ -44,23 +132,66 @@ export default function ChartUploader() { const abortController = new AbortController(); hashLookupAbort.current = abortController; - setHashLookup({ status: 'checking', fileKey }); + setMaidataValidation({ status: 'checking', fileKey }); + setHashLookup({ status: 'idle' }); + + let bytes: Uint8Array; + let validationResult: MaidataValidationResult; try { - if (!user) { - const nextState: HashLookupState = { - status: 'loginRequired', - fileKey, - message: isUserLoading - ? loc('MaidataHashChecking', 'Checking whether this chart already exists...') - : loc('NotLoggedIn', 'Not logged in'), - }; - setHashLookup(nextState); - return nextState; + bytes = new Uint8Array(await file.arrayBuffer()); + validationResult = validateMaidataBytes(bytes); + } catch { + if (hashLookupSeq.current === seq) { + const nextState: MaidataValidationState = { status: 'readError', fileKey }; + setMaidataValidation(nextState); + setHashLookup({ status: 'idle' }); + if (showValidationToast) { + showMaidataValidationToasts(nextState); + } } + return; + } + + if (hashLookupSeq.current !== seq) { + return; + } + + if (!validationResult.valid) { + const nextState: MaidataValidationState = { + status: 'invalid', + fileKey, + result: validationResult, + }; + setMaidataValidation(nextState); + if (showValidationToast) { + showMaidataValidationToasts(nextState); + } + return; + } - const bytes = new Uint8Array(await file.arrayBuffer()); - const hashes = hashCandidatesFromBytes(bytes); + const hashes = hashCandidatesFromBytes(bytes); + setMaidataValidation({ + status: 'valid', + fileKey, + result: validationResult, + contentHash: hashes[0], + }); + + if (!user) { + setHashLookup({ + status: 'loginRequired', + fileKey, + message: isUserLoading + ? loc('MaidataHashChecking', 'Checking whether this chart already exists...') + : loc('NotLoggedIn', 'Not logged in'), + }); + return; + } + + setHashLookup({ status: 'checking', fileKey }); + + try { let inheritedState: HashLookupState | null = null; for (const hash of hashes) { @@ -130,9 +261,10 @@ export default function ChartUploader() { } } - function resetHashLookup() { + function resetMaidataInspection() { hashLookupSeq.current += 1; hashLookupAbort.current?.abort(); + setMaidataValidation({ status: 'idle' }); setHashLookup({ status: 'idle' }); } @@ -141,13 +273,59 @@ export default function ChartUploader() { return; } + maidataFileBeforePicker.current = null; const file = event.currentTarget.files?.[0]; if (!file) { - resetHashLookup(); + resetMaidataInspection(); + return; + } + + void inspectMaidata(file); + } + + function onFileInputClick(index: number, event: React.MouseEvent) { + if (index !== 0 || event.currentTarget.value === '' || typeof DataTransfer === 'undefined') { return; } - void lookupMaidataHash(file); + // 将上次选中的文件暂存到另一对象,并清空当前控件value,使再次选择同一文件也会触发change重新校验maidata + maidataFileBeforePicker.current = event.currentTarget.files?.[0] ?? null; + event.currentTarget.value = ''; + } + + function markMaidataFileChanged(fileKey: string) { + hashLookupSeq.current += 1; + hashLookupAbort.current?.abort(); + const nextState: MaidataValidationState = { status: 'fileChanged', fileKey }; + setMaidataValidation(nextState); + setHashLookup({ status: 'idle' }); + showMaidataValidationToasts(nextState); + } + + async function createStableMaidataFile(file: File) { + let bytes: Uint8Array; + + try { + // 上传前重新读取磁盘文件,用于识别选择后被修改的文件。 + bytes = new Uint8Array(await file.arrayBuffer()); + } catch { + markMaidataFileChanged(getFileKey(file)); + return null; + } + + const contentHash = hashCandidatesFromBytes(bytes)[0]; + if (!maidataValidation.contentHash || contentHash !== maidataValidation.contentHash) { + markMaidataFileChanged(getFileKey(file)); + return null; + } + + // 使用内存副本上传,避免读取完成后源文件再次变化导致 ERR_UPLOAD_FILE_CHANGED。 + const stableBuffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(stableBuffer).set(bytes); + return new File([stableBuffer], file.name, { + type: file.type, + lastModified: file.lastModified, + }); } async function onSubmit(event: React.FormEvent) { @@ -157,16 +335,16 @@ export default function ChartUploader() { const filesNecessary = formData.getAll('formfiles'); const fileChecks = [ - { file: filesNecessary[0], name: 'maidata.txt' }, - { file: filesNecessary[1], name: 'bg.png/bg.jpg' }, - { file: filesNecessary[2], name: 'track' }, + { file: filesNecessary[0], name: 'maidata.txt', checkSize: false }, + { file: filesNecessary[1], name: 'bg.png/bg.jpg', checkSize: true }, + { file: filesNecessary[2], name: 'track', checkSize: true }, ]; const missedFiles: string[] = []; - for (const { file, name } of fileChecks) { + for (const { file, name, checkSize } of fileChecks) { const fileObj = file as File; - if (!fileObj || fileObj.name === '' || fileObj.size === 0) { + if (!fileObj || fileObj.name === '' || (checkSize && fileObj.size === 0)) { missedFiles.push(name); } } @@ -178,11 +356,47 @@ export default function ChartUploader() { return; } + const maidataFile = filesNecessary[0] as File; + const maidataFileKey = getFileKey(maidataFile); + const validationMatchesFile = maidataValidation.fileKey === maidataFileKey; + + if ( + validationMatchesFile + && ( + maidataValidation.status === 'invalid' + || maidataValidation.status === 'readError' + || maidataValidation.status === 'fileChanged' + ) + ) { + showMaidataValidationToasts(maidataValidation); + return; + } + + if (!validationMatchesFile || maidataValidation.status !== 'valid') { + await inspectMaidata(maidataFile, true); + return; + } + if (hashLookup.status === 'exists') { toast.error(loc('MaidataAlreadyExists', 'This chart already exists on the site'), { autoClose: false }); return; } + const stableMaidataFile = await createStableMaidataFile(maidataFile); + if (!stableMaidataFile) { + return; + } + + const uploadFormData = new FormData(); + let formfileIndex = 0; + for (const [key, value] of formData.entries()) { + if (key === 'formfiles' && formfileIndex++ === 0) { + uploadFormData.append(key, stableMaidataFile); + } else { + uploadFormData.append(key, value); + } + } + const uploading = toast.loading(loc('Uploading'), { hideProgressBar: false, }); @@ -190,7 +404,7 @@ export default function ChartUploader() { setIsUploading(true); try { - const response = await axios.post(endpoints.maichart.upload, formData, { + const response = await axios.post(endpoints.maichart.upload, uploadFormData, { onUploadProgress: function (progressEvent) { if (progressEvent.total && progressEvent.lengthComputable) { const progress = progressEvent.loaded / progressEvent.total; @@ -245,6 +459,11 @@ export default function ChartUploader() { loginRequired: 'text-red-300', error: 'text-red-300', }; + const maidataValidationMessages = getMaidataValidationMessages(maidataValidation); + const cannotUploadByValidation = maidataValidation.status === 'checking' + || maidataValidation.status === 'invalid' + || maidataValidation.status === 'readError' + || maidataValidation.status === 'fileChanged'; const cannotUploadByHash = hashLookup.status === 'checking' || hashLookup.status === 'exists' || hashLookup.status === 'loginRequired'; return ( @@ -278,7 +497,9 @@ export default function ChartUploader() { className="bg-transparent hover:file:bg-white/20 file:bg-white/10 file:mr-4 px-4 file:px-3 py-3 file:py-1.5 file:border-0 file:rounded-md focus:outline-none w-full file:font-semibold text-gray-300 hover:file:text-white file:text-gray-300 file:text-xs text-sm transition-colors cursor-pointer" type="file" name="formfiles" + ref={index === 0 ? maidataInputRef : undefined} disabled={isUploading} + onClick={index === 0 ? (event) => onFileInputClick(index, event) : undefined} onChange={(event) => onFileChange(index, event)} /> @@ -286,6 +507,14 @@ export default function ChartUploader() { ))} + {maidataValidationMessages.length > 0 && ( +
+ {maidataValidationMessages.map((message) => ( +
{message}
+ ))} +
+ )} + {hashLookup.status !== 'idle' && (
{hashLookup.status === 'checking' && } @@ -305,12 +534,12 @@ export default function ChartUploader() { whileHover={{ scale: 1.01 }} whileTap={{ scale: 0.99 }} className={`mt-4 w-full py-3 rounded-xl font-bold text-gray-200 shadow-lg transition-all duration-200 flex items-center justify-center gap-2 border border-white/5 - ${isUploading || cannotUploadByHash + ${isUploading || cannotUploadByValidation || cannotUploadByHash ? 'bg-red-900/50 cursor-not-allowed opacity-70' : 'bg-[#2e2e2e] hover:bg-[#3a3a3a] hover:border-white/20 hover:shadow-[0_0_15px_rgba(255,255,255,0.1)]' }`} type="submit" - disabled={isUploading || cannotUploadByHash} + disabled={isUploading || cannotUploadByValidation || cannotUploadByHash} > {isUploading ? ( <> diff --git a/src/utils/maidataValidation.test.ts b/src/utils/maidataValidation.test.ts new file mode 100644 index 0000000..2256e3e --- /dev/null +++ b/src/utils/maidataValidation.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { validateMaidataBytes } from './maidataValidation'; + +const encoder = new TextEncoder(); +const validMetadata = '&title=Song\n&artist=Artist\n&des=Designer'; + +describe('validateMaidataBytes', () => { + it.each([ + ['zero bytes', new Uint8Array()], + ['UTF-8 BOM only', new Uint8Array([0xef, 0xbb, 0xbf])], + ['whitespace only', encoder.encode(' \t\r\n\n')], + ])('treats %s as empty', (_name, bytes) => { + expect(validateMaidataBytes(bytes)).toEqual({ + valid: false, + empty: true, + missingOrEmptyMetadata: [], + }); + }); + + it('accepts standard required metadata', () => { + expect(validateMaidataBytes(encoder.encode(validMetadata))).toEqual({ + valid: true, + empty: false, + missingOrEmptyMetadata: [], + }); + }); + + it('accepts leading whitespace, mixed case, and spaces around equals signs', () => { + const text = ' &TITLE = Song\n\t&Artist= Artist\n &dEs = Designer '; + + expect(validateMaidataBytes(encoder.encode(text)).valid).toBe(true); + }); + + it.each([ + ['title', '&artist=Artist\n&des=Designer'], + ['artist', '&title=Song\n&des=Designer'], + ['des', '&title=Song\n&artist=Artist'], + ] as const)('reports %s when it is missing', (key, text) => { + expect(validateMaidataBytes(encoder.encode(text)).missingOrEmptyMetadata).toEqual([key]); + }); + + it('reports metadata whose values contain only whitespace', () => { + const text = '&title= \t\n&artist=Artist\n&des= '; + + expect(validateMaidataBytes(encoder.encode(text)).missingOrEmptyMetadata).toEqual(['title', 'des']); + }); + + it('accepts a duplicate metadata tag when any occurrence has a value', () => { + const text = '&title=\n&title=Song\n&artist=Artist\n&des=Designer'; + + expect(validateMaidataBytes(encoder.encode(text)).valid).toBe(true); + }); + + it('rejects duplicate metadata tags when every occurrence is empty', () => { + const text = '&title=\n&title= \n&artist=Artist\n&des=Designer'; + + expect(validateMaidataBytes(encoder.encode(text)).missingOrEmptyMetadata).toEqual(['title']); + }); + + it('does not match comments, inline text, or difficulty-specific designer tags', () => { + const text = '|| &title=Song\nprefix &artist=Artist\n&des_1=Designer'; + + expect(validateMaidataBytes(encoder.encode(text)).missingOrEmptyMetadata).toEqual([ + 'title', + 'artist', + 'des', + ]); + }); + + it.each(['\n', '\r\n', '\r'])('supports %j line endings and a UTF-8 BOM', (lineEnding) => { + const text = `\ufeff${validMetadata.replaceAll('\n', lineEnding)}`; + + expect(validateMaidataBytes(encoder.encode(text)).valid).toBe(true); + }); +}); diff --git a/src/utils/maidataValidation.ts b/src/utils/maidataValidation.ts new file mode 100644 index 0000000..5389b4f --- /dev/null +++ b/src/utils/maidataValidation.ts @@ -0,0 +1,45 @@ +export const requiredMaidataMetadata = ['title', 'artist', 'des'] as const; + +export type RequiredMaidataMetadata = (typeof requiredMaidataMetadata)[number]; + +export interface MaidataValidationResult { + valid: boolean; + empty: boolean; + missingOrEmptyMetadata: RequiredMaidataMetadata[]; +} + +const metadataPattern = /^\s*&(title|artist|des)\s*=\s*(.*)$/i; + +export function validateMaidataBytes(bytes: Uint8Array): MaidataValidationResult { + const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes); + const text = decoded.charCodeAt(0) === 0xfeff ? decoded.slice(1) : decoded; + + if (text.trim() === '') { + return { + valid: false, + empty: true, + missingOrEmptyMetadata: [], + }; + } + + const metadataWithValue = new Set(); + + for (const line of text.split(/\r\n|\n|\r/)) { + const match = line.match(metadataPattern); + if (!match || match[2].trim() === '') { + continue; + } + + metadataWithValue.add(match[1].toLowerCase() as RequiredMaidataMetadata); + } + + const missingOrEmptyMetadata = requiredMaidataMetadata.filter( + (key) => !metadataWithValue.has(key), + ); + + return { + valid: missingOrEmptyMetadata.length === 0, + empty: false, + missingOrEmptyMetadata, + }; +}