def delete_members(id: int, user_ids: List[int]):
    conv = PrivateConversation.from_pk(id,
                                       _404=True,
                                       asrt=MessagePermissions.VIEW_OTHERS)
    not_members = [
        str(uid) for uid in user_ids
        if uid not in {u.id
                       for u in conv.members}
    ]
    if not_members:
        raise APIException(
            'The following user_ids are not in the conversation: '  # type: ignore
            f'{", ".join(not_members)}.')

    states = []
    og_members = []
    for uid in list(set(user_ids)):
        st = PrivateConversationState.from_attrs(conv_id=id, user_id=uid)
        states.append(st)
        if st.original_member:
            og_members.append(User.from_pk(st.user_id).username)
    if og_members:
        raise APIException(
            'The following original members cannot be removed from the conversation: '
            f'{", ".join(og_members)}.')
    for st in states:
        st.deleted = True
    db.session.commit()
    conv.del_property_cache('members')
    cache.delete(
        PrivateConversationState.__cache_key_members__.format(conv_id=conv.id))
    return flask.jsonify(conv.members)
Exemple #2
0
def check_rate_limit() -> None:
    """
    Check whether or not a user has exceeded the rate limits specified in the
    config. Rate limits per API key or session and per user are recorded.
    The redis database is used to keep track of caching, by incrementing
    "rate limit" cache keys on each request and setting a timeout on them.
    The rate limit can be adjusted in the configuration file.

    :raises APIException: If the rate limit has been exceeded
    """
    if not flask.g.user:
        return check_rate_limit_unauthenticated()

    user_cache_key = f'rate_limit_user_{flask.g.user.id}'
    key_cache_key = f'rate_limit_api_key_{flask.g.api_key.hash}'

    auth_specific_requests = cache.inc(
        key_cache_key, timeout=app.config['RATE_LIMIT_AUTH_SPECIFIC'][1]
    )
    if auth_specific_requests > app.config['RATE_LIMIT_AUTH_SPECIFIC'][0]:
        time_left = cache.ttl(key_cache_key)
        raise APIException(
            f'Client rate limit exceeded. {time_left} seconds until lock expires.'
        )

    user_specific_requests = cache.inc(
        user_cache_key, timeout=app.config['RATE_LIMIT_PER_USER'][1]
    )
    if user_specific_requests > app.config['RATE_LIMIT_PER_USER'][0]:
        time_left = cache.ttl(user_cache_key)
        raise APIException(
            f'User rate limit exceeded. {time_left} seconds until lock expires.'
        )
Exemple #3
0
def ValInviteCode(code: Optional[str]) -> None:
    """
    Check an invite code against existing invite codes;
    Raises an APIException if the code isn't valid.

    :param code:          Invite code to check and verify

    :return:              An invite code or, if site is open registration, ``None``
    :raises APIException: The invite code cannot be used
    """
    if not app.config['REQUIRE_INVITE_CODE']:
        return

    if code is not None and (not isinstance(code, str) or len(code) != 24):
        raise APIException('Invite code must be a 24 character string.')

    invite = Invite.from_pk(code)
    if invite and not invite.invitee_id:
        time_since_usage = (datetime.utcnow().replace(tzinfo=pytz.utc) -
                            invite.time_sent)
        if time_since_usage.total_seconds() < app.config['INVITE_LIFETIME']:
            return

    if code:
        raise APIException(f'{code} is not a valid invite code.')
    raise APIException('An invite code is required for registration.')
Exemple #4
0
def modify_post(id: int,
                sticky: bool = None,
                contents: str = None) -> flask.Response:
    """
    This is the endpoint for forum post editing. The ``forums_posts_modify``
    permission is required to access this endpoint. Posts can be marked
    sticky with this endpoint.

    .. :quickref: ForumThread; Edit a forum post.

    **Example request**:

    .. parsed-literal::

       PUT /forums/posts/6 HTTP/1.1

       {
         "sticky": true
       }


    **Example response**:

    .. parsed-literal::

       {
         "status": "success",
         "response": "<ForumPost>"
       }

    :>json dict response: The modified forum post

    :statuscode 200: Modification successful
    :statuscode 400: Modification unsuccessful
    :statuscode 404: Forum post does not exist
    """
    post = ForumPost.from_pk(id, _404=True)
    assert_user(post.user_id, 'forums_posts_modify')
    thread = ForumThread.from_pk(post.thread_id)
    if not thread:
        raise APIException(f'ForumPost {id} does not exist.')
    if thread.locked and not flask.g.user.has_permission(
            'forums_posts_modify'):
        raise APIException('You cannot modify posts in a locked thread.')
    if contents is not None:
        ForumPostEditHistory.new(
            post_id=post.id,
            editor_id=post.edited_user_id or post.user_id,
            contents=post.contents,
            time=datetime.utcnow().replace(tzinfo=pytz.utc),
        )
        post.contents = contents
        post.edited_user_id = flask.g.user.id
        post.edited_time = datetime.utcnow().replace(tzinfo=pytz.utc)
    if flask.g.user.has_permission('forums_posts_modify'):
        if sticky is not None:
            post.sticky = sticky
    db.session.commit()
    return flask.jsonify(post)
Exemple #5
0
def alter_thread_subscription(thread_id: int) -> flask.Response:
    """
    This is the endpoint for forum thread subscription. The ``forums_subscriptions_modify``
    permission is required to access this endpoint. A POST request creates a subscription,
    whereas a DELETE request removes a subscription.

    .. :quickref: ForumThreadSubscription; Subscribe to a forum thread.

    **Example response**:

    .. parsed-literal::

       {
         "status": "success",
         "response": "Successfully subscribed to thread 2."
       }

    :>json str response: Success or failure message

    :statuscode 200: Subscription alteration successful
    :statuscode 400: Subscription alteration unsuccessful
    :statuscode 404: Forum thread does not exist
    """
    thread = ForumThread.from_pk(thread_id, _404=True)
    subscription = ForumThreadSubscription.from_attrs(
        user_id=flask.g.user.id, thread_id=thread.id
    )
    if flask.request.method == 'POST':
        if subscription:
            raise APIException(
                f'You are already subscribed to thread {thread_id}.'
            )
        ForumThreadSubscription.new(
            user_id=flask.g.user.id, thread_id=thread_id
        )
        return flask.jsonify(f'Successfully subscribed to thread {thread_id}.')
    else:  # method = DELETE
        if not subscription:
            raise APIException(
                f'You are not subscribed to thread {thread_id}.'
            )
        db.session.delete(subscription)
        db.session.commit()
        cache.delete(
            ForumThreadSubscription.__cache_key_users__.format(
                thread_id=thread_id
            )
        )
        cache.delete(
            ForumThreadSubscription.__cache_key_of_user__.format(
                user_id=flask.g.user.id
            )
        )
        return flask.jsonify(
            f'Successfully unsubscribed from thread {thread_id}.'
        )
Exemple #6
0
def create_post(contents: str, thread_id: int) -> flask.Response:
    """
    This is the endpoint for forum posting. The ``forums_posts_modify``
    permission is required to access this endpoint.

    .. :quickref: ForumPost; Create a forum post.

    **Example request**:

    .. parsed-literal::

       POST /forums/posts HTTP/1.1

       {
         "topic": "How do I get easy ration?",
         "forum_id": 4
       }

    **Example response**:

    .. parsed-literal::

       {
         "status": "success",
         "response": "<ForumPost>"
       }

    :>json dict response: The newly created forum post

    :statuscode 200: Creation successful
    :statuscode 400: Creation unsuccessful
    """
    thread = ForumThread.from_pk(thread_id, _404=True)
    if thread.locked and not flask.g.user.has_permission(
            'forums_post_in_locked'):
        raise APIException('You cannot post in a locked thread.')
    thread_last_post = thread.last_post
    if (thread_last_post and thread_last_post.user_id == flask.g.user.id
            and not flask.g.user.has_permission('forums_posts_double')):
        if len(thread_last_post.contents) + len(contents) > 255997:
            raise APIException('Post could not be merged into previous post '
                               '(must be <256,000 characters combined).')
        return modify_post(
            id=thread_last_post.id,
            contents=f'{thread_last_post.contents}\n\n\n{contents}',
            skip_validation=True,
        )

    post = ForumPost.new(thread_id=thread_id,
                         user_id=flask.g.user.id,
                         contents=contents)
    return flask.jsonify(post)
Exemple #7
0
def vote_on_poll(choice_id: int) -> flask.Response:
    """
    This is the endpoint for forum poll voting. The ``forums_polls_vote``
    permission is required to access this endpoint.

    .. :quickref: ForumPollChoice; Vote on a forum poll choice.

    **Example response**:

    .. parsed-literal::

       {
         "status": "success",
         "response": "You have successfully voted for choice 1032."
       }

    :>json str response: The result message

    :statuscode 200: Voting successful
    :statuscode 400: Voting unsuccessful
    :statuscode 404: Poll or poll choice
    """
    choice = ForumPollChoice.from_pk(choice_id, _404=True)
    if ForumPollAnswer.from_attrs(poll_id=choice.poll.id,
                                  user_id=flask.g.user.id):
        raise APIException('You have already voted on this poll.')
    ForumPollAnswer.new(poll_id=choice.poll.id,
                        user_id=flask.g.user.id,
                        choice_id=choice.id)
    return flask.jsonify(
        f'You have successfully voted for choice {choice.id}.')
def delete_category(id: int) -> flask.Response:
    """
    This is the endpoint for forum category deletion . The ``forums_forums_modify`` permission
    is required to access this endpoint. The category must have no forums assigned to it
    in order to delete it.

    .. :quickref: ForumCategory; Delete a forum category.

    **Example response**:

    .. parsed-literal::

       {
         "status": "success",
         "response": "ForumCategory 1 (Site) has been deleted."
       }

    :>json str response: The deleted forum category message

    :statuscode 200: Deletion successful
    :statuscode 400: Deletion unsuccessful
    :statuscode 404: Forum category does not exist
    """
    category = ForumCategory.from_pk(id, _404=True)
    if category.forums:
        raise APIException(
            'You cannot delete a forum category while it still has forums assigned to it.'
        )
    category.deleted = True
    db.session.commit()
    return flask.jsonify(
        f'ForumCategory {id} ({category.name}) has been deleted.')
Exemple #9
0
    def from_type(cls,
                  type: str,
                  *,
                  create_new: bool = False,
                  error: bool = False) -> 'NotificationType':
        """
        Get the ID of the notification type, and if the type is not in the database,
        add it and return the new ID.

        :param type:           The notification type
        :param create_new:     Whether or not to create a new type with the given str
        :param error:          Whether or not to error if the type doesn't exist

        :return:               The notification ID
        :raises _404Exception: If error kwarg is passed and type doesn't exist
        """
        noti_type = cls.from_query(
            key=cls.__cache_key_id_of_type__.format(type=type),
            filter=cls.type == type,
        )
        if noti_type:
            return noti_type
        elif create_new:
            return cls._new(type=type)
        elif error:
            raise APIException(f'{type} is not a notification type.')
        return None
Exemple #10
0
def clear_notifications(read: bool, user: User, type: str = None):
    """
    Clear a user's notifications; optionally of a specific type. Requires the
    ``notifications_modify`` permission. Clearing another user's notifications
    requires the ``notifications_modify_others`` permission.

    .. :quickref: Notification; View notifications of a type.

    **Example response**:

    .. parsed-literal::

       {
         "status": "success",
         "response": "All notifications cleared."
       }

    :>json str response: Response message

    :statuscode 200: Successfully cleared notifications.
    :statuscode 403: User does not have permission to clear notifications.
    """
    if not read:
        raise APIException('You cannot set all notifications to unread.')
    Notification.update_many(
        pks=Notification.get_pks_from_type(user.id, type, include_read=False),
        update={'read': True},
    )
    Notification.clear_cache_keys(user.id)
    return flask.jsonify(
        f'{"All" if not type else type} notifications cleared.')
Exemple #11
0
def invite_user(email: str):
    """
    Sends an invite to the provided email address. Requires the ``invites_send``
    permission. If the site is open registration, this endpoint will raise a
    400 Exception.

    .. :quickref: Invite; Send an invite.

    **Example request**:

    .. parsed-literal::

       POST /invites/an-invite-code HTTP/1.1

       {
         "email": "*****@*****.**"
       }

    **Example response**:

    .. parsed-literal::

       {
         "status": "success",
         "response": "<Invite>"
       }

    :<json string email: E-mail to send the invite to

    :statuscode 200: Successfully sent invite
    :statuscode 400: Unable to send invites or incorrect email
    :statuscode 403: Unauthorized to send invites
    """
    if not app.config['REQUIRE_INVITE_CODE']:
        raise APIException(
            'An invite code is not required to register, so invites have been disabled.'
        )
    if not flask.g.user.invites:
        raise APIException('You do not have an invite to send.')

    invite = Invite.new(inviter_id=flask.g.user.id,
                        email=email,
                        ip=flask.request.remote_addr)
    flask.g.user.invites -= 1
    db.session.commit()
    return flask.jsonify(invite)
Exemple #12
0
def check_rate_limit_unauthenticated() -> None:
    """Applies a harsher 30 req / minute to unauthenticated users."""
    cache_key = f'rate_limit_unauth_{flask.request.remote_addr}'
    requests = cache.inc(cache_key, timeout=60)
    if requests > 30:
        time_left = cache.ttl(cache_key)
        raise APIException(
            f'Unauthenticated rate limit exceeded. {time_left} seconds until lock expires.'
        )
Exemple #13
0
def get_rules(section: str) -> dict:
    if section not in SECTION_NAMES:
        raise APIException(f'{section} is not a valid section of the rules.')
    rules = cache.get(f'rules_{section}')
    if not rules:
        filename = os.path.join(os.path.dirname(__file__), f'{section}.json')
        with open(filename, 'r') as f:
            rules = json.load(f)
        cache.set(f'rules_{section}', rules, 0)
    return rules
Exemple #14
0
 def from_language(
     cls, language: str, error: bool = False
 ) -> Optional['WikiLanguage']:
     language = language.lower()
     wiki_language = cls.from_query(
         key=cls.__cache_key_from_language__.format(language=language),
         filter=func.lower(cls.language) == language,
     )
     if error and not wiki_language:
         raise APIException(f'Invalid WikiLanguage {language}.')
     return wiki_language
Exemple #15
0
 def is_valid(cls, pk: str, error: bool = False) -> bool:
     """
     Override the ``SinglePKMixin`` is_valid function to check for presence, not
     the lack of one.
     """
     alias = cls.str_to_alias(pk)
     presence = cls.from_pk(alias)
     if error and presence:
         raise APIException(
             f'The wiki alias {alias} has already been taken.'
         )
     return not presence
Exemple #16
0
 def is_valid_choice(
     cls, pk: int, poll_id: int, error: bool = False
 ) -> bool:
     poll = ForumPoll.from_pk(poll_id, _404=True)
     choice = ForumPollChoice.from_pk(pk, _404=True)
     if poll.id != choice.poll_id:
         if error:
             raise APIException(
                 f'Poll {poll_id} has no answer choice {pk}.'
             )
         return False  # pragma: no cover
     return True
Exemple #17
0
def check_permissions(
    user: User,
    permissions: Dict[str, bool]  # noqa: C901 (McCabe complexity)
) -> Tuple[Set[str], Set[str], Set[str]]:
    """
    The abstracted meat of the permission checkers. Takes the input and
    some model-specific information and returns permission information.

    :param user:        The recipient of the permission changes
    :param permissions: A dictionary of permission changes, with permission name
                        and boolean (True = Add, False = Remove) key value pairs
    :param perm_model:  The permission model to be checked
    :param perm_attr:   The attribute of the user classes which represents the permissions
    """
    add: Set[str] = set()
    ungrant: Set[str] = set()
    delete: Set[str] = set()
    errors: Dict[str, Set[str]] = defaultdict(set)

    uc_permissions: Set[str] = set(user.user_class_model.permissions)
    for class_ in SecondaryClass.from_user(user.id):
        uc_permissions |= set(class_.permissions)
    custom_permissions: Dict[str, bool] = UserPermission.from_user(user.id)

    for perm, active in permissions.items():
        if active is True:
            if perm in custom_permissions:
                if custom_permissions[perm] is False:
                    delete.add(perm)
                    add.add(perm)
            elif perm not in uc_permissions:
                add.add(perm)
            if perm not in add.union(delete):
                errors['add'].add(perm)
        else:
            if perm in custom_permissions and custom_permissions[perm] is True:
                delete.add(perm)
            if perm in uc_permissions:
                ungrant.add(perm)
            if perm not in delete.union(ungrant):
                errors['delete'].add(perm)

    if errors:
        message = []
        if 'add' in errors:
            message.append(f'The following permissions could not be added: '
                           f'{", ".join(errors["add"])}.')
        if 'delete' in errors:
            message.append(f'The following permissions could not be deleted: '
                           f'{", ".join(errors["delete"])}.')
        raise APIException(' '.join(message))

    return add, ungrant, delete
Exemple #18
0
def get_request_data() -> Dict[Any, Any]:
    """
    Turn the incoming json data into a dictionary.

    :return:              The unserialized dict sent by the requester.
    :raises APIException: If the sent data cannot be decoded from JSON.
    """
    try:
        raw_data = flask.request.get_data()
        return json.loads(raw_data) if raw_data else {}
    except ValueError:
        raise APIException('Unable to decode data. Is it valid JSON?')
Exemple #19
0
 def new(cls, username: str, password: str, email: str) -> 'User':
     """
     Alternative constructor which generates a password hash and
     lowercases and strips leading and trailing spaces from the email.
     """
     if cls.from_username(username) is not None:
         raise APIException(f'The username {username} is already in use.')
     return super()._new(
         username=username,
         passhash=generate_password_hash(password),
         email=email.lower().strip(),
     )
Exemple #20
0
    def new(
        cls, *, poll_id: int, user_id: int, choice_id: bool
    ) -> 'ForumPollAnswer':
        User.is_valid(user_id, error=True)
        # ForumPollChoice also validates ForumPoll.
        ForumPollChoice.is_valid_choice(choice_id, poll_id=poll_id, error=True)
        if cls.from_attrs(poll_id=poll_id, user_id=user_id):
            raise APIException('You have already voted for this poll.')

        cache.delete(
            ForumPollChoice.__cache_key_answers__.format(id=choice_id)
        )
        return cls._new(poll_id=poll_id, user_id=user_id, choice_id=choice_id)
Exemple #21
0
def edit_wiki_article(id: int, title: str, language: str, contents: str):
    wiki = WikiArticle.from_pk(id, _404=True)
    if not wiki:
        raise APIException(f'WikiArticle {id} does not exist.')
    language_id = WikiLanguage.from_language(language, error=True)
    wiki = WikiRevision.new(
        article_id=id,
        title=title,
        language_id=language_id,
        editor_id=flask.g.user.id,
        contents=contents,
    )
    return wiki
Exemple #22
0
    def new(cls: Type[UC], name: str, permissions: List[str] = None) -> UC:
        """
        Create a new userclass.

        :param name:        The name of the userclass
        :param permissions: The permissions to be attributed to the userclass
        :return:            The newly created userclass
        """
        if cls.from_name(name):
            raise APIException(
                f'Another {cls.__name__} already has the name {name}.'
            )
        return super()._new(name=name, permissions=permissions or [])
Exemple #23
0
 def new_function(*args, **kwargs):
     if not kwargs.get('skip_validation'):
         try:
             if flask.request.method == 'GET':
                 kwargs.update(schema(flask.request.args.to_dict()))
             else:
                 kwargs.update(schema(get_request_data()))
         except Invalid as e:
             raise APIException(
                 f'Invalid data: {e.msg} (key "{".".join([str(p) for p in e.path])}")'
             )
     else:
         del kwargs['skip_validation']
     return func(*args, **kwargs)
Exemple #24
0
def add_members(id: int, user_ids: List[int]):
    conv = PrivateConversation.from_pk(id,
                                       _404=True,
                                       asrt=MessagePermissions.VIEW_OTHERS)
    already_members = [
        u.username for u in conv.members if u.id in set(user_ids)
    ]
    if already_members:
        raise APIException(
            'The following members are already in the conversation: '
            f'{", ".join(already_members)}.')
    for uid in list(set(user_ids)):
        PrivateConversationState.new(conv_id=id, user_id=uid)
    conv.del_property_cache('members')
    return flask.jsonify(conv.members)
Exemple #25
0
def revoke_api_key(hash: str) -> flask.Response:
    """
    Revokes an API key currently in use by the user. Requires the
    ``api_keys_revoke`` permission to revoke one's own API keys, and the
    ``api_keys_revoke_others`` permission to revoke the keys of other users.

    .. :quickref: APIKey; Revoke an API key.

    **Example request**:

    .. parsed-literal::

       DELETE /api_keys HTTP/1.1

       {
         "hash": "abcdefghij"
       }

    **Example response**:

    .. parsed-literal::

       {
         "status": "success",
         "response": "API Key abcdefghij has been revoked."
       }

    :<json str hash: The hash of the API key

    :statuscode 200: Successfully revoked API keys
    :statuscode 404: API key does not exist or user does not have permission
        to revoke the API key
    """
    api_key = APIKey.from_pk(
        hash,
        include_dead=True,
        _404=True,
        asrt=ApikeyPermissions.REVOKE_OTHERS,
    )
    if api_key.revoked:
        raise APIException(f'APIKey {hash} is already revoked.')
    api_key.revoked = True
    db.session.commit()
    return flask.jsonify(f'APIKey {hash} has been revoked.')
Exemple #26
0
def change_poll_choices(poll: ForumPoll, add: List[str],
                        delete: List[int]) -> None:
    """
    Change the choices to a poll. Create new choices or delete existing ones.
    The choices parameter should contain a dictionary of answer name keys and
    their status as booleans. True = Add, False = Delete.

    :param poll:    The forum poll to alter
    :param choices: The choices to edit
    """
    poll_choice_choices = {c.choice for c in poll.choices}
    poll_choice_ids = {c.id for c in poll.choices}
    errors = {
        'add': {choice
                for choice in add if choice in poll_choice_choices},
        'delete':
        {choice
         for choice in delete if choice not in poll_choice_ids},
    }

    error_message = []
    if errors['add']:
        error_message.append(
            f'The following poll choices could not be added: '  # type: ignore
            f'{", ".join(errors["add"])}.')
    if errors['delete']:
        error_message.append(
            f'The following poll choices could not be deleted: '  # type: ignore
            f'{", ".join([str(d) for d in errors["delete"]])}.')
    if error_message:
        raise APIException(' '.join(error_message))

    for choice in delete:
        choice = ForumPollChoice.from_pk(choice)
        choice.delete_answers()  # type: ignore
        db.session.delete(choice)
    for choice_new in add:
        db.session.add(ForumPollChoice(poll_id=poll.id, choice=choice_new))
    cache.delete(ForumPollChoice.__cache_key_of_poll__.format(poll_id=poll.id))
    poll.del_property_cache('choices')
    db.session.commit()
Exemple #27
0
        def new_function(*args, **kwargs) -> Callable:
            try:
                user_id = int(flask.request.args.to_dict().get(
                    'user_id', flask.g.user.id))
            except ValueError:
                raise APIException('User ID must be an integer.')

            # Remove user_id from the query string because validator will choke on it.
            flask.request.args = MultiDict([
                (e, v) for e, v in flask.request.args.to_dict().items()
                if e != 'user_id'
            ])
            if user_id == flask.g.user.id:
                return func(*args, user=flask.g.user, **kwargs)
            if permission:
                if not flask.g.user.has_permission(permission):
                    raise _403Exception
                elif flask.g.api_key and not flask.g.api_key.has_permission(
                        permission):
                    raise _403Exception(
                        message='This APIKey does not have permission to '
                        'access this resource.')
            return func(*args, user=User.from_pk(user_id, _404=True), **kwargs)
Exemple #28
0
def change_user_permissions(user: User, permissions: Dict[str, bool]) -> None:
    """
    Change the permissions belonging to a user. Permissions can be
    added to a user, deleted from a user, and ungranted from a user.
    Adding a permission occurs when the user does not have the specified
    permission, through custom or userclass. There are two types of permission
    removal: deletion and ungranting. Deletion ocrurs when the user has the
    permission through custom, while ungranting occurs when the user has the
    permission through userclass. If they have both custom and userclass, they
    will lose both.

    :param user:          The user to change permissions for
    :param permissions:   The permissions to change

    :raises APIException: Invalid permissions to change
    """
    to_add, to_ungrant, to_delete = check_permissions(user, permissions)
    for p in to_ungrant:
        if not Permissions.is_valid_permission(p):
            raise APIException(f'{p} is not a valid permission.')
    alter_permissions(user, to_add, to_ungrant, to_delete)
    cache.delete(user.__cache_key_permissions__.format(id=user.id))
    user.del_property_cache('permissions')