このコミットが含まれているのは:
2026-07-18 00:01:12 +09:00
コミット ff970f8171
11個のファイルの変更、449行の追加、173行の削除
+36 -1
ファイルの表示
@@ -11,6 +11,9 @@ const SESSION_VERSION = 3
const SESSION_PREFIX = 'post-import-session:'
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
const MAX_SESSION_JSON_BYTES = 262_144
const MAX_SESSION_ROWS = 100
const MAX_SOURCE_BYTES = 262_144
const SESSION_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const ATTRIBUTE_KEYS = [
@@ -50,11 +53,15 @@ const SESSION_KEYS = [
'source',
'rows',
'repairMode'] as const
const textEncoder = new TextEncoder ()
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value))
const byteLength = (value: string): number =>
textEncoder.encode (value).byteLength
export const validatePostImportSessionId = (value: unknown): string | null => {
if (typeof value !== 'string')
@@ -184,6 +191,14 @@ const hasOnlyKeys = (
const sanitiseResetSnapshot = (value: unknown): PostImportResetSnapshot | null => {
if (!(isPlainObject (value)))
return null
if (!(hasOnlyKeys (value, ['url',
'attributes',
'provenance',
'tagSources',
'fieldWarnings',
'baseWarnings',
'metadataUrl'])))
return null
if (typeof value.url !== 'string')
return null
if (!(isPlainObject (value.attributes)))
@@ -353,6 +368,8 @@ const sanitiseRow = (value: unknown): PostImportRow | null => {
return null
if (typeof value.url !== 'string')
return null
if (byteLength (value.url) > 20_480)
return null
if (!(isPlainObject (value.attributes)))
return null
if (!(isPlainObject (value.provenance)))
@@ -450,17 +467,23 @@ const sanitiseSession = (value: unknown): PostImportSession | null => {
return null
if (value.version !== SESSION_VERSION || !(Array.isArray (value.rows)))
return null
if (value.rows.length === 0 || value.rows.length > MAX_SESSION_ROWS)
return null
if (typeof value.expiresAt !== 'string' || isExpiredSession (value.expiresAt))
return null
if (typeof value.source !== 'string' || byteLength (value.source) > MAX_SOURCE_BYTES)
return null
const rows = value.rows.map (sanitiseRow)
if (rows.some (row => row == null))
return null
if ((new Set (rows.map (row => row?.sourceRow))).size !== rows.length)
return null
return {
version: SESSION_VERSION,
expiresAt: value.expiresAt,
source: typeof value.source === 'string' ? value.source : '',
source: value.source,
rows: (rows as PostImportRow[]).map (row => recalculateThumbnailWarning (row)),
repairMode: value.repairMode === 'failed' ? 'failed' : 'all' }
}
@@ -497,6 +520,12 @@ export const cleanupExpiredPostImportSessions = (
const raw = sessionStorage.getItem (key)
if (raw == null)
continue
if (byteLength (raw) > MAX_SESSION_JSON_BYTES)
{
sessionStorage.removeItem (key)
--i
continue
}
try
{
@@ -585,6 +614,11 @@ export const loadPostImportSession = (
const raw = readStorage (sessionKey (validatedId), onError)
if (raw == null)
return null
if (byteLength (raw) > MAX_SESSION_JSON_BYTES)
{
removeStorage (sessionKey (validatedId), onError)
return null
}
try
{
@@ -598,6 +632,7 @@ export const loadPostImportSession = (
}
catch
{
removeStorage (sessionKey (validatedId), onError)
return null
}
}
+81 -4
ファイルの表示
@@ -84,13 +84,55 @@ const STATE_KEYS = [
'created_post_id',
'recoverable'] as const
const SINGLE_ROW_KEYS = [...BASIC_KEYS, ...STATE_KEYS, 'session_id'] as const
const ENCODED_ROW_KEYS = [
'n',
'u',
't',
'h',
'f',
'b',
'x',
'd',
'g',
'p',
'm',
's',
'e',
'i',
'c',
'r'] as const
const ALLOWED_MANUAL_FIELDS = [
'title',
'thumbnailBase',
'originalCreatedFrom',
'originalCreatedBefore',
'duration',
'tags',
'parentPostIds'] as const
const MAX_REGULAR_URL_LENGTH = 768
const MAX_COMPRESSED_STATE_LENGTH = 767
const COMPRESSED_STATE_PREFIX = 'z1.'
const MAX_COMPRESSED_STATE_PARAM_LENGTH = 8_192
const MAX_COMPRESSED_STATE_BYTES = 16_384
const MAX_DECOMPRESSED_STATE_BYTES = 262_144
const MAX_STATE_ROWS = 100
const MAX_QUERY_STRING_BYTES = 20_480
const textEncoder = new TextEncoder ()
const textDecoder = new TextDecoder ()
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value != null && !(Array.isArray (value))
const hasOnlyKeys = (
value: Record<string, unknown>,
allowedKeys: readonly string[],
): boolean =>
Object.keys (value).every (key => allowedKeys.includes (key))
const byteLength = (value: string): number =>
textEncoder.encode (value).byteLength
const isValidSkipReason = (value: unknown): value is PostImportSkipReason =>
value === 'existing' || value === 'manual'
@@ -202,18 +244,25 @@ const compressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
const decompressBytes = async (value: Uint8Array): Promise<Uint8Array> => {
const stream =
new Blob ([value]).stream ().pipeThrough (new DecompressionStream ('gzip'))
return new Uint8Array (await new Response (stream).arrayBuffer ())
const bytes = new Uint8Array (await new Response (stream).arrayBuffer ())
if (bytes.byteLength > MAX_DECOMPRESSED_STATE_BYTES)
throw new Error ('decompressed state too large')
return bytes
}
const decodeCompressedState = async (value: string): Promise<string | null> => {
if (!(value.startsWith (COMPRESSED_STATE_PREFIX)))
return null
if (value.length > MAX_COMPRESSED_STATE_PARAM_LENGTH)
return null
const encoded = value.slice (COMPRESSED_STATE_PREFIX.length)
const compressed = decodeBase64UrlBytes (encoded)
if (compressed == null)
return null
if (compressed.byteLength > MAX_COMPRESSED_STATE_BYTES)
return null
try
{
@@ -318,6 +367,10 @@ const parseManual = (value: string | null): string[] =>
.filter (entry => entry !== '')
const hasOnlyAllowedManualFields = (value: string[]): boolean =>
value.every (field => ALLOWED_MANUAL_FIELDS.includes (field as never))
const isStringArray = (value: unknown): value is string[] =>
Array.isArray (value) && value.every (entry => typeof entry === 'string')
@@ -326,10 +379,12 @@ const parseEncodedRow = (
value: unknown,
requireUrl: boolean,
): FullRowState | null => {
if (typeof value !== 'object' || value == null || Array.isArray (value))
if (!(isPlainObject (value)))
return null
const row = value as Record<string, unknown>
if (!(hasOnlyKeys (row, ENCODED_ROW_KEYS)))
return null
const url = row['u']
const manual = row['m']
const skip = decodeSkip (row['s'])
@@ -341,8 +396,12 @@ const parseEncodedRow = (
if (requireUrl && typeof url !== 'string')
return null
if (typeof url === 'string' && byteLength (url) > MAX_QUERY_STRING_BYTES)
return null
if (manual != null && !(isStringArray (manual)))
return null
if ((manual ?? []).some (field => !(ALLOWED_MANUAL_FIELDS.includes (field as never))))
return null
if (row['s'] != null && skip == null)
return null
if (row['i'] != null && importStatus == null)
@@ -382,6 +441,8 @@ const parseEncodedRow = (
const fieldValue = row[key]
if (fieldValue != null && typeof fieldValue !== 'string')
throw new Error ('invalid row')
if (typeof fieldValue === 'string' && byteLength (fieldValue) > MAX_QUERY_STRING_BYTES)
throw new Error ('invalid row')
return [target, fieldValue ?? '']
}),
) as Record<(typeof stringFields)[number][1], string>
@@ -418,6 +479,8 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
const url = params.get ('url')
if (url == null || url === '')
return null
if (byteLength (url) > MAX_QUERY_STRING_BYTES)
return null
const hasOnlyUrl = Array.from (params.keys ()).every (
key => key === 'url' || key === 'session_id')
@@ -460,6 +523,8 @@ const parseSingleRow = (params: URLSearchParams): ParsedPostNewState | null => {
return null
if (importStatus != null && !(isValidImportStatus (importStatus)))
return null
if (!(hasOnlyAllowedManualFields (parseManual (params.get ('manual')))))
return null
const row = buildRow ({
source_row: 1,
@@ -509,7 +574,12 @@ const parseMetaRows = (
return null
const urls = urlsValue.split (' ').filter (url => url !== '')
if (urls.length < 2 || urls.length !== parsed.rows.length)
if (urls.length < 2
|| urls.length > MAX_STATE_ROWS
|| urls.length !== parsed.rows.length
|| parsed.rows.length > MAX_STATE_ROWS)
return null
if (urls.some (url => byteLength (url) > MAX_QUERY_STRING_BYTES))
return null
const fullRows = parsed.rows.map ((row, index) => {
@@ -520,6 +590,8 @@ const parseMetaRows = (
})
if (fullRows.some (row => row == null))
return null
if ((new Set (fullRows.map (row => (row as FullRowState).source_row))).size !== fullRows.length)
return null
const rows = fullRows.map (row => buildRow (row as FullRowState))
return {
@@ -545,12 +617,17 @@ const parseFullState = async (stateValue: string): Promise<ParsedPostNewState |
return null
}
if (parsed.v !== 1 || !(Array.isArray (parsed.rows)) || parsed.rows.length === 0)
if (parsed.v !== 1
|| !(Array.isArray (parsed.rows))
|| parsed.rows.length === 0
|| parsed.rows.length > MAX_STATE_ROWS)
return null
const fullRows = parsed.rows.map (row => parseEncodedRow (row, true))
if (fullRows.some (row => row == null))
return null
if ((new Set (fullRows.map (row => (row as FullRowState).source_row))).size !== fullRows.length)
return null
const rows = fullRows.map (row => buildRow (row as FullRowState))
return {