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