Coverage for src / couchers / servicers / account.py: 91%
316 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-07 16:21 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-07 16:21 +0000
1import json
2import logging
3from datetime import timedelta
4from urllib.parse import urlencode
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
14from couchers import urls
15from couchers.config import config
16from couchers.constants import DONATION_DRIVE_START, PHONE_REVERIFICATION_INTERVAL, SMS_CODE_ATTEMPTS, SMS_CODE_LIFETIME
17from couchers.context import CouchersContext
18from couchers.crypto import (
19 b64decode,
20 b64encode,
21 generate_invite_code,
22 hash_password,
23 simple_decrypt,
24 simple_encrypt,
25 urlsafe_secure_token,
26 verify_password,
27 verify_token,
28)
29from couchers.experimentation import check_gate
30from couchers.helpers.geoip import geoip_approximate_location
31from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification
32from couchers.jobs.enqueue import queue_job
33from couchers.jobs.handlers import finalize_strong_verification
34from couchers.materialized_views import LiteUser
35from couchers.metrics import (
36 account_deletion_initiations_counter,
37 strong_verification_data_deletions_counter,
38 strong_verification_initiations_counter,
39)
40from couchers.models import (
41 AccountDeletionReason,
42 AccountDeletionToken,
43 ContributeOption,
44 ContributorForm,
45 HostRequest,
46 HostRequestStatus,
47 InviteCode,
48 ModNote,
49 ProfilePublicVisibility,
50 StrongVerificationAttempt,
51 StrongVerificationAttemptStatus,
52 StrongVerificationCallbackEvent,
53 User,
54 UserSession,
55 Volunteer,
56)
57from couchers.models.notifications import NotificationTopicAction
58from couchers.notifications.notify import notify
59from couchers.phone import sms
60from couchers.phone.check import is_e164_format, is_known_operator
61from couchers.proto import account_pb2, account_pb2_grpc, auth_pb2, iris_pb2_grpc, notification_data_pb2
62from couchers.proto.google.api import httpbody_pb2
63from couchers.proto.internal import jobs_pb2, verification_pb2
64from couchers.servicers.api import lite_user_to_pb
65from couchers.servicers.public import format_volunteer_link
66from couchers.servicers.references import get_pending_references_to_write, reftype2api
67from couchers.sql import where_moderated_content_visible, where_users_column_visible
68from couchers.tasks import (
69 maybe_send_contributor_form_email,
70 send_account_deletion_report_email,
71 send_email_changed_confirmation_to_new_email,
72)
73from couchers.utils import (
74 Timestamp_from_datetime,
75 create_lang_cookie,
76 date_to_api,
77 dt_from_page_token,
78 dt_to_page_token,
79 is_valid_email,
80 now,
81 to_aware_datetime,
82)
84logger = logging.getLogger(__name__)
85logger.setLevel(logging.DEBUG)
87contributeoption2sql = {
88 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None,
89 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes,
90 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe,
91 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no,
92}
94contributeoption2api = {
95 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED,
96 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES,
97 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE,
98 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO,
99}
101profilepublicitysetting2sql = {
102 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None,
103 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing,
104 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only,
105 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited,
106 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most,
107 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full,
108}
110profilepublicitysetting2api = {
111 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN,
112 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING,
113 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY,
114 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED,
115 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST,
116 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL,
117}
119MAX_PAGINATION_LENGTH = 50
122def mod_note_to_pb(note: ModNote) -> account_pb2.ModNote:
123 return account_pb2.ModNote(
124 note_id=note.id,
125 note_content=note.note_content,
126 created=Timestamp_from_datetime(note.created),
127 acknowledged=Timestamp_from_datetime(note.acknowledged) if note.acknowledged else None,
128 )
131def abort_on_invalid_password(password: str, context: CouchersContext) -> None:
132 """
133 Internal utility function: given a password, aborts if password is unforgivably insecure
134 """
135 if len(password) < 8:
136 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_short")
138 if len(password) > 256:
139 # Hey, what are you trying to do? Give us a DDOS attack?
140 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_long")
142 # check for the most common weak passwords (not meant to be an exhaustive check!)
143 if password.lower() in ("password", "12345678", "couchers", "couchers1"):
144 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "insecure_password")
147def _volunteer_info_to_pb(volunteer: Volunteer, username: str) -> account_pb2.GetMyVolunteerInfoRes:
148 return account_pb2.GetMyVolunteerInfoRes(
149 display_name=volunteer.display_name,
150 display_location=volunteer.display_location,
151 role=volunteer.role,
152 started_volunteering=date_to_api(volunteer.started_volunteering),
153 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None,
154 show_on_team_page=volunteer.show_on_team_page,
155 **format_volunteer_link(volunteer, username),
156 )
159class Account(account_pb2_grpc.AccountServicer):
160 def GetAccountInfo(
161 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
162 ) -> account_pb2.GetAccountInfoRes:
163 user, volunteer = session.execute(
164 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
165 ).one()
167 # Test experimentation integration - check if user is in the test gate
168 # Create 'test_statsig_integration' in Statsig console to test
169 test_gate = check_gate(context, "test_statsig_integration")
170 logger.info(f"Experimentation gate 'test_statsig_integration' for user {user.id}: {test_gate}")
172 should_show_donation_banner = DONATION_DRIVE_START is not None and (
173 user.last_donated is None or user.last_donated < DONATION_DRIVE_START
174 )
176 return account_pb2.GetAccountInfoRes(
177 username=user.username,
178 email=user.email,
179 phone=user.phone if (user.phone_is_verified or not user.phone_code_expired) else None,
180 has_donated=user.last_donated is not None,
181 phone_verified=user.phone_is_verified,
182 profile_complete=user.has_completed_profile,
183 my_home_complete=user.has_completed_my_home,
184 timezone=user.timezone,
185 is_superuser=user.is_superuser,
186 ui_language_preference=user.ui_language_preference,
187 profile_public_visibility=profilepublicitysetting2api[user.public_visibility],
188 is_volunteer=volunteer is not None,
189 should_show_donation_banner=should_show_donation_banner,
190 **get_strong_verification_fields(session, user),
191 )
193 def ChangePasswordV2(
194 self, request: account_pb2.ChangePasswordV2Req, context: CouchersContext, session: Session
195 ) -> empty_pb2.Empty:
196 """
197 Changes the user's password. They have to confirm their old password just in case.
199 If they didn't have an old password previously, then we don't check that.
200 """
201 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
203 if not verify_password(user.hashed_password, request.old_password):
204 # wrong password
205 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password")
207 abort_on_invalid_password(request.new_password, context)
208 user.hashed_password = hash_password(request.new_password)
210 session.commit()
212 notify(
213 session,
214 user_id=user.id,
215 topic_action=NotificationTopicAction.password__change,
216 key="",
217 )
219 return empty_pb2.Empty()
221 def ChangeEmailV2(
222 self, request: account_pb2.ChangeEmailV2Req, context: CouchersContext, session: Session
223 ) -> empty_pb2.Empty:
224 """
225 Change the user's email address.
227 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one.
229 Otherwise they need to confirm twice, via an email sent to each of their old and new emails.
231 In all confirmation emails, the user must click on the confirmation link.
232 """
233 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
235 # check password first
236 if not verify_password(user.hashed_password, request.password):
237 # wrong password
238 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password")
240 # not a valid email
241 if not is_valid_email(request.new_email):
242 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
244 # email already in use (possibly by this user)
245 if session.execute(select(User).where(User.email == request.new_email)).scalar_one_or_none():
246 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
248 user.new_email = request.new_email
249 user.new_email_token = urlsafe_secure_token()
250 user.new_email_token_created = now()
251 user.new_email_token_expiry = now() + timedelta(hours=2)
253 send_email_changed_confirmation_to_new_email(session, user)
255 # will still go into old email
256 notify(
257 session,
258 user_id=user.id,
259 topic_action=NotificationTopicAction.email_address__change,
260 key="",
261 data=notification_data_pb2.EmailAddressChange(
262 new_email=request.new_email,
263 ),
264 )
266 # session autocommit
267 return empty_pb2.Empty()
269 def ChangeLanguagePreference(
270 self, request: account_pb2.ChangeLanguagePreferenceReq, context: CouchersContext, session: Session
271 ) -> empty_pb2.Empty:
272 # select the user from the db
273 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
275 # update the user's preference
276 user.ui_language_preference = request.ui_language_preference
277 context.set_cookies(create_lang_cookie(request.ui_language_preference))
279 return empty_pb2.Empty()
281 def FillContributorForm(
282 self, request: account_pb2.FillContributorFormReq, context: CouchersContext, session: Session
283 ) -> empty_pb2.Empty:
284 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
286 form = request.contributor_form
288 form = ContributorForm(
289 user=user,
290 ideas=form.ideas or None,
291 features=form.features or None,
292 experience=form.experience or None,
293 contribute=contributeoption2sql[form.contribute],
294 contribute_ways=form.contribute_ways,
295 expertise=form.expertise or None,
296 )
298 session.add(form)
299 session.flush()
300 maybe_send_contributor_form_email(session, form)
302 user.filled_contributor_form = True
304 return empty_pb2.Empty()
306 def GetContributorFormInfo(
307 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
308 ) -> account_pb2.GetContributorFormInfoRes:
309 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
311 return account_pb2.GetContributorFormInfoRes(
312 filled_contributor_form=user.filled_contributor_form,
313 )
315 def ChangePhone(
316 self, request: account_pb2.ChangePhoneReq, context: CouchersContext, session: Session
317 ) -> empty_pb2.Empty:
318 phone = request.phone
319 # early quick validation
320 if phone and not is_e164_format(phone):
321 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_phone")
323 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
324 if user.last_donated is None:
325 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_donated")
327 if not phone:
328 user.phone = None
329 user.phone_verification_verified = None
330 user.phone_verification_token = None
331 user.phone_verification_attempts = 0
332 return empty_pb2.Empty()
334 if not is_known_operator(phone):
335 context.abort_with_error_code(grpc.StatusCode.UNIMPLEMENTED, "unrecognized_phone_number")
337 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL:
338 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "reverification_too_early")
340 token = sms.generate_random_code()
341 result = sms.send_sms(phone, sms.format_message(token))
343 if result == "success":
344 user.phone = phone
345 user.phone_verification_verified = None
346 user.phone_verification_token = token
347 user.phone_verification_sent = now()
348 user.phone_verification_attempts = 0
350 notify(
351 session,
352 user_id=user.id,
353 topic_action=NotificationTopicAction.phone_number__change,
354 key="",
355 data=notification_data_pb2.PhoneNumberChange(
356 phone=phone,
357 ),
358 )
360 return empty_pb2.Empty()
362 context.abort(grpc.StatusCode.UNIMPLEMENTED, result)
364 def VerifyPhone(
365 self, request: account_pb2.VerifyPhoneReq, context: CouchersContext, session: Session
366 ) -> empty_pb2.Empty:
367 if not sms.looks_like_a_code(request.token): 367 ↛ 368line 367 didn't jump to line 368 because the condition on line 367 was never true
368 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "wrong_sms_code")
370 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
371 if user.phone_verification_token is None:
372 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification")
374 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME: 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true
375 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification")
377 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS:
378 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "too_many_sms_code_attempts")
380 if not verify_token(request.token, user.phone_verification_token):
381 user.phone_verification_attempts += 1
382 session.commit()
383 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "wrong_sms_code")
385 # Delete verifications from everyone else that has this number
386 session.execute(
387 update(User)
388 .where(User.phone == user.phone)
389 .where(User.id != context.user_id)
390 .values(
391 {
392 "phone_verification_verified": None,
393 "phone_verification_attempts": 0,
394 "phone_verification_token": None,
395 "phone": None,
396 }
397 )
398 .execution_options(synchronize_session=False)
399 )
401 user.phone_verification_token = None
402 user.phone_verification_verified = now()
403 user.phone_verification_attempts = 0
405 notify(
406 session,
407 user_id=user.id,
408 topic_action=NotificationTopicAction.phone_number__verify,
409 key="",
410 data=notification_data_pb2.PhoneNumberVerify(
411 phone=user.phone,
412 ),
413 )
415 return empty_pb2.Empty()
417 def InitiateStrongVerification(
418 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
419 ) -> account_pb2.InitiateStrongVerificationRes:
420 if not config["ENABLE_STRONG_VERIFICATION"]:
421 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "strong_verification_disabled")
423 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
424 existing_verification = session.execute(
425 select(StrongVerificationAttempt)
426 .where(StrongVerificationAttempt.user_id == user.id)
427 .where(StrongVerificationAttempt.is_valid)
428 ).scalar_one_or_none()
429 if existing_verification: 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true
430 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "strong_verification_already_verified")
432 strong_verification_initiations_counter.labels(user.gender).inc()
434 verification_attempt_token = urlsafe_secure_token()
435 # this is the iris reference data, they will return this on every callback, it also doubles as webhook auth given lack of it otherwise
436 reference = b64encode(
437 simple_encrypt(
438 "iris_callback",
439 verification_pb2.VerificationReferencePayload(
440 verification_attempt_token=verification_attempt_token,
441 user_id=user.id,
442 ).SerializeToString(),
443 )
444 )
445 response = requests.post(
446 "https://passportreader.app/api/v1/session.create",
447 auth=(config["IRIS_ID_PUBKEY"], config["IRIS_ID_SECRET"]),
448 json={
449 "callback_url": f"{config['BACKEND_BASE_URL']}/iris/webhook",
450 "face_verification": False,
451 "passport_only": True,
452 "reference": reference,
453 },
454 timeout=10,
455 verify="/etc/ssl/certs/ca-certificates.crt",
456 )
458 if response.status_code != 200: 458 ↛ 459line 458 didn't jump to line 459 because the condition on line 458 was never true
459 raise Exception(f"Iris didn't return 200: {response.text}")
461 iris_session_id = response.json()["id"]
462 token = response.json()["token"]
463 session.add(
464 StrongVerificationAttempt(
465 user_id=user.id,
466 verification_attempt_token=verification_attempt_token,
467 iris_session_id=iris_session_id,
468 iris_token=token,
469 )
470 )
472 redirect_params = {
473 "token": token,
474 "redirect_url": urls.complete_strong_verification_url(
475 verification_attempt_token=verification_attempt_token
476 ),
477 }
478 redirect_url = "https://passportreader.app/open?" + urlencode(redirect_params)
480 return account_pb2.InitiateStrongVerificationRes(
481 verification_attempt_token=verification_attempt_token,
482 redirect_url=redirect_url,
483 )
485 def GetStrongVerificationAttemptStatus(
486 self, request: account_pb2.GetStrongVerificationAttemptStatusReq, context: CouchersContext, session: Session
487 ) -> account_pb2.GetStrongVerificationAttemptStatusRes:
488 verification_attempt = session.execute(
489 select(StrongVerificationAttempt)
490 .where(StrongVerificationAttempt.user_id == context.user_id)
491 .where(StrongVerificationAttempt.is_visible)
492 .where(StrongVerificationAttempt.verification_attempt_token == request.verification_attempt_token)
493 ).scalar_one_or_none()
494 if not verification_attempt: 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true
495 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "strong_verification_attempt_not_found")
496 status_to_pb = {
497 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED,
498 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP,
499 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP,
500 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND,
501 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED,
502 }
503 return account_pb2.GetStrongVerificationAttemptStatusRes(
504 status=status_to_pb.get(
505 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN
506 ),
507 )
509 def DeleteStrongVerificationData(
510 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
511 ) -> empty_pb2.Empty:
512 verification_attempts = (
513 session.execute(
514 select(StrongVerificationAttempt)
515 .where(StrongVerificationAttempt.user_id == context.user_id)
516 .where(StrongVerificationAttempt.has_full_data)
517 )
518 .scalars()
519 .all()
520 )
521 for verification_attempt in verification_attempts:
522 verification_attempt.status = StrongVerificationAttemptStatus.deleted
523 verification_attempt.has_full_data = False
524 verification_attempt.passport_encrypted_data = None
525 verification_attempt.passport_date_of_birth = None
526 verification_attempt.passport_sex = None
527 session.flush()
528 # double check:
529 verification_attempts = (
530 session.execute(
531 select(StrongVerificationAttempt)
532 .where(StrongVerificationAttempt.user_id == context.user_id)
533 .where(StrongVerificationAttempt.has_full_data)
534 )
535 .scalars()
536 .all()
537 )
538 assert len(verification_attempts) == 0
540 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
541 strong_verification_data_deletions_counter.labels(user.gender).inc()
543 return empty_pb2.Empty()
545 def DeleteAccount(
546 self, request: account_pb2.DeleteAccountReq, context: CouchersContext, session: Session
547 ) -> empty_pb2.Empty:
548 """
549 Triggers email with token to confirm deletion
551 Frontend should confirm via unique string (i.e. username) before this is called
552 """
553 if not request.confirm:
554 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_account_delete")
556 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
558 reason = request.reason.strip()
559 if reason:
560 deletion_reason = AccountDeletionReason(user_id=user.id, reason=reason)
561 session.add(deletion_reason)
562 session.flush()
563 send_account_deletion_report_email(session, deletion_reason)
565 token = AccountDeletionToken(token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2))
567 notify(
568 session,
569 user_id=user.id,
570 topic_action=NotificationTopicAction.account_deletion__start,
571 key="",
572 data=notification_data_pb2.AccountDeletionStart(
573 deletion_token=token.token,
574 ),
575 )
576 session.add(token)
578 account_deletion_initiations_counter.labels(user.gender).inc()
580 return empty_pb2.Empty()
582 def ListModNotes(
583 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
584 ) -> account_pb2.ListModNotesRes:
585 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
587 notes = (
588 session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.created.asc()))
589 .scalars()
590 .all()
591 )
593 return account_pb2.ListModNotesRes(mod_notes=[mod_note_to_pb(note) for note in notes])
595 def ListActiveSessions(
596 self, request: account_pb2.ListActiveSessionsReq, context: CouchersContext, session: Session
597 ) -> account_pb2.ListActiveSessionsRes:
598 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
599 page_token = dt_from_page_token(request.page_token) if request.page_token else now()
601 user_sessions = (
602 session.execute(
603 select(UserSession)
604 .where(UserSession.user_id == context.user_id)
605 .where(UserSession.is_valid)
606 .where(UserSession.is_api_key == False)
607 .where(UserSession.last_seen <= page_token)
608 .order_by(UserSession.last_seen.desc())
609 .limit(page_size + 1)
610 )
611 .scalars()
612 .all()
613 )
615 def _active_session_to_pb(user_session: UserSession) -> account_pb2.ActiveSession:
616 user_agent = user_agents_parse(user_session.user_agent or "")
617 return account_pb2.ActiveSession(
618 created=Timestamp_from_datetime(user_session.created),
619 expiry=Timestamp_from_datetime(user_session.expiry),
620 last_seen=Timestamp_from_datetime(user_session.last_seen),
621 operating_system=user_agent.os.family,
622 browser=user_agent.browser.family,
623 device=user_agent.device.family,
624 approximate_location=geoip_approximate_location(user_session.ip_address) or "Unknown",
625 is_current_session=user_session.token == context.token,
626 )
628 return account_pb2.ListActiveSessionsRes(
629 active_sessions=list(map(_active_session_to_pb, user_sessions[:page_size])),
630 next_page_token=dt_to_page_token(user_sessions[-1].last_seen) if len(user_sessions) > page_size else None,
631 )
633 def LogOutSession(
634 self, request: account_pb2.LogOutSessionReq, context: CouchersContext, session: Session
635 ) -> empty_pb2.Empty:
636 session.execute(
637 update(UserSession)
638 .where(UserSession.token != context.token)
639 .where(UserSession.user_id == context.user_id)
640 .where(UserSession.is_valid)
641 .where(UserSession.is_api_key == False)
642 .where(UserSession.created == to_aware_datetime(request.created))
643 .values(expiry=func.now())
644 .execution_options(synchronize_session=False)
645 )
646 return empty_pb2.Empty()
648 def LogOutOtherSessions(
649 self, request: account_pb2.LogOutOtherSessionsReq, context: CouchersContext, session: Session
650 ) -> empty_pb2.Empty:
651 if not request.confirm:
652 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_logout_other_sessions")
654 session.execute(
655 update(UserSession)
656 .where(UserSession.token != context.token)
657 .where(UserSession.user_id == context.user_id)
658 .where(UserSession.is_valid)
659 .where(UserSession.is_api_key == False)
660 .values(expiry=func.now())
661 .execution_options(synchronize_session=False)
662 )
663 return empty_pb2.Empty()
665 def SetProfilePublicVisibility(
666 self, request: account_pb2.SetProfilePublicVisibilityReq, context: CouchersContext, session: Session
667 ) -> empty_pb2.Empty:
668 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
669 user.public_visibility = profilepublicitysetting2sql[request.profile_public_visibility] # type: ignore[assignment]
670 user.has_modified_public_visibility = True
671 return empty_pb2.Empty()
673 def CreateInviteCode(
674 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
675 ) -> account_pb2.CreateInviteCodeRes:
676 code = generate_invite_code()
677 session.add(InviteCode(id=code, creator_user_id=context.user_id))
679 return account_pb2.CreateInviteCodeRes(
680 code=code,
681 url=urls.invite_code_link(code=code),
682 )
684 def DisableInviteCode(
685 self, request: account_pb2.DisableInviteCodeReq, context: CouchersContext, session: Session
686 ) -> empty_pb2.Empty:
687 invite = session.execute(
688 select(InviteCode).where(InviteCode.id == request.code, InviteCode.creator_user_id == context.user_id)
689 ).scalar_one_or_none()
691 if not invite: 691 ↛ 692line 691 didn't jump to line 692 because the condition on line 691 was never true
692 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_found")
694 invite.disabled = func.now()
695 session.commit()
697 return empty_pb2.Empty()
699 def ListInviteCodes(
700 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
701 ) -> account_pb2.ListInviteCodesRes:
702 results = session.execute(
703 select(
704 InviteCode.id,
705 InviteCode.created,
706 InviteCode.disabled,
707 func.count(User.id).label("num_users"),
708 )
709 .outerjoin(User, User.invite_code_id == InviteCode.id)
710 .where(InviteCode.creator_user_id == context.user_id)
711 .group_by(InviteCode.id, InviteCode.disabled)
712 .order_by(func.count(User.id).desc(), InviteCode.disabled)
713 ).all()
715 return account_pb2.ListInviteCodesRes(
716 invite_codes=[
717 account_pb2.InviteCodeInfo(
718 code=code_id,
719 created=Timestamp_from_datetime(created),
720 disabled=Timestamp_from_datetime(disabled) if disabled else None,
721 uses=len_users,
722 url=urls.invite_code_link(code=code_id),
723 )
724 for code_id, created, disabled, len_users in results
725 ]
726 )
728 def GetReminders(
729 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
730 ) -> account_pb2.GetRemindersRes:
731 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
733 # responding to reqs comes first in desc order of when they were received
734 query = select(HostRequest.conversation_id, LiteUser).join(LiteUser, LiteUser.id == HostRequest.surfer_user_id)
735 query = where_users_column_visible(query, context, HostRequest.surfer_user_id)
736 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=True)
737 pending_host_requests = session.execute(
738 query.where(HostRequest.host_user_id == context.user_id)
739 .where(HostRequest.status == HostRequestStatus.pending)
740 .where(HostRequest.start_time > func.now())
741 .order_by(HostRequest.conversation_id.asc())
742 ).all()
743 reminders = [
744 account_pb2.Reminder(
745 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder(
746 host_request_id=host_request_id,
747 surfer_user=lite_user_to_pb(session, lite_user, context),
748 )
749 )
750 for host_request_id, lite_user in pending_host_requests
751 ]
753 # references come second, in order of deadline, desc
754 reminders += [
755 account_pb2.Reminder(
756 write_reference_reminder=account_pb2.WriteReferenceReminder(
757 host_request_id=host_request_id,
758 reference_type=reftype2api[reference_type],
759 other_user=lite_user_to_pb(session, lite_user, context),
760 )
761 )
762 for host_request_id, reference_type, _, lite_user in get_pending_references_to_write(session, context)
763 ]
765 if not user.has_completed_profile:
766 reminders.append(account_pb2.Reminder(complete_profile_reminder=account_pb2.CompleteProfileReminder()))
768 if not has_strong_verification(session, user):
769 reminders.append(
770 account_pb2.Reminder(complete_verification_reminder=account_pb2.CompleteVerificationReminder())
771 )
773 return account_pb2.GetRemindersRes(reminders=reminders)
775 def GetMyVolunteerInfo(
776 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
777 ) -> account_pb2.GetMyVolunteerInfoRes:
778 user, volunteer = session.execute(
779 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
780 ).one()
781 if not volunteer:
782 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer")
783 return _volunteer_info_to_pb(volunteer, user.username)
785 def UpdateMyVolunteerInfo(
786 self, request: account_pb2.UpdateMyVolunteerInfoReq, context: CouchersContext, session: Session
787 ) -> account_pb2.GetMyVolunteerInfoRes:
788 user, volunteer = session.execute(
789 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
790 ).one()
791 if not volunteer:
792 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer")
794 if request.HasField("display_name"): 794 ↛ 797line 794 didn't jump to line 797 because the condition on line 794 was always true
795 volunteer.display_name = request.display_name.value or None
797 if request.HasField("display_location"):
798 volunteer.display_location = request.display_location.value or None
800 if request.HasField("show_on_team_page"): 800 ↛ 801line 800 didn't jump to line 801 because the condition on line 800 was never true
801 volunteer.show_on_team_page = request.show_on_team_page.value
803 if request.HasField("link_type") or request.HasField("link_text") or request.HasField("link_url"): 803 ↛ 829line 803 didn't jump to line 829 because the condition on line 803 was always true
804 link_type = request.link_type.value or volunteer.link_type
805 link_text = request.link_text.value or volunteer.link_text
806 link_url = request.link_url.value or volunteer.link_url
807 if link_type == "couchers": 807 ↛ 809line 807 didn't jump to line 809 because the condition on line 807 was never true
808 # this is the default
809 link_type = None
810 link_text = None
811 link_url = None
812 elif link_type == "linkedin":
813 # this is the username
814 link_text = link_text
815 link_url = f"https://www.linkedin.com/in/{link_text}/"
816 elif link_type == "email":
817 if not is_valid_email(link_text): 817 ↛ 818line 817 didn't jump to line 818 because the condition on line 817 was never true
818 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
819 link_url = f"mailto:{link_text}"
820 elif link_type == "website": 820 ↛ 824line 820 didn't jump to line 824 because the condition on line 820 was always true
821 if not link_url.startswith("https://") or "/" in link_text or link_text not in link_url: 821 ↛ 822line 821 didn't jump to line 822 because the condition on line 821 was never true
822 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_website_url")
823 else:
824 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_link_type")
825 volunteer.link_type = link_type
826 volunteer.link_text = link_text
827 volunteer.link_url = link_url
829 session.flush()
831 return _volunteer_info_to_pb(volunteer, user.username)
834class Iris(iris_pb2_grpc.IrisServicer):
835 def Webhook(
836 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session
837 ) -> httpbody_pb2.HttpBody:
838 json_data = json.loads(request.data)
839 reference_payload = verification_pb2.VerificationReferencePayload.FromString(
840 simple_decrypt("iris_callback", b64decode(json_data["session_reference"]))
841 )
842 # if we make it past the decrypt, we consider this webhook authenticated
843 verification_attempt_token = reference_payload.verification_attempt_token
844 user_id = reference_payload.user_id
846 verification_attempt = session.execute(
847 select(StrongVerificationAttempt)
848 .where(StrongVerificationAttempt.user_id == reference_payload.user_id)
849 .where(StrongVerificationAttempt.verification_attempt_token == reference_payload.verification_attempt_token)
850 .where(StrongVerificationAttempt.iris_session_id == json_data["session_id"])
851 ).scalar_one()
852 iris_status = json_data["session_state"]
853 session.add(
854 StrongVerificationCallbackEvent(
855 verification_attempt_id=verification_attempt.id,
856 iris_status=iris_status,
857 )
858 )
859 if iris_status == "INITIATED":
860 # the user opened the session in the app
861 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app
862 elif iris_status == "COMPLETED":
863 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
864 elif iris_status == "APPROVED": 864 ↛ 874line 864 didn't jump to line 874 because the condition on line 864 was always true
865 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
866 session.commit()
867 # background worker will go and sort this one out
868 queue_job(
869 session,
870 job=finalize_strong_verification,
871 payload=jobs_pb2.FinalizeStrongVerificationPayload(verification_attempt_id=verification_attempt.id),
872 priority=8,
873 )
874 elif iris_status in ["FAILED", "ABORTED", "REJECTED"]:
875 verification_attempt.status = StrongVerificationAttemptStatus.failed
877 return httpbody_pb2.HttpBody(
878 content_type="application/json",
879 # json.dumps escapes non-ascii characters
880 data=json.dumps({"success": True}).encode("ascii"),
881 )