Coverage for src/couchers/servicers/account.py: 94%
307 statements
« prev ^ index » next coverage.py v7.6.10, created at 2025-08-28 14:55 +0000
« prev ^ index » next coverage.py v7.6.10, created at 2025-08-28 14:55 +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.sql import func, update
10from user_agents import parse as user_agents_parse
12from couchers import errors, urls
13from couchers.config import config
14from couchers.constants import PHONE_REVERIFICATION_INTERVAL, SMS_CODE_ATTEMPTS, SMS_CODE_LIFETIME
15from couchers.crypto import (
16 b64decode,
17 b64encode,
18 generate_invite_code,
19 hash_password,
20 simple_decrypt,
21 simple_encrypt,
22 urlsafe_secure_token,
23 verify_password,
24 verify_token,
25)
26from couchers.helpers.geoip import geoip_approximate_location
27from couchers.helpers.strong_verification import get_strong_verification_fields, has_strong_verification
28from couchers.jobs.enqueue import queue_job
29from couchers.materialized_views import LiteUser
30from couchers.metrics import (
31 account_deletion_initiations_counter,
32 strong_verification_data_deletions_counter,
33 strong_verification_initiations_counter,
34)
35from couchers.models import (
36 AccountDeletionReason,
37 AccountDeletionToken,
38 ContributeOption,
39 ContributorForm,
40 HostRequest,
41 HostRequestStatus,
42 InviteCode,
43 ModNote,
44 ProfilePublicVisibility,
45 StrongVerificationAttempt,
46 StrongVerificationAttemptStatus,
47 StrongVerificationCallbackEvent,
48 User,
49 UserSession,
50 Volunteer,
51)
52from couchers.notifications.notify import notify
53from couchers.phone import sms
54from couchers.phone.check import is_e164_format, is_known_operator
55from couchers.servicers.api import lite_user_to_pb
56from couchers.servicers.references import get_pending_references_to_write, reftype2api
57from couchers.sql import couchers_select as select
58from couchers.tasks import (
59 maybe_send_contributor_form_email,
60 send_account_deletion_report_email,
61 send_email_changed_confirmation_to_new_email,
62)
63from couchers.utils import (
64 Timestamp_from_datetime,
65 create_lang_cookie,
66 date_to_api,
67 dt_from_page_token,
68 dt_to_page_token,
69 is_valid_email,
70 now,
71 to_aware_datetime,
72)
73from proto import account_pb2, account_pb2_grpc, auth_pb2, iris_pb2_grpc, notification_data_pb2
74from proto.google.api import httpbody_pb2
75from proto.internal import jobs_pb2, verification_pb2
77logger = logging.getLogger(__name__)
78logger.setLevel(logging.DEBUG)
80contributeoption2sql = {
81 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None,
82 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes,
83 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe,
84 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no,
85}
87contributeoption2api = {
88 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED,
89 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES,
90 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE,
91 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO,
92}
94profilepublicitysetting2sql = {
95 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None,
96 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing,
97 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only,
98 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited,
99 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most,
100 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full,
101}
103profilepublicitysetting2api = {
104 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN,
105 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING,
106 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY,
107 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED,
108 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST,
109 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL,
110}
112MAX_PAGINATION_LENGTH = 50
115def mod_note_to_pb(note: ModNote):
116 return account_pb2.ModNote(
117 note_id=note.id,
118 note_content=note.note_content,
119 created=Timestamp_from_datetime(note.created),
120 acknowledged=Timestamp_from_datetime(note.acknowledged) if note.acknowledged else None,
121 )
124def abort_on_invalid_password(password, context):
125 """
126 Internal utility function: given a password, aborts if password is unforgivably insecure
127 """
128 if len(password) < 8:
129 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.PASSWORD_TOO_SHORT)
131 if len(password) > 256:
132 # Hey, what are you trying to do? Give us a DDOS attack?
133 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.PASSWORD_TOO_LONG)
135 # check for most common weak passwords (not meant to be an exhaustive check!)
136 if password.lower() in ("password", "12345678", "couchers", "couchers1"):
137 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INSECURE_PASSWORD)
140def _format_volunteer_link(volunteer, username):
141 if volunteer.link_type:
142 return dict(link_type=volunteer.link_type, link_text=volunteer.link_text, link_url=volunteer.link_url)
143 else:
144 return dict(
145 link_type="couchers",
146 link_text=f"@{username}",
147 link_url=urls.user_link(username=username),
148 )
151def _volunteer_info_to_pb(volunteer, username):
152 return account_pb2.GetMyVolunteerInfoRes(
153 display_name=volunteer.display_name,
154 display_location=volunteer.display_location,
155 role=volunteer.role,
156 started_volunteering=date_to_api(volunteer.started_volunteering),
157 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None,
158 show_on_team_page=volunteer.show_on_team_page,
159 **_format_volunteer_link(volunteer, username),
160 )
163class Account(account_pb2_grpc.AccountServicer):
164 def GetAccountInfo(self, request, context, session):
165 user, volunteer = session.execute(
166 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
167 ).one()
169 return account_pb2.GetAccountInfoRes(
170 username=user.username,
171 email=user.email,
172 phone=user.phone if (user.phone_is_verified or not user.phone_code_expired) else None,
173 has_donated=user.has_donated,
174 phone_verified=user.phone_is_verified,
175 profile_complete=user.has_completed_profile,
176 my_home_complete=user.has_completed_my_home,
177 timezone=user.timezone,
178 is_superuser=user.is_superuser,
179 ui_language_preference=user.ui_language_preference,
180 profile_public_visibility=profilepublicitysetting2api[user.public_visibility],
181 is_volunteer=volunteer is not None,
182 **get_strong_verification_fields(session, user),
183 )
185 def ChangePasswordV2(self, request, context, session):
186 """
187 Changes the user's password. They have to confirm their old password just in case.
189 If they didn't have an old password previously, then we don't check that.
190 """
191 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
193 if not verify_password(user.hashed_password, request.old_password):
194 # wrong password
195 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PASSWORD)
197 abort_on_invalid_password(request.new_password, context)
198 user.hashed_password = hash_password(request.new_password)
200 session.commit()
202 notify(
203 session,
204 user_id=user.id,
205 topic_action="password:change",
206 )
208 return empty_pb2.Empty()
210 def ChangeEmailV2(self, request, context, session):
211 """
212 Change the user's email address.
214 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one.
216 Otherwise they need to confirm twice, via an email sent to each of their old and new emails.
218 In all confirmation emails, the user must click on the confirmation link.
219 """
220 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
222 # check password first
223 if not verify_password(user.hashed_password, request.password):
224 # wrong password
225 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PASSWORD)
227 # not a valid email
228 if not is_valid_email(request.new_email):
229 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL)
231 # email already in use (possibly by this user)
232 if session.execute(select(User).where(User.email == request.new_email)).scalar_one_or_none():
233 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL)
235 user.new_email = request.new_email
236 user.new_email_token = urlsafe_secure_token()
237 user.new_email_token_created = now()
238 user.new_email_token_expiry = now() + timedelta(hours=2)
240 send_email_changed_confirmation_to_new_email(session, user)
242 # will still go into old email
243 notify(
244 session,
245 user_id=user.id,
246 topic_action="email_address:change",
247 data=notification_data_pb2.EmailAddressChange(
248 new_email=request.new_email,
249 ),
250 )
252 # session autocommit
253 return empty_pb2.Empty()
255 def ChangeLanguagePreference(self, request, context, session):
256 # select the user from the db
257 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
259 # update the user's preference
260 user.ui_language_preference = request.ui_language_preference
261 context.set_cookies(create_lang_cookie(request.ui_language_preference))
263 return empty_pb2.Empty()
265 def FillContributorForm(self, request, context, session):
266 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
268 form = request.contributor_form
270 form = ContributorForm(
271 user=user,
272 ideas=form.ideas or None,
273 features=form.features or None,
274 experience=form.experience or None,
275 contribute=contributeoption2sql[form.contribute],
276 contribute_ways=form.contribute_ways,
277 expertise=form.expertise or None,
278 )
280 session.add(form)
281 session.flush()
282 maybe_send_contributor_form_email(session, form)
284 user.filled_contributor_form = True
286 return empty_pb2.Empty()
288 def GetContributorFormInfo(self, request, context, session):
289 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
291 return account_pb2.GetContributorFormInfoRes(
292 filled_contributor_form=user.filled_contributor_form,
293 )
295 def ChangePhone(self, request, context, session):
296 phone = request.phone
297 # early quick validation
298 if phone and not is_e164_format(phone):
299 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_PHONE)
301 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
302 if not user.has_donated:
303 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NOT_DONATED)
305 if not phone:
306 user.phone = None
307 user.phone_verification_verified = None
308 user.phone_verification_token = None
309 user.phone_verification_attempts = 0
310 return empty_pb2.Empty()
312 if not is_known_operator(phone):
313 context.abort(grpc.StatusCode.UNIMPLEMENTED, errors.UNRECOGNIZED_PHONE_NUMBER)
315 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL:
316 context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, errors.REVERIFICATION_TOO_EARLY)
318 token = sms.generate_random_code()
319 result = sms.send_sms(phone, sms.format_message(token))
321 if result == "success":
322 user.phone = phone
323 user.phone_verification_verified = None
324 user.phone_verification_token = token
325 user.phone_verification_sent = now()
326 user.phone_verification_attempts = 0
328 notify(
329 session,
330 user_id=user.id,
331 topic_action="phone_number:change",
332 data=notification_data_pb2.PhoneNumberChange(
333 phone=phone,
334 ),
335 )
337 return empty_pb2.Empty()
339 context.abort(grpc.StatusCode.UNIMPLEMENTED, result)
341 def VerifyPhone(self, request, context, session):
342 if not sms.looks_like_a_code(request.token):
343 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.WRONG_SMS_CODE)
345 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
346 if user.phone_verification_token is None:
347 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PENDING_VERIFICATION)
349 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME:
350 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PENDING_VERIFICATION)
352 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS:
353 context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED, errors.TOO_MANY_SMS_CODE_ATTEMPTS)
355 if not verify_token(request.token, user.phone_verification_token):
356 user.phone_verification_attempts += 1
357 session.commit()
358 context.abort(grpc.StatusCode.NOT_FOUND, errors.WRONG_SMS_CODE)
360 # Delete verifications from everyone else that has this number
361 session.execute(
362 update(User)
363 .where(User.phone == user.phone)
364 .where(User.id != context.user_id)
365 .values(
366 {
367 "phone_verification_verified": None,
368 "phone_verification_attempts": 0,
369 "phone_verification_token": None,
370 "phone": None,
371 }
372 )
373 .execution_options(synchronize_session=False)
374 )
376 user.phone_verification_token = None
377 user.phone_verification_verified = now()
378 user.phone_verification_attempts = 0
380 notify(
381 session,
382 user_id=user.id,
383 topic_action="phone_number:verify",
384 data=notification_data_pb2.PhoneNumberVerify(
385 phone=user.phone,
386 ),
387 )
389 return empty_pb2.Empty()
391 def InitiateStrongVerification(self, request, context, session):
392 if not config["ENABLE_STRONG_VERIFICATION"]:
393 context.abort(grpc.StatusCode.UNAVAILABLE, errors.STRONG_VERIFICATION_DISABLED)
395 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
396 existing_verification = session.execute(
397 select(StrongVerificationAttempt)
398 .where(StrongVerificationAttempt.user_id == user.id)
399 .where(StrongVerificationAttempt.is_valid)
400 ).scalar_one_or_none()
401 if existing_verification:
402 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.STRONG_VERIFICATION_ALREADY_VERIFIED)
404 strong_verification_initiations_counter.labels(user.gender).inc()
406 verification_attempt_token = urlsafe_secure_token()
407 # this is the iris reference data, they will return this on every callback, it also doubles as webhook auth given lack of it otherwise
408 reference = b64encode(
409 simple_encrypt(
410 "iris_callback",
411 verification_pb2.VerificationReferencePayload(
412 verification_attempt_token=verification_attempt_token,
413 user_id=user.id,
414 ).SerializeToString(),
415 )
416 )
417 response = requests.post(
418 "https://passportreader.app/api/v1/session.create",
419 auth=(config["IRIS_ID_PUBKEY"], config["IRIS_ID_SECRET"]),
420 json={
421 "callback_url": f"{config['BACKEND_BASE_URL']}/iris/webhook",
422 "face_verification": False,
423 "reference": reference,
424 },
425 timeout=10,
426 )
428 if response.status_code != 200:
429 raise Exception(f"Iris didn't return 200: {response.text}")
431 iris_session_id = response.json()["id"]
432 token = response.json()["token"]
433 session.add(
434 StrongVerificationAttempt(
435 user_id=user.id,
436 verification_attempt_token=verification_attempt_token,
437 iris_session_id=iris_session_id,
438 iris_token=token,
439 )
440 )
442 redirect_params = {
443 "token": token,
444 "redirect_url": urls.complete_strong_verification_url(
445 verification_attempt_token=verification_attempt_token
446 ),
447 }
448 redirect_url = "https://passportreader.app/open?" + urlencode(redirect_params)
450 return account_pb2.InitiateStrongVerificationRes(
451 verification_attempt_token=verification_attempt_token,
452 redirect_url=redirect_url,
453 )
455 def GetStrongVerificationAttemptStatus(self, request, context, session):
456 verification_attempt = session.execute(
457 select(StrongVerificationAttempt)
458 .where(StrongVerificationAttempt.user_id == context.user_id)
459 .where(StrongVerificationAttempt.is_visible)
460 .where(StrongVerificationAttempt.verification_attempt_token == request.verification_attempt_token)
461 ).scalar_one_or_none()
462 if not verification_attempt:
463 context.abort(grpc.StatusCode.NOT_FOUND, errors.STRONG_VERIFICATION_ATTEMPT_NOT_FOUND)
464 status_to_pb = {
465 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED,
466 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP,
467 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP,
468 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND,
469 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED,
470 }
471 return account_pb2.GetStrongVerificationAttemptStatusRes(
472 status=status_to_pb.get(
473 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN
474 ),
475 )
477 def DeleteStrongVerificationData(self, request, context, session):
478 verification_attempts = (
479 session.execute(
480 select(StrongVerificationAttempt)
481 .where(StrongVerificationAttempt.user_id == context.user_id)
482 .where(StrongVerificationAttempt.has_full_data)
483 )
484 .scalars()
485 .all()
486 )
487 for verification_attempt in verification_attempts:
488 verification_attempt.status = StrongVerificationAttemptStatus.deleted
489 verification_attempt.has_full_data = False
490 verification_attempt.passport_encrypted_data = None
491 verification_attempt.passport_date_of_birth = None
492 verification_attempt.passport_sex = None
493 session.flush()
494 # double check:
495 verification_attempts = (
496 session.execute(
497 select(StrongVerificationAttempt)
498 .where(StrongVerificationAttempt.user_id == context.user_id)
499 .where(StrongVerificationAttempt.has_full_data)
500 )
501 .scalars()
502 .all()
503 )
504 assert len(verification_attempts) == 0
506 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
507 strong_verification_data_deletions_counter.labels(user.gender).inc()
509 return empty_pb2.Empty()
511 def DeleteAccount(self, request, context, session):
512 """
513 Triggers email with token to confirm deletion
515 Frontend should confirm via unique string (i.e. username) before this is called
516 """
517 if not request.confirm:
518 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.MUST_CONFIRM_ACCOUNT_DELETE)
520 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
522 reason = request.reason.strip()
523 if reason:
524 reason = AccountDeletionReason(user_id=user.id, reason=reason)
525 session.add(reason)
526 session.flush()
527 send_account_deletion_report_email(session, reason)
529 token = AccountDeletionToken(token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2))
531 notify(
532 session,
533 user_id=user.id,
534 topic_action="account_deletion:start",
535 data=notification_data_pb2.AccountDeletionStart(
536 deletion_token=token.token,
537 ),
538 )
539 session.add(token)
541 account_deletion_initiations_counter.labels(user.gender).inc()
543 return empty_pb2.Empty()
545 def ListModNotes(self, request, context, session):
546 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
548 notes = (
549 session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.created.asc()))
550 .scalars()
551 .all()
552 )
554 return account_pb2.ListModNotesRes(mod_notes=[mod_note_to_pb(note) for note in notes])
556 def ListActiveSessions(self, request, context, session):
557 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
558 page_token = dt_from_page_token(request.page_token) if request.page_token else now()
560 user_sessions = (
561 session.execute(
562 select(UserSession)
563 .where(UserSession.user_id == context.user_id)
564 .where(UserSession.is_valid)
565 .where(UserSession.is_api_key == False)
566 .where(UserSession.last_seen <= page_token)
567 .order_by(UserSession.last_seen.desc())
568 .limit(page_size + 1)
569 )
570 .scalars()
571 .all()
572 )
574 def _active_session_to_pb(user_session):
575 user_agent = user_agents_parse(user_session.user_agent or "")
576 return account_pb2.ActiveSession(
577 created=Timestamp_from_datetime(user_session.created),
578 expiry=Timestamp_from_datetime(user_session.expiry),
579 last_seen=Timestamp_from_datetime(user_session.last_seen),
580 operating_system=user_agent.os.family,
581 browser=user_agent.browser.family,
582 device=user_agent.device.family,
583 approximate_location=geoip_approximate_location(user_session.ip_address) or "Unknown",
584 is_current_session=user_session.token == context.token,
585 )
587 return account_pb2.ListActiveSessionsRes(
588 active_sessions=list(map(_active_session_to_pb, user_sessions[:page_size])),
589 next_page_token=dt_to_page_token(user_sessions[-1].last_seen) if len(user_sessions) > page_size else None,
590 )
592 def LogOutSession(self, request, context, session):
593 session.execute(
594 update(UserSession)
595 .where(UserSession.token != context.token)
596 .where(UserSession.user_id == context.user_id)
597 .where(UserSession.is_valid)
598 .where(UserSession.is_api_key == False)
599 .where(UserSession.created == to_aware_datetime(request.created))
600 .values(expiry=func.now())
601 .execution_options(synchronize_session=False)
602 )
603 return empty_pb2.Empty()
605 def LogOutOtherSessions(self, request, context, session):
606 if not request.confirm:
607 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.MUST_CONFIRM_LOGOUT_OTHER_SESSIONS)
609 session.execute(
610 update(UserSession)
611 .where(UserSession.token != context.token)
612 .where(UserSession.user_id == context.user_id)
613 .where(UserSession.is_valid)
614 .where(UserSession.is_api_key == False)
615 .values(expiry=func.now())
616 .execution_options(synchronize_session=False)
617 )
618 return empty_pb2.Empty()
620 def SetProfilePublicVisibility(self, request, context, session):
621 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
622 user.public_visibility = profilepublicitysetting2sql[request.profile_public_visibility]
623 user.has_modified_public_visibility = True
624 return empty_pb2.Empty()
626 def CreateInviteCode(self, request, context, session):
627 code = generate_invite_code()
628 session.add(InviteCode(id=code, creator_user_id=context.user_id))
630 return account_pb2.CreateInviteCodeRes(
631 code=code,
632 url=urls.invite_code_link(code=code),
633 )
635 def DisableInviteCode(self, request, context, session):
636 invite = session.execute(
637 select(InviteCode).where(InviteCode.id == request.code, InviteCode.creator_user_id == context.user_id)
638 ).scalar_one_or_none()
640 if not invite:
641 context.abort(grpc.StatusCode.NOT_FOUND, errors.NOT_FOUND)
643 invite.disabled = func.now()
644 session.commit()
646 return empty_pb2.Empty()
648 def ListInviteCodes(self, request, context, session):
649 results = session.execute(
650 select(
651 InviteCode.id,
652 InviteCode.created,
653 InviteCode.disabled,
654 func.count(User.id).label("num_users"),
655 )
656 .outerjoin(User, User.invite_code_id == InviteCode.id)
657 .where(InviteCode.creator_user_id == context.user_id)
658 .group_by(InviteCode.id, InviteCode.disabled)
659 .order_by(func.count(User.id).desc(), InviteCode.disabled)
660 ).all()
662 return account_pb2.ListInviteCodesRes(
663 invite_codes=[
664 account_pb2.InviteCodeInfo(
665 code=code_id,
666 created=Timestamp_from_datetime(created),
667 disabled=Timestamp_from_datetime(disabled) if disabled else None,
668 uses=len_users,
669 url=urls.invite_code_link(code=code_id),
670 )
671 for code_id, created, disabled, len_users in results
672 ]
673 )
675 def GetReminders(self, request, context, session):
676 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
678 # responding to reqs comes first in desc order of when they were received
679 pending_host_requests = session.execute(
680 select(HostRequest.conversation_id, LiteUser)
681 .join(LiteUser, LiteUser.id == HostRequest.surfer_user_id)
682 .where_users_column_visible(context, HostRequest.surfer_user_id)
683 .where(HostRequest.host_user_id == context.user_id)
684 .where(HostRequest.status == HostRequestStatus.pending)
685 .where(HostRequest.start_time > func.now())
686 .order_by(HostRequest.conversation_id.asc())
687 ).all()
688 reminders = [
689 account_pb2.Reminder(
690 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder(
691 host_request_id=host_request_id,
692 surfer_user=lite_user_to_pb(lite_user),
693 )
694 )
695 for host_request_id, lite_user in pending_host_requests
696 ]
698 # references come second, in order of deadline, desc
699 reminders += [
700 account_pb2.Reminder(
701 write_reference_reminder=account_pb2.WriteReferenceReminder(
702 host_request_id=host_request_id,
703 reference_type=reftype2api[reference_type],
704 other_user=lite_user_to_pb(lite_user),
705 )
706 )
707 for host_request_id, reference_type, _, lite_user in get_pending_references_to_write(session, context)
708 ]
710 if not user.has_completed_profile:
711 reminders.append(account_pb2.Reminder(complete_profile_reminder=account_pb2.CompleteProfileReminder()))
713 if not has_strong_verification(session, user):
714 reminders.append(
715 account_pb2.Reminder(complete_verification_reminder=account_pb2.CompleteVerificationReminder())
716 )
718 return account_pb2.GetRemindersRes(reminders=reminders)
720 def GetMyVolunteerInfo(self, request, context, session):
721 user, volunteer = session.execute(
722 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
723 ).one()
724 if not volunteer:
725 context.abort(grpc.StatusCode.NOT_FOUND, errors.NOT_A_VOLUNTEER)
726 return _volunteer_info_to_pb(volunteer, user.username)
728 def UpdateMyVolunteerInfo(self, request, context, session):
729 user, volunteer = session.execute(
730 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
731 ).one()
732 if not volunteer:
733 context.abort(grpc.StatusCode.NOT_FOUND, errors.NOT_A_VOLUNTEER)
735 if request.HasField("display_name"):
736 volunteer.display_name = request.display_name.value or None
738 if request.HasField("display_location"):
739 volunteer.display_location = request.display_location.value or None
741 if request.HasField("show_on_team_page"):
742 volunteer.show_on_team_page = request.show_on_team_page.value
744 if request.HasField("link_type") or request.HasField("link_text") or request.HasField("link_url"):
745 link_type = request.link_type.value or volunteer.link_type
746 link_text = request.link_text.value or volunteer.link_text
747 link_url = request.link_url.value or volunteer.link_url
748 if link_type == "couchers":
749 # this is the default
750 link_type = None
751 link_text = None
752 link_url = None
753 elif link_type == "linkedin":
754 # this is the username
755 link_text = link_text
756 link_url = f"https://www.linkedin.com/in/{link_text}/"
757 elif link_type == "email":
758 if not is_valid_email(link_text):
759 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL)
760 link_url = f"mailto:{link_text}"
761 elif link_type == "website":
762 if not link_url.startswith("https://") or "/" in link_text or link_text not in link_url:
763 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_WEBSITE_URL)
764 else:
765 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_LINK_TYPE)
766 volunteer.link_type = link_type
767 volunteer.link_text = link_text
768 volunteer.link_url = link_url
770 session.flush()
772 return _volunteer_info_to_pb(volunteer, user.username)
775class Iris(iris_pb2_grpc.IrisServicer):
776 def Webhook(self, request, context, session):
777 json_data = json.loads(request.data)
778 reference_payload = verification_pb2.VerificationReferencePayload.FromString(
779 simple_decrypt("iris_callback", b64decode(json_data["session_reference"]))
780 )
781 # if we make it past the decrypt, we consider this webhook authenticated
782 verification_attempt_token = reference_payload.verification_attempt_token
783 user_id = reference_payload.user_id
785 verification_attempt = session.execute(
786 select(StrongVerificationAttempt)
787 .where(StrongVerificationAttempt.user_id == reference_payload.user_id)
788 .where(StrongVerificationAttempt.verification_attempt_token == reference_payload.verification_attempt_token)
789 .where(StrongVerificationAttempt.iris_session_id == json_data["session_id"])
790 ).scalar_one()
791 iris_status = json_data["session_state"]
792 session.add(
793 StrongVerificationCallbackEvent(
794 verification_attempt_id=verification_attempt.id,
795 iris_status=iris_status,
796 )
797 )
798 if iris_status == "INITIATED":
799 # the user opened the session in the app
800 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app
801 elif iris_status == "COMPLETED":
802 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
803 elif iris_status == "APPROVED":
804 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
805 session.commit()
806 # background worker will go and sort this one out
807 queue_job(
808 session,
809 job_type="finalize_strong_verification",
810 payload=jobs_pb2.FinalizeStrongVerificationPayload(verification_attempt_id=verification_attempt.id),
811 priority=8,
812 )
813 elif iris_status in ["FAILED", "ABORTED", "REJECTED"]:
814 verification_attempt.status = StrongVerificationAttemptStatus.failed
816 return httpbody_pb2.HttpBody(
817 content_type="application/json",
818 # json.dumps escapes non-ascii characters
819 data=json.dumps({"success": True}).encode("ascii"),
820 )