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