Coverage for src / couchers / servicers / account.py: 91%

316 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-07 16:21 +0000

1import json 

2import logging 

3from datetime import 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 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 DONATION_DRIVE_START, 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.experimentation import check_gate 

30from couchers.helpers.geoip import geoip_approximate_location 

31from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification 

32from couchers.jobs.enqueue import queue_job 

33from couchers.jobs.handlers import finalize_strong_verification 

34from couchers.materialized_views import LiteUser 

35from couchers.metrics import ( 

36 account_deletion_initiations_counter, 

37 strong_verification_data_deletions_counter, 

38 strong_verification_initiations_counter, 

39) 

40from couchers.models import ( 

41 AccountDeletionReason, 

42 AccountDeletionToken, 

43 ContributeOption, 

44 ContributorForm, 

45 HostRequest, 

46 HostRequestStatus, 

47 InviteCode, 

48 ModNote, 

49 ProfilePublicVisibility, 

50 StrongVerificationAttempt, 

51 StrongVerificationAttemptStatus, 

52 StrongVerificationCallbackEvent, 

53 User, 

54 UserSession, 

55 Volunteer, 

56) 

57from couchers.models.notifications import NotificationTopicAction 

58from couchers.notifications.notify import notify 

59from couchers.phone import sms 

60from couchers.phone.check import is_e164_format, is_known_operator 

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

62from couchers.proto.google.api import httpbody_pb2 

63from couchers.proto.internal import jobs_pb2, verification_pb2 

64from couchers.servicers.api import lite_user_to_pb 

65from couchers.servicers.public import format_volunteer_link 

66from couchers.servicers.references import get_pending_references_to_write, reftype2api 

67from couchers.sql import where_moderated_content_visible, where_users_column_visible 

68from couchers.tasks import ( 

69 maybe_send_contributor_form_email, 

70 send_account_deletion_report_email, 

71 send_email_changed_confirmation_to_new_email, 

72) 

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 _volunteer_info_to_pb(volunteer: Volunteer, username: str) -> account_pb2.GetMyVolunteerInfoRes: 

148 return account_pb2.GetMyVolunteerInfoRes( 

149 display_name=volunteer.display_name, 

150 display_location=volunteer.display_location, 

151 role=volunteer.role, 

152 started_volunteering=date_to_api(volunteer.started_volunteering), 

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

154 show_on_team_page=volunteer.show_on_team_page, 

155 **format_volunteer_link(volunteer, username), 

156 ) 

157 

158 

159class Account(account_pb2_grpc.AccountServicer): 

160 def GetAccountInfo( 

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

162 ) -> account_pb2.GetAccountInfoRes: 

163 user, volunteer = session.execute( 

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

165 ).one() 

166 

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

168 # Create 'test_statsig_integration' in Statsig console to test 

169 test_gate = check_gate(context, "test_statsig_integration") 

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

171 

172 should_show_donation_banner = DONATION_DRIVE_START is not None and ( 

173 user.last_donated is None or user.last_donated < DONATION_DRIVE_START 

174 ) 

175 

176 return account_pb2.GetAccountInfoRes( 

177 username=user.username, 

178 email=user.email, 

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

180 has_donated=user.last_donated is not None, 

181 phone_verified=user.phone_is_verified, 

182 profile_complete=user.has_completed_profile, 

183 my_home_complete=user.has_completed_my_home, 

184 timezone=user.timezone, 

185 is_superuser=user.is_superuser, 

186 ui_language_preference=user.ui_language_preference, 

187 profile_public_visibility=profilepublicitysetting2api[user.public_visibility], 

188 is_volunteer=volunteer is not None, 

189 should_show_donation_banner=should_show_donation_banner, 

190 **get_strong_verification_fields(session, user), 

191 ) 

192 

193 def ChangePasswordV2( 

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

195 ) -> empty_pb2.Empty: 

196 """ 

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

198 

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

200 """ 

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

202 

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

204 # wrong password 

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

206 

207 abort_on_invalid_password(request.new_password, context) 

208 user.hashed_password = hash_password(request.new_password) 

209 

210 session.commit() 

211 

212 notify( 

213 session, 

214 user_id=user.id, 

215 topic_action=NotificationTopicAction.password__change, 

216 key="", 

217 ) 

218 

219 return empty_pb2.Empty() 

220 

221 def ChangeEmailV2( 

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

223 ) -> empty_pb2.Empty: 

224 """ 

225 Change the user's email address. 

226 

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

228 

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

230 

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

232 """ 

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

234 

235 # check password first 

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

237 # wrong password 

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

239 

240 # not a valid email 

241 if not is_valid_email(request.new_email): 

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

243 

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

245 if session.execute(select(User).where(User.email == request.new_email)).scalar_one_or_none(): 

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

247 

248 user.new_email = request.new_email 

249 user.new_email_token = urlsafe_secure_token() 

250 user.new_email_token_created = now() 

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

252 

253 send_email_changed_confirmation_to_new_email(session, user) 

254 

255 # will still go into old email 

256 notify( 

257 session, 

258 user_id=user.id, 

259 topic_action=NotificationTopicAction.email_address__change, 

260 key="", 

261 data=notification_data_pb2.EmailAddressChange( 

262 new_email=request.new_email, 

263 ), 

264 ) 

265 

266 # session autocommit 

267 return empty_pb2.Empty() 

268 

269 def ChangeLanguagePreference( 

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

271 ) -> empty_pb2.Empty: 

272 # select the user from the db 

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

274 

275 # update the user's preference 

276 user.ui_language_preference = request.ui_language_preference 

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

278 

279 return empty_pb2.Empty() 

280 

281 def FillContributorForm( 

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

283 ) -> empty_pb2.Empty: 

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

285 

286 form = request.contributor_form 

287 

288 form = ContributorForm( 

289 user=user, 

290 ideas=form.ideas or None, 

291 features=form.features or None, 

292 experience=form.experience or None, 

293 contribute=contributeoption2sql[form.contribute], 

294 contribute_ways=form.contribute_ways, 

295 expertise=form.expertise or None, 

296 ) 

297 

298 session.add(form) 

299 session.flush() 

300 maybe_send_contributor_form_email(session, form) 

301 

302 user.filled_contributor_form = True 

303 

304 return empty_pb2.Empty() 

305 

306 def GetContributorFormInfo( 

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

308 ) -> account_pb2.GetContributorFormInfoRes: 

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

310 

311 return account_pb2.GetContributorFormInfoRes( 

312 filled_contributor_form=user.filled_contributor_form, 

313 ) 

314 

315 def ChangePhone( 

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

317 ) -> empty_pb2.Empty: 

318 phone = request.phone 

319 # early quick validation 

320 if phone and not is_e164_format(phone): 

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

322 

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

324 if user.last_donated is None: 

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

326 

327 if not phone: 

328 user.phone = None 

329 user.phone_verification_verified = None 

330 user.phone_verification_token = None 

331 user.phone_verification_attempts = 0 

332 return empty_pb2.Empty() 

333 

334 if not is_known_operator(phone): 

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

336 

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

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

339 

340 token = sms.generate_random_code() 

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

342 

343 if result == "success": 

344 user.phone = phone 

345 user.phone_verification_verified = None 

346 user.phone_verification_token = token 

347 user.phone_verification_sent = now() 

348 user.phone_verification_attempts = 0 

349 

350 notify( 

351 session, 

352 user_id=user.id, 

353 topic_action=NotificationTopicAction.phone_number__change, 

354 key="", 

355 data=notification_data_pb2.PhoneNumberChange( 

356 phone=phone, 

357 ), 

358 ) 

359 

360 return empty_pb2.Empty() 

361 

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

363 

364 def VerifyPhone( 

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

366 ) -> empty_pb2.Empty: 

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

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

369 

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

371 if user.phone_verification_token is None: 

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

373 

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

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

376 

377 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS: 

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

379 

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

381 user.phone_verification_attempts += 1 

382 session.commit() 

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

384 

385 # Delete verifications from everyone else that has this number 

386 session.execute( 

387 update(User) 

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

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

390 .values( 

391 { 

392 "phone_verification_verified": None, 

393 "phone_verification_attempts": 0, 

394 "phone_verification_token": None, 

395 "phone": None, 

396 } 

397 ) 

398 .execution_options(synchronize_session=False) 

399 ) 

400 

401 user.phone_verification_token = None 

402 user.phone_verification_verified = now() 

403 user.phone_verification_attempts = 0 

404 

405 notify( 

406 session, 

407 user_id=user.id, 

408 topic_action=NotificationTopicAction.phone_number__verify, 

409 key="", 

410 data=notification_data_pb2.PhoneNumberVerify( 

411 phone=user.phone, 

412 ), 

413 ) 

414 

415 return empty_pb2.Empty() 

416 

417 def InitiateStrongVerification( 

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

419 ) -> account_pb2.InitiateStrongVerificationRes: 

420 if not config["ENABLE_STRONG_VERIFICATION"]: 

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

422 

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

424 existing_verification = session.execute( 

425 select(StrongVerificationAttempt) 

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

427 .where(StrongVerificationAttempt.is_valid) 

428 ).scalar_one_or_none() 

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

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

431 

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

433 

434 verification_attempt_token = urlsafe_secure_token() 

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

436 reference = b64encode( 

437 simple_encrypt( 

438 "iris_callback", 

439 verification_pb2.VerificationReferencePayload( 

440 verification_attempt_token=verification_attempt_token, 

441 user_id=user.id, 

442 ).SerializeToString(), 

443 ) 

444 ) 

445 response = requests.post( 

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

447 auth=(config["IRIS_ID_PUBKEY"], config["IRIS_ID_SECRET"]), 

448 json={ 

449 "callback_url": f"{config['BACKEND_BASE_URL']}/iris/webhook", 

450 "face_verification": False, 

451 "passport_only": True, 

452 "reference": reference, 

453 }, 

454 timeout=10, 

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

456 ) 

457 

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

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

460 

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

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

463 session.add( 

464 StrongVerificationAttempt( 

465 user_id=user.id, 

466 verification_attempt_token=verification_attempt_token, 

467 iris_session_id=iris_session_id, 

468 iris_token=token, 

469 ) 

470 ) 

471 

472 redirect_params = { 

473 "token": token, 

474 "redirect_url": urls.complete_strong_verification_url( 

475 verification_attempt_token=verification_attempt_token 

476 ), 

477 } 

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

479 

480 return account_pb2.InitiateStrongVerificationRes( 

481 verification_attempt_token=verification_attempt_token, 

482 redirect_url=redirect_url, 

483 ) 

484 

485 def GetStrongVerificationAttemptStatus( 

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

487 ) -> account_pb2.GetStrongVerificationAttemptStatusRes: 

488 verification_attempt = session.execute( 

489 select(StrongVerificationAttempt) 

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

491 .where(StrongVerificationAttempt.is_visible) 

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

493 ).scalar_one_or_none() 

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

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

496 status_to_pb = { 

497 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED, 

498 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP, 

499 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP, 

500 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND, 

501 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED, 

502 } 

503 return account_pb2.GetStrongVerificationAttemptStatusRes( 

504 status=status_to_pb.get( 

505 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN 

506 ), 

507 ) 

508 

509 def DeleteStrongVerificationData( 

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

511 ) -> empty_pb2.Empty: 

512 verification_attempts = ( 

513 session.execute( 

514 select(StrongVerificationAttempt) 

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

516 .where(StrongVerificationAttempt.has_full_data) 

517 ) 

518 .scalars() 

519 .all() 

520 ) 

521 for verification_attempt in verification_attempts: 

522 verification_attempt.status = StrongVerificationAttemptStatus.deleted 

523 verification_attempt.has_full_data = False 

524 verification_attempt.passport_encrypted_data = None 

525 verification_attempt.passport_date_of_birth = None 

526 verification_attempt.passport_sex = None 

527 session.flush() 

528 # double check: 

529 verification_attempts = ( 

530 session.execute( 

531 select(StrongVerificationAttempt) 

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

533 .where(StrongVerificationAttempt.has_full_data) 

534 ) 

535 .scalars() 

536 .all() 

537 ) 

538 assert len(verification_attempts) == 0 

539 

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

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

542 

543 return empty_pb2.Empty() 

544 

545 def DeleteAccount( 

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

547 ) -> empty_pb2.Empty: 

548 """ 

549 Triggers email with token to confirm deletion 

550 

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

552 """ 

553 if not request.confirm: 

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

555 

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

557 

558 reason = request.reason.strip() 

559 if reason: 

560 deletion_reason = AccountDeletionReason(user_id=user.id, reason=reason) 

561 session.add(deletion_reason) 

562 session.flush() 

563 send_account_deletion_report_email(session, deletion_reason) 

564 

565 token = AccountDeletionToken(token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2)) 

566 

567 notify( 

568 session, 

569 user_id=user.id, 

570 topic_action=NotificationTopicAction.account_deletion__start, 

571 key="", 

572 data=notification_data_pb2.AccountDeletionStart( 

573 deletion_token=token.token, 

574 ), 

575 ) 

576 session.add(token) 

577 

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

579 

580 return empty_pb2.Empty() 

581 

582 def ListModNotes( 

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

584 ) -> account_pb2.ListModNotesRes: 

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

586 

587 notes = ( 

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

589 .scalars() 

590 .all() 

591 ) 

592 

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

594 

595 def ListActiveSessions( 

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

597 ) -> account_pb2.ListActiveSessionsRes: 

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

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

600 

601 user_sessions = ( 

602 session.execute( 

603 select(UserSession) 

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

605 .where(UserSession.is_valid) 

606 .where(UserSession.is_api_key == False) 

607 .where(UserSession.last_seen <= page_token) 

608 .order_by(UserSession.last_seen.desc()) 

609 .limit(page_size + 1) 

610 ) 

611 .scalars() 

612 .all() 

613 ) 

614 

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

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

617 return account_pb2.ActiveSession( 

618 created=Timestamp_from_datetime(user_session.created), 

619 expiry=Timestamp_from_datetime(user_session.expiry), 

620 last_seen=Timestamp_from_datetime(user_session.last_seen), 

621 operating_system=user_agent.os.family, 

622 browser=user_agent.browser.family, 

623 device=user_agent.device.family, 

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

625 is_current_session=user_session.token == context.token, 

626 ) 

627 

628 return account_pb2.ListActiveSessionsRes( 

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

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

631 ) 

632 

633 def LogOutSession( 

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

635 ) -> empty_pb2.Empty: 

636 session.execute( 

637 update(UserSession) 

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

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

640 .where(UserSession.is_valid) 

641 .where(UserSession.is_api_key == False) 

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

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

644 .execution_options(synchronize_session=False) 

645 ) 

646 return empty_pb2.Empty() 

647 

648 def LogOutOtherSessions( 

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

650 ) -> empty_pb2.Empty: 

651 if not request.confirm: 

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

653 

654 session.execute( 

655 update(UserSession) 

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

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

658 .where(UserSession.is_valid) 

659 .where(UserSession.is_api_key == False) 

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

661 .execution_options(synchronize_session=False) 

662 ) 

663 return empty_pb2.Empty() 

664 

665 def SetProfilePublicVisibility( 

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

667 ) -> empty_pb2.Empty: 

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

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

670 user.has_modified_public_visibility = True 

671 return empty_pb2.Empty() 

672 

673 def CreateInviteCode( 

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

675 ) -> account_pb2.CreateInviteCodeRes: 

676 code = generate_invite_code() 

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

678 

679 return account_pb2.CreateInviteCodeRes( 

680 code=code, 

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

682 ) 

683 

684 def DisableInviteCode( 

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

686 ) -> empty_pb2.Empty: 

687 invite = session.execute( 

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

689 ).scalar_one_or_none() 

690 

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

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

693 

694 invite.disabled = func.now() 

695 session.commit() 

696 

697 return empty_pb2.Empty() 

698 

699 def ListInviteCodes( 

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

701 ) -> account_pb2.ListInviteCodesRes: 

702 results = session.execute( 

703 select( 

704 InviteCode.id, 

705 InviteCode.created, 

706 InviteCode.disabled, 

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

708 ) 

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

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

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

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

713 ).all() 

714 

715 return account_pb2.ListInviteCodesRes( 

716 invite_codes=[ 

717 account_pb2.InviteCodeInfo( 

718 code=code_id, 

719 created=Timestamp_from_datetime(created), 

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

721 uses=len_users, 

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

723 ) 

724 for code_id, created, disabled, len_users in results 

725 ] 

726 ) 

727 

728 def GetReminders( 

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

730 ) -> account_pb2.GetRemindersRes: 

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

732 

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

734 query = select(HostRequest.conversation_id, LiteUser).join(LiteUser, LiteUser.id == HostRequest.surfer_user_id) 

735 query = where_users_column_visible(query, context, HostRequest.surfer_user_id) 

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

737 pending_host_requests = session.execute( 

738 query.where(HostRequest.host_user_id == context.user_id) 

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

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

741 .order_by(HostRequest.conversation_id.asc()) 

742 ).all() 

743 reminders = [ 

744 account_pb2.Reminder( 

745 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder( 

746 host_request_id=host_request_id, 

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

748 ) 

749 ) 

750 for host_request_id, lite_user in pending_host_requests 

751 ] 

752 

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

754 reminders += [ 

755 account_pb2.Reminder( 

756 write_reference_reminder=account_pb2.WriteReferenceReminder( 

757 host_request_id=host_request_id, 

758 reference_type=reftype2api[reference_type], 

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

760 ) 

761 ) 

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

763 ] 

764 

765 if not user.has_completed_profile: 

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

767 

768 if not has_strong_verification(session, user): 

769 reminders.append( 

770 account_pb2.Reminder(complete_verification_reminder=account_pb2.CompleteVerificationReminder()) 

771 ) 

772 

773 return account_pb2.GetRemindersRes(reminders=reminders) 

774 

775 def GetMyVolunteerInfo( 

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

777 ) -> account_pb2.GetMyVolunteerInfoRes: 

778 user, volunteer = session.execute( 

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

780 ).one() 

781 if not volunteer: 

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

783 return _volunteer_info_to_pb(volunteer, user.username) 

784 

785 def UpdateMyVolunteerInfo( 

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

787 ) -> account_pb2.GetMyVolunteerInfoRes: 

788 user, volunteer = session.execute( 

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

790 ).one() 

791 if not volunteer: 

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

793 

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

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

796 

797 if request.HasField("display_location"): 

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

799 

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

801 volunteer.show_on_team_page = request.show_on_team_page.value 

802 

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

804 link_type = request.link_type.value or volunteer.link_type 

805 link_text = request.link_text.value or volunteer.link_text 

806 link_url = request.link_url.value or volunteer.link_url 

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

808 # this is the default 

809 link_type = None 

810 link_text = None 

811 link_url = None 

812 elif link_type == "linkedin": 

813 # this is the username 

814 link_text = link_text 

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

816 elif link_type == "email": 

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

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

819 link_url = f"mailto:{link_text}" 

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

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

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

823 else: 

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

825 volunteer.link_type = link_type 

826 volunteer.link_text = link_text 

827 volunteer.link_url = link_url 

828 

829 session.flush() 

830 

831 return _volunteer_info_to_pb(volunteer, user.username) 

832 

833 

834class Iris(iris_pb2_grpc.IrisServicer): 

835 def Webhook( 

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

837 ) -> httpbody_pb2.HttpBody: 

838 json_data = json.loads(request.data) 

839 reference_payload = verification_pb2.VerificationReferencePayload.FromString( 

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

841 ) 

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

843 verification_attempt_token = reference_payload.verification_attempt_token 

844 user_id = reference_payload.user_id 

845 

846 verification_attempt = session.execute( 

847 select(StrongVerificationAttempt) 

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

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

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

851 ).scalar_one() 

852 iris_status = json_data["session_state"] 

853 session.add( 

854 StrongVerificationCallbackEvent( 

855 verification_attempt_id=verification_attempt.id, 

856 iris_status=iris_status, 

857 ) 

858 ) 

859 if iris_status == "INITIATED": 

860 # the user opened the session in the app 

861 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app 

862 elif iris_status == "COMPLETED": 

863 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

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

865 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

866 session.commit() 

867 # background worker will go and sort this one out 

868 queue_job( 

869 session, 

870 job=finalize_strong_verification, 

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

872 priority=8, 

873 ) 

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

875 verification_attempt.status = StrongVerificationAttemptStatus.failed 

876 

877 return httpbody_pb2.HttpBody( 

878 content_type="application/json", 

879 # json.dumps escapes non-ascii characters 

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

881 )