mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-03-11 18:27:57 +08:00
Feat: optimize gmail/google-drive (#13230)
### What problem does this PR solve? Feat: optimize gmail/google-drive Now: <img width="700" alt="image" src="https://github.com/user-attachments/assets/0c4b6044-7209-4c4f-ac0c-32070b79daf7" /> <img width="700" alt="image" src="https://github.com/user-attachments/assets/406f93d8-9b0f-4f5a-b8bb-3936990f558c" /> ### Type of change - [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
@ -193,20 +193,25 @@ async def start_google_web_oauth():
|
||||
if source not in ("google-drive", "gmail"):
|
||||
return get_json_result(code=RetCode.ARGUMENT_ERROR, message="Invalid Google OAuth type.")
|
||||
|
||||
req = await get_request_json()
|
||||
|
||||
if source == "gmail":
|
||||
redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
|
||||
default_redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
|
||||
scopes = GOOGLE_SCOPES[DocumentSource.GMAIL]
|
||||
else:
|
||||
redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
|
||||
default_redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
|
||||
scopes = GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE]
|
||||
|
||||
redirect_uri = req.get("redirect_uri", default_redirect_uri)
|
||||
if isinstance(redirect_uri, str):
|
||||
redirect_uri = redirect_uri.strip()
|
||||
|
||||
if not redirect_uri:
|
||||
return get_json_result(
|
||||
code=RetCode.SERVER_ERROR,
|
||||
message="Google OAuth redirect URI is not configured on the server.",
|
||||
)
|
||||
|
||||
req = await get_request_json()
|
||||
raw_credentials = req.get("credentials", "")
|
||||
|
||||
try:
|
||||
@ -246,6 +251,7 @@ async def start_google_web_oauth():
|
||||
cache_payload = {
|
||||
"user_id": current_user.id,
|
||||
"client_config": client_config,
|
||||
"redirect_uri": redirect_uri,
|
||||
"created_at": int(time.time()),
|
||||
}
|
||||
REDIS_CONN.set_obj(_web_state_cache_key(flow_id, source), cache_payload, WEB_FLOW_TTL_SECS)
|
||||
@ -276,6 +282,7 @@ async def google_gmail_web_oauth_callback():
|
||||
|
||||
state_obj = json.loads(state_cache)
|
||||
client_config = state_obj.get("client_config")
|
||||
redirect_uri = state_obj.get("redirect_uri", GMAIL_WEB_OAUTH_REDIRECT_URI)
|
||||
if not client_config:
|
||||
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
|
||||
return await _render_web_oauth_popup(state_id, False, "Authorization session was invalid. Please retry.", source)
|
||||
@ -291,7 +298,7 @@ async def google_gmail_web_oauth_callback():
|
||||
try:
|
||||
# TODO(google-oauth): branch scopes/redirect_uri based on source_type (drive vs gmail)
|
||||
flow = Flow.from_client_config(client_config, scopes=GOOGLE_SCOPES[DocumentSource.GMAIL])
|
||||
flow.redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
|
||||
flow.redirect_uri = redirect_uri
|
||||
flow.fetch_token(code=code)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logging.exception("Failed to exchange Google OAuth code: %s", exc)
|
||||
@ -326,6 +333,7 @@ async def google_drive_web_oauth_callback():
|
||||
|
||||
state_obj = json.loads(state_cache)
|
||||
client_config = state_obj.get("client_config")
|
||||
redirect_uri = state_obj.get("redirect_uri", GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI)
|
||||
if not client_config:
|
||||
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
|
||||
return await _render_web_oauth_popup(state_id, False, "Authorization session was invalid. Please retry.", source)
|
||||
@ -341,7 +349,7 @@ async def google_drive_web_oauth_callback():
|
||||
try:
|
||||
# TODO(google-oauth): branch scopes/redirect_uri based on source_type (drive vs gmail)
|
||||
flow = Flow.from_client_config(client_config, scopes=GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE])
|
||||
flow.redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
|
||||
flow.redirect_uri = redirect_uri
|
||||
flow.fetch_token(code=code)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logging.exception("Failed to exchange Google OAuth code: %s", exc)
|
||||
@ -480,4 +488,4 @@ async def poll_box_web_result():
|
||||
|
||||
REDIS_CONN.delete(_web_result_cache_key(flow_id, "box"))
|
||||
|
||||
return get_json_result(data={"credentials": cache_raw})
|
||||
return get_json_result(data={"credentials": cache_raw})
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import message from '@/components/ui/message';
|
||||
import { FileMimeType } from '@/constants/common';
|
||||
import {
|
||||
@ -49,6 +50,51 @@ const describeCredentials = (content?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseJsonObject = (content?: string): Record<string, any> | null => {
|
||||
if (!content) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const extractRedirectUri = (content?: string): string => {
|
||||
const parsed = parseJsonObject(content);
|
||||
if (!parsed) return '';
|
||||
|
||||
if (typeof parsed.redirect_uri === 'string' && parsed.redirect_uri.trim()) {
|
||||
return parsed.redirect_uri.trim();
|
||||
}
|
||||
|
||||
const redirectUris =
|
||||
parsed.web?.redirect_uris ?? parsed.installed?.redirect_uris;
|
||||
if (Array.isArray(redirectUris)) {
|
||||
const firstValidRedirect = redirectUris.find(
|
||||
(item) => typeof item === 'string' && item.trim(),
|
||||
);
|
||||
if (firstValidRedirect) {
|
||||
return firstValidRedirect.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const withRedirectUri = (credentials: string, redirectUri: string): string => {
|
||||
const trimmedRedirectUri = redirectUri.trim();
|
||||
if (!trimmedRedirectUri) return credentials;
|
||||
|
||||
const parsed = parseJsonObject(credentials);
|
||||
if (!parsed) return credentials;
|
||||
|
||||
return JSON.stringify({
|
||||
...parsed,
|
||||
redirect_uri: trimmedRedirectUri,
|
||||
});
|
||||
};
|
||||
|
||||
const GmailTokenField = ({
|
||||
value,
|
||||
onChange,
|
||||
@ -56,6 +102,7 @@ const GmailTokenField = ({
|
||||
}: GmailTokenFieldProps) => {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [pendingCredentials, setPendingCredentials] = useState<string>('');
|
||||
const [redirectUri, setRedirectUri] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [webAuthLoading, setWebAuthLoading] = useState(false);
|
||||
const [webFlowId, setWebFlowId] = useState<string | null>(null);
|
||||
@ -89,6 +136,12 @@ const GmailTokenField = ({
|
||||
webFlowIdRef.current = webFlowId;
|
||||
}, [webFlowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setRedirectUri(extractRedirectUri(value));
|
||||
}
|
||||
}, [dialogOpen, value]);
|
||||
|
||||
const credentialSummary = useMemo(() => describeCredentials(value), [value]);
|
||||
const hasVerifiedTokens = useMemo(
|
||||
() => Boolean(value && credentialHasRefreshToken(value)),
|
||||
@ -118,7 +171,11 @@ const GmailTokenField = ({
|
||||
flow_id: flowId,
|
||||
});
|
||||
if (data.code === 0 && data.data?.credentials) {
|
||||
onChange(data.data.credentials);
|
||||
const rawCredentials =
|
||||
typeof data.data.credentials === 'string'
|
||||
? data.data.credentials
|
||||
: JSON.stringify(data.data.credentials);
|
||||
onChange(withRedirectUri(rawCredentials, redirectUri));
|
||||
setPendingCredentials('');
|
||||
message.success('Gmail credentials verified.');
|
||||
resetDialog(false);
|
||||
@ -143,7 +200,7 @@ const GmailTokenField = ({
|
||||
clearWebState();
|
||||
}
|
||||
},
|
||||
[clearWebState, onChange, resetDialog],
|
||||
[clearWebState, onChange, redirectUri, resetDialog],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -198,8 +255,10 @@ const GmailTokenField = ({
|
||||
}
|
||||
setFiles([file]);
|
||||
clearWebState();
|
||||
const extractedRedirectUri = extractRedirectUri(text);
|
||||
setRedirectUri(extractedRedirectUri);
|
||||
if (credentialHasRefreshToken(text)) {
|
||||
onChange(text);
|
||||
onChange(withRedirectUri(text, extractedRedirectUri));
|
||||
setPendingCredentials('');
|
||||
message.success('Gmail OAuth credentials uploaded.');
|
||||
return;
|
||||
@ -223,11 +282,17 @@ const GmailTokenField = ({
|
||||
message.error('No Google credential file detected.');
|
||||
return;
|
||||
}
|
||||
if (!redirectUri.trim()) {
|
||||
message.error('Please fill in Redirect URI.');
|
||||
return;
|
||||
}
|
||||
const trimmedRedirectUri = redirectUri.trim();
|
||||
setWebAuthLoading(true);
|
||||
clearWebState();
|
||||
try {
|
||||
const { data } = await startGmailWebAuth({
|
||||
credentials: pendingCredentials,
|
||||
redirect_uri: trimmedRedirectUri,
|
||||
});
|
||||
if (data.code === 0 && data.data?.authorization_url) {
|
||||
const flowId = data.data.flow_id;
|
||||
@ -255,7 +320,7 @@ const GmailTokenField = ({
|
||||
} finally {
|
||||
setWebAuthLoading(false);
|
||||
}
|
||||
}, [clearWebState, pendingCredentials]);
|
||||
}, [clearWebState, pendingCredentials, redirectUri]);
|
||||
|
||||
const handleManualWebCheck = useCallback(() => {
|
||||
if (!webFlowId) {
|
||||
@ -334,6 +399,14 @@ const GmailTokenField = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Redirect URI</label>
|
||||
<Input
|
||||
value={redirectUri}
|
||||
placeholder="https://example.com/gmail/oauth/callback"
|
||||
onChange={(e) => setRedirectUri(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/10 px-4 py-4 text-sm text-muted-foreground">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
Authorize in browser
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import message from '@/components/ui/message';
|
||||
import { FileMimeType } from '@/constants/common';
|
||||
import {
|
||||
@ -47,12 +48,58 @@ const describeCredentials = (content?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseJsonObject = (content?: string): Record<string, any> | null => {
|
||||
if (!content) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
return typeof parsed === 'object' && parsed !== null ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const extractRedirectUri = (content?: string): string => {
|
||||
const parsed = parseJsonObject(content);
|
||||
if (!parsed) return '';
|
||||
|
||||
if (typeof parsed.redirect_uri === 'string' && parsed.redirect_uri.trim()) {
|
||||
return parsed.redirect_uri.trim();
|
||||
}
|
||||
|
||||
const redirectUris =
|
||||
parsed.web?.redirect_uris ?? parsed.installed?.redirect_uris;
|
||||
if (Array.isArray(redirectUris)) {
|
||||
const firstValidRedirect = redirectUris.find(
|
||||
(item) => typeof item === 'string' && item.trim(),
|
||||
);
|
||||
if (firstValidRedirect) {
|
||||
return firstValidRedirect.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const withRedirectUri = (credentials: string, redirectUri: string): string => {
|
||||
const trimmedRedirectUri = redirectUri.trim();
|
||||
if (!trimmedRedirectUri) return credentials;
|
||||
|
||||
const parsed = parseJsonObject(credentials);
|
||||
if (!parsed) return credentials;
|
||||
|
||||
return JSON.stringify({
|
||||
...parsed,
|
||||
redirect_uri: trimmedRedirectUri,
|
||||
});
|
||||
};
|
||||
|
||||
const GoogleDriveTokenField = ({
|
||||
value,
|
||||
onChange,
|
||||
}: GoogleDriveTokenFieldProps) => {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [pendingCredentials, setPendingCredentials] = useState<string>('');
|
||||
const [redirectUri, setRedirectUri] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [webAuthLoading, setWebAuthLoading] = useState(false);
|
||||
const [webFlowId, setWebFlowId] = useState<string | null>(null);
|
||||
@ -86,6 +133,12 @@ const GoogleDriveTokenField = ({
|
||||
webFlowIdRef.current = webFlowId;
|
||||
}, [webFlowId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) {
|
||||
setRedirectUri(extractRedirectUri(value));
|
||||
}
|
||||
}, [dialogOpen, value]);
|
||||
|
||||
const credentialSummary = useMemo(() => describeCredentials(value), [value]);
|
||||
const hasVerifiedTokens = useMemo(
|
||||
() => Boolean(value && credentialHasRefreshToken(value)),
|
||||
@ -115,7 +168,11 @@ const GoogleDriveTokenField = ({
|
||||
flow_id: flowId,
|
||||
});
|
||||
if (data.code === 0 && data.data?.credentials) {
|
||||
onChange(data.data.credentials);
|
||||
const rawCredentials =
|
||||
typeof data.data.credentials === 'string'
|
||||
? data.data.credentials
|
||||
: JSON.stringify(data.data.credentials);
|
||||
onChange(withRedirectUri(rawCredentials, redirectUri));
|
||||
setPendingCredentials('');
|
||||
message.success('Google Drive credentials verified.');
|
||||
resetDialog(false);
|
||||
@ -140,7 +197,7 @@ const GoogleDriveTokenField = ({
|
||||
clearWebState();
|
||||
}
|
||||
},
|
||||
[clearWebState, onChange, resetDialog],
|
||||
[clearWebState, onChange, redirectUri, resetDialog],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -195,8 +252,10 @@ const GoogleDriveTokenField = ({
|
||||
}
|
||||
setFiles([file]);
|
||||
clearWebState();
|
||||
const extractedRedirectUri = extractRedirectUri(text);
|
||||
setRedirectUri(extractedRedirectUri);
|
||||
if (credentialHasRefreshToken(text)) {
|
||||
onChange(text);
|
||||
onChange(withRedirectUri(text, extractedRedirectUri));
|
||||
setPendingCredentials('');
|
||||
message.success('OAuth credentials uploaded.');
|
||||
return;
|
||||
@ -220,11 +279,17 @@ const GoogleDriveTokenField = ({
|
||||
message.error('No Google credential file detected.');
|
||||
return;
|
||||
}
|
||||
if (!redirectUri.trim()) {
|
||||
message.error('Please fill in Redirect URI.');
|
||||
return;
|
||||
}
|
||||
const trimmedRedirectUri = redirectUri.trim();
|
||||
setWebAuthLoading(true);
|
||||
clearWebState();
|
||||
try {
|
||||
const { data } = await startGoogleDriveWebAuth({
|
||||
credentials: pendingCredentials,
|
||||
redirect_uri: trimmedRedirectUri,
|
||||
});
|
||||
if (data.code === 0 && data.data?.authorization_url) {
|
||||
const flowId = data.data.flow_id;
|
||||
@ -252,7 +317,7 @@ const GoogleDriveTokenField = ({
|
||||
} finally {
|
||||
setWebAuthLoading(false);
|
||||
}
|
||||
}, [clearWebState, pendingCredentials]);
|
||||
}, [clearWebState, pendingCredentials, redirectUri]);
|
||||
|
||||
const handleManualWebCheck = useCallback(() => {
|
||||
if (!webFlowId) {
|
||||
@ -330,6 +395,14 @@ const GoogleDriveTokenField = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Redirect URI</label>
|
||||
<Input
|
||||
value={redirectUri}
|
||||
placeholder="https://example.com/google-drive/oauth/callback"
|
||||
onChange={(e) => setRedirectUri(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/10 px-4 py-4 text-sm text-muted-foreground">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
Authorize in browser
|
||||
|
||||
@ -33,16 +33,20 @@ export const getDataSourceLogs = (id: string, params?: any) =>
|
||||
export const featchDataSourceDetail = (id: string) =>
|
||||
request.get(api.dataSourceDetail(id));
|
||||
|
||||
export const startGoogleDriveWebAuth = (payload: { credentials: string }) =>
|
||||
request.post(api.googleWebAuthStart('google-drive'), { data: payload });
|
||||
export const startGoogleDriveWebAuth = (payload: {
|
||||
credentials: string;
|
||||
redirect_uri?: string;
|
||||
}) => request.post(api.googleWebAuthStart('google-drive'), { data: payload });
|
||||
|
||||
export const pollGoogleDriveWebAuthResult = (payload: { flow_id: string }) =>
|
||||
request.post(api.googleWebAuthResult('google-drive'), { data: payload });
|
||||
|
||||
// Gmail web auth follows the same pattern as Google Drive, but uses
|
||||
// Gmail-specific endpoints and is consumed by the GmailTokenField UI.
|
||||
export const startGmailWebAuth = (payload: { credentials: string }) =>
|
||||
request.post(api.googleWebAuthStart('gmail'), { data: payload });
|
||||
export const startGmailWebAuth = (payload: {
|
||||
credentials: string;
|
||||
redirect_uri?: string;
|
||||
}) => request.post(api.googleWebAuthStart('gmail'), { data: payload });
|
||||
|
||||
export const pollGmailWebAuthResult = (payload: { flow_id: string }) =>
|
||||
request.post(api.googleWebAuthResult('gmail'), { data: payload });
|
||||
|
||||
Reference in New Issue
Block a user