Exemple #1
0
    def ResetPassword(self, request, context):
        """
        If the user does not exist, do nothing.

        If the user exists, we send them an email. If they have a password, clicking that email will remove the password.
        If they don't have a password, it sends them an email saying someone tried to reset the password but there was none.

        Note that as long as emails are send synchronously, this is far from constant time regardless of output.
        """
        with session_scope() as session:
            user = session.execute(
                select(User).where_username_or_email(request.user).where(
                    ~User.is_deleted)).scalar_one_or_none()
            if user:
                send_password_reset_email(session, user)

                notify(
                    user_id=user.id,
                    topic="account_recovery",
                    key="",
                    action="start",
                    icon="wrench",
                    title=f"Password reset initiated",
                    link=urls.account_settings_link(),
                )

            else:  # user not found
                logger.debug(f"Didn't find user")

        return empty_pb2.Empty()
Exemple #2
0
    def CompletePasswordReset(self, request, context):
        """
        Completes the password reset: just clears the user's password
        """
        with session_scope() as session:
            res = session.execute(
                select(PasswordResetToken, User).join(
                    User, User.id == PasswordResetToken.user_id).where(
                        PasswordResetToken.token ==
                        request.password_reset_token).where(
                            PasswordResetToken.is_valid)).one_or_none()
            if res:
                password_reset_token, user = res
                session.delete(password_reset_token)
                user.hashed_password = None
                session.commit()

                notify(
                    user_id=user.id,
                    topic="account_recovery",
                    key="",
                    action="complete",
                    icon="wrench",
                    title=f"Password reset completed",
                    link=urls.account_settings_link(),
                )

                return empty_pb2.Empty()
            else:
                context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)
Exemple #3
0
    def CreateApiKey(self, request, context):
        with session_scope() as session:
            user = session.execute(
                select(User).where_username_or_email_or_id(
                    request.user)).scalar_one_or_none()
            if not user:
                context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
            token, expiry = create_session(context,
                                           session,
                                           user,
                                           long_lived=True,
                                           is_api_key=True,
                                           duration=timedelta(days=365))
            send_api_key_email(session, user, token, expiry)

            notify(
                user_id=user.id,
                topic="api_key",
                key="",
                action="create",
                icon="wrench",
                title=
                f"An admin created an API key for you, please check your email",
                link=urls.account_settings_link(),
            )

            return _user_to_details(user)
Exemple #4
0
    def SendFriendRequest(self, request, context):
        if context.user_id == request.user_id:
            context.abort(grpc.StatusCode.FAILED_PRECONDITION,
                          errors.CANT_FRIEND_SELF)

        with session_scope() as session:
            user = session.execute(
                select(User).where(User.id == context.user_id)).scalar_one()
            to_user = session.execute(
                select(User).where_users_visible(context).where(
                    User.id == request.user_id)).scalar_one_or_none()

            if not to_user:
                context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)

            if (session.execute(
                    select(FriendRelationship).where(
                        or_(
                            and_(
                                FriendRelationship.from_user_id ==
                                context.user_id,
                                FriendRelationship.to_user_id ==
                                request.user_id,
                            ),
                            and_(
                                FriendRelationship.from_user_id ==
                                request.user_id,
                                FriendRelationship.to_user_id ==
                                context.user_id,
                            ),
                        )).
                    where(
                        or_(
                            FriendRelationship.status == FriendStatus.accepted,
                            FriendRelationship.status == FriendStatus.pending,
                        ))).scalar_one_or_none() is not None):
                context.abort(grpc.StatusCode.FAILED_PRECONDITION,
                              errors.FRIENDS_ALREADY_OR_PENDING)

            # TODO: Race condition where we can create two friend reqs, needs db constraint! See comment in table

            friend_relationship = FriendRelationship(
                from_user=user, to_user=to_user, status=FriendStatus.pending)
            session.add(friend_relationship)
            session.commit()

            send_friend_request_email(friend_relationship)

            notify(
                user_id=friend_relationship.to_user_id,
                topic="friend_request",
                key=str(friend_relationship.from_user_id),
                action="send",
                avatar_key=user.avatar.thumbnail_url if user.avatar else None,
                icon="person",
                title=f"**{user.name}** sent you a friend request",
                link=urls.friend_requests_link(),
            )

            return empty_pb2.Empty()
Exemple #5
0
    def ConfirmChangeEmail(self, request, context):
        with session_scope() as session:
            user_with_valid_token_from_old_email = session.execute(
                select(User).where(
                    User.old_email_token == request.change_email_token).where(
                        User.old_email_token_created <= now()).where(
                            User.old_email_token_expiry >= now())
            ).scalar_one_or_none()
            user_with_valid_token_from_new_email = session.execute(
                select(User).where(
                    User.new_email_token == request.change_email_token).where(
                        User.new_email_token_created <= now()).where(
                            User.new_email_token_expiry >= now())
            ).scalar_one_or_none()

            if user_with_valid_token_from_old_email:
                user = user_with_valid_token_from_old_email
                user.old_email_token = None
                user.old_email_token_created = None
                user.old_email_token_expiry = None
                user.need_to_confirm_via_old_email = False
            elif user_with_valid_token_from_new_email:
                user = user_with_valid_token_from_new_email
                user.new_email_token = None
                user.new_email_token_created = None
                user.new_email_token_expiry = None
                user.need_to_confirm_via_new_email = False
            else:
                context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN)

            # Using "___ is False" instead of "not ___" so that "None" doesn't pass
            if user.need_to_confirm_via_old_email is False and user.need_to_confirm_via_new_email is False:
                user.email = user.new_email
                user.new_email = None
                user.need_to_confirm_via_old_email = None
                user.need_to_confirm_via_new_email = None

                notify(
                    user_id=user.id,
                    topic="email_address",
                    key="",
                    action="change",
                    icon="wrench",
                    title=f"Your email was changed",
                    link=urls.account_settings_link(),
                )

                return auth_pb2.ConfirmChangeEmailRes(
                    state=auth_pb2.EMAIL_CONFIRMATION_STATE_SUCCESS)
            elif user.need_to_confirm_via_old_email:
                return auth_pb2.ConfirmChangeEmailRes(
                    state=auth_pb2.
                    EMAIL_CONFIRMATION_STATE_REQUIRES_CONFIRMATION_FROM_OLD_EMAIL
                )
            else:
                return auth_pb2.ConfirmChangeEmailRes(
                    state=auth_pb2.
                    EMAIL_CONFIRMATION_STATE_REQUIRES_CONFIRMATION_FROM_NEW_EMAIL
                )
Exemple #6
0
    def VerifyPhone(self, request, context):
        if not sms.looks_like_a_code(request.token):
            context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                          errors.WRONG_SMS_CODE)

        with session_scope() as session:
            user = session.execute(
                select(User).where(User.id == context.user_id)).scalar_one()
            if user.phone_verification_token is None:
                context.abort(grpc.StatusCode.FAILED_PRECONDITION,
                              errors.NO_PENDING_VERIFICATION)

            if now() - user.phone_verification_sent > SMS_CODE_LIFETIME:
                context.abort(grpc.StatusCode.FAILED_PRECONDITION,
                              errors.NO_PENDING_VERIFICATION)

            if user.phone_verification_attempts > SMS_CODE_ATTEMPTS:
                context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED,
                              errors.TOO_MANY_SMS_CODE_ATTEMPTS)

            if not verify_token(request.token, user.phone_verification_token):
                user.phone_verification_attempts += 1
                session.commit()
                context.abort(grpc.StatusCode.NOT_FOUND, errors.WRONG_SMS_CODE)

            # Delete verifications from everyone else that has this number
            session.execute(
                update(User).where(User.phone == user.phone).where(
                    User.id != context.user_id).values({
                        "phone_verification_verified":
                        None,
                        "phone_verification_attempts":
                        0,
                        "phone_verification_token":
                        None,
                        "phone":
                        None,
                    }).execution_options(synchronize_session=False))

            user.phone_verification_token = None
            user.phone_verification_verified = now()
            user.phone_verification_attempts = 0

            notify(
                user_id=user.id,
                topic="phone_number",
                key="",
                action="verify",
                icon="wrench",
                title=f"Your phone number was verified",
                link=urls.account_settings_link(),
            )

        return empty_pb2.Empty()
Exemple #7
0
    def ChangePhone(self, request, context):
        phone = request.phone
        # early quick validation
        if phone and not is_e164_format(phone):
            context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                          errors.INVALID_PHONE)

        with session_scope() as session:
            user = session.execute(
                select(User).where(User.id == context.user_id)).scalar_one()
            if not phone:
                user.phone = None
                user.phone_verification_verified = None
                user.phone_verification_token = None
                user.phone_verification_attempts = 0
                return empty_pb2.Empty()

            if not is_known_operator(phone):
                context.abort(grpc.StatusCode.UNIMPLEMENTED,
                              errors.UNRECOGNIZED_PHONE_NUMBER)

            if now(
            ) - user.phone_verification_sent < PHONE_REVERIFICATION_INTERVAL:
                context.abort(grpc.StatusCode.RESOURCE_EXHAUSTED,
                              errors.REVERIFICATION_TOO_EARLY)

            token = sms.generate_random_code()
            result = sms.send_sms(phone, sms.format_message(token))

            if result == "success":
                user.phone = phone
                user.phone_verification_verified = None
                user.phone_verification_token = token
                user.phone_verification_sent = now()
                user.phone_verification_attempts = 0

                notify(
                    user_id=user.id,
                    topic="phone_number",
                    key="",
                    action="change",
                    icon="wrench",
                    title=f"Your phone number was changed",
                    link=urls.account_settings_link(),
                )

                return empty_pb2.Empty()

        context.abort(grpc.StatusCode.UNIMPLEMENTED, result)
Exemple #8
0
def process_generate_message_notifications(payload):
    """
    Generates notifications for a message sent to a group chat
    """
    logger.info(f"Sending out notifications for message_id = {payload.message_id}")

    with session_scope() as session:
        message, group_chat = session.execute(
            select(Message, GroupChat)
            .join(GroupChat, GroupChat.conversation_id == Message.conversation_id)
            .where(Message.id == payload.message_id)
        ).one()

        if message.message_type != MessageType.text:
            logger.info(f"Not a text message, not notifying. message_id = {payload.message_id}")
            return

        subscriptions = (
            session.execute(
                select(GroupChatSubscription)
                .join(User, User.id == GroupChatSubscription.user_id)
                .where(GroupChatSubscription.group_chat_id == message.conversation_id)
                .where(User.is_visible)
                .where(User.id != message.author_id)
                .where(GroupChatSubscription.left == None)
                .where(not_(GroupChatSubscription.is_muted))
            )
            .scalars()
            .all()
        )

        for subscription in subscriptions:
            logger.info(f"Notifying user_id = {subscription.user_id}")
            notify(
                user_id=subscription.user_id,
                topic="chat",
                key=str(message.conversation_id),
                action="message",
                icon="message",
                title=f"{message.author.name} sent a message in {group_chat.title}",
                content=message.text,
                link=urls.chat_link(chat_id=message.conversation_id),
            )
Exemple #9
0
    def ChangePassword(self, request, context):
        """
        Changes the user's password. They have to confirm their old password just in case.

        If they didn't have an old password previously, then we don't check that.
        """
        with session_scope() as session:
            user = session.execute(
                select(User).where(User.id == context.user_id)).scalar_one()

            if not request.HasField("old_password") and not request.HasField(
                    "new_password"):
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.MISSING_BOTH_PASSWORDS)

            _check_password(user, "old_password", request, context)

            # password correct or no password

            if not request.HasField("new_password"):
                # the user wants to unset their password
                user.hashed_password = None
            else:
                abort_on_invalid_password(request.new_password.value, context)
                user.hashed_password = hash_password(
                    request.new_password.value)

            session.commit()

            send_password_changed_email(user)

            notify(
                user_id=user.id,
                topic="password",
                key="",
                action="change",
                icon="wrench",
                title=f"Your password was changed",
                link=urls.account_settings_link(),
            )

        return empty_pb2.Empty()
Exemple #10
0
    def ChangeUserBirthdate(self, request, context):
        with session_scope() as session:
            user = session.execute(
                select(User).where_username_or_email_or_id(
                    request.user)).scalar_one_or_none()
            if not user:
                context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)
            user.birthdate = parse_date(request.birthdate)

            notify(
                user_id=user.id,
                topic="birthdate",
                key="",
                action="change",
                icon="wrench",
                title=f"An admin changed your birth date",
                link=urls.account_settings_link(),
            )

            return _user_to_details(user)
Exemple #11
0
    def RespondFriendRequest(self, request, context):
        with session_scope() as session:
            friend_request = session.execute(
                select(FriendRelationship).where_users_column_visible(
                    context, FriendRelationship.from_user_id).
                where(FriendRelationship.to_user_id == context.user_id).where(
                    FriendRelationship.status == FriendStatus.pending).where(
                        FriendRelationship.id ==
                        request.friend_request_id)).scalar_one_or_none()

            if not friend_request:
                context.abort(grpc.StatusCode.NOT_FOUND,
                              errors.FRIEND_REQUEST_NOT_FOUND)

            friend_request.status = FriendStatus.accepted if request.accept else FriendStatus.rejected
            friend_request.time_responded = func.now()

            if friend_request.status == FriendStatus.accepted:
                send_friend_request_accepted_email(friend_request)

            session.commit()

            if friend_request.status == FriendStatus.accepted:
                notify(
                    user_id=friend_request.from_user_id,
                    topic="friend_request",
                    key=str(friend_request.to_user_id),
                    action="accept",
                    avatar_key=friend_request.to_user.avatar.thumbnail_url
                    if friend_request.to_user.avatar else None,
                    icon="person",
                    title=
                    f"**{friend_request.from_user.name}** accepted your friend request",
                    link=urls.user_link(
                        username=friend_request.to_user.username),
                )

            return empty_pb2.Empty()
Exemple #12
0
    def ChangeEmail(self, request, context):
        """
        Change the user's email address.

        If the user has a password, a notification is sent to the old email, and a confirmation is sent to the new one.

        Otherwise they need to confirm twice, via an email sent to each of their old and new emails.

        In all confirmation emails, the user must click on the confirmation link.
        """
        with session_scope() as session:
            user = session.execute(
                select(User).where(User.id == context.user_id)).scalar_one()

            # check password first
            _check_password(user, "password", request, context)

            # not a valid email
            if not is_valid_email(request.new_email):
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.INVALID_EMAIL)

            # email already in use (possibly by this user)
            if session.execute(
                    select(User).where(
                        User.email == request.new_email)).scalar_one_or_none():
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.INVALID_EMAIL)

            user.new_email = request.new_email
            user.new_email_token = urlsafe_secure_token()
            user.new_email_token_created = now()
            user.new_email_token_expiry = now() + timedelta(hours=2)
            user.need_to_confirm_via_new_email = True

            if user.has_password:
                user.old_email_token = None
                user.old_email_token_created = None
                user.old_email_token_expiry = None
                user.need_to_confirm_via_old_email = False
                send_email_changed_notification_email(user)
                send_email_changed_confirmation_to_new_email(user)

                notify(
                    user_id=user.id,
                    topic="email_address",
                    key="",
                    action="change",
                    icon="wrench",
                    title=f"Your email was changed",
                    link=urls.account_settings_link(),
                )
            else:
                user.old_email_token = urlsafe_secure_token()
                user.old_email_token_created = now()
                user.old_email_token_expiry = now() + timedelta(hours=2)
                user.need_to_confirm_via_old_email = True
                send_email_changed_confirmation_to_old_email(user)
                send_email_changed_confirmation_to_new_email(user)

        # session autocommit
        return empty_pb2.Empty()
Exemple #13
0
    def CreateHostRequest(self, request, context):
        with session_scope() as session:
            if request.host_user_id == context.user_id:
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.CANT_REQUEST_SELF)

            # just to check host exists and is visible
            host = session.execute(
                select(User).where_users_visible(context).where(
                    User.id == request.host_user_id)).scalar_one_or_none()
            if not host:
                context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND)

            from_date = parse_date(request.from_date)
            to_date = parse_date(request.to_date)

            if not from_date or not to_date:
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.INVALID_DATE)

            today = today_in_timezone(host.timezone)

            # request starts from the past
            if from_date < today:
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.DATE_FROM_BEFORE_TODAY)

            # from_date is not >= to_date
            if from_date >= to_date:
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.DATE_FROM_AFTER_TO)

            # No need to check today > to_date

            if from_date - today > timedelta(days=365):
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.DATE_FROM_AFTER_ONE_YEAR)

            if to_date - from_date > timedelta(days=365):
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.DATE_TO_AFTER_ONE_YEAR)

            conversation = Conversation()
            session.add(conversation)
            session.flush()

            session.add(
                Message(
                    conversation_id=conversation.id,
                    author_id=context.user_id,
                    message_type=MessageType.chat_created,
                ))

            message = Message(
                conversation_id=conversation.id,
                author_id=context.user_id,
                text=request.text,
                message_type=MessageType.text,
            )
            session.add(message)
            session.flush()

            host_request = HostRequest(
                conversation_id=conversation.id,
                surfer_user_id=context.user_id,
                host_user_id=host.id,
                from_date=from_date,
                to_date=to_date,
                status=HostRequestStatus.pending,
                surfer_last_seen_message_id=message.id,
                # TODO: tz
                # timezone=host.timezone,
            )
            session.add(host_request)
            session.commit()

            send_new_host_request_email(host_request)

            notify(
                user_id=host_request.host_user_id,
                topic="host_request",
                action="create",
                key=str(host_request.surfer_user_id),
                avatar_key=host_request.surfer.avatar.thumbnail_url
                if host_request.surfer.avatar else None,
                title=
                f"**{host_request.surfer.name}** sent you a hosting request",
                content=request.text,
                link=urls.host_request_link_host(),
            )

            return requests_pb2.CreateHostRequestRes(
                host_request_id=host_request.conversation_id)
Exemple #14
0
    def SendHostRequestMessage(self, request, context):
        if request.text == "":
            context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                          errors.INVALID_MESSAGE)
        with session_scope() as session:
            host_request = session.execute(
                select(HostRequest).where(
                    HostRequest.conversation_id ==
                    request.host_request_id)).scalar_one_or_none()

            if not host_request:
                context.abort(grpc.StatusCode.NOT_FOUND,
                              errors.HOST_REQUEST_NOT_FOUND)

            if host_request.surfer_user_id != context.user_id and host_request.host_user_id != context.user_id:
                context.abort(grpc.StatusCode.NOT_FOUND,
                              errors.HOST_REQUEST_NOT_FOUND)

            if host_request.status == HostRequestStatus.rejected or host_request.status == HostRequestStatus.cancelled:
                context.abort(grpc.StatusCode.PERMISSION_DENIED,
                              errors.HOST_REQUEST_CLOSED)

            if host_request.end_time < now():
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.HOST_REQUEST_IN_PAST)

            message = Message()
            message.conversation_id = host_request.conversation_id
            message.author_id = context.user_id
            message.message_type = MessageType.text
            message.text = request.text
            session.add(message)
            session.flush()

            if host_request.surfer_user_id == context.user_id:
                host_request.surfer_last_seen_message_id = message.id

                notify(
                    user_id=host_request.host_user_id,
                    topic="host_request",
                    action="message",
                    key=str(host_request.surfer_user_id),
                    avatar_key=host_request.surfer.avatar.thumbnail_url
                    if host_request.surfer.avatar else None,
                    title=
                    f"**{host_request.surfer.name}** sent a message in their host request",
                    link=urls.host_request_link_host(),
                )

            else:
                host_request.host_last_seen_message_id = message.id

                notify(
                    user_id=host_request.surfer_user_id,
                    topic="host_request",
                    action="message",
                    key=str(host_request.host_user_id),
                    avatar_key=host_request.host.avatar.thumbnail_url
                    if host_request.host.avatar else None,
                    title=
                    f"**{host_request.host.name}** sent a message in your host request",
                    link=urls.host_request_link_guest(),
                )

            session.commit()

            return empty_pb2.Empty()
Exemple #15
0
    def RespondHostRequest(self, request, context):
        with session_scope() as session:
            host_request = session.execute(
                select(HostRequest).where_users_column_visible(
                    context,
                    HostRequest.surfer_user_id).where_users_column_visible(
                        context, HostRequest.host_user_id).where(
                            HostRequest.conversation_id ==
                            request.host_request_id)).scalar_one_or_none()

            if not host_request:
                context.abort(grpc.StatusCode.NOT_FOUND,
                              errors.HOST_REQUEST_NOT_FOUND)

            if host_request.surfer_user_id != context.user_id and host_request.host_user_id != context.user_id:
                context.abort(grpc.StatusCode.NOT_FOUND,
                              errors.HOST_REQUEST_NOT_FOUND)

            if request.status == conversations_pb2.HOST_REQUEST_STATUS_PENDING:
                context.abort(grpc.StatusCode.PERMISSION_DENIED,
                              errors.INVALID_HOST_REQUEST_STATUS)

            if host_request.end_time < now():
                context.abort(grpc.StatusCode.INVALID_ARGUMENT,
                              errors.HOST_REQUEST_IN_PAST)

            control_message = Message()

            if request.status == conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED:
                # only host can accept
                if context.user_id != host_request.host_user_id:
                    context.abort(grpc.StatusCode.PERMISSION_DENIED,
                                  errors.NOT_THE_HOST)
                # can't accept a cancelled or confirmed request (only reject), or already accepted
                if (host_request.status == HostRequestStatus.cancelled
                        or host_request.status == HostRequestStatus.confirmed
                        or host_request.status == HostRequestStatus.accepted):
                    context.abort(grpc.StatusCode.PERMISSION_DENIED,
                                  errors.INVALID_HOST_REQUEST_STATUS)
                control_message.host_request_status_target = HostRequestStatus.accepted
                host_request.status = HostRequestStatus.accepted

                send_host_request_accepted_email_to_guest(host_request)

                notify(
                    user_id=host_request.surfer_user_id,
                    topic="host_request",
                    action="accept",
                    key=str(host_request.host_user_id),
                    avatar_key=host_request.host.avatar.thumbnail_url
                    if host_request.host.avatar else None,
                    title=
                    f"**{host_request.host.name}** accepted your host request",
                    link=urls.host_request_link_guest(),
                )

            if request.status == conversations_pb2.HOST_REQUEST_STATUS_REJECTED:
                # only host can reject
                if context.user_id != host_request.host_user_id:
                    context.abort(grpc.StatusCode.PERMISSION_DENIED,
                                  errors.INVALID_HOST_REQUEST_STATUS)
                # can't reject a cancelled or already rejected request
                if (host_request.status == HostRequestStatus.cancelled
                        or host_request.status == HostRequestStatus.rejected):
                    context.abort(grpc.StatusCode.PERMISSION_DENIED,
                                  errors.INVALID_HOST_REQUEST_STATUS)
                control_message.host_request_status_target = HostRequestStatus.rejected
                host_request.status = HostRequestStatus.rejected

                send_host_request_rejected_email_to_guest(host_request)

                notify(
                    user_id=host_request.surfer_user_id,
                    topic="host_request",
                    action="reject",
                    key=str(host_request.host_user_id),
                    avatar_key=host_request.host.avatar.thumbnail_url
                    if host_request.host.avatar else None,
                    title=
                    f"**{host_request.host.name}** rejected your host request",
                    link=urls.host_request_link_guest(),
                )

            if request.status == conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED:
                # only hostee can confirm
                if context.user_id != host_request.surfer_user_id:
                    context.abort(grpc.StatusCode.PERMISSION_DENIED,
                                  errors.INVALID_HOST_REQUEST_STATUS)
                # can only confirm an accepted request
                if host_request.status != HostRequestStatus.accepted:
                    context.abort(grpc.StatusCode.PERMISSION_DENIED,
                                  errors.INVALID_HOST_REQUEST_STATUS)
                control_message.host_request_status_target = HostRequestStatus.confirmed
                host_request.status = HostRequestStatus.confirmed

                send_host_request_confirmed_email_to_host(host_request)

                notify(
                    user_id=host_request.host_user_id,
                    topic="host_request",
                    action="confirm",
                    key=str(host_request.surfer_user_id),
                    avatar_key=host_request.surfer.avatar.thumbnail_url
                    if host_request.surfer.avatar else None,
                    title=
                    f"**{host_request.surfer.name}** confirmed their host request",
                    link=urls.host_request_link_host(),
                )

            if request.status == conversations_pb2.HOST_REQUEST_STATUS_CANCELLED:
                # only hostee can cancel
                if context.user_id != host_request.surfer_user_id:
                    context.abort(grpc.StatusCode.PERMISSION_DENIED,
                                  errors.INVALID_HOST_REQUEST_STATUS)
                # can't' cancel an already cancelled or rejected request
                if (host_request.status == HostRequestStatus.rejected
                        or host_request.status == HostRequestStatus.cancelled):
                    context.abort(grpc.StatusCode.PERMISSION_DENIED,
                                  errors.INVALID_HOST_REQUEST_STATUS)
                control_message.host_request_status_target = HostRequestStatus.cancelled
                host_request.status = HostRequestStatus.cancelled

                send_host_request_cancelled_email_to_host(host_request)

                notify(
                    user_id=host_request.host_user_id,
                    topic="host_request",
                    action="cancel",
                    key=str(host_request.surfer_user_id),
                    avatar_key=host_request.surfer.avatar.thumbnail_url
                    if host_request.surfer.avatar else None,
                    title=
                    f"**{host_request.surfer.name}** cancelled their host request",
                    link=urls.host_request_link_host(),
                )

            control_message.message_type = MessageType.host_request_status_changed
            control_message.conversation_id = host_request.conversation_id
            control_message.author_id = context.user_id
            session.add(control_message)

            if request.text:
                latest_message = Message()
                latest_message.conversation_id = host_request.conversation_id
                latest_message.text = request.text
                latest_message.author_id = context.user_id
                latest_message.message_type = MessageType.text
                session.add(latest_message)
            else:
                latest_message = control_message

            session.flush()

            if host_request.surfer_user_id == context.user_id:
                host_request.surfer_last_seen_message_id = latest_message.id
            else:
                host_request.host_last_seen_message_id = latest_message.id
            session.commit()

            return empty_pb2.Empty()