Skip to content

Commit

Permalink
Merge pull request #351 from scidsg/contact-method
Browse files Browse the repository at this point in the history
Add optional contact method field
  • Loading branch information
glenn-sorrentino authored May 25, 2024
2 parents f172850 + 95932aa commit c57e77c
Show file tree
Hide file tree
Showing 9 changed files with 209 additions and 66 deletions.
73 changes: 35 additions & 38 deletions hushline/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from flask_wtf import FlaskForm
from sqlalchemy import event
from wtforms import PasswordField, StringField, TextAreaField
from wtforms.validators import DataRequired, Length, ValidationError
from wtforms.validators import DataRequired, Length, Optional, ValidationError

from .crypto import encrypt_message
from .db import db
Expand All @@ -43,10 +43,13 @@ class TwoFactorForm(FlaskForm):


class MessageForm(FlaskForm):
contact_method = StringField(
"Contact Method",
validators=[Optional(), Length(max=255)], # Optional if you want it to be non-mandatory
)
content = TextAreaField(
"Message",
validators=[DataRequired(), Length(max=10000)],
render_kw={"placeholder": "Include a contact method if you want a response..."},
)


Expand Down Expand Up @@ -117,84 +120,78 @@ def inbox() -> Response | str:
)

@app.route("/submit_message/<username>", methods=["GET", "POST"])
@limiter.limit("120 per minute")
def submit_message(username: str) -> Response | str:
# Initialize the form
def submit_message(username: str):
form = MessageForm()

# Retrieve the user details
user = User.query.filter_by(primary_username=username).first()
if not user:
flash("🫥 User not found.")
flash("User not found.")
return redirect(url_for("index"))

# Decide the display name or username
display_name_or_username = user.display_name or user.primary_username

# Check if there is a prefill content
prefill_content = request.args.get("prefill", "")
if prefill_content:
# Pre-fill the form with the content if provided
form.content.data = prefill_content

# Process form submission
if form.validate_on_submit():
content = form.content.data
contact_method = form.contact_method.data.strip() if form.contact_method.data else ""
full_content = (
f"Contact Method: {contact_method}\n\n{content}" if contact_method else content
)
client_side_encrypted = request.form.get("client_side_encrypted", "false") == "true"

# Handle encryption if necessary
if not client_side_encrypted and user.pgp_key:
encrypted_content = encrypt_message(content, user.pgp_key)
email_content = encrypted_content if encrypted_content else content
if not encrypted_content:
flash("⛔️ Failed to encrypt message with PGP key.", "error")
if client_side_encrypted:
content_to_save = (
content # Assume content is already encrypted and includes contact method
)
elif user.pgp_key:
try:
encrypted_content = encrypt_message(full_content, user.pgp_key)
if not encrypted_content:
flash("Failed to encrypt message with PGP key.", "error")
return redirect(url_for("submit_message", username=username))
content_to_save = encrypted_content
except Exception as e:
app.logger.error("Encryption failed: %s", str(e), exc_info=True)
flash("Failed to encrypt message due to an error.", "error")
return redirect(url_for("submit_message", username=username))
else:
email_content = content
content_to_save = full_content

# Save the new message
new_message = Message(content=email_content, user_id=user.id)
new_message = Message(content=content_to_save, user_id=user.id)
db.session.add(new_message)
db.session.commit()

# Attempt to send an email notification
if (
user.email
and user.smtp_server
and user.smtp_port
and user.smtp_username
and user.smtp_password
and email_content
and content_to_save
):
try:
sender_email = user.smtp_username
email_sent = send_email(
user.email, "New Message", email_content, user, sender_email
user.email, "New Message", content_to_save, user, sender_email
)
flash_message = (
"👍 Message submitted and email sent successfully."
"Message submitted and email sent successfully."
if email_sent
else "👍 Message submitted, but failed to send email."
else "Message submitted, but failed to send email."
)
flash(flash_message)
except Exception as e:
app.logger.error(f"Error sending email: {str(e)}", exc_info=True)
flash(
"👍 Message submitted, but an error occurred while sending email.",
"warning",
"Message submitted, but an error occurred while sending email.", "warning"
)
app.logger.error(f"Error sending email: {str(e)}")
else:
flash("👍 Message submitted successfully.")
flash("Message submitted successfully.")

return redirect(url_for("submit_message", username=username))

# Render the form page
return render_template(
"submit_message.html",
form=form,
user=user,
username=username,
display_name_or_username=display_name_or_username,
display_name_or_username=user.display_name or user.primary_username,
current_user_id=session.get("user_id"),
public_key=user.pgp_key,
)
Expand Down
2 changes: 1 addition & 1 deletion hushline/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ a.logoutLink {

.message.encrypted p {
font-family: var(--font-mono);
font-size: var(--font-size-small);
font-size: calc(var(--font-size-smaller) * 1.0625);
white-space: break-spaces
}

Expand Down
38 changes: 21 additions & 17 deletions hushline/static/js/client-side-encryption.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
document.addEventListener('DOMContentLoaded', function() {
// Client-side encryption
const form = document.getElementById('messageForm');
const messageField = document.querySelector('textarea[name="content"]');
const contactMethodField = document.getElementById('contact_method');
const encryptedFlag = document.getElementById('clientSideEncrypted');
const publicKeyArmored = document.getElementById('publicKey') ? document.getElementById('publicKey').value : '';

Expand All @@ -26,22 +26,26 @@ document.addEventListener('DOMContentLoaded', function() {
}
}

if (form) {
form.addEventListener('submit', async function(event) {
event.preventDefault();
form.addEventListener('submit', async function(event) {
event.preventDefault();

const messageWithNote = messageField.value;
const encryptedMessage = await encryptMessage(publicKeyArmored, messageWithNote);
const contactMethod = contactMethodField.value.trim();
let fullMessage = messageField.value;
if (contactMethod) {
fullMessage = `Contact Method: ${contactMethod}\n\n${messageField.value}`;
}

if (encryptedMessage) {
messageField.value = encryptedMessage;
encryptedFlag.value = 'true';
form.submit(); // Programmatically submit the form
} else {
console.log('Client-side encryption failed, submitting plaintext.');
encryptedFlag.value = 'false';
form.submit(); // Submit the plaintext message for potential server-side encryption
}
});
}
const encryptedMessage = await encryptMessage(publicKeyArmored, fullMessage);

if (encryptedMessage) {
messageField.value = encryptedMessage;
encryptedFlag.value = 'true';
contactMethodField.disabled = true; // Disable the contact method field to prevent it from being submitted
} else {
console.log('Client-side encryption failed, submitting plaintext.');
encryptedFlag.value = 'false';
}

form.submit(); // Submit the form after processing
});
});
19 changes: 14 additions & 5 deletions hushline/static/js/inbox.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
document.addEventListener('DOMContentLoaded', function() {
// Handle message deletion confirmation
document.getElementById('deleteMessageButton')?.addEventListener('click', function(event) {
const confirmed = confirm('Are you sure you want to delete this message? This cannot be undone.');
if (!confirmed) {
event.preventDefault();
// Listen for clicks anywhere in the document
document.addEventListener('click', function(event) {
// Check if the clicked element or its parents have the 'btn-danger' class
let targetElement = event.target;
while (targetElement != null) {
if (targetElement.classList && targetElement.classList.contains('btn-danger')) {
// Confirm before deletion
const confirmed = confirm('Are you sure you want to delete this message? This cannot be undone.');
if (!confirmed) {
event.preventDefault();
}
return; // Exit the loop and function after handling the click
}
targetElement = targetElement.parentElement;
}
});
});
9 changes: 4 additions & 5 deletions hushline/templates/submit_message.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ <h2 class="submit">Submit a message to {{ display_name_or_username }}</h2>
{% else %}
<p class="helper">⚠️ Your messages will NOT be encrypted. If you expect messages to contain sensitive information, please <a href="https://github.com/scidsg/hushline/blob/main/docs/1-getting-started.md" target="_blank" rel="noopener noreferrer">add a public PGP key</a>.</p>
{% endif %}
{% else %}
{% else %}
{% if user.pgp_key %}
<p class="helper">🔐 Your message will be encrypted and only readable by {{ display_name_or_username }}.</p>
{% else %}
Expand All @@ -27,8 +27,10 @@ <h2 class="submit">Submit a message to {{ display_name_or_username }}</h2>
{% endif %}
<form method="POST" action="/submit_message/{{ username }}" id="messageForm">
{{ form.hidden_tag() }}
<label for="contact_method">Contact Method (Optional)</label>
<input type="text" id="contact_method" name="contact_method" value="{{ form.contact_method.data if form.contact_method.data is not none else '' }}">
<label for="content">Message</label>
<textarea id="content" maxlength="2000" name="content" placeholder="Include a contact method if you want a response..." required="" size="32" data-mvelo-frame="att" spellcheck="true">{{ form.content.data if form.content.data is not none else '' }}</textarea>
<textarea id="content" maxlength="10000" name="content" required="" spellcheck="true">{{ form.content.data if form.content.data is not none else '' }}</textarea>
<!-- Hidden field for public PGP key -->
<input type="hidden" id="publicKey" value="{{ user.pgp_key }}" />
<!-- Hidden field to indicate if the message was encrypted client-side -->
Expand All @@ -40,7 +42,4 @@ <h2 class="submit">Submit a message to {{ display_name_or_username }}</h2>
{% block scripts %}
<script src="https://unpkg.com/openpgp@latest/dist/openpgp.min.js"></script>
<script src="{{ url_for('static', filename='js/client-side-encryption.js') }}"></script>



{% endblock %}
33 changes: 33 additions & 0 deletions migrations/versions/2e5a6b20908d_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""empty message
Revision ID: 2e5a6b20908d
Revises: cab44a7264a5
Create Date: 2024-05-24 19:57:36.878591
"""

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "2e5a6b20908d"
down_revision = "cab44a7264a5"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("message", schema=None) as batch_op:
batch_op.add_column(sa.Column("contact_method", sa.String(length=255), nullable=True))

# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("message", schema=None) as batch_op:
batch_op.drop_column("contact_method")

# ### end Alembic commands ###
33 changes: 33 additions & 0 deletions migrations/versions/73306df7f74c_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""empty message
Revision ID: 73306df7f74c
Revises: 2e5a6b20908d
Create Date: 2024-05-25 01:04:44.495978
"""

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "73306df7f74c"
down_revision = "2e5a6b20908d"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("message", schema=None) as batch_op:
batch_op.drop_column("contact_method")

# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("message", schema=None) as batch_op:
batch_op.add_column(sa.Column("contact_method", sa.VARCHAR(length=255), nullable=True))

# ### end Alembic commands ###
25 changes: 25 additions & 0 deletions migrations/versions/cab44a7264a5_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""empty message
Revision ID: cab44a7264a5
Revises: 9803e4dcb0a6
Create Date: 2024-05-24 19:55:09.067118
"""

# revision identifiers, used by Alembic.
revision = "cab44a7264a5"
down_revision = "9803e4dcb0a6"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
43 changes: 43 additions & 0 deletions tests/test_submit_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,46 @@ def test_submit_message(client):

# Assert that the submitted message is displayed in the inbox
assert b"This is a test message." in response.data


def test_submit_message_with_contact_method(client):
# Register a user
user = register_user(client, "test_user_concat", "Secure-Test-Pass123")
assert user is not None

# Log in the user
login_success = login_user(client, "test_user_concat", "Secure-Test-Pass123")
assert login_success

# Prepare the message and contact method data
message_content = "This is a test message."
contact_method = "email@example.com"
message_data = {
"content": message_content,
"contact_method": contact_method,
"client_side_encrypted": "false", # Simulate that this is not client-side encrypted
}

# Send a POST request to submit the message
response = client.post(
f"/submit_message/{user.primary_username}",
data=message_data,
follow_redirects=True,
)

# Assert that the response status code is 200 (OK)
assert response.status_code == 200
assert b"Message submitted successfully." in response.data

# Verify that the message is saved in the database
message = Message.query.filter_by(user_id=user.id).first()
assert message is not None

# Check if the message content includes the concatenated contact method
expected_content = f"Contact Method: {contact_method}\n\n{message_content}"
assert message.content == expected_content

# Navigate to the inbox to check if the message displays correctly
response = client.get(f"/inbox?username={user.primary_username}", follow_redirects=True)
assert response.status_code == 200
assert expected_content.encode() in response.data

0 comments on commit c57e77c

Please sign in to comment.