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

make file submissions dis/allowable #4879

Merged
Merged
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
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