@@ -25,22 +25,11 @@ const TEXT_FILE_COUNT_CAP: usize = 5;
2525/// Cap on aggregate text file bytes per message (matches Discord/Slack 1 MB).
2626const TEXT_TOTAL_CAP : u64 = 1024 * 1024 ;
2727
28- // --- Google Chat types ---
29- //
30- // Google Chat delivers webhooks in two shapes depending on the App's
31- // Connection settings in the Cloud Console:
32- // - HTTP endpoint URL mode: top-level fields (message, user, space, ...)
33- // - Pub/Sub mode: wrapped under `chat.messagePayload`
34- // Both are supported via the optional fields below; the handler prefers
35- // the wrapped form and falls back to top-level when `chat` is absent.
28+ // --- Google Chat types (v2 envelope format) ---
3629
3730#[ derive( Debug , Deserialize ) ]
3831pub struct GoogleChatEnvelope {
3932 pub chat : Option < ChatPayload > ,
40- // HTTP endpoint URL top-level fields (used when `chat` is None)
41- pub message : Option < GoogleChatMessage > ,
42- pub user : Option < GoogleChatUser > ,
43- pub space : Option < GoogleChatSpace > ,
4433}
4534
4635#[ derive( Debug , Deserialize ) ]
@@ -143,20 +132,20 @@ pub struct GoogleChatSpace {
143132
144133const GOOGLE_CHAT_ISSUER : & str = "https://accounts.google.com" ;
145134const GOOGLE_CHAT_JWKS_URL : & str = "https://www.googleapis.com/oauth2/v3/certs" ;
146- const GOOGLE_CHAT_SIGNER_EMAIL : & str = "chat@system .gserviceaccount.com" ;
135+ const GOOGLE_CHAT_EMAIL_SUFFIX : & str = "@gcp-sa-gsuiteaddons.iam .gserviceaccount.com" ;
147136const JWKS_CACHE_TTL : std:: time:: Duration = std:: time:: Duration :: from_secs ( 3600 ) ;
148137
149- /// Verify the JWT's `email` claim belongs to Google Chat.
150- /// HTTP endpoint URL webhooks are signed by `chat@system .gserviceaccount.com`.
138+ /// Verify the JWT's `email` claim belongs to a Google Chat service account .
139+ /// Google Chat webhooks use `service-{PROJECT_NUMBER}@gcp-sa-gsuiteaddons.iam .gserviceaccount.com`.
151140/// Without this check, any Google-issued ID token would be accepted.
152141fn verify_email_claim ( claims : & serde_json:: Value ) -> Result < ( ) , String > {
153142 let email = claims
154143 . get ( "email" )
155144 . and_then ( |v| v. as_str ( ) )
156145 . ok_or ( "missing email claim" ) ?;
157- if email != GOOGLE_CHAT_SIGNER_EMAIL {
146+ if !email . ends_with ( GOOGLE_CHAT_EMAIL_SUFFIX ) {
158147 return Err ( format ! (
159- "email claim mismatch: expected {GOOGLE_CHAT_SIGNER_EMAIL }, got {email}"
148+ "email claim mismatch: expected *{GOOGLE_CHAT_EMAIL_SUFFIX }, got {email}"
160149 ) ) ;
161150 }
162151 Ok ( ( ) )
@@ -495,20 +484,13 @@ pub async fn webhook(
495484 }
496485 } ;
497486
498- // Try the Pub/Sub `chat`-wrapped shape first, then fall back to the
499- // HTTP endpoint URL top-level shape.
500- let ( msg_opt, top_user, top_space) = if let Some ( chat) = envelope. chat {
501- let user = chat. user ;
502- let ( msg, space) = match chat. message_payload {
503- Some ( p) => ( p. message , p. space ) ,
504- None => ( None , None ) ,
505- } ;
506- ( msg, user, space)
507- } else {
508- ( envelope. message , envelope. user , envelope. space )
487+ let Some ( chat) = envelope. chat else {
488+ return empty_json_response ( ) ;
509489 } ;
510-
511- let Some ( ref msg) = msg_opt else {
490+ let Some ( payload) = chat. message_payload else {
491+ return empty_json_response ( ) ;
492+ } ;
493+ let Some ( ref msg) = payload. message else {
512494 return empty_json_response ( ) ;
513495 } ;
514496
@@ -525,8 +507,8 @@ pub async fn webhook(
525507 return empty_json_response ( ) ;
526508 }
527509
528- let sender = msg. sender . as_ref ( ) . or ( top_user . as_ref ( ) ) ;
529- let space = msg. space . as_ref ( ) . or ( top_space . as_ref ( ) ) ;
510+ let sender = msg. sender . as_ref ( ) . or ( chat . user . as_ref ( ) ) ;
511+ let space = msg. space . as_ref ( ) . or ( payload . space . as_ref ( ) ) ;
530512
531513 let is_bot = sender. map ( |s| s. user_type == "BOT" ) . unwrap_or ( false ) ;
532514 if is_bot {
@@ -1297,11 +1279,8 @@ pub async fn download_googlechat_file(
12971279 remaining_budget : u64 ,
12981280) -> Option < crate :: schema:: Attachment > {
12991281 let ext = content_name. rsplit ( '.' ) . next ( ) . unwrap_or ( "" ) . to_lowercase ( ) ;
1300- if !TEXT_EXTS . contains ( & ext. as_str ( ) ) {
1301- tracing:: debug!( content_name, "skipping non-text googlechat file attachment" ) ;
1302- return None ;
1303- }
1304- let max_size = FILE_MAX_DOWNLOAD . min ( remaining_budget) ;
1282+ let is_text = TEXT_EXTS . contains ( & ext. as_str ( ) ) ;
1283+ let max_size = if is_text { FILE_MAX_DOWNLOAD . min ( remaining_budget) } else { remaining_budget. min ( 20 * 1024 * 1024 ) } ;
13051284 let url = media_url ( api_base, resource_name) ;
13061285 let resp = match client. get ( & url) . bearer_auth ( token) . timeout ( MEDIA_REQUEST_TIMEOUT ) . send ( ) . await {
13071286 Ok ( r) => r,
@@ -1328,10 +1307,18 @@ pub async fn download_googlechat_file(
13281307 return None ;
13291308 }
13301309 let path = crate :: store:: store_media ( & bytes) . await ?;
1310+
1311+ let att_type = if is_text && String :: from_utf8 ( bytes. to_vec ( ) ) . is_ok ( ) {
1312+ "text_file"
1313+ } else {
1314+ "document"
1315+ } ;
1316+ let mime = if att_type == "text_file" { "text/plain" } else { "application/octet-stream" } ;
1317+
13311318 Some ( crate :: schema:: Attachment {
1332- attachment_type : "text_file" . into ( ) ,
1319+ attachment_type : att_type . into ( ) ,
13331320 filename : content_name. to_string ( ) ,
1334- mime_type : "text/plain" . into ( ) ,
1321+ mime_type : mime . into ( ) ,
13351322 data : String :: new ( ) ,
13361323 size : bytes. len ( ) as u64 ,
13371324 path : Some ( path) ,
@@ -1659,8 +1646,8 @@ mod tests {
16591646 }
16601647
16611648 #[ test]
1662- fn email_claim_accepts_chat_system_account ( ) {
1663- let claims = serde_json:: json!( { "email" : "chat@system .gserviceaccount.com" } ) ;
1649+ fn email_claim_accepts_gsuite_addons_account ( ) {
1650+ let claims = serde_json:: json!( { "email" : "service-123456@gcp-sa-gsuiteaddons.iam .gserviceaccount.com" } ) ;
16641651 assert ! ( verify_email_claim( & claims) . is_ok( ) ) ;
16651652 }
16661653
@@ -2439,32 +2426,4 @@ mod tests {
24392426 . await ;
24402427 assert ! ( result. is_none( ) , "oversized image must be rejected" ) ;
24412428 }
2442-
2443- #[ test]
2444- fn parses_http_endpoint_url_top_level_envelope ( ) {
2445- let envelope: GoogleChatEnvelope = serde_json:: from_value ( serde_json:: json!( {
2446- "message" : {
2447- "name" : "spaces/AAAA/messages/BBBB" ,
2448- "text" : "hello" ,
2449- "attachment" : [ ]
2450- } ,
2451- "user" : {
2452- "name" : "users/123" ,
2453- "displayName" : "Test User" ,
2454- "type" : "HUMAN"
2455- } ,
2456- "space" : {
2457- "name" : "spaces/AAAA" ,
2458- "type" : "DM"
2459- }
2460- } ) )
2461- . unwrap ( ) ;
2462- assert ! ( envelope. chat. is_none( ) ) ;
2463- assert ! ( envelope. message. is_some( ) ) ;
2464- assert_eq ! ( envelope. message. unwrap( ) . name, "spaces/AAAA/messages/BBBB" ) ;
2465- assert ! ( envelope. user. is_some( ) ) ;
2466- assert_eq ! ( envelope. user. unwrap( ) . name, "users/123" ) ;
2467- assert ! ( envelope. space. is_some( ) ) ;
2468- assert_eq ! ( envelope. space. unwrap( ) . name, "spaces/AAAA" ) ;
2469- }
24702429}
0 commit comments