このコミットが含まれているのは:
2026-10-31 13:50:10 +09:00
コミット dba409c3e8
33個のファイルの変更、1490行の追加、236行の削除
+36
ファイルの表示
@@ -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
}
+22 -1
ファイルの表示
@@ -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 ('停止')
})
})
+3
ファイルの表示
@@ -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>>>
+35 -2
ファイルの表示
@@ -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