feat: validate intentional post requests
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { buildSearchQuery, normalizeUserTarget } from './inputs'
|
||||||
|
|
||||||
|
describe('normalizeUserTarget', () => {
|
||||||
|
it.each([
|
||||||
|
['@tan_stack', 'tan_stack'],
|
||||||
|
['tan_stack', 'tan_stack'],
|
||||||
|
['https://x.com/tan_stack', 'tan_stack'],
|
||||||
|
['https://twitter.com/tan_stack/', 'tan_stack'],
|
||||||
|
])('normalizes %s', (input, expected) => {
|
||||||
|
expect(normalizeUserTarget(input)).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
'not valid',
|
||||||
|
'https://example.com/tan_stack',
|
||||||
|
'https://x.com/tan_stack/status/1',
|
||||||
|
])('rejects %s', (input) => {
|
||||||
|
expect(() => normalizeUserTarget(input)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildSearchQuery', () => {
|
||||||
|
it('keeps a deliberate query unchanged', () => {
|
||||||
|
expect(buildSearchQuery(' AI lang:ja ', false)).toBe('AI lang:ja')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds the follows operator once', () => {
|
||||||
|
expect(buildSearchQuery('AI lang:ja', true)).toBe(
|
||||||
|
'AI lang:ja filter:follows',
|
||||||
|
)
|
||||||
|
expect(buildSearchQuery('AI filter:follows', true)).toBe(
|
||||||
|
'AI filter:follows',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an empty query', () => {
|
||||||
|
expect(() => buildSearchQuery(' ', false)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
|
||||||
|
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
|
||||||
|
|
||||||
|
export class InputError extends Error {}
|
||||||
|
|
||||||
|
export const userPageInputSchema = z.object({
|
||||||
|
target: z.string().trim().min(1).max(256),
|
||||||
|
cursor: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const searchPageInputSchema = z.object({
|
||||||
|
query: z.string().trim().min(1).max(512),
|
||||||
|
product: z.enum(['Top', 'Latest']),
|
||||||
|
following: z.boolean(),
|
||||||
|
cursor: z.string().min(1).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const userRouteSearchSchema = z.object({
|
||||||
|
target: z.string().catch(''),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const postSearchRouteSchema = z.object({
|
||||||
|
q: z.string().catch(''),
|
||||||
|
product: z.enum(['Top', 'Latest']).catch('Latest'),
|
||||||
|
following: z.boolean().catch(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
function requireHandle(value: string): string {
|
||||||
|
if (!HANDLE.test(value)) {
|
||||||
|
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeUserTarget(raw: string): string {
|
||||||
|
const value = raw.trim()
|
||||||
|
if (!value) {
|
||||||
|
throw new InputError('ハンドルまたはプロフィール URL を入力してください。')
|
||||||
|
}
|
||||||
|
if (value.startsWith('@')) {
|
||||||
|
return requireHandle(value.slice(1))
|
||||||
|
}
|
||||||
|
if (!value.includes('://')) {
|
||||||
|
return requireHandle(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: URL
|
||||||
|
try {
|
||||||
|
url = new URL(value)
|
||||||
|
} catch {
|
||||||
|
throw new InputError('プロフィール URL の形式を確認してください。')
|
||||||
|
}
|
||||||
|
if (!['x.com', 'twitter.com'].includes(url.hostname.toLowerCase())) {
|
||||||
|
throw new InputError('x.com または twitter.com の URL を入力してください。')
|
||||||
|
}
|
||||||
|
const segments = url.pathname.split('/').filter(Boolean)
|
||||||
|
if (segments.length !== 1) {
|
||||||
|
throw new InputError('プロフィール URL を入力してください。')
|
||||||
|
}
|
||||||
|
return requireHandle(segments[0] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSearchQuery(raw: string, following: boolean): string {
|
||||||
|
const query = raw.trim()
|
||||||
|
if (!query) {
|
||||||
|
throw new InputError('検索語を入力してください。')
|
||||||
|
}
|
||||||
|
if (query.length > 512) {
|
||||||
|
throw new InputError('検索語は 512 文字以内で入力してください。')
|
||||||
|
}
|
||||||
|
return following && !FOLLOWS.test(query) ? `${query} filter:follows` : query
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { flattenPostPages } from './page'
|
||||||
|
import type { Post, PostPage } from './types'
|
||||||
|
|
||||||
|
const post = (id: string): Post => ({
|
||||||
|
id,
|
||||||
|
text: `post-${id}`,
|
||||||
|
author: { username: `user-${id}`, name: `User ${id}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('flattenPostPages', () => {
|
||||||
|
it('preserves API order and removes duplicate IDs', () => {
|
||||||
|
const pages: PostPage[] = [
|
||||||
|
{ tweets: [post('1'), post('2')], nextCursor: 'next' },
|
||||||
|
{ tweets: [post('2'), post('3')] },
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(flattenPostPages(pages).map(({ id }) => id)).toEqual(['1', '2', '3'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Post, PostPage } from './types'
|
||||||
|
|
||||||
|
export function flattenPostPages(pages: PostPage[]): Post[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return pages.flatMap(({ tweets }) =>
|
||||||
|
tweets.filter(({ id }) => {
|
||||||
|
if (seen.has(id)) return false
|
||||||
|
seen.add(id)
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { SearchProduct, TweetData } from '@yuta/bird'
|
||||||
|
|
||||||
|
export type Post = TweetData
|
||||||
|
|
||||||
|
export type PostPage = {
|
||||||
|
tweets: Post[]
|
||||||
|
nextCursor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadErrorCode =
|
||||||
|
| 'invalid-input'
|
||||||
|
| 'user-not-found'
|
||||||
|
| 'user-unavailable'
|
||||||
|
| 'relay-config'
|
||||||
|
| 'timeout'
|
||||||
|
| 'upstream'
|
||||||
|
|
||||||
|
export type LoadError = {
|
||||||
|
code: LoadErrorCode
|
||||||
|
message: string
|
||||||
|
retryable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadResult =
|
||||||
|
| { ok: true; page: PostPage }
|
||||||
|
| { ok: false; error: LoadError }
|
||||||
|
|
||||||
|
export type UserPageInput = {
|
||||||
|
target: string
|
||||||
|
cursor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchPageInput = {
|
||||||
|
query: string
|
||||||
|
product: SearchProduct
|
||||||
|
following: boolean
|
||||||
|
cursor?: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user