このコミットが含まれているのは:
@@ -162,4 +162,40 @@ describe ('api helpers', () => {
|
||||
await apiPatch ('/posts/1')
|
||||
expect (mocks.client.get).toHaveBeenCalledTimes (2)
|
||||
})
|
||||
it ('aborts one write while a shared CSRF lookup remains pending for another write', async () => {
|
||||
let complete!: (value: { data: { token: string } }) => void
|
||||
mocks.client.get.mockReturnValueOnce (new Promise (resolve => { complete = resolve }))
|
||||
const { apiPost } = await import ('@/lib/api')
|
||||
const controller = new AbortController ()
|
||||
const first = apiPost ('/counts/export/jobs', {}, { signal: controller.signal })
|
||||
const second = apiPost ('/posts', {})
|
||||
const rejected = expect (first).rejects.toMatchObject ({ name: 'AbortError' })
|
||||
controller.abort ()
|
||||
await rejected
|
||||
complete ({ data: { token: 'shared' } })
|
||||
await second
|
||||
expect (mocks.client.get).toHaveBeenCalledWith ('/users/csrf',
|
||||
expect.objectContaining ({ timeout: 15_000 }))
|
||||
expect (mocks.client.post).toHaveBeenCalledTimes (1)
|
||||
expect (mocks.client.post.mock.calls[0][0]).toBe ('/posts')
|
||||
})
|
||||
|
||||
it ('releases native download frames after failure or explicit cancellation', async () => {
|
||||
const { startAPIDownload } = await import ('@/lib/api')
|
||||
const failure = vi.fn ()
|
||||
const release = startAPIDownload ('/counts/export', 'file', failure,
|
||||
new URLSearchParams ({ job: 'job', attempt: 'attempt' }))
|
||||
const frame = document.querySelector ('iframe')!
|
||||
expect (new URL (frame.src).searchParams.get ('attempt')).toBe ('attempt')
|
||||
frame.dispatchEvent (new Event ('error'))
|
||||
frame.dispatchEvent (new Event ('error'))
|
||||
expect (failure).toHaveBeenCalledTimes (1)
|
||||
expect (frame).not.toBeInTheDocument ()
|
||||
release ()
|
||||
const cancel = startAPIDownload ('/counts/export', 'file', failure)
|
||||
cancel ()
|
||||
expect (document.querySelector ('iframe')).toBeNull ()
|
||||
expect (failure).toHaveBeenCalledTimes (1)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
+42
-10
@@ -63,7 +63,22 @@ const withSession = (opt?: Opt): AxiosRequestConfig => {
|
||||
|
||||
|
||||
const withCSRF = async (opt?: Opt): Promise<AxiosRequestConfig> => {
|
||||
const token = await csrfToken ()
|
||||
const signal = opt?.signal
|
||||
if (signal?.aborted) throw new DOMException ('Aborted', 'AbortError')
|
||||
const pending = csrfToken ()
|
||||
// A shared CSRF lookup must not make an individual aborted write wait forever.
|
||||
const token = signal == null ? await pending : await new Promise<string> ((resolve, reject) => {
|
||||
const abort = () => reject (new DOMException ('Aborted', 'AbortError'))
|
||||
signal.addEventListener ('abort', abort, { once: true })
|
||||
if (signal.aborted) abort ()
|
||||
void pending.then (value => {
|
||||
signal.removeEventListener ('abort', abort)
|
||||
resolve (value)
|
||||
}, error => {
|
||||
signal.removeEventListener ('abort', abort)
|
||||
reject (error)
|
||||
})
|
||||
})
|
||||
const config = withSession (opt)
|
||||
return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } }
|
||||
}
|
||||
@@ -73,7 +88,8 @@ const csrfToken = async (): Promise<string> => {
|
||||
const state = csrfState ()
|
||||
if (state.token != null)
|
||||
return state.token
|
||||
state.pending ??= client.get<{ token: string }> ('/users/csrf', withSession ())
|
||||
state.pending ??= client.get<{ token: string }> (
|
||||
'/users/csrf', { ...withSession (), timeout: 15_000 })
|
||||
.then (response => {
|
||||
if (csrfState () !== state)
|
||||
return csrfToken ()
|
||||
@@ -223,19 +239,35 @@ export const startAPIDownload = (
|
||||
): (() => void) => {
|
||||
const frame = document.createElement ('iframe')
|
||||
frame.hidden = true
|
||||
let timer: ReturnType<typeof window.setTimeout> | null = null
|
||||
let closed = false
|
||||
const release = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (timer != null) window.clearTimeout (timer)
|
||||
frame.remove ()
|
||||
}
|
||||
const failure = () => {
|
||||
if (closed) return
|
||||
release ()
|
||||
onError?.()
|
||||
}
|
||||
frame.addEventListener ('load', () => {
|
||||
const document = frame.contentDocument
|
||||
if (document != null && document.URL !== 'about:blank')
|
||||
try
|
||||
{
|
||||
onError?.()
|
||||
frame.remove ()
|
||||
const content = frame.contentDocument
|
||||
// Attachment navigations normally have no document. An error document is failure.
|
||||
if (content != null && content.URL !== 'about:blank') failure ()
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cross-origin navigation cannot be inspected; the authenticated ACK decides.
|
||||
}
|
||||
})
|
||||
frame.addEventListener ('error', () => { onError?.(); frame.remove () })
|
||||
frame.addEventListener ('error', failure)
|
||||
const suffix = query == null ? '' : `?${query.toString ()}`
|
||||
frame.src = `${API_BASE_URL}${path}/files/${encodeURIComponent (token)}${suffix}`
|
||||
document.body.append (frame)
|
||||
// Attachments do not reliably fire load; keep this isolated from page navigation.
|
||||
const timer = window.setTimeout (() => frame.remove (), 10 * 60_000)
|
||||
return () => { window.clearTimeout (timer); frame.remove () }
|
||||
timer = window.setTimeout (release, 10 * 60_000)
|
||||
return release
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams,
|
||||
countsRequestParams, normaliseCountsParams, selectedCountChildren } from '@/lib/counts'
|
||||
import { countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
|
||||
import { countsErrorMessage, countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
|
||||
from '@/lib/countsDisplay'
|
||||
import { countsClipboard } from '@/lib/countsClipboard'
|
||||
import { createUII18n, translateUIMessage } from '@/lib/i18n'
|
||||
@@ -157,3 +157,24 @@ describe ('Counts logical display and exports', () => {
|
||||
expect (isCountsPath (path.split ('?')[0])).toBe (true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe ('Counts safe failure messages', () => {
|
||||
it.each ([
|
||||
[400, { error: 'too_many_axes', max_axes: 8 }, '最大8個'],
|
||||
[422, { error: 'too_many_columns', max_columns: 73 }, '最大73列'],
|
||||
[503, { error: 'counts_query_timeout' }, '時間制限'],
|
||||
[503, { error: 'export_worker_unavailable' }, 'ワーカー'],
|
||||
[400, {}, '入力条件'], [401, {}, '認証状態'], [403, {}, '権限']]) (
|
||||
'distinguishes HTTP %s with a known safe response', (status, data, expected) => {
|
||||
const failure = { isAxiosError: true, response: { status, data } }
|
||||
expect (countsErrorMessage (failure, 'ja').text).toContain (expected)
|
||||
})
|
||||
it ('never renders arbitrary exception messages or private identifiers', () => {
|
||||
const failure = { isAxiosError: true, response: { status: 500,
|
||||
data: { error: 'SELECT private_title FROM secret_table', message: 'private-user-17' } } }
|
||||
const text = countsErrorMessage (failure, 'ja').text
|
||||
expect (text).not.toMatch (/SELECT|private|secret_table/)
|
||||
expect (countsErrorMessage ({ error: 'worker_stalled' }, 'ja').text).toContain ('停止')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { TagNameSnapshot } from '@/types'
|
||||
|
||||
export const countsMaskedValue = '__counts_private__'
|
||||
|
||||
export type CountsResource = 'posts' | 'tags' | 'nico_tags'
|
||||
| 'tag_descriptions' | 'tag_groups' | 'materials'
|
||||
export type CountsName = TagNameSnapshot & {
|
||||
@@ -77,6 +79,7 @@ export type CountsResult = {
|
||||
executionAt?: string
|
||||
columnValues?: (string | number | null)[][]
|
||||
cells?: { values: (string | number | null)[]; count: number }[]
|
||||
columnValuesEditable?: boolean
|
||||
conditionLabels?: Record<string, Record<string, string | null>>
|
||||
axisLabels?: Record<string, Record<string, string | null>>
|
||||
axisNames?: Record<string, Record<string, Record<string, unknown>>>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
|
||||
import { isApiError } from '@/lib/api'
|
||||
import { countsMaskedValue, fieldsForCounts, orderedCountFields, selectedCountChildren,
|
||||
sortedCountChildren } from '@/lib/counts'
|
||||
import { countsEventTypes, countsFieldLabel } from '@/lib/countsDefinitions'
|
||||
import { languageUIMessage, translateUIMessage } from '@/lib/i18n'
|
||||
@@ -12,6 +13,37 @@ import type { CountsAxis, CountsDefinition } from '@/lib/countsDefinitions'
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
|
||||
|
||||
// Render only known codes and numerical limits, never arbitrary server error text.
|
||||
export const countsErrorMessage = (failure: unknown, locale: LocaleCode) => {
|
||||
const response = isApiError (failure) ? failure.response : null
|
||||
const body = (response?.data ?? failure) as {
|
||||
error?: unknown; max_axes?: unknown; maxAxes?: unknown
|
||||
max_columns?: unknown; maxColumns?: unknown } | null
|
||||
const code = body != null && typeof body === 'object' ? body.error : null
|
||||
const message = (key: string, values?: Record<string, string | number>) =>
|
||||
translateUIMessage (key, 'counts', locale, values)
|
||||
if (code === 'too_many_axes' || code === 'too_many_columns')
|
||||
{
|
||||
const maximum = code === 'too_many_axes' ? body?.maxAxes ?? body?.max_axes
|
||||
: body?.maxColumns ?? body?.max_columns
|
||||
if (typeof maximum === 'number')
|
||||
return message (code === 'too_many_axes' ? 'axisLimit' : 'columnLimit.message', {
|
||||
max: maximum })
|
||||
}
|
||||
if (code === 'private_condition') return message ('privateCondition')
|
||||
if (code === 'counts_query_timeout' || code === 'generation_timeout')
|
||||
return message ('errors.queryTimeout')
|
||||
if (code === 'export_worker_unavailable' || code === 'worker_unavailable')
|
||||
return message ('errors.workerUnavailable')
|
||||
if (['worker_stalled', 'interrupted', 'generation_failed', 'export_too_large',
|
||||
'export_storage_full'].includes (String (code))) return message ('errors.stopped')
|
||||
if (response?.status === 401) return message ('errors.authentication')
|
||||
if (response?.status === 403) return message ('errors.permission')
|
||||
if (response?.status === 400 || response?.status === 422) return message ('errors.input')
|
||||
return message ('common:fetchError')
|
||||
}
|
||||
|
||||
|
||||
export const countsFieldValue = (row: CountRecord, field: string): unknown =>
|
||||
row[field] ?? row[field.replace (/_([a-z])/g, (_, letter: string) => letter.toUpperCase ())]
|
||||
|
||||
@@ -450,6 +482,7 @@ export const countsConditionValue = (
|
||||
axis: CountsAxis, locale: LocaleCode, timezone: string,
|
||||
labels?: Record<string, Record<string, string | null>>,
|
||||
): string => {
|
||||
if (value === countsMaskedValue) return '********'
|
||||
if (axis.field === 'user' && value == null)
|
||||
return translateUIMessage ('bot', 'counts', locale).text
|
||||
if (value == null) return 'NULL'
|
||||
@@ -488,7 +521,7 @@ export const countsSummaryValue = (
|
||||
): string => {
|
||||
const message = (key: string, values?: Record<string, string | number>) =>
|
||||
translateUIMessage (key, 'counts', locale, values).text
|
||||
if (value === '__counts_private__') return '********'
|
||||
if (value === countsMaskedValue) return '********'
|
||||
if (value == null) return axis.field === 'user' ? message ('bot') : ''
|
||||
if (['language_code', 'source_language_code'].includes (axis.field))
|
||||
return languageUIMessage (String (value), locale).text
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする