Improve AskAI fallback and secure admin APIs (#522)

This commit is contained in:
shenlan 2025-10-14 20:53:59 +08:00 committed by GitHub
parent 2b4760adfa
commit 2b560384b5
7 changed files with 539 additions and 17 deletions

View File

@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceBaseUrl } from '@lib/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = `${getAccountServiceBaseUrl()}/api/auth`
const READ_ROLES: AccountUserRole[] = ['admin', 'operator']
const WRITE_ROLES: AccountUserRole[] = ['admin']
type ErrorPayload = {
error: string
}
async function proxyAccountRequest(request: NextRequest, endpoint: string, method: string, token: string) {
const headers = new Headers({
Authorization: `Bearer ${token}`,
Accept: 'application/json',
})
let body: string | undefined
if (method !== 'GET' && method !== 'HEAD') {
body = await request.text()
const contentType = request.headers.get('content-type') ?? 'application/json'
headers.set('Content-Type', contentType)
}
const response = await fetch(endpoint, {
method,
headers,
body,
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!userHasRole(user, READ_ROLES)) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
return proxyAccountRequest(request, `${ACCOUNT_API_BASE}/admin/settings`, 'GET', session.token)
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!userHasRole(user, WRITE_ROLES)) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
return proxyAccountRequest(request, `${ACCOUNT_API_BASE}/admin/settings`, 'POST', session.token)
}

View File

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceBaseUrl } from '@lib/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = `${getAccountServiceBaseUrl()}/api/auth`
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
type ErrorPayload = {
error: string
}
type RouteParams = {
params: {
userId: string
}
}
function resolveUserId(param?: string): string | null {
if (!param) {
return null
}
const trimmed = param.trim()
return trimmed.length > 0 ? trimmed : null
}
export async function POST(request: NextRequest, { params }: RouteParams) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!userHasRole(user, REQUIRED_ROLES)) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
const userId = resolveUserId(params?.userId)
if (!userId) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_user' }, { status: 400 })
}
const body = await request.text()
const headers = new Headers({
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
})
const contentType = request.headers.get('content-type') ?? 'application/json'
headers.set('Content-Type', contentType)
const response = await fetch(`${ACCOUNT_API_BASE}/admin/users/${encodeURIComponent(userId)}/role`, {
method: 'POST',
headers,
body,
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}

View File

@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceBaseUrl } from '@lib/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = `${getAccountServiceBaseUrl()}/api/auth`
const ALLOWED_ROLES: AccountUserRole[] = ['admin', 'operator']
type MetricsErrorPayload = {
error: string
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<MetricsErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!userHasRole(user, ALLOWED_ROLES)) {
return NextResponse.json<MetricsErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
const response = await fetch(`${ACCOUNT_API_BASE}/admin/users/metrics`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
},
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<MetricsErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}

View File

@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { getInternalServerServiceBaseUrl } from '@lib/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const SERVER_API_BASE = getInternalServerServiceBaseUrl()
const SERVER_USERS_ENDPOINT = `${SERVER_API_BASE}/api/users`
const ALLOWED_ROLES: AccountUserRole[] = ['admin', 'operator']
type ErrorPayload = {
error: string
}
type PermissionAwareHeaders = {
'X-User-Role': string
'X-User-Permissions'?: string
}
function buildForwardHeaders(role: string, permissions: string[]): PermissionAwareHeaders {
const headers: PermissionAwareHeaders = {
'X-User-Role': role,
}
if (permissions.length > 0) {
headers['X-User-Permissions'] = permissions.join(',')
}
return headers
}
export async function GET() {
const session = await getAccountSession()
const user = session.user
if (!user) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!userHasRole(user, ALLOWED_ROLES)) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
const headers = new Headers({
Accept: 'application/json',
...buildForwardHeaders(user.role, user.permissions),
})
const response = await fetch(SERVER_USERS_ENDPOINT, {
method: 'GET',
headers,
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}

View File

@ -168,39 +168,68 @@ export function AskAIDialog({
}
try {
let { answer, retrieved } = await streamChat(
'/api/rag/query',
{ question: normalized, history },
updateAI
)
let ragAnswer = ''
let ragRetrieved: any[] = []
let ragError: any = null
if (!answer || retrieved.length === 0) {
console.warn(
!answer
? 'RAG query returned empty answer, falling back to /api/askai'
: 'RAG query returned no relevant chunks, falling back to /api/askai'
try {
const result = await streamChat(
'/api/rag/query',
{ question: normalized, history },
updateAI
)
ragAnswer = result?.answer ?? ''
ragRetrieved = Array.isArray(result?.retrieved) ? result.retrieved : []
} catch (err: any) {
if (err?.name === 'AbortError') {
throw err
}
ragError = err
}
if (ragError) {
console.warn('RAG query failed, falling back to /api/askai', ragError)
}
let answer = ragAnswer
let retrieved = ragRetrieved
if (!answer || retrieved.length === 0 || ragError) {
if (!ragError && !answer) {
console.warn('RAG query returned empty answer, falling back to /api/askai')
} else if (!ragError && retrieved.length === 0) {
console.warn('RAG query returned no relevant chunks, falling back to /api/askai')
}
try {
const result = await streamChat(
'/api/askai',
{ question: normalized, history },
updateAI
)
if (result.answer) {
if (result?.answer) {
answer = result.answer
}
if (result.retrieved && result.retrieved.length > 0) {
if (Array.isArray(result?.retrieved) && result.retrieved.length > 0) {
retrieved = result.retrieved
}
} catch (err) {
console.error('Fallback /api/askai failed', err)
// ignore, fallback handled below
} catch (fallbackError: any) {
if (fallbackError?.name === 'AbortError') {
throw fallbackError
}
console.error('Fallback /api/askai failed', fallbackError)
if (!answer) {
updateAI('Sorry, I could not find an answer at this time.')
return
}
}
if (!answer) {
answer = 'Sorry, I could not find an answer at this time.'
updateAI(answer)
} else if (retrieved.length === 0) {
return
}
if (retrieved.length === 0) {
answer +=
'\n\n_Note: No relevant documents were found; this answer may be inaccurate._'
updateAI(answer)

View File

@ -0,0 +1,247 @@
'use server'
import { cookies } from 'next/headers'
import type { NextRequest } from 'next/server'
import { SESSION_COOKIE_NAME } from '@lib/authGateway'
import { getAccountServiceBaseUrl } from '@lib/serviceConfig'
const ACCOUNT_SERVICE_BASE = getAccountServiceBaseUrl()
const ACCOUNT_API_BASE = `${ACCOUNT_SERVICE_BASE}/api/auth`
export type AccountUserRole = 'guest' | 'user' | 'operator' | 'admin'
export type AccountTenantMembership = {
id: string
name?: string
role?: AccountUserRole
}
export type AccountSessionUser = {
id: string
uuid: string
email: string
name?: string
username?: string
role: AccountUserRole
groups: string[]
permissions: string[]
tenantId?: string
tenants?: AccountTenantMembership[]
}
export type AccountSessionResult = {
token?: string
user: AccountSessionUser | null
}
type RawAccountTenant = {
id?: unknown
name?: unknown
role?: unknown
}
type RawAccountUser = {
id?: unknown
uuid?: unknown
email?: unknown
name?: unknown
username?: unknown
role?: unknown
groups?: unknown
permissions?: unknown
tenantId?: unknown
tenants?: unknown
}
type AccountSessionResponse = {
user?: RawAccountUser | null
}
const KNOWN_ROLE_MAP: Record<string, AccountUserRole> = {
admin: 'admin',
administrator: 'admin',
operator: 'operator',
ops: 'operator',
user: 'user',
member: 'user',
}
function normalizeRole(value: unknown): AccountUserRole {
if (typeof value !== 'string') {
return 'guest'
}
const normalized = value.trim().toLowerCase()
if (!normalized) {
return 'guest'
}
return KNOWN_ROLE_MAP[normalized] ?? 'guest'
}
function normalizeString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
const result: string[] = []
for (const entry of value) {
const normalized = normalizeString(entry)
if (normalized) {
result.push(normalized)
}
}
return result
}
function normalizeTenants(value: unknown): AccountTenantMembership[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const normalized: AccountTenantMembership[] = []
for (const tenant of value) {
if (!tenant || typeof tenant !== 'object') {
continue
}
const raw = tenant as RawAccountTenant
const identifier = normalizeString(raw.id)
if (!identifier) {
continue
}
const entry: AccountTenantMembership = { id: identifier }
const name = normalizeString(raw.name)
if (name) {
entry.name = name
}
const role = normalizeRole(raw.role)
if (role !== 'guest') {
entry.role = role
}
normalized.push(entry)
}
return normalized.length > 0 ? normalized : undefined
}
function buildUser(raw: RawAccountUser | null | undefined): AccountSessionUser | null {
if (!raw || typeof raw !== 'object') {
return null
}
const identifier = normalizeString(raw.uuid) ?? normalizeString(raw.id)
const email = normalizeString(raw.email)
if (!identifier || !email) {
return null
}
const name = normalizeString(raw.name)
const username = normalizeString(raw.username) ?? name
const role = normalizeRole(raw.role)
const groups = normalizeStringList(raw.groups)
const permissions = normalizeStringList(raw.permissions)
const tenantId = normalizeString(raw.tenantId)
const tenants = normalizeTenants(raw.tenants)
return {
id: identifier,
uuid: identifier,
email,
name: name ?? undefined,
username: username ?? undefined,
role,
groups,
permissions,
tenantId: tenantId ?? undefined,
tenants,
}
}
function extractBearer(value: string | null): string | undefined {
if (!value) {
return undefined
}
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
const prefix = 'Bearer '
if (trimmed.startsWith(prefix)) {
return trimmed.slice(prefix.length).trim() || undefined
}
return trimmed
}
function resolveTokenFromRequest(request?: NextRequest): string | undefined {
if (request) {
const authHeader = request.headers.get('authorization')
const authToken = extractBearer(authHeader)
if (authToken) {
return authToken
}
const sessionHeader = request.headers.get('x-account-session')
if (sessionHeader && sessionHeader.trim().length > 0) {
return sessionHeader.trim()
}
const cookieToken = request.cookies.get(SESSION_COOKIE_NAME)?.value
if (cookieToken && cookieToken.trim().length > 0) {
return cookieToken.trim()
}
}
try {
const cookieStore = cookies()
const cookieToken = cookieStore.get(SESSION_COOKIE_NAME)?.value
if (cookieToken && cookieToken.trim().length > 0) {
return cookieToken.trim()
}
} catch (error) {
// Accessing cookies() outside a request context throws; ignore and fall through.
console.warn('Failed to read session cookie from request context', error)
}
return undefined
}
export function userHasRole(user: AccountSessionUser | null, roles: AccountUserRole[]): boolean {
if (!user || roles.length === 0) {
return false
}
return roles.includes(user.role)
}
export async function getAccountSession(request?: NextRequest): Promise<AccountSessionResult> {
const token = resolveTokenFromRequest(request)
if (!token) {
return { token: undefined, user: null }
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/session`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
cache: 'no-store',
})
if (!response.ok) {
return { token, user: null }
}
const payload = (await response.json().catch(() => null)) as AccountSessionResponse | null
if (!payload?.user) {
return { token, user: null }
}
const user = buildUser(payload.user)
return { token, user }
} catch (error) {
console.error('Failed to resolve account session', error)
return { token, user: null }
}
}

View File

@ -20,7 +20,8 @@
"@components/*": ["components/*"],
"@i18n/*": ["i18n/*"],
"@lib/*": ["lib/*"],
"@types/*": ["types/*"]
"@types/*": ["types/*"],
"@server/*": ["server/*"]
},
"types": ["node"],
"plugins": [{ "name": "next" }]
@ -30,6 +31,7 @@
"components",
"i18n",
"lib",
"server",
"types",
".next/types/**/*.ts"
],