From 06b9c1cb50d196aef28ad6f995b941aa6efdd49e Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 18 Jul 2026 13:07:49 +0900 Subject: [PATCH] #399 --- frontend/src/lib/postImportStorage.ts | 48 +++++++++--- frontend/src/lib/postNewQueryState.ts | 102 +++++++++++++++++++++++--- 2 files changed, 126 insertions(+), 24 deletions(-) diff --git a/frontend/src/lib/postImportStorage.ts b/frontend/src/lib/postImportStorage.ts index 16d1815..c75ec84 100644 --- a/frontend/src/lib/postImportStorage.ts +++ b/frontend/src/lib/postImportStorage.ts @@ -205,7 +205,10 @@ const isValidSkipReason = ( value === 'existing' || value === 'manual' const isPositiveInteger = (value: unknown): value is number => - Number.isInteger (value) && Number (value) > 0 + Number.isSafeInteger (value) && Number (value) > 0 + +const isNonEmptyMessageRecord = (value: Record): boolean => + Object.values (value).some (messages => messages.length > 0) const hasOnlyKeys = ( value: Record, @@ -285,6 +288,13 @@ const sanitiseExistingPost = (value: unknown): PostImportExistingPost | undefine thumbnailUrl: value.thumbnailUrl as string | undefined } } +const canonicalisePersistedRow = ( + row: PostImportRow, +): PostImportRow | null => { + const sanitised = sanitiseRow (serialisePostImportRow (row)) + return sanitised == null ? null : recalculateThumbnailWarning (sanitised) +} + const recalculateThumbnailWarning = (row: PostImportRow): PostImportRow => { const others = (row.fieldWarnings.thumbnailBase ?? []).filter ( key => key !== 'サムネールなし') @@ -379,9 +389,15 @@ export const serialisePostImportRows = (rows: PostImportRow[]) => export const serialisedPostImportRowsEqual = ( left: PostImportRow[], right: PostImportRow[], -): boolean => - JSON.stringify (serialisePostImportRows (left)) - === JSON.stringify (serialisePostImportRows (right)) +): boolean => { + const canonicalLeft = left.map (canonicalisePersistedRow) + const canonicalRight = right.map (canonicalisePersistedRow) + if (canonicalLeft.some (row => row == null) || canonicalRight.some (row => row == null)) + return false + + return JSON.stringify (serialisePostImportRows (canonicalLeft as PostImportRow[])) + === JSON.stringify (serialisePostImportRows (canonicalRight as PostImportRow[])) +} const sanitiseRow = (value: unknown): PostImportRow | null => { @@ -409,8 +425,6 @@ const sanitiseRow = (value: unknown): PostImportRow | null => { return null if (value.skipReason === 'existing' && !(isPositiveInteger (value.existingPostId))) return null - if (value.skipReason !== 'existing' && value.existingPostId != null) - return null if (value.importStatus === 'created' && !(isPositiveInteger (value.createdPostId))) return null if (value.importStatus !== 'created' && value.createdPostId != null) @@ -462,21 +476,31 @@ const sanitiseRow = (value: unknown): PostImportRow | null => { return null } + const existingPost = sanitiseExistingPost (value.existingPost) + const existingPostId = + isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined + if (existingPost != null && existingPostId == null) + return null + if (existingPost != null && existingPost.id !== existingPostId) + return null + return { sourceRow: Number (value.sourceRow), url: value.url, attributes: value.attributes as Record, - fieldWarnings, + fieldWarnings: isNonEmptyMessageRecord (fieldWarnings) ? fieldWarnings : { }, baseWarnings: value.baseWarnings, - validationErrors, - importErrors, + validationErrors: isNonEmptyMessageRecord (validationErrors) ? validationErrors : { }, + importErrors: + importErrors != null && isNonEmptyMessageRecord (importErrors) + ? importErrors + : undefined, provenance: value.provenance as Record, tagSources: value.tagSources as Record | undefined, status: value.status, skipReason: value.skipReason ?? undefined, - existingPostId: - isPositiveInteger (value.existingPostId) ? Number (value.existingPostId) : undefined, - existingPost: sanitiseExistingPost (value.existingPost), + existingPostId, + existingPost, metadataUrl: typeof value.metadataUrl === 'string' ? value.metadataUrl : undefined, resetSnapshot, createdPostId: diff --git a/frontend/src/lib/postNewQueryState.ts b/frontend/src/lib/postNewQueryState.ts index 8f01eb3..d6e6de9 100644 --- a/frontend/src/lib/postNewQueryState.ts +++ b/frontend/src/lib/postNewQueryState.ts @@ -101,6 +101,7 @@ const ENCODED_ROW_KEYS = [ 'i', 'c', 'r'] as const +const ENCODED_STATE_KEYS = ['v', 'rows'] as const const ALLOWED_MANUAL_FIELDS = [ 'title', 'thumbnailBase', @@ -200,12 +201,19 @@ const decodeImportStatus = ( } +const isStrictPositiveIntegerString = (value: string): boolean => + /^[1-9][0-9]*$/.test (value) + && Number.isSafeInteger (Number (value)) + + const parsePositiveInt = (value: string | null): number | null => { if (value == null || value === '') return null - const parsed = Number.parseInt (value, 10) - return Number.isInteger (parsed) && parsed > 0 ? parsed : null + if (!(isStrictPositiveIntegerString (value))) + return null + + return Number (value) } @@ -244,9 +252,37 @@ const compressBytes = async (value: Uint8Array): Promise => { const decompressBytes = async (value: Uint8Array): Promise => { const stream = new Blob ([value]).stream ().pipeThrough (new DecompressionStream ('gzip')) - const bytes = new Uint8Array (await new Response (stream).arrayBuffer ()) - if (bytes.byteLength > MAX_DECOMPRESSED_STATE_BYTES) - throw new Error ('decompressed state too large') + const reader = stream.getReader () + const chunks: Uint8Array[] = [] + let totalBytes = 0 + + try + { + while (true) + { + const result = await reader.read () + if (result.done) + break + totalBytes += result.value.byteLength + if (totalBytes > MAX_DECOMPRESSED_STATE_BYTES) + { + await reader.cancel () + throw new Error ('decompressed state too large') + } + chunks.push (result.value) + } + } + finally + { + reader.releaseLock () + } + + const bytes = new Uint8Array (totalBytes) + let offset = 0 + chunks.forEach (chunk => { + bytes.set (chunk, offset) + offset += chunk.byteLength + }) return bytes } @@ -408,21 +444,31 @@ const parseEncodedRow = ( return null if (existingPostId != null && !(typeof existingPostId === 'number' - && Number.isInteger (existingPostId) + && Number.isSafeInteger (existingPostId) && existingPostId > 0)) return null if (createdPostId != null && !(typeof createdPostId === 'number' - && Number.isInteger (createdPostId) + && Number.isSafeInteger (createdPostId) && createdPostId > 0)) return null if (recoverable != null && typeof recoverable !== 'boolean') return null if (sourceRow != null && !(typeof sourceRow === 'number' - && Number.isInteger (sourceRow) + && Number.isSafeInteger (sourceRow) && sourceRow > 0)) return null + if (skip === 'existing' && existingPostId == null) + return null + if (importStatus === 'created' && createdPostId == null) + return null + if (importStatus !== 'created' && createdPostId != null) + return null + if (recoverable === true + && importStatus !== 'failed' + && importStatus !== 'pending') + return null const stringFields = [ ['t', 'title'], @@ -515,16 +561,41 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => { const skipValue = params.get ('skip') const importStatus = params.get ('import_status') + const recoverableValue = params.get ('recoverable') const recoverable = - params.get ('recoverable') == null + recoverableValue == null ? null - : params.get ('recoverable') === 'true' + : recoverableValue === 'true' + ? true + : recoverableValue === 'false' + ? false + : null if (skipValue != null && !(isValidSkipReason (skipValue))) return null if (importStatus != null && !(isValidImportStatus (importStatus))) return null + if (recoverableValue != null && recoverable == null) + return null if (!(hasOnlyAllowedManualFields (parseManual (params.get ('manual'))))) return null + const existingPostIdValue = params.get ('existing_post_id') + const createdPostIdValue = params.get ('created_post_id') + const existingPostId = parsePositiveInt (existingPostIdValue) + const createdPostId = parsePositiveInt (createdPostIdValue) + if (existingPostIdValue != null && existingPostId == null) + return null + if (createdPostIdValue != null && createdPostId == null) + return null + if (skipValue === 'existing' && existingPostId == null) + return null + if (importStatus === 'created' && createdPostId == null) + return null + if (importStatus !== 'created' && createdPostIdValue != null) + return null + if (recoverable === true + && importStatus !== 'failed' + && importStatus !== 'pending') + return null const row = buildRow ({ source_row: 1, @@ -539,9 +610,9 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => { parent_post_ids: params.get ('parent_post_ids') ?? '', manual: parseManual (params.get ('manual')), skip: skipValue, - existing_post_id: parsePositiveInt (params.get ('existing_post_id')), + existing_post_id: existingPostId, import_status: importStatus, - created_post_id: parsePositiveInt (params.get ('created_post_id')), + created_post_id: createdPostId, recoverable }) return { @@ -559,6 +630,8 @@ const parseMetaRows = ( const decodedBytes = decodeBase64UrlBytes (metaValue) if (decodedBytes == null) return null + if (decodedBytes.byteLength > MAX_DECOMPRESSED_STATE_BYTES) + return null let parsed: MultiRowMetaState try @@ -618,6 +691,8 @@ const parseFullState = async (stateValue: string): Promise, ENCODED_STATE_KEYS)) || !(Array.isArray (parsed.rows)) || parsed.rows.length === 0 || parsed.rows.length > MAX_STATE_ROWS) @@ -754,6 +829,9 @@ export const appendPostNewSessionId = ( export const parsePostNewState = async ( search: string, ): Promise => { + if (byteLength (search) > MAX_QUERY_STRING_BYTES) + return null + const params = new URLSearchParams (search) const stateValue = params.get ('state') if (stateValue != null)