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

317 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-29 02:10 +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.completed_profile import has_completed_profile 

31from couchers.helpers.geoip import geoip_approximate_location 

32from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification 

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 HostRequest, 

47 HostRequestStatus, 

48 InviteCode, 

49 ModNote, 

50 ProfilePublicVisibility, 

51 StrongVerificationAttempt, 

52 StrongVerificationAttemptStatus, 

53 StrongVerificationCallbackEvent, 

54 User, 

55 UserSession, 

56 Volunteer, 

57) 

58from couchers.models.notifications import NotificationTopicAction 

59from couchers.notifications.notify import notify 

60from couchers.phone import sms 

61from couchers.phone.check import is_e164_format, is_known_operator 

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

63from couchers.proto.google.api import httpbody_pb2 

64from couchers.proto.internal import jobs_pb2, verification_pb2 

65from couchers.servicers.api import lite_user_to_pb 

66from couchers.servicers.public import format_volunteer_link 

67from couchers.servicers.references import get_pending_references_to_write, reftype2api 

68from couchers.sql import where_moderated_content_visible, where_users_column_visible 

69from couchers.tasks import ( 

70 maybe_send_contributor_form_email, 

71 send_account_deletion_report_email, 

72 send_email_changed_confirmation_to_new_email, 

73) 

74from couchers.utils import ( 

75 Timestamp_from_datetime, 

76 create_lang_cookie, 

77 date_to_api, 

78 dt_from_page_token, 

79 dt_to_page_token, 

80 is_valid_email, 

81 now, 

82 to_aware_datetime, 

83) 

84 

85logger = logging.getLogger(__name__) 

86logger.setLevel(logging.DEBUG) 

87 

88contributeoption2sql = { 

89 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None, 

90 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes, 

91 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe, 

92 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no, 

93} 

94 

95contributeoption2api = { 

96 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED, 

97 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES, 

98 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE, 

99 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO, 

100} 

101 

102profilepublicitysetting2sql = { 

103 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None, 

104 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing, 

105 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only, 

106 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited, 

107 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most, 

108 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full, 

109} 

110 

111profilepublicitysetting2api = { 

112 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN, 

113 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING, 

114 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY, 

115 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED, 

116 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST, 

117 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL, 

118} 

119 

120MAX_PAGINATION_LENGTH = 50 

121 

122 

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

124 return account_pb2.ModNote( 

125 note_id=note.id, 

126 note_content=note.note_content, 

127 created=Timestamp_from_datetime(note.created), 

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

129 ) 

130 

131 

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

133 """ 

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

135 """ 

136 if len(password) < 8: 

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

138 

139 if len(password) > 256: 

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

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

142 

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

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

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

146 

147 

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

149 return account_pb2.GetMyVolunteerInfoRes( 

150 display_name=volunteer.display_name, 

151 display_location=volunteer.display_location, 

152 role=volunteer.role, 

153 started_volunteering=date_to_api(volunteer.started_volunteering), 

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

155 show_on_team_page=volunteer.show_on_team_page, 

156 **format_volunteer_link(volunteer, username), 

157 ) 

158 

159 

160class Account(account_pb2_grpc.AccountServicer): 

161 def GetAccountInfo( 

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

163 ) -> account_pb2.GetAccountInfoRes: 

164 user, volunteer = session.execute( 

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

166 ).one() 

167 

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

169 # Create 'test_statsig_integration' in Statsig console to test 

170 test_gate = check_gate(context, "test_statsig_integration") 

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

172 

173 should_show_donation_banner = DONATION_DRIVE_START is not None and ( 

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

175 ) 

176 

177 return account_pb2.GetAccountInfoRes( 

178 username=user.username, 

179 email=user.email, 

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

181 has_donated=user.last_donated is not None, 

182 phone_verified=user.phone_is_verified, 

183 profile_complete=has_completed_profile(session, user), 

184 my_home_complete=user.has_completed_my_home, 

185 timezone=user.timezone, 

186 is_superuser=user.is_superuser, 

187 ui_language_preference=user.ui_language_preference, 

188 profile_public_visibility=profilepublicitysetting2api[user.public_visibility], 

189 is_volunteer=volunteer is not None, 

190 should_show_donation_banner=should_show_donation_banner, 

191 **get_strong_verification_fields(session, user), 

192 ) 

193 

194 def ChangePasswordV2( 

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

196 ) -> empty_pb2.Empty: 

197 """ 

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

199 

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

201 """ 

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

203 

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

205 # wrong password 

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

207 

208 abort_on_invalid_password(request.new_password, context) 

209 user.hashed_password = hash_password(request.new_password) 

210 

211 session.commit() 

212 

213 notify( 

214 session, 

215 user_id=user.id, 

216 topic_action=NotificationTopicAction.password__change, 

217 key="", 

218 ) 

219 

220 return empty_pb2.Empty() 

221 

222 def ChangeEmailV2( 

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

224 ) -> empty_pb2.Empty: 

225 """ 

226 Change the user's email address. 

227 

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

229 

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

231 

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

233 """ 

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

235 

236 # check password first 

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

238 # wrong password 

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

240 

241 # not a valid email 

242 if not is_valid_email(request.new_email): 

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

244 

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

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

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

248 

249 user.new_email = request.new_email 

250 user.new_email_token = urlsafe_secure_token() 

251 user.new_email_token_created = now() 

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

253 

254 send_email_changed_confirmation_to_new_email(session, user) 

255 

256 # will still go into old email 

257 notify( 

258 session, 

259 user_id=user.id, 

260 topic_action=NotificationTopicAction.email_address__change, 

261 key="", 

262 data=notification_data_pb2.EmailAddressChange( 

263 new_email=request.new_email, 

264 ), 

265 ) 

266 

267 # session autocommit 

268 return empty_pb2.Empty() 

269 

270 def ChangeLanguagePreference( 

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

272 ) -> empty_pb2.Empty: 

273 # select the user from the db 

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

275 

276 # update the user's preference 

277 user.ui_language_preference = request.ui_language_preference 

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

279 

280 return empty_pb2.Empty() 

281 

282 def FillContributorForm( 

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

284 ) -> empty_pb2.Empty: 

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

286 

287 form = request.contributor_form 

288 

289 form = ContributorForm( 

290 user_id=user.id, 

291 ideas=form.ideas or None, 

292 features=form.features or None, 

293 experience=form.experience or None, 

294 contribute=contributeoption2sql[form.contribute], 

295 contribute_ways=form.contribute_ways, 

296 expertise=form.expertise or None, 

297 ) 

298 

299 session.add(form) 

300 session.flush() 

301 maybe_send_contributor_form_email(session, form) 

302 

303 user.filled_contributor_form = True 

304 

305 return empty_pb2.Empty() 

306 

307 def GetContributorFormInfo( 

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

309 ) -> account_pb2.GetContributorFormInfoRes: 

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

311 

312 return account_pb2.GetContributorFormInfoRes( 

313 filled_contributor_form=user.filled_contributor_form, 

314 ) 

315 

316 def ChangePhone( 

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

318 ) -> empty_pb2.Empty: 

319 phone = request.phone 

320 # early quick validation 

321 if phone and not is_e164_format(phone): 

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

323 

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

325 if user.last_donated is None: 

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

327 

328 if not phone: 

329 user.phone = None 

330 user.phone_verification_verified = None 

331 user.phone_verification_token = None 

332 user.phone_verification_attempts = 0 

333 return empty_pb2.Empty() 

334 

335 if not is_known_operator(phone): 

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

337 

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

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

340 

341 token = sms.generate_random_code() 

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

343 

344 if result == "success": 

345 user.phone = phone 

346 user.phone_verification_verified = None 

347 user.phone_verification_token = token 

348 user.phone_verification_sent = now() 

349 user.phone_verification_attempts = 0 

350 

351 notify( 

352 session, 

353 user_id=user.id, 

354 topic_action=NotificationTopicAction.phone_number__change, 

355 key="", 

356 data=notification_data_pb2.PhoneNumberChange( 

357 phone=phone, 

358 ), 

359 ) 

360 

361 return empty_pb2.Empty() 

362 

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

364 

365 def VerifyPhone( 

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

367 ) -> empty_pb2.Empty: 

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

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

370 

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

372 if user.phone_verification_token is None: 

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

374 

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

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

377 

378 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS: 

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

380 

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

382 user.phone_verification_attempts += 1 

383 session.commit() 

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

385 

386 # Delete verifications from everyone else that has this number 

387 session.execute( 

388 update(User) 

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

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

391 .values( 

392 { 

393 "phone_verification_verified": None, 

394 "phone_verification_attempts": 0, 

395 "phone_verification_token": None, 

396 "phone": None, 

397 } 

398 ) 

399 .execution_options(synchronize_session=False) 

400 ) 

401 

402 user.phone_verification_token = None 

403 user.phone_verification_verified = now() 

404 user.phone_verification_attempts = 0 

405 

406 notify( 

407 session, 

408 user_id=user.id, 

409 topic_action=NotificationTopicAction.phone_number__verify, 

410 key="", 

411 data=notification_data_pb2.PhoneNumberVerify( 

412 phone=user.phone, 

413 ), 

414 ) 

415 

416 return empty_pb2.Empty() 

417 

418 def InitiateStrongVerification( 

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

420 ) -> account_pb2.InitiateStrongVerificationRes: 

421 if not config["ENABLE_STRONG_VERIFICATION"]: 

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

423 

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

425 existing_verification = session.execute( 

426 select(StrongVerificationAttempt) 

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

428 .where(StrongVerificationAttempt.is_valid) 

429 ).scalar_one_or_none() 

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

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

432 

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

434 

435 verification_attempt_token = urlsafe_secure_token() 

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

437 reference = b64encode( 

438 simple_encrypt( 

439 "iris_callback", 

440 verification_pb2.VerificationReferencePayload( 

441 verification_attempt_token=verification_attempt_token, 

442 user_id=user.id, 

443 ).SerializeToString(), 

444 ) 

445 ) 

446 response = requests.post( 

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

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

449 json={ 

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

451 "face_verification": False, 

452 "passport_only": True, 

453 "reference": reference, 

454 }, 

455 timeout=10, 

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

457 ) 

458 

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

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

461 

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

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

464 session.add( 

465 StrongVerificationAttempt( 

466 user_id=user.id, 

467 verification_attempt_token=verification_attempt_token, 

468 iris_session_id=iris_session_id, 

469 iris_token=token, 

470 ) 

471 ) 

472 

473 redirect_params = { 

474 "token": token, 

475 "redirect_url": urls.complete_strong_verification_url( 

476 verification_attempt_token=verification_attempt_token 

477 ), 

478 } 

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

480 

481 return account_pb2.InitiateStrongVerificationRes( 

482 verification_attempt_token=verification_attempt_token, 

483 redirect_url=redirect_url, 

484 ) 

485 

486 def GetStrongVerificationAttemptStatus( 

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

488 ) -> account_pb2.GetStrongVerificationAttemptStatusRes: 

489 verification_attempt = session.execute( 

490 select(StrongVerificationAttempt) 

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

492 .where(StrongVerificationAttempt.is_visible) 

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

494 ).scalar_one_or_none() 

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

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

497 status_to_pb = { 

498 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED, 

499 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP, 

500 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP, 

501 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND, 

502 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED, 

503 } 

504 return account_pb2.GetStrongVerificationAttemptStatusRes( 

505 status=status_to_pb.get( 

506 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN 

507 ), 

508 ) 

509 

510 def DeleteStrongVerificationData( 

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

512 ) -> empty_pb2.Empty: 

513 verification_attempts = ( 

514 session.execute( 

515 select(StrongVerificationAttempt) 

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

517 .where(StrongVerificationAttempt.has_full_data) 

518 ) 

519 .scalars() 

520 .all() 

521 ) 

522 for verification_attempt in verification_attempts: 

523 verification_attempt.status = StrongVerificationAttemptStatus.deleted 

524 verification_attempt.has_full_data = False 

525 verification_attempt.passport_encrypted_data = None 

526 verification_attempt.passport_date_of_birth = None 

527 verification_attempt.passport_sex = None 

528 session.flush() 

529 # double check: 

530 verification_attempts = ( 

531 session.execute( 

532 select(StrongVerificationAttempt) 

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

534 .where(StrongVerificationAttempt.has_full_data) 

535 ) 

536 .scalars() 

537 .all() 

538 ) 

539 assert len(verification_attempts) == 0 

540 

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

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

543 

544 return empty_pb2.Empty() 

545 

546 def DeleteAccount( 

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

548 ) -> empty_pb2.Empty: 

549 """ 

550 Triggers email with token to confirm deletion 

551 

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

553 """ 

554 if not request.confirm: 

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

556 

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

558 

559 reason = request.reason.strip() 

560 if reason: 

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

562 session.add(deletion_reason) 

563 session.flush() 

564 send_account_deletion_report_email(session, deletion_reason) 

565 

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

567 

568 notify( 

569 session, 

570 user_id=user.id, 

571 topic_action=NotificationTopicAction.account_deletion__start, 

572 key="", 

573 data=notification_data_pb2.AccountDeletionStart( 

574 deletion_token=token.token, 

575 ), 

576 ) 

577 session.add(token) 

578 

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

580 

581 return empty_pb2.Empty() 

582 

583 def ListModNotes( 

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

585 ) -> account_pb2.ListModNotesRes: 

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

587 

588 notes = ( 

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

590 .scalars() 

591 .all() 

592 ) 

593 

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

595 

596 def ListActiveSessions( 

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

598 ) -> account_pb2.ListActiveSessionsRes: 

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

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

601 

602 user_sessions = ( 

603 session.execute( 

604 select(UserSession) 

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

606 .where(UserSession.is_valid) 

607 .where(UserSession.is_api_key == False) 

608 .where(UserSession.last_seen <= page_token) 

609 .order_by(UserSession.last_seen.desc()) 

610 .limit(page_size + 1) 

611 ) 

612 .scalars() 

613 .all() 

614 ) 

615 

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

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

618 return account_pb2.ActiveSession( 

619 created=Timestamp_from_datetime(user_session.created), 

620 expiry=Timestamp_from_datetime(user_session.expiry), 

621 last_seen=Timestamp_from_datetime(user_session.last_seen), 

622 operating_system=user_agent.os.family, 

623 browser=user_agent.browser.family, 

624 device=user_agent.device.family, 

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

626 is_current_session=user_session.token == context.token, 

627 ) 

628 

629 return account_pb2.ListActiveSessionsRes( 

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

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

632 ) 

633 

634 def LogOutSession( 

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

636 ) -> empty_pb2.Empty: 

637 session.execute( 

638 update(UserSession) 

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

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

641 .where(UserSession.is_valid) 

642 .where(UserSession.is_api_key == False) 

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

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

645 .execution_options(synchronize_session=False) 

646 ) 

647 return empty_pb2.Empty() 

648 

649 def LogOutOtherSessions( 

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

651 ) -> empty_pb2.Empty: 

652 if not request.confirm: 

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

654 

655 session.execute( 

656 update(UserSession) 

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

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

659 .where(UserSession.is_valid) 

660 .where(UserSession.is_api_key == False) 

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

662 .execution_options(synchronize_session=False) 

663 ) 

664 return empty_pb2.Empty() 

665 

666 def SetProfilePublicVisibility( 

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

668 ) -> empty_pb2.Empty: 

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

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

671 user.has_modified_public_visibility = True 

672 return empty_pb2.Empty() 

673 

674 def CreateInviteCode( 

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

676 ) -> account_pb2.CreateInviteCodeRes: 

677 code = generate_invite_code() 

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

679 

680 return account_pb2.CreateInviteCodeRes( 

681 code=code, 

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

683 ) 

684 

685 def DisableInviteCode( 

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

687 ) -> empty_pb2.Empty: 

688 invite = session.execute( 

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

690 ).scalar_one_or_none() 

691 

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

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

694 

695 invite.disabled = func.now() 

696 session.commit() 

697 

698 return empty_pb2.Empty() 

699 

700 def ListInviteCodes( 

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

702 ) -> account_pb2.ListInviteCodesRes: 

703 results = session.execute( 

704 select( 

705 InviteCode.id, 

706 InviteCode.created, 

707 InviteCode.disabled, 

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

709 ) 

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

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

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

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

714 ).all() 

715 

716 return account_pb2.ListInviteCodesRes( 

717 invite_codes=[ 

718 account_pb2.InviteCodeInfo( 

719 code=code_id, 

720 created=Timestamp_from_datetime(created), 

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

722 uses=len_users, 

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

724 ) 

725 for code_id, created, disabled, len_users in results 

726 ] 

727 ) 

728 

729 def GetReminders( 

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

731 ) -> account_pb2.GetRemindersRes: 

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

733 

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

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

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

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

738 pending_host_requests = session.execute( 

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

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

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

742 .order_by(HostRequest.conversation_id.asc()) 

743 ).all() 

744 reminders = [ 

745 account_pb2.Reminder( 

746 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder( 

747 host_request_id=host_request_id, 

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

749 ) 

750 ) 

751 for host_request_id, lite_user in pending_host_requests 

752 ] 

753 

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

755 reminders += [ 

756 account_pb2.Reminder( 

757 write_reference_reminder=account_pb2.WriteReferenceReminder( 

758 host_request_id=host_request_id, 

759 reference_type=reftype2api[reference_type], 

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

761 ) 

762 ) 

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

764 ] 

765 

766 if not has_completed_profile(session, user): 

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

768 

769 if not has_strong_verification(session, user): 

770 reminders.append( 

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

772 ) 

773 

774 return account_pb2.GetRemindersRes(reminders=reminders) 

775 

776 def GetMyVolunteerInfo( 

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

778 ) -> account_pb2.GetMyVolunteerInfoRes: 

779 user, volunteer = session.execute( 

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

781 ).one() 

782 if not volunteer: 

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

784 return _volunteer_info_to_pb(volunteer, user.username) 

785 

786 def UpdateMyVolunteerInfo( 

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

788 ) -> account_pb2.GetMyVolunteerInfoRes: 

789 user, volunteer = session.execute( 

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

791 ).one() 

792 if not volunteer: 

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

794 

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

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

797 

798 if request.HasField("display_location"): 

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

800 

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

802 volunteer.show_on_team_page = request.show_on_team_page.value 

803 

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

805 link_type = request.link_type.value or volunteer.link_type 

806 link_text = request.link_text.value or volunteer.link_text 

807 link_url = request.link_url.value or volunteer.link_url 

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

809 # this is the default 

810 link_type = None 

811 link_text = None 

812 link_url = None 

813 elif link_type == "linkedin": 

814 # this is the username 

815 link_text = link_text 

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

817 elif link_type == "email": 

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

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

820 link_url = f"mailto:{link_text}" 

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

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

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

824 else: 

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

826 volunteer.link_type = link_type 

827 volunteer.link_text = link_text 

828 volunteer.link_url = link_url 

829 

830 session.flush() 

831 

832 return _volunteer_info_to_pb(volunteer, user.username) 

833 

834 

835class Iris(iris_pb2_grpc.IrisServicer): 

836 def Webhook( 

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

838 ) -> httpbody_pb2.HttpBody: 

839 json_data = json.loads(request.data) 

840 reference_payload = verification_pb2.VerificationReferencePayload.FromString( 

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

842 ) 

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

844 verification_attempt_token = reference_payload.verification_attempt_token 

845 user_id = reference_payload.user_id 

846 

847 verification_attempt = session.execute( 

848 select(StrongVerificationAttempt) 

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

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

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

852 ).scalar_one() 

853 iris_status = json_data["session_state"] 

854 session.add( 

855 StrongVerificationCallbackEvent( 

856 verification_attempt_id=verification_attempt.id, 

857 iris_status=iris_status, 

858 ) 

859 ) 

860 if iris_status == "INITIATED": 

861 # the user opened the session in the app 

862 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app 

863 elif iris_status == "COMPLETED": 

864 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

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

866 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

867 session.commit() 

868 # background worker will go and sort this one out 

869 queue_job( 

870 session, 

871 job=finalize_strong_verification, 

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

873 priority=8, 

874 ) 

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

876 verification_attempt.status = StrongVerificationAttemptStatus.failed 

877 

878 return httpbody_pb2.HttpBody( 

879 content_type="application/json", 

880 # json.dumps escapes non-ascii characters 

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

882 )