mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-01-09 07:32:05 +08:00
🔨 chore: mobile related server implementation pick from mobile app (#9691)
* server: sync from feat/mobile-app (exclude apps/mobile) * Update package.json * chore(mobile): update mobile router imports to use lambda * chore(mobile): refactor mobile router * chore: format tsconfig.json * chore(mobile): simplify mobile router --------- Co-authored-by: Arvin Xu <arvinx@foxmail.com> Co-authored-by: Tsuki <976499226@qq.com>
This commit is contained in:
parent
6508e2fcaf
commit
61bbd596f0
@ -51,6 +51,7 @@
|
||||
"desktop:prepare-dist": "tsx scripts/electronWorkflow/moveNextStandalone.ts",
|
||||
"dev": "next dev --turbopack -p 3010",
|
||||
"dev:desktop": "next dev --turbopack -p 3015",
|
||||
"dev:mobile": "next dev --turbopack -p 3018",
|
||||
"docs:i18n": "lobe-i18n md && npm run lint:md && npm run lint:mdx && prettier -c --write locales/**/*",
|
||||
"docs:seo": "lobe-seo && npm run lint:mdx",
|
||||
"e2e": "playwright test",
|
||||
|
||||
31
src/app/(backend)/trpc/mobile/[trpc]/route.ts
Normal file
31
src/app/(backend)/trpc/mobile/[trpc]/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { pino } from '@/libs/logger';
|
||||
import { createLambdaContext } from '@/libs/trpc/lambda/context';
|
||||
import { mobileRouter } from '@/server/routers/mobile';
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
/**
|
||||
* @link https://trpc.io/docs/v11/context
|
||||
*/
|
||||
createContext: () => createLambdaContext(req),
|
||||
|
||||
endpoint: '/trpc/mobile',
|
||||
|
||||
onError: ({ error, path, type }) => {
|
||||
pino.info(`Error in tRPC handler (mobile) on path: ${path}, type: ${type}`);
|
||||
console.error(error);
|
||||
},
|
||||
|
||||
req,
|
||||
responseMeta({ ctx }) {
|
||||
const headers = ctx?.resHeaders;
|
||||
|
||||
return { headers };
|
||||
},
|
||||
router: mobileRouter,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@ -11,11 +11,20 @@ const useStyles = createStyles(({ css, token }) => ({
|
||||
connector: css`
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
`,
|
||||
connectorLine: css`
|
||||
width: 32px;
|
||||
height: 1px;
|
||||
background-color: ${token.colorBorderSecondary};
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 24px;
|
||||
}
|
||||
`,
|
||||
icon: css`
|
||||
overflow: hidden;
|
||||
@ -29,6 +38,12 @@ const useStyles = createStyles(({ css, token }) => ({
|
||||
border-radius: 16px;
|
||||
|
||||
background-color: ${token.colorBgElevated};
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
`,
|
||||
lobeIcon: css`
|
||||
overflow: hidden;
|
||||
@ -41,6 +56,11 @@ const useStyles = createStyles(({ css, token }) => ({
|
||||
border-radius: 50%;
|
||||
|
||||
background-color: ${token.colorBgElevated};
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
@ -55,13 +75,27 @@ const OAuthApplicationLogo = memo<OAuthApplicationLogoProps>(
|
||||
const { styles, theme } = useStyles();
|
||||
return isFirstParty ? (
|
||||
<Flexbox align={'center'} gap={12} horizontal justify={'center'}>
|
||||
<Image alt={clientDisplayName} height={64} src={logoUrl!} unoptimized width={64} />
|
||||
<Image
|
||||
alt={clientDisplayName}
|
||||
height={64}
|
||||
src={logoUrl!}
|
||||
style={{ height: 'auto', maxWidth: '100%' }}
|
||||
unoptimized
|
||||
width={64}
|
||||
/>
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Flexbox align={'center'} gap={12} horizontal justify={'center'}>
|
||||
<div className={styles.icon}>
|
||||
{logoUrl ? (
|
||||
<Image alt={clientDisplayName} height={56} src={logoUrl} unoptimized width={56} />
|
||||
<Image
|
||||
alt={clientDisplayName}
|
||||
height={56}
|
||||
src={logoUrl}
|
||||
style={{ height: 'auto', maxWidth: '100%' }}
|
||||
unoptimized
|
||||
width={56}
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={ServerIcon} />
|
||||
)}
|
||||
@ -72,7 +106,11 @@ const OAuthApplicationLogo = memo<OAuthApplicationLogoProps>(
|
||||
</Center>
|
||||
<div className={styles.connectorLine} />
|
||||
<div className={styles.lobeIcon}>
|
||||
<ProductLogo height={48} style={{ objectFit: 'cover' }} width={48} />
|
||||
<ProductLogo
|
||||
height={48}
|
||||
style={{ height: 'auto', maxWidth: '100%', objectFit: 'cover' }}
|
||||
width={48}
|
||||
/>
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
@ -46,7 +46,6 @@ const ModelSelect = memo<ModelSelectProps>(({ value, onChange, showAbility = tru
|
||||
provider: provider.id,
|
||||
value: `${provider.id}/${model.id}`,
|
||||
}));
|
||||
|
||||
if (enabledList.length === 1) {
|
||||
const provider = enabledList[0];
|
||||
|
||||
|
||||
@ -35,6 +35,21 @@ export const defaultClients: ClientMetadata[] = [
|
||||
// 标记为公共客户端客户端,无密钥
|
||||
token_endpoint_auth_method: 'none',
|
||||
},
|
||||
{
|
||||
application_type: 'native', // 移动端使用 native 类型
|
||||
client_id: 'lobehub-mobile',
|
||||
client_name: 'LobeHub Mobile',
|
||||
// 支持授权码流程和刷新令牌
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
logo_uri: 'https://hub-apac-1.lobeobjects.space/docs/73f69adfa1b802a0e250f6ff9d62f70b.png',
|
||||
// 移动端不需要 post_logout_redirect_uris,因为注销通常在应用内处理
|
||||
post_logout_redirect_uris: [],
|
||||
// 移动端使用自定义 URL Scheme
|
||||
redirect_uris: ['com.lobehub.app://auth/callback'],
|
||||
response_types: ['code'],
|
||||
// 公共客户端,无密钥
|
||||
token_endpoint_auth_method: 'none',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -106,7 +106,8 @@ export const validateOIDCJWT = async (token: string) => {
|
||||
|
||||
// 提取用户信息
|
||||
const userId = payload.sub;
|
||||
const clientId = payload.aud;
|
||||
const clientId = payload.client_id;
|
||||
const aud = payload.aud;
|
||||
|
||||
if (!userId) {
|
||||
throw new TRPCError({
|
||||
@ -119,7 +120,7 @@ export const validateOIDCJWT = async (token: string) => {
|
||||
clientId,
|
||||
payload,
|
||||
tokenData: {
|
||||
aud: clientId,
|
||||
aud: aud,
|
||||
client_id: clientId,
|
||||
exp: payload.exp,
|
||||
iat: payload.iat,
|
||||
|
||||
@ -7,6 +7,7 @@ import { serverDBEnv } from '@/config/db';
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import { appEnv } from '@/envs/app';
|
||||
import { getJWKS } from '@/libs/oidc-provider/jwt';
|
||||
import { normalizeLocale } from '@/locales/resources';
|
||||
|
||||
import { DrizzleAdapter } from './adapter';
|
||||
import { defaultClaims, defaultClients, defaultScopes } from './config';
|
||||
@ -76,6 +77,9 @@ export const createOIDCProvider = async (db: LobeChatDatabase): Promise<Provider
|
||||
// 1. 客户端配置
|
||||
clients: defaultClients,
|
||||
|
||||
// 新增:确保 ID Token 包含所有 scope 对应的 claims,而不仅仅是 openid scope
|
||||
conformIdTokenClaims: false,
|
||||
|
||||
// 7. Cookie 配置
|
||||
cookies: {
|
||||
keys: cookieKeys,
|
||||
@ -93,6 +97,7 @@ export const createOIDCProvider = async (db: LobeChatDatabase): Promise<Provider
|
||||
resourceIndicators: {
|
||||
defaultResource: () => API_AUDIENCE,
|
||||
enabled: true,
|
||||
|
||||
getResourceServerInfo: (ctx, resourceIndicator) => {
|
||||
logProvider('getResourceServerInfo called with indicator: %s', resourceIndicator); // <-- 添加这行日志
|
||||
if (resourceIndicator === API_AUDIENCE) {
|
||||
@ -107,6 +112,8 @@ export const createOIDCProvider = async (db: LobeChatDatabase): Promise<Provider
|
||||
logProvider('Indicator does not match API_AUDIENCE, throwing InvalidTarget.'); // <-- 添加这行日志
|
||||
throw new errors.InvalidTarget();
|
||||
},
|
||||
// 当客户端使用刷新令牌请求新的访问令牌但没有指定资源时,授权服务器会检查原始授权中包含的所有资源,并将这些资源用于新的访问令牌。这提供了一种便捷的方式来维持授权一致性,而不需要客户端在每次刷新时重新指定所有资源
|
||||
useGrantedResource: () => true,
|
||||
},
|
||||
revocation: { enabled: true },
|
||||
rpInitiatedLogout: { enabled: true },
|
||||
@ -195,7 +202,25 @@ export const createOIDCProvider = async (db: LobeChatDatabase): Promise<Provider
|
||||
// ---> 添加日志 <---
|
||||
logProvider('interactions.url function called');
|
||||
logProvider('Interaction details: %O', interaction);
|
||||
const interactionUrl = `/oauth/consent/${interaction.uid}`;
|
||||
|
||||
// 读取 OIDC 请求中的 ui_locales 参数(空格分隔的语言优先级)
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
|
||||
const uiLocalesRaw = (interaction.params?.ui_locales || ctx.oidc?.params?.ui_locales) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
let query = '';
|
||||
if (uiLocalesRaw) {
|
||||
// 取第一个优先语言,规范化到站点支持的标签
|
||||
const first = uiLocalesRaw.split(/[\s,]+/).find(Boolean);
|
||||
const hl = normalizeLocale(first);
|
||||
query = `?hl=${encodeURIComponent(hl)}`;
|
||||
logProvider('Detected ui_locales=%s -> using hl=%s', uiLocalesRaw, hl);
|
||||
} else {
|
||||
logProvider('No ui_locales provided in authorization request');
|
||||
}
|
||||
|
||||
const interactionUrl = `/oauth/consent/${interaction.uid}${query}`;
|
||||
logProvider('Generated interaction URL: %s', interactionUrl);
|
||||
// ---> 添加日志结束 <---
|
||||
return interactionUrl;
|
||||
|
||||
@ -143,7 +143,32 @@ const defaultMiddleware = (request: NextRequest) => {
|
||||
|
||||
url.pathname = nextPathname;
|
||||
|
||||
return NextResponse.rewrite(url, { status: 200 });
|
||||
// build rewrite response first
|
||||
const rewrite = NextResponse.rewrite(url, { status: 200 });
|
||||
|
||||
// If locale explicitly provided via query (?hl=), persist it in cookie when user has no prior preference
|
||||
if (explicitlyLocale) {
|
||||
const existingLocale = request.cookies.get(LOBE_LOCALE_COOKIE)?.value as Locales | undefined;
|
||||
if (!existingLocale) {
|
||||
rewrite.cookies.set(LOBE_LOCALE_COOKIE, explicitlyLocale, {
|
||||
// 90 days is a balanced persistence for locale preference
|
||||
maxAge: 60 * 60 * 24 * 90,
|
||||
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
logDefault('Persisted explicit locale to cookie (no prior cookie): %s', explicitlyLocale);
|
||||
} else {
|
||||
logDefault(
|
||||
'Locale cookie exists (%s), skip overwrite with %s',
|
||||
existingLocale,
|
||||
explicitlyLocale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return rewrite;
|
||||
};
|
||||
|
||||
const isPublicRoute = createRouteMatcher([
|
||||
@ -158,6 +183,8 @@ const isPublicRoute = createRouteMatcher([
|
||||
'/login',
|
||||
'/signup',
|
||||
// oauth
|
||||
// Make only the consent view public (GET page), not other oauth paths
|
||||
'/oauth/consent/(.*)',
|
||||
'/oidc/handoff',
|
||||
'/oidc/token',
|
||||
]);
|
||||
@ -212,6 +239,11 @@ const nextAuthMiddleware = NextAuth.auth((req) => {
|
||||
logNextAuth('Request a protected route, redirecting to sign-in page');
|
||||
const nextLoginUrl = new URL('/next-auth/signin', req.nextUrl.origin);
|
||||
nextLoginUrl.searchParams.set('callbackUrl', req.nextUrl.href);
|
||||
const hl = req.nextUrl.searchParams.get('hl');
|
||||
if (hl) {
|
||||
nextLoginUrl.searchParams.set('hl', hl);
|
||||
logNextAuth('Preserving locale to sign-in: hl=%s', hl);
|
||||
}
|
||||
return Response.redirect(nextLoginUrl);
|
||||
}
|
||||
logNextAuth('Request a free route but not login, allow visit without auth header');
|
||||
|
||||
30
src/server/routers/mobile/index.ts
Normal file
30
src/server/routers/mobile/index.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* This file contains the root router of Lobe Chat tRPC-backend for Mobile App
|
||||
* Only includes routers that are actually used by the mobile client
|
||||
*/
|
||||
import { publicProcedure, router } from '@/libs/trpc/lambda';
|
||||
|
||||
import { agentRouter } from '../lambda/agent';
|
||||
import { aiChatRouter } from '../lambda/aiChat';
|
||||
import { aiModelRouter } from '../lambda/aiModel';
|
||||
import { aiProviderRouter } from '../lambda/aiProvider';
|
||||
import { marketRouter } from '../lambda/market';
|
||||
import { messageRouter } from '../lambda/message';
|
||||
import { sessionRouter } from '../lambda/session';
|
||||
import { sessionGroupRouter } from '../lambda/sessionGroup';
|
||||
import { topicRouter } from '../lambda/topic';
|
||||
|
||||
export const mobileRouter = router({
|
||||
agent: agentRouter,
|
||||
aiChat: aiChatRouter,
|
||||
aiModel: aiModelRouter,
|
||||
aiProvider: aiProviderRouter,
|
||||
healthcheck: publicProcedure.query(() => "i'm live!"),
|
||||
market: marketRouter,
|
||||
message: messageRouter,
|
||||
session: sessionRouter,
|
||||
sessionGroup: sessionGroupRouter,
|
||||
topic: topicRouter,
|
||||
});
|
||||
|
||||
export type MobileRouter = typeof mobileRouter;
|
||||
@ -31,6 +31,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"exclude": ["node_modules", "public/sw.js", "apps/desktop", "tmp", "temp", ".temp", "e2e"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"public/sw.js",
|
||||
"apps/desktop",
|
||||
"apps/mobile",
|
||||
"tmp",
|
||||
"temp",
|
||||
".temp",
|
||||
"e2e"
|
||||
],
|
||||
"include": ["**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next-env.d.ts"]
|
||||
}
|
||||
|
||||
@ -45,6 +45,7 @@ export default defineConfig({
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/apps/desktop/**',
|
||||
'**/apps/mobile/**',
|
||||
'**/packages/**',
|
||||
'**/e2e/**',
|
||||
],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user