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

324 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 14:14 +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.event_log import log_event 

30from couchers.experimentation import check_gate 

31from couchers.helpers.completed_profile import has_completed_profile 

32from couchers.helpers.geoip import geoip_approximate_location 

33from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification 

34from couchers.jobs.enqueue import queue_job 

35from couchers.jobs.handlers import finalize_strong_verification 

36from couchers.materialized_views import LiteUser 

37from couchers.metrics import ( 

38 account_deletion_initiations_counter, 

39 strong_verification_data_deletions_counter, 

40 strong_verification_initiations_counter, 

41) 

42from couchers.models import ( 

43 AccountDeletionReason, 

44 AccountDeletionToken, 

45 ContributeOption, 

46 ContributorForm, 

47 HostRequest, 

48 HostRequestStatus, 

49 InviteCode, 

50 ModNote, 

51 ProfilePublicVisibility, 

52 StrongVerificationAttempt, 

53 StrongVerificationAttemptStatus, 

54 StrongVerificationCallbackEvent, 

55 User, 

56 UserSession, 

57 Volunteer, 

58) 

59from couchers.models.notifications import NotificationTopicAction 

60from couchers.notifications.notify import notify 

61from couchers.phone import sms 

62from couchers.phone.check import is_e164_format, is_known_operator 

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

64from couchers.proto.google.api import httpbody_pb2 

65from couchers.proto.internal import internal_pb2, jobs_pb2 

66from couchers.servicers.api import lite_user_to_pb 

67from couchers.servicers.public import format_volunteer_link 

68from couchers.servicers.references import get_pending_references_to_write, reftype2api 

69from couchers.sql import where_moderated_content_visible, where_users_column_visible 

70from couchers.tasks import ( 

71 maybe_send_contributor_form_email, 

72 send_account_deletion_report_email, 

73 send_email_changed_confirmation_to_new_email, 

74) 

75from couchers.utils import ( 

76 Timestamp_from_datetime, 

77 create_lang_cookie, 

78 date_to_api, 

79 dt_from_page_token, 

80 dt_to_page_token, 

81 is_valid_email, 

82 now, 

83 to_aware_datetime, 

84) 

85 

86logger = logging.getLogger(__name__) 

87logger.setLevel(logging.DEBUG) 

88 

89contributeoption2sql = { 

90 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None, 

91 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes, 

92 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe, 

93 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no, 

94} 

95 

96contributeoption2api = { 

97 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED, 

98 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES, 

99 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE, 

100 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO, 

101} 

102 

103profilepublicitysetting2sql = { 

104 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None, 

105 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing, 

106 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only, 

107 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited, 

108 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most, 

109 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full, 

110} 

111 

112profilepublicitysetting2api = { 

113 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN, 

114 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING, 

115 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY, 

116 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED, 

117 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST, 

118 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL, 

119} 

120 

121MAX_PAGINATION_LENGTH = 50 

122 

123 

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

125 return account_pb2.ModNote( 

126 note_id=note.id, 

127 note_content=note.note_content, 

128 created=Timestamp_from_datetime(note.created), 

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

130 ) 

131 

132 

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

134 """ 

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

136 """ 

137 if len(password) < 8: 

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

139 

140 if len(password) > 256: 

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

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

143 

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

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

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

147 

148 

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

150 return account_pb2.GetMyVolunteerInfoRes( 

151 display_name=volunteer.display_name, 

152 display_location=volunteer.display_location, 

153 role=volunteer.role, 

154 started_volunteering=date_to_api(volunteer.started_volunteering), 

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

156 show_on_team_page=volunteer.show_on_team_page, 

157 **format_volunteer_link(volunteer, username), 

158 ) 

159 

160 

161class Account(account_pb2_grpc.AccountServicer): 

162 def GetAccountInfo( 

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

164 ) -> account_pb2.GetAccountInfoRes: 

165 user, volunteer = session.execute( 

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

167 ).one() 

168 

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

170 # Create 'test_statsig_integration' in Statsig console to test 

171 test_gate = check_gate(context, "test_statsig_integration") 

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

173 

174 should_show_donation_banner = DONATION_DRIVE_START is not None and ( 

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

176 ) 

177 

178 return account_pb2.GetAccountInfoRes( 

179 username=user.username, 

180 email=user.email, 

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

182 has_donated=user.last_donated is not None, 

183 phone_verified=user.phone_is_verified, 

184 profile_complete=has_completed_profile(session, user), 

185 my_home_complete=user.has_completed_my_home, 

186 timezone=user.timezone, 

187 is_superuser=user.is_superuser, 

188 ui_language_preference=user.ui_language_preference, 

189 profile_public_visibility=profilepublicitysetting2api[user.public_visibility], 

190 is_volunteer=volunteer is not None, 

191 should_show_donation_banner=should_show_donation_banner, 

192 **get_strong_verification_fields(session, user), 

193 ) 

194 

195 def ChangePasswordV2( 

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

197 ) -> empty_pb2.Empty: 

198 """ 

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

200 

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

202 """ 

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

204 

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

206 # wrong password 

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

208 

209 abort_on_invalid_password(request.new_password, context) 

210 user.hashed_password = hash_password(request.new_password) 

211 

212 session.commit() 

213 

214 notify( 

215 session, 

216 user_id=user.id, 

217 topic_action=NotificationTopicAction.password__change, 

218 key="", 

219 ) 

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

221 

222 return empty_pb2.Empty() 

223 

224 def ChangeEmailV2( 

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

226 ) -> empty_pb2.Empty: 

227 """ 

228 Change the user's email address. 

229 

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

231 

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

233 

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

235 """ 

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

237 

238 # check password first 

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

240 # wrong password 

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

242 

243 # not a valid email 

244 if not is_valid_email(request.new_email): 

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

246 

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

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

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

250 

251 user.new_email = request.new_email 

252 user.new_email_token = urlsafe_secure_token() 

253 user.new_email_token_created = now() 

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

255 

256 send_email_changed_confirmation_to_new_email(session, user) 

257 

258 # will still go into old email 

259 notify( 

260 session, 

261 user_id=user.id, 

262 topic_action=NotificationTopicAction.email_address__change, 

263 key="", 

264 data=notification_data_pb2.EmailAddressChange( 

265 new_email=request.new_email, 

266 ), 

267 ) 

268 

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

270 

271 # session autocommit 

272 return empty_pb2.Empty() 

273 

274 def ChangeLanguagePreference( 

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

276 ) -> empty_pb2.Empty: 

277 # select the user from the db 

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

279 

280 # update the user's preference 

281 user.ui_language_preference = request.ui_language_preference 

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

283 

284 return empty_pb2.Empty() 

285 

286 def FillContributorForm( 

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

288 ) -> empty_pb2.Empty: 

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

290 

291 form = request.contributor_form 

292 

293 form = ContributorForm( 

294 user_id=user.id, 

295 ideas=form.ideas or None, 

296 features=form.features or None, 

297 experience=form.experience or None, 

298 contribute=contributeoption2sql[form.contribute], 

299 contribute_ways=form.contribute_ways, 

300 expertise=form.expertise or None, 

301 ) 

302 

303 session.add(form) 

304 session.flush() 

305 maybe_send_contributor_form_email(session, form) 

306 

307 user.filled_contributor_form = True 

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

309 

310 return empty_pb2.Empty() 

311 

312 def GetContributorFormInfo( 

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

314 ) -> account_pb2.GetContributorFormInfoRes: 

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

316 

317 return account_pb2.GetContributorFormInfoRes( 

318 filled_contributor_form=user.filled_contributor_form, 

319 ) 

320 

321 def ChangePhone( 

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

323 ) -> empty_pb2.Empty: 

324 phone = request.phone 

325 # early quick validation 

326 if phone and not is_e164_format(phone): 

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

328 

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

330 if user.last_donated is None: 

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

332 

333 if not phone: 

334 user.phone = None 

335 user.phone_verification_verified = None 

336 user.phone_verification_token = None 

337 user.phone_verification_attempts = 0 

338 return empty_pb2.Empty() 

339 

340 if not is_known_operator(phone): 

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

342 

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

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

345 

346 token = sms.generate_random_code() 

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

348 

349 if result == "success": 

350 user.phone = phone 

351 user.phone_verification_verified = None 

352 user.phone_verification_token = token 

353 user.phone_verification_sent = now() 

354 user.phone_verification_attempts = 0 

355 

356 notify( 

357 session, 

358 user_id=user.id, 

359 topic_action=NotificationTopicAction.phone_number__change, 

360 key="", 

361 data=notification_data_pb2.PhoneNumberChange( 

362 phone=phone, 

363 ), 

364 ) 

365 

366 return empty_pb2.Empty() 

367 

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

369 

370 def VerifyPhone( 

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

372 ) -> empty_pb2.Empty: 

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

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

375 

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

377 if user.phone_verification_token is None: 

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

379 

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

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

382 

383 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS: 

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

385 

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

387 user.phone_verification_attempts += 1 

388 session.commit() 

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

390 

391 # Delete verifications from everyone else that has this number 

392 session.execute( 

393 update(User) 

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

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

396 .values( 

397 { 

398 "phone_verification_verified": None, 

399 "phone_verification_attempts": 0, 

400 "phone_verification_token": None, 

401 "phone": None, 

402 } 

403 ) 

404 .execution_options(synchronize_session=False) 

405 ) 

406 

407 user.phone_verification_token = None 

408 user.phone_verification_verified = now() 

409 user.phone_verification_attempts = 0 

410 

411 notify( 

412 session, 

413 user_id=user.id, 

414 topic_action=NotificationTopicAction.phone_number__verify, 

415 key="", 

416 data=notification_data_pb2.PhoneNumberVerify( 

417 phone=user.phone, 

418 ), 

419 ) 

420 

421 return empty_pb2.Empty() 

422 

423 def InitiateStrongVerification( 

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

425 ) -> account_pb2.InitiateStrongVerificationRes: 

426 if not config["ENABLE_STRONG_VERIFICATION"]: 

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

428 

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

430 existing_verification = session.execute( 

431 select(StrongVerificationAttempt) 

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

433 .where(StrongVerificationAttempt.is_valid) 

434 ).scalar_one_or_none() 

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

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

437 

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

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

440 

441 verification_attempt_token = urlsafe_secure_token() 

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

443 reference = b64encode( 

444 simple_encrypt( 

445 "iris_callback", 

446 internal_pb2.VerificationReferencePayload( 

447 verification_attempt_token=verification_attempt_token, 

448 user_id=user.id, 

449 ).SerializeToString(), 

450 ) 

451 ) 

452 response = requests.post( 

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

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

455 json={ 

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

457 "face_verification": False, 

458 "passport_only": True, 

459 "reference": reference, 

460 }, 

461 timeout=10, 

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

463 ) 

464 

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

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

467 

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

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

470 session.add( 

471 StrongVerificationAttempt( 

472 user_id=user.id, 

473 verification_attempt_token=verification_attempt_token, 

474 iris_session_id=iris_session_id, 

475 iris_token=token, 

476 ) 

477 ) 

478 

479 redirect_params = { 

480 "token": token, 

481 "redirect_url": urls.complete_strong_verification_url( 

482 verification_attempt_token=verification_attempt_token 

483 ), 

484 } 

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

486 

487 return account_pb2.InitiateStrongVerificationRes( 

488 verification_attempt_token=verification_attempt_token, 

489 redirect_url=redirect_url, 

490 ) 

491 

492 def GetStrongVerificationAttemptStatus( 

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

494 ) -> account_pb2.GetStrongVerificationAttemptStatusRes: 

495 verification_attempt = session.execute( 

496 select(StrongVerificationAttempt) 

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

498 .where(StrongVerificationAttempt.is_visible) 

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

500 ).scalar_one_or_none() 

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

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

503 status_to_pb = { 

504 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED, 

505 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP, 

506 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP, 

507 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND, 

508 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED, 

509 } 

510 return account_pb2.GetStrongVerificationAttemptStatusRes( 

511 status=status_to_pb.get( 

512 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN 

513 ), 

514 ) 

515 

516 def DeleteStrongVerificationData( 

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

518 ) -> empty_pb2.Empty: 

519 verification_attempts = ( 

520 session.execute( 

521 select(StrongVerificationAttempt) 

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

523 .where(StrongVerificationAttempt.has_full_data) 

524 ) 

525 .scalars() 

526 .all() 

527 ) 

528 for verification_attempt in verification_attempts: 

529 verification_attempt.status = StrongVerificationAttemptStatus.deleted 

530 verification_attempt.has_full_data = False 

531 verification_attempt.passport_encrypted_data = None 

532 verification_attempt.passport_date_of_birth = None 

533 verification_attempt.passport_sex = None 

534 session.flush() 

535 # double check: 

536 verification_attempts = ( 

537 session.execute( 

538 select(StrongVerificationAttempt) 

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

540 .where(StrongVerificationAttempt.has_full_data) 

541 ) 

542 .scalars() 

543 .all() 

544 ) 

545 assert len(verification_attempts) == 0 

546 

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

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

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

550 

551 return empty_pb2.Empty() 

552 

553 def DeleteAccount( 

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

555 ) -> empty_pb2.Empty: 

556 """ 

557 Triggers email with token to confirm deletion 

558 

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

560 """ 

561 if not request.confirm: 

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

563 

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

565 

566 reason = request.reason.strip() 

567 if reason: 

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

569 session.add(deletion_reason) 

570 session.flush() 

571 send_account_deletion_report_email(session, deletion_reason) 

572 

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

574 

575 notify( 

576 session, 

577 user_id=user.id, 

578 topic_action=NotificationTopicAction.account_deletion__start, 

579 key="", 

580 data=notification_data_pb2.AccountDeletionStart( 

581 deletion_token=token.token, 

582 ), 

583 ) 

584 session.add(token) 

585 

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

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

588 

589 return empty_pb2.Empty() 

590 

591 def ListModNotes( 

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

593 ) -> account_pb2.ListModNotesRes: 

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

595 

596 notes = ( 

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

598 .scalars() 

599 .all() 

600 ) 

601 

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

603 

604 def ListActiveSessions( 

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

606 ) -> account_pb2.ListActiveSessionsRes: 

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

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

609 

610 user_sessions = ( 

611 session.execute( 

612 select(UserSession) 

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

614 .where(UserSession.is_valid) 

615 .where(UserSession.is_api_key == False) 

616 .where(UserSession.last_seen <= page_token) 

617 .order_by(UserSession.last_seen.desc()) 

618 .limit(page_size + 1) 

619 ) 

620 .scalars() 

621 .all() 

622 ) 

623 

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

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

626 return account_pb2.ActiveSession( 

627 created=Timestamp_from_datetime(user_session.created), 

628 expiry=Timestamp_from_datetime(user_session.expiry), 

629 last_seen=Timestamp_from_datetime(user_session.last_seen), 

630 operating_system=user_agent.os.family, 

631 browser=user_agent.browser.family, 

632 device=user_agent.device.family, 

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

634 is_current_session=user_session.token == context.token, 

635 ) 

636 

637 return account_pb2.ListActiveSessionsRes( 

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

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

640 ) 

641 

642 def LogOutSession( 

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

644 ) -> empty_pb2.Empty: 

645 session.execute( 

646 update(UserSession) 

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

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

649 .where(UserSession.is_valid) 

650 .where(UserSession.is_api_key == False) 

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

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

653 .execution_options(synchronize_session=False) 

654 ) 

655 return empty_pb2.Empty() 

656 

657 def LogOutOtherSessions( 

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

659 ) -> empty_pb2.Empty: 

660 if not request.confirm: 

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

662 

663 session.execute( 

664 update(UserSession) 

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

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

667 .where(UserSession.is_valid) 

668 .where(UserSession.is_api_key == False) 

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

670 .execution_options(synchronize_session=False) 

671 ) 

672 return empty_pb2.Empty() 

673 

674 def SetProfilePublicVisibility( 

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

676 ) -> empty_pb2.Empty: 

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

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

679 user.has_modified_public_visibility = True 

680 return empty_pb2.Empty() 

681 

682 def CreateInviteCode( 

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

684 ) -> account_pb2.CreateInviteCodeRes: 

685 code = generate_invite_code() 

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

687 

688 return account_pb2.CreateInviteCodeRes( 

689 code=code, 

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

691 ) 

692 

693 def DisableInviteCode( 

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

695 ) -> empty_pb2.Empty: 

696 invite = session.execute( 

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

698 ).scalar_one_or_none() 

699 

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

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

702 

703 invite.disabled = func.now() 

704 session.commit() 

705 

706 return empty_pb2.Empty() 

707 

708 def ListInviteCodes( 

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

710 ) -> account_pb2.ListInviteCodesRes: 

711 results = session.execute( 

712 select( 

713 InviteCode.id, 

714 InviteCode.created, 

715 InviteCode.disabled, 

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

717 ) 

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

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

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

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

722 ).all() 

723 

724 return account_pb2.ListInviteCodesRes( 

725 invite_codes=[ 

726 account_pb2.InviteCodeInfo( 

727 code=code_id, 

728 created=Timestamp_from_datetime(created), 

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

730 uses=len_users, 

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

732 ) 

733 for code_id, created, disabled, len_users in results 

734 ] 

735 ) 

736 

737 def GetReminders( 

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

739 ) -> account_pb2.GetRemindersRes: 

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

741 

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

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

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

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

746 pending_host_requests = session.execute( 

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

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

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

750 .order_by(HostRequest.conversation_id.asc()) 

751 ).all() 

752 reminders = [ 

753 account_pb2.Reminder( 

754 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder( 

755 host_request_id=host_request_id, 

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

757 ) 

758 ) 

759 for host_request_id, lite_user in pending_host_requests 

760 ] 

761 

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

763 reminders += [ 

764 account_pb2.Reminder( 

765 write_reference_reminder=account_pb2.WriteReferenceReminder( 

766 host_request_id=host_request_id, 

767 reference_type=reftype2api[reference_type], 

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

769 ) 

770 ) 

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

772 ] 

773 

774 if not has_completed_profile(session, user): 

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

776 

777 if not has_strong_verification(session, user): 

778 reminders.append( 

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

780 ) 

781 

782 return account_pb2.GetRemindersRes(reminders=reminders) 

783 

784 def GetMyVolunteerInfo( 

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

786 ) -> account_pb2.GetMyVolunteerInfoRes: 

787 user, volunteer = session.execute( 

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

789 ).one() 

790 if not volunteer: 

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

792 return _volunteer_info_to_pb(volunteer, user.username) 

793 

794 def UpdateMyVolunteerInfo( 

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

796 ) -> account_pb2.GetMyVolunteerInfoRes: 

797 user, volunteer = session.execute( 

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

799 ).one() 

800 if not volunteer: 

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

802 

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

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

805 

806 if request.HasField("display_location"): 

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

808 

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

810 volunteer.show_on_team_page = request.show_on_team_page.value 

811 

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

813 link_type = request.link_type.value or volunteer.link_type 

814 link_text = request.link_text.value or volunteer.link_text 

815 link_url = request.link_url.value or volunteer.link_url 

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

817 # this is the default 

818 link_type = None 

819 link_text = None 

820 link_url = None 

821 elif link_type == "linkedin": 

822 # this is the username 

823 link_text = link_text 

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

825 elif link_type == "email": 

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

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

828 link_url = f"mailto:{link_text}" 

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

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

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

832 else: 

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

834 volunteer.link_type = link_type 

835 volunteer.link_text = link_text 

836 volunteer.link_url = link_url 

837 

838 session.flush() 

839 

840 return _volunteer_info_to_pb(volunteer, user.username) 

841 

842 

843class Iris(iris_pb2_grpc.IrisServicer): 

844 def Webhook( 

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

846 ) -> httpbody_pb2.HttpBody: 

847 json_data = json.loads(request.data) 

848 reference_payload = internal_pb2.VerificationReferencePayload.FromString( 

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

850 ) 

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

852 verification_attempt_token = reference_payload.verification_attempt_token 

853 user_id = reference_payload.user_id 

854 

855 verification_attempt = session.execute( 

856 select(StrongVerificationAttempt) 

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

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

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

860 ).scalar_one() 

861 iris_status = json_data["session_state"] 

862 session.add( 

863 StrongVerificationCallbackEvent( 

864 verification_attempt_id=verification_attempt.id, 

865 iris_status=iris_status, 

866 ) 

867 ) 

868 if iris_status == "INITIATED": 

869 # the user opened the session in the app 

870 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app 

871 elif iris_status == "COMPLETED": 

872 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

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

874 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

875 session.commit() 

876 # background worker will go and sort this one out 

877 queue_job( 

878 session, 

879 job=finalize_strong_verification, 

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

881 priority=8, 

882 ) 

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

884 verification_attempt.status = StrongVerificationAttemptStatus.failed 

885 

886 return httpbody_pb2.HttpBody( 

887 content_type="application/json", 

888 # json.dumps escapes non-ascii characters 

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

890 )