diff options
Diffstat (limited to 'src/handlers.rs')
| -rw-r--r-- | src/handlers.rs | 158 |
1 files changed, 75 insertions, 83 deletions
diff --git a/src/handlers.rs b/src/handlers.rs index 2e9964c..8d8b62f 100644 --- a/src/handlers.rs +++ b/src/handlers.rs | |||
| @@ -1,24 +1,27 @@ | |||
| 1 | /// API handlers, the ends of each filter chain | 1 | /// API handlers, the ends of each filter chain |
| 2 | use crate::block::{AuthRequest, Block, Claims, InitialAuthRequest, NakedBlock, Transaction}; | ||
| 3 | use crate::student::{MetuId, User, UserAtRest}; | ||
| 4 | use crate::Db; | ||
| 2 | use aes::Aes128; | 5 | use aes::Aes128; |
| 3 | use askama::Template; | 6 | use askama::Template; |
| 4 | use blake2::{Blake2s, Digest}; | 7 | use blake2::{Blake2s, Digest}; |
| 5 | use block_modes::block_padding::Pkcs7; | 8 | use block_modes::{block_padding::Pkcs7, BlockMode, Cbc}; |
| 6 | use block_modes::{BlockMode, Cbc}; | ||
| 7 | use chrono::Utc; | 9 | use chrono::Utc; |
| 8 | use jsonwebtoken::errors::ErrorKind; | 10 | use jsonwebtoken::errors::ErrorKind; |
| 9 | use jsonwebtoken::{decode, Algorithm, DecodingKey, TokenData, Validation}; | 11 | use jsonwebtoken::{decode, Algorithm, DecodingKey, TokenData, Validation}; |
| 10 | use lazy_static::lazy_static; | 12 | use lazy_static::lazy_static; |
| 11 | use log::{debug, warn}; | 13 | use log::{debug, warn}; |
| 12 | use math; | ||
| 13 | use md5::Md5; | 14 | use md5::Md5; |
| 14 | use parking_lot::RwLockUpgradableReadGuard; | 15 | use parking_lot::RwLockUpgradableReadGuard; |
| 15 | use rsa::{PaddingScheme, RSAPrivateKey}; | 16 | use rsa::{PaddingScheme, RSAPrivateKey}; |
| 16 | use serde::Serialize; | 17 | use serde::Serialize; |
| 17 | use sha2::Sha256; | 18 | use sha2::Sha256; |
| 18 | use std::collections::{HashMap, HashSet}; | 19 | use std::{ |
| 19 | use std::convert::Infallible; | 20 | collections::{HashMap, HashSet}, |
| 20 | use std::fs; | 21 | convert::Infallible, |
| 21 | use std::hash::Hash; | 22 | fs, |
| 23 | hash::Hash, | ||
| 24 | }; | ||
| 22 | use warp::{http::StatusCode, reply}; | 25 | use warp::{http::StatusCode, reply}; |
| 23 | 26 | ||
| 24 | use crate::PRIVATE_KEY; | 27 | use crate::PRIVATE_KEY; |
| @@ -52,11 +55,6 @@ enum ResponseType { | |||
| 52 | Error, | 55 | Error, |
| 53 | } | 56 | } |
| 54 | 57 | ||
| 55 | use crate::schema::{ | ||
| 56 | AuthRequest, Block, Claims, Db, InitialAuthRequest, MetuId, NakedBlock, Transaction, User, | ||
| 57 | UserAtRest, | ||
| 58 | }; | ||
| 59 | |||
| 60 | const BEARER: &str = "Bearer "; | 58 | const BEARER: &str = "Bearer "; |
| 61 | 59 | ||
| 62 | lazy_static! { | 60 | lazy_static! { |
| @@ -64,7 +62,7 @@ lazy_static! { | |||
| 64 | .lines() | 62 | .lines() |
| 65 | .filter(|line| !line.starts_with('-')) | 63 | .filter(|line| !line.starts_with('-')) |
| 66 | .fold(String::new(), |mut data, line| { | 64 | .fold(String::new(), |mut data, line| { |
| 67 | data.push_str(&line); | 65 | data.push_str(line); |
| 68 | data | 66 | data |
| 69 | }); | 67 | }); |
| 70 | 68 | ||
| @@ -87,9 +85,9 @@ lazy_static! { | |||
| 87 | /// - Student picks a short temporary key (`k_temp`) | 85 | /// - Student picks a short temporary key (`k_temp`) |
| 88 | /// - Creates a JSON object (`auth_plaintext`) with their `metu_id` and `public key` in base64 (PEM) format (`S_PK`): | 86 | /// - Creates a JSON object (`auth_plaintext`) with their `metu_id` and `public key` in base64 (PEM) format (`S_PK`): |
| 89 | /// { | 87 | /// { |
| 90 | /// student_id: "e12345", | 88 | /// `student_id`: "e12345", |
| 91 | /// passwd: "15 char secret" | 89 | /// `passwd`: "15 char secret" |
| 92 | /// public_key: "---BEGIN PUBLIC KEY..." | 90 | /// `public_key`: "---BEGIN PUBLIC KEY..." |
| 93 | /// } | 91 | /// } |
| 94 | /// | 92 | /// |
| 95 | /// - Encrypts the serialized string of `auth_plaintext` with 128 bit block AES in CBC mode with Pkcs7 padding using the temporary key (`k_temp`), the result is `auth_ciphertext` | 93 | /// - Encrypts the serialized string of `auth_plaintext` with 128 bit block AES in CBC mode with Pkcs7 padding using the temporary key (`k_temp`), the result is `auth_ciphertext` |
| @@ -97,18 +95,17 @@ lazy_static! { | |||
| 97 | /// using sha256 with `gradecoin_public_key`, giving us `key_ciphertext` | 95 | /// using sha256 with `gradecoin_public_key`, giving us `key_ciphertext` |
| 98 | /// - The payload JSON object (`auth_request`) can be JSON serialized now: | 96 | /// - The payload JSON object (`auth_request`) can be JSON serialized now: |
| 99 | /// { | 97 | /// { |
| 100 | /// c: "auth_ciphertext" | 98 | /// c: "`auth_ciphertext`" |
| 101 | /// key: "key_ciphertext" | 99 | /// key: "`key_ciphertext`" |
| 102 | /// } | 100 | /// } |
| 103 | /// | 101 | /// |
| 104 | /// ## Gradecoin Side | 102 | /// ## Gradecoin Side |
| 105 | /// | 103 | /// |
| 106 | /// - Upon receiving, we first RSA decrypt with OAEP padding scheme using SHA256 with `gradecoin_private_key` as the key and auth_request.key `key` as the ciphertext, receiving `temp_key` (this is the temporary key chosen by student) | 104 | /// - Upon receiving, we first RSA decrypt with OAEP padding scheme using SHA256 with `gradecoin_private_key` as the key and `auth_request.key` `key` as the ciphertext, receiving `temp_key` (this is the temporary key chosen by student) |
| 107 | /// - With `temp_key`, we can AES 128 Cbc Pkcs7 decrypt the `auth_request.c`, giving us | 105 | /// - With `temp_key`, we can AES 128 Cbc Pkcs7 decrypt the `auth_request.c`, giving us `auth_plaintext` |
| 108 | /// auth_plaintext | ||
| 109 | /// - The `auth_plaintext` String can be deserialized to [`AuthRequest`] | 106 | /// - The `auth_plaintext` String can be deserialized to [`AuthRequest`] |
| 110 | /// - We then verify the payload and calculate the User fingerprint | 107 | /// - We then verify the payload and calculate the User fingerprint |
| 111 | /// - Finally, create the new [`User`] object, insert to users HashMap `<fingerprint, User>` | 108 | /// - Finally, create the new [`User`] object, insert to users `HashMap` `<fingerprint, User>` |
| 112 | /// | 109 | /// |
| 113 | pub async fn authenticate_user( | 110 | pub async fn authenticate_user( |
| 114 | request: InitialAuthRequest, | 111 | request: InitialAuthRequest, |
| @@ -226,7 +223,7 @@ pub async fn authenticate_user( | |||
| 226 | 223 | ||
| 227 | // c field was properly base64 encoded, now available in auth_packet | 224 | // c field was properly base64 encoded, now available in auth_packet |
| 228 | // decryptor was setup properly, with the correct lenght key | 225 | // decryptor was setup properly, with the correct lenght key |
| 229 | let mut buf = auth_packet.to_vec(); | 226 | let mut buf = auth_packet; |
| 230 | let auth_plaintext = match cipher.decrypt(&mut buf) { | 227 | let auth_plaintext = match cipher.decrypt(&mut buf) { |
| 231 | Ok(p) => p, | 228 | Ok(p) => p, |
| 232 | Err(err) => { | 229 | Err(err) => { |
| @@ -284,22 +281,21 @@ pub async fn authenticate_user( | |||
| 284 | 281 | ||
| 285 | // is the student in AuthRequest privileged? | 282 | // is the student in AuthRequest privileged? |
| 286 | let privileged_student_id = | 283 | let privileged_student_id = |
| 287 | match MetuId::new(request.student_id.clone(), request.passwd.clone()) { | 284 | if let Some(id) = MetuId::new(request.student_id.clone(), request.passwd.clone()) { |
| 288 | Some(id) => id, | 285 | id |
| 289 | None => { | 286 | } else { |
| 290 | debug!( | 287 | debug!( |
| 291 | "Someone tried to auth with invalid credentials: {} {}", | 288 | "Someone tried to auth with invalid credentials: {} {}", |
| 292 | &request.student_id, &request.passwd | 289 | &request.student_id, &request.passwd |
| 293 | ); | 290 | ); |
| 294 | let res_json = warp::reply::json(&GradeCoinResponse { | 291 | let res_json = warp::reply::json(&GradeCoinResponse { |
| 295 | res: ResponseType::Error, | 292 | res: ResponseType::Error, |
| 296 | message: | 293 | message: |
| 297 | "The credentials given ('student_id', 'passwd') cannot hold a Gradecoin account" | 294 | "The credentials given ('student_id', 'passwd') cannot hold a Gradecoin account" |
| 298 | .to_owned(), | 295 | .to_owned(), |
| 299 | }); | 296 | }); |
| 300 | 297 | ||
| 301 | return Ok(warp::reply::with_status(res_json, StatusCode::BAD_REQUEST)); | 298 | return Ok(warp::reply::with_status(res_json, StatusCode::BAD_REQUEST)); |
| 302 | } | ||
| 303 | }; | 299 | }; |
| 304 | 300 | ||
| 305 | // Students should be able to authenticate once | 301 | // Students should be able to authenticate once |
| @@ -329,7 +325,7 @@ pub async fn authenticate_user( | |||
| 329 | return Ok(warp::reply::with_status(res_json, StatusCode::BAD_REQUEST)); | 325 | return Ok(warp::reply::with_status(res_json, StatusCode::BAD_REQUEST)); |
| 330 | } | 326 | } |
| 331 | 327 | ||
| 332 | let fingerprint = format!("{:x}", Sha256::digest(&request.public_key.as_bytes())); | 328 | let fingerprint = format!("{:x}", Sha256::digest(request.public_key.as_bytes())); |
| 333 | 329 | ||
| 334 | let new_user = User { | 330 | let new_user = User { |
| 335 | user_id: privileged_student_id, | 331 | user_id: privileged_student_id, |
| @@ -387,7 +383,7 @@ pub async fn list_transactions(db: Db) -> Result<impl warp::Reply, Infallible> { | |||
| 387 | /// Proposes a new block for the next round. | 383 | /// Proposes a new block for the next round. |
| 388 | /// Can reject the block | 384 | /// Can reject the block |
| 389 | /// | 385 | /// |
| 390 | /// The proposer has to put their transaction as the first transaction of the [`transaction_list`]. | 386 | /// The proposer has to put their transaction as the first transaction of the transaction_list. |
| 391 | /// This is the analogue of `coinbase` in Bitcoin works | 387 | /// This is the analogue of `coinbase` in Bitcoin works |
| 392 | /// | 388 | /// |
| 393 | /// The `coinbase` transaction also gets something for their efforts. | 389 | /// The `coinbase` transaction also gets something for their efforts. |
| @@ -420,9 +416,10 @@ pub async fn propose_block( | |||
| 420 | let pending_transactions = db.pending_transactions.upgradable_read(); | 416 | let pending_transactions = db.pending_transactions.upgradable_read(); |
| 421 | 417 | ||
| 422 | // we get the proposers fingerprint by finding the transaction (id) then extracting the source | 418 | // we get the proposers fingerprint by finding the transaction (id) then extracting the source |
| 423 | let internal_user_fingerprint = match pending_transactions.get(&new_block.transaction_list[0]) { | 419 | let internal_user_fingerprint = |
| 424 | Some(coinbase) => &coinbase.source, | 420 | if let Some(coinbase) = pending_transactions.get(&new_block.transaction_list[0]) { |
| 425 | None => { | 421 | &coinbase.source |
| 422 | } else { | ||
| 426 | debug!( | 423 | debug!( |
| 427 | "Transaction with id {} is not found in the pending_transactions", | 424 | "Transaction with id {} is not found in the pending_transactions", |
| 428 | new_block.transaction_list[0] | 425 | new_block.transaction_list[0] |
| @@ -434,34 +431,31 @@ pub async fn propose_block( | |||
| 434 | }); | 431 | }); |
| 435 | 432 | ||
| 436 | return Ok(warp::reply::with_status(res_json, StatusCode::BAD_REQUEST)); | 433 | return Ok(warp::reply::with_status(res_json, StatusCode::BAD_REQUEST)); |
| 437 | } | 434 | }; |
| 438 | }; | ||
| 439 | 435 | ||
| 440 | let users_store = db.users.upgradable_read(); | 436 | let users_store = db.users.upgradable_read(); |
| 441 | 437 | ||
| 442 | // this probably cannot fail, if the transaction is valid then it must've been checked already | 438 | // this probably cannot fail, if the transaction is valid then it must've been checked already |
| 443 | let internal_user = match users_store.get(internal_user_fingerprint) { | 439 | let internal_user = if let Some(existing_user) = users_store.get(internal_user_fingerprint) { |
| 444 | Some(existing_user) => existing_user, | 440 | existing_user |
| 445 | None => { | 441 | } else { |
| 446 | debug!( | 442 | debug!( |
| 447 | "User with public key signature {:?} is not found in the database", | 443 | "User with public key signature {:?} is not found in the database", |
| 448 | new_block.transaction_list[0] | 444 | new_block.transaction_list[0] |
| 449 | ); | 445 | ); |
| 450 | 446 | ||
| 451 | let res_json = warp::reply::json(&GradeCoinResponse { | 447 | let res_json = warp::reply::json(&GradeCoinResponse { |
| 452 | res: ResponseType::Error, | 448 | res: ResponseType::Error, |
| 453 | message: "User with that public key signature is not found in the database" | 449 | message: "User with that public key signature is not found in the database".to_owned(), |
| 454 | .to_owned(), | 450 | }); |
| 455 | }); | ||
| 456 | 451 | ||
| 457 | return Ok(warp::reply::with_status(res_json, StatusCode::BAD_REQUEST)); | 452 | return Ok(warp::reply::with_status(res_json, StatusCode::BAD_REQUEST)); |
| 458 | } | ||
| 459 | }; | 453 | }; |
| 460 | 454 | ||
| 461 | let proposer_public_key = &internal_user.public_key; | 455 | let proposer_public_key = &internal_user.public_key; |
| 462 | 456 | ||
| 463 | // JWT Check | 457 | // JWT Check |
| 464 | let token_payload = match authorize_proposer(token, &proposer_public_key) { | 458 | let token_payload = match authorize_proposer(&token, proposer_public_key) { |
| 465 | Ok(data) => data, | 459 | Ok(data) => data, |
| 466 | Err(below) => { | 460 | Err(below) => { |
| 467 | debug!("Something went wrong with the JWT {:?}", below); | 461 | debug!("Something went wrong with the JWT {:?}", below); |
| @@ -501,7 +495,7 @@ pub async fn propose_block( | |||
| 501 | } | 495 | } |
| 502 | 496 | ||
| 503 | // Are transactions in the block valid? | 497 | // Are transactions in the block valid? |
| 504 | for transaction_hash in new_block.transaction_list.iter() { | 498 | for transaction_hash in &new_block.transaction_list { |
| 505 | if !pending_transactions.contains_key(transaction_hash) { | 499 | if !pending_transactions.contains_key(transaction_hash) { |
| 506 | let res_json = warp::reply::json(&GradeCoinResponse { | 500 | let res_json = warp::reply::json(&GradeCoinResponse { |
| 507 | res: ResponseType::Error, | 501 | res: ResponseType::Error, |
| @@ -536,7 +530,7 @@ pub async fn propose_block( | |||
| 536 | } | 530 | } |
| 537 | 531 | ||
| 538 | // Are the 6 leftmost characters (=24 bits) zero? | 532 | // Are the 6 leftmost characters (=24 bits) zero? |
| 539 | let should_zero = hashvalue[0] as i32 + hashvalue[1] as i32 + hashvalue[2] as i32; | 533 | let should_zero = i32::from(hashvalue[0]) + i32::from(hashvalue[1]) + i32::from(hashvalue[2]); |
| 540 | 534 | ||
| 541 | if should_zero != 0 { | 535 | if should_zero != 0 { |
| 542 | debug!("the hash does not have 6 rightmost zero bits"); | 536 | debug!("the hash does not have 6 rightmost zero bits"); |
| @@ -566,7 +560,7 @@ pub async fn propose_block( | |||
| 566 | let holding: HashMap<String, Transaction> = HashMap::new(); | 560 | let holding: HashMap<String, Transaction> = HashMap::new(); |
| 567 | 561 | ||
| 568 | // Play out the transactions | 562 | // Play out the transactions |
| 569 | for fingerprint in new_block.transaction_list.iter() { | 563 | for fingerprint in &new_block.transaction_list { |
| 570 | if let Some(transaction) = pending_transactions.remove(fingerprint) { | 564 | if let Some(transaction) = pending_transactions.remove(fingerprint) { |
| 571 | let source = &transaction.source; | 565 | let source = &transaction.source; |
| 572 | let target = &transaction.target; | 566 | let target = &transaction.target; |
| @@ -581,7 +575,7 @@ pub async fn propose_block( | |||
| 581 | if is_source_bot { | 575 | if is_source_bot { |
| 582 | // Add staking reward | 576 | // Add staking reward |
| 583 | to.balance += | 577 | to.balance += |
| 584 | math::round::ceil((transaction.amount as f64) * STAKING_REWARD, 0) | 578 | math::round::ceil((f64::from(transaction.amount)) * STAKING_REWARD, 0) |
| 585 | as u16; | 579 | as u16; |
| 586 | } | 580 | } |
| 587 | } | 581 | } |
| @@ -592,8 +586,8 @@ pub async fn propose_block( | |||
| 592 | pending_transactions.insert( | 586 | pending_transactions.insert( |
| 593 | transaction_id, | 587 | transaction_id, |
| 594 | Transaction { | 588 | Transaction { |
| 595 | source: target.to_owned(), | 589 | source: target.clone(), |
| 596 | target: source.to_owned(), | 590 | target: source.clone(), |
| 597 | amount: transaction.amount, | 591 | amount: transaction.amount, |
| 598 | timestamp: Utc::now().naive_local(), | 592 | timestamp: Utc::now().naive_local(), |
| 599 | }, | 593 | }, |
| @@ -602,8 +596,8 @@ pub async fn propose_block( | |||
| 602 | } | 596 | } |
| 603 | } | 597 | } |
| 604 | 598 | ||
| 605 | for (fp, tx) in holding.iter() { | 599 | for (fp, tx) in &holding { |
| 606 | pending_transactions.insert(fp.to_owned(), tx.to_owned()); | 600 | pending_transactions.insert(fp.clone(), tx.clone()); |
| 607 | } | 601 | } |
| 608 | 602 | ||
| 609 | // just update everyone's .guy file | 603 | // just update everyone's .guy file |
| @@ -665,23 +659,21 @@ pub async fn propose_transaction( | |||
| 665 | let users_store = db.users.read(); | 659 | let users_store = db.users.read(); |
| 666 | 660 | ||
| 667 | // Is this transaction from an authorized source? | 661 | // Is this transaction from an authorized source? |
| 668 | let internal_user = match users_store.get(&new_transaction.source) { | 662 | let internal_user = if let Some(existing_user) = users_store.get(&new_transaction.source) { |
| 669 | Some(existing_user) => existing_user, | 663 | existing_user |
| 670 | None => { | 664 | } else { |
| 671 | debug!( | 665 | debug!( |
| 672 | "User with public key signature {:?} is not found in the database", | 666 | "User with public key signature {:?} is not found in the database", |
| 673 | new_transaction.source | 667 | new_transaction.source |
| 674 | ); | 668 | ); |
| 675 | 669 | ||
| 676 | return Ok(warp::reply::with_status( | 670 | return Ok(warp::reply::with_status( |
| 677 | warp::reply::json(&GradeCoinResponse { | 671 | warp::reply::json(&GradeCoinResponse { |
| 678 | res: ResponseType::Error, | 672 | res: ResponseType::Error, |
| 679 | message: "User with the given public key signature is not authorized" | 673 | message: "User with the given public key signature is not authorized".to_owned(), |
| 680 | .to_owned(), | 674 | }), |
| 681 | }), | 675 | StatusCode::BAD_REQUEST, |
| 682 | StatusCode::BAD_REQUEST, | 676 | )); |
| 683 | )); | ||
| 684 | } | ||
| 685 | }; | 677 | }; |
| 686 | 678 | ||
| 687 | if internal_user.is_bot { | 679 | if internal_user.is_bot { |
| @@ -702,7 +694,7 @@ pub async fn propose_transaction( | |||
| 702 | // *this* point | 694 | // *this* point |
| 703 | let proposer_public_key = &internal_user.public_key; | 695 | let proposer_public_key = &internal_user.public_key; |
| 704 | 696 | ||
| 705 | let token_payload = match authorize_proposer(token, &proposer_public_key) { | 697 | let token_payload = match authorize_proposer(&token, proposer_public_key) { |
| 706 | Ok(data) => data, | 698 | Ok(data) => data, |
| 707 | Err(below) => { | 699 | Err(below) => { |
| 708 | debug!("JWT Error: {:?}", below); | 700 | debug!("JWT Error: {:?}", below); |
| @@ -815,7 +807,7 @@ pub async fn propose_transaction( | |||
| 815 | 807 | ||
| 816 | debug!("Taking the hash of {}", serd_tx); | 808 | debug!("Taking the hash of {}", serd_tx); |
| 817 | 809 | ||
| 818 | let hashed_transaction = Md5::digest(&serd_tx.as_bytes()); | 810 | let hashed_transaction = Md5::digest(serd_tx.as_bytes()); |
| 819 | if token_payload.claims.tha != format!("{:x}", hashed_transaction) { | 811 | if token_payload.claims.tha != format!("{:x}", hashed_transaction) { |
| 820 | return Ok(warp::reply::with_status( | 812 | return Ok(warp::reply::with_status( |
| 821 | warp::reply::json(&GradeCoinResponse { | 813 | warp::reply::json(&GradeCoinResponse { |
| @@ -859,7 +851,7 @@ pub async fn list_blocks(db: Db) -> Result<impl warp::Reply, Infallible> { | |||
| 859 | /// *[`jwt_token`]: The raw JWT token, "Bearer aaa.bbb.ccc" | 851 | /// *[`jwt_token`]: The raw JWT token, "Bearer aaa.bbb.ccc" |
| 860 | /// *[`user_pem`]: User Public Key, "BEGIN RSA" | 852 | /// *[`user_pem`]: User Public Key, "BEGIN RSA" |
| 861 | /// NOT async, might look into it if this becomes a bottleneck | 853 | /// NOT async, might look into it if this becomes a bottleneck |
| 862 | fn authorize_proposer(jwt_token: String, user_pem: &str) -> Result<TokenData<Claims>, String> { | 854 | fn authorize_proposer(jwt_token: &str, user_pem: &str) -> Result<TokenData<Claims>, String> { |
| 863 | // Throw away the "Bearer " part | 855 | // Throw away the "Bearer " part |
| 864 | let raw_jwt = jwt_token.trim_start_matches(BEARER).to_owned(); | 856 | let raw_jwt = jwt_token.trim_start_matches(BEARER).to_owned(); |
| 865 | 857 | ||
| @@ -929,7 +921,7 @@ pub async fn user_list_handler(db: Db) -> Result<impl warp::Reply, warp::Rejecti | |||
| 929 | 921 | ||
| 930 | for (fingerprint, user) in users.iter() { | 922 | for (fingerprint, user) in users.iter() { |
| 931 | sane_users.push(DisplayUsers { | 923 | sane_users.push(DisplayUsers { |
| 932 | fingerprint: fingerprint.to_owned(), | 924 | fingerprint: fingerprint.clone(), |
| 933 | balance: user.balance, | 925 | balance: user.balance, |
| 934 | is_bot: user.is_bot, | 926 | is_bot: user.is_bot, |
| 935 | }); | 927 | }); |
