Compare commits

...

5 Commits

Author SHA1 Message Date
Teriuihi fedb80f3c2 Refactor form request handling and add rate limiting.
Consolidate email verification logic into FormRequestHandler to simplify code maintenance. Implement a new rate limiting feature to restrict form submissions based on IP and email address, improving server security and performance.
2024-08-10 01:52:34 +02:00
Teriuihi f0c84e809f Add rate limiting functionality
Introduces a new 'rate_limit' table to track request counts by IP and email. Adds `RateLimitQuery` class for querying and inserting rate limits, and `RateLimitEntryDTO` for passing rate limit data.
2024-08-10 00:50:53 +02:00
Teriuihi 4f3db5ae8b workspace.xml update 2024-08-10 00:36:00 +02:00
Teriuihi c9fc81cfca Add StaffAppFormData handling in FormQuery
Imported StaffAppFormData and updated deserialization logic to include it as a case in the FormQuery class. This allows the processing of staff application forms when parsing form data from JSON.
2024-08-10 00:35:42 +02:00
Teriuihi 5e564fe9a7 Remove restriction on dot character in Discord name validation
This change allows Discord names to include dots anywhere within the name, increasing flexibility for valid usernames. The previous regex pattern incorrectly restricted dots, affecting valid user entries.
2024-08-10 00:35:29 +02:00
15 changed files with 352 additions and 87 deletions

View File

@ -4,7 +4,9 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="ce59df2a-8d56-446a-867b-80e627daf479" name="Changes" comment="Refactor form submission to use dynamic Discord URLs and emails&#10;&#10;Updated form classes to return Optional URLs for Discord bot submissions. Refactored VerifyController to handle these Optionals and improved error handling when sending forms. Added receiver email method in form classes for more flexible form submissions.">
<list default="true" id="ce59df2a-8d56-446a-867b-80e627daf479" name="Changes" comment="Correct regex pattern for case-insensitive match&#10;&#10;Updated the regex pattern in `StaffAppFormData.java` to ensure that the &quot;yes&quot; or &quot;no&quot; answers are case-insensitive. This improves the form validation to accept &quot;Yes&quot;, &quot;YES&quot;, &quot;No&quot;, or &quot;NO&quot; without errors.">
<change afterPath="$PROJECT_DIR$/src/main/java/com/alttd/forms/mail/rate_limitter/RateLimitEntryDTO.java" afterDir="false" />
<change afterPath="$PROJECT_DIR$/src/main/java/com/alttd/forms/mail/rate_limitter/RateLimitQuery.java" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
@ -364,7 +366,9 @@
<workItem from="1722801254695" duration="6135000" />
<workItem from="1722879625782" duration="358000" />
<workItem from="1722973564053" duration="6695000" />
<workItem from="1723057624940" duration="36000" />
<workItem from="1723057624940" duration="2895000" />
<workItem from="1723138548381" duration="1949000" />
<workItem from="1723230192640" duration="55000" />
</task>
<task id="LOCAL-00001" summary="Initial commit for site for forms">
<option name="closed" value="true" />
@ -558,7 +562,23 @@
<option name="project" value="LOCAL" />
<updated>1722983786557</updated>
</task>
<option name="localTasksCounter" value="25" />
<task id="LOCAL-00025" summary="Refactor packages and add exception handler.&#10;&#10;Renamed various classes to follow the &quot;controlers&quot; package structure for better organization and consistency. Added `ControllerExceptionHandler` to manage validation exceptions globally and improve error logging.">
<option name="closed" value="true" />
<created>1723059243750</created>
<option name="number" value="00025" />
<option name="presentableId" value="LOCAL-00025" />
<option name="project" value="LOCAL" />
<updated>1723059243750</updated>
</task>
<task id="LOCAL-00026" summary="Correct regex pattern for case-insensitive match&#10;&#10;Updated the regex pattern in `StaffAppFormData.java` to ensure that the &quot;yes&quot; or &quot;no&quot; answers are case-insensitive. This improves the form validation to accept &quot;Yes&quot;, &quot;YES&quot;, &quot;No&quot;, or &quot;NO&quot; without errors.">
<option name="closed" value="true" />
<created>1723059265558</created>
<option name="number" value="00026" />
<option name="presentableId" value="LOCAL-00026" />
<option name="project" value="LOCAL" />
<updated>1723059265558</updated>
</task>
<option name="localTasksCounter" value="27" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -576,9 +596,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="Initial commit for site for forms" />
<MESSAGE value="Refactor code to use Form objects instead of JSON strings&#10;&#10;Several parts of the code have been altered to use Form objects instead of JSON strings. Changes include updating the FormQueryResult record type to hold an Optional&lt;Form&gt; instead of an Optional&lt;String&gt;, altering methods in the StoreFormQuery class to insert Form data into the database and replacing JSON handling methods in the FormQuery class with Form object oriented methods. A 'form_class' field has also been added to the 'form' table in the database to aid form identification and reconstruction from stored data." />
<MESSAGE value="Add TestForm class and set up related unit tests&#10;&#10;The changes add a new TestForm class to implement unit tests for form handling in the application. The included tests verify the storing, retrieving, and verification of forms from a database, as well as form object creation and confirmation of form data. This is a step towards improving the code's reliability and making it easier to catch potential bugs or issues." />
<MESSAGE value="Remove unnecessary Gson imports in ContactFormData&#10;&#10;The Gson and GsonBuilder imports in the ContactFormData class were unused and have been removed. This cleanup simplifies the code and reduces unnecessary dependencies.&#10;" />
<MESSAGE value="Mark fields as final in VerificationData and ContactFormData&#10;&#10;The 'code', 'eMail' fields in the VerificationData class and 'username', 'email', 'question' fields in the ContactFormData class are now marked as 'final'. At the same time, some unused imports from ContactController, ContactFormData, FormQuery, and VerifyController have been removed for code cleanliness." />
<MESSAGE value="Update Form HTML output and disable validation auto-configuration&#10;&#10;The form-to-HTML output process has been switched from a `&lt;div&gt;` approach to using a `StringBuilder` with a table structure in `ContactFormData.java`. Also, the spring validation auto-configuration has been disabled by adding `exclude = ValidationAutoConfiguration.class` in the `@SpringBootApplication` annotation of `Main.java`. Some changes in `.idea/workspace.xml` and `VerifyController.java` were made as well." />
@ -601,6 +618,9 @@
<MESSAGE value="Update Jackson config and refactor JSON handling&#10;&#10;Introduced Jackson dependencies to replace Gson for JSON processing. Updated application properties and controllers to handle Jackson-specific exceptions. Refactored form serialization to use Jackson's `ObjectMapper` for better date handling and consistency." />
<MESSAGE value="Add getDiscordBotUrl method to form classes&#10;&#10;Implemented getDiscordBotUrl in form classes for dynamic URL handling. Updated VerifyController to use this method for constructing Discord bot URIs. This enhances flexibility and maintainability in form submission handling." />
<MESSAGE value="Refactor form submission to use dynamic Discord URLs and emails&#10;&#10;Updated form classes to return Optional URLs for Discord bot submissions. Refactored VerifyController to handle these Optionals and improved error handling when sending forms. Added receiver email method in form classes for more flexible form submissions." />
<option name="LAST_COMMIT_MESSAGE" value="Refactor form submission to use dynamic Discord URLs and emails&#10;&#10;Updated form classes to return Optional URLs for Discord bot submissions. Refactored VerifyController to handle these Optionals and improved error handling when sending forms. Added receiver email method in form classes for more flexible form submissions." />
<MESSAGE value="Add validation exception handling to StaffAppController&#10;&#10;Introduced an `ExceptionHandler` for `MethodArgumentNotValidException` in `StaffAppController` to return detailed validation error messages. Updated the regex pattern in `StaffAppFormData` to be case-insensitive. This enhances error reporting and user input validation in the application." />
<MESSAGE value="Refactor packages and add exception handler.&#10;&#10;Renamed various classes to follow the &quot;controlers&quot; package structure for better organization and consistency. Added `ControllerExceptionHandler` to manage validation exceptions globally and improve error logging." />
<MESSAGE value="Correct regex pattern for case-insensitive match&#10;&#10;Updated the regex pattern in `StaffAppFormData.java` to ensure that the &quot;yes&quot; or &quot;no&quot; answers are case-insensitive. This improves the form validation to accept &quot;Yes&quot;, &quot;YES&quot;, &quot;No&quot;, or &quot;NO&quot; without errors." />
<option name="LAST_COMMIT_MESSAGE" value="Correct regex pattern for case-insensitive match&#10;&#10;Updated the regex pattern in `StaffAppFormData.java` to ensure that the &quot;yes&quot; or &quot;no&quot; answers are case-insensitive. This improves the form validation to accept &quot;Yes&quot;, &quot;YES&quot;, &quot;No&quot;, or &quot;NO&quot; without errors." />
</component>
</project>

View File

@ -1,6 +1,7 @@
package com.alttd.forms;
import com.alttd.forms.database.DatabaseConnection;
import com.alttd.forms.mail.rate_limitter.FormRateLimit;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration;
@ -16,6 +17,7 @@ public class Main {
path = args[0];
SpringApplication.run(Main.class, args);
DatabaseConnection.initialize(args[0]);
FormRateLimit.initialize(args[0]);
}
public static String getConfigPath() {

View File

@ -0,0 +1,41 @@
package com.alttd.forms.controlers;
import com.alttd.forms.form.Form;
import com.alttd.forms.form.StoreFormQuery;
import com.alttd.forms.mail.verification.VerificationResult;
import com.alttd.forms.mail.verification.Verify;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.concurrent.CompletableFuture;
public class FormRequestHandler {
private static final Logger logger = LoggerFactory.getLogger(FormRequestHandler.class);
public static CompletableFuture<ResponseEntity<String>> handleRequestWithVerifyMail(Form form, String ip) {
CompletableFuture<Integer> storeFormForVerificationCode = new StoreFormQuery().storeFormForVerificationCode(form.getSender(), form);
return storeFormForVerificationCode.thenCompose(code -> Verify.verifyEmail(ip, form.getSender(), code, form).thenApply(verificationResult -> {
if (verificationResult == VerificationResult.VERIFICATION_SENT) {
//TODO if this is ok tell the user they have x min to verify if they fail to do so they have to remake the form
logger.trace("Staff application form stored and requested verification from user");
return ResponseEntity.ok("User Data received and email verification sent.");
} else if (verificationResult == VerificationResult.RATE_LIMIT_EXCEEDED) {
logger.trace("User hit the rate limit email: {} ip: {}", form.getSender(), ip);
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("You have sent too many forms today, " +
"please wait up to an hour before trying again. " +
"You can resubmit your form by hitting submit again later. " +
"Continued use of the form will extent the time you have to wait.");
} else {
logger.trace("Failed to send verification email {}", verificationResult.name());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to send verification email. Reason: " + verificationResult.name());
}
})).exceptionally(throwable -> {
logger.error("Failed to store form", throwable);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to store your form");
});
}
}

View File

@ -1,12 +1,10 @@
package com.alttd.forms.controlers.apply;
import com.alttd.forms.form.StoreFormQuery;
import com.alttd.forms.mail.verification.VerificationResult;
import com.alttd.forms.mail.verification.Verify;
import com.alttd.forms.controlers.FormRequestHandler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ -19,24 +17,10 @@ public class StaffAppController {
private static final Logger logger = LoggerFactory.getLogger(StaffAppController.class);
@PostMapping("/staffApplication")
public CompletableFuture<ResponseEntity<String>> submitForm(@Valid @RequestBody StaffAppFormData formData) {
public CompletableFuture<ResponseEntity<String>> submitForm(@Valid @RequestBody StaffAppFormData formData, HttpServletRequest request) {
logger.debug("submitForm");
logger.trace(formData.toString());
CompletableFuture<Integer> storeFormForVerificationCode = new StoreFormQuery().storeFormForVerificationCode(formData.email, formData);
return storeFormForVerificationCode.thenCompose(code -> Verify.verifyEmail(formData.email, code).thenApply(verificationResult -> {
if (verificationResult == VerificationResult.VERIFICATION_SENT) {
//TODO if this is ok tell the user they have x min to verify if they fail to do so they have to remake the form
logger.trace("Staff application form stored and requested verification from user");
return ResponseEntity.ok("User Data received and email verification sent.");
} else {
logger.trace("Failed to send verification email");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to send verification email. Reason: " + verificationResult.name());
}
})).exceptionally(throwable -> {
logger.error("Failed to store form", throwable);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to store your form");
});
return FormRequestHandler.handleRequestWithVerifyMail(formData, request.getRemoteAddr());
}
}

View File

@ -40,7 +40,7 @@ public class StaffAppFormData extends Form {
@NotEmpty(message = "Discord name is required")
@Length(min = 2, max = 32, message = "Discord name should be between 2 and 32 characters")
@Pattern(regexp = "^(?!.*\\..)([a-z0-9._]{2,32})$", message = "Please enter a valid Discord name")
@Pattern(regexp = "^([a-z0-9._]{2,32})$", message = "Please enter a valid Discord name")
public final String discord;
@NotEmpty(message = "An answer is required")
@ -119,6 +119,11 @@ public class StaffAppFormData extends Form {
return "apply@alttd.com";
}
@Override
public String getSender() {
return email;
}
@Override
public String toHtml() {
String[] fields = {"Username", "Email", "Discord", "PC requirements", "Age", "Pronoun", "Join date", "Avg time", "Available days", "Available time", "Staff experience", "Plugin experience", "Why staff", "Expectations mod", "Other"};

View File

@ -1,11 +1,9 @@
package com.alttd.forms.controlers.contact;
import com.alttd.forms.form.StoreFormQuery;
import com.alttd.forms.mail.verification.VerificationResult;
import com.alttd.forms.mail.verification.Verify;
import com.alttd.forms.controlers.FormRequestHandler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ -18,25 +16,11 @@ public class ContactController {
private static final Logger logger = LoggerFactory.getLogger(ContactController.class);
@PostMapping("/submitContactForm")
public CompletableFuture<ResponseEntity<String>> submitForm(@Valid @RequestBody ContactFormData formData) {
public CompletableFuture<ResponseEntity<String>> submitForm(@Valid @RequestBody ContactFormData formData, HttpServletRequest request) {
logger.debug("submitForm");
logger.trace(formData.toString());
CompletableFuture<Integer> storeFormForVerificationCode = new StoreFormQuery().storeFormForVerificationCode(formData.email, formData);
return storeFormForVerificationCode.thenCompose(code -> Verify.verifyEmail(formData.email, code).thenApply(verificationResult -> {
if (verificationResult == VerificationResult.VERIFICATION_SENT) {
//TODO if this is ok tell the user they have x min to verify if they fail to do so they have to remake the form
logger.trace("Contact form stored and requested verification from user");
return ResponseEntity.ok("User Data received and email verification sent.");
} else {
logger.trace("Failed to send verification email");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to send verification email. Reason: " + verificationResult.name());
}
})).exceptionally(throwable -> {
logger.error("Failed to store form", throwable);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Failed to store your form");
});
return FormRequestHandler.handleRequestWithVerifyMail(formData, request.getRemoteAddr());
}
}

View File

@ -48,6 +48,11 @@ public class ContactFormData extends Form {
return "support@alttd.com";
}
@Override
public String getSender() {
return email;
}
@Override
public String toHtml() {
String[] fields = {"Username", "Email", "Question"};

View File

@ -12,8 +12,35 @@ public class Database {
public static void createTables() {
String[] createTables = {
"CREATE TABLE IF NOT EXISTS verify_form (e_mail VARCHAR(256), verification_code INT, formId INT, PRIMARY KEY(e_mail, verification_code))",
"CREATE TABLE IF NOT EXISTS form (formId INT AUTO_INCREMENT, creation_date BIGINT, form_json TEXT, form_class VARCHAR(64), PRIMARY KEY(formId))"
// language=SQL
"""
CREATE TABLE IF NOT EXISTS verify_form(
e_mail VARCHAR(256),
verification_code INT,
formId INT,
PRIMARY KEY(e_mail, verification_code)
)
""",
// language=SQL
"""
CREATE TABLE IF NOT EXISTS form(
formId INT AUTO_INCREMENT,
creation_date BIGINT,
form_json TEXT,
form_class VARCHAR(64),
PRIMARY KEY(formId)
)
""",
// language=SQL
"""
CREATE TABLE IF NOT EXISTS rate_limit(
id INT AUTO_INCREMENT,
time TIMESTAMP,
ip VARCHAR(45),
mail VARCHAR(256),
PRIMARY KEY(id)
)
"""
};
Connection connection = DatabaseConnection.getConnection();
for (String query : createTables) {

View File

@ -18,20 +18,25 @@ public abstract class Form {
public String toHtml(String[] fields, String[] values) {
StringBuilder htmlOutput = new StringBuilder();
//language=HTML
htmlOutput.append("<table style='border-collapse: collapse; width: 100%;'>");
for (int i = 0; i < fields.length; i++) {
htmlOutput.append("<tr style='border: 1px solid #ddd;'>");
htmlOutput.append("<td style='border: 1px solid #ddd; padding: 10px; font-weight: bold;'>");
htmlOutput.append(fields[i]);
htmlOutput.append("</td>");
htmlOutput.append("<td style='border: 1px solid #ddd; padding: 10px;'>");
htmlOutput.append(values[i]);
htmlOutput.append("</td>");
htmlOutput.append("</tr>");
htmlOutput.append(
String.format(
//language=HTML
"""
<tr style='border: 1px solid #ddd;'>
<td style='border: 1px solid #ddd; padding: 10px; font-weight: bold;'>
%s
</td>
<td style='border: 1px solid #ddd; padding: 10px;'>
%s
</td>
</tr>
""", fields[i], values[i]));
}
//language=HTML
htmlOutput.append("</table>");
return htmlOutput.toString();
}
@ -41,4 +46,6 @@ public abstract class Form {
public abstract Optional<String> getDiscordBotUrl();
public abstract String getReceiver();
public abstract String getSender();
}

View File

@ -0,0 +1,87 @@
package com.alttd.forms.mail.rate_limitter;
import com.alttd.forms.database.DatabaseConnection;
import com.alttd.forms.properties.PropertiesLoader;
import com.alttd.forms.properties.PropertiesWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.CompletableFuture;
public class FormRateLimit {
private static final Logger logger = LoggerFactory.getLogger(FormRateLimit.class);
private static FormRateLimit instance;
private Properties properties;
private FormRateLimit(String path) {
FormRateLimit.instance = this;
loadProperties(path);
}
public static FormRateLimit getInstance() {
return FormRateLimit.instance;
}
public static void initialize(String path) {
if (instance != null)
return;
FormRateLimit.instance = new FormRateLimit(path);
}
private void loadProperties(String path) {
String fileName = "form.properties";
Optional<Properties> optionalProperties = PropertiesLoader.loadProperties(path, fileName);
if (optionalProperties.isPresent()) {
properties = optionalProperties.get();
return;
}
properties = new Properties();
properties.setProperty("rate-limit-ip-count", "10");
properties.setProperty("rate-limit-mail-count", "10");
properties.setProperty("rate-limit-ip-minutes", "60");
properties.setProperty("rate-limit-mail-minutes", "60");
PropertiesWriter.writeProperties(properties, path, fileName);
}
public CompletableFuture<Optional<Boolean>> isRateLimited(String ip, String mail) {
return CompletableFuture.supplyAsync(
() -> {
RateLimitQuery rateLimitQuery = new RateLimitQuery(DatabaseConnection.getConnection());
try {
if (isIpRateLimited(rateLimitQuery, ip) || isMailRateLimited(rateLimitQuery, mail))
return Optional.of(true);
} catch (SQLException e) {
logger.error("Failed rate limit query for ip: {}, mail: {}", ip, mail, e);
return Optional.empty();
} catch (NumberFormatException e) {
logger.error("Failed loading numbers from properties", e);
return Optional.empty();
}
return Optional.of(false);
});
}
private boolean isIpRateLimited(RateLimitQuery rateLimitQuery, String ip) throws SQLException, NumberFormatException {
int minutes = Integer.parseInt(properties.getProperty("rate-limit-ip-minutes"));
Instant after = Instant.now().minus(Duration.ofMinutes(minutes));
int ipHits = rateLimitQuery.getIpHits(ip, after);
int ipRateLimit = Integer.parseInt(properties.getProperty("rate-limit-ip-count"));
return ipHits >= ipRateLimit;
}
private boolean isMailRateLimited(RateLimitQuery rateLimitQuery, String mail) throws SQLException, NumberFormatException {
int minutes = Integer.parseInt(properties.getProperty("rate-limit-mail-minutes"));
Instant after = Instant.now().minus(Duration.ofMinutes(minutes));
int ipHits = rateLimitQuery.getMailHits(mail, after);
int ipRateLimit = Integer.parseInt(properties.getProperty("rate-limit-mail-count"));
return ipHits >= ipRateLimit;
}
}

View File

@ -0,0 +1,6 @@
package com.alttd.forms.mail.rate_limitter;
import java.time.Instant;
public record RateLimitEntryDTO(Instant time, String ip, String mail) {
}

View File

@ -0,0 +1,64 @@
package com.alttd.forms.mail.rate_limitter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.*;
import java.time.Instant;
public class RateLimitQuery {
private static final Logger logger = LoggerFactory.getLogger(RateLimitQuery.class);
private final Connection connection;
protected RateLimitQuery(Connection connection) {
this.connection = connection;
}
protected int getIpHits(String ip, Instant after) throws SQLException {
String sql = "SELECT COUNT(*) AS hits FROM rate_limit WHERE ip = ? AND time > ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, ip);
stmt.setTimestamp(2, Timestamp.from(after));
ResultSet resultSet = stmt.executeQuery();
if (!resultSet.next()) {
return 0;
}
return resultSet.getInt("hits");
} catch (SQLException e) {
logger.error("Failed get ip hits query for ip: {}", ip, e);
throw e;
}
}
protected int getMailHits(String mail, Instant after) throws SQLException {
String sql = "SELECT COUNT(*) AS hits FROM rate_limit WHERE mail = ? AND time > ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, mail);
stmt.setTimestamp(2, Timestamp.from(after));
ResultSet resultSet = stmt.executeQuery();
if (!resultSet.next()) {
return 0;
}
return resultSet.getInt("hits");
} catch (SQLException e) {
logger.error("Failed get mail hits query for ip: {}", mail, e);
throw e;
}
}
protected boolean insertRateLimitEntry(RateLimitEntryDTO entry) throws SQLException {
String sql = "INSERT INTO rate_limit (time, ip, mail) VALUES (?, ?, ?)";
try {
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setTimestamp(1, Timestamp.from(entry.time()));
stmt.setString(2, entry.ip());
stmt.setString(3, entry.mail());
return stmt.executeUpdate() > 0;
} catch (SQLException e) {
logger.error("Failed to store rate limit for ip: {}, mail: {}, time: {}", entry.ip(), entry.mail(), entry.time(), e);
throw e;
}
}
}

View File

@ -1,6 +1,6 @@
package com.alttd.forms.mail.verification;
public enum VerificationResult {
NO_MAIL_ACCOUNT, FAILED_TO_SEND, VERIFICATION_SENT,
NO_MAIL_ACCOUNT, FAILED_TO_SEND, VERIFICATION_SENT, RATE_LIMIT_EXCEEDED, FAILED_TO_RETRIEVE_RATE_LIMIT_DATA;
}

View File

@ -1,6 +1,8 @@
package com.alttd.forms.mail.verification;
import com.alttd.forms.form.Form;
import com.alttd.forms.mail.MailSettings;
import com.alttd.forms.mail.rate_limitter.FormRateLimit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -15,7 +17,7 @@ public class Verify {
private static final Logger logger = LoggerFactory.getLogger(Verify.class);
public static CompletableFuture<VerificationResult> verifyEmail(String address, int code) {
public static CompletableFuture<VerificationResult> verifyEmail(String ip, String address, int code, Form form) {
logger.debug("verifyEmail");
Properties mailProperties = MailSettings.getMailProperties();
logger.debug("mailProperties: {}", mailProperties);
@ -30,35 +32,62 @@ public class Verify {
//TODO rate limit sending mail from IP and to specific e-mail addresses (max 1 per minute and max 10 per day)
//TODO include a link to all emails that people can click to block us from sending mail to them so no one can use us to spam ppl
return CompletableFuture.supplyAsync(() -> sendMail(ip, address, code, session, passwordAuthentication, form));
}
private static VerificationResult sendMail(String ip, String address, int code, Session session, PasswordAuthentication passwordAuthentication, Form form) {
Optional<Boolean> isRateLimited = FormRateLimit.getInstance().isRateLimited(ip, address).join();
if (isRateLimited.isEmpty()) {
return VerificationResult.FAILED_TO_RETRIEVE_RATE_LIMIT_DATA;
} else if (isRateLimited.get()) {
return VerificationResult.RATE_LIMIT_EXCEEDED;
}
Message message;
try {
logger.trace("Creating mail");
Message message = new MimeMessage(session);
message.setFrom(new InternetAddress(passwordAuthentication.getUserName()));
logger.trace("Set from to {}", passwordAuthentication.getUserName());
message.setRecipients(
Message.RecipientType.TO,
InternetAddress.parse(address)
);
logger.trace("Set recipients");
message.setSubject("Altitude Email Verification");
message.setText("Please verify your email by entering the following code on the page you made the form in\n" + code); //TODO pretty html stuff
logger.trace("Set code: {}", code);
//TODO include the form they filled in (also in pretty html stuff)
return CompletableFuture.supplyAsync(() -> {
try {
logger.trace("Sending mail");
Transport.send(message);
logger.trace("Sending mail succeeded");
return VerificationResult.VERIFICATION_SENT;
} catch (MessagingException e) {
logger.error("Failed to send mail", e);
return VerificationResult.FAILED_TO_SEND;
}
});
message = getMail(address, code, session, passwordAuthentication, form);
} catch (MessagingException e) {
logger.error("Failed to create MimeMessage", e);
return CompletableFuture.completedFuture(VerificationResult.FAILED_TO_SEND);
return VerificationResult.FAILED_TO_SEND;
}
try {
logger.trace("Sending mail");
Transport.send(message);
logger.trace("Sending mail succeeded");
return VerificationResult.VERIFICATION_SENT;
} catch (MessagingException e) {
logger.error("Failed to send mail", e);
return VerificationResult.FAILED_TO_SEND;
}
}
private static Message getMail(String address, int code, Session session, PasswordAuthentication passwordAuthentication, Form form) throws MessagingException {
logger.trace("Creating mail");
Message message = new MimeMessage(session);
message.setFrom(new InternetAddress(passwordAuthentication.getUserName()));
logger.trace("Set from to {}", passwordAuthentication.getUserName());
message.setRecipients(
Message.RecipientType.TO,
InternetAddress.parse(address)
);
logger.trace("Set recipients");
message.setSubject("Altitude Email Verification");
String body = String.format(
//language=HTML
"""
<title>
Email Verification
</title>
<p>Please verify your email by entering the following code on the page you made the form in:</p>
<p>%s</p>
<p></p>
<p>This is the form you're submitting:</p>
""", code);
body += form.toHtml();
message.setContent(body, "text/html");
logger.trace("Set code: {}", code);
return message;
}
}

View File

@ -1,5 +1,6 @@
package com.alttd.forms.verify_mail;
import com.alttd.forms.controlers.apply.StaffAppFormData;
import com.alttd.forms.controlers.contact.ContactFormData;
import com.alttd.forms.database.DatabaseConnection;
import com.alttd.forms.form.Form;
@ -72,6 +73,9 @@ public class FormQuery {
case "ContactFormData" -> {
return objectMapper.readValue(json, ContactFormData.class);
}
case "StaffAppFormData" -> {
return objectMapper.readValue(json, StaffAppFormData.class);
}
default -> throw new IllegalArgumentException("Invalid form class name: " + className);
}
}