Coverage for app/backend/src/couchers/servicers/account.py: 92%
333 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-03 11:56 +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 StayProposalKind,
54 StrongVerificationAttempt,
55 StrongVerificationAttemptStatus,
56 StrongVerificationCallbackEvent,
57 User,
58 UserSession,
59 Volunteer,
60)
61from couchers.models.notifications import NotificationTopicAction
62from couchers.notifications.notify import notify
63from couchers.phone import sms
64from couchers.phone.check import is_e164_format, is_known_operator
65from couchers.proto import account_pb2, account_pb2_grpc, auth_pb2, iris_pb2_grpc, notification_data_pb2
66from couchers.proto.google.api import httpbody_pb2
67from couchers.proto.internal import internal_pb2, jobs_pb2
68from couchers.servicers.api import lite_user_to_pb
69from couchers.servicers.public import format_volunteer_link
70from couchers.servicers.references import get_pending_references_to_write, reftype2api
71from couchers.sql import where_moderated_content_visible, where_users_column_visible
72from couchers.tasks import maybe_send_contributor_form_email, send_email_changed_confirmation_to_new_email
73from couchers.utils import (
74 Timestamp_from_datetime,
75 create_lang_cookie,
76 date_to_api,
77 dt_from_page_token,
78 dt_to_page_token,
79 is_valid_email,
80 now,
81 to_aware_datetime,
82)
84logger = logging.getLogger(__name__)
85logger.setLevel(logging.DEBUG)
87contributeoption2sql = {
88 auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED: None,
89 auth_pb2.CONTRIBUTE_OPTION_YES: ContributeOption.yes,
90 auth_pb2.CONTRIBUTE_OPTION_MAYBE: ContributeOption.maybe,
91 auth_pb2.CONTRIBUTE_OPTION_NO: ContributeOption.no,
92}
94contributeoption2api = {
95 None: auth_pb2.CONTRIBUTE_OPTION_UNSPECIFIED,
96 ContributeOption.yes: auth_pb2.CONTRIBUTE_OPTION_YES,
97 ContributeOption.maybe: auth_pb2.CONTRIBUTE_OPTION_MAYBE,
98 ContributeOption.no: auth_pb2.CONTRIBUTE_OPTION_NO,
99}
101profilepublicitysetting2sql = {
102 account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN: None,
103 account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING: ProfilePublicVisibility.nothing,
104 account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY: ProfilePublicVisibility.map_only,
105 account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED: ProfilePublicVisibility.limited,
106 account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST: ProfilePublicVisibility.most,
107 account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL: ProfilePublicVisibility.full,
108}
110profilepublicitysetting2api = {
111 None: account_pb2.PROFILE_PUBLIC_VISIBILITY_UNKNOWN,
112 ProfilePublicVisibility.nothing: account_pb2.PROFILE_PUBLIC_VISIBILITY_NOTHING,
113 ProfilePublicVisibility.map_only: account_pb2.PROFILE_PUBLIC_VISIBILITY_MAP_ONLY,
114 ProfilePublicVisibility.limited: account_pb2.PROFILE_PUBLIC_VISIBILITY_LIMITED,
115 ProfilePublicVisibility.most: account_pb2.PROFILE_PUBLIC_VISIBILITY_MOST,
116 ProfilePublicVisibility.full: account_pb2.PROFILE_PUBLIC_VISIBILITY_FULL,
117}
119MAX_PAGINATION_LENGTH = 50
122def mod_note_to_pb(note: ModNote) -> account_pb2.ModNote:
123 return account_pb2.ModNote(
124 note_id=note.id,
125 note_content=note.note_content,
126 created=Timestamp_from_datetime(note.created),
127 acknowledged=Timestamp_from_datetime(note.acknowledged) if note.acknowledged else None,
128 )
131def abort_on_invalid_password(password: str, context: CouchersContext) -> None:
132 """
133 Internal utility function: given a password, aborts if password is unforgivably insecure
134 """
135 if len(password) < 8:
136 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_short")
138 if len(password) > 256:
139 # Hey, what are you trying to do? Give us a DDOS attack?
140 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "password_too_long")
142 # check for the most common weak passwords (not meant to be an exhaustive check!)
143 if password.lower() in ("password", "12345678", "couchers", "couchers1"):
144 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "insecure_password")
147def abort_if_email_reused(context: CouchersContext, session: Session, user: User) -> None:
148 if session.execute(
149 select(User.id).where(User.email == user.email).where(User.reserves_email).where(User.id != user.id)
150 ).first():
151 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "undelete_email_reused")
154def _volunteer_info_to_pb(volunteer: Volunteer, username: str) -> account_pb2.GetMyVolunteerInfoRes:
155 return account_pb2.GetMyVolunteerInfoRes(
156 display_name=volunteer.display_name,
157 display_location=volunteer.display_location,
158 role=volunteer.role,
159 started_volunteering=date_to_api(volunteer.started_volunteering),
160 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None,
161 show_on_team_page=volunteer.show_on_team_page,
162 **format_volunteer_link(volunteer, username),
163 )
166class Account(account_pb2_grpc.AccountServicer):
167 def GetAccountInfo(
168 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
169 ) -> account_pb2.GetAccountInfoRes:
170 user, volunteer = session.execute(
171 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
172 ).one()
174 # Test experimentation integration - check if user is in the test gate
175 # Create 'test_growthbook_integration' in GrowthBook to test
176 test_gate = context.get_boolean_value("test_growthbook_integration", default=False)
177 logger.info(f"Experimentation gate 'test_growthbook_integration' for user {user.id}: {test_gate}")
179 # The donation drive (and its banner) is controlled by the donation_drive_start flag: a Unix
180 # epoch in seconds when a drive is running, or 0/unset when there's no drive. Users who haven't
181 # donated since the drive started see the banner.
182 drive_start_epoch = context.get_integer_value("donation_drive_start", 0)
183 drive_start = datetime.fromtimestamp(drive_start_epoch, tz=UTC) if drive_start_epoch else None
184 should_show_donation_banner = drive_start is not None and (
185 user.last_donated is None or user.last_donated < drive_start
186 )
188 return account_pb2.GetAccountInfoRes(
189 username=user.username,
190 email=user.email,
191 phone=user.phone if (user.phone_is_verified or not user.phone_code_expired) else None,
192 has_donated=user.last_donated is not None,
193 phone_verified=user.phone_is_verified,
194 profile_complete=has_completed_profile(session, user),
195 my_home_complete=user.has_completed_my_home,
196 timezone=user.timezone,
197 is_superuser=user.is_superuser,
198 ui_language_preference=user.ui_language_preference,
199 profile_public_visibility=profilepublicitysetting2api[user.public_visibility],
200 is_volunteer=volunteer is not None,
201 should_show_donation_banner=should_show_donation_banner,
202 **get_strong_verification_fields(session, user),
203 )
205 def ChangePasswordV2(
206 self, request: account_pb2.ChangePasswordV2Req, context: CouchersContext, session: Session
207 ) -> empty_pb2.Empty:
208 """
209 Changes the user's password. They have to confirm their old password just in case.
211 If they didn't have an old password previously, then we don't check that.
212 """
213 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
215 if not verify_password(user.hashed_password, request.old_password):
216 # wrong password
217 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password")
219 abort_on_invalid_password(request.new_password, context)
220 user.hashed_password = hash_password(request.new_password)
222 session.commit()
224 notify(
225 session,
226 user_id=user.id,
227 topic_action=NotificationTopicAction.password__change,
228 key="",
229 )
230 log_event(context, session, "account.password_changed", {})
232 return empty_pb2.Empty()
234 def ChangeEmailV2(
235 self, request: account_pb2.ChangeEmailV2Req, context: CouchersContext, session: Session
236 ) -> empty_pb2.Empty:
237 """
238 Change the user's email address.
240 If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one.
242 Otherwise they need to confirm twice, via an email sent to each of their old and new emails.
244 In all confirmation emails, the user must click on the confirmation link.
245 """
246 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
248 # check password first
249 if not verify_password(user.hashed_password, request.password):
250 # wrong password
251 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_password")
253 # not a valid email
254 if not is_valid_email(request.new_email):
255 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
257 # email already in use (possibly by this user)
258 if session.execute(select(User.id).where(User.email == request.new_email).where(User.reserves_email)).first():
259 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
261 user.new_email = request.new_email
262 user.new_email_token = urlsafe_secure_token()
263 user.new_email_token_created = now()
264 user.new_email_token_expiry = now() + timedelta(hours=2)
266 send_email_changed_confirmation_to_new_email(context, session, user)
268 # will still go into old email
269 notify(
270 session,
271 user_id=user.id,
272 topic_action=NotificationTopicAction.email_address__change,
273 key="",
274 data=notification_data_pb2.EmailAddressChange(
275 new_email=request.new_email,
276 ),
277 )
279 log_event(context, session, "account.email_change_initiated", {})
281 # session autocommit
282 return empty_pb2.Empty()
284 def ChangeLanguagePreference(
285 self, request: account_pb2.ChangeLanguagePreferenceReq, context: CouchersContext, session: Session
286 ) -> empty_pb2.Empty:
287 # select the user from the db
288 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
290 # update the user's preference
291 user.ui_language_preference = request.ui_language_preference
292 context.set_cookies(create_lang_cookie(request.ui_language_preference))
294 return empty_pb2.Empty()
296 def FillContributorForm(
297 self, request: account_pb2.FillContributorFormReq, context: CouchersContext, session: Session
298 ) -> empty_pb2.Empty:
299 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
301 form = request.contributor_form
303 form = ContributorForm(
304 user_id=user.id,
305 ideas=form.ideas or None,
306 features=form.features or None,
307 experience=form.experience or None,
308 contribute=contributeoption2sql[form.contribute],
309 contribute_ways=form.contribute_ways,
310 expertise=form.expertise or None,
311 )
313 session.add(form)
314 session.flush()
315 maybe_send_contributor_form_email(session, form)
317 user.filled_contributor_form = True
318 log_event(context, session, "contributor.form_submitted", {"is_filled": form.is_filled})
320 return empty_pb2.Empty()
322 def GetContributorFormInfo(
323 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
324 ) -> account_pb2.GetContributorFormInfoRes:
325 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
327 return account_pb2.GetContributorFormInfoRes(
328 filled_contributor_form=user.filled_contributor_form,
329 )
331 def ChangePhone(
332 self, request: account_pb2.ChangePhoneReq, context: CouchersContext, session: Session
333 ) -> empty_pb2.Empty:
334 phone = request.phone
335 # early quick validation
336 if phone and not is_e164_format(phone):
337 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_phone")
339 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
340 if user.last_donated is None:
341 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_donated")
343 if not phone:
344 user.phone = None
345 user.phone_verification_verified = None
346 user.phone_verification_token = None
347 user.phone_verification_attempts = 0
348 return empty_pb2.Empty()
350 # Removing a number is always allowed; sending a verification SMS is gated.
351 if not context.get_boolean_value("sms_enabled", default=False):
352 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "sms_disabled")
354 if not is_known_operator(phone):
355 context.abort_with_error_code(grpc.StatusCode.UNIMPLEMENTED, "unrecognized_phone_number")
357 if now() - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL:
358 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "reverification_too_early")
360 token = sms.generate_random_code()
361 result = sms.send_sms(phone, sms.format_message(token))
363 if result == "success":
364 user.phone = phone
365 user.phone_verification_verified = None
366 user.phone_verification_token = token
367 user.phone_verification_sent = now()
368 user.phone_verification_attempts = 0
370 notify(
371 session,
372 user_id=user.id,
373 topic_action=NotificationTopicAction.phone_number__change,
374 key="",
375 data=notification_data_pb2.PhoneNumberChange(
376 phone=phone,
377 ),
378 )
380 return empty_pb2.Empty()
382 context.abort(grpc.StatusCode.UNIMPLEMENTED, result)
384 def VerifyPhone(
385 self, request: account_pb2.VerifyPhoneReq, context: CouchersContext, session: Session
386 ) -> empty_pb2.Empty:
387 if not sms.looks_like_a_code(request.token): 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true
388 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "wrong_sms_code")
390 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
391 if user.phone_verification_token is None:
392 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification")
394 if now() - user.phone_verification_sent > SMS_CODE_LIFETIME: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true
395 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "no_pending_verification")
397 if user.phone_verification_attempts > SMS_CODE_ATTEMPTS:
398 context.abort_with_error_code(grpc.StatusCode.RESOURCE_EXHAUSTED, "too_many_sms_code_attempts")
400 if not verify_token(request.token, user.phone_verification_token):
401 user.phone_verification_attempts += 1
402 session.commit()
403 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "wrong_sms_code")
405 # Delete verifications from everyone else that has this number
406 session.execute(
407 update(User)
408 .where(User.phone == user.phone)
409 .where(User.id != context.user_id)
410 .values(
411 {
412 "phone_verification_verified": None,
413 "phone_verification_attempts": 0,
414 "phone_verification_token": None,
415 "phone": None,
416 }
417 )
418 .execution_options(synchronize_session=False)
419 )
421 user.phone_verification_token = None
422 user.phone_verification_verified = now()
423 user.phone_verification_attempts = 0
425 notify(
426 session,
427 user_id=user.id,
428 topic_action=NotificationTopicAction.phone_number__verify,
429 key="",
430 data=notification_data_pb2.PhoneNumberVerify(
431 phone=user.phone,
432 ),
433 )
435 return empty_pb2.Empty()
437 def InitiateStrongVerification(
438 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
439 ) -> account_pb2.InitiateStrongVerificationRes:
440 if not context.get_boolean_value("strong_verification_enabled", default=False):
441 context.abort_with_error_code(grpc.StatusCode.UNAVAILABLE, "strong_verification_disabled")
443 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
444 existing_verification = session.execute(
445 select(StrongVerificationAttempt)
446 .where(StrongVerificationAttempt.user_id == user.id)
447 .where(StrongVerificationAttempt.is_valid)
448 ).scalar_one_or_none()
449 if existing_verification: 449 ↛ 450line 449 didn't jump to line 450 because the condition on line 449 was never true
450 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "strong_verification_already_verified")
452 strong_verification_initiations_counter.labels(user.gender).inc()
453 log_event(context, session, "verification.strong_initiated", {"gender": user.gender})
455 verification_attempt_token = urlsafe_secure_token()
456 # this is the iris reference data, they will return this on every callback, it also doubles as webhook auth given lack of it otherwise
457 reference = b64encode(
458 simple_encrypt(
459 "iris_callback",
460 internal_pb2.VerificationReferencePayload(
461 verification_attempt_token=verification_attempt_token,
462 user_id=user.id,
463 ).SerializeToString(),
464 )
465 )
466 response = requests.post(
467 "https://passportreader.app/api/v1/session.create",
468 auth=(config.IRIS_ID_PUBKEY, config.IRIS_ID_SECRET),
469 json={
470 "callback_url": f"{config.BACKEND_BASE_URL}/iris/webhook",
471 "face_verification": False,
472 "passport_only": True,
473 "reference": reference,
474 },
475 timeout=10,
476 verify="/etc/ssl/certs/ca-certificates.crt",
477 )
479 if response.status_code != 200: 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true
480 raise Exception(f"Iris didn't return 200: {response.text}")
482 iris_session_id = response.json()["id"]
483 token = response.json()["token"]
484 session.add(
485 StrongVerificationAttempt(
486 user_id=user.id,
487 verification_attempt_token=verification_attempt_token,
488 iris_session_id=iris_session_id,
489 iris_token=token,
490 )
491 )
493 redirect_params = {
494 "token": token,
495 "redirect_url": urls.complete_strong_verification_url(
496 verification_attempt_token=verification_attempt_token
497 ),
498 }
499 redirect_url = "https://passportreader.app/open?" + urlencode(redirect_params)
501 return account_pb2.InitiateStrongVerificationRes(
502 verification_attempt_token=verification_attempt_token,
503 redirect_url=redirect_url,
504 )
506 def GetStrongVerificationAttemptStatus(
507 self, request: account_pb2.GetStrongVerificationAttemptStatusReq, context: CouchersContext, session: Session
508 ) -> account_pb2.GetStrongVerificationAttemptStatusRes:
509 verification_attempt = session.execute(
510 select(StrongVerificationAttempt)
511 .where(StrongVerificationAttempt.user_id == context.user_id)
512 .where(StrongVerificationAttempt.is_visible)
513 .where(StrongVerificationAttempt.verification_attempt_token == request.verification_attempt_token)
514 ).scalar_one_or_none()
515 if not verification_attempt: 515 ↛ 516line 515 didn't jump to line 516 because the condition on line 515 was never true
516 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "strong_verification_attempt_not_found")
517 status_to_pb = {
518 StrongVerificationAttemptStatus.succeeded: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_SUCCEEDED,
519 StrongVerificationAttemptStatus.in_progress_waiting_on_user_to_open_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_TO_OPEN_APP,
520 StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_USER_IN_APP,
521 StrongVerificationAttemptStatus.in_progress_waiting_on_backend: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_IN_PROGRESS_WAITING_ON_BACKEND,
522 StrongVerificationAttemptStatus.failed: account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_FAILED,
523 }
524 return account_pb2.GetStrongVerificationAttemptStatusRes(
525 status=status_to_pb.get(
526 verification_attempt.status, account_pb2.STRONG_VERIFICATION_ATTEMPT_STATUS_UNKNOWN
527 ),
528 )
530 def DeleteStrongVerificationData(
531 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
532 ) -> empty_pb2.Empty:
533 verification_attempts = (
534 session.execute(
535 select(StrongVerificationAttempt)
536 .where(StrongVerificationAttempt.user_id == context.user_id)
537 .where(StrongVerificationAttempt.has_full_data)
538 )
539 .scalars()
540 .all()
541 )
542 for verification_attempt in verification_attempts:
543 verification_attempt.status = StrongVerificationAttemptStatus.deleted
544 verification_attempt.has_full_data = False
545 verification_attempt.passport_encrypted_data = None
546 verification_attempt.passport_date_of_birth = None
547 verification_attempt.passport_sex = None
548 session.flush()
549 # double check:
550 verification_attempts = (
551 session.execute(
552 select(StrongVerificationAttempt)
553 .where(StrongVerificationAttempt.user_id == context.user_id)
554 .where(StrongVerificationAttempt.has_full_data)
555 )
556 .scalars()
557 .all()
558 )
559 assert len(verification_attempts) == 0
561 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
562 strong_verification_data_deletions_counter.labels(user.gender).inc()
563 log_event(context, session, "verification.strong_data_deleted", {"gender": user.gender})
565 return empty_pb2.Empty()
567 def DeleteAccount(
568 self, request: account_pb2.DeleteAccountReq, context: CouchersContext, session: Session
569 ) -> empty_pb2.Empty:
570 """
571 Triggers email with token to confirm deletion
573 Frontend should confirm via unique string (i.e. username) before this is called
574 """
575 if not request.confirm:
576 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "must_confirm_account_delete")
578 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one()
580 reason = request.reason.strip()
581 if reason:
582 session.add(AccountDeletionReason(user_id=user.id, reason=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, HostRequest.proposal_kind, 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_public_trip_offer_reminder=account_pb2.RespondToPublicTripOfferReminder(
772 host_request_id=host_request_id,
773 host_user=lite_user_to_pb(session, lite_user, context),
774 )
775 )
776 if proposal_kind == StayProposalKind.invitation
777 else account_pb2.Reminder(
778 respond_to_host_request_reminder=account_pb2.RespondToHostRequestReminder(
779 host_request_id=host_request_id,
780 surfer_user=lite_user_to_pb(session, lite_user, context),
781 )
782 )
783 for host_request_id, proposal_kind, lite_user in pending_host_requests
784 ]
786 # the initiator needs to confirm accepted requests: the surfer on a normal request, the host on an offer
787 confirm_query = select(HostRequest.conversation_id, HostRequest.proposal_kind, LiteUser).join(
788 LiteUser, LiteUser.id == HostRequest.recipient_user_id
789 )
790 confirm_query = where_users_column_visible(confirm_query, context, HostRequest.recipient_user_id)
791 confirm_query = where_moderated_content_visible(confirm_query, context, HostRequest, is_list_operation=True)
792 accepted_host_requests = session.execute(
793 confirm_query.where(HostRequest.initiator_user_id == context.user_id)
794 .where(HostRequest.status == HostRequestStatus.accepted)
795 .where(HostRequest.end_time > func.now())
796 .order_by(HostRequest.end_time.asc())
797 ).all()
798 reminders += [
799 account_pb2.Reminder(
800 confirm_public_trip_offer_reminder=account_pb2.ConfirmPublicTripOfferReminder(
801 host_request_id=host_request_id,
802 surfer_user=lite_user_to_pb(session, lite_user, context),
803 )
804 )
805 if proposal_kind == StayProposalKind.invitation
806 else account_pb2.Reminder(
807 confirm_host_request_reminder=account_pb2.ConfirmHostRequestReminder(
808 host_request_id=host_request_id,
809 host_user=lite_user_to_pb(session, lite_user, context),
810 )
811 )
812 for host_request_id, proposal_kind, lite_user in accepted_host_requests
813 ]
815 # references come second, in order of deadline, desc
816 reminders += [
817 account_pb2.Reminder(
818 write_reference_reminder=account_pb2.WriteReferenceReminder(
819 host_request_id=host_request_id,
820 reference_type=reftype2api[reference_type],
821 other_user=lite_user_to_pb(session, lite_user, context),
822 )
823 )
824 for host_request_id, reference_type, _, lite_user in get_pending_references_to_write(session, context)
825 ]
827 if not has_completed_profile(session, user):
828 reminders.append(account_pb2.Reminder(complete_profile_reminder=account_pb2.CompleteProfileReminder()))
830 if user.hosting_status in (HostingStatus.can_host, HostingStatus.maybe) and not user.has_completed_my_home:
831 reminders.append(account_pb2.Reminder(complete_my_home_reminder=account_pb2.CompleteMyHomeReminder()))
833 return account_pb2.GetRemindersRes(reminders=reminders)
835 def GetMyVolunteerInfo(
836 self, request: empty_pb2.Empty, context: CouchersContext, session: Session
837 ) -> account_pb2.GetMyVolunteerInfoRes:
838 user, volunteer = session.execute(
839 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
840 ).one()
841 if not volunteer:
842 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer")
843 return _volunteer_info_to_pb(volunteer, user.username)
845 def UpdateMyVolunteerInfo(
846 self, request: account_pb2.UpdateMyVolunteerInfoReq, context: CouchersContext, session: Session
847 ) -> account_pb2.GetMyVolunteerInfoRes:
848 user, volunteer = session.execute(
849 select(User, Volunteer).outerjoin(Volunteer, Volunteer.user_id == User.id).where(User.id == context.user_id)
850 ).one()
851 if not volunteer:
852 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "not_a_volunteer")
854 if request.HasField("display_name"): 854 ↛ 857line 854 didn't jump to line 857 because the condition on line 854 was always true
855 volunteer.display_name = request.display_name.value or None
857 if request.HasField("display_location"):
858 volunteer.display_location = request.display_location.value or None
860 if request.HasField("show_on_team_page"): 860 ↛ 861line 860 didn't jump to line 861 because the condition on line 860 was never true
861 volunteer.show_on_team_page = request.show_on_team_page.value
863 if request.HasField("link_type") or request.HasField("link_text") or request.HasField("link_url"): 863 ↛ 889line 863 didn't jump to line 889 because the condition on line 863 was always true
864 link_type = request.link_type.value or volunteer.link_type
865 link_text = request.link_text.value or volunteer.link_text
866 link_url = request.link_url.value or volunteer.link_url
867 if link_type == "couchers": 867 ↛ 869line 867 didn't jump to line 869 because the condition on line 867 was never true
868 # this is the default
869 link_type = None
870 link_text = None
871 link_url = None
872 elif link_type == "linkedin":
873 # this is the username
874 link_text = link_text
875 link_url = f"https://www.linkedin.com/in/{link_text}/"
876 elif link_type == "email":
877 if not is_valid_email(link_text): 877 ↛ 878line 877 didn't jump to line 878 because the condition on line 877 was never true
878 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email")
879 link_url = f"mailto:{link_text}"
880 elif link_type == "website": 880 ↛ 884line 880 didn't jump to line 884 because the condition on line 880 was always true
881 if not link_url.startswith("https://") or "/" in link_text or link_text not in link_url: 881 ↛ 882line 881 didn't jump to line 882 because the condition on line 881 was never true
882 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_website_url")
883 else:
884 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_link_type")
885 volunteer.link_type = link_type
886 volunteer.link_text = link_text
887 volunteer.link_url = link_url
889 session.flush()
891 return _volunteer_info_to_pb(volunteer, user.username)
894class Iris(iris_pb2_grpc.IrisServicer):
895 def Webhook(
896 self, request: httpbody_pb2.HttpBody, context: CouchersContext, session: Session
897 ) -> httpbody_pb2.HttpBody:
898 json_data = json.loads(request.data)
899 reference_payload = internal_pb2.VerificationReferencePayload.FromString(
900 simple_decrypt("iris_callback", b64decode(json_data["session_reference"]))
901 )
902 # if we make it past the decrypt, we consider this webhook authenticated
903 verification_attempt_token = reference_payload.verification_attempt_token
904 user_id = reference_payload.user_id
906 verification_attempt = session.execute(
907 select(StrongVerificationAttempt)
908 .where(StrongVerificationAttempt.user_id == reference_payload.user_id)
909 .where(StrongVerificationAttempt.verification_attempt_token == reference_payload.verification_attempt_token)
910 .where(StrongVerificationAttempt.iris_session_id == json_data["session_id"])
911 ).scalar_one()
912 iris_status = json_data["session_state"]
913 session.add(
914 StrongVerificationCallbackEvent(
915 verification_attempt_id=verification_attempt.id,
916 iris_status=iris_status,
917 )
918 )
919 if iris_status == "INITIATED":
920 # the user opened the session in the app
921 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_user_in_app
922 elif iris_status == "COMPLETED":
923 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
924 elif iris_status == "APPROVED": 924 ↛ 934line 924 didn't jump to line 934 because the condition on line 924 was always true
925 verification_attempt.status = StrongVerificationAttemptStatus.in_progress_waiting_on_backend
926 session.commit()
927 # background worker will go and sort this one out
928 queue_job(
929 session,
930 job=finalize_strong_verification,
931 payload=jobs_pb2.FinalizeStrongVerificationPayload(verification_attempt_id=verification_attempt.id),
932 priority=18,
933 )
934 elif iris_status in ["FAILED", "ABORTED", "REJECTED"]:
935 verification_attempt.status = StrongVerificationAttemptStatus.failed
937 return httpbody_pb2.HttpBody(
938 content_type="application/json",
939 # json.dumps escapes non-ascii characters
940 data=json.dumps({"success": True}).encode("ascii"),
941 )