Exemple #1
0
async def _update_spotify_tokens(user: User) -> bool:
    """
    Update the user's Spotify tokens. Returns success status
    """
    try:
        exchange_data: TokenExchangeData = await get_new_access_token(
            user.spotifyRefreshToken, GrantType.REFRESH_TOKEN)
    except SpotifyApiError as err:
        LOGGER.warning(
            "Exiting update loop. Could not refresh Spotify token for "
            "user %s: %s",
            user.id,
            err,
        )
        err_dict = err.response_json()
        if err_dict.get("error_description") == "Refresh token revoked":
            LOGGER.warning(
                "Deleting user %s as their Spotify refresh token is revoked "
                "and we have no way to recover :(",
                user.id,
            )
            await user.delete()
        return False
    await user.update(
        spotifyExpiresAt=calc_spotify_expiry(exchange_data.expires_in),
        spotifyAccessToken=exchange_data.access_token,
        spotifyRefreshToken=exchange_data.refresh_token or "",
        updatedAt=datetime.now(timezone.utc),
    )
    return True
Exemple #2
0
async def _throttled_update_user(user, sem):
    async with sem:  # semaphore limits num of simultaneous updated
        try:
            await _update_user(user)
        except (httpx.HTTPError, sqlalchemy.exc.SQLAlchemyError) as err:
            LOGGER.error("Fatal error in update loop for user %s: %s", user.id,
                         err)
Exemple #3
0
async def shutdown():
    """
    Shutdown actions
    """
    # pylint:disable=import-outside-toplevel
    current_task = asyncio.current_task()
    other_tasks = [t for t in asyncio.all_tasks() if t is not current_task]
    LOGGER.info("Cancelling %s outstanding tasks", len(other_tasks))
    for task in other_tasks:
        task.cancel()

    await DATABASE.disconnect()
    await asyncio.gather(*other_tasks, return_exceptions=True)
Exemple #4
0
async def slack_grant(request: Request):
    """
    Slack grant redirect (request access token)
    """
    # TODO include state data
    grant_args = {
        "client_id": SETTINGS.slack_client_id,
        "scope": " ".join(AUTHORIZE_SCOPES),
        "redirect_uri": SETTINGS.slack_redirect_uri,
    }
    LOGGER.debug("Slack grant redirect initiated [%s]", request.client)
    return RedirectResponse(
        f"{AUTHORIZE_URI}?{urlencode(grant_args, doseq=True)}")
Exemple #5
0
async def worker_entrypoint() -> None:
    """
    The entrypoint for the worker. Currently a stub
    """
    # pylint:disable=protected-access
    DATABASE._connection_context = ContextVar("connection_context")  # Hack :(
    sem = asyncio.Semaphore(SETTINGS.worker_coroutines)
    while True:
        LOGGER.debug("Starting global update loop")
        update_tasks = [
            _throttled_update_user(user=user, sem=sem)
            for user in await User.objects.all()
        ]
        await asyncio.gather(*update_tasks)
Exemple #6
0
async def sign_out(request: Request):
    """
    Sign out
    """
    try:
        user_id = request.session["user_id"]
    except KeyError:
        LOGGER.debug("Unauthenticated user attempted to sign out [%s]",
                     request.client)
    else:
        LOGGER.info("User %s signed out [%s]", user_id, request.client)

    auth.sign_out(request)
    return RedirectResponse("/")
async def spotify_grant(request: Request):
    """
    Spotify grant redirect (request access & refresh tokens)
    """
    # TODO include state data
    grant_args = {
        "client_id": SETTINGS.spotify_client_id,
        "response_type": "code",
        "scope": AUTHORIZE_SCOPES,
        "redirect_uri": SETTINGS.spotify_redirect_uri,
    }
    LOGGER.debug("Spotify grant redirect initiated [%s]", request.client)
    return RedirectResponse(
        f"{AUTHORIZE_URI}?{urlencode(grant_args, doseq=True)}")
Exemple #8
0
async def get_or_create_from_session(
        session: Dict[str, Any]) -> Tuple[Optional[User], bool]:
    """
    Get or create a user based on auth info in their session
    """
    created = False
    now = datetime.now(timezone.utc)
    try:
        full_session = FullSession(**session)
    except ValidationError:
        return (None, created)

    try:
        user = await User.objects.get(
            slackId=full_session.slack_id,
            spotifyId=full_session.spotify_id,
        )
    except orm.exceptions.MultipleMatches:
        LOGGER.error(
            "Multiple users returned for slack_id=%s, spotify_id=%s!",
            full_session.slack_id,
            full_session.spotify_id,
        )
        user = None
    except orm.exceptions.NoMatch:
        created = True
        user = await User.objects.create(
            slackId=full_session.slack_id,
            slackAccessToken=full_session.slack_access_token,
            spotifyId=full_session.spotify_id,
            spotifyExpiresAt=full_session.spotify_expires_at,
            spotifyAccessToken=full_session.spotify_access_token,
            spotifyRefreshToken=full_session.spotify_refresh_token,
            createdAt=now,
            updatedAt=now,
        )
    else:
        await user.update(
            slackAccessToken=full_session.slack_access_token,
            spotifyExpiresAt=full_session.spotify_expires_at,
            spotifyAccessToken=full_session.spotify_access_token,
            spotifyRefreshToken=full_session.spotify_refresh_token,
            updatedAt=now,
        )

    return (user, created)
Exemple #9
0
async def sign_in(request: Request) -> Optional[User]:
    """
    Try signing in the user (creating or updated them)

    Returns None if the user can't be signed in.
    """
    user, created = await get_or_create_from_session(session=request.session)
    if user:
        LOGGER.info(
            "User %s signed in (%s) [%s]",
            user.id,
            "created" if created else "updated",
            request.client,
        )
        request.session.clear()
        request.session["user_id"] = user.id
    else:
        LOGGER.debug("User sign in attempt failed [%s]", request.client)
    return user
Exemple #10
0
async def _set_user_status(user: User, user_profile_args: UserProfileArgs,
                           status_set_last_time: bool) -> bool:
    """
    Set the user status & update their database entry. Returns success status
    """
    try:
        await set_status(user_profile_args, user.slackAccessToken)
    except SlackApiError as err:
        LOGGER.warning(
            "Exiting update loop. Could not set status for user %s: "
            "%s",
            user.id,
            err,
        )
        return False
    await user.update(
        statusSetLastTime=status_set_last_time,
        updatedAt=datetime.now(timezone.utc),
    )
    return True
Exemple #11
0
async def delete_account(request: Request):
    """
    Delete account (stop monitoring)
    """
    try:
        user_id = request.session["user_id"]
    except KeyError:
        LOGGER.debug(
            "Unauthenticated user attempted to delete account [%s]",
            request.client,
        )
        return RedirectResponse("/")

    try:
        user = await User.objects.get(id=user_id)
    except orm.exceptions.NoMatch:
        LOGGER.warning(
            "User %s not found in the database; cannot delete [%s]",
            user_id,
            request.client,
        )
    else:
        await user.delete()
        LOGGER.info("User %s deleted account [%s]", user_id, request.client)

    auth.sign_out(request)
    return RedirectResponse("/")
Exemple #12
0
async def spotify_grant_callback(request: Request,
                                 code: Optional[str] = None,
                                 error: Optional[str] = None):
    """
    Spotify grant callback (receive access & refresh tokens)
    """
    if code is None and error is None:
        error = f"Neither 'code' nor 'error' were provided from Spotify"
    if error is not None:
        LOGGER.warning("Spotify grant callback error: %s", error)
        return RedirectResponse("/")

    try:
        exchange_data: TokenExchangeData = await get_new_access_token(
            cast(str, code),
            GrantType.CODE  # Checks above ensure code is str
        )
    except SpotifyApiError as err:
        LOGGER.warning("%s", err)
        return RedirectResponse("/")

    try:
        me_data: MeData = await get_me(exchange_data.access_token)
    except SpotifyApiError as err:
        LOGGER.warning("%s", err)
        return RedirectResponse("/")

    request.session["spotify_id"] = me_data.id
    request.session["spotify_expires_at"] = (
        datetime.now(timezone.utc) +
        timedelta(seconds=exchange_data.expires_in)).isoformat()
    request.session["spotify_access_token"] = exchange_data.access_token
    request.session["spotify_refresh_token"] = exchange_data.refresh_token

    await sign_in(request)

    LOGGER.debug("Spotify grant callback successful [%s]", request.client)
    return RedirectResponse("/")
Exemple #13
0
async def _set_user_status(user: User, user_profile_args: UserProfileArgs,
                           status_set_last_time: bool) -> bool:
    global UPDATE_THRESHOLD
    """
    Set the user status & update their database entry. Returns success status
    """
    try:
        current_profile = await get_status(user.slackAccessToken)
        if current_profile.profile.status_text == user_profile_args.status_text:
            return True
        LOGGER.info("Setting user status %s", user_profile_args)
        await asyncio.sleep(1)
        user_profile_data: UserProfileData = await set_status(
            user_profile_args, user.slackAccessToken)
    except SlackApiError as err:
        LOGGER.warning(
            "Exiting update loop. Could not set status for user %s: %s",
            user.id,
            err,
        )
        UPDATE_THRESHOLD = datetime.now(timezone.utc) + timedelta(seconds=6)
        return False
    if not user_profile_data.ok:
        LOGGER.warning(
            "Exiting update loop. Could not set status for user %s: %s",
            user.id,
            user_profile_data.error,
        )
        if user_profile_data.error in {"token_revoked"}:
            LOGGER.warning("Slack token revoked. Deleting user %s", user.id)
            await user.delete()
        return False
    await user.update(
        statusSetLastTime=status_set_last_time,
        updatedAt=datetime.now(timezone.utc),
    )
    return True
Exemple #14
0
async def slack_grant_callback(request: Request, code: Optional[str] = None):
    """
    Slack grant callback (receive access token)
    """
    if code is None:
        LOGGER.warning(
            "Spotify grant callback error: 'code' was not provided by Spotify")
        return RedirectResponse("/")

    try:
        exchange_data: TokenExchangeData = await get_new_access_token(code, )
    except SlackApiError as err:
        LOGGER.warning("%s", err)
        return RedirectResponse("/")

    request.session["slack_id"] = exchange_data.user_id
    request.session["slack_access_token"] = exchange_data.access_token

    await sign_in(request)

    LOGGER.debug("Slack grant callback successful [%s]", request.client)
    return RedirectResponse("/")
Exemple #15
0
async def _update_user(user: User) -> None:
    """
    Update a single user
    """
    global UPDATE_THRESHOLD  # pylint:disable=global-statement
    update_threshold_delta = UPDATE_THRESHOLD - datetime.now(timezone.utc)
    if update_threshold_delta.total_seconds() > 0:
        LOGGER.debug(
            "Sleeping update thread for %s for %ss",
            user.id,
            update_threshold_delta.total_seconds(),
        )
        await asyncio.sleep(update_threshold_delta.total_seconds())

    # Handle Spotify token refreshes
    spotify_token_expired = user.spotifyExpiresAt <= datetime.now(
        timezone.utc) - timedelta(minutes=5)
    if spotify_token_expired and not user.spotifyRefreshToken:
        LOGGER.warning(
            "Deleting user %s as their Spotify token is expired and no "
            "refresh token is available :(",
            user.id,
        )
        await user.delete()
        return

    if spotify_token_expired:
        LOGGER.debug("Refreshing Spotify token for user %s", user.id)
        update_ok = await _update_spotify_tokens(user)
        if not update_ok:
            return
        LOGGER.debug("Refreshing Spotify token for user %s COMPLETE", user.id)

    # Retrieve Spotify player status
    try:
        player: Optional[PlayerData] = await get_player(user.spotifyAccessToken
                                                        )
    except SpotifyApiError as err:
        if err.retry_after is None:
            LOGGER.warning(
                "Exiting update loop. Could not retrieve player data for user %s: "
                "%s",
                user.id,
                err,
            )
        else:
            UPDATE_THRESHOLD = datetime.now(
                timezone.utc) + timedelta(seconds=err.retry_after)
            LOGGER.warning(
                "Exiting update loop. Spotify is throttling for %ss",
                err.retry_after,
            )
        return
    if player is not None and player.item is not None and player.is_playing:
        user_profile_args = UserProfileArgs(
            status_text=_calc_status_text(player.item),
            status_emoji=get_custom_emoji(user, player.item),
        )
        LOGGER.debug("Setting user status %s", user_profile_args)  # TODO rm
        await _set_user_status(user, user_profile_args, True)
    elif user.statusSetLastTime:
        user_profile_args = UserProfileArgs(status_text="", status_emoji="")
        await _set_user_status(user, user_profile_args, False)
Exemple #16
0
@APP.on_event("shutdown")
async def shutdown():
    """
    Shutdown actions
    """
    await DATABASE.disconnect()


if __name__ == "__main__":
    logging.basicConfig(level=2, format="%(levelname)-9s %(message)s")

    CONFIG = uvicorn.Config(
        "backend.main:APP",
        host="0.0.0.0",
        port=SETTINGS.port,
        loop="uvloop",
        log_level="info",
        use_colors=True,
    )
    SERVER = uvicorn.Server(config=CONFIG)

    # Start both the server & worker together
    CONFIG.setup_event_loop()
    LOOP = asyncio.get_event_loop()
    try:
        LOOP.run_until_complete(
            asyncio.gather(SERVER.serve(), worker_entrypoint()))
    finally:
        LOOP.close()
        LOGGER.info("Shutdown successful")