🔨 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:
Rdmclin2 2025-10-16 11:32:58 +07:00 committed by GitHub
parent 6508e2fcaf
commit 61bbd596f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 191 additions and 9 deletions

View File

@ -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",

View 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 };

View File

@ -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>
);

View File

@ -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];

View File

@ -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',
},
];
/**

View File

@ -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,

View File

@ -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;

View File

@ -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');

View 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;

View File

@ -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"]
}

View File

@ -45,6 +45,7 @@ export default defineConfig({
'**/dist/**',
'**/build/**',
'**/apps/desktop/**',
'**/apps/mobile/**',
'**/packages/**',
'**/e2e/**',
],