Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consolidate logic for searching sources and provide pagination framework #6764

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
2 changes: 2 additions & 0 deletions securedrop/actions/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class NotFoundError(Exception):
pass
35 changes: 35 additions & 0 deletions securedrop/actions/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Optional

from sqlalchemy.orm import Query


@dataclass(frozen=True)
class PaginationConfig:
page_size: int
page_number: int

def __post_init__(self) -> None:
if self.page_size < 1:
raise ValueError("Received a page_size that's less than 1")
if self.page_number < 0:
raise ValueError("Received a page_number that's less than 0")


class SupportsPagination(ABC):
@abstractmethod
def create_query(self) -> Query:
pass

def perform(self, paginate_results_with_config: Optional[PaginationConfig] = None) -> List:
query = self.create_query()

if paginate_results_with_config:
offset = (
paginate_results_with_config.page_size * paginate_results_with_config.page_number
)
limit = paginate_results_with_config.page_size
query = query.offset(offset).limit(limit)

return query.all()
134 changes: 134 additions & 0 deletions securedrop/actions/sources_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Optional

import models
from actions.exceptions import NotFoundError
from actions.pagination import SupportsPagination
from encryption import EncryptionManager, GpgKeyNotFoundError
from sqlalchemy.orm import Query, Session
from store import Storage


class SearchSourcesOrderByEnum(str, Enum):
# Not needed yet; only here if we ever need to order the results. For example:
# LAST_UPDATED_DESC = "LAST_UPDATED_DESC"
pass


@dataclass(frozen=True)
class SearchSourcesFilters:
# The default values for the filters below are meant to match the most common
# "use case" when searching sources. Also, using None means "don't enable this filter"
filter_by_is_pending: Optional[bool] = False
filter_by_is_starred: Optional[bool] = None
filter_by_is_deleted: Optional[bool] = False
filter_by_was_updated_after: Optional[datetime] = None


class SearchSourcesAction(SupportsPagination):
def __init__(
self,
db_session: Session,
filters: SearchSourcesFilters = SearchSourcesFilters(),
order_by: Optional[SearchSourcesOrderByEnum] = None,
):
self._db_session = db_session
self._filters = filters
if order_by:
raise NotImplementedError("The order_by argument is not implemented")

def create_query(self) -> Query:
query = self._db_session.query(models.Source)

if self._filters.filter_by_is_deleted is True:
query = query.filter(models.Source.deleted_at.isnot(None))
elif self._filters.filter_by_is_deleted is False:
query = query.filter(models.Source.deleted_at.is_(None))
else:
# filter_by_is_deleted is None; nothing to do
pass

if self._filters.filter_by_is_pending is not None:
query = query.filter_by(pending=self._filters.filter_by_is_pending)

if self._filters.filter_by_is_starred is not None:
query = query.filter(models.Source.is_starred == self._filters.filter_by_is_starred)

if self._filters.filter_by_was_updated_after is not None:
query = query.filter(
models.Source.last_updated > self._filters.filter_by_was_updated_after
)

if self._filters.filter_by_is_pending in [None, False]:
# Never return sources with a None last_updated field unless "pending" sources
# were explicitly requested
query = query.filter(models.Source.last_updated.isnot(None))

return query


class GetSingleSourceAction:
def __init__(
self,
db_session: Session,
# The two arguments are mutually exclusive
filesystem_id: Optional[str] = None,
uuid: Optional[str] = None,
) -> None:
self._db_session = db_session
if uuid and filesystem_id:
raise ValueError("uuid and filesystem_id are mutually exclusive")
if uuid is None and filesystem_id is None:
raise ValueError("At least one of uuid and filesystem_id must be supplied")

self._filesystem_id = filesystem_id
self._uuid = uuid

def perform(self) -> models.Source:
source: Optional[models.Source]
if self._uuid:
source = self._db_session.query(models.Source).filter_by(uuid=self._uuid).one_or_none()
elif self._filesystem_id:
source = (
self._db_session.query(models.Source)
.filter_by(filesystem_id=self._filesystem_id)
.one_or_none()
)
else:
raise ValueError("Should never happen")

if source is None:
raise NotFoundError()
else:
return source


class DeleteSingleSourceAction:
"""Delete a source and all of its submissions and GPG key."""

def __init__(
self,
db_session: Session,
source: models.Source,
) -> None:
self._db_session = db_session
self._source = source

def perform(self) -> None:
# Delete the source's collection of submissions
path = Path(Storage.get_default().path(self._source.filesystem_id))
if path.exists():
Storage.get_default().move_to_shredder(path.as_posix())

# Delete the source's reply keypair, if it exists
try:
EncryptionManager.get_default().delete_source_key_pair(self._source.filesystem_id)
except GpgKeyNotFoundError:
pass

# Delete their entry in the db
self._db_session.delete(self._source)
self._db_session.commit()
6 changes: 4 additions & 2 deletions securedrop/journalist.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from actions.sources_actions import SearchSourcesAction
from db import db
from encryption import EncryptionManager, GpgKeyNotFoundError
from execution import asynchronous
from journalist_app import create_app
from models import Source
from sdconfig import SecureDropConfig

config = SecureDropConfig.get_current()
Expand All @@ -14,7 +15,8 @@ def prime_keycache() -> None:
"""Pre-load the source public keys into Redis."""
with app.app_context():
encryption_mgr = EncryptionManager.get_default()
for source in Source.query.filter_by(pending=False, deleted_at=None).all():
all_sources = SearchSourcesAction(db_session=db.session).perform()
for source in all_sources:
try:
encryption_mgr.get_source_public_key(source.filesystem_id)
except GpgKeyNotFoundError:
Expand Down
8 changes: 6 additions & 2 deletions securedrop/journalist_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
import i18n
import template_filters
import version
from actions.exceptions import NotFoundError
from db import db
from flask import Flask, abort, g, json, redirect, render_template, request, url_for
from flask_babel import gettext
from flask_wtf.csrf import CSRFError, CSRFProtect
from journalist_app import account, admin, api, col, main
from journalist_app.sessions import Session, session
from journalist_app.utils import get_source
from models import InstanceConfig
from sdconfig import SecureDropConfig
from werkzeug import Response
Expand Down Expand Up @@ -72,6 +72,11 @@ def handle_csrf_error(e: CSRFError) -> "Response":
session.destroy(("error", msg), session.get("locale"))
return redirect(url_for("main.login"))

# Convert a NotFoundError raised by an action into a 404
@app.errorhandler(NotFoundError)
def handle_action_raised_not_found(e: NotFoundError) -> None:
abort(404)

def _handle_http_exception(
error: HTTPException,
) -> Tuple[Union[Response, str], Optional[int]]:
Expand Down Expand Up @@ -132,7 +137,6 @@ def setup_g() -> Optional[Response]:
filesystem_id = request.form.get("filesystem_id")
if filesystem_id:
g.filesystem_id = filesystem_id # pylint: disable=assigning-non-slot
g.source = get_source(filesystem_id) # pylint: disable=assigning-non-slot

return None

Expand Down
43 changes: 26 additions & 17 deletions securedrop/journalist_app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@

import flask
import werkzeug
from actions.exceptions import NotFoundError
from actions.sources_actions import (
DeleteSingleSourceAction,
GetSingleSourceAction,
SearchSourcesAction,
)
from db import db
from flask import Blueprint, abort, jsonify, request
from journalist_app import utils
Expand All @@ -17,7 +23,6 @@
LoginThrottledException,
Reply,
SeenReply,
Source,
Submission,
WrongPasswordException,
)
Expand Down Expand Up @@ -121,31 +126,31 @@ def get_token() -> Tuple[flask.Response, int]:

@api.route("/sources", methods=["GET"])
def get_all_sources() -> Tuple[flask.Response, int]:
sources = Source.query.filter_by(pending=False, deleted_at=None).all()
sources = SearchSourcesAction(db_session=db.session).perform()
return jsonify({"sources": [source.to_json() for source in sources]}), 200

@api.route("/sources/<source_uuid>", methods=["GET", "DELETE"])
def single_source(source_uuid: str) -> Tuple[flask.Response, int]:
if request.method == "GET":
source = get_or_404(Source, source_uuid, column=Source.uuid)
source = GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
return jsonify(source.to_json()), 200
elif request.method == "DELETE":
source = get_or_404(Source, source_uuid, column=Source.uuid)
utils.delete_collection(source.filesystem_id)
source = GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
DeleteSingleSourceAction(db_session=db.session, source=source).perform()
return jsonify({"message": "Source and submissions deleted"}), 200
else:
abort(405)

@api.route("/sources/<source_uuid>/add_star", methods=["POST"])
def add_star(source_uuid: str) -> Tuple[flask.Response, int]:
source = get_or_404(Source, source_uuid, column=Source.uuid)
source = GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
utils.make_star_true(source.filesystem_id)
db.session.commit()
return jsonify({"message": "Star added"}), 201

@api.route("/sources/<source_uuid>/remove_star", methods=["DELETE"])
def remove_star(source_uuid: str) -> Tuple[flask.Response, int]:
source = get_or_404(Source, source_uuid, column=Source.uuid)
source = GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
utils.make_star_false(source.filesystem_id)
db.session.commit()
return jsonify({"message": "Star removed"}), 200
Expand All @@ -160,29 +165,29 @@ def flag(source_uuid: str) -> Tuple[flask.Response, int]:
@api.route("/sources/<source_uuid>/conversation", methods=["DELETE"])
def source_conversation(source_uuid: str) -> Tuple[flask.Response, int]:
if request.method == "DELETE":
source = get_or_404(Source, source_uuid, column=Source.uuid)
utils.delete_source_files(source.filesystem_id)
source = GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
utils.delete_source_files(source)
return jsonify({"message": "Source data deleted"}), 200
else:
abort(405)

@api.route("/sources/<source_uuid>/submissions", methods=["GET"])
def all_source_submissions(source_uuid: str) -> Tuple[flask.Response, int]:
source = get_or_404(Source, source_uuid, column=Source.uuid)
source = GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
return (
jsonify({"submissions": [submission.to_json() for submission in source.submissions]}),
200,
)

@api.route("/sources/<source_uuid>/submissions/<submission_uuid>/download", methods=["GET"])
def download_submission(source_uuid: str, submission_uuid: str) -> flask.Response:
get_or_404(Source, source_uuid, column=Source.uuid)
GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
submission = get_or_404(Submission, submission_uuid, column=Submission.uuid)
return utils.serve_file_with_etag(submission)

@api.route("/sources/<source_uuid>/replies/<reply_uuid>/download", methods=["GET"])
def download_reply(source_uuid: str, reply_uuid: str) -> flask.Response:
get_or_404(Source, source_uuid, column=Source.uuid)
GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
reply = get_or_404(Reply, reply_uuid, column=Reply.uuid)

return utils.serve_file_with_etag(reply)
Expand All @@ -193,11 +198,11 @@ def download_reply(source_uuid: str, reply_uuid: str) -> flask.Response:
)
def single_submission(source_uuid: str, submission_uuid: str) -> Tuple[flask.Response, int]:
if request.method == "GET":
get_or_404(Source, source_uuid, column=Source.uuid)
GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
submission = get_or_404(Submission, submission_uuid, column=Submission.uuid)
return jsonify(submission.to_json()), 200
elif request.method == "DELETE":
get_or_404(Source, source_uuid, column=Source.uuid)
GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
submission = get_or_404(Submission, submission_uuid, column=Submission.uuid)
utils.delete_file_object(submission)
return jsonify({"message": "Submission deleted"}), 200
Expand All @@ -207,13 +212,13 @@ def single_submission(source_uuid: str, submission_uuid: str) -> Tuple[flask.Res
@api.route("/sources/<source_uuid>/replies", methods=["GET", "POST"])
def all_source_replies(source_uuid: str) -> Tuple[flask.Response, int]:
if request.method == "GET":
source = get_or_404(Source, source_uuid, column=Source.uuid)
source = GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
return (
jsonify({"replies": [reply.to_json() for reply in source.replies]}),
200,
)
elif request.method == "POST":
source = get_or_404(Source, source_uuid, column=Source.uuid)
source = GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
if request.json is None:
abort(400, "please send requests in valid JSON")

Expand Down Expand Up @@ -277,7 +282,7 @@ def all_source_replies(source_uuid: str) -> Tuple[flask.Response, int]:

@api.route("/sources/<source_uuid>/replies/<reply_uuid>", methods=["GET", "DELETE"])
def single_reply(source_uuid: str, reply_uuid: str) -> Tuple[flask.Response, int]:
get_or_404(Source, source_uuid, column=Source.uuid)
GetSingleSourceAction(db_session=db.session, uuid=source_uuid).perform()
reply = get_or_404(Reply, reply_uuid, column=Reply.uuid)
if request.method == "GET":
return jsonify(reply.to_json()), 200
Expand Down Expand Up @@ -364,6 +369,10 @@ def logout() -> Tuple[flask.Response, int]:
session.destroy()
return jsonify({"message": "Your token has been revoked."}), 200

@api.errorhandler(NotFoundError)
def handle_not_found_error(e: NotFoundError) -> Tuple[flask.Response, int]:
return jsonify({"message": "Not Found."}), 404

def _handle_api_http_exception(
error: werkzeug.exceptions.HTTPException,
) -> Tuple[flask.Response, int]:
Expand Down
Loading