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

315 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-30 14:33 +0000

1import json 

2import logging 

3from datetime import timedelta 

4from urllib.parse import urlencode 

5 

6import grpc 

7import requests 

8from google.protobuf import empty_pb2 

9from sqlalchemy import select 

10from sqlalchemy.orm import Session 

11from sqlalchemy.sql import func, update 

12from user_agents import parse as user_agents_parse 

13 

14from couchers import urls 

15from couchers.config import config 

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

17from couchers.context import CouchersContext 

18from couchers.crypto import ( 

19 b64decode, 

20 b64encode, 

21 generate_invite_code, 

22 hash_password, 

23 simple_decrypt, 

24 simple_encrypt, 

25 urlsafe_secure_token, 

26 verify_password, 

27 verify_token, 

28) 

29from couchers.experimentation import check_gate 

30from couchers.helpers.geoip import geoip_approximate_location 

31from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification 

32from couchers.jobs.enqueue import queue_job 

33from couchers.jobs.handlers import finalize_strong_verification 

34from couchers.materialized_views import LiteUser 

35from couchers.metrics import ( 

36 account_deletion_initiations_counter, 

37 strong_verification_data_deletions_counter, 

38 strong_verification_initiations_counter, 

39) 

40from couchers.models import ( 

41 AccountDeletionReason, 

42 AccountDeletionToken, 

43 ContributeOption, 

44 ContributorForm, 

45 HostRequest, 

46 HostRequestStatus, 

47 InviteCode, 

48 ModNote, 

49 ProfilePublicVisibility, 

50 StrongVerificationAttempt, 

51 StrongVerificationAttemptStatus, 

52 StrongVerificationCallbackEvent, 

53 User, 

54 UserSession, 

55 Volunteer, 

56) 

57from couchers.notifications.notify import notify 

58from couchers.phone import sms 

59from couchers.phone.check import is_e164_format, is_known_operator 

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

61from couchers.proto.google.api import httpbody_pb2 

62from couchers.proto.internal import jobs_pb2, verification_pb2 

63from couchers.servicers.api import lite_user_to_pb 

64from couchers.servicers.public import format_volunteer_link 

65from couchers.servicers.references import get_pending_references_to_write, reftype2api 

66from couchers.sql import where_moderated_content_visible, where_users_column_visible 

67from couchers.tasks import ( 

68 maybe_send_contributor_form_email, 

69 send_account_deletion_report_email, 

70 send_email_changed_confirmation_to_new_email, 

71) 

72from couchers.utils import ( 

73 Timestamp_from_datetime, 

74 create_lang_cookie, 

75 date_to_api, 

76 dt_from_page_token, 

77 dt_to_page_token, 

78 is_valid_email, 

79 now, 

80 to_aware_datetime, 

81) 

82 

83logger = logging.getLogger(__name__) 

84logger.setLevel(logging.DEBUG) 

85 

86contributeoption2sql = { 

87 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None, 

88 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes, 

89 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe, 

90 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no, 

91} 

92 

93contributeoption2api = { 

94 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED, 

95 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES, 

96 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE, 

97 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO, 

98} 

99 

100profilepublicitysetting2sql = { 

101 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None, 

102 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing, 

103 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only, 

104 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited, 

105 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most, 

106 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full, 

107} 

108 

109profilepublicitysetting2api = { 

110 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN, 

111 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING, 

112 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY, 

113 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED, 

114 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST, 

115 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL, 

116} 

117 

118MAX_PAGINATION_LENGTH = 50 

119 

120 

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

122 return account_pb2.ModNote( 

123 note_id=note.id, 

124 note_content=note.note_content, 

125 created=Timestamp_from_datetime(note.created), 

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

127 ) 

128 

129 

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

131 """ 

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

133 """ 

134 if len(password) < 8: 

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

136 

137 if len(password) > 256: 

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

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

140 

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

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

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

144 

145 

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

147 return account_pb2.GetMyVolunteerInfoRes( 

148 display_name=volunteer.display_name, 

149 display_location=volunteer.display_location, 

150 role=volunteer.role, 

151 started_volunteering=date_to_api(volunteer.started_volunteering), 

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

153 show_on_team_page=volunteer.show_on_team_page, 

154 **format_volunteer_link(volunteer, username), 

155 ) 

156 

157 

158class Account(account_pb2_grpc.AccountServicer): 

159 def GetAccountInfo( 

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

161 ) -> account_pb2.GetAccountInfoRes: 

162 user, volunteer = session.execute( 

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

164 ).one() 

165 

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

167 # Create 'test_statsig_integration' in Statsig console to test 

168 test_gate = check_gate(context, "test_statsig_integration") 

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

170 

171 should_show_donation_banner = DONATION_DRIVE_START is not None and ( 

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

173 ) 

174 

175 return account_pb2.GetAccountInfoRes( 

176 username=user.username, 

177 email=user.email, 

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

179 has_donated=user.last_donated is not None, 

180 phone_verified=user.phone_is_verified, 

181 profile_complete=user.has_completed_profile, 

182 my_home_complete=user.has_completed_my_home, 

183 timezone=user.timezone, 

184 is_superuser=user.is_superuser, 

185 ui_language_preference=user.ui_language_preference, 

186 profile_public_visibility=profilepublicitysetting2api[user.public_visibility], 

187 is_volunteer=volunteer is not None, 

188 should_show_donation_banner=should_show_donation_banner, 

189 **get_strong_verification_fields(session, user), 

190 ) 

191 

192 def ChangePasswordV2( 

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

194 ) -> empty_pb2.Empty: 

195 """ 

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

197 

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

199 """ 

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

201 

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

203 # wrong password 

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

205 

206 abort_on_invalid_password(request.new_password, context) 

207 user.hashed_password = hash_password(request.new_password) 

208 

209 session.commit() 

210 

211 notify( 

212 session, 

213 user_id=user.id, 

214 topic_action="password:change", 

215 key="", 

216 ) 

217 

218 return empty_pb2.Empty() 

219 

220 def ChangeEmailV2( 

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

222 ) -> empty_pb2.Empty: 

223 """ 

224 Change the user's email address. 

225 

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

227 

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

229 

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

231 """ 

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

233 

234 # check password first 

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

236 # wrong password 

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

238 

239 # not a valid email 

240 if not is_valid_email(request.new_email): 

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

242 

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

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

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

246 

247 user.new_email = request.new_email 

248 user.new_email_token = urlsafe_secure_token() 

249 user.new_email_token_created = now() 

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

251 

252 send_email_changed_confirmation_to_new_email(session, user) 

253 

254 # will still go into old email 

255 notify( 

256 session, 

257 user_id=user.id, 

258 topic_action="email_address:change", 

259 key="", 

260 data=notification_data_pb2.EmailAddressChange( 

261 new_email=request.new_email, 

262 ), 

263 ) 

264 

265 # session autocommit 

266 return empty_pb2.Empty() 

267 

268 def ChangeLanguagePreference( 

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

270 ) -> empty_pb2.Empty: 

271 # select the user from the db 

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

273 

274 # update the user's preference 

275 user.ui_language_preference = request.ui_language_preference 

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

277 

278 return empty_pb2.Empty() 

279 

280 def FillContributorForm( 

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

282 ) -> empty_pb2.Empty: 

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

284 

285 form = request.contributor_form 

286 

287 form = ContributorForm( 

288 user=user, 

289 ideas=form.ideas or None, 

290 features=form.features or None, 

291 experience=form.experience or None, 

292 contribute=contributeoption2sql[form.contribute], 

293 contribute_ways=form.contribute_ways, 

294 expertise=form.expertise or None, 

295 ) 

296 

297 session.add(form) 

298 session.flush() 

299 maybe_send_contributor_form_email(session, form) 

300 

301 user.filled_contributor_form = True 

302 

303 return empty_pb2.Empty() 

304 

305 def GetContributorFormInfo( 

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

307 ) -> account_pb2.GetContributorFormInfoRes: 

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

309 

310 return account_pb2.GetContributorFormInfoRes( 

311 filled_contributor_form=user.filled_contributor_form, 

312 ) 

313 

314 def ChangePhone( 

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

316 ) -> empty_pb2.Empty: 

317 phone = request.phone 

318 # early quick validation 

319 if phone and not is_e164_format(phone): 

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

321 

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

323 if user.last_donated is None: 

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

325 

326 if not phone: 

327 user.phone = None 

328 user.phone_verification_verified = None 

329 user.phone_verification_token = None 

330 user.phone_verification_attempts = 0 

331 return empty_pb2.Empty() 

332 

333 if not is_known_operator(phone): 

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

335 

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

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

338 

339 token = sms.generate_random_code() 

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

341 

342 if result == "success": 

343 user.phone = phone 

344 user.phone_verification_verified = None 

345 user.phone_verification_token = token 

346 user.phone_verification_sent = now() 

347 user.phone_verification_attempts = 0 

348 

349 notify( 

350 session, 

351 user_id=user.id, 

352 topic_action="phone_number:change", 

353 key="", 

354 data=notification_data_pb2.PhoneNumberChange( 

355 phone=phone, 

356 ), 

357 ) 

358 

359 return empty_pb2.Empty() 

360 

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

362 

363 def VerifyPhone( 

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

365 ) -> empty_pb2.Empty: 

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

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

368 

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

370 if user.phone_verification_token is None: 

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

372 

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

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

375 

376 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS: 

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

378 

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

380 user.phone_verification_attempts += 1 

381 session.commit() 

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

383 

384 # Delete verifications from everyone else that has this number 

385 session.execute( 

386 update(User) 

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

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

389 .values( 

390 { 

391 "phone_verification_verified": None, 

392 "phone_verification_attempts": 0, 

393 "phone_verification_token": None, 

394 "phone": None, 

395 } 

396 ) 

397 .execution_options(synchronize_session=False) 

398 ) 

399 

400 user.phone_verification_token = None 

401 user.phone_verification_verified = now() 

402 user.phone_verification_attempts = 0 

403 

404 notify( 

405 session, 

406 user_id=user.id, 

407 topic_action="phone_number:verify", 

408 key="", 

409 data=notification_data_pb2.PhoneNumberVerify( 

410 phone=user.phone, 

411 ), 

412 ) 

413 

414 return empty_pb2.Empty() 

415 

416 def InitiateStrongVerification( 

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

418 ) -> account_pb2.InitiateStrongVerificationRes: 

419 if not config["ENABLE_STRONG_VERIFICATION"]: 

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

421 

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

423 existing_verification = session.execute( 

424 select(StrongVerificationAttempt) 

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

426 .where(StrongVerificationAttempt.is_valid) 

427 ).scalar_one_or_none() 

428 if existing_verification: 

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

430 

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

432 

433 verification_attempt_token = urlsafe_secure_token() 

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

435 reference = b64encode( 

436 simple_encrypt( 

437 "iris_callback", 

438 verification_pb2.VerificationReferencePayload( 

439 verification_attempt_token=verification_attempt_token, 

440 user_id=user.id, 

441 ).SerializeToString(), 

442 ) 

443 ) 

444 response = requests.post( 

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

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

447 json={ 

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

449 "face_verification": False, 

450 "passport_only": True, 

451 "reference": reference, 

452 }, 

453 timeout=10, 

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

455 ) 

456 

457 if response.status_code != 200: 

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

459 

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

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

462 session.add( 

463 StrongVerificationAttempt( 

464 user_id=user.id, 

465 verification_attempt_token=verification_attempt_token, 

466 iris_session_id=iris_session_id, 

467 iris_token=token, 

468 ) 

469 ) 

470 

471 redirect_params = { 

472 "token": token, 

473 "redirect_url": urls.complete_strong_verification_url( 

474 verification_attempt_token=verification_attempt_token 

475 ), 

476 } 

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

478 

479 return account_pb2.InitiateStrongVerificationRes( 

480 verification_attempt_token=verification_attempt_token, 

481 redirect_url=redirect_url, 

482 ) 

483 

484 def GetStrongVerificationAttemptStatus( 

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

486 ) -> account_pb2.GetStrongVerificationAttemptStatusRes: 

487 verification_attempt = session.execute( 

488 select(StrongVerificationAttempt) 

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

490 .where(StrongVerificationAttempt.is_visible) 

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

492 ).scalar_one_or_none() 

493 if not verification_attempt: 

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

495 status_to_pb = { 

496 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED, 

497 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP, 

498 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP, 

499 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND, 

500 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED, 

501 } 

502 return account_pb2.GetStrongVerificationAttemptStatusRes( 

503 status=status_to_pb.get( 

504 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN 

505 ), 

506 ) 

507 

508 def DeleteStrongVerificationData( 

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

510 ) -> empty_pb2.Empty: 

511 verification_attempts = ( 

512 session.execute( 

513 select(StrongVerificationAttempt) 

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

515 .where(StrongVerificationAttempt.has_full_data) 

516 ) 

517 .scalars() 

518 .all() 

519 ) 

520 for verification_attempt in verification_attempts: 

521 verification_attempt.status = StrongVerificationAttemptStatus.deleted 

522 verification_attempt.has_full_data = False 

523 verification_attempt.passport_encrypted_data = None 

524 verification_attempt.passport_date_of_birth = None 

525 verification_attempt.passport_sex = None 

526 session.flush() 

527 # double check: 

528 verification_attempts = ( 

529 session.execute( 

530 select(StrongVerificationAttempt) 

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

532 .where(StrongVerificationAttempt.has_full_data) 

533 ) 

534 .scalars() 

535 .all() 

536 ) 

537 assert len(verification_attempts) == 0 

538 

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

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

541 

542 return empty_pb2.Empty() 

543 

544 def DeleteAccount( 

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

546 ) -> empty_pb2.Empty: 

547 """ 

548 Triggers email with token to confirm deletion 

549 

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

551 """ 

552 if not request.confirm: 

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

554 

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

556 

557 reason = request.reason.strip() 

558 if reason: 

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

560 session.add(deletion_reason) 

561 session.flush() 

562 send_account_deletion_report_email(session, deletion_reason) 

563 

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

565 

566 notify( 

567 session, 

568 user_id=user.id, 

569 topic_action="account_deletion:start", 

570 key="", 

571 data=notification_data_pb2.AccountDeletionStart( 

572 deletion_token=token.token, 

573 ), 

574 ) 

575 session.add(token) 

576 

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

578 

579 return empty_pb2.Empty() 

580 

581 def ListModNotes( 

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

583 ) -> account_pb2.ListModNotesRes: 

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

585 

586 notes = ( 

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

588 .scalars() 

589 .all() 

590 ) 

591 

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

593 

594 def ListActiveSessions( 

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

596 ) -> account_pb2.ListActiveSessionsRes: 

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

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

599 

600 user_sessions = ( 

601 session.execute( 

602 select(UserSession) 

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

604 .where(UserSession.is_valid) 

605 .where(UserSession.is_api_key == False) 

606 .where(UserSession.last_seen <= page_token) 

607 .order_by(UserSession.last_seen.desc()) 

608 .limit(page_size + 1) 

609 ) 

610 .scalars() 

611 .all() 

612 ) 

613 

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

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

616 return account_pb2.ActiveSession( 

617 created=Timestamp_from_datetime(user_session.created), 

618 expiry=Timestamp_from_datetime(user_session.expiry), 

619 last_seen=Timestamp_from_datetime(user_session.last_seen), 

620 operating_system=user_agent.os.family, 

621 browser=user_agent.browser.family, 

622 device=user_agent.device.family, 

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

624 is_current_session=user_session.token == context.token, 

625 ) 

626 

627 return account_pb2.ListActiveSessionsRes( 

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

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

630 ) 

631 

632 def LogOutSession( 

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

634 ) -> empty_pb2.Empty: 

635 session.execute( 

636 update(UserSession) 

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

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

639 .where(UserSession.is_valid) 

640 .where(UserSession.is_api_key == False) 

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

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

643 .execution_options(synchronize_session=False) 

644 ) 

645 return empty_pb2.Empty() 

646 

647 def LogOutOtherSessions( 

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

649 ) -> empty_pb2.Empty: 

650 if not request.confirm: 

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

652 

653 session.execute( 

654 update(UserSession) 

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

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

657 .where(UserSession.is_valid) 

658 .where(UserSession.is_api_key == False) 

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

660 .execution_options(synchronize_session=False) 

661 ) 

662 return empty_pb2.Empty() 

663 

664 def SetProfilePublicVisibility( 

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

666 ) -> empty_pb2.Empty: 

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

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

669 user.has_modified_public_visibility = True 

670 return empty_pb2.Empty() 

671 

672 def CreateInviteCode( 

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

674 ) -> account_pb2.CreateInviteCodeRes: 

675 code = generate_invite_code() 

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

677 

678 return account_pb2.CreateInviteCodeRes( 

679 code=code, 

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

681 ) 

682 

683 def DisableInviteCode( 

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

685 ) -> empty_pb2.Empty: 

686 invite = session.execute( 

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

688 ).scalar_one_or_none() 

689 

690 if not invite: 

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

692 

693 invite.disabled = func.now() 

694 session.commit() 

695 

696 return empty_pb2.Empty() 

697 

698 def ListInviteCodes( 

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

700 ) -> account_pb2.ListInviteCodesRes: 

701 results = session.execute( 

702 select( 

703 InviteCode.id, 

704 InviteCode.created, 

705 InviteCode.disabled, 

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

707 ) 

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

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

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

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

712 ).all() 

713 

714 return account_pb2.ListInviteCodesRes( 

715 invite_codes=[ 

716 account_pb2.InviteCodeInfo( 

717 code=code_id, 

718 created=Timestamp_from_datetime(created), 

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

720 uses=len_users, 

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

722 ) 

723 for code_id, created, disabled, len_users in results 

724 ] 

725 ) 

726 

727 def GetReminders( 

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

729 ) -> account_pb2.GetRemindersRes: 

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

731 

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

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

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

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

736 pending_host_requests = session.execute( 

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

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

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

740 .order_by(HostRequest.conversation_id.asc()) 

741 ).all() 

742 reminders = [ 

743 account_pb2.Reminder( 

744 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder( 

745 host_request_id=host_request_id, 

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

747 ) 

748 ) 

749 for host_request_id, lite_user in pending_host_requests 

750 ] 

751 

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

753 reminders += [ 

754 account_pb2.Reminder( 

755 write_reference_reminder=account_pb2.WriteReferenceReminder( 

756 host_request_id=host_request_id, 

757 reference_type=reftype2api[reference_type], 

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

759 ) 

760 ) 

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

762 ] 

763 

764 if not user.has_completed_profile: 

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

766 

767 if not has_strong_verification(session, user): 

768 reminders.append( 

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

770 ) 

771 

772 return account_pb2.GetRemindersRes(reminders=reminders) 

773 

774 def GetMyVolunteerInfo( 

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

776 ) -> account_pb2.GetMyVolunteerInfoRes: 

777 user, volunteer = session.execute( 

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

779 ).one() 

780 if not volunteer: 

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

782 return _volunteer_info_to_pb(volunteer, user.username) 

783 

784 def UpdateMyVolunteerInfo( 

785 self, request: account_pb2.UpdateMyVolunteerInfoReq, 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 

793 if request.HasField("display_name"): 

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

795 

796 if request.HasField("display_location"): 

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

798 

799 if request.HasField("show_on_team_page"): 

800 volunteer.show_on_team_page = request.show_on_team_page.value 

801 

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

803 link_type = request.link_type.value or volunteer.link_type 

804 link_text = request.link_text.value or volunteer.link_text 

805 link_url = request.link_url.value or volunteer.link_url 

806 if link_type == "couchers": 

807 # this is the default 

808 link_type = None 

809 link_text = None 

810 link_url = None 

811 elif link_type == "linkedin": 

812 # this is the username 

813 link_text = link_text 

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

815 elif link_type == "email": 

816 if not is_valid_email(link_text): 

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

818 link_url = f"mailto:{link_text}" 

819 elif link_type == "website": 

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

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

822 else: 

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

824 volunteer.link_type = link_type 

825 volunteer.link_text = link_text 

826 volunteer.link_url = link_url 

827 

828 session.flush() 

829 

830 return _volunteer_info_to_pb(volunteer, user.username) 

831 

832 

833class Iris(iris_pb2_grpc.IrisServicer): 

834 def Webhook( 

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

836 ) -> httpbody_pb2.HttpBody: 

837 json_data = json.loads(request.data) 

838 reference_payload = verification_pb2.VerificationReferencePayload.FromString( 

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

840 ) 

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

842 verification_attempt_token = reference_payload.verification_attempt_token 

843 user_id = reference_payload.user_id 

844 

845 verification_attempt = session.execute( 

846 select(StrongVerificationAttempt) 

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

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

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

850 ).scalar_one() 

851 iris_status = json_data["session_state"] 

852 session.add( 

853 StrongVerificationCallbackEvent( 

854 verification_attempt_id=verification_attempt.id, 

855 iris_status=iris_status, 

856 ) 

857 ) 

858 if iris_status == "INITIATED": 

859 # the user opened the session in the app 

860 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app 

861 elif iris_status == "COMPLETED": 

862 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

863 elif iris_status == "APPROVED": 

864 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend 

865 session.commit() 

866 # background worker will go and sort this one out 

867 queue_job( 

868 session, 

869 job=finalize_strong_verification, 

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

871 priority=8, 

872 ) 

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

874 verification_attempt.status = StrongVerificationAttemptStatus.failed 

875 

876 return httpbody_pb2.HttpBody( 

877 content_type="application/json", 

878 # json.dumps escapes non-ascii characters 

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

880 )