Coverage for app/backend/src/couchers/servicers/account.py: 92%

333 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +0000

1import json 

2import logging 

3from datetime import UTC, datetime, timedelta 

4from urllib.parse import urlencode 

5 

6import grpc 

7import requests 

8from google.protobuf import empty_pb2 

9from sqlalchemy import select 

10from sqlalchemy.orm import Session 

11from sqlalchemy.sql import exists, func, update 

12from user_agents import parse as user_agents_parse 

13 

14from couchers import urls 

15from couchers.config import config 

16from couchers.constants import PHONE_REVERIFICATION_INTERVAL, SMS_CODE_ATTEMPTS, SMS_CODE_LIFETIME 

17from couchers.context import CouchersContext 

18from couchers.crypto import ( 

19 b64decode, 

20 b64encode, 

21 generate_invite_code, 

22 hash_password, 

23 simple_decrypt, 

24 simple_encrypt, 

25 urlsafe_secure_token, 

26 verify_password, 

27 verify_token, 

28) 

29from couchers.event_log import log_event 

30from couchers.helpers.completed_profile import has_completed_profile 

31from couchers.helpers.geoip import geoip_approximate_location 

32from couchers.helpers.strong_verification import get_strong_verification_fields 

33from couchers.jobs.enqueue import queue_job 

34from couchers.jobs.handlers import finalize_strong_verification 

35from couchers.materialized_views import LiteUser 

36from couchers.metrics import ( 

37 account_deletion_initiations_counter, 

38 strong_verification_data_deletions_counter, 

39 strong_verification_initiations_counter, 

40) 

41from couchers.models import ( 

42 AccountDeletionReason, 

43 AccountDeletionToken, 

44 ContributeOption, 

45 ContributorForm, 

46 HostingStatus, 

47 HostRequest, 

48 HostRequestStatus, 

49 InviteCode, 

50 Message, 

51 ModNote, 

52 ProfilePublicVisibility, 

53 StayProposalKind, 

54 StrongVerificationAttempt, 

55 StrongVerificationAttemptStatus, 

56 StrongVerificationCallbackEvent, 

57 User, 

58 UserSession, 

59 Volunteer, 

60) 

61from couchers.models.notifications import NotificationTopicAction 

62from couchers.notifications.notify import notify 

63from couchers.phone import sms 

64from couchers.phone.check import is_e164_format, is_known_operator 

65from couchers.proto import account_pb2, account_pb2_grpc, auth_pb2, iris_pb2_grpc, notification_data_pb2 

66from couchers.proto.google.api import httpbody_pb2 

67from couchers.proto.internal import internal_pb2, jobs_pb2 

68from couchers.servicers.api import lite_user_to_pb 

69from couchers.servicers.public import format_volunteer_link 

70from couchers.servicers.references import get_pending_references_to_write, reftype2api 

71from couchers.sql import where_moderated_content_visible, where_users_column_visible 

72from couchers.tasks import maybe_send_contributor_form_email, send_email_changed_confirmation_to_new_email 

73from couchers.utils import ( 

74 Timestamp_from_datetime, 

75 create_lang_cookie, 

76 date_to_api, 

77 dt_from_page_token, 

78 dt_to_page_token, 

79 is_valid_email, 

80 now, 

81 to_aware_datetime, 

82) 

83 

84logger = logging.getLogger(__name__) 

85logger.setLevel(logging.DEBUG) 

86 

87contributeoption2sql = { 

88 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None, 

89 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes, 

90 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe, 

91 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no, 

92} 

93 

94contributeoption2api = { 

95 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED, 

96 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES, 

97 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE, 

98 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO, 

99} 

100 

101profilepublicitysetting2sql = { 

102 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None, 

103 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing, 

104 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only, 

105 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited, 

106 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most, 

107 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full, 

108} 

109 

110profilepublicitysetting2api = { 

111 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN, 

112 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING, 

113 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY, 

114 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED, 

115 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST, 

116 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL, 

117} 

118 

119MAX_PAGINATION_LENGTH = 50 

120 

121 

122def mod_note_to_pb(note: ModNote) -> account_pb2.ModNote: 

123 return account_pb2.ModNote( 

124 note_id=note.id, 

125 note_content=note.note_content, 

126 created=Timestamp_from_datetime(note.created), 

127 acknowledged=Timestamp_from_datetime(note.acknowledged) if note.acknowledged else None, 

128 ) 

129 

130 

131def abort_on_invalid_password(password: str, context: CouchersContext) -> None: 

132 """ 

133 Internal utility function: given a password, aborts if password is unforgivably insecure 

134 """ 

135 if len(password) < 8: 

136 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_short") 

137 

138 if len(password) > 256: 

139 # Hey, what are you trying to do? Give us a DDOS attack? 

140 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_long") 

141 

142 # check for the most common weak passwords (not meant to be an exhaustive check!) 

143 if password.lower() in ("password", "12345678", "couchers", "couchers1"): 

144 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "insecure_password") 

145 

146 

147def abort_if_email_reused(context: CouchersContext, session: Session, user: User) -> None: 

148 if session.execute( 

149 select(User.id).where(User.email == user.email).where(User.reserves_email).where(User.id != user.id) 

150 ).first(): 

151 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "undelete_email_reused") 

152 

153 

154def _volunteer_info_to_pb(volunteer: Volunteer, username: str) -> account_pb2.GetMyVolunteerInfoRes: 

155 return account_pb2.GetMyVolunteerInfoRes( 

156 display_name=volunteer.display_name, 

157 display_location=volunteer.display_location, 

158 role=volunteer.role, 

159 started_volunteering=date_to_api(volunteer.started_volunteering), 

160 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None, 

161 show_on_team_page=volunteer.show_on_team_page, 

162 **format_volunteer_link(volunteer, username), 

163 ) 

164 

165 

166class Account(account_pb2_grpc.AccountServicer): 

167 def GetAccountInfo( 

168 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

169 ) -> account_pb2.GetAccountInfoRes: 

170 user, volunteer = session.execute( 

171 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id) 

172 ).one() 

173 

174 # Test experimentation integration - check if user is in the test gate 

175 # Create 'test_growthbook_integration' in GrowthBook to test 

176 test_gate = context.get_boolean_value("test_growthbook_integration", default=False) 

177 logger.info(f"Experimentation gate 'test_growthbook_integration' for user {user.id}: {test_gate}") 

178 

179 # The donation drive (and its banner) is controlled by the donation_drive_start flag: a Unix 

180 # epoch in seconds when a drive is running, or 0/unset when there's no drive. Users who haven't 

181 # donated since the drive started see the banner. 

182 drive_start_epoch = context.get_integer_value("donation_drive_start", 0) 

183 drive_start = datetime.fromtimestamp(drive_start_epoch, tz=UTC) if drive_start_epoch else None 

184 should_show_donation_banner = drive_start is not None and ( 

185 user.last_donated is None or user.last_donated < drive_start 

186 ) 

187 

188 return account_pb2.GetAccountInfoRes( 

189 username=user.username, 

190 email=user.email, 

191 phone=user.phone if (user.phone_is_verified or not user.phone_code_expired) else None, 

192 has_donated=user.last_donated is not None, 

193 phone_verified=user.phone_is_verified, 

194 profile_complete=has_completed_profile(session, user), 

195 my_home_complete=user.has_completed_my_home, 

196 timezone=user.timezone, 

197 is_superuser=user.is_superuser, 

198 ui_language_preference=user.ui_language_preference, 

199 profile_public_visibility=profilepublicitysetting2api[user.public_visibility], 

200 is_volunteer=volunteer is not None, 

201 should_show_donation_banner=should_show_donation_banner, 

202 **get_strong_verification_fields(session, user), 

203 ) 

204 

205 def ChangePasswordV2( 

206 self, request: account_pb2.ChangePasswordV2Req, context: CouchersContext, session: Session 

207 ) -> empty_pb2.Empty: 

208 """ 

209 Changes the user's password. They have to confirm their old password just in case. 

210 

211 If they didn't have an old password previously, then we don't check that. 

212 """ 

213 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

214 

215 if not verify_password(user.hashed_password, request.old_password): 

216 # wrong password 

217 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password") 

218 

219 abort_on_invalid_password(request.new_password, context) 

220 user.hashed_password = hash_password(request.new_password) 

221 

222 session.commit() 

223 

224 notify( 

225 session, 

226 user_id=user.id, 

227 topic_action=NotificationTopicAction.password__change, 

228 key="", 

229 ) 

230 log_event(context, session, "account.password_changed", {}) 

231 

232 return empty_pb2.Empty() 

233 

234 def ChangeEmailV2( 

235 self, request: account_pb2.ChangeEmailV2Req, context: CouchersContext, session: Session 

236 ) -> empty_pb2.Empty: 

237 """ 

238 Change the user's email address. 

239 

240 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one. 

241 

242 Otherwise they need to confirm twice, via an email sent to each of their old and new emails. 

243 

244 In all confirmation emails, the user must click on the confirmation link. 

245 """ 

246 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

247 

248 # check password first 

249 if not verify_password(user.hashed_password, request.password): 

250 # wrong password 

251 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password") 

252 

253 # not a valid email 

254 if not is_valid_email(request.new_email): 

255 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email") 

256 

257 # email already in use (possibly by this user) 

258 if session.execute(select(User.id).where(User.email == request.new_email).where(User.reserves_email)).first(): 

259 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email") 

260 

261 user.new_email = request.new_email 

262 user.new_email_token = urlsafe_secure_token() 

263 user.new_email_token_created = now() 

264 user.new_email_token_expiry = now() + timedelta(hours=2) 

265 

266 send_email_changed_confirmation_to_new_email(context, session, user) 

267 

268 # will still go into old email 

269 notify( 

270 session, 

271 user_id=user.id, 

272 topic_action=NotificationTopicAction.email_address__change, 

273 key="", 

274 data=notification_data_pb2.EmailAddressChange( 

275 new_email=request.new_email, 

276 ), 

277 ) 

278 

279 log_event(context, session, "account.email_change_initiated", {}) 

280 

281 # session autocommit 

282 return empty_pb2.Empty() 

283 

284 def ChangeLanguagePreference( 

285 self, request: account_pb2.ChangeLanguagePreferenceReq, context: CouchersContext, session: Session 

286 ) -> empty_pb2.Empty: 

287 # select the user from the db 

288 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

289 

290 # update the user's preference 

291 user.ui_language_preference = request.ui_language_preference 

292 context.set_cookies(create_lang_cookie(request.ui_language_preference)) 

293 

294 return empty_pb2.Empty() 

295 

296 def FillContributorForm( 

297 self, request: account_pb2.FillContributorFormReq, context: CouchersContext, session: Session 

298 ) -> empty_pb2.Empty: 

299 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

300 

301 form = request.contributor_form 

302 

303 form = ContributorForm( 

304 user_id=user.id, 

305 ideas=form.ideas or None, 

306 features=form.features or None, 

307 experience=form.experience or None, 

308 contribute=contributeoption2sql[form.contribute], 

309 contribute_ways=form.contribute_ways, 

310 expertise=form.expertise or None, 

311 ) 

312 

313 session.add(form) 

314 session.flush() 

315 maybe_send_contributor_form_email(session, form) 

316 

317 user.filled_contributor_form = True 

318 log_event(context, session, "contributor.form_submitted", {"is_filled": form.is_filled}) 

319 

320 return empty_pb2.Empty() 

321 

322 def GetContributorFormInfo( 

323 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

324 ) -> account_pb2.GetContributorFormInfoRes: 

325 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

326 

327 return account_pb2.GetContributorFormInfoRes( 

328 filled_contributor_form=user.filled_contributor_form, 

329 ) 

330 

331 def ChangePhone( 

332 self, request: account_pb2.ChangePhoneReq, context: CouchersContext, session: Session 

333 ) -> empty_pb2.Empty: 

334 phone = request.phone 

335 # early quick validation 

336 if phone and not is_e164_format(phone): 

337 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_phone") 

338 

339 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

340 if user.last_donated is None: 

341 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_donated") 

342 

343 if not phone: 

344 user.phone = None 

345 user.phone_verification_verified = None 

346 user.phone_verification_token = None 

347 user.phone_verification_attempts = 0 

348 return empty_pb2.Empty() 

349 

350 # Removing a number is always allowed; sending a verification SMS is gated. 

351 if not context.get_boolean_value("sms_enabled", default=False): 

352 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "sms_disabled") 

353 

354 if not is_known_operator(phone): 

355 context.abort_with_error_code(grpc.StatusCode.UNIMPLEMENTED, "unrecognized_phone_number") 

356 

357 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL: 

358 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "reverification_too_early") 

359 

360 token = sms.generate_random_code() 

361 result = sms.send_sms(phone, sms.format_message(token)) 

362 

363 if result == "success": 

364 user.phone = phone 

365 user.phone_verification_verified = None 

366 user.phone_verification_token = token 

367 user.phone_verification_sent = now() 

368 user.phone_verification_attempts = 0 

369 

370 notify( 

371 session, 

372 user_id=user.id, 

373 topic_action=NotificationTopicAction.phone_number__change, 

374 key="", 

375 data=notification_data_pb2.PhoneNumberChange( 

376 phone=phone, 

377 ), 

378 ) 

379 

380 return empty_pb2.Empty() 

381 

382 context.abort(grpc.StatusCode.UNIMPLEMENTED, result) 

383 

384 def VerifyPhone( 

385 self, request: account_pb2.VerifyPhoneReq, context: CouchersContext, session: Session 

386 ) -> empty_pb2.Empty: 

387 if not sms.looks_like_a_code(request.token): 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true

388 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "wrong_sms_code") 

389 

390 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

391 if user.phone_verification_token is None: 

392 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification") 

393 

394 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification") 

396 

397 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS: 

398 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "too_many_sms_code_attempts") 

399 

400 if not verify_token(request.token, user.phone_verification_token): 

401 user.phone_verification_attempts += 1 

402 session.commit() 

403 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "wrong_sms_code") 

404 

405 # Delete verifications from everyone else that has this number 

406 session.execute( 

407 update(User) 

408 .where(User.phone == user.phone) 

409 .where(User.id != context.user_id) 

410 .values( 

411 { 

412 "phone_verification_verified": None, 

413 "phone_verification_attempts": 0, 

414 "phone_verification_token": None, 

415 "phone": None, 

416 } 

417 ) 

418 .execution_options(synchronize_session=False) 

419 ) 

420 

421 user.phone_verification_token = None 

422 user.phone_verification_verified = now() 

423 user.phone_verification_attempts = 0 

424 

425 notify( 

426 session, 

427 user_id=user.id, 

428 topic_action=NotificationTopicAction.phone_number__verify, 

429 key="", 

430 data=notification_data_pb2.PhoneNumberVerify( 

431 phone=user.phone, 

432 ), 

433 ) 

434 

435 return empty_pb2.Empty() 

436 

437 def InitiateStrongVerification( 

438 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

439 ) -> account_pb2.InitiateStrongVerificationRes: 

440 if not context.get_boolean_value("strong_verification_enabled", default=False): 

441 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "strong_verification_disabled") 

442 

443 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

444 existing_verification = session.execute( 

445 select(StrongVerificationAttempt) 

446 .where(StrongVerificationAttempt.user_id == user.id) 

447 .where(StrongVerificationAttempt.is_valid) 

448 ).scalar_one_or_none() 

449 if existing_verification: 449 ↛ 450line 449 didn't jump to line 450 because the condition on line 449 was never true

450 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "strong_verification_already_verified") 

451 

452 strong_verification_initiations_counter.labels(user.gender).inc() 

453 log_event(context, session, "verification.strong_initiated", {"gender": user.gender}) 

454 

455 verification_attempt_token = urlsafe_secure_token() 

456 # this is the iris reference data, they will return this on every callback, it also doubles as webhook auth given lack of it otherwise 

457 reference = b64encode( 

458 simple_encrypt( 

459 "iris_callback", 

460 internal_pb2.VerificationReferencePayload( 

461 verification_attempt_token=verification_attempt_token, 

462 user_id=user.id, 

463 ).SerializeToString(), 

464 ) 

465 ) 

466 response = requests.post( 

467 "https://passportreader.app/api/v1/session.create", 

468 auth=(config.IRIS_ID_PUBKEY, config.IRIS_ID_SECRET), 

469 json={ 

470 "callback_url": f"{config.BACKEND_BASE_URL}/iris/webhook", 

471 "face_verification": False, 

472 "passport_only": True, 

473 "reference": reference, 

474 }, 

475 timeout=10, 

476 verify="/etc/ssl/certs/ca-certificates.crt", 

477 ) 

478 

479 if response.status_code != 200: 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true

480 raise Exception(f"Iris didn't return 200: {response.text}") 

481 

482 iris_session_id = response.json()["id"] 

483 token = response.json()["token"] 

484 session.add( 

485 StrongVerificationAttempt( 

486 user_id=user.id, 

487 verification_attempt_token=verification_attempt_token, 

488 iris_session_id=iris_session_id, 

489 iris_token=token, 

490 ) 

491 ) 

492 

493 redirect_params = { 

494 "token": token, 

495 "redirect_url": urls.complete_strong_verification_url( 

496 verification_attempt_token=verification_attempt_token 

497 ), 

498 } 

499 redirect_url = "https://passportreader.app/open?" + urlencode(redirect_params) 

500 

501 return account_pb2.InitiateStrongVerificationRes( 

502 verification_attempt_token=verification_attempt_token, 

503 redirect_url=redirect_url, 

504 ) 

505 

506 def GetStrongVerificationAttemptStatus( 

507 self, request: account_pb2.GetStrongVerificationAttemptStatusReq, context: CouchersContext, session: Session 

508 ) -> account_pb2.GetStrongVerificationAttemptStatusRes: 

509 verification_attempt = session.execute( 

510 select(StrongVerificationAttempt) 

511 .where(StrongVerificationAttempt.user_id == context.user_id) 

512 .where(StrongVerificationAttempt.is_visible) 

513 .where(StrongVerificationAttempt.verification_attempt_token == request.verification_attempt_token) 

514 ).scalar_one_or_none() 

515 if not verification_attempt: 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true

516 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "strong_verification_attempt_not_found") 

517 status_to_pb = { 

518 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED, 

519 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP, 

520 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP, 

521 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND, 

522 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED, 

523 } 

524 return account_pb2.GetStrongVerificationAttemptStatusRes( 

525 status=status_to_pb.get( 

526 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN 

527 ), 

528 ) 

529 

530 def DeleteStrongVerificationData( 

531 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

532 ) -> empty_pb2.Empty: 

533 verification_attempts = ( 

534 session.execute( 

535 select(StrongVerificationAttempt) 

536 .where(StrongVerificationAttempt.user_id == context.user_id) 

537 .where(StrongVerificationAttempt.has_full_data) 

538 ) 

539 .scalars() 

540 .all() 

541 ) 

542 for verification_attempt in verification_attempts: 

543 verification_attempt.status = StrongVerificationAttemptStatus.deleted 

544 verification_attempt.has_full_data = False 

545 verification_attempt.passport_encrypted_data = None 

546 verification_attempt.passport_date_of_birth = None 

547 verification_attempt.passport_sex = None 

548 session.flush() 

549 # double check: 

550 verification_attempts = ( 

551 session.execute( 

552 select(StrongVerificationAttempt) 

553 .where(StrongVerificationAttempt.user_id == context.user_id) 

554 .where(StrongVerificationAttempt.has_full_data) 

555 ) 

556 .scalars() 

557 .all() 

558 ) 

559 assert len(verification_attempts) == 0 

560 

561 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

562 strong_verification_data_deletions_counter.labels(user.gender).inc() 

563 log_event(context, session, "verification.strong_data_deleted", {"gender": user.gender}) 

564 

565 return empty_pb2.Empty() 

566 

567 def DeleteAccount( 

568 self, request: account_pb2.DeleteAccountReq, context: CouchersContext, session: Session 

569 ) -> empty_pb2.Empty: 

570 """ 

571 Triggers email with token to confirm deletion 

572 

573 Frontend should confirm via unique string (i.e. username) before this is called 

574 """ 

575 if not request.confirm: 

576 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_account_delete") 

577 

578 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

579 

580 reason = request.reason.strip() 

581 if reason: 

582 session.add(AccountDeletionReason(user_id=user.id, reason=reason)) 

583 

584 token = AccountDeletionToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now() + timedelta(hours=2)) 

585 

586 notify( 

587 session, 

588 user_id=user.id, 

589 topic_action=NotificationTopicAction.account_deletion__start, 

590 key="", 

591 data=notification_data_pb2.AccountDeletionStart( 

592 deletion_token=token.token, 

593 ), 

594 ) 

595 session.add(token) 

596 

597 account_deletion_initiations_counter.labels(user.gender).inc() 

598 log_event(context, session, "account.deletion_initiated", {"gender": user.gender, "has_reason": bool(reason)}) 

599 

600 return empty_pb2.Empty() 

601 

602 def ListModNotes( 

603 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

604 ) -> account_pb2.ListModNotesRes: 

605 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

606 

607 notes = ( 

608 session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.created.asc())) 

609 .scalars() 

610 .all() 

611 ) 

612 

613 return account_pb2.ListModNotesRes(mod_notes=[mod_note_to_pb(note) for note in notes]) 

614 

615 def ListActiveSessions( 

616 self, request: account_pb2.ListActiveSessionsReq, context: CouchersContext, session: Session 

617 ) -> account_pb2.ListActiveSessionsRes: 

618 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

619 page_token = dt_from_page_token(request.page_token) if request.page_token else now() 

620 

621 user_sessions = ( 

622 session.execute( 

623 select(UserSession) 

624 .where(UserSession.user_id == context.user_id) 

625 .where(UserSession.is_valid) 

626 .where(UserSession.is_api_key == False) 

627 .where(UserSession.last_seen <= page_token) 

628 .order_by(UserSession.last_seen.desc()) 

629 .limit(page_size + 1) 

630 ) 

631 .scalars() 

632 .all() 

633 ) 

634 

635 def _active_session_to_pb(user_session: UserSession) -> account_pb2.ActiveSession: 

636 user_agent = user_agents_parse(user_session.user_agent or "") 

637 return account_pb2.ActiveSession( 

638 created=Timestamp_from_datetime(user_session.created), 

639 expiry=Timestamp_from_datetime(user_session.expiry), 

640 last_seen=Timestamp_from_datetime(user_session.last_seen), 

641 operating_system=user_agent.os.family, 

642 browser=user_agent.browser.family, 

643 device=user_agent.device.family, 

644 approximate_location=geoip_approximate_location(user_session.ip_address) or "Unknown", 

645 is_current_session=user_session.token == context.token, 

646 ) 

647 

648 return account_pb2.ListActiveSessionsRes( 

649 active_sessions=list(map(_active_session_to_pb, user_sessions[:page_size])), 

650 next_page_token=dt_to_page_token(user_sessions[-1].last_seen) if len(user_sessions) > page_size else None, 

651 ) 

652 

653 def LogOutSession( 

654 self, request: account_pb2.LogOutSessionReq, context: CouchersContext, session: Session 

655 ) -> empty_pb2.Empty: 

656 session.execute( 

657 update(UserSession) 

658 .where(UserSession.token != context.token) 

659 .where(UserSession.user_id == context.user_id) 

660 .where(UserSession.is_valid) 

661 .where(UserSession.is_api_key == False) 

662 .where(UserSession.created == to_aware_datetime(request.created)) 

663 .values(expiry=func.now()) 

664 .execution_options(synchronize_session=False) 

665 ) 

666 return empty_pb2.Empty() 

667 

668 def LogOutOtherSessions( 

669 self, request: account_pb2.LogOutOtherSessionsReq, context: CouchersContext, session: Session 

670 ) -> empty_pb2.Empty: 

671 if not request.confirm: 

672 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_logout_other_sessions") 

673 

674 session.execute( 

675 update(UserSession) 

676 .where(UserSession.token != context.token) 

677 .where(UserSession.user_id == context.user_id) 

678 .where(UserSession.is_valid) 

679 .where(UserSession.is_api_key == False) 

680 .values(expiry=func.now()) 

681 .execution_options(synchronize_session=False) 

682 ) 

683 return empty_pb2.Empty() 

684 

685 def SetProfilePublicVisibility( 

686 self, request: account_pb2.SetProfilePublicVisibilityReq, context: CouchersContext, session: Session 

687 ) -> empty_pb2.Empty: 

688 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

689 user.public_visibility = profilepublicitysetting2sql[request.profile_public_visibility] # type: ignore[assignment] 

690 user.has_modified_public_visibility = True 

691 return empty_pb2.Empty() 

692 

693 def CreateInviteCode( 

694 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

695 ) -> account_pb2.CreateInviteCodeRes: 

696 code = generate_invite_code() 

697 session.add(InviteCode(id=code, creator_user_id=context.user_id)) 

698 

699 return account_pb2.CreateInviteCodeRes( 

700 code=code, 

701 url=urls.invite_code_link(code=code), 

702 ) 

703 

704 def DisableInviteCode( 

705 self, request: account_pb2.DisableInviteCodeReq, context: CouchersContext, session: Session 

706 ) -> empty_pb2.Empty: 

707 invite = session.execute( 

708 select(InviteCode).where(InviteCode.id == request.code, InviteCode.creator_user_id == context.user_id) 

709 ).scalar_one_or_none() 

710 

711 if not invite: 711 ↛ 712line 711 didn't jump to line 712 because the condition on line 711 was never true

712 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_found") 

713 

714 invite.disabled = func.now() 

715 session.commit() 

716 

717 return empty_pb2.Empty() 

718 

719 def ListInviteCodes( 

720 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

721 ) -> account_pb2.ListInviteCodesRes: 

722 results = session.execute( 

723 select( 

724 InviteCode.id, 

725 InviteCode.created, 

726 InviteCode.disabled, 

727 func.count(User.id).label("num_users"), 

728 ) 

729 .outerjoin(User, User.invite_code_id == InviteCode.id) 

730 .where(InviteCode.creator_user_id == context.user_id) 

731 .group_by(InviteCode.id, InviteCode.disabled) 

732 .order_by(func.count(User.id).desc(), InviteCode.disabled) 

733 ).all() 

734 

735 return account_pb2.ListInviteCodesRes( 

736 invite_codes=[ 

737 account_pb2.InviteCodeInfo( 

738 code=code_id, 

739 created=Timestamp_from_datetime(created), 

740 disabled=Timestamp_from_datetime(disabled) if disabled else None, 

741 uses=len_users, 

742 url=urls.invite_code_link(code=code_id), 

743 ) 

744 for code_id, created, disabled, len_users in results 

745 ] 

746 ) 

747 

748 def GetReminders( 

749 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

750 ) -> account_pb2.GetRemindersRes: 

751 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

752 

753 # responding to reqs comes first in desc order of when they were received 

754 host_has_sent_message = select(1).where( 

755 Message.conversation_id == HostRequest.conversation_id, Message.author_id == HostRequest.recipient_user_id 

756 ) 

757 query = select(HostRequest.conversation_id, HostRequest.proposal_kind, LiteUser).join( 

758 LiteUser, LiteUser.id == HostRequest.initiator_user_id 

759 ) 

760 query = where_users_column_visible(query, context, HostRequest.initiator_user_id) 

761 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=True) 

762 pending_host_requests = session.execute( 

763 query.where(HostRequest.recipient_user_id == context.user_id) 

764 .where(HostRequest.status == HostRequestStatus.pending) 

765 .where(HostRequest.start_time > func.now()) 

766 .where(~exists(host_has_sent_message)) 

767 .order_by(HostRequest.conversation_id.asc()) 

768 ).all() 

769 reminders = [ 

770 account_pb2.Reminder( 

771 respond_to_public_trip_offer_reminder=account_pb2.RespondToPublicTripOfferReminder( 

772 host_request_id=host_request_id, 

773 host_user=lite_user_to_pb(session, lite_user, context), 

774 ) 

775 ) 

776 if proposal_kind == StayProposalKind.invitation 

777 else account_pb2.Reminder( 

778 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder( 

779 host_request_id=host_request_id, 

780 surfer_user=lite_user_to_pb(session, lite_user, context), 

781 ) 

782 ) 

783 for host_request_id, proposal_kind, lite_user in pending_host_requests 

784 ] 

785 

786 # the initiator needs to confirm accepted requests: the surfer on a normal request, the host on an offer 

787 confirm_query = select(HostRequest.conversation_id, HostRequest.proposal_kind, LiteUser).join( 

788 LiteUser, LiteUser.id == HostRequest.recipient_user_id 

789 ) 

790 confirm_query = where_users_column_visible(confirm_query, context, HostRequest.recipient_user_id) 

791 confirm_query = where_moderated_content_visible(confirm_query, context, HostRequest, is_list_operation=True) 

792 accepted_host_requests = session.execute( 

793 confirm_query.where(HostRequest.initiator_user_id == context.user_id) 

794 .where(HostRequest.status == HostRequestStatus.accepted) 

795 .where(HostRequest.end_time > func.now()) 

796 .order_by(HostRequest.end_time.asc()) 

797 ).all() 

798 reminders += [ 

799 account_pb2.Reminder( 

800 confirm_public_trip_offer_reminder=account_pb2.ConfirmPublicTripOfferReminder( 

801 host_request_id=host_request_id, 

802 surfer_user=lite_user_to_pb(session, lite_user, context), 

803 ) 

804 ) 

805 if proposal_kind == StayProposalKind.invitation 

806 else account_pb2.Reminder( 

807 confirm_host_request_reminder=account_pb2.ConfirmHostRequestReminder( 

808 host_request_id=host_request_id, 

809 host_user=lite_user_to_pb(session, lite_user, context), 

810 ) 

811 ) 

812 for host_request_id, proposal_kind, lite_user in accepted_host_requests 

813 ] 

814 

815 # references come second, in order of deadline, desc 

816 reminders += [ 

817 account_pb2.Reminder( 

818 write_reference_reminder=account_pb2.WriteReferenceReminder( 

819 host_request_id=host_request_id, 

820 reference_type=reftype2api[reference_type], 

821 other_user=lite_user_to_pb(session, lite_user, context), 

822 ) 

823 ) 

824 for host_request_id, reference_type, _, lite_user in get_pending_references_to_write(session, context) 

825 ] 

826 

827 if not has_completed_profile(session, user): 

828 reminders.append(account_pb2.Reminder(complete_profile_reminder=account_pb2.CompleteProfileReminder())) 

829 

830 if user.hosting_status in (HostingStatus.can_host, HostingStatus.maybe) and not user.has_completed_my_home: 

831 reminders.append(account_pb2.Reminder(complete_my_home_reminder=account_pb2.CompleteMyHomeReminder())) 

832 

833 return account_pb2.GetRemindersRes(reminders=reminders) 

834 

835 def GetMyVolunteerInfo( 

836 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

837 ) -> account_pb2.GetMyVolunteerInfoRes: 

838 user, volunteer = session.execute( 

839 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id) 

840 ).one() 

841 if not volunteer: 

842 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer") 

843 return _volunteer_info_to_pb(volunteer, user.username) 

844 

845 def UpdateMyVolunteerInfo( 

846 self, request: account_pb2.UpdateMyVolunteerInfoReq, context: CouchersContext, session: Session 

847 ) -> account_pb2.GetMyVolunteerInfoRes: 

848 user, volunteer = session.execute( 

849 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id) 

850 ).one() 

851 if not volunteer: 

852 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer") 

853 

854 if request.HasField("display_name"): 854 ↛ 857line 854 didn't jump to line 857 because the condition on line 854 was always true

855 volunteer.display_name = request.display_name.value or None 

856 

857 if request.HasField("display_location"): 

858 volunteer.display_location = request.display_location.value or None 

859 

860 if request.HasField("show_on_team_page"): 860 ↛ 861line 860 didn't jump to line 861 because the condition on line 860 was never true

861 volunteer.show_on_team_page = request.show_on_team_page.value 

862 

863 if request.HasField("link_type") or request.HasField("link_text") or request.HasField("link_url"): 863 ↛ 889line 863 didn't jump to line 889 because the condition on line 863 was always true

864 link_type = request.link_type.value or volunteer.link_type 

865 link_text = request.link_text.value or volunteer.link_text 

866 link_url = request.link_url.value or volunteer.link_url 

867 if link_type == "couchers": 867 ↛ 869line 867 didn't jump to line 869 because the condition on line 867 was never true

868 # this is the default 

869 link_type = None 

870 link_text = None 

871 link_url = None 

872 elif link_type == "linkedin": 

873 # this is the username 

874 link_text = link_text 

875 link_url = f"https://www.linkedin.com/in/{link_text}/" 

876 elif link_type == "email": 

877 if not is_valid_email(link_text): 877 ↛ 878line 877 didn't jump to line 878 because the condition on line 877 was never true

878 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email") 

879 link_url = f"mailto:{link_text}" 

880 elif link_type == "website": 880 ↛ 884line 880 didn't jump to line 884 because the condition on line 880 was always true

881 if not link_url.startswith("https://") or "/" in link_text or link_text not in link_url: 881 ↛ 882line 881 didn't jump to line 882 because the condition on line 881 was never true

882 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_website_url") 

883 else: 

884 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_link_type") 

885 volunteer.link_type = link_type 

886 volunteer.link_text = link_text 

887 volunteer.link_url = link_url 

888 

889 session.flush() 

890 

891 return _volunteer_info_to_pb(volunteer, user.username) 

892 

893 

894class Iris(iris_pb2_grpc.IrisServicer): 

895 def Webhook( 

896 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session 

897 ) -> httpbody_pb2.HttpBody: 

898 json_data = json.loads(request.data) 

899 reference_payload = internal_pb2.VerificationReferencePayload.FromString( 

900 simple_decrypt("iris_callback", b64decode(json_data["session_reference"])) 

901 ) 

902 # if we make it past the decrypt, we consider this webhook authenticated 

903 verification_attempt_token = reference_payload.verification_attempt_token 

904 user_id = reference_payload.user_id 

905 

906 verification_attempt = session.execute( 

907 select(StrongVerificationAttempt) 

908 .where(StrongVerificationAttempt.user_id == reference_payload.user_id) 

909 .where(StrongVerificationAttempt.verification_attempt_token == reference_payload.verification_attempt_token) 

910 .where(StrongVerificationAttempt.iris_session_id == json_data["session_id"]) 

911 ).scalar_one() 

912 iris_status = json_data["session_state"] 

913 session.add( 

914 StrongVerificationCallbackEvent( 

915 verification_attempt_id=verification_attempt.id, 

916 iris_status=iris_status, 

917 ) 

918 ) 

919 if iris_status == "INITIATED": 

920 # the user opened the session in the app 

921 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app 

922 elif iris_status == "COMPLETED": 

923 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

924 elif iris_status == "APPROVED": 924 ↛ 934line 924 didn't jump to line 934 because the condition on line 924 was always true

925 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

926 session.commit() 

927 # background worker will go and sort this one out 

928 queue_job( 

929 session, 

930 job=finalize_strong_verification, 

931 payload=jobs_pb2.FinalizeStrongVerificationPayload(verification_attempt_id=verification_attempt.id), 

932 priority=18, 

933 ) 

934 elif iris_status in ["FAILED", "ABORTED", "REJECTED"]: 

935 verification_attempt.status = StrongVerificationAttemptStatus.failed 

936 

937 return httpbody_pb2.HttpBody( 

938 content_type="application/json", 

939 # json.dumps escapes non-ascii characters 

940 data=json.dumps({"success": True}).encode("ascii"), 

941 )