広場投稿追加画面の刷新 (#399) #413
@@ -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<string, string[]>): boolean =>
|
||||
Object.values (value).some (messages => messages.length > 0)
|
||||
|
||||
const hasOnlyKeys = (
|
||||
value: Record<string, unknown>,
|
||||
@@ -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<string, string | number>,
|
||||
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<string, PostImportOrigin>,
|
||||
tagSources: value.tagSources as Record<PostImportOrigin, string> | 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:
|
||||
|
||||
@@ -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<Uint8Array> => {
|
||||
const decompressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
|
||||
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<ParsedPostNewState |
|
||||
}
|
||||
|
||||
if (parsed.v !== 1
|
||||
|| !(isPlainObject (parsed))
|
||||
|| !(hasOnlyKeys (parsed as Record<string, unknown>, 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<ParsedPostNewState | null> => {
|
||||
if (byteLength (search) > MAX_QUERY_STRING_BYTES)
|
||||
return null
|
||||
|
||||
const params = new URLSearchParams (search)
|
||||
const stateValue = params.get ('state')
|
||||
if (stateValue != null)
|
||||
|
||||
新しい課題から参照
ユーザをブロックする