mirror of
				https://github.com/dani-garcia/vaultwarden.git
				synced 2025-10-31 13:51:14 +00:00 
			
		
		
		
	feat: Push Notifications
Co-authored-by: samb-devel <125741162+samb-devel@users.noreply.github.com> Co-authored-by: Zoruk <Zoruk@users.noreply.github.com>
This commit is contained in:
		
					parent
					
						
							
								adf67a8ee8
							
						
					
				
			
			
				commit
				
					
						2d66292350
					
				
			
		
					 22 changed files with 526 additions and 70 deletions
				
			
		|  | @ -72,6 +72,13 @@ | |||
| # WEBSOCKET_ADDRESS=0.0.0.0 | ||||
| # WEBSOCKET_PORT=3012 | ||||
| 
 | ||||
| ## Enables push notifications (requires key and id from https://bitwarden.com/host) | ||||
| # PUSH_ENABLED=true | ||||
| # PUSH_INSTALLATION_ID=CHANGEME | ||||
| # PUSH_INSTALLATION_KEY=CHANGEME | ||||
| ## Don't change this unless you know what you're doing. | ||||
| # PUSH_RELAY_BASE_URI=https://push.bitwarden.com | ||||
| 
 | ||||
| ## Controls whether users are allowed to create Bitwarden Sends. | ||||
| ## This setting applies globally to all users. | ||||
| ## To control this on a per-org basis instead, use the "Disable Send" org policy. | ||||
|  |  | |||
|  | @ -0,0 +1 @@ | |||
| ALTER TABLE devices ADD COLUMN push_uuid TEXT; | ||||
|  | @ -0,0 +1 @@ | |||
| ALTER TABLE devices ADD COLUMN push_uuid TEXT; | ||||
|  | @ -0,0 +1 @@ | |||
| ALTER TABLE devices ADD COLUMN push_uuid TEXT; | ||||
|  | @ -13,7 +13,7 @@ use rocket::{ | |||
| }; | ||||
| 
 | ||||
| use crate::{ | ||||
|     api::{core::log_event, ApiResult, EmptyResult, JsonResult, Notify, NumberOrString}, | ||||
|     api::{core::log_event, unregister_push_device, ApiResult, EmptyResult, JsonResult, Notify, NumberOrString}, | ||||
|     auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp}, | ||||
|     config::ConfigBuilder, | ||||
|     db::{backup_database, get_sql_server_version, models::*, DbConn, DbConnType}, | ||||
|  | @ -402,14 +402,22 @@ async fn delete_user(uuid: &str, token: AdminToken, mut conn: DbConn) -> EmptyRe | |||
| #[post("/users/<uuid>/deauth")] | ||||
| async fn deauth_user(uuid: &str, _token: AdminToken, mut conn: DbConn, nt: Notify<'_>) -> EmptyResult { | ||||
|     let mut user = get_user_or_404(uuid, &mut conn).await?; | ||||
| 
 | ||||
|     nt.send_logout(&user, None, &mut conn).await; | ||||
| 
 | ||||
|     if CONFIG.push_enabled() { | ||||
|         for device in Device::find_push_device_by_user(&user.uuid, &mut conn).await { | ||||
|             match unregister_push_device(device.uuid).await { | ||||
|                 Ok(r) => r, | ||||
|                 Err(e) => error!("Unable to unregister devices from Bitwarden server: {}", e), | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     Device::delete_all_by_user(&user.uuid, &mut conn).await?; | ||||
|     user.reset_security_stamp(); | ||||
| 
 | ||||
|     let save_result = user.save(&mut conn).await; | ||||
| 
 | ||||
|     nt.send_logout(&user, None).await; | ||||
| 
 | ||||
|     save_result | ||||
|     user.save(&mut conn).await | ||||
| } | ||||
| 
 | ||||
| #[post("/users/<uuid>/disable")] | ||||
|  | @ -421,7 +429,7 @@ async fn disable_user(uuid: &str, _token: AdminToken, mut conn: DbConn, nt: Noti | |||
| 
 | ||||
|     let save_result = user.save(&mut conn).await; | ||||
| 
 | ||||
|     nt.send_logout(&user, None).await; | ||||
|     nt.send_logout(&user, None, &mut conn).await; | ||||
| 
 | ||||
|     save_result | ||||
| } | ||||
|  |  | |||
|  | @ -4,7 +4,8 @@ use serde_json::Value; | |||
| 
 | ||||
| use crate::{ | ||||
|     api::{ | ||||
|         core::log_user_event, EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType, | ||||
|         core::log_user_event, register_push_device, unregister_push_device, EmptyResult, JsonResult, JsonUpcase, | ||||
|         Notify, NumberOrString, PasswordData, UpdateType, | ||||
|     }, | ||||
|     auth::{decode_delete, decode_invite, decode_verify_email, Headers}, | ||||
|     crypto, | ||||
|  | @ -35,6 +36,7 @@ pub fn routes() -> Vec<rocket::Route> { | |||
|         post_verify_email_token, | ||||
|         post_delete_recover, | ||||
|         post_delete_recover_token, | ||||
|         post_device_token, | ||||
|         delete_account, | ||||
|         post_delete_account, | ||||
|         revision_date, | ||||
|  | @ -46,6 +48,9 @@ pub fn routes() -> Vec<rocket::Route> { | |||
|         get_known_device, | ||||
|         get_known_device_from_path, | ||||
|         put_avatar, | ||||
|         put_device_token, | ||||
|         put_clear_device_token, | ||||
|         post_clear_device_token, | ||||
|     ] | ||||
| } | ||||
| 
 | ||||
|  | @ -338,7 +343,7 @@ async fn post_password( | |||
|     // Prevent loging out the client where the user requested this endpoint from.
 | ||||
|     // If you do logout the user it will causes issues at the client side.
 | ||||
|     // Adding the device uuid will prevent this.
 | ||||
|     nt.send_logout(&user, Some(headers.device.uuid)).await; | ||||
|     nt.send_logout(&user, Some(headers.device.uuid), &mut conn).await; | ||||
| 
 | ||||
|     save_result | ||||
| } | ||||
|  | @ -398,7 +403,7 @@ async fn post_kdf(data: JsonUpcase<ChangeKdfData>, headers: Headers, mut conn: D | |||
|     user.set_password(&data.NewMasterPasswordHash, Some(data.Key), true, None); | ||||
|     let save_result = user.save(&mut conn).await; | ||||
| 
 | ||||
|     nt.send_logout(&user, Some(headers.device.uuid)).await; | ||||
|     nt.send_logout(&user, Some(headers.device.uuid), &mut conn).await; | ||||
| 
 | ||||
|     save_result | ||||
| } | ||||
|  | @ -485,7 +490,7 @@ async fn post_rotatekey(data: JsonUpcase<KeyData>, headers: Headers, mut conn: D | |||
|     // Prevent loging out the client where the user requested this endpoint from.
 | ||||
|     // If you do logout the user it will causes issues at the client side.
 | ||||
|     // Adding the device uuid will prevent this.
 | ||||
|     nt.send_logout(&user, Some(headers.device.uuid)).await; | ||||
|     nt.send_logout(&user, Some(headers.device.uuid), &mut conn).await; | ||||
| 
 | ||||
|     save_result | ||||
| } | ||||
|  | @ -508,7 +513,7 @@ async fn post_sstamp( | |||
|     user.reset_security_stamp(); | ||||
|     let save_result = user.save(&mut conn).await; | ||||
| 
 | ||||
|     nt.send_logout(&user, None).await; | ||||
|     nt.send_logout(&user, None, &mut conn).await; | ||||
| 
 | ||||
|     save_result | ||||
| } | ||||
|  | @ -611,7 +616,7 @@ async fn post_email( | |||
| 
 | ||||
|     let save_result = user.save(&mut conn).await; | ||||
| 
 | ||||
|     nt.send_logout(&user, None).await; | ||||
|     nt.send_logout(&user, None, &mut conn).await; | ||||
| 
 | ||||
|     save_result | ||||
| } | ||||
|  | @ -930,3 +935,64 @@ impl<'r> FromRequest<'r> for KnownDevice { | |||
|         }) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize)] | ||||
| #[allow(non_snake_case)] | ||||
| struct PushToken { | ||||
|     PushToken: String, | ||||
| } | ||||
| 
 | ||||
| #[post("/devices/identifier/<uuid>/token", data = "<data>")] | ||||
| async fn post_device_token(uuid: &str, data: JsonUpcase<PushToken>, headers: Headers, conn: DbConn) -> EmptyResult { | ||||
|     put_device_token(uuid, data, headers, conn).await | ||||
| } | ||||
| 
 | ||||
| #[put("/devices/identifier/<uuid>/token", data = "<data>")] | ||||
| async fn put_device_token(uuid: &str, data: JsonUpcase<PushToken>, headers: Headers, mut conn: DbConn) -> EmptyResult { | ||||
|     if !CONFIG.push_enabled() { | ||||
|         return Ok(()); | ||||
|     } | ||||
| 
 | ||||
|     let data = data.into_inner().data; | ||||
|     let token = data.PushToken; | ||||
|     let mut device = match Device::find_by_uuid_and_user(&headers.device.uuid, &headers.user.uuid, &mut conn).await { | ||||
|         Some(device) => device, | ||||
|         None => err!(format!("Error: device {uuid} should be present before a token can be assigned")), | ||||
|     }; | ||||
|     device.push_token = Some(token); | ||||
|     if device.push_uuid.is_none() { | ||||
|         device.push_uuid = Some(uuid::Uuid::new_v4().to_string()); | ||||
|     } | ||||
|     if let Err(e) = device.save(&mut conn).await { | ||||
|         err!(format!("An error occured while trying to save the device push token: {e}")); | ||||
|     } | ||||
|     if let Err(e) = register_push_device(headers.user.uuid, device).await { | ||||
|         err!(format!("An error occured while proceeding registration of a device: {e}")); | ||||
|     } | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| #[put("/devices/identifier/<uuid>/clear-token")] | ||||
| async fn put_clear_device_token(uuid: &str, mut conn: DbConn) -> EmptyResult { | ||||
|     // This only clears push token
 | ||||
|     // https://github.com/bitwarden/core/blob/master/src/Api/Controllers/DevicesController.cs#L109
 | ||||
|     // https://github.com/bitwarden/core/blob/master/src/Core/Services/Implementations/DeviceService.cs#L37
 | ||||
|     // This is somehow not implemented in any app, added it in case it is required
 | ||||
|     if !CONFIG.push_enabled() { | ||||
|         return Ok(()); | ||||
|     } | ||||
| 
 | ||||
|     if let Some(device) = Device::find_by_uuid(uuid, &mut conn).await { | ||||
|         Device::clear_push_token_by_uuid(uuid, &mut conn).await?; | ||||
|         unregister_push_device(device.uuid).await?; | ||||
|     } | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| // On upstream server, both PUT and POST are declared. Implementing the POST method in case it would be useful somewhere
 | ||||
| #[post("/devices/identifier/<uuid>/clear-token")] | ||||
| async fn post_clear_device_token(uuid: &str, conn: DbConn) -> EmptyResult { | ||||
|     put_clear_device_token(uuid, conn).await | ||||
| } | ||||
|  |  | |||
|  | @ -511,10 +511,9 @@ pub async fn update_cipher_from_data( | |||
|             ) | ||||
|             .await; | ||||
|         } | ||||
| 
 | ||||
|         nt.send_cipher_update(ut, cipher, &cipher.update_users_revision(conn).await, &headers.device.uuid, None).await; | ||||
|         nt.send_cipher_update(ut, cipher, &cipher.update_users_revision(conn).await, &headers.device.uuid, None, conn) | ||||
|             .await; | ||||
|     } | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
|  | @ -580,6 +579,7 @@ async fn post_ciphers_import( | |||
|     let mut user = headers.user; | ||||
|     user.update_revision(&mut conn).await?; | ||||
|     nt.send_user_update(UpdateType::SyncVault, &user).await; | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
|  | @ -777,6 +777,7 @@ async fn post_collections_admin( | |||
|         &cipher.update_users_revision(&mut conn).await, | ||||
|         &headers.device.uuid, | ||||
|         Some(Vec::from_iter(posted_collections)), | ||||
|         &mut conn, | ||||
|     ) | ||||
|     .await; | ||||
| 
 | ||||
|  | @ -1122,6 +1123,7 @@ async fn save_attachment( | |||
|         &cipher.update_users_revision(&mut conn).await, | ||||
|         &headers.device.uuid, | ||||
|         None, | ||||
|         &mut conn, | ||||
|     ) | ||||
|     .await; | ||||
| 
 | ||||
|  | @ -1407,8 +1409,15 @@ async fn move_cipher_selected( | |||
|         // Move cipher
 | ||||
|         cipher.move_to_folder(data.FolderId.clone(), &user_uuid, &mut conn).await?; | ||||
| 
 | ||||
|         nt.send_cipher_update(UpdateType::SyncCipherUpdate, &cipher, &[user_uuid.clone()], &headers.device.uuid, None) | ||||
|             .await; | ||||
|         nt.send_cipher_update( | ||||
|             UpdateType::SyncCipherUpdate, | ||||
|             &cipher, | ||||
|             &[user_uuid.clone()], | ||||
|             &headers.device.uuid, | ||||
|             None, | ||||
|             &mut conn, | ||||
|         ) | ||||
|         .await; | ||||
|     } | ||||
| 
 | ||||
|     Ok(()) | ||||
|  | @ -1489,6 +1498,7 @@ async fn delete_all( | |||
| 
 | ||||
|             user.update_revision(&mut conn).await?; | ||||
|             nt.send_user_update(UpdateType::SyncVault, &user).await; | ||||
| 
 | ||||
|             Ok(()) | ||||
|         } | ||||
|     } | ||||
|  | @ -1519,6 +1529,7 @@ async fn _delete_cipher_by_uuid( | |||
|             &cipher.update_users_revision(conn).await, | ||||
|             &headers.device.uuid, | ||||
|             None, | ||||
|             conn, | ||||
|         ) | ||||
|         .await; | ||||
|     } else { | ||||
|  | @ -1529,6 +1540,7 @@ async fn _delete_cipher_by_uuid( | |||
|             &cipher.update_users_revision(conn).await, | ||||
|             &headers.device.uuid, | ||||
|             None, | ||||
|             conn, | ||||
|         ) | ||||
|         .await; | ||||
|     } | ||||
|  | @ -1599,8 +1611,10 @@ async fn _restore_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &mut DbCon | |||
|         &cipher.update_users_revision(conn).await, | ||||
|         &headers.device.uuid, | ||||
|         None, | ||||
|         conn, | ||||
|     ) | ||||
|     .await; | ||||
| 
 | ||||
|     if let Some(org_uuid) = &cipher.organization_uuid { | ||||
|         log_event( | ||||
|             EventType::CipherRestored as i32, | ||||
|  | @ -1681,8 +1695,10 @@ async fn _delete_cipher_attachment_by_id( | |||
|         &cipher.update_users_revision(conn).await, | ||||
|         &headers.device.uuid, | ||||
|         None, | ||||
|         conn, | ||||
|     ) | ||||
|     .await; | ||||
| 
 | ||||
|     if let Some(org_uuid) = cipher.organization_uuid { | ||||
|         log_event( | ||||
|             EventType::CipherAttachmentDeleted as i32, | ||||
|  |  | |||
|  | @ -50,7 +50,7 @@ async fn post_folders(data: JsonUpcase<FolderData>, headers: Headers, mut conn: | |||
|     let mut folder = Folder::new(headers.user.uuid, data.Name); | ||||
| 
 | ||||
|     folder.save(&mut conn).await?; | ||||
|     nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device.uuid).await; | ||||
|     nt.send_folder_update(UpdateType::SyncFolderCreate, &folder, &headers.device.uuid, &mut conn).await; | ||||
| 
 | ||||
|     Ok(Json(folder.to_json())) | ||||
| } | ||||
|  | @ -88,7 +88,7 @@ async fn put_folder( | |||
|     folder.name = data.Name; | ||||
| 
 | ||||
|     folder.save(&mut conn).await?; | ||||
|     nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device.uuid).await; | ||||
|     nt.send_folder_update(UpdateType::SyncFolderUpdate, &folder, &headers.device.uuid, &mut conn).await; | ||||
| 
 | ||||
|     Ok(Json(folder.to_json())) | ||||
| } | ||||
|  | @ -112,6 +112,6 @@ async fn delete_folder(uuid: &str, headers: Headers, mut conn: DbConn, nt: Notif | |||
|     // Delete the actual folder entry
 | ||||
|     folder.delete(&mut conn).await?; | ||||
| 
 | ||||
|     nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device.uuid).await; | ||||
|     nt.send_folder_update(UpdateType::SyncFolderDelete, &folder, &headers.device.uuid, &mut conn).await; | ||||
|     Ok(()) | ||||
| } | ||||
|  |  | |||
|  | @ -14,7 +14,6 @@ pub use sends::purge_sends; | |||
| pub use two_factor::send_incomplete_2fa_notifications; | ||||
| 
 | ||||
| pub fn routes() -> Vec<Route> { | ||||
|     let mut device_token_routes = routes![clear_device_token, put_device_token]; | ||||
|     let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains]; | ||||
|     let mut hibp_routes = routes![hibp_breach]; | ||||
|     let mut meta_routes = routes![alive, now, version, config]; | ||||
|  | @ -28,7 +27,6 @@ pub fn routes() -> Vec<Route> { | |||
|     routes.append(&mut organizations::routes()); | ||||
|     routes.append(&mut two_factor::routes()); | ||||
|     routes.append(&mut sends::routes()); | ||||
|     routes.append(&mut device_token_routes); | ||||
|     routes.append(&mut eq_domains_routes); | ||||
|     routes.append(&mut hibp_routes); | ||||
|     routes.append(&mut meta_routes); | ||||
|  | @ -57,37 +55,6 @@ use crate::{ | |||
|     util::get_reqwest_client, | ||||
| }; | ||||
| 
 | ||||
| #[put("/devices/identifier/<uuid>/clear-token")] | ||||
| fn clear_device_token(uuid: &str) -> &'static str { | ||||
|     // This endpoint doesn't have auth header
 | ||||
| 
 | ||||
|     let _ = uuid; | ||||
|     // uuid is not related to deviceId
 | ||||
| 
 | ||||
|     // This only clears push token
 | ||||
|     // https://github.com/bitwarden/core/blob/master/src/Api/Controllers/DevicesController.cs#L109
 | ||||
|     // https://github.com/bitwarden/core/blob/master/src/Core/Services/Implementations/DeviceService.cs#L37
 | ||||
|     "" | ||||
| } | ||||
| 
 | ||||
| #[put("/devices/identifier/<uuid>/token", data = "<data>")] | ||||
| fn put_device_token(uuid: &str, data: JsonUpcase<Value>, headers: Headers) -> Json<Value> { | ||||
|     let _data: Value = data.into_inner().data; | ||||
|     // Data has a single string value "PushToken"
 | ||||
|     let _ = uuid; | ||||
|     // uuid is not related to deviceId
 | ||||
| 
 | ||||
|     // TODO: This should save the push token, but we don't have push functionality
 | ||||
| 
 | ||||
|     Json(json!({ | ||||
|         "Id": headers.device.uuid, | ||||
|         "Name": headers.device.name, | ||||
|         "Type": headers.device.atype, | ||||
|         "Identifier": headers.device.uuid, | ||||
|         "CreationDate": crate::util::format_date(&headers.device.created_at), | ||||
|     })) | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize, Deserialize, Debug)] | ||||
| #[allow(non_snake_case)] | ||||
| struct GlobalDomain { | ||||
|  |  | |||
|  | @ -2716,7 +2716,7 @@ async fn put_reset_password( | |||
|     user.set_password(reset_request.NewMasterPasswordHash.as_str(), Some(reset_request.Key), true, None); | ||||
|     user.save(&mut conn).await?; | ||||
| 
 | ||||
|     nt.send_logout(&user, None).await; | ||||
|     nt.send_logout(&user, None, &mut conn).await; | ||||
| 
 | ||||
|     log_event( | ||||
|         EventType::OrganizationUserAdminResetPassword as i32, | ||||
|  |  | |||
|  | @ -180,7 +180,8 @@ async fn post_send(data: JsonUpcase<SendData>, headers: Headers, mut conn: DbCon | |||
| 
 | ||||
|     let mut send = create_send(data, headers.user.uuid)?; | ||||
|     send.save(&mut conn).await?; | ||||
|     nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await).await; | ||||
|     nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await, &mut conn) | ||||
|         .await; | ||||
| 
 | ||||
|     Ok(Json(send.to_json())) | ||||
| } | ||||
|  | @ -252,7 +253,8 @@ async fn post_send_file(data: Form<UploadData<'_>>, headers: Headers, mut conn: | |||
| 
 | ||||
|     // Save the changes in the database
 | ||||
|     send.save(&mut conn).await?; | ||||
|     nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await).await; | ||||
|     nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await, &mut conn) | ||||
|         .await; | ||||
| 
 | ||||
|     Ok(Json(send.to_json())) | ||||
| } | ||||
|  | @ -335,7 +337,8 @@ async fn post_send_file_v2_data( | |||
|             data.data.move_copy_to(file_path).await? | ||||
|         } | ||||
| 
 | ||||
|         nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await).await; | ||||
|         nt.send_send_update(UpdateType::SyncSendCreate, &send, &send.update_users_revision(&mut conn).await, &mut conn) | ||||
|             .await; | ||||
|     } else { | ||||
|         err!("Send not found. Unable to save the file."); | ||||
|     } | ||||
|  | @ -397,7 +400,8 @@ async fn post_access( | |||
| 
 | ||||
|     send.save(&mut conn).await?; | ||||
| 
 | ||||
|     nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await).await; | ||||
|     nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await, &mut conn) | ||||
|         .await; | ||||
| 
 | ||||
|     Ok(Json(send.to_json_access(&mut conn).await)) | ||||
| } | ||||
|  | @ -448,7 +452,8 @@ async fn post_access_file( | |||
| 
 | ||||
|     send.save(&mut conn).await?; | ||||
| 
 | ||||
|     nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await).await; | ||||
|     nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await, &mut conn) | ||||
|         .await; | ||||
| 
 | ||||
|     let token_claims = crate::auth::generate_send_claims(send_id, file_id); | ||||
|     let token = crate::auth::encode_jwt(&token_claims); | ||||
|  | @ -530,7 +535,8 @@ async fn put_send( | |||
|     } | ||||
| 
 | ||||
|     send.save(&mut conn).await?; | ||||
|     nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await).await; | ||||
|     nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await, &mut conn) | ||||
|         .await; | ||||
| 
 | ||||
|     Ok(Json(send.to_json())) | ||||
| } | ||||
|  | @ -547,7 +553,8 @@ async fn delete_send(id: &str, headers: Headers, mut conn: DbConn, nt: Notify<'_ | |||
|     } | ||||
| 
 | ||||
|     send.delete(&mut conn).await?; | ||||
|     nt.send_send_update(UpdateType::SyncSendDelete, &send, &send.update_users_revision(&mut conn).await).await; | ||||
|     nt.send_send_update(UpdateType::SyncSendDelete, &send, &send.update_users_revision(&mut conn).await, &mut conn) | ||||
|         .await; | ||||
| 
 | ||||
|     Ok(()) | ||||
| } | ||||
|  | @ -567,7 +574,8 @@ async fn put_remove_password(id: &str, headers: Headers, mut conn: DbConn, nt: N | |||
| 
 | ||||
|     send.set_password(None); | ||||
|     send.save(&mut conn).await?; | ||||
|     nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await).await; | ||||
|     nt.send_send_update(UpdateType::SyncSendUpdate, &send, &send.update_users_revision(&mut conn).await, &mut conn) | ||||
|         .await; | ||||
| 
 | ||||
|     Ok(Json(send.to_json())) | ||||
| } | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ pub mod core; | |||
| mod icons; | ||||
| mod identity; | ||||
| mod notifications; | ||||
| mod push; | ||||
| mod web; | ||||
| 
 | ||||
| use rocket::serde::json::Json; | ||||
|  | @ -22,6 +23,10 @@ pub use crate::api::{ | |||
|     identity::routes as identity_routes, | ||||
|     notifications::routes as notifications_routes, | ||||
|     notifications::{start_notification_server, Notify, UpdateType}, | ||||
|     push::{ | ||||
|         push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device, | ||||
|         unregister_push_device, | ||||
|     }, | ||||
|     web::catchers as web_catchers, | ||||
|     web::routes as web_routes, | ||||
|     web::static_files, | ||||
|  |  | |||
|  | @ -21,7 +21,10 @@ use tokio_tungstenite::{ | |||
| 
 | ||||
| use crate::{ | ||||
|     auth::ClientIp, | ||||
|     db::models::{Cipher, Folder, Send as DbSend, User}, | ||||
|     db::{ | ||||
|         models::{Cipher, Folder, Send as DbSend, User}, | ||||
|         DbConn, | ||||
|     }, | ||||
|     Error, CONFIG, | ||||
| }; | ||||
| 
 | ||||
|  | @ -33,6 +36,8 @@ static WS_USERS: Lazy<Arc<WebSocketUsers>> = Lazy::new(|| { | |||
|     }) | ||||
| }); | ||||
| 
 | ||||
| use super::{push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update}; | ||||
| 
 | ||||
| pub fn routes() -> Vec<Route> { | ||||
|     routes![websockets_hub] | ||||
| } | ||||
|  | @ -233,19 +238,33 @@ impl WebSocketUsers { | |||
|         ); | ||||
| 
 | ||||
|         self.send_update(&user.uuid, &data).await; | ||||
| 
 | ||||
|         if CONFIG.push_enabled() { | ||||
|             push_user_update(ut, user).await; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub async fn send_logout(&self, user: &User, acting_device_uuid: Option<String>) { | ||||
|     pub async fn send_logout(&self, user: &User, acting_device_uuid: Option<String>, conn: &mut DbConn) { | ||||
|         let data = create_update( | ||||
|             vec![("UserId".into(), user.uuid.clone().into()), ("Date".into(), serialize_date(user.updated_at))], | ||||
|             UpdateType::LogOut, | ||||
|             acting_device_uuid, | ||||
|             acting_device_uuid.clone(), | ||||
|         ); | ||||
| 
 | ||||
|         self.send_update(&user.uuid, &data).await; | ||||
| 
 | ||||
|         if CONFIG.push_enabled() { | ||||
|             push_logout(user, acting_device_uuid, conn).await; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub async fn send_folder_update(&self, ut: UpdateType, folder: &Folder, acting_device_uuid: &String) { | ||||
|     pub async fn send_folder_update( | ||||
|         &self, | ||||
|         ut: UpdateType, | ||||
|         folder: &Folder, | ||||
|         acting_device_uuid: &String, | ||||
|         conn: &mut DbConn, | ||||
|     ) { | ||||
|         let data = create_update( | ||||
|             vec![ | ||||
|                 ("Id".into(), folder.uuid.clone().into()), | ||||
|  | @ -257,6 +276,10 @@ impl WebSocketUsers { | |||
|         ); | ||||
| 
 | ||||
|         self.send_update(&folder.user_uuid, &data).await; | ||||
| 
 | ||||
|         if CONFIG.push_enabled() { | ||||
|             push_folder_update(ut, folder, acting_device_uuid, conn).await; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub async fn send_cipher_update( | ||||
|  | @ -266,6 +289,7 @@ impl WebSocketUsers { | |||
|         user_uuids: &[String], | ||||
|         acting_device_uuid: &String, | ||||
|         collection_uuids: Option<Vec<String>>, | ||||
|         conn: &mut DbConn, | ||||
|     ) { | ||||
|         let org_uuid = convert_option(cipher.organization_uuid.clone()); | ||||
|         // Depending if there are collections provided or not, we need to have different values for the following variables.
 | ||||
|  | @ -295,9 +319,13 @@ impl WebSocketUsers { | |||
|         for uuid in user_uuids { | ||||
|             self.send_update(uuid, &data).await; | ||||
|         } | ||||
| 
 | ||||
|         if CONFIG.push_enabled() && user_uuids.len() == 1 { | ||||
|             push_cipher_update(ut, cipher, acting_device_uuid, conn).await; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub async fn send_send_update(&self, ut: UpdateType, send: &DbSend, user_uuids: &[String]) { | ||||
|     pub async fn send_send_update(&self, ut: UpdateType, send: &DbSend, user_uuids: &[String], conn: &mut DbConn) { | ||||
|         let user_uuid = convert_option(send.user_uuid.clone()); | ||||
| 
 | ||||
|         let data = create_update( | ||||
|  | @ -313,6 +341,9 @@ impl WebSocketUsers { | |||
|         for uuid in user_uuids { | ||||
|             self.send_update(uuid, &data).await; | ||||
|         } | ||||
|         if CONFIG.push_enabled() && user_uuids.len() == 1 { | ||||
|             push_send_update(ut, send, conn).await; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -354,7 +385,7 @@ fn create_ping() -> Vec<u8> { | |||
| } | ||||
| 
 | ||||
| #[allow(dead_code)] | ||||
| #[derive(Eq, PartialEq)] | ||||
| #[derive(Copy, Clone, Eq, PartialEq)] | ||||
| pub enum UpdateType { | ||||
|     SyncCipherUpdate = 0, | ||||
|     SyncCipherCreate = 1, | ||||
|  |  | |||
							
								
								
									
										280
									
								
								src/api/push.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										280
									
								
								src/api/push.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,280 @@ | |||
| use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}; | ||||
| use serde_json::Value; | ||||
| use tokio::sync::RwLock; | ||||
| 
 | ||||
| use crate::{ | ||||
|     api::{ApiResult, EmptyResult, UpdateType}, | ||||
|     db::models::{Cipher, Device, Folder, Send, User}, | ||||
|     util::get_reqwest_client, | ||||
|     CONFIG, | ||||
| }; | ||||
| 
 | ||||
| use once_cell::sync::Lazy; | ||||
| use std::time::{Duration, Instant}; | ||||
| 
 | ||||
| #[derive(Deserialize)] | ||||
| struct AuthPushToken { | ||||
|     access_token: String, | ||||
|     expires_in: i32, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| struct LocalAuthPushToken { | ||||
|     access_token: String, | ||||
|     valid_until: Instant, | ||||
| } | ||||
| 
 | ||||
| async fn get_auth_push_token() -> ApiResult<String> { | ||||
|     static PUSH_TOKEN: Lazy<RwLock<LocalAuthPushToken>> = Lazy::new(|| { | ||||
|         RwLock::new(LocalAuthPushToken { | ||||
|             access_token: String::new(), | ||||
|             valid_until: Instant::now(), | ||||
|         }) | ||||
|     }); | ||||
|     let push_token = PUSH_TOKEN.read().await; | ||||
| 
 | ||||
|     if push_token.valid_until.saturating_duration_since(Instant::now()).as_secs() > 0 { | ||||
|         debug!("Auth Push token still valid, no need for a new one"); | ||||
|         return Ok(push_token.access_token.clone()); | ||||
|     } | ||||
|     drop(push_token); // Drop the read lock now
 | ||||
| 
 | ||||
|     let installation_id = CONFIG.push_installation_id(); | ||||
|     let client_id = format!("installation.{installation_id}"); | ||||
|     let client_secret = CONFIG.push_installation_key(); | ||||
| 
 | ||||
|     let params = [ | ||||
|         ("grant_type", "client_credentials"), | ||||
|         ("scope", "api.push"), | ||||
|         ("client_id", &client_id), | ||||
|         ("client_secret", &client_secret), | ||||
|     ]; | ||||
| 
 | ||||
|     let res = match get_reqwest_client().post("https://identity.bitwarden.com/connect/token").form(¶ms).send().await | ||||
|     { | ||||
|         Ok(r) => r, | ||||
|         Err(e) => err!(format!("Error getting push token from bitwarden server: {e}")), | ||||
|     }; | ||||
| 
 | ||||
|     let json_pushtoken = match res.json::<AuthPushToken>().await { | ||||
|         Ok(r) => r, | ||||
|         Err(e) => err!(format!("Unexpected push token received from bitwarden server: {e}")), | ||||
|     }; | ||||
| 
 | ||||
|     let mut push_token = PUSH_TOKEN.write().await; | ||||
|     push_token.valid_until = Instant::now() | ||||
|         .checked_add(Duration::new((json_pushtoken.expires_in / 2) as u64, 0)) // Token valid for half the specified time
 | ||||
|         .unwrap(); | ||||
| 
 | ||||
|     push_token.access_token = json_pushtoken.access_token; | ||||
| 
 | ||||
|     debug!("Token still valid for {}", push_token.valid_until.saturating_duration_since(Instant::now()).as_secs()); | ||||
|     Ok(push_token.access_token.clone()) | ||||
| } | ||||
| 
 | ||||
| pub async fn register_push_device(user_uuid: String, device: Device) -> EmptyResult { | ||||
|     if !CONFIG.push_enabled() { | ||||
|         return Ok(()); | ||||
|     } | ||||
|     let auth_push_token = get_auth_push_token().await?; | ||||
| 
 | ||||
|     //Needed to register a device for push to bitwarden :
 | ||||
|     let data = json!({ | ||||
|         "userId": user_uuid, | ||||
|         "deviceId": device.push_uuid, | ||||
|         "identifier": device.uuid, | ||||
|         "type": device.atype, | ||||
|         "pushToken": device.push_token | ||||
|     }); | ||||
| 
 | ||||
|     let auth_header = format!("Bearer {}", &auth_push_token); | ||||
| 
 | ||||
|     get_reqwest_client() | ||||
|         .post(CONFIG.push_relay_uri() + "/push/register") | ||||
|         .header(CONTENT_TYPE, "application/json") | ||||
|         .header(ACCEPT, "application/json") | ||||
|         .header(AUTHORIZATION, auth_header) | ||||
|         .json(&data) | ||||
|         .send() | ||||
|         .await? | ||||
|         .error_for_status()?; | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| pub async fn unregister_push_device(uuid: String) -> EmptyResult { | ||||
|     if !CONFIG.push_enabled() { | ||||
|         return Ok(()); | ||||
|     } | ||||
|     let auth_push_token = get_auth_push_token().await?; | ||||
| 
 | ||||
|     let auth_header = format!("Bearer {}", &auth_push_token); | ||||
| 
 | ||||
|     match get_reqwest_client() | ||||
|         .delete(CONFIG.push_relay_uri() + "/push/" + &uuid) | ||||
|         .header(AUTHORIZATION, auth_header) | ||||
|         .send() | ||||
|         .await | ||||
|     { | ||||
|         Ok(r) => r, | ||||
|         Err(e) => err!(format!("An error occured during device unregistration: {e}")), | ||||
|     }; | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| pub async fn push_cipher_update( | ||||
|     ut: UpdateType, | ||||
|     cipher: &Cipher, | ||||
|     acting_device_uuid: &String, | ||||
|     conn: &mut crate::db::DbConn, | ||||
| ) { | ||||
|     // We shouldn't send a push notification on cipher update if the cipher belongs to an organization, this isn't implemented in the upstream server too.
 | ||||
|     if cipher.organization_uuid.is_some() { | ||||
|         return; | ||||
|     }; | ||||
|     let user_uuid = match &cipher.user_uuid { | ||||
|         Some(c) => c, | ||||
|         None => { | ||||
|             debug!("Cipher has no uuid"); | ||||
|             return; | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     for device in Device::find_by_user(user_uuid, conn).await { | ||||
|         let data = json!({ | ||||
|             "userId": user_uuid, | ||||
|             "organizationId": (), | ||||
|             "deviceId": device.push_uuid, | ||||
|             "identifier": acting_device_uuid, | ||||
|             "type": ut as i32, | ||||
|             "payload": { | ||||
|                 "Id": cipher.uuid, | ||||
|                 "UserId": cipher.user_uuid, | ||||
|                 "OrganizationId": (), | ||||
|                 "RevisionDate": cipher.updated_at | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         send_to_push_relay(data).await; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| pub async fn push_logout(user: &User, acting_device_uuid: Option<String>, conn: &mut crate::db::DbConn) { | ||||
|     if let Some(d) = acting_device_uuid { | ||||
|         for device in Device::find_by_user(&user.uuid, conn).await { | ||||
|             let data = json!({ | ||||
|                 "userId": user.uuid, | ||||
|                 "organizationId": (), | ||||
|                 "deviceId": device.push_uuid, | ||||
|                 "identifier": d, | ||||
|                 "type": UpdateType::LogOut as i32, | ||||
|                 "payload": { | ||||
|                     "UserId": user.uuid, | ||||
|                     "Date": user.updated_at | ||||
|                 } | ||||
|             }); | ||||
|             send_to_push_relay(data).await; | ||||
|         } | ||||
|     } else { | ||||
|         let data = json!({ | ||||
|             "userId": user.uuid, | ||||
|             "organizationId": (), | ||||
|             "deviceId": (), | ||||
|             "identifier": (), | ||||
|             "type": UpdateType::LogOut as i32, | ||||
|             "payload": { | ||||
|                 "UserId": user.uuid, | ||||
|                 "Date": user.updated_at | ||||
|             } | ||||
|         }); | ||||
|         send_to_push_relay(data).await; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| pub async fn push_user_update(ut: UpdateType, user: &User) { | ||||
|     let data = json!({ | ||||
|         "userId": user.uuid, | ||||
|         "organizationId": (), | ||||
|         "deviceId": (), | ||||
|         "identifier": (), | ||||
|         "type": ut as i32, | ||||
|         "payload": { | ||||
|             "UserId": user.uuid, | ||||
|             "Date": user.updated_at | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     send_to_push_relay(data).await; | ||||
| } | ||||
| 
 | ||||
| pub async fn push_folder_update( | ||||
|     ut: UpdateType, | ||||
|     folder: &Folder, | ||||
|     acting_device_uuid: &String, | ||||
|     conn: &mut crate::db::DbConn, | ||||
| ) { | ||||
|     for device in Device::find_by_user(&folder.user_uuid, conn).await { | ||||
|         let data = json!({ | ||||
|             "userId": folder.user_uuid, | ||||
|             "organizationId": (), | ||||
|             "deviceId": device.push_uuid, | ||||
|             "identifier": acting_device_uuid, | ||||
|             "type": ut as i32, | ||||
|             "payload": { | ||||
|                 "Id": folder.uuid, | ||||
|                 "UserId": folder.user_uuid, | ||||
|                 "RevisionDate": folder.updated_at | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         send_to_push_relay(data).await; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| pub async fn push_send_update(ut: UpdateType, send: &Send, conn: &mut crate::db::DbConn) { | ||||
|     if let Some(s) = &send.user_uuid { | ||||
|         for device in Device::find_by_user(s, conn).await { | ||||
|             let data = json!({ | ||||
|                 "userId": send.user_uuid, | ||||
|                 "organizationId": (), | ||||
|                 "deviceId": device.push_uuid, | ||||
|                 "identifier": (), | ||||
|                 "type": ut as i32, | ||||
|                 "payload": { | ||||
|                     "Id": send.uuid, | ||||
|                     "UserId": send.user_uuid, | ||||
|                     "RevisionDate": send.revision_date | ||||
|                 } | ||||
|             }); | ||||
| 
 | ||||
|             send_to_push_relay(data).await; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| async fn send_to_push_relay(data: Value) { | ||||
|     if !CONFIG.push_enabled() { | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     let auth_push_token = match get_auth_push_token().await { | ||||
|         Ok(s) => s, | ||||
|         Err(e) => { | ||||
|             debug!("Could not get the auth push token: {}", e); | ||||
|             return; | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     let auth_header = format!("Bearer {}", &auth_push_token); | ||||
| 
 | ||||
|     if let Err(e) = get_reqwest_client() | ||||
|         .post(CONFIG.push_relay_uri() + "/push/send") | ||||
|         .header(ACCEPT, "application/json") | ||||
|         .header(CONTENT_TYPE, "application/json") | ||||
|         .header(AUTHORIZATION, auth_header) | ||||
|         .json(&data) | ||||
|         .send() | ||||
|         .await | ||||
|     { | ||||
|         error!("An error occured while sending a send update to the push relay: {}", e); | ||||
|     }; | ||||
| } | ||||
|  | @ -377,6 +377,16 @@ make_config! { | |||
|         /// Websocket port
 | ||||
|         websocket_port:         u16,    false,  def,    3012; | ||||
|     }, | ||||
|     push { | ||||
|         /// Enable push notifications
 | ||||
|         push_enabled:           bool,   false,  def,    false; | ||||
|         /// Push relay base uri
 | ||||
|         push_relay_uri:         String, false,  def,    "https://push.bitwarden.com".to_string(); | ||||
|         /// Installation id |> The installation id from https://bitwarden.com/host
 | ||||
|         push_installation_id:   Pass,   false,  def,    String::new(); | ||||
|         /// Installation key |> The installation key from https://bitwarden.com/host
 | ||||
|         push_installation_key:  Pass,   false,  def,    String::new(); | ||||
|     }, | ||||
|     jobs { | ||||
|         /// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run.
 | ||||
|         /// Set to 0 to globally disable scheduled jobs.
 | ||||
|  | @ -724,6 +734,17 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     if cfg.push_enabled && (cfg.push_installation_id == String::new() || cfg.push_installation_key == String::new()) { | ||||
|         err!( | ||||
|             "Misconfigured Push Notification service\n\ | ||||
|             ########################################################################################\n\ | ||||
|             # It looks like you enabled Push Notification feature, but didn't configure it         #\n\ | ||||
|             # properly. Make sure the installation id and key from https://bitwarden.com/host are  #\n\
 | ||||
|             # added to your configuration.                                                         #\n\ | ||||
|             ########################################################################################\n" | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     if cfg._enable_duo | ||||
|         && (cfg.duo_host.is_some() || cfg.duo_ikey.is_some() || cfg.duo_skey.is_some()) | ||||
|         && !(cfg.duo_host.is_some() && cfg.duo_ikey.is_some() && cfg.duo_skey.is_some()) | ||||
|  |  | |||
|  | @ -15,7 +15,8 @@ db_object! { | |||
|         pub user_uuid: String, | ||||
| 
 | ||||
|         pub name: String, | ||||
|         pub atype: i32, // https://github.com/bitwarden/server/blob/master/src/Core/Enums/DeviceType.cs
 | ||||
|         pub atype: i32,         // https://github.com/bitwarden/server/blob/master/src/Core/Enums/DeviceType.cs
 | ||||
|         pub push_uuid: Option<String>, | ||||
|         pub push_token: Option<String>, | ||||
| 
 | ||||
|         pub refresh_token: String, | ||||
|  | @ -38,6 +39,7 @@ impl Device { | |||
|             name, | ||||
|             atype, | ||||
| 
 | ||||
|             push_uuid: None, | ||||
|             push_token: None, | ||||
|             refresh_token: String::new(), | ||||
|             twofactor_remember: None, | ||||
|  | @ -155,6 +157,35 @@ impl Device { | |||
|         }} | ||||
|     } | ||||
| 
 | ||||
|     pub async fn find_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> { | ||||
|         db_run! { conn: { | ||||
|             devices::table | ||||
|                 .filter(devices::user_uuid.eq(user_uuid)) | ||||
|                 .load::<DeviceDb>(conn) | ||||
|                 .expect("Error loading devices") | ||||
|                 .from_db() | ||||
|         }} | ||||
|     } | ||||
| 
 | ||||
|     pub async fn find_by_uuid(uuid: &str, conn: &mut DbConn) -> Option<Self> { | ||||
|         db_run! { conn: { | ||||
|             devices::table | ||||
|                 .filter(devices::uuid.eq(uuid)) | ||||
|                 .first::<DeviceDb>(conn) | ||||
|                 .ok() | ||||
|                 .from_db() | ||||
|         }} | ||||
|     } | ||||
| 
 | ||||
|     pub async fn clear_push_token_by_uuid(uuid: &str, conn: &mut DbConn) -> EmptyResult { | ||||
|         db_run! { conn: { | ||||
|             diesel::update(devices::table) | ||||
|                 .filter(devices::uuid.eq(uuid)) | ||||
|                 .set(devices::push_token.eq::<Option<String>>(None)) | ||||
|                 .execute(conn) | ||||
|                 .map_res("Error removing push token") | ||||
|         }} | ||||
|     } | ||||
|     pub async fn find_by_refresh_token(refresh_token: &str, conn: &mut DbConn) -> Option<Self> { | ||||
|         db_run! { conn: { | ||||
|             devices::table | ||||
|  | @ -175,4 +206,14 @@ impl Device { | |||
|                 .from_db() | ||||
|         }} | ||||
|     } | ||||
|     pub async fn find_push_device_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> { | ||||
|         db_run! { conn: { | ||||
|             devices::table | ||||
|                 .filter(devices::user_uuid.eq(user_uuid)) | ||||
|                 .filter(devices::push_token.is_not_null()) | ||||
|                 .load::<DeviceDb>(conn) | ||||
|                 .expect("Error loading push devices") | ||||
|                 .from_db() | ||||
|         }} | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -49,6 +49,7 @@ table! { | |||
|         user_uuid -> Text, | ||||
|         name -> Text, | ||||
|         atype -> Integer, | ||||
|         push_uuid -> Nullable<Text>, | ||||
|         push_token -> Nullable<Text>, | ||||
|         refresh_token -> Text, | ||||
|         twofactor_remember -> Nullable<Text>, | ||||
|  |  | |||
|  | @ -49,6 +49,7 @@ table! { | |||
|         user_uuid -> Text, | ||||
|         name -> Text, | ||||
|         atype -> Integer, | ||||
|         push_uuid -> Nullable<Text>, | ||||
|         push_token -> Nullable<Text>, | ||||
|         refresh_token -> Text, | ||||
|         twofactor_remember -> Nullable<Text>, | ||||
|  |  | |||
|  | @ -49,6 +49,7 @@ table! { | |||
|         user_uuid -> Text, | ||||
|         name -> Text, | ||||
|         atype -> Integer, | ||||
|         push_uuid -> Nullable<Text>, | ||||
|         push_token -> Nullable<Text>, | ||||
|         refresh_token -> Text, | ||||
|         twofactor_remember -> Nullable<Text>, | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue