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

310 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-15 14:48 +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.orm import Session 

10from sqlalchemy.sql import func, update 

11from user_agents import parse as user_agents_parse 

12 

13from couchers import urls 

14from couchers.config import config 

15from couchers.constants import DONATION_DRIVE_START, PHONE_REVERIFICATION_INTERVAL, SMS_CODE_ATTEMPTS, SMS_CODE_LIFETIME 

16from couchers.context import CouchersContext 

17from couchers.crypto import ( 

18 b64decode, 

19 b64encode, 

20 generate_invite_code, 

21 hash_password, 

22 simple_decrypt, 

23 simple_encrypt, 

24 urlsafe_secure_token, 

25 verify_password, 

26 verify_token, 

27) 

28from couchers.experimentation import check_gate 

29from couchers.helpers.geoip import geoip_approximate_location 

30from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification 

31from couchers.jobs.enqueue import queue_job 

32from couchers.materialized_views import LiteUser 

33from couchers.metrics import ( 

34 account_deletion_initiations_counter, 

35 strong_verification_data_deletions_counter, 

36 strong_verification_initiations_counter, 

37) 

38from couchers.models import ( 

39 AccountDeletionReason, 

40 AccountDeletionToken, 

41 ContributeOption, 

42 ContributorForm, 

43 HostRequest, 

44 HostRequestStatus, 

45 InviteCode, 

46 ModNote, 

47 ProfilePublicVisibility, 

48 StrongVerificationAttempt, 

49 StrongVerificationAttemptStatus, 

50 StrongVerificationCallbackEvent, 

51 User, 

52 UserSession, 

53 Volunteer, 

54) 

55from couchers.notifications.notify import notify 

56from couchers.phone import sms 

57from couchers.phone.check import is_e164_format, is_known_operator 

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

59from couchers.proto.google.api import httpbody_pb2 

60from couchers.proto.internal import jobs_pb2, verification_pb2 

61from couchers.servicers.api import lite_user_to_pb 

62from couchers.servicers.public import format_volunteer_link 

63from couchers.servicers.references import get_pending_references_to_write, reftype2api 

64from couchers.sql import couchers_select as select 

65from couchers.tasks import ( 

66 maybe_send_contributor_form_email, 

67 send_account_deletion_report_email, 

68 send_email_changed_confirmation_to_new_email, 

69) 

70from couchers.utils import ( 

71 Timestamp_from_datetime, 

72 create_lang_cookie, 

73 date_to_api, 

74 dt_from_page_token, 

75 dt_to_page_token, 

76 is_valid_email, 

77 now, 

78 to_aware_datetime, 

79) 

80 

81logger = logging.getLogger(__name__) 

82logger.setLevel(logging.DEBUG) 

83 

84contributeoption2sql = { 

85 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None, 

86 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes, 

87 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe, 

88 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no, 

89} 

90 

91contributeoption2api = { 

92 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED, 

93 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES, 

94 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE, 

95 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO, 

96} 

97 

98profilepublicitysetting2sql = { 

99 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None, 

100 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing, 

101 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only, 

102 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited, 

103 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most, 

104 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full, 

105} 

106 

107profilepublicitysetting2api = { 

108 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN, 

109 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING, 

110 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY, 

111 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED, 

112 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST, 

113 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL, 

114} 

115 

116MAX_PAGINATION_LENGTH = 50 

117 

118 

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

120 return account_pb2.ModNote( 

121 note_id=note.id, 

122 note_content=note.note_content, 

123 created=Timestamp_from_datetime(note.created), 

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

125 ) 

126 

127 

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

129 """ 

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

131 """ 

132 if len(password) < 8: 

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

134 

135 if len(password) > 256: 

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

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

138 

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

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

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

142 

143 

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

145 return account_pb2.GetMyVolunteerInfoRes( 

146 display_name=volunteer.display_name, 

147 display_location=volunteer.display_location, 

148 role=volunteer.role, 

149 started_volunteering=date_to_api(volunteer.started_volunteering), 

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

151 show_on_team_page=volunteer.show_on_team_page, 

152 **format_volunteer_link(volunteer, username), 

153 ) 

154 

155 

156class Account(account_pb2_grpc.AccountServicer): 

157 def GetAccountInfo( 

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

159 ) -> account_pb2.GetAccountInfoRes: 

160 user, volunteer = session.execute( 

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

162 ).one() 

163 

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

165 # Create 'test_statsig_integration' in Statsig console to test 

166 test_gate = check_gate(context, "test_statsig_integration") 

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

168 

169 should_show_donation_banner = DONATION_DRIVE_START is not None and ( 

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

171 ) 

172 

173 return account_pb2.GetAccountInfoRes( 

174 username=user.username, 

175 email=user.email, 

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

177 has_donated=user.last_donated is not None, 

178 phone_verified=user.phone_is_verified, 

179 profile_complete=user.has_completed_profile, 

180 my_home_complete=user.has_completed_my_home, 

181 timezone=user.timezone, 

182 is_superuser=user.is_superuser, 

183 ui_language_preference=user.ui_language_preference, 

184 profile_public_visibility=profilepublicitysetting2api[user.public_visibility], 

185 is_volunteer=volunteer is not None, 

186 should_show_donation_banner=should_show_donation_banner, 

187 **get_strong_verification_fields(session, user), 

188 ) 

189 

190 def ChangePasswordV2( 

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

192 ) -> empty_pb2.Empty: 

193 """ 

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

195 

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

197 """ 

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

199 

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

201 # wrong password 

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

203 

204 abort_on_invalid_password(request.new_password, context) 

205 user.hashed_password = hash_password(request.new_password) 

206 

207 session.commit() 

208 

209 notify( 

210 session, 

211 user_id=user.id, 

212 topic_action="password:change", 

213 key="", 

214 ) 

215 

216 return empty_pb2.Empty() 

217 

218 def ChangeEmailV2( 

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

220 ) -> empty_pb2.Empty: 

221 """ 

222 Change the user's email address. 

223 

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

225 

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

227 

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

229 """ 

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

231 

232 # check password first 

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

234 # wrong password 

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

236 

237 # not a valid email 

238 if not is_valid_email(request.new_email): 

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

240 

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

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

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

244 

245 user.new_email = request.new_email 

246 user.new_email_token = urlsafe_secure_token() 

247 user.new_email_token_created = now() 

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

249 

250 send_email_changed_confirmation_to_new_email(session, user) 

251 

252 # will still go into old email 

253 notify( 

254 session, 

255 user_id=user.id, 

256 topic_action="email_address:change", 

257 key="", 

258 data=notification_data_pb2.EmailAddressChange( 

259 new_email=request.new_email, 

260 ), 

261 ) 

262 

263 # session autocommit 

264 return empty_pb2.Empty() 

265 

266 def ChangeLanguagePreference( 

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

268 ) -> empty_pb2.Empty: 

269 # select the user from the db 

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

271 

272 # update the user's preference 

273 user.ui_language_preference = request.ui_language_preference 

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

275 

276 return empty_pb2.Empty() 

277 

278 def FillContributorForm( 

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

280 ) -> empty_pb2.Empty: 

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

282 

283 form = request.contributor_form 

284 

285 form = ContributorForm( 

286 user=user, 

287 ideas=form.ideas or None, 

288 features=form.features or None, 

289 experience=form.experience or None, 

290 contribute=contributeoption2sql[form.contribute], 

291 contribute_ways=form.contribute_ways, 

292 expertise=form.expertise or None, 

293 ) 

294 

295 session.add(form) 

296 session.flush() 

297 maybe_send_contributor_form_email(session, form) 

298 

299 user.filled_contributor_form = True 

300 

301 return empty_pb2.Empty() 

302 

303 def GetContributorFormInfo( 

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

305 ) -> account_pb2.GetContributorFormInfoRes: 

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

307 

308 return account_pb2.GetContributorFormInfoRes( 

309 filled_contributor_form=user.filled_contributor_form, 

310 ) 

311 

312 def ChangePhone( 

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

314 ) -> empty_pb2.Empty: 

315 phone = request.phone 

316 # early quick validation 

317 if phone and not is_e164_format(phone): 

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

319 

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

321 if user.last_donated is None: 

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

323 

324 if not phone: 

325 user.phone = None 

326 user.phone_verification_verified = None 

327 user.phone_verification_token = None 

328 user.phone_verification_attempts = 0 

329 return empty_pb2.Empty() 

330 

331 if not is_known_operator(phone): 

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

333 

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

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

336 

337 token = sms.generate_random_code() 

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

339 

340 if result == "success": 

341 user.phone = phone 

342 user.phone_verification_verified = None 

343 user.phone_verification_token = token 

344 user.phone_verification_sent = now() 

345 user.phone_verification_attempts = 0 

346 

347 notify( 

348 session, 

349 user_id=user.id, 

350 topic_action="phone_number:change", 

351 key="", 

352 data=notification_data_pb2.PhoneNumberChange( 

353 phone=phone, 

354 ), 

355 ) 

356 

357 return empty_pb2.Empty() 

358 

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

360 

361 def VerifyPhone( 

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

363 ) -> empty_pb2.Empty: 

364 if not sms.looks_like_a_code(request.token): 

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

366 

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

368 if user.phone_verification_token is None: 

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

370 

371 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME: 

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

373 

374 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS: 

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

376 

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

378 user.phone_verification_attempts += 1 

379 session.commit() 

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

381 

382 # Delete verifications from everyone else that has this number 

383 session.execute( 

384 update(User) 

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

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

387 .values( 

388 { 

389 "phone_verification_verified": None, 

390 "phone_verification_attempts": 0, 

391 "phone_verification_token": None, 

392 "phone": None, 

393 } 

394 ) 

395 .execution_options(synchronize_session=False) 

396 ) 

397 

398 user.phone_verification_token = None 

399 user.phone_verification_verified = now() 

400 user.phone_verification_attempts = 0 

401 

402 notify( 

403 session, 

404 user_id=user.id, 

405 topic_action="phone_number:verify", 

406 key="", 

407 data=notification_data_pb2.PhoneNumberVerify( 

408 phone=user.phone, 

409 ), 

410 ) 

411 

412 return empty_pb2.Empty() 

413 

414 def InitiateStrongVerification( 

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

416 ) -> account_pb2.InitiateStrongVerificationRes: 

417 if not config["ENABLE_STRONG_VERIFICATION"]: 

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

419 

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

421 existing_verification: StrongVerificationAttempt = session.execute( 

422 select(StrongVerificationAttempt) 

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

424 .where(StrongVerificationAttempt.is_valid) 

425 ).scalar_one_or_none() 

426 if existing_verification: 

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

428 

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

430 

431 verification_attempt_token = urlsafe_secure_token() 

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

433 reference = b64encode( 

434 simple_encrypt( 

435 "iris_callback", 

436 verification_pb2.VerificationReferencePayload( 

437 verification_attempt_token=verification_attempt_token, 

438 user_id=user.id, 

439 ).SerializeToString(), 

440 ) 

441 ) 

442 response = requests.post( 

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

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

445 json={ 

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

447 "face_verification": False, 

448 "passport_only": True, 

449 "reference": reference, 

450 }, 

451 timeout=10, 

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

453 ) 

454 

455 if response.status_code != 200: 

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

457 

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

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

460 session.add( 

461 StrongVerificationAttempt( 

462 user_id=user.id, 

463 verification_attempt_token=verification_attempt_token, 

464 iris_session_id=iris_session_id, 

465 iris_token=token, 

466 ) 

467 ) 

468 

469 redirect_params = { 

470 "token": token, 

471 "redirect_url": urls.complete_strong_verification_url( 

472 verification_attempt_token=verification_attempt_token 

473 ), 

474 } 

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

476 

477 return account_pb2.InitiateStrongVerificationRes( 

478 verification_attempt_token=verification_attempt_token, 

479 redirect_url=redirect_url, 

480 ) 

481 

482 def GetStrongVerificationAttemptStatus( 

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

484 ) -> account_pb2.GetStrongVerificationAttemptStatusRes: 

485 verification_attempt = session.execute( 

486 select(StrongVerificationAttempt) 

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

488 .where(StrongVerificationAttempt.is_visible) 

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

490 ).scalar_one_or_none() 

491 if not verification_attempt: 

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

493 status_to_pb = { 

494 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED, 

495 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP, 

496 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP, 

497 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND, 

498 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED, 

499 } 

500 return account_pb2.GetStrongVerificationAttemptStatusRes( 

501 status=status_to_pb.get( 

502 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN 

503 ), 

504 ) 

505 

506 def DeleteStrongVerificationData( 

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

508 ) -> empty_pb2.Empty: 

509 verification_attempts = ( 

510 session.execute( 

511 select(StrongVerificationAttempt) 

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

513 .where(StrongVerificationAttempt.has_full_data) 

514 ) 

515 .scalars() 

516 .all() 

517 ) 

518 for verification_attempt in verification_attempts: 

519 verification_attempt.status = StrongVerificationAttemptStatus.deleted 

520 verification_attempt.has_full_data = False 

521 verification_attempt.passport_encrypted_data = None 

522 verification_attempt.passport_date_of_birth = None 

523 verification_attempt.passport_sex = None 

524 session.flush() 

525 # double check: 

526 verification_attempts = ( 

527 session.execute( 

528 select(StrongVerificationAttempt) 

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

530 .where(StrongVerificationAttempt.has_full_data) 

531 ) 

532 .scalars() 

533 .all() 

534 ) 

535 assert len(verification_attempts) == 0 

536 

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

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

539 

540 return empty_pb2.Empty() 

541 

542 def DeleteAccount( 

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

544 ) -> empty_pb2.Empty: 

545 """ 

546 Triggers email with token to confirm deletion 

547 

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

549 """ 

550 if not request.confirm: 

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

552 

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

554 

555 reason = request.reason.strip() 

556 if reason: 

557 reason = AccountDeletionReason(user_id=user.id, reason=reason) 

558 session.add(reason) 

559 session.flush() 

560 send_account_deletion_report_email(session, reason) 

561 

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

563 

564 notify( 

565 session, 

566 user_id=user.id, 

567 topic_action="account_deletion:start", 

568 key="", 

569 data=notification_data_pb2.AccountDeletionStart( 

570 deletion_token=token.token, 

571 ), 

572 ) 

573 session.add(token) 

574 

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

576 

577 return empty_pb2.Empty() 

578 

579 def ListModNotes( 

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

581 ) -> account_pb2.ListModNotesRes: 

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

583 

584 notes = ( 

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

586 .scalars() 

587 .all() 

588 ) 

589 

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

591 

592 def ListActiveSessions( 

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

594 ) -> account_pb2.ListActiveSessionsRes: 

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

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

597 

598 user_sessions = ( 

599 session.execute( 

600 select(UserSession) 

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

602 .where(UserSession.is_valid) 

603 .where(UserSession.is_api_key == False) 

604 .where(UserSession.last_seen <= page_token) 

605 .order_by(UserSession.last_seen.desc()) 

606 .limit(page_size + 1) 

607 ) 

608 .scalars() 

609 .all() 

610 ) 

611 

612 def _active_session_to_pb(user_session): 

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

614 return account_pb2.ActiveSession( 

615 created=Timestamp_from_datetime(user_session.created), 

616 expiry=Timestamp_from_datetime(user_session.expiry), 

617 last_seen=Timestamp_from_datetime(user_session.last_seen), 

618 operating_system=user_agent.os.family, 

619 browser=user_agent.browser.family, 

620 device=user_agent.device.family, 

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

622 is_current_session=user_session.token == context.token, 

623 ) 

624 

625 return account_pb2.ListActiveSessionsRes( 

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

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

628 ) 

629 

630 def LogOutSession( 

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

632 ) -> empty_pb2.Empty: 

633 session.execute( 

634 update(UserSession) 

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

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

637 .where(UserSession.is_valid) 

638 .where(UserSession.is_api_key == False) 

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

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

641 .execution_options(synchronize_session=False) 

642 ) 

643 return empty_pb2.Empty() 

644 

645 def LogOutOtherSessions( 

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

647 ) -> empty_pb2.Empty: 

648 if not request.confirm: 

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

650 

651 session.execute( 

652 update(UserSession) 

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

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

655 .where(UserSession.is_valid) 

656 .where(UserSession.is_api_key == False) 

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

658 .execution_options(synchronize_session=False) 

659 ) 

660 return empty_pb2.Empty() 

661 

662 def SetProfilePublicVisibility(self, request, context, session): 

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

664 user.public_visibility = profilepublicitysetting2sql[request.profile_public_visibility] 

665 user.has_modified_public_visibility = True 

666 return empty_pb2.Empty() 

667 

668 def CreateInviteCode( 

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

670 ) -> account_pb2.CreateInviteCodeRes: 

671 code = generate_invite_code() 

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

673 

674 return account_pb2.CreateInviteCodeRes( 

675 code=code, 

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

677 ) 

678 

679 def DisableInviteCode( 

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

681 ) -> empty_pb2.Empty: 

682 invite = session.execute( 

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

684 ).scalar_one_or_none() 

685 

686 if not invite: 

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

688 

689 invite.disabled = func.now() 

690 session.commit() 

691 

692 return empty_pb2.Empty() 

693 

694 def ListInviteCodes( 

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

696 ) -> account_pb2.ListInviteCodesRes: 

697 results = session.execute( 

698 select( 

699 InviteCode.id, 

700 InviteCode.created, 

701 InviteCode.disabled, 

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

703 ) 

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

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

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

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

708 ).all() 

709 

710 return account_pb2.ListInviteCodesRes( 

711 invite_codes=[ 

712 account_pb2.InviteCodeInfo( 

713 code=code_id, 

714 created=Timestamp_from_datetime(created), 

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

716 uses=len_users, 

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

718 ) 

719 for code_id, created, disabled, len_users in results 

720 ] 

721 ) 

722 

723 def GetReminders( 

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

725 ) -> account_pb2.GetRemindersRes: 

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

727 

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

729 pending_host_requests = session.execute( 

730 select(HostRequest.conversation_id, LiteUser) 

731 .join(LiteUser, LiteUser.id == HostRequest.surfer_user_id) 

732 .where_users_column_visible(context, HostRequest.surfer_user_id) 

733 .where_moderated_content_visible(context, HostRequest, is_list_operation=True) 

734 .where(HostRequest.host_user_id == context.user_id) 

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

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

737 .order_by(HostRequest.conversation_id.asc()) 

738 ).all() 

739 reminders = [ 

740 account_pb2.Reminder( 

741 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder( 

742 host_request_id=host_request_id, 

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

744 ) 

745 ) 

746 for host_request_id, lite_user in pending_host_requests 

747 ] 

748 

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

750 reminders += [ 

751 account_pb2.Reminder( 

752 write_reference_reminder=account_pb2.WriteReferenceReminder( 

753 host_request_id=host_request_id, 

754 reference_type=reftype2api[reference_type], 

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

756 ) 

757 ) 

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

759 ] 

760 

761 if not user.has_completed_profile: 

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

763 

764 if not has_strong_verification(session, user): 

765 reminders.append( 

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

767 ) 

768 

769 return account_pb2.GetRemindersRes(reminders=reminders) 

770 

771 def GetMyVolunteerInfo( 

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

773 ) -> account_pb2.GetMyVolunteerInfoRes: 

774 user, volunteer = session.execute( 

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

776 ).one() 

777 if not volunteer: 

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

779 return _volunteer_info_to_pb(volunteer, user.username) 

780 

781 def UpdateMyVolunteerInfo( 

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

783 ) -> account_pb2.GetMyVolunteerInfoRes: 

784 user, volunteer = session.execute( 

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

786 ).one() 

787 if not volunteer: 

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

789 

790 if request.HasField("display_name"): 

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

792 

793 if request.HasField("display_location"): 

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

795 

796 if request.HasField("show_on_team_page"): 

797 volunteer.show_on_team_page = request.show_on_team_page.value 

798 

799 if request.HasField("link_type") or request.HasField("link_text") or request.HasField("link_url"): 

800 link_type = request.link_type.value or volunteer.link_type 

801 link_text = request.link_text.value or volunteer.link_text 

802 link_url = request.link_url.value or volunteer.link_url 

803 if link_type == "couchers": 

804 # this is the default 

805 link_type = None 

806 link_text = None 

807 link_url = None 

808 elif link_type == "linkedin": 

809 # this is the username 

810 link_text = link_text 

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

812 elif link_type == "email": 

813 if not is_valid_email(link_text): 

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

815 link_url = f"mailto:{link_text}" 

816 elif link_type == "website": 

817 if not link_url.startswith("https://") or "/" in link_text or link_text not in link_url: 

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

819 else: 

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

821 volunteer.link_type = link_type 

822 volunteer.link_text = link_text 

823 volunteer.link_url = link_url 

824 

825 session.flush() 

826 

827 return _volunteer_info_to_pb(volunteer, user.username) 

828 

829 

830class Iris(iris_pb2_grpc.IrisServicer): 

831 def Webhook( 

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

833 ) -> httpbody_pb2.HttpBody: 

834 json_data = json.loads(request.data) 

835 reference_payload = verification_pb2.VerificationReferencePayload.FromString( 

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

837 ) 

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

839 verification_attempt_token = reference_payload.verification_attempt_token 

840 user_id = reference_payload.user_id 

841 

842 verification_attempt = session.execute( 

843 select(StrongVerificationAttempt) 

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

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

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

847 ).scalar_one() 

848 iris_status = json_data["session_state"] 

849 session.add( 

850 StrongVerificationCallbackEvent( 

851 verification_attempt_id=verification_attempt.id, 

852 iris_status=iris_status, 

853 ) 

854 ) 

855 if iris_status == "INITIATED": 

856 # the user opened the session in the app 

857 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app 

858 elif iris_status == "COMPLETED": 

859 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

860 elif iris_status == "APPROVED": 

861 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

862 session.commit() 

863 # background worker will go and sort this one out 

864 queue_job( 

865 session, 

866 job_type="finalize_strong_verification", 

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

868 priority=8, 

869 ) 

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

871 verification_attempt.status = StrongVerificationAttemptStatus.failed 

872 

873 return httpbody_pb2.HttpBody( 

874 content_type="application/json", 

875 # json.dumps escapes non-ascii characters 

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

877 )