このコミットが含まれているのは:
2026-10-19 23:08:59 +09:00
コミット 4ab13c4244
56個のファイルの変更、1071行の追加、259行の削除
+30 -67
ファイルの表示
@@ -1,7 +1,7 @@
import { createReadStream } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import { createServer } from 'node:http'
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
import { createServer, request as requestHTTP } from 'node:http'
import { request as requestHTTPS } from 'node:https'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -11,39 +11,6 @@ const root = fileURLToPath (new URL ('./dist/client/', import.meta.url))
const template = await readFile (path.join (root, 'index.html'), 'utf8')
const origin = new URL (process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173').origin
const apiBaseURL = process.env.SSR_API_BASE_URL ?? 'http://127.0.0.1:3002'
const sessionSecret = process.env.SSR_COOKIE_SECRET
if (process.env.NODE_ENV === 'production' && (!sessionSecret || sessionSecret.length < 32))
throw new Error ('SSR_COOKIE_SECRET must contain at least 32 characters in production')
const sessionKey = createHash ('sha256').update (sessionSecret ?? randomBytes (32)).digest ()
const cookieName = '__Host-btrc-session'
const sessionSeconds = 7 * 24 * 60 * 60
const encodeSession = code => {
const iv = randomBytes (12)
const cipher = createCipheriv ('aes-256-gcm', sessionKey, iv)
const payload = JSON.stringify ({ code, expires: Date.now () + sessionSeconds * 1000 })
const encrypted = Buffer.concat ([cipher.update (payload, 'utf8'), cipher.final ()])
return Buffer.concat ([iv, cipher.getAuthTag (), encrypted]).toString ('base64url')
}
const decodeSession = cookie => {
try
{
const token = cookie?.split (';').map (part => part.trim ())
.find (part => part.startsWith (`${cookieName}=`))?.slice (cookieName.length + 1)
if (!token || token.length > 2048) return undefined
const payload = Buffer.from (token, 'base64url')
const decipher = createDecipheriv ('aes-256-gcm', sessionKey, payload.subarray (0, 12))
decipher.setAuthTag (payload.subarray (12, 28))
const data = JSON.parse (Buffer.concat ([
decipher.update (payload.subarray (28)), decipher.final ()]).toString ('utf8'))
return typeof data.code === 'string' && data.expires > Date.now () ? data.code : undefined
}
catch
{
return undefined
}
}
const types = {
'.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
'.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg',
@@ -57,38 +24,32 @@ if (process.env.NODE_ENV === 'production' && !process.env.PUBLIC_ORIGIN)
const server = createServer (async (request, response) => {
response.setHeader ('Cache-Control', 'private, no-store')
const forwardedFor = request.headers['x-forwarded-for'] ?? request.socket.remoteAddress
if (request.url === '/_ssr/session')
if (request.url === '/api' || request.url.startsWith ('/api/'))
{
if (request.method !== 'POST' || request.headers.origin !== origin)
{
response.writeHead (403).end ()
return
}
const code = request.headers['x-transfer-code']
if (typeof code !== 'string' || code.length > 512 || code.length === 0)
{
response.writeHead (400).end ()
return
}
try
{
const verified = await fetch (new URL ('users/session', apiBaseURL.replace (/\/?$/, '/')), {
headers: { 'X-Transfer-Code': code, 'X-Forwarded-For': forwardedFor },
signal: AbortSignal.timeout (10_000) })
if (!verified.ok)
{
response.writeHead (verified.status === 401 || verified.status === 403
? verified.status : 503).end ()
return
}
response.setHeader ('Set-Cookie', `${cookieName}=${encodeSession (code)}; `
+ `Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${sessionSeconds}`)
response.writeHead (204).end ()
}
catch
{
response.writeHead (503).end ()
}
const address = new URL (request.url, origin)
const target = new URL (apiBaseURL)
target.pathname = target.pathname.replace (/\/$/, '') + address.pathname.slice (4)
target.search = address.search
const send = target.protocol === 'https:' ? requestHTTPS : requestHTTP
const upstream = send (target, {
method: request.method,
headers: { ...request.headers, host: target.host,
'x-forwarded-host': new URL (origin).host,
'x-forwarded-proto': new URL (origin).protocol.replace (':', ''),
'x-forwarded-for': forwardedFor } }, result => {
response.writeHead (result.statusCode ?? 502, result.headers)
result.on ('error', () => response.destroy ())
result.pipe (response)
})
upstream.setTimeout (10_000, () => upstream.destroy (new Error ('API timeout')))
upstream.on ('error', () => {
if (response.headersSent)
response.destroy ()
else
response.writeHead (503).end ()
})
request.on ('aborted', () => upstream.destroy ())
request.pipe (upstream)
return
}
if (!['GET', 'HEAD'].includes (request.method))
@@ -124,7 +85,9 @@ const server = createServer (async (request, response) => {
return
}
const result = await render (url.pathname + url.search, origin, apiBaseURL, {
transferCode: decodeSession (request.headers.cookie), forwardedFor })
cookie: request.headers.cookie, forwardedFor })
if (result?.setCookies?.length > 0)
response.setHeader ('Set-Cookie', result.setCookies)
if (result?.location != null)
{
response.writeHead (result.status, { Location: result.location }).end ()
+48 -49
ファイルの表示
@@ -16,11 +16,14 @@ import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import PageHead from '@/components/common/PageHead'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import { isApiError } from '@/lib/api'
import { initialiseUserSession } from '@/lib/users'
import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki'
import { useLocale } from '@/lib/useLocale'
import { applyClientAnimationMode,
applyClientAppearance,
@@ -42,7 +45,7 @@ import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { RouteMatch } from 'react-router-dom'
import type { ClientAnimationMode } from '@/lib/settings'
import type { AvailableLocale } from '@/lib/locales'
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { PageRoute } from '@/lib/routeLocales'
import type { User } from '@/types'
@@ -101,10 +104,12 @@ export const pageRoutes = (
): PageRoute[] => [
{ path: '', element: <LocaleRedirect to="/posts"/>,
handle: { redirectTo: '/posts' } },
{ path: 'posts', element: <PostListPage/> },
{ path: 'posts', element: <PostListPage/>,
handle: { switchLocale: switchPostFilterLocale } },
{ path: 'posts/new', element: <PostNewPage user={user}/>,
handle: { robots: 'noindex' } },
{ path: 'posts/search', element: <PostSearchPage/> },
{ path: 'posts/search', element: <PostSearchPage/>,
handle: { switchLocale: switchPostFilterLocale } },
{ path: 'posts/:id', element: <PostDetailRoute user={user}/> },
{ path: 'posts/changes', element: <PostHistoryPage/> },
{ path: 'tags', element: <TagListPage/> },
@@ -122,13 +127,17 @@ export const pageRoutes = (
handle: { robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: <MaterialDetailPage/> }] },
{ path: 'wiki', element: <WikiSearchPage/> },
{ path: 'wiki/:title', element: <WikiDetailPage/> },
{ path: 'wiki/:title', element: <WikiDetailPage/>,
handle: { switchLocale: switchWikiLocale } },
{ path: 'wiki/new', element: <WikiNewPage user={user}/>,
handle: { robots: 'noindex' } },
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
handle: { robots: 'noindex' } },
{ path: 'wiki/:id/diff', element: <WikiDiffPage/> },
{ path: 'wiki/changes', element: <WikiHistoryPage/> },
handle: { robots: 'noindex',
switchLocale: request => switchWikiLocale (request, 'edit') } },
{ path: 'wiki/:id/diff', element: <WikiDiffPage/>,
handle: { switchLocale: request => switchWikiLocale (request, 'diff') } },
{ path: 'wiki/changes', element: <WikiHistoryPage/>,
handle: { switchLocale: request => switchWikiLocale (request, 'history') } },
{ path: 'help', element: <HelpListPage/> },
{ path: 'help/changes', element: <HelpChangesPage/> },
{ path: 'help/:identifier/diff', element: <HelpDiffPage/> },
@@ -177,7 +186,15 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const client = useQueryClient ()
const location = useLocation ()
const robots = resolvePageRoute (location.pathname)?.route.handle?.robots
const locale = useLocale ()
const route = resolvePageRoute (location.pathname)
const robots = route?.route.handle?.robots
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
const resolve = route?.route.handle?.switchLocale
return (resolve != null && route != null)
? resolve ({ location, params: route.params, locale, targetLocale })
: switchLocalePath (location, targetLocale)
}
const [user, setUser] = useState<User | null> (
() => client.getQueryData<User> (sessionKeys.current) ?? null)
const [status, setStatus] = useState (200)
@@ -213,36 +230,21 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
}, [animationMode])
useEffect (() => {
const createUser = async () => {
const data = await apiPost<{ code: string; user: User }> ('/users')
if (data.code)
{
localStorage.setItem ('user_code', data.code)
setUser (data.user)
}
}
const code = localStorage.getItem ('user_code')
if (code)
let cancelled = false
void (async () => {
try
{
void (async () => {
try
{
const data = await apiPost<{ valid: boolean; user: User }> ('/users/verify', { code })
if (data.valid)
setUser (data.user)
else
await createUser ()
}
catch (err)
{
if (isApiError (err))
setStatus (err.response?.status ?? 200)
}
}) ()
const user = await initialiseUserSession ()
if (!cancelled)
setUser (user)
}
else
createUser ()
catch (error)
{
if (!cancelled && isApiError (error))
setStatus (error.response?.status ?? 503)
}
}) ()
return () => { cancelled = true }
}, [])
useEffect (() => {
@@ -295,7 +297,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user} locales={locales}/>
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
@@ -315,13 +317,14 @@ const LocaleRoute: FC = () => {
const { locale } = useParams ()
const location = useLocation ()
const { i18n } = useTranslation ()
const { data, isPending, isError, refetch } = useQuery ({
const { data, isError } = useQuery ({
queryKey: localesKeys.index,
queryFn: fetchLocales,
staleTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
refetchOnReconnect: 'always' })
staleTime: 60_000,
refetchInterval: 60_000,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true })
const activeLocale =
(isLocaleCode (locale)
&& data?.locales.some (entry => entry.code === locale))
@@ -335,16 +338,12 @@ const LocaleRoute: FC = () => {
void i18n.changeLanguage (activeLocale)
}, [activeLocale, i18n])
useEffect (() => {
void refetch ()
}, [location.key, refetch])
const redirect = legacyLocalePath (location)
if (!(isLocaleCode (locale)) && redirect == null)
return <NotFound/>
if (isError)
if (isError && data == null)
return <ServiceUnavailable/>
if (isPending)
if (data == null)
return null
if (isLocaleCode (locale))
{
+39 -5
ファイルの表示
@@ -10,6 +10,7 @@ import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import TranslatedText from '@/components/TranslatedText'
import { toast } from '@/components/ui/use-toast'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { isLocaleCode,
localeFromPath,
@@ -27,10 +28,13 @@ import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react'
import type { AvailableLocale } from '@/lib/locales'
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null; locales: AvailableLocale[] }
type Props = {
user: User | null
locales: AvailableLocale[]
resolveLocaleSwitch?: (locale: LocaleCode) => Promise<string> }
type NavDensity = 'auto' | 'comfortable' | 'compact'
@@ -125,12 +129,21 @@ export const menuOutline = (
}
const TopNav: FC<Props> = ({ user, locales }) => {
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const localeName = locales.find (entry => entry.code === locale)?.name
const navigate = useLocaleNavigate ()
const location = useLocation ()
const currentLocation = useRef (location)
currentLocation.current = location
const localeSwitchRequest = useRef (0)
const [switchingLocale, setSwitchingLocale] = useState (false)
useEffect (() => {
++localeSwitchRequest.current
setSwitchingLocale (false)
return () => { ++localeSwitchRequest.current }
}, [location.key])
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
@@ -380,13 +393,34 @@ const TopNav: FC<Props> = ({ user, locales }) => {
<select
aria-label={formatUIString (localeName ?? '', locale)}
value={locale}
disabled={switchingLocale}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={event => {
onChange={async event => {
const nextLocale = event.target.value
if (isLocaleCode (nextLocale)
&& locales.some (entry => entry.code === nextLocale))
{
navigate (switchLocalePath (location, nextLocale))
const request = ++localeSwitchRequest.current
setSwitchingLocale (true)
try
{
const path = resolveLocaleSwitch == null
? switchLocalePath (location, nextLocale)
: await resolveLocaleSwitch (nextLocale)
if (request === localeSwitchRequest.current
&& currentLocation.current.key === location.key)
navigate (path)
}
catch
{
if (request === localeSwitchRequest.current)
toast ({ title: label ('common:fetchError') })
}
finally
{
if (request === localeSwitchRequest.current)
setSwitchingLocale (false)
}
}
}}>
{locales.map (entry => isLocaleCode (entry.code) && (
+2 -1
ファイルの表示
@@ -11,6 +11,7 @@ import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api'
import { useUITranslation } from '@/lib/useUITranslation'
import { rememberUserCode } from '@/lib/users'
import type { FC } from 'react'
@@ -43,7 +44,7 @@ const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
'/users/verify', { code: inputCode })
if (data.valid)
{
localStorage.setItem ('user_code', inputCode)
rememberUserCode (data.user.inheritanceCode)
setUser (data.user)
toast ({ title: label ('transfer.success') })
onVisibleChange (false)
+2 -1
ファイルの表示
@@ -9,6 +9,7 @@ import { Dialog,
import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api'
import { useUITranslation } from '@/lib/useUITranslation'
import { rememberUserCode } from '@/lib/users'
import type { FC } from 'react'
@@ -44,7 +45,7 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
{ headers: { 'Content-Type': 'multipart/form-data' } })
if (data.code)
{
localStorage.setItem ('user_code', data.code)
rememberUserCode (data.code)
setUser (user => ({ ...user, inheritanceCode: data.code } as User))
toast ({ title: label ('userCode.renewed') })
}
+1 -3
ファイルの表示
@@ -1,7 +1,5 @@
// frontend/src/config.ts
const ENV: string = 'development'
const config = {
API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002' }
API_BASE_URL: '/api' }
export const API_BASE_URL = config.API_BASE_URL
+9 -6
ファイルの表示
@@ -24,7 +24,8 @@ type RenderedPage = {
html: string
head: string
locale: string
bootstrap: string }
bootstrap: string
setCookies?: string[] }
type RouteResponse = { status: 404 | 308; location?: string }
@@ -60,7 +61,7 @@ export const render = async (
address: string,
origin: string,
apiBaseURL: string,
authentication: Pick<RenderContext, 'transferCode' | 'forwardedFor'> = { },
authentication: Pick<RenderContext, 'cookie' | 'forwardedFor'> = { },
): Promise<RenderedPage | RouteResponse> => {
const url = new URL (address, origin)
const legacy = legacyLocalePath ({ pathname: url.pathname, search: url.search, hash: '' })
@@ -73,7 +74,8 @@ export const render = async (
if (known?.route.handle?.redirectTo != null)
return { status: 308,
location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
const requestContext = { pathname: url.pathname, origin, apiBaseURL,
const requestContext: RenderContext & { status: number } = {
pathname: url.pathname, origin, apiBaseURL,
...authentication, status: 200 }
return withRenderContext (requestContext, async () => {
const client = new QueryClient ({ defaultOptions: {
@@ -83,7 +85,7 @@ export const render = async (
const locales = await client.fetchQuery ({
queryKey: localesKeys.index, queryFn: fetchLocales })
const active = locales.locales.some (entry => entry.code === locale)
if (authentication.transferCode != null)
if (authentication.cookie != null)
{
try
{
@@ -95,7 +97,7 @@ export const render = async (
{
if (!isApiError (error) || error.response?.status !== 401)
throw error
delete requestContext.transferCode
delete requestContext.cookie
}
}
const prepare = known?.route.handle?.prepare
@@ -177,7 +179,8 @@ export const render = async (
url: url.pathname + url.search })
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
.replace (/\u2029/g, '\\u2029')
return { status, html, head, locale, bootstrap }
return { status, html, head, locale, bootstrap,
setCookies: requestContext.setCookies }
}
finally
{
+73 -4
ファイルの表示
@@ -28,11 +28,17 @@ vi.mock ('@/config', () => ({
describe ('api helpers', () => {
beforeEach (() => {
vi.resetModules ()
vi.clearAllMocks ()
localStorage.clear ()
mocks.client.get.mockReset ().mockResolvedValue ({ data: { token: 'csrf-token' } })
mocks.client.post.mockReset ().mockResolvedValue ({ data: { ok: true } })
mocks.client.patch.mockReset ().mockResolvedValue ({ data: { ok: true } })
mocks.client.delete.mockReset ().mockResolvedValue ({ data: '' })
mocks.isAxiosError.mockReturnValue (false)
})
it ('adds the transfer code header and camelizes get responses', async () => {
it ('uses Cookies without sending the recovery code and camelises responses', async () => {
localStorage.setItem ('user_code', 'abc123')
mocks.client.get.mockResolvedValueOnce ({
data: { post_id: 1, nested_value: { created_at: 'now' } },
@@ -47,11 +53,13 @@ describe ('api helpers', () => {
expect (mocks.client.get).toHaveBeenCalledWith (
'/posts/1',
{
headers: { 'X-Transfer-Code': 'abc123', 'X-Extra': '1' },
headers: { 'X-Extra': '1' },
params: { page: 2 },
},
)
expect (data).toEqual ({ postId: 1, nestedValue: { createdAt: 'now' } })
const { default: axios } = await import ('axios')
expect (axios.create).toHaveBeenCalledWith ({ baseURL: '/api', withCredentials: true })
})
it ('passes an empty body for post-like requests when body is omitted', async () => {
@@ -63,7 +71,7 @@ describe ('api helpers', () => {
expect (mocks.client.patch).toHaveBeenCalledWith (
'/posts/1',
{},
{ headers: { 'X-Transfer-Code': '' } },
{ headers: { 'X-CSRF-Token': 'csrf-token' } },
)
expect (data.okValue).toBe (true)
})
@@ -88,9 +96,70 @@ describe ('api helpers', () => {
expect (mocks.client.delete).toHaveBeenCalledWith (
'/posts/1',
{ headers: { 'X-Transfer-Code': '' } },
{ headers: { 'X-CSRF-Token': 'csrf-token' } },
)
expect (isApiError (err)).toBe (true)
expect (mocks.isAxiosError).toHaveBeenCalledWith (err)
})
it ('shares initial CSRF acquisition across concurrent writes and reuses the token', async () => {
let complete!: (value: { data: { token: string } }) => void
mocks.client.get.mockReturnValueOnce (new Promise (resolve => { complete = resolve }))
const { apiPost, apiPatch } = await import ('@/lib/api')
const first = apiPost ('/posts', { title: 'one' })
const second = apiPatch ('/posts/1', { title: 'two' })
expect (mocks.client.get).toHaveBeenCalledTimes (1)
expect (mocks.client.post).not.toHaveBeenCalled ()
complete ({ data: { token: 'shared-token' } })
await Promise.all ([first, second])
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (1)
expect (mocks.client.post).toHaveBeenCalledWith ('/posts', { title: 'one' }, {
headers: { 'X-CSRF-Token': 'shared-token' } })
expect (mocks.client.patch).toHaveBeenLastCalledWith ('/posts/1', { }, {
headers: { 'X-CSRF-Token': 'shared-token' } })
})
it.each (['/users', '/users/verify', '/users/code/renew']) (
'refreshes CSRF after a session change through %s', async path => {
mocks.client.get.mockResolvedValueOnce ({ data: { token: 'before' } })
mocks.client.get.mockResolvedValueOnce ({ data: { token: 'after' } })
mocks.client.post.mockResolvedValueOnce ({ data: { valid: true } })
const { apiPost, apiPatch } = await import ('@/lib/api')
await apiPost (path)
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
expect (mocks.client.patch).toHaveBeenCalledWith ('/posts/1', { }, {
headers: { 'X-CSRF-Token': 'after' } })
})
it ('clears the recovery code and CSRF token after session deletion', async () => {
localStorage.setItem ('user_code', 'backup')
const { apiDelete, apiPatch } = await import ('@/lib/api')
await apiDelete ('/users/session')
expect (localStorage.getItem ('user_code')).toBeNull ()
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
it ('clears rejected CSRF tokens without replaying a write', async () => {
const error = { response: { status: 403, headers: { 'x-csrf-invalid': '1' } } }
mocks.isAxiosError.mockReturnValue (true)
mocks.client.patch.mockRejectedValueOnce (error)
const { apiPatch } = await import ('@/lib/api')
await expect (apiPatch ('/posts/1')).rejects.toBe (error)
expect (mocks.client.patch).toHaveBeenCalledTimes (1)
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
it ('allows CSRF acquisition to recover after a failed request', async () => {
const error = new Error ('offline')
mocks.client.get.mockRejectedValueOnce (error)
const { apiPatch } = await import ('@/lib/api')
await expect (apiPatch ('/posts/1')).rejects.toBe (error)
expect (mocks.client.patch).not.toHaveBeenCalled ()
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
})
+122 -27
ファイルの表示
@@ -4,9 +4,10 @@ import toCamel from 'camelcase-keys'
import { API_BASE_URL } from '@/config'
import { currentRenderContext } from '@/lib/renderContext'
import type { AxiosError, AxiosRequestConfig } from 'axios'
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { LocaleCode } from '@/lib/locales'
import type { RenderContext } from '@/lib/renderContext'
type Opt = {
locale?: LocaleCode
@@ -15,49 +16,128 @@ type Opt = {
signal?: AbortSignal
responseType?: 'blob' }
const client = axios.create ({ baseURL: API_BASE_URL })
const client = axios.create ({ baseURL: API_BASE_URL, withCredentials: true })
type CSRFState = NonNullable<RenderContext['csrf']>
let browserCSRF: CSRFState = { }
const withUserCode = (opt?: Opt): AxiosRequestConfig => {
const csrfState = (): CSRFState => {
if (typeof window !== 'undefined')
return browserCSRF
const context = currentRenderContext ()
if (context == null)
throw new Error ('Missing request context for CSRF')
return context.csrf ??= { }
}
const resetCSRF = (): void => {
if (typeof window !== 'undefined')
browserCSRF = { }
else
{
const context = currentRenderContext ()
if (context != null)
context.csrf = { }
}
}
const withSession = (opt?: Opt): AxiosRequestConfig => {
const { locale, ...config } = opt ?? { }
return {
...config,
...(currentRenderContext ()?.apiBaseURL != null
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
...(locale != null && { params: { locale, ...config.params } }),
headers: { 'X-Transfer-Code': typeof window === 'undefined'
? currentRenderContext ()?.transferCode ?? ''
: localStorage.getItem ('user_code') ?? '',
headers: { ...(currentRenderContext ()?.cookie != null && {
Cookie: currentRenderContext ()!.cookie }),
...(typeof window === 'undefined' && currentRenderContext () != null && {
'X-Forwarded-Host': new URL (currentRenderContext ()!.origin).host,
'X-Forwarded-Proto': new URL (currentRenderContext ()!.origin)
.protocol.replace (':', '') }),
...(currentRenderContext ()?.forwardedFor != null && {
'X-Forwarded-For': currentRenderContext ()!.forwardedFor }),
...(config.headers ?? { }) } }
}
const withCSRF = async (opt?: Opt): Promise<AxiosRequestConfig> => {
const token = await csrfToken ()
const config = withSession (opt)
return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } }
}
const csrfToken = async (): Promise<string> => {
const state = csrfState ()
if (state.token != null)
return state.token
state.pending ??= client.get<{ token: string }> ('/users/csrf', withSession ())
.then (response => {
if (csrfState () !== state)
return csrfToken ()
if (typeof response.data.token !== 'string')
throw new Error ('Missing CSRF token')
rememberSessionCookies (response.headers)
state.token = response.data.token
delete state.pending
return state.token
}).catch (error => {
delete state.pending
throw error
})
return state.pending
}
const csrfWrite = async <T,> (
send: (config: AxiosRequestConfig) => Promise<AxiosResponse<T>>,
opt?: Opt,
): Promise<AxiosResponse<T>> => {
try
{
return await send (await withCSRF (opt))
}
catch (error)
{
if (axios.isAxiosError (error)
&& error.response?.headers?.['x-csrf-invalid'] === '1')
resetCSRF ()
throw error
}
}
const rememberSessionCookies = (headers: AxiosResponse['headers']) => {
const context = currentRenderContext ()
const cookies = headers?.['set-cookie']
if (typeof window === 'undefined' && context != null && Array.isArray (cookies))
{
context.setCookies = cookies
const pairs = new Map ((context.cookie ?? '').split (';').filter (Boolean)
.map (value => value.trim ().split (/=(.*)/s).slice (0, 2) as [string, string]))
for (const cookie of cookies)
{
const pair = cookie.split (';')[0]
const separator = pair.indexOf ('=')
pairs.set (pair.slice (0, separator), pair.slice (separator + 1))
}
context.cookie = [...pairs].map (([key, value]) => `${key}=${value}`).join ('; ')
}
}
const apiP = async <T> (
method: 'post' | 'put' | 'patch',
path: string,
body?: unknown,
opt?: Opt): Promise<T> => {
const res = await client[method] (path, body ?? { }, withUserCode (opt))
if (typeof window !== 'undefined' && method === 'post'
&& ['/users', '/users/verify', '/users/code/renew'].includes (path))
{
const code = res.data?.code ?? (res.data?.valid ? (body as { code?: string })?.code : null)
if (typeof code === 'string')
{
try
{
await fetch ('/_ssr/session', {
method: 'POST', credentials: 'same-origin',
headers: { 'X-Transfer-Code': code } })
}
catch
{
// Existing CSR authentication remains usable if the SSR endpoint is unavailable.
}
}
}
const res = await csrfWrite (config => client[method] (path, body ?? { }, config), opt)
if (method === 'post' && (path === '/users' || path === '/users/code/renew'
|| (path === '/users/verify' && res.data?.valid)))
resetCSRF ()
rememberSessionCookies (res.headers)
if (opt?.responseType === 'blob')
return res.data as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
@@ -67,7 +147,8 @@ const apiP = async <T> (
export const apiGet = async <T> (
path: string,
opt?: Opt): Promise<T> => {
const res = await client.get (path, withUserCode (opt))
const res = await client.get (path, withSession (opt))
rememberSessionCookies (res.headers)
if (opt?.responseType === 'blob')
return res.data as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
@@ -95,7 +176,21 @@ export const apiPatch = async <T> (
export const apiDelete = async <T = void> (
path: string,
opt?: Opt): Promise<T> => {
const res = await client.delete (path, withUserCode (opt))
const res = await csrfWrite (config => client.delete (path, config), opt)
if (path === '/users/session')
resetCSRF ()
rememberSessionCookies (res.headers)
if (typeof window !== 'undefined' && path === '/users/session')
{
try
{
localStorage.removeItem ('user_code')
}
catch
{
// The server session is already cleared even if storage is unavailable.
}
}
if (res.data == null || res.data === '')
return undefined as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
+31 -1
ファイルの表示
@@ -1,11 +1,13 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
import { switchLocalePath } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
import type { LocaleSwitchRequest } from '@/lib/routeLocales'
import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async (
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
{ url, title, tags, tagReferences, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
locale: LocaleCode = 'ja',
): Promise<{
@@ -15,6 +17,7 @@ export const fetchPosts = async (
...(url && { url }),
...(title && { title }),
...(tags && { tags }),
...(tagReferences && { tag_refs: tagReferences }),
...(match && { match }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
@@ -27,6 +30,33 @@ export const fetchPosts = async (
...(order && { order }) } })
export const switchPostFilterLocale = async (
{ location, locale, targetLocale }: LocaleSwitchRequest,
): Promise<string> => {
const query = new URLSearchParams (location.search)
const tags = query.get ('tags')
if (tags == null || tags.trim () === '')
return switchLocalePath (location, targetLocale)
const { references } = await apiGet<{ references: {
name: string
tagId?: number
externalTagId?: number
negative?: boolean
missing?: boolean }[] }> ('/posts/tag_filters', {
locale, params: { tags, target_locale: targetLocale,
tag_refs: query.get ('tag_refs') ?? undefined } })
query.set ('tags', references.map (reference => reference.name).join (' '))
query.set ('tag_refs', JSON.stringify (references.map (reference => ({
name: reference.name,
negative: reference.negative,
...(reference.tagId != null && { tag_id: reference.tagId }),
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
...(reference.missing && { missing: true }) }))))
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
}
export const fetchPost = async (
id: string,
locale: LocaleCode = 'ja',
+3 -1
ファイルの表示
@@ -23,6 +23,7 @@ const wikiApi = vi.hoisted (() => ({
fetchWikiPages: vi.fn (),
fetchWikiPageByReference: vi.fn (),
fetchWikiAutolinkCandidates: vi.fn (),
fetchWikiTag: vi.fn (),
}))
vi.mock ('@/lib/posts', () => postsApi)
@@ -49,6 +50,7 @@ describe ('prefetchForURL', () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValue (null)
wikiApi.fetchWikiPageByReference.mockResolvedValue (null)
wikiApi.fetchWikiAutolinkCandidates.mockResolvedValue ([])
wikiApi.fetchWikiTag.mockResolvedValue (null)
})
it ('prefetches post indexes from query parameters', async () => {
@@ -129,7 +131,7 @@ describe ('prefetchForURL', () => {
expect (wikiApi.fetchWikiPageByReference).toHaveBeenCalledWith (
'Alias', { version: undefined }, 'ja')
expect (wikiApi.fetchWikiPage).not.toHaveBeenCalled ()
expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual', 'ja')
expect (wikiApi.fetchWikiTag).toHaveBeenCalledWith ({ name: 'Actual' }, 'ja')
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({ tags: 'Actual', limit: 8 }), 'ja',
)
+11 -6
ファイルの表示
@@ -10,13 +10,14 @@ import { getHelpArticle,
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchNicoTags, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiAutolinkCandidates,
fetchWikiPageByReference,
fetchWikiTag,
fetchWikiPages } from '@/lib/wiki'
import type { LocaleCode } from '@/lib/locales'
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
import type { Category, FetchPostsOrder, FetchTagsOrder, WikiTagReference } from '@/types'
type Prefetcher = (qc: QueryClient, url: URL, locale: LocaleCode) => Promise<void>
@@ -127,16 +128,18 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url, locale) => {
}
const effectiveTitle = wikiPage?.title ?? title
const tagReference: WikiTagReference = (wikiPage != null || !/^\d+$/.test (title))
? { name: effectiveTitle } : { wikiId: title }
await qc.prefetchQuery ({
queryKey: tagsKeys.show (effectiveTitle, locale),
queryFn: () => fetchTagByName (effectiveTitle, locale) })
const tag = await qc.fetchQuery ({
queryKey: wikiKeys.tag (tagReference, locale),
queryFn: () => fetchWikiTag (tagReference, locale) })
if (version)
return
const p = {
tags: effectiveTitle,
tags: wikiPage?.title ?? tag?.name ?? title,
match: 'all',
page: 1,
limit: 8,
@@ -172,6 +175,8 @@ const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
const keys = {
tags, match: m, page, limit, url: qURL, title,
...(url.searchParams.get ('tag_refs') && {
tagReferences: url.searchParams.get ('tag_refs')! }),
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
updatedFrom, updatedTo, order }
+3 -1
ファイルの表示
@@ -5,7 +5,7 @@ import type {
FetchPostsParams,
FetchTagsParams,
MaterialFilter,
} from '@/types'
WikiTagReference } from '@/types'
export const localesKeys = {
root: ['locales'] as const,
@@ -123,6 +123,8 @@ export const materialsKeys = {
export const wikiKeys = {
root: ['wiki'] as const,
tag: (reference: WikiTagReference, locale: LocaleCode) =>
['wiki', locale, 'tag', reference] as const,
diff: (id: string, p: { from: string | null; to: string | null }, locale: LocaleCode) =>
['wiki', locale, 'diff', id, p] as const,
autolinkCandidates: (language: string, locale: LocaleCode) =>
+3 -1
ファイルの表示
@@ -2,7 +2,9 @@ export type RenderContext = {
pathname: string
origin: string
apiBaseURL?: string
transferCode?: string
cookie?: string
setCookies?: string[]
csrf?: { token?: string; pending?: Promise<string> }
forwardedFor?: string
status?: number }
+8 -1
ファイルの表示
@@ -1,5 +1,5 @@
import type { QueryClient } from '@tanstack/react-query'
import type { IndexRouteObject, NonIndexRouteObject, Params } from 'react-router-dom'
import type { IndexRouteObject, Location, NonIndexRouteObject, Params } from 'react-router-dom'
import type { LocaleCode } from '@/lib/locales'
@@ -9,8 +9,15 @@ export type PagePreparation = {
locale: LocaleCode
searchParams: URLSearchParams }
export type LocaleSwitchRequest = {
location: Pick<Location, 'pathname' | 'search' | 'hash'>
params: Readonly<Params>
locale: LocaleCode
targetLocale: LocaleCode }
export type PageRouteHandle = {
prepare?: (request: PagePreparation) => Promise<number>
switchLocale?: (request: LocaleSwitchRequest) => Promise<string>
locale?: LocaleCode
robots?: 'noindex'
authentication?: 'user'
+69
ファイルの表示
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const api = vi.hoisted (() => ({ apiGet: vi.fn (), apiPost: vi.fn () }))
vi.mock ('@/lib/api', () => ({ ...api,
isApiError: (error: { isAxiosError?: boolean }) => error.isAxiosError === true }))
const user = { id: 1, name: 'Cookie user', role: 'guest', inheritanceCode: 'current-code' }
const noSession = { isAxiosError: true, response: { status: 401 } }
describe ('Cookie session initialisation', () => {
beforeEach (() => {
vi.resetModules ()
vi.clearAllMocks ()
api.apiGet.mockReset ()
api.apiPost.mockReset ()
localStorage.clear ()
})
it ('keeps Cookie identity and updates an older recovery code', async () => {
localStorage.setItem ('user_code', 'old-code')
api.apiGet.mockResolvedValueOnce (user)
const { initialiseUserSession } = await import ('@/lib/users')
await expect (initialiseUserSession ()).resolves.toEqual (user)
expect (api.apiGet).toHaveBeenCalledWith ('/users/me')
expect (api.apiPost).not.toHaveBeenCalled ()
expect (localStorage.getItem ('user_code')).toBe ('current-code')
})
it ('restores from storage only after Cookie authentication returns 401', async () => {
localStorage.setItem ('user_code', 'backup-code')
api.apiGet.mockRejectedValueOnce (noSession)
api.apiPost.mockResolvedValueOnce ({ valid: true, user })
const { initialiseUserSession } = await import ('@/lib/users')
await expect (initialiseUserSession ()).resolves.toEqual (user)
expect (api.apiPost).toHaveBeenCalledExactlyOnceWith (
'/users/verify', { code: 'backup-code', restore: true })
})
it ('creates an account after a recovery code is rejected', async () => {
localStorage.setItem ('user_code', 'expired-code')
api.apiGet.mockRejectedValueOnce (noSession)
api.apiPost.mockResolvedValueOnce ({ valid: false })
api.apiPost.mockResolvedValueOnce ({ code: user.inheritanceCode, user })
const { initialiseUserSession } = await import ('@/lib/users')
await expect (initialiseUserSession ()).resolves.toEqual (user)
expect (api.apiPost).toHaveBeenLastCalledWith ('/users')
})
it.each ([403, 503]) ('does not replace a session when the API returns %s', async status => {
localStorage.setItem ('user_code', 'backup-code')
const error = { isAxiosError: true, response: { status } }
api.apiGet.mockRejectedValueOnce (error)
const { initialiseUserSession } = await import ('@/lib/users')
await expect (initialiseUserSession ()).rejects.toBe (error)
expect (api.apiPost).not.toHaveBeenCalled ()
expect (localStorage.getItem ('user_code')).toBe ('backup-code')
})
it ('shares concurrent initialisation to avoid duplicate account creation', async () => {
api.apiGet.mockRejectedValueOnce (noSession)
api.apiPost.mockResolvedValueOnce ({ code: user.inheritanceCode, user })
const { initialiseUserSession } = await import ('@/lib/users')
await Promise.all ([initialiseUserSession (), initialiseUserSession ()])
expect (api.apiGet).toHaveBeenCalledTimes (1)
expect (api.apiPost).toHaveBeenCalledExactlyOnceWith ('/users')
})
})
+64
ファイルの表示
@@ -1,6 +1,70 @@
import { apiGet, apiPost, isApiError } from '@/lib/api'
import type { User, UserRole } from '@/types'
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
let pendingSession: Promise<User> | null = null
const storedUserCode = (): string | null => {
try
{
return localStorage.getItem ('user_code')
}
catch
{
return null
}
}
export const rememberUserCode = (code: string): void => {
try
{
localStorage.setItem ('user_code', code)
}
catch
{
// A missing recovery copy must not invalidate the authenticated Cookie.
}
}
const restoreUserSession = async (): Promise<User> => {
try
{
return await apiGet<User> ('/users/me')
}
catch (error)
{
if (!isApiError (error) || error.response?.status !== 401)
throw error
}
const code = storedUserCode ()
if (code)
{
const data = await apiPost<{ valid: boolean; user: User }> (
'/users/verify', { code, restore: true })
if (data.valid)
return data.user
}
const data = await apiPost<{ code: string; user: User }> ('/users')
return data.user
}
export const initialiseUserSession = (): Promise<User> => {
if (typeof window === 'undefined')
throw new Error ('Browser session initialisation is client-only')
pendingSession ??= restoreUserSession ().then (user => {
rememberUserCode (user.inheritanceCode)
return user
}).finally (() => {
pendingSession = null
})
return pendingSession
}
export const canEditContent = (
+56 -2
ファイルの表示
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki'
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages,
switchWikiLocale } from '@/lib/wiki'
const api = vi.hoisted (() => ({ apiGet: vi.fn () }))
@@ -8,7 +9,60 @@ vi.mock ('@/lib/api', () => api)
describe ('wiki API functions', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.apiGet.mockReset ()
})
it.each (['detail', 'edit', 'diff', 'history'] as const) (
'switches only the Locale for a missing resource on the %s screen',
async view => {
api.apiGet.mockRejectedValue ({
isAxiosError: true, response: { status: 404 } })
const pathname = view === 'history' ? '/wiki/changes'
: `/wiki/${encodeURIComponent ('げいかうい')}${
view === 'detail' ? '' : `/${view}`}`
const search = '?id=missing&from=11&to=12&version=3&page=2'
const params = view === 'history' ? { }
: (view === 'detail' ? { title: 'げいかうい' } : { id: 'げいかうい' })
const path = await switchWikiLocale ({
location: { pathname: `/ain-Kana${pathname}`, search, hash: '#section' },
params,
locale: 'ain-Kana', targetLocale: 'ja' }, view)
expect (path).toBe (`/ja${pathname}${search}#section`)
})
it.each (['wiki', 'tag'] as const) (
'rejects communication failures during the %s lookup', async lookup => {
const error = new Error ('network failure')
if (lookup === 'tag')
api.apiGet.mockRejectedValueOnce ({
isAxiosError: true, response: { status: 404 } })
api.apiGet.mockRejectedValueOnce (error)
await expect (switchWikiLocale ({
location: { pathname: '/ain-Kana/wiki/missing', search: '', hash: '' },
params: { title: 'missing' }, locale: 'ain-Kana', targetLocale: 'ja' }))
.rejects.toBe (error)
})
it.each (['edit', 'diff'] as const) ('keeps the %s screen with the target Wiki ID',
async view => {
api.apiGet.mockResolvedValueOnce ({ id: 101, tagId: 7, resourceType: 'tag_description' })
api.apiGet.mockResolvedValueOnce ({ id: 202, tagId: 7, resourceType: 'tag_description' })
const path = await switchWikiLocale ({
location: { pathname: `/ja/wiki/101/${view}`, search: '?from=11&to=12&keep=1',
hash: '#section' },
params: { id: '101' }, locale: 'ja', targetLocale: 'ain-Kana' }, view)
expect (path).toBe (`/ain-Kana/wiki/202/${view}?keep=1#section`)
expect (api.apiGet).toHaveBeenNthCalledWith (2, '/wiki/101', {
locale: 'ain-Kana', params: { } })
})
it ('keeps filtered history with the target-language description ID', async () => {
api.apiGet.mockResolvedValueOnce ({ id: 101 })
api.apiGet.mockResolvedValueOnce ({ id: 202 })
const path = await switchWikiLocale ({
location: { pathname: '/ja/wiki/changes', search: '?id=101&keep=1', hash: '' },
params: { }, locale: 'ja', targetLocale: 'ain-Kana' }, 'history')
expect (path).toBe ('/ain-Kana/wiki/changes?id=202&keep=1')
})
it ('fetches wiki index and show pages with expected parameters', async () => {
+68 -4
ファイルの表示
@@ -1,9 +1,13 @@
import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api'
import { switchLocalePath } from '@/lib/locales'
import { fetchTag, fetchTagByName } from '@/lib/tags'
import type { LocaleCode } from '@/lib/locales'
import type { WikiAutolinkCandidate, WikiPage, WikiPageChange } from '@/types'
import type { LocaleSwitchRequest } from '@/lib/routeLocales'
import type { Tag, WikiAutolinkCandidate, WikiPage,
WikiPageChange, WikiTagReference } from '@/types'
export const fetchWikiAutolinkCandidates = (
@@ -66,8 +70,68 @@ export const fetchWikiPageByReference = async (
}
catch (error)
{
if (isAxiosError (error) && error.response?.status === 404)
return null
throw error
if (!isAxiosError (error) || error.response?.status !== 404)
throw error
const tag = await fetchWikiTag ({ wikiId: reference }, locale)
return tag == null ? null : fetchWikiPageByTitle (tag.name, params, locale)
}
}
export const fetchWikiTag = async (
reference: WikiTagReference,
locale: LocaleCode,
): Promise<Tag | null> => {
if ('name' in reference)
return fetchTagByName (reference.name, locale)
try
{
return await apiGet (`/wiki/${encodeURIComponent (reference.wikiId)}/tag`, { locale })
}
catch (error)
{
if (!isAxiosError (error) || error.response?.status !== 404)
throw error
return fetchTagByName (reference.wikiId, locale)
}
}
export const switchWikiLocale = async (
{ location, params, locale, targetLocale }: LocaleSwitchRequest,
view: 'detail' | 'edit' | 'diff' | 'history' = 'detail',
): Promise<string> => {
const query = new URLSearchParams (location.search)
const reference = params.title ?? params.id ?? query.get ('id')
if (reference == null)
return switchLocalePath (location, targetLocale)
const page = await fetchWikiPageByReference (reference, { }, locale)
const sourceTag = page == null ? await fetchWikiTag (
/^\d+$/.test (reference) ? { wikiId: reference } : { name: reference }, locale) : null
if (page == null && sourceTag == null)
return switchLocalePath (location, targetLocale)
const translated = page == null ? null
: await fetchWikiPageByReference (String (page.id), { }, targetLocale)
const tag = sourceTag != null
? await fetchTag (String (sourceTag.id), targetLocale)
: ((translated == null && page?.resourceType === 'tag_description')
? await fetchWikiTag ({ wikiId: String (page.id) }, targetLocale) : null)
const destination = tag?.name ?? translated?.id ?? page?.id
if (destination == null)
throw new Error ('Wiki resource could not be resolved')
for (const key of ['version', 'from', 'to'])
query.delete (key)
const historyId = view === 'history' ? translated?.id : null
if (historyId != null)
query.set ('id', String (historyId))
else
query.delete ('id')
const suffix = (translated != null && (view === 'edit' || view === 'diff'))
? `/${view}` : ''
return switchLocalePath ({
pathname: historyId != null
? location.pathname
: `/wiki/${encodeURIComponent (destination)}${suffix}`,
search: query.size === 0 ? '' : `?${query}`,
hash: location.hash }, targetLocale)
}
+1
ファイルの表示
@@ -61,6 +61,7 @@ const PostListPage: FC = () => {
const keys = {
tags: tagsKey, match, page, limit,
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
url: '', title: '', originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order } as const
+1
ファイルの表示
@@ -126,6 +126,7 @@ const PostSearchPage: FC = () => {
const keys: FetchPostsParams = {
tags: qTags, match: qMatch, page, limit,
...(query.get ('tag_refs') && { tagReferences: query.get ('tag_refs')! }),
url: qURL,
title: qTitle,
originalCreatedFrom: qOriginalCreatedFrom,
+8 -1
ファイルの表示
@@ -257,7 +257,14 @@ const NicoTagListPage: FC<Props> = ({ user }) => {
{nicoTags.length > 0
? (
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="w-full table-auto border-collapse">
<table className="w-full min-w-[800px] table-auto border-collapse">
<colgroup>
<col className="w-64"/>
<col/>
<col className="w-56"/>
<col className="w-56"/>
{editable && <col className="w-24"/>}
</colgroup>
<thead className="border-b-2 border-black dark:border-white
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
<tr>
+8 -5
ファイルの表示
@@ -72,9 +72,12 @@ const renderPrimaryName = (
language: string,
label: (key: string) => ReactNode,
) => (
<span lang={language} className="whitespace-nowrap">
<span lang={language} className="whitespace-normal [overflow-wrap:anywhere]">
{snapshot.name}
{snapshot.name != null && snapshot.autoGenerated && label ('nameChanges.uncertified')}
{snapshot.name != null && snapshot.autoGenerated && (
<span className="ainu-ui-label inline-block whitespace-nowrap">
{label ('nameChanges.uncertified')}
</span>)}
</span>)
@@ -93,7 +96,7 @@ const renderAliasDiff = (
<ins
key={name}
lang={language}
className="mr-2 inline-block whitespace-nowrap
className="mr-2 inline-block max-w-full whitespace-normal [overflow-wrap:anywhere]
underline text-green-600 dark:text-green-400">
{name}
</ins>)
@@ -105,7 +108,7 @@ const renderAliasDiff = (
<del
key={name}
lang={language}
className="mr-2 inline-block whitespace-nowrap
className="mr-2 inline-block max-w-full whitespace-normal [overflow-wrap:anywhere]
line-through text-red-600 dark:text-red-400">
{name}
</del>)
@@ -115,7 +118,7 @@ const renderAliasDiff = (
<span
key={name}
lang={language}
className="mr-2 inline-block whitespace-nowrap">
className="mr-2 inline-block max-w-full whitespace-normal [overflow-wrap:anywhere]">
{name}
</span>)
})
+14 -2
ファイルの表示
@@ -360,7 +360,18 @@ const TagListPage: FC = () => {
{loading ? label ('common:loading') : (results.length > 0 ? (
<div className="mt-4 min-w-0 max-w-full">
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="w-full table-auto border-collapse">
<table className="w-full min-w-[2000px] table-auto border-collapse">
<colgroup>
<col className="w-72"/>
<col className="w-16"/>
<col className="w-48"/>
<col className="w-32"/>
<col className="w-72"/>
<col className="w-48"/>
<col className="w-56"/>
<col className="w-56"/>
<col className="w-16"/>
</colgroup>
<thead className="border-b-2 border-black dark:border-white
[&_.ainu-ui-text]:!whitespace-nowrap [&_wbr]:hidden">
<tr>
@@ -468,7 +479,8 @@ const TagListPage: FC = () => {
<PrefetchLink
to={row.category === 'nico'
? `/posts/changes?external_tag=${ row.id }`
: `/posts/changes?tag=${ row.id }`}>
: `/posts/changes?tag=${ row.id }`}
className="ainu-ui-label inline-block whitespace-nowrap">
{label ('history')}
</PrefetchLink>
</td>
+4 -1
ファイルの表示
@@ -27,6 +27,8 @@ const postsApi = vi.hoisted (() => ({
vi.mock ('@/lib/wiki', () => ({
...wikiApi,
fetchWikiAutolinkCandidates: async () => [],
fetchWikiTag: (reference: { name?: string; wikiId?: string }, locale: string) =>
tagsApi.fetchTagByName (reference.name ?? reference.wikiId, locale),
fetchWikiPageByReference: (reference: string, ...args: unknown[]) =>
/^\d+$/.test (reference)
? wikiApi.fetchWikiPage (reference, ...args)
@@ -118,7 +120,8 @@ describe ('WikiDetailPage', () => {
expect (screen.queryByText ('404')).not.toBeInTheDocument ()
expect (queryClient.getQueryData (wikiKeys.show (page.title, { }))).toEqual (page)
expect (queryClient.getQueryState (wikiKeys.index ({ }))?.isInvalidated).toBe (true)
await waitFor (() => expect (queryClient.getQueryData (tagsKeys.show (page.title)))
await waitFor (() => expect (queryClient.getQueryData (
wikiKeys.tag ({ name: page.title }, 'ja')))
.toMatchObject ({ hasWiki: true }))
expect (getHelpTitleIndex).toHaveBeenCalledTimes (1)
})
+17 -17
ファイルの表示
@@ -15,14 +15,13 @@ import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { getHelpTitleIndex } from '@/lib/help'
import { localeLanguage } from '@/lib/locales'
import { fetchPosts } from '@/lib/posts'
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTagByName } from '@/lib/tags'
import { fetchWikiPageByReference } from '@/lib/wiki'
import { helpKeys, postsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchWikiPageByReference, fetchWikiTag } from '@/lib/wiki'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
import type { Tag } from '@/types'
import type { Tag, WikiTagReference } from '@/types'
const WikiDetailPage: FC = () => {
@@ -65,21 +64,24 @@ const WikiDetailPage: FC = () => {
const effectiveTitle = wikiPage?.title ?? title
const deprecated = wikiPage?.deprecatedAt != null
const tagReference: WikiTagReference = (wikiPage != null || !numericReference)
? { name: effectiveTitle } : { wikiId: title }
const { data: tag, isPending: tagPending,
isFetching: tagFetching, error: tagError } = useQuery ({
enabled: Boolean (effectiveTitle) && (!numericReference || wikiPage != null),
queryKey: tagsKeys.show (effectiveTitle, locale),
queryFn: () => fetchTagByName (effectiveTitle, locale) })
enabled: Boolean (title) && (missingWiki || wikiPage != null),
queryKey: wikiKeys.tag (tagReference, locale),
queryFn: () => fetchWikiTag (tagReference, locale) })
const displayTitle = wikiPage?.title ?? tag?.name ?? title
const keys = {
tags: effectiveTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
tags: displayTitle, match: 'all', page: 1, limit: 8, url: '', title: '',
originalCreatedFrom: '', originalCreatedTo: '',
createdFrom: '', createdTo: '', updatedFrom: '', updatedTo: '',
order: 'original_created_at:desc' } as const
const { data } = useQuery ({
enabled: Boolean (effectiveTitle) && !(version)
&& (!numericReference || wikiPage != null),
&& (!numericReference || wikiPage != null || tag != null),
queryKey: postsKeys.index (keys, locale),
queryFn: () => fetchPosts (keys, locale) })
const posts = data?.posts || []
@@ -103,12 +105,10 @@ const WikiDetailPage: FC = () => {
if (displayError != null)
return <MainArea><p role="alert">{displayError.message}</p></MainArea>
if (missingWiki && numericReference)
return <NotFound/>
const waiting = loading || (isFetching && wikiPage == null)
|| (missingWiki && (lookupPending || lookupFetching || tagPending
|| (tagFetching && tag == null) || identifiers.length === 1))
|| (missingWiki && ((!numericReference
&& (lookupPending || lookupFetching || identifiers.length === 1))
|| tagPending || (tagFetching && tag == null)))
if (missingWiki && !(waiting) && (tag == null || version != null))
return <NotFound/>
@@ -116,7 +116,7 @@ const WikiDetailPage: FC = () => {
return (
<MainArea>
<PageHead title={{
text: `${effectiveTitle}${deprecated
text: `${displayTitle}${deprecated
? message ('labels.deprecatedSuffix').text : ''} Wiki`,
language: deprecated ? message ('labels.deprecatedSuffix').language : locale }}>
{!(wikiPage?.body) && <meta name="robots" content="noindex"/>}
@@ -141,7 +141,7 @@ const WikiDetailPage: FC = () => {
<article className="prose dark:prose-invert mx-auto p-4">
<h1 className="prose-a:no-underline">
<TagLink tag={tag ?? { ...defaultTag,
name: effectiveTitle,
name: displayTitle,
deprecatedAt: wikiPage?.deprecatedAt ?? null }}
withWiki={false}
withCount={false}
@@ -150,7 +150,7 @@ const WikiDetailPage: FC = () => {
</h1>
{waiting
? label ('common:loading')
: <WikiBody title={title} body={wikiPage?.body}
: <WikiBody title={displayTitle} body={wikiPage?.body}
language={wikiPage?.languageCode ?? (wikiPage?.resourceType === 'tag_description'
? localeLanguage (locale) : 'und')}/>}
+3
ファイルの表示
@@ -20,6 +20,7 @@ export type FetchPostsParams = {
url: string
title: string
tags: string
tagReferences?: string
match: 'all' | 'any'
originalCreatedFrom: string
originalCreatedTo: string
@@ -476,6 +477,8 @@ export type User = {
export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBehavior]
export type WikiTagReference = { name: string } | { wikiId: string }
export type WikiPage = {
id: number
resourceType: 'tag_description' | 'tag_group'
+2 -1
ファイルの表示
@@ -36,7 +36,8 @@ export default defineConfig ({
strictPort: true,
allowedHosts: ['hub.nizika.monster', 'localhost', 'nico-dev.test'],
proxy: { '/api': { target: 'http://localhost:3002',
changeOrigin: true,
changeOrigin: false,
rewrite: requestPath => requestPath.replace (/^\/api/, ''),
secure: false } },
watch: { usePolling: true,
interval: 100 } } })