Coverage for app/backend/src/couchers/servicers/account.py: 92%
333 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-25 17:29 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-25 17:29 +0000
1import json
2import logging
3from datetime import UTC, datetime, 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 exists, func, update
12from user_agents import parse as user_agents_parse
14from couchers import urls
15from couchers.config import config
16from couchers.constants import 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.event_log import log_event
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
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 HostingStatus,
47 HostRequest,
48 HostRequestStatus,
49 InviteCode,
50 Message,
51 ModNote,
52 ProfilePublicVisibility,
53 StrongVerificationAttempt,
54 StrongVerificationAttemptStatus,
55 StrongVerificationCallbackEvent,
56 User,
57 UserSession,
58 Volunteer,
59)
60from couchers.models.notifications import NotificationTopicAction
61from couchers.notifications.notify import notify
62from couchers.phone import sms
63from couchers.phone.check import is_e164_format, is_known_operator
64from couchers.proto import account_pb2, account_pb2_grpc, auth_pb2, iris_pb2_grpc, notification_data_pb2
65from couchers.proto.google.api import httpbody_pb2
66from couchers.proto.internal import internal_pb2, jobs_pb2
67from couchers.servicers.api import lite_user_to_pb
68from couchers.servicers.public import format_volunteer_link
69from couchers.servicers.references import get_pending_references_to_write, reftype2api
70from couchers.sql import where_moderated_content_visible, where_users_column_visible
71from couchers.tasks import (
72 maybe_send_contributor_form_email,
73 send_account_deletion_report_email,
74 send_email_changed_confirmation_to_new_email,
75)
76from couchers.utils import (
77 Timestamp_from_datetime,
78 create_lang_cookie,
79 date_to_api,
80 dt_from_page_token,
81 dt_to_page_token,
82 is_valid_email,
83 now,
84 to_aware_datetime,
85)
87logger = logging.getLogger(__name__)
88logger.setLevel(logging.DEBUG)
90contributeoption2sql = {
91 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None,
92 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes,
93 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe,
94 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no,
95}
97contributeoption2api = {
98 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED,
99 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES,
100 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE,
101 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO,
102}
104profilepublicitysetting2sql = {
105 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None,
106 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing,
107 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only,
108 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited,
109 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most,
110 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full,
111}
113profilepublicitysetting2api = {
114 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN,
115 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING,
116 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY,
117 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED,
118 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST,
119 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL,
120}
122MAX_PAGINATION_LENGTH = 50
125def mod_note_to_pb(note: ModNote) -> account_pb2.ModNote:
126 return account_pb2.ModNote(
127 note_id=note.id,
128 note_content=note.note_content,
129 created=Timestamp_from_datetime(note.created),
130 acknowledged=Timestamp_from_datetime(note.acknowledged) if note.acknowledged else None,
131 )
134def abort_on_invalid_password(password: str, context: CouchersContext) -> None:
135 """
136 Internal utility function: given a password, aborts if password is unforgivably insecure
137 """
138 if len(password) < 8:
139 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_short")
141 if len(password) > 256:
142 # Hey, what are you trying to do? Give us a DDOS attack?
143 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_long")
145 # check for the most common weak passwords (not meant to be an exhaustive check!)
146 if password.lower() in ("password", "12345678", "couchers", "couchers1"):
147 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "insecure_password")
150def _volunteer_info_to_pb(volunteer: Volunteer, username: str) -> account_pb2.GetMyVolunteerInfoRes:
151 return account_pb2.GetMyVolunteerInfoRes(
152 display_name=volunteer.display_name,
153 display_location=volunteer.display_location,
154 role=volunteer.role,
155 started_volunteering=date_to_api(volunteer.started_volunteering),
156 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None,
157 show_on_team_page=volunteer.show_on_team_page,
158 **format_volunteer_link(volunteer, username),
159 )
162class Account(account_pb2_grpc.AccountServicer):
163 def GetAccountInfo(
164 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
165 ) -> account_pb2.GetAccountInfoRes:
166 user, volunteer = session.execute(
167 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
168 ).one()
170 # Test experimentation integration - check if user is in the test gate
171 # Create 'test_growthbook_integration' in GrowthBook to test
172 test_gate = context.get_boolean_value("test_growthbook_integration", default=False)
173 logger.info(f"Experimentation gate 'test_growthbook_integration' for user {user.id}: {test_gate}")
175 # The donation drive (and its banner) is controlled by the donation_drive_start flag: a Unix
176 # epoch in seconds when a drive is running, or 0/unset when there's no drive. Users who haven't
177 # donated since the drive started see the banner.
178 drive_start_epoch = context.get_integer_value("donation_drive_start", 0)
179 drive_start = datetime.fromtimestamp(drive_start_epoch, tz=UTC) if drive_start_epoch else None
180 should_show_donation_banner = drive_start is not None and (
181 user.last_donated is None or user.last_donated < drive_start
182 )
184 return account_pb2.GetAccountInfoRes(
185 username=user.username,
186 email=user.email,
187 phone=user.phone if (user.phone_is_verified or not user.phone_code_expired) else None,
188 has_donated=user.last_donated is not None,
189 phone_verified=user.phone_is_verified,
190 profile_complete=has_completed_profile(session, user),
191 my_home_complete=user.has_completed_my_home,
192 timezone=user.timezone,
193 is_superuser=user.is_superuser,
194 ui_language_preference=user.ui_language_preference,
195 profile_public_visibility=profilepublicitysetting2api[user.public_visibility],
196 is_volunteer=volunteer is not None,
197 should_show_donation_banner=should_show_donation_banner,
198 **get_strong_verification_fields(session, user),
199 )
201 def ChangePasswordV2(
202 self, request: account_pb2.ChangePasswordV2Req, context: CouchersContext, session: Session
203 ) -> empty_pb2.Empty:
204 """
205 Changes the user's password. They have to confirm their old password just in case.
207 If they didn't have an old password previously, then we don't check that.
208 """
209 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
211 if not verify_password(user.hashed_password, request.old_password):
212 # wrong password
213 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password")
215 abort_on_invalid_password(request.new_password, context)
216 user.hashed_password = hash_password(request.new_password)
218 session.commit()
220 notify(
221 session,
222 user_id=user.id,
223 topic_action=NotificationTopicAction.password__change,
224 key="",
225 )
226 log_event(context, session, "account.password_changed", {})
228 return empty_pb2.Empty()
230 def ChangeEmailV2(
231 self, request: account_pb2.ChangeEmailV2Req, context: CouchersContext, session: Session
232 ) -> empty_pb2.Empty:
233 """
234 Change the user's email address.
236 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one.
238 Otherwise they need to confirm twice, via an email sent to each of their old and new emails.
240 In all confirmation emails, the user must click on the confirmation link.
241 """
242 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
244 # check password first
245 if not verify_password(user.hashed_password, request.password):
246 # wrong password
247 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password")
249 # not a valid email
250 if not is_valid_email(request.new_email):
251 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
253 # email already in use (possibly by this user)
254 if session.execute(select(User).where(User.email == request.new_email)).scalar_one_or_none():
255 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
257 user.new_email = request.new_email
258 user.new_email_token = urlsafe_secure_token()
259 user.new_email_token_created = now()
260 user.new_email_token_expiry = now() + timedelta(hours=2)
262 send_email_changed_confirmation_to_new_email(context, session, user)
264 # will still go into old email
265 notify(
266 session,
267 user_id=user.id,
268 topic_action=NotificationTopicAction.email_address__change,
269 key="",
270 data=notification_data_pb2.EmailAddressChange(
271 new_email=request.new_email,
272 ),
273 )
275 log_event(context, session, "account.email_change_initiated", {})
277 # session autocommit
278 return empty_pb2.Empty()
280 def ChangeLanguagePreference(
281 self, request: account_pb2.ChangeLanguagePreferenceReq, context: CouchersContext, session: Session
282 ) -> empty_pb2.Empty:
283 # select the user from the db
284 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
286 # update the user's preference
287 user.ui_language_preference = request.ui_language_preference
288 context.set_cookies(create_lang_cookie(request.ui_language_preference))
290 return empty_pb2.Empty()
292 def FillContributorForm(
293 self, request: account_pb2.FillContributorFormReq, context: CouchersContext, session: Session
294 ) -> empty_pb2.Empty:
295 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
297 form = request.contributor_form
299 form = ContributorForm(
300 user_id=user.id,
301 ideas=form.ideas or None,
302 features=form.features or None,
303 experience=form.experience or None,
304 contribute=contributeoption2sql[form.contribute],
305 contribute_ways=form.contribute_ways,
306 expertise=form.expertise or None,
307 )
309 session.add(form)
310 session.flush()
311 maybe_send_contributor_form_email(session, form)
313 user.filled_contributor_form = True
314 log_event(context, session, "contributor.form_submitted", {"is_filled": form.is_filled})
316 return empty_pb2.Empty()
318 def GetContributorFormInfo(
319 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
320 ) -> account_pb2.GetContributorFormInfoRes:
321 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
323 return account_pb2.GetContributorFormInfoRes(
324 filled_contributor_form=user.filled_contributor_form,
325 )
327 def ChangePhone(
328 self, request: account_pb2.ChangePhoneReq, context: CouchersContext, session: Session
329 ) -> empty_pb2.Empty:
330 phone = request.phone
331 # early quick validation
332 if phone and not is_e164_format(phone):
333 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_phone")
335 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
336 if user.last_donated is None:
337 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_donated")
339 if not phone:
340 user.phone = None
341 user.phone_verification_verified = None
342 user.phone_verification_token = None
343 user.phone_verification_attempts = 0
344 return empty_pb2.Empty()
346 # Removing a number is always allowed; sending a verification SMS is gated.
347 if not context.get_boolean_value("sms_enabled", default=False):
348 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "sms_disabled")
350 if not is_known_operator(phone):
351 context.abort_with_error_code(grpc.StatusCode.UNIMPLEMENTED, "unrecognized_phone_number")
353 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL:
354 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "reverification_too_early")
356 token = sms.generate_random_code()
357 result = sms.send_sms(phone, sms.format_message(token))
359 if result == "success":
360 user.phone = phone
361 user.phone_verification_verified = None
362 user.phone_verification_token = token
363 user.phone_verification_sent = now()
364 user.phone_verification_attempts = 0
366 notify(
367 session,
368 user_id=user.id,
369 topic_action=NotificationTopicAction.phone_number__change,
370 key="",
371 data=notification_data_pb2.PhoneNumberChange(
372 phone=phone,
373 ),
374 )
376 return empty_pb2.Empty()
378 context.abort(grpc.StatusCode.UNIMPLEMENTED, result)
380 def VerifyPhone(
381 self, request: account_pb2.VerifyPhoneReq, context: CouchersContext, session: Session
382 ) -> empty_pb2.Empty:
383 if not sms.looks_like_a_code(request.token): 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true
384 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "wrong_sms_code")
386 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
387 if user.phone_verification_token is None:
388 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification")
390 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME: 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true
391 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification")
393 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS:
394 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "too_many_sms_code_attempts")
396 if not verify_token(request.token, user.phone_verification_token):
397 user.phone_verification_attempts += 1
398 session.commit()
399 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "wrong_sms_code")
401 # Delete verifications from everyone else that has this number
402 session.execute(
403 update(User)
404 .where(User.phone == user.phone)
405 .where(User.id != context.user_id)
406 .values(
407 {
408 "phone_verification_verified": None,
409 "phone_verification_attempts": 0,
410 "phone_verification_token": None,
411 "phone": None,
412 }
413 )
414 .execution_options(synchronize_session=False)
415 )
417 user.phone_verification_token = None
418 user.phone_verification_verified = now()
419 user.phone_verification_attempts = 0
421 notify(
422 session,
423 user_id=user.id,
424 topic_action=NotificationTopicAction.phone_number__verify,
425 key="",
426 data=notification_data_pb2.PhoneNumberVerify(
427 phone=user.phone,
428 ),
429 )
431 return empty_pb2.Empty()
433 def InitiateStrongVerification(
434 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
435 ) -> account_pb2.InitiateStrongVerificationRes:
436 if not context.get_boolean_value("strong_verification_enabled", default=False):
437 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "strong_verification_disabled")
439 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
440 existing_verification = session.execute(
441 select(StrongVerificationAttempt)
442 .where(StrongVerificationAttempt.user_id == user.id)
443 .where(StrongVerificationAttempt.is_valid)
444 ).scalar_one_or_none()
445 if existing_verification: 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true
446 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "strong_verification_already_verified")
448 strong_verification_initiations_counter.labels(user.gender).inc()
449 log_event(context, session, "verification.strong_initiated", {"gender": user.gender})
451 verification_attempt_token = urlsafe_secure_token()
452 # this is the iris reference data, they will return this on every callback, it also doubles as webhook auth given lack of it otherwise
453 reference = b64encode(
454 simple_encrypt(
455 "iris_callback",
456 internal_pb2.VerificationReferencePayload(
457 verification_attempt_token=verification_attempt_token,
458 user_id=user.id,
459 ).SerializeToString(),
460 )
461 )
462 response = requests.post(
463 "https://passportreader.app/api/v1/session.create",
464 auth=(config.IRIS_ID_PUBKEY, config.IRIS_ID_SECRET),
465 json={
466 "callback_url": f"{config.BACKEND_BASE_URL}/iris/webhook",
467 "face_verification": False,
468 "passport_only": True,
469 "reference": reference,
470 },
471 timeout=10,
472 verify="/etc/ssl/certs/ca-certificates.crt",
473 )
475 if response.status_code != 200: 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 raise Exception(f"Iris didn't return 200: {response.text}")
478 iris_session_id = response.json()["id"]
479 token = response.json()["token"]
480 session.add(
481 StrongVerificationAttempt(
482 user_id=user.id,
483 verification_attempt_token=verification_attempt_token,
484 iris_session_id=iris_session_id,
485 iris_token=token,
486 )
487 )
489 redirect_params = {
490 "token": token,
491 "redirect_url": urls.complete_strong_verification_url(
492 verification_attempt_token=verification_attempt_token
493 ),
494 }
495 redirect_url = "https://passportreader.app/open?" + urlencode(redirect_params)
497 return account_pb2.InitiateStrongVerificationRes(
498 verification_attempt_token=verification_attempt_token,
499 redirect_url=redirect_url,
500 )
502 def GetStrongVerificationAttemptStatus(
503 self, request: account_pb2.GetStrongVerificationAttemptStatusReq, context: CouchersContext, session: Session
504 ) -> account_pb2.GetStrongVerificationAttemptStatusRes:
505 verification_attempt = session.execute(
506 select(StrongVerificationAttempt)
507 .where(StrongVerificationAttempt.user_id == context.user_id)
508 .where(StrongVerificationAttempt.is_visible)
509 .where(StrongVerificationAttempt.verification_attempt_token == request.verification_attempt_token)
510 ).scalar_one_or_none()
511 if not verification_attempt: 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true
512 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "strong_verification_attempt_not_found")
513 status_to_pb = {
514 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED,
515 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP,
516 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP,
517 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND,
518 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED,
519 StrongVerificationAttemptStatus.duplicate: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED,
520 }
521 return account_pb2.GetStrongVerificationAttemptStatusRes(
522 status=status_to_pb.get(
523 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN
524 ),
525 )
527 def DeleteStrongVerificationData(
528 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
529 ) -> empty_pb2.Empty:
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 for verification_attempt in verification_attempts:
540 verification_attempt.status = StrongVerificationAttemptStatus.deleted
541 verification_attempt.has_full_data = False
542 verification_attempt.passport_encrypted_data = None
543 verification_attempt.passport_date_of_birth = None
544 verification_attempt.passport_sex = None
545 session.flush()
546 # double check:
547 verification_attempts = (
548 session.execute(
549 select(StrongVerificationAttempt)
550 .where(StrongVerificationAttempt.user_id == context.user_id)
551 .where(StrongVerificationAttempt.has_full_data)
552 )
553 .scalars()
554 .all()
555 )
556 assert len(verification_attempts) == 0
558 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
559 strong_verification_data_deletions_counter.labels(user.gender).inc()
560 log_event(context, session, "verification.strong_data_deleted", {"gender": user.gender})
562 return empty_pb2.Empty()
564 def DeleteAccount(
565 self, request: account_pb2.DeleteAccountReq, context: CouchersContext, session: Session
566 ) -> empty_pb2.Empty:
567 """
568 Triggers email with token to confirm deletion
570 Frontend should confirm via unique string (i.e. username) before this is called
571 """
572 if not request.confirm:
573 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_account_delete")
575 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
577 reason = request.reason.strip()
578 if reason:
579 deletion_reason = AccountDeletionReason(user_id=user.id, reason=reason)
580 session.add(deletion_reason)
581 session.flush()
582 send_account_deletion_report_email(session, deletion_reason)
584 token = AccountDeletionToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now() + timedelta(hours=2))
586 notify(
587 session,
588 user_id=user.id,
589 topic_action=NotificationTopicAction.account_deletion__start,
590 key="",
591 data=notification_data_pb2.AccountDeletionStart(
592 deletion_token=token.token,
593 ),
594 )
595 session.add(token)
597 account_deletion_initiations_counter.labels(user.gender).inc()
598 log_event(context, session, "account.deletion_initiated", {"gender": user.gender, "has_reason": bool(reason)})
600 return empty_pb2.Empty()
602 def ListModNotes(
603 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
604 ) -> account_pb2.ListModNotesRes:
605 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
607 notes = (
608 session.execute(select(ModNote).where(ModNote.user_id == user.id).order_by(ModNote.created.asc()))
609 .scalars()
610 .all()
611 )
613 return account_pb2.ListModNotesRes(mod_notes=[mod_note_to_pb(note) for note in notes])
615 def ListActiveSessions(
616 self, request: account_pb2.ListActiveSessionsReq, context: CouchersContext, session: Session
617 ) -> account_pb2.ListActiveSessionsRes:
618 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
619 page_token = dt_from_page_token(request.page_token) if request.page_token else now()
621 user_sessions = (
622 session.execute(
623 select(UserSession)
624 .where(UserSession.user_id == context.user_id)
625 .where(UserSession.is_valid)
626 .where(UserSession.is_api_key == False)
627 .where(UserSession.last_seen <= page_token)
628 .order_by(UserSession.last_seen.desc())
629 .limit(page_size + 1)
630 )
631 .scalars()
632 .all()
633 )
635 def _active_session_to_pb(user_session: UserSession) -> account_pb2.ActiveSession:
636 user_agent = user_agents_parse(user_session.user_agent or "")
637 return account_pb2.ActiveSession(
638 created=Timestamp_from_datetime(user_session.created),
639 expiry=Timestamp_from_datetime(user_session.expiry),
640 last_seen=Timestamp_from_datetime(user_session.last_seen),
641 operating_system=user_agent.os.family,
642 browser=user_agent.browser.family,
643 device=user_agent.device.family,
644 approximate_location=geoip_approximate_location(user_session.ip_address) or "Unknown",
645 is_current_session=user_session.token == context.token,
646 )
648 return account_pb2.ListActiveSessionsRes(
649 active_sessions=list(map(_active_session_to_pb, user_sessions[:page_size])),
650 next_page_token=dt_to_page_token(user_sessions[-1].last_seen) if len(user_sessions) > page_size else None,
651 )
653 def LogOutSession(
654 self, request: account_pb2.LogOutSessionReq, context: CouchersContext, session: Session
655 ) -> empty_pb2.Empty:
656 session.execute(
657 update(UserSession)
658 .where(UserSession.token != context.token)
659 .where(UserSession.user_id == context.user_id)
660 .where(UserSession.is_valid)
661 .where(UserSession.is_api_key == False)
662 .where(UserSession.created == to_aware_datetime(request.created))
663 .values(expiry=func.now())
664 .execution_options(synchronize_session=False)
665 )
666 return empty_pb2.Empty()
668 def LogOutOtherSessions(
669 self, request: account_pb2.LogOutOtherSessionsReq, context: CouchersContext, session: Session
670 ) -> empty_pb2.Empty:
671 if not request.confirm:
672 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_logout_other_sessions")
674 session.execute(
675 update(UserSession)
676 .where(UserSession.token != context.token)
677 .where(UserSession.user_id == context.user_id)
678 .where(UserSession.is_valid)
679 .where(UserSession.is_api_key == False)
680 .values(expiry=func.now())
681 .execution_options(synchronize_session=False)
682 )
683 return empty_pb2.Empty()
685 def SetProfilePublicVisibility(
686 self, request: account_pb2.SetProfilePublicVisibilityReq, context: CouchersContext, session: Session
687 ) -> empty_pb2.Empty:
688 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
689 user.public_visibility = profilepublicitysetting2sql[request.profile_public_visibility] # type: ignore[assignment]
690 user.has_modified_public_visibility = True
691 return empty_pb2.Empty()
693 def CreateInviteCode(
694 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
695 ) -> account_pb2.CreateInviteCodeRes:
696 code = generate_invite_code()
697 session.add(InviteCode(id=code, creator_user_id=context.user_id))
699 return account_pb2.CreateInviteCodeRes(
700 code=code,
701 url=urls.invite_code_link(code=code),
702 )
704 def DisableInviteCode(
705 self, request: account_pb2.DisableInviteCodeReq, context: CouchersContext, session: Session
706 ) -> empty_pb2.Empty:
707 invite = session.execute(
708 select(InviteCode).where(InviteCode.id == request.code, InviteCode.creator_user_id == context.user_id)
709 ).scalar_one_or_none()
711 if not invite: 711 ↛ 712line 711 didn't jump to line 712 because the condition on line 711 was never true
712 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_found")
714 invite.disabled = func.now()
715 session.commit()
717 return empty_pb2.Empty()
719 def ListInviteCodes(
720 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
721 ) -> account_pb2.ListInviteCodesRes:
722 results = session.execute(
723 select(
724 InviteCode.id,
725 InviteCode.created,
726 InviteCode.disabled,
727 func.count(User.id).label("num_users"),
728 )
729 .outerjoin(User, User.invite_code_id == InviteCode.id)
730 .where(InviteCode.creator_user_id == context.user_id)
731 .group_by(InviteCode.id, InviteCode.disabled)
732 .order_by(func.count(User.id).desc(), InviteCode.disabled)
733 ).all()
735 return account_pb2.ListInviteCodesRes(
736 invite_codes=[
737 account_pb2.InviteCodeInfo(
738 code=code_id,
739 created=Timestamp_from_datetime(created),
740 disabled=Timestamp_from_datetime(disabled) if disabled else None,
741 uses=len_users,
742 url=urls.invite_code_link(code=code_id),
743 )
744 for code_id, created, disabled, len_users in results
745 ]
746 )
748 def GetReminders(
749 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
750 ) -> account_pb2.GetRemindersRes:
751 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
753 # responding to reqs comes first in desc order of when they were received
754 host_has_sent_message = select(1).where(
755 Message.conversation_id == HostRequest.conversation_id, Message.author_id == HostRequest.recipient_user_id
756 )
757 query = select(HostRequest.conversation_id, LiteUser).join(
758 LiteUser, LiteUser.id == HostRequest.initiator_user_id
759 )
760 query = where_users_column_visible(query, context, HostRequest.initiator_user_id)
761 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=True)
762 pending_host_requests = session.execute(
763 query.where(HostRequest.recipient_user_id == context.user_id)
764 .where(HostRequest.status == HostRequestStatus.pending)
765 .where(HostRequest.start_time > func.now())
766 .where(~exists(host_has_sent_message))
767 .order_by(HostRequest.conversation_id.asc())
768 ).all()
769 reminders = [
770 account_pb2.Reminder(
771 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder(
772 host_request_id=host_request_id,
773 surfer_user=lite_user_to_pb(session, lite_user, context),
774 )
775 )
776 for host_request_id, lite_user in pending_host_requests
777 ]
779 # surfer needs to confirm accepted requests
780 confirm_query = select(HostRequest.conversation_id, LiteUser).join(
781 LiteUser, LiteUser.id == HostRequest.recipient_user_id
782 )
783 confirm_query = where_users_column_visible(confirm_query, context, HostRequest.recipient_user_id)
784 confirm_query = where_moderated_content_visible(confirm_query, context, HostRequest, is_list_operation=True)
785 accepted_host_requests = session.execute(
786 confirm_query.where(HostRequest.initiator_user_id == context.user_id)
787 .where(HostRequest.status == HostRequestStatus.accepted)
788 .where(HostRequest.end_time > func.now())
789 .order_by(HostRequest.end_time.asc())
790 ).all()
791 reminders += [
792 account_pb2.Reminder(
793 confirm_host_request_reminder=account_pb2.ConfirmHostRequestReminder(
794 host_request_id=host_request_id,
795 host_user=lite_user_to_pb(session, lite_user, context),
796 )
797 )
798 for host_request_id, lite_user in accepted_host_requests
799 ]
801 # references come second, in order of deadline, desc
802 reminders += [
803 account_pb2.Reminder(
804 write_reference_reminder=account_pb2.WriteReferenceReminder(
805 host_request_id=host_request_id,
806 reference_type=reftype2api[reference_type],
807 other_user=lite_user_to_pb(session, lite_user, context),
808 )
809 )
810 for host_request_id, reference_type, _, lite_user in get_pending_references_to_write(session, context)
811 ]
813 if not has_completed_profile(session, user):
814 reminders.append(account_pb2.Reminder(complete_profile_reminder=account_pb2.CompleteProfileReminder()))
816 if user.hosting_status in (HostingStatus.can_host, HostingStatus.maybe) and not user.has_completed_my_home:
817 reminders.append(account_pb2.Reminder(complete_my_home_reminder=account_pb2.CompleteMyHomeReminder()))
819 return account_pb2.GetRemindersRes(reminders=reminders)
821 def GetMyVolunteerInfo(
822 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
823 ) -> account_pb2.GetMyVolunteerInfoRes:
824 user, volunteer = session.execute(
825 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
826 ).one()
827 if not volunteer:
828 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer")
829 return _volunteer_info_to_pb(volunteer, user.username)
831 def UpdateMyVolunteerInfo(
832 self, request: account_pb2.UpdateMyVolunteerInfoReq, context: CouchersContext, session: Session
833 ) -> account_pb2.GetMyVolunteerInfoRes:
834 user, volunteer = session.execute(
835 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
836 ).one()
837 if not volunteer:
838 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer")
840 if request.HasField("display_name"): 840 ↛ 843line 840 didn't jump to line 843 because the condition on line 840 was always true
841 volunteer.display_name = request.display_name.value or None
843 if request.HasField("display_location"):
844 volunteer.display_location = request.display_location.value or None
846 if request.HasField("show_on_team_page"): 846 ↛ 847line 846 didn't jump to line 847 because the condition on line 846 was never true
847 volunteer.show_on_team_page = request.show_on_team_page.value
849 if request.HasField("link_type") or request.HasField("link_text") or request.HasField("link_url"): 849 ↛ 875line 849 didn't jump to line 875 because the condition on line 849 was always true
850 link_type = request.link_type.value or volunteer.link_type
851 link_text = request.link_text.value or volunteer.link_text
852 link_url = request.link_url.value or volunteer.link_url
853 if link_type == "couchers": 853 ↛ 855line 853 didn't jump to line 855 because the condition on line 853 was never true
854 # this is the default
855 link_type = None
856 link_text = None
857 link_url = None
858 elif link_type == "linkedin":
859 # this is the username
860 link_text = link_text
861 link_url = f"https://www.linkedin.com/in/{link_text}/"
862 elif link_type == "email":
863 if not is_valid_email(link_text): 863 ↛ 864line 863 didn't jump to line 864 because the condition on line 863 was never true
864 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
865 link_url = f"mailto:{link_text}"
866 elif link_type == "website": 866 ↛ 870line 866 didn't jump to line 870 because the condition on line 866 was always true
867 if not link_url.startswith("https://") or "/" in link_text or link_text not in link_url: 867 ↛ 868line 867 didn't jump to line 868 because the condition on line 867 was never true
868 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_website_url")
869 else:
870 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_link_type")
871 volunteer.link_type = link_type
872 volunteer.link_text = link_text
873 volunteer.link_url = link_url
875 session.flush()
877 return _volunteer_info_to_pb(volunteer, user.username)
880class Iris(iris_pb2_grpc.IrisServicer):
881 def Webhook(
882 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session
883 ) -> httpbody_pb2.HttpBody:
884 json_data = json.loads(request.data)
885 reference_payload = internal_pb2.VerificationReferencePayload.FromString(
886 simple_decrypt("iris_callback", b64decode(json_data["session_reference"]))
887 )
888 # if we make it past the decrypt, we consider this webhook authenticated
889 verification_attempt_token = reference_payload.verification_attempt_token
890 user_id = reference_payload.user_id
892 verification_attempt = session.execute(
893 select(StrongVerificationAttempt)
894 .where(StrongVerificationAttempt.user_id == reference_payload.user_id)
895 .where(StrongVerificationAttempt.verification_attempt_token == reference_payload.verification_attempt_token)
896 .where(StrongVerificationAttempt.iris_session_id == json_data["session_id"])
897 ).scalar_one()
898 iris_status = json_data["session_state"]
899 session.add(
900 StrongVerificationCallbackEvent(
901 verification_attempt_id=verification_attempt.id,
902 iris_status=iris_status,
903 )
904 )
905 if iris_status == "INITIATED":
906 # the user opened the session in the app
907 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app
908 elif iris_status == "COMPLETED":
909 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
910 elif iris_status == "APPROVED": 910 ↛ 920line 910 didn't jump to line 920 because the condition on line 910 was always true
911 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
912 session.commit()
913 # background worker will go and sort this one out
914 queue_job(
915 session,
916 job=finalize_strong_verification,
917 payload=jobs_pb2.FinalizeStrongVerificationPayload(verification_attempt_id=verification_attempt.id),
918 priority=8,
919 )
920 elif iris_status in ["FAILED", "ABORTED", "REJECTED"]:
921 verification_attempt.status = StrongVerificationAttemptStatus.failed
923 return httpbody_pb2.HttpBody(
924 content_type="application/json",
925 # json.dumps escapes non-ascii characters
926 data=json.dumps({"success": True}).encode("ascii"),
927 )