Skip to content

Commit

Permalink
Merge pull request #4879 from wbaid/config-allow-document-uploads
Browse files Browse the repository at this point in the history
make file submissions dis/allowable
  • Loading branch information
redshiftzero authored Nov 20, 2019
2 parents 67c23e9 + ab25a8f commit 7b76c60
Show file tree
Hide file tree
Showing 16 changed files with 311 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""add versioned instance config
Revision ID: 523fff3f969c
Revises: 3da3fcab826a
Create Date: 2019-11-02 23:06:12.161868
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '523fff3f969c'
down_revision = '3da3fcab826a'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('instance_config',
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('valid_until', sa.DateTime(), nullable=True),
sa.Column('allow_document_uploads', sa.Boolean(), nullable=True),

sa.PrimaryKeyConstraint('version'),
sa.UniqueConstraint('valid_until'),
)
# ### end Alembic commands ###

# Data migration: Since allow_document_uploads is the first
# instance_config setting (column), all we have to do is insert a
# row with its default value.
conn = op.get_bind()
conn.execute("""INSERT INTO instance_config (allow_document_uploads) VALUES (1)""")


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('instance_config')
# ### end Alembic commands ###
6 changes: 5 additions & 1 deletion securedrop/journalist_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from journalist_app.utils import (get_source, logged_in,
JournalistInterfaceSessionInterface,
cleanup_expired_revoked_tokens)
from models import Journalist
from models import InstanceConfig, Journalist
from store import Storage

import typing
Expand Down Expand Up @@ -124,6 +124,10 @@ def _handle_http_exception(error):
def expire_blacklisted_tokens():
return cleanup_expired_revoked_tokens()

@app.before_request
def load_instance_config():
app.instance_config = InstanceConfig.get_current()

@app.before_request
def setup_g():
# type: () -> Optional[Response]
Expand Down
30 changes: 23 additions & 7 deletions securedrop/journalist_app/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
from sqlalchemy.orm.exc import NoResultFound

from db import db
from models import Journalist, InvalidUsernameException, FirstOrLastNameError, PasswordError
from models import (InstanceConfig, Journalist, InvalidUsernameException,
FirstOrLastNameError, PasswordError)
from journalist_app.decorators import admin_required
from journalist_app.utils import (make_password, commit_account_changes, set_diceware_password,
validate_hotp_secret, revoke_token)
from journalist_app.forms import LogoForm, NewUserForm
from journalist_app.forms import LogoForm, NewUserForm, SubmissionPreferencesForm


def make_blueprint(config):
Expand All @@ -28,9 +29,12 @@ def index():
@view.route('/config', methods=('GET', 'POST'))
@admin_required
def manage_config():
form = LogoForm()
if form.validate_on_submit():
f = form.logo.data
# The UI prompt ("prevent") is the opposite of the setting ("allow"):
submission_preferences_form = SubmissionPreferencesForm(
prevent_document_uploads=not current_app.instance_config.allow_document_uploads)
logo_form = LogoForm()
if logo_form.validate_on_submit():
f = logo_form.logo.data
custom_logo_filepath = os.path.join(current_app.static_folder, 'i',
'custom_logo.png')
try:
Expand All @@ -42,10 +46,22 @@ def manage_config():
finally:
return redirect(url_for("admin.manage_config"))
else:
for field, errors in list(form.errors.items()):
for field, errors in list(logo_form.errors.items()):
for error in errors:
flash(error, "logo-error")
return render_template("config.html", form=form)
return render_template("config.html",
submission_preferences_form=submission_preferences_form,
logo_form=logo_form)

@view.route('/update-submission-preferences', methods=['POST'])
@admin_required
def update_submission_preferences():
form = SubmissionPreferencesForm()
if form.validate_on_submit():
# The UI prompt ("prevent") is the opposite of the setting ("allow"):
value = not bool(request.form.get('prevent_document_uploads'))
InstanceConfig.set('allow_document_uploads', value)
return redirect(url_for('admin.manage_config'))

@view.route('/add', methods=('GET', 'POST'))
@admin_required
Expand Down
4 changes: 4 additions & 0 deletions securedrop/journalist_app/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ class ReplyForm(FlaskForm):
)


class SubmissionPreferencesForm(FlaskForm):
prevent_document_uploads = BooleanField('prevent_document_uploads')


class LogoForm(FlaskForm):
logo = FileField(validators=[
FileRequired(message=gettext('File required.')),
Expand Down
17 changes: 16 additions & 1 deletion securedrop/journalist_templates/config.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ <h2>{{ gettext('Logo Image') }}</h2>
<form method="post" enctype="multipart/form-data">
<input name="csrf_token" type="hidden" value="{{ csrf_token() }}">
<p>
{{ form.logo(id="logo-upload") }}
{{ logo_form.logo(id="logo-upload") }}
<br>
</p>
<h5>
Expand All @@ -41,4 +41,19 @@ <h5>
{% include 'logo_upload_flashed.html' %}
</form>

<hr class="no-line">

<h2>{{ gettext('Submission Preferences') }}</h2>

<form action="{{ url_for('admin.update_submission_preferences') }}" method="post">
<input name="csrf_token" type="hidden" value="{{ csrf_token() }}">
<p>
{{ submission_preferences_form.prevent_document_uploads() }}
<label for="prevent_document_uploads">{{ gettext('Prevent sources from uploading documents. Sources will still be able to send messages.') }}</label>
</p>
<button type="submit" id="submit-submission-preferences">
<i class="fas fa-pencil-alt"></i> {{ gettext('UPDATE SUBMISSION PREFERENCES') }}
</button>
</form>

{% endblock %}
65 changes: 65 additions & 0 deletions securedrop/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -739,3 +739,68 @@ class RevokedToken(db.Model):
id = Column(Integer, primary_key=True)
journalist_id = Column(Integer, ForeignKey('journalists.id'))
token = db.Column(db.Text, nullable=False, unique=True)


class InstanceConfig(db.Model):
'''Versioned key-value store of settings configurable from the journalist
interface. The current version has valid_until=None.
'''

__tablename__ = 'instance_config'
version = Column(Integer, primary_key=True)
valid_until = Column(DateTime, default=None, unique=True)

allow_document_uploads = Column(Boolean, default=True)

# Columns not listed here will be included by InstanceConfig.copy() when
# updating the configuration.
metadata_cols = ['version', 'valid_until']

def __repr__(self):
return "<InstanceConfig(version=%s, valid_until=%s)>" % (self.version, self.valid_until)

def copy(self):
'''Make a copy of only the configuration columns of the given
InstanceConfig object: i.e., excluding metadata_cols.
'''

new = type(self)()
for col in self.__table__.columns:
if col.name in self.metadata_cols:
continue

setattr(new, col.name, getattr(self, col.name))

return new

@classmethod
def get_current(cls):
'''If the database was created via db.create_all(), data migrations
weren't run, and the "instance_config" table is empty. In this case,
save and return a base configuration derived from each setting's
column-level default.
'''

try:
return cls.query.filter(cls.valid_until == None).one() # noqa: E711
except NoResultFound:
current = cls()
db.session.add(current)
db.session.commit()
return current

@classmethod
def set(cls, name, value):
'''Invalidate the current configuration and append a new one with the
requested change.
'''

old = cls.get_current()
old.valid_until = datetime.datetime.utcnow()
db.session.add(old)

new = old.copy()
setattr(new, name, value)
db.session.add(new)

db.session.commit()
6 changes: 6 additions & 0 deletions securedrop/sass/modules/_snippet.sass
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@

&:focus
outline: none

.wide
width: 100%

textarea
width: 100%
7 changes: 6 additions & 1 deletion securedrop/source_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from crypto_util import CryptoUtil
from db import db
from models import Source
from models import InstanceConfig, Source
from request_that_secures_file_uploads import RequestThatSecuresFileUploads
from source_app import main, info, api
from source_app.decorators import ignore_static
Expand Down Expand Up @@ -130,6 +130,11 @@ def check_tor2web():
.format(url=url_for('info.tor2web_warning'))),
"banner-warning")

@app.before_request
@ignore_static
def load_instance_config():
app.instance_config = InstanceConfig.get_current()

@app.before_request
@ignore_static
def setup_g():
Expand Down
3 changes: 2 additions & 1 deletion securedrop/source_app/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import json
import platform

from flask import Blueprint, make_response
from flask import Blueprint, current_app, make_response

import version

Expand All @@ -12,6 +12,7 @@ def make_blueprint(config):
@view.route('/metadata')
def metadata():
meta = {
'allow_document_uploads': current_app.instance_config.allow_document_uploads,
'gpg_fpr': config.JOURNALIST_KEY,
'sd_version': version.__version__,
'server_os': platform.linux_distribution()[1],
Expand Down
13 changes: 9 additions & 4 deletions securedrop/source_app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def lookup():

return render_template(
'lookup.html',
allow_document_uploads=current_app.instance_config.allow_document_uploads,
codename=g.codename,
replies=replies,
flagged=g.source.flagged,
Expand All @@ -131,16 +132,20 @@ def lookup():
@view.route('/submit', methods=('POST',))
@login_required
def submit():
allow_document_uploads = current_app.instance_config.allow_document_uploads
msg = request.form['msg']
fh = None
if 'fh' in request.files:
if allow_document_uploads and 'fh' in request.files:
fh = request.files['fh']

# Don't submit anything if it was an "empty" submission. #878
if not (msg or fh):
flash(gettext(
"You must enter a message or choose a file to submit."),
"error")
if allow_document_uploads:
flash(gettext(
"You must enter a message or choose a file to submit."),
"error")
else:
flash(gettext("You must enter a message."), "error")
return redirect(url_for('main.lookup'))

fnames = []
Expand Down
15 changes: 13 additions & 2 deletions securedrop/source_templates/lookup.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,34 @@
{% endif %}
</div>

{% if allow_document_uploads %}
<h2 class="headline">{{ gettext('Submit Files or Messages') }}</h2>
<p class="explanation">{{ gettext('You can submit any kind of file, a message, or both.') }}</p>
{% else %}
<h2 class="headline">{{ gettext('Submit Messages') }}</h2>
{% endif %}

<p class="explanation extended-explanation">{{ gettext('If you are already familiar with GPG, you can optionally encrypt your files and messages with our <a href="{url}" class="text-link">public key</a> before submission. Files are encrypted as they are received by SecureDrop.').format(url=url_for('info.download_journalist_pubkey')) }}
<p class="explanation extended-explanation">
{% if allow_document_uploads %}
{{ gettext('If you are already familiar with GPG, you can optionally encrypt your files and messages with our <a href="{url}" class="text-link">public key</a> before submission. Files are encrypted as they are received by SecureDrop.').format(url=url_for('info.download_journalist_pubkey')) }}
{% else %}
{{ gettext('If you are already familiar with GPG, you can optionally encrypt your messages with our <a href="{url}" class="text-link">public key</a> before submission.').format(url=url_for('info.download_journalist_pubkey')) }}
{% endif %}
{{ gettext('<a href="{url}" class="text-link">Learn more</a>.').format(url=url_for('info.why_download_journalist_pubkey')) }}</p>

<hr class="no-line">

<form id="upload" method="post" action="{{ url_for('main.submit') }}" enctype="multipart/form-data" autocomplete="off">
<input name="csrf_token" type="hidden" value="{{ csrf_token() }}">
<div class="snippet">
{% if allow_document_uploads %}
<div class="attachment grid-item center">
<img class="center" id="upload-icon" src="{{ url_for('static', filename='i/arrow-upload-large.png') }}" width="56" height="56">
<input type="file" name="fh" autocomplete="off">
<p class="center" id="max-file-size">{{ gettext('Maximum upload size: 500 MB') }}</p>
</div>
<div class="message grid-item">
{% endif %}
<div class="message grid-item{% if not allow_document_uploads %} wide{% endif %}">
<textarea name="msg" class="fill-parent" placeholder="{{ gettext('Write a message.') }}"></textarea>
</div>
</div>
Expand Down
40 changes: 40 additions & 0 deletions securedrop/tests/migrations/migration_523fff3f969c.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from sqlalchemy import text
from sqlalchemy.exc import OperationalError

from db import db
from journalist_app import create_app


instance_config_sql = "SELECT * FROM instance_config"


class UpgradeTester:
def __init__(self, config):
self.config = config
self.app = create_app(config)

def load_data(self):
pass

def check_upgrade(self):
with self.app.app_context():
db.engine.execute(text(instance_config_sql)).fetchall()


class DowngradeTester:
def __init__(self, config):
self.config = config
self.app = create_app(config)

def load_data(self):
pass

def check_downgrade(self):
with self.app.app_context():
try:
db.engine.execute(text(instance_config_sql)).fetchall()

# The SQLite driver appears to return this rather than the
# expected NoSuchTableError.
except OperationalError:
pass
2 changes: 2 additions & 0 deletions securedrop/tests/test_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ def __getattr__(self, name):
return getattr(config, name)
not_translated = 'code hello i18n'
with source_app.create_app(Config()).test_client() as c:
with c.application.app_context():
db.create_all()
c.get('/')
assert not_translated == gettext(not_translated)

Expand Down
Loading

0 comments on commit 7b76c60

Please sign in to comment.