diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 972a7e3..9b1f12d 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { implementation(project(":open_api")) implementation(project(":database")) implementation(project(":frontend")) + implementation(project(":discord")) annotationProcessor("org.projectlombok:lombok") implementation("com.mysql:mysql-connector-j:8.0.32") implementation("org.mybatis:mybatis:3.5.13") diff --git a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java index 6c63203..f3d2423 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java +++ b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java @@ -11,6 +11,7 @@ import com.nimbusds.jose.proc.SecurityContext; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -39,15 +40,17 @@ public class SecurityConfig { return http .authorizeHttpRequests( auth -> auth - .requestMatchers("/api/form/**").hasAuthority(PermissionClaimDto.USER.getValue()) - .requestMatchers("/api/login/userLogin").hasAuthority(PermissionClaimDto.USER.getValue()) + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/api/form/**").authenticated() + .requestMatchers("/api/login/getUsername").authenticated() + .requestMatchers("/api/mail/**").authenticated() .requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) + .requestMatchers("/api/login/userLogin/**").permitAll() .anyRequest().permitAll() ) .csrf(AbstractHttpConfigurer::disable) - .anonymous(AbstractHttpConfigurer::disable) .oauth2ResourceServer( oauth2 -> oauth2 .jwt(Customizer.withDefaults()) diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java index b6e9e46..a6a3e1f 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java @@ -7,6 +7,8 @@ import com.alttd.altitudeweb.database.litebans.HistoryType; import com.alttd.altitudeweb.database.litebans.IdHistoryMapper; import com.alttd.altitudeweb.database.web_db.forms.Appeal; import com.alttd.altitudeweb.database.web_db.forms.AppealMapper; +import com.alttd.altitudeweb.database.web_db.mail.EmailVerification; +import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper; import com.alttd.altitudeweb.mappers.AppealDataMapper; import com.alttd.altitudeweb.model.AppealResponseDto; import com.alttd.altitudeweb.model.DiscordAppealDto; @@ -22,6 +24,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -64,14 +67,39 @@ public class AppealController implements AppealsApi { throw new ResponseStatusException(HttpStatusCode.valueOf(404), "History not found"); } - appealMail.sendAppealNotification(appeal, history); + CompletableFuture> emailVerificationCompletableFuture = new CompletableFuture<>(); + Connection.getConnection(Databases.DEFAULT) + .runQuery(sqlSession -> { + log.debug("Retrieving mail by uuid and address"); - AppealResponseDto appealResponseDto = new AppealResponseDto( - appeal.id().toString(), - "Your appeal has been submitted. You will be notified when it has been reviewed.", - false); + EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class) + .findByUserAndEmail(appeal.uuid(), appeal.email()); + emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail)); + }); + Optional optionalEmailVerification = emailVerificationCompletableFuture.join(); - return ResponseEntity.ok().body(appealResponseDto); + if (optionalEmailVerification.isEmpty()) { + return ResponseEntity.ok().body(new AppealResponseDto( + appeal.id().toString(), + "Your appeal has been saved and a verification mail has been send, please verify your email " + + "address by clicking the link in your email. Once it is verified we will review your appeal.", + false)); + } + EmailVerification emailVerification = optionalEmailVerification.get(); + if (!emailVerification.verified()) { + return ResponseEntity.ok().body(new AppealResponseDto( + appeal.id().toString(), + "Your appeal has been saved and a verification mail has been resend, please verify your email " + + "address by clicking the link in your email. Once it is verified we will review your appeal.", + false + )); + } else { + AppealResponseDto appealResponseDto = new AppealResponseDto( + appeal.id().toString(), + "Your appeal has been submitted. You will be notified when it has been reviewed.", + true); + return ResponseEntity.ok().body(appealResponseDto); + } } @Override diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/MailController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/MailController.java new file mode 100644 index 0000000..847c050 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/MailController.java @@ -0,0 +1,95 @@ +package com.alttd.altitudeweb.controllers.forms; + +import com.alttd.altitudeweb.api.MailApi; +import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid; +import com.alttd.altitudeweb.database.web_db.mail.EmailVerification; +import com.alttd.altitudeweb.model.MailResponseDto; +import com.alttd.altitudeweb.model.SubmitEmailDto; +import com.alttd.altitudeweb.model.VerifyCodeDto; +import com.alttd.altitudeweb.model.EmailEntryDto; +import com.alttd.altitudeweb.services.limits.RateLimit; +import com.alttd.altitudeweb.services.mail.MailVerificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RateLimit(limit = 60, timeValue = 1, timeUnit = TimeUnit.HOURS) +public class MailController implements MailApi { + + private final MailVerificationService mailVerificationService; + + @Override + @RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailSubmit") + public ResponseEntity submitEmailForVerification(SubmitEmailDto submitEmailDto) { + UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); + EmailVerification saved = mailVerificationService.submitEmail(uuid, submitEmailDto.getEmail()); + MailResponseDto response = new MailResponseDto() + .email(saved.email()) + .message("Verification email sent") + .verified(false); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Override + @RateLimit(limit = 20, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailVerify") + public ResponseEntity verifyEmailCode(VerifyCodeDto verifyCodeDto) { + UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); + Optional optionalEmailVerification = mailVerificationService.verifyCode(uuid, verifyCodeDto.getCode()); + if (optionalEmailVerification.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid verification code"); + } + EmailVerification emailVerification = optionalEmailVerification.get(); + MailResponseDto response = new MailResponseDto() + .email(emailVerification.email()) + .message("Email verified successfully") + .verified(true); + return ResponseEntity.ok(response); + } + + @Override + @RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailResend") + public ResponseEntity resendVerificationEmail(SubmitEmailDto submitEmailDto) { + UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); + EmailVerification updated = mailVerificationService.resend(uuid, submitEmailDto.getEmail()); + if (updated == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user"); + } + MailResponseDto response = new MailResponseDto() + .email(updated.email()) + .message("Verification email resent") + .verified(false); + return ResponseEntity.ok(response); + } + + @Override + @RateLimit(limit = 10, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "mailDelete") + public ResponseEntity deleteEmail(SubmitEmailDto submitEmailDto) { + UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); + boolean deleted = mailVerificationService.delete(uuid, submitEmailDto.getEmail()); + if (!deleted) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found for user"); + } + return ResponseEntity.noContent().build(); + } + + @Override + public ResponseEntity> getUserEmails() { + UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid(); + List emails = mailVerificationService.listAll(uuid); + List result = emails.stream() + .map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified())) + .toList(); + return ResponseEntity.ok(result); + } +} diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java index 464b705..fc1998e 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/login/LoginController.java @@ -134,20 +134,32 @@ public class LoginController implements LoginApi { return username.join(); } + @Value("${UNSECURED:#{false}}") + private boolean unsecured; + @RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login") @Override public ResponseEntity login(String code) { + if (unsecured) { + log.warn("Unsecured login is enabled, skipping login validation!"); + } else { + log.info("Received login request with code {}", code); + } if (code == null) { log.warn("Received null login code"); return ResponseEntity.badRequest().build(); } CacheEntry cacheEntry = cache.get(code); - if (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now())) { + if (!unsecured && (cacheEntry == null || cacheEntry.expiry().isBefore(Instant.now()))) { log.warn("Received invalid login code {}", code); return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } + if (unsecured && cacheEntry == null) { + cacheEntry = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1))); + } + String token = generateToken(cacheEntry.uuid); log.debug("Generated token for user {} with token {}", cacheEntry.uuid, token); diff --git a/backend/src/main/java/com/alttd/altitudeweb/mappers/AppealDataMapper.java b/backend/src/main/java/com/alttd/altitudeweb/mappers/AppealDataMapper.java index ba0d640..67a69ae 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/mappers/AppealDataMapper.java +++ b/backend/src/main/java/com/alttd/altitudeweb/mappers/AppealDataMapper.java @@ -4,6 +4,7 @@ import com.alttd.altitudeweb.database.web_db.forms.Appeal; import com.alttd.altitudeweb.model.MinecraftAppealDto; import org.springframework.stereotype.Service; +import java.time.Instant; import java.util.UUID; @Service @@ -21,9 +22,11 @@ public class AppealDataMapper { return new Appeal( UUID.randomUUID(), minecraftAppealDto.getUuid(), + minecraftAppealDto.getPunishmentType().toString(), + minecraftAppealDto.getPunishmentId(), minecraftAppealDto.getUsername(), minecraftAppealDto.getAppeal(), - null, + Instant.now(), null, minecraftAppealDto.getEmail(), null diff --git a/backend/src/main/java/com/alttd/altitudeweb/services/mail/MailVerificationService.java b/backend/src/main/java/com/alttd/altitudeweb/services/mail/MailVerificationService.java new file mode 100644 index 0000000..ed4b527 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/services/mail/MailVerificationService.java @@ -0,0 +1,140 @@ +package com.alttd.altitudeweb.services.mail; + +import com.alttd.altitudeweb.database.Databases; +import com.alttd.altitudeweb.database.web_db.mail.EmailVerification; +import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper; +import com.alttd.altitudeweb.setup.Connection; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MailVerificationService { + + private final JavaMailSender mailSender; + + @Value("${spring.mail.username}") + private String fromEmail; + + public java.util.List listAll(UUID userUuid) { + java.util.concurrent.CompletableFuture> future = new java.util.concurrent.CompletableFuture<>(); + Connection.getConnection(Databases.DEFAULT) + .runQuery(sql -> { + EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class); + future.complete(mapper.findAllByUser(userUuid)); + }); + return future.join(); + } + + public EmailVerification submitEmail(UUID userUuid, String email) { + String code = generateCode(); + Instant now = Instant.now(); + + CompletableFuture future = new CompletableFuture<>(); + Connection.getConnection(Databases.DEFAULT) + .runQuery(sql -> { + EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class); + EmailVerification existing = mapper.findByUserAndEmail(userUuid, email); + EmailVerification toPersist; + if (existing == null) { + toPersist = new EmailVerification(UUID.randomUUID(), userUuid, email, code, false, now, null, now); + mapper.insert(toPersist); + } else { + mapper.updateCodeAndLastSent(existing.id(), code, now); + toPersist = new EmailVerification(existing.id(), userUuid, email, code, false, existing.createdAt(), null, now); + } + future.complete(toPersist); + }); + EmailVerification saved = future.join(); + sendVerificationEmail(saved); + return saved; + } + + public Optional verifyCode(UUID userUuid, String code) { + CompletableFuture> future = new CompletableFuture<>(); + Connection.getConnection(Databases.DEFAULT) + .runQuery(sql -> { + EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class); + EmailVerification found = mapper.findByUserAndCode(userUuid, code); + if (found == null) { + future.complete(Optional.empty()); + return; + } + mapper.markVerified(found.id(), Instant.now()); + future.complete(Optional.of(found)); + }); + return future.join(); + } + + public EmailVerification resend(UUID userUuid, String email) { + String code = generateCode(); + Instant now = Instant.now(); + CompletableFuture future = new CompletableFuture<>(); + Connection.getConnection(Databases.DEFAULT) + .runQuery(sql -> { + EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class); + EmailVerification existing = mapper.findByUserAndEmail(userUuid, email); + if (existing != null) { + mapper.updateCodeAndLastSent(existing.id(), code, now); + future.complete(new EmailVerification(existing.id(), userUuid, email, code, false, existing.createdAt(), null, now)); + } else { + future.complete(null); + } + }); + EmailVerification updated = future.join(); + if (updated != null) { + sendVerificationEmail(updated); + } + return updated; + } + + public boolean delete(UUID userUuid, String email) { + CompletableFuture future = new CompletableFuture<>(); + Connection.getConnection(Databases.DEFAULT) + .runQuery(sql -> { + EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class); + EmailVerification existing = mapper.findByUserAndEmail(userUuid, email); + if (existing != null) { + mapper.deleteByUserAndEmail(userUuid, email); + future.complete(true); + } else { + future.complete(false); + } + }); + return future.join(); + } + + private void sendVerificationEmail(EmailVerification emailVerification) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true); + helper.setFrom(fromEmail); + helper.setTo(emailVerification.email()); + helper.setSubject("Your verification code"); + helper.setText("Your verification code is: " + emailVerification.verificationCode(), false); + mailSender.send(message); + } catch (MessagingException e) { + log.error("Failed to send verification email to {}", emailVerification.email(), e); + } + } + + private String generateCode() { + // 6-digit numeric code + Random random = new Random(); + int num = 100000 + random.nextInt(899999); + return String.valueOf(num); + } +} diff --git a/backend/src/main/resources/application-test.properties b/backend/src/main/resources/application-test.properties index 33ffe39..be52ca8 100644 --- a/backend/src/main/resources/application-test.properties +++ b/backend/src/main/resources/application-test.properties @@ -1,9 +1,4 @@ -spring.application.name=AltitudeWeb -database.name=${DB_NAME:web_db} -database.port=${DB_PORT:3306} -database.host=${DB_HOST:localhost} -database.user=${DB_USER:root} -database.password=${DB_PASSWORD:root} -cors.allowed-origins=${CORS:http://localhost:4200} -my-server.address=${SERVER_ADDRESS:http://localhost} +cors.allowed-origins=${CORS:http://localhost:4200,http://localhost:8080} +my-server.address=${SERVER_ADDRESS:http://localhost:8080} logging.level.com.alttd.altitudeweb=DEBUG +logging.level.org.springframework.security=DEBUG diff --git a/database/src/main/java/com/alttd/altitudeweb/database/Databases.java b/database/src/main/java/com/alttd/altitudeweb/database/Databases.java index bf76977..fa21d91 100644 --- a/database/src/main/java/com/alttd/altitudeweb/database/Databases.java +++ b/database/src/main/java/com/alttd/altitudeweb/database/Databases.java @@ -6,7 +6,8 @@ import lombok.Getter; public enum Databases { DEFAULT("web_db"), LUCK_PERMS("luckperms"), - LITE_BANS("litebans"); + LITE_BANS("litebans"), + DISCORD("discordLink"); private final String internalName; diff --git a/database/src/main/java/com/alttd/altitudeweb/database/discord/OutputChannel.java b/database/src/main/java/com/alttd/altitudeweb/database/discord/OutputChannel.java new file mode 100644 index 0000000..a0e6231 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/discord/OutputChannel.java @@ -0,0 +1,4 @@ +package com.alttd.altitudeweb.database.discord; + +public record OutputChannel(long guild, String outputType, long channel, String channelType) { +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/discord/OutputChannelMapper.java b/database/src/main/java/com/alttd/altitudeweb/database/discord/OutputChannelMapper.java new file mode 100644 index 0000000..b332021 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/discord/OutputChannelMapper.java @@ -0,0 +1,15 @@ +package com.alttd.altitudeweb.database.discord; + +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +public interface OutputChannelMapper { + @Select(""" + SELECT guild, output_type, channel, channel_type + FROM output_channels + WHERE output_type = #{outputType} + """) + List getChannelsWithOutputType(@Param("outputType") String outputType); +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java index acb3859..1108b9c 100644 --- a/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/PrivilegedUser.java @@ -11,7 +11,7 @@ import java.util.UUID; @NoArgsConstructor @AllArgsConstructor public class PrivilegedUser { - private int id; + private Integer id; private UUID uuid; private List permissions; } diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/mail/EmailVerification.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/mail/EmailVerification.java new file mode 100644 index 0000000..96c2e48 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/mail/EmailVerification.java @@ -0,0 +1,16 @@ +package com.alttd.altitudeweb.database.web_db.mail; + +import java.time.Instant; +import java.util.UUID; + +public record EmailVerification( + UUID id, + UUID userUuid, + String email, + String verificationCode, + boolean verified, + Instant createdAt, + Instant verifiedAt, + Instant lastSentAt +) { +} diff --git a/database/src/main/java/com/alttd/altitudeweb/database/web_db/mail/EmailVerificationMapper.java b/database/src/main/java/com/alttd/altitudeweb/database/web_db/mail/EmailVerificationMapper.java new file mode 100644 index 0000000..b80c2a9 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/web_db/mail/EmailVerificationMapper.java @@ -0,0 +1,58 @@ +package com.alttd.altitudeweb.database.web_db.mail; + +import org.apache.ibatis.annotations.*; + +import java.time.Instant; +import java.util.UUID; + +public interface EmailVerificationMapper { + + @Insert(""" + INSERT INTO user_emails (id, user_uuid, email, verification_code, verified, created_at, last_sent_at) + VALUES (#{id}, #{userUuid}, #{email}, #{verificationCode}, #{verified}, #{createdAt}, #{lastSentAt}) + """) + void insert(EmailVerification emailVerification); + + @Select(""" + SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified, + created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt + FROM user_emails + WHERE user_uuid = #{userUuid} AND email = #{email} + """) + EmailVerification findByUserAndEmail(@Param("userUuid") UUID userUuid, @Param("email") String email); + + @Select(""" + SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified, + created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt + FROM user_emails + WHERE user_uuid = #{userUuid} AND verification_code = #{code} + ORDER BY created_at DESC LIMIT 1 + """) + EmailVerification findByUserAndCode(@Param("userUuid") UUID userUuid, @Param("code") String code); + + @Select(""" + SELECT id, user_uuid AS userUuid, email, verification_code AS verificationCode, verified, + created_at AS createdAt, verified_at AS verifiedAt, last_sent_at AS lastSentAt + FROM user_emails + WHERE user_uuid = #{userUuid} + ORDER BY created_at ASC + """) + java.util.List findAllByUser(@Param("userUuid") UUID userUuid); + + @Update(""" + UPDATE user_emails SET verified = 1, verified_at = #{verifiedAt} + WHERE id = #{id} + """) + void markVerified(@Param("id") UUID id, @Param("verifiedAt") Instant verifiedAt); + + @Update(""" + UPDATE user_emails SET verification_code = #{code}, last_sent_at = #{lastSentAt}, verified = 0, verified_at = NULL + WHERE id = #{id} + """) + void updateCodeAndLastSent(@Param("id") UUID id, @Param("code") String code, @Param("lastSentAt") Instant lastSentAt); + + @Delete(""" + DELETE FROM user_emails WHERE user_uuid = #{userUuid} AND email = #{email} + """) + void deleteByUserAndEmail(@Param("userUuid") UUID userUuid, @Param("email") String email); +} diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java b/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java index 3d1b14d..3eb1f0d 100644 --- a/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java +++ b/database/src/main/java/com/alttd/altitudeweb/setup/Connection.java @@ -35,6 +35,7 @@ public class Connection { InitializeWebDb.init(); InitializeLiteBans.init(); InitializeLuckPerms.init(); + InitializeDiscord.init(); } @FunctionalInterface diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeDiscord.java b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeDiscord.java new file mode 100644 index 0000000..630e57d --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeDiscord.java @@ -0,0 +1,19 @@ +package com.alttd.altitudeweb.setup; + +import com.alttd.altitudeweb.database.Databases; +import com.alttd.altitudeweb.database.discord.OutputChannelMapper; +import com.alttd.altitudeweb.database.luckperms.TeamMemberMapper; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class InitializeDiscord { + + protected static void init() { + log.info("Initializing Discord"); + Connection.getConnection(Databases.DISCORD, (configuration) -> { + configuration.addMapper(OutputChannelMapper.class); + }).join(); + log.debug("Initialized Discord"); + } + +} diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java index b89c27d..bb3fe1c 100644 --- a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java +++ b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeWebDb.java @@ -22,6 +22,7 @@ public class InitializeWebDb { configuration.addMapper(KeyPairMapper.class); configuration.addMapper(PrivilegedUserMapper.class); configuration.addMapper(AppealMapper.class); + configuration.addMapper(com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper.class); }).join() .runQuery(sqlSession -> { createSettingsTable(sqlSession); @@ -29,6 +30,7 @@ public class InitializeWebDb { createPrivilegedUsersTable(sqlSession); createPrivilegesTable(sqlSession); createAppealTable(sqlSession); + createUserEmailsTable(sqlSession); }); log.debug("Initialized WebDb"); } @@ -102,6 +104,27 @@ public class InitializeWebDb { } } + private static void createUserEmailsTable(@NotNull SqlSession sqlSession) { + String query = """ + CREATE TABLE IF NOT EXISTS user_emails ( + id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY, + user_uuid UUID NOT NULL, + email VARCHAR(255) NOT NULL, + verification_code VARCHAR(16) NOT NULL, + verified BOOLEAN NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + verified_at TIMESTAMP NULL, + last_sent_at TIMESTAMP NULL, + FOREIGN KEY (user_uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE + ); + """; + try (Statement statement = sqlSession.getConnection().createStatement()) { + statement.execute(query); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + private static void createAppealTable(@NotNull SqlSession sqlSession) { String query = """ CREATE TABLE IF NOT EXISTS appeals ( diff --git a/frontend/src/app/pages/forms/appeal/appeal.component.ts b/frontend/src/app/pages/forms/appeal/appeal.component.ts index 489edfb..c30aeb6 100644 --- a/frontend/src/app/pages/forms/appeal/appeal.component.ts +++ b/frontend/src/app/pages/forms/appeal/appeal.component.ts @@ -1,6 +1,16 @@ -import {AfterViewInit, Component, ElementRef, OnInit, Renderer2, signal} from '@angular/core'; +import { + AfterViewInit, + Component, + computed, + ElementRef, + inject, + OnDestroy, + OnInit, + Renderer2, + signal +} from '@angular/core'; import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms'; -import {AppealsService, HistoryService, MinecraftAppeal, PunishmentHistory} from '@api'; +import {AppealsService, EmailEntry, HistoryService, MailService, MinecraftAppeal, PunishmentHistory} from '@api'; import {HeaderComponent} from '@header/header.component'; import {NgOptimizedImage} from '@angular/common'; import {MatButtonModule} from '@angular/material/button'; @@ -28,19 +38,23 @@ import {HistoryFormatService} from '@pages/reference/bans/history-format.service templateUrl: './appeal.component.html', styleUrl: './appeal.component.scss' }) -export class AppealComponent implements OnInit, AfterViewInit { +export class AppealComponent implements OnInit, OnDestroy, AfterViewInit { - public form: FormGroup; + private mailService = inject(MailService); + private historyFormatService = inject(HistoryFormatService); + private appealsService = inject(AppealsService); + private historyService = inject(HistoryService); + public authService = inject(AuthService); private resizeObserver: ResizeObserver | null = null; private boundHandleResize: any; + + protected form: FormGroup; protected history = signal(null); protected selectedPunishment = signal(null); + private emails = signal([]); + protected verifiedEmails = computed(() => this.emails().filter(email => email.verified)); constructor( - private historyFormatService: HistoryFormatService, - private appealApi: AppealsService, - private historyApi: HistoryService, - protected authService: AuthService, private elementRef: ElementRef, private renderer: Renderer2 ) { @@ -48,6 +62,9 @@ export class AppealComponent implements OnInit, AfterViewInit { email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}), appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]}) }); + this.mailService.getUserEmails().subscribe(emails => { + this.emails.set(emails); + }) } ngOnInit() { @@ -55,7 +72,7 @@ export class AppealComponent implements OnInit, AfterViewInit { if (uuid === null) { throw new Error('JWT subject is null, are you logged in?'); } - this.historyApi.getAllHistoryForUUID(uuid).subscribe(history => { + this.historyService.getAllHistoryForUUID(uuid).subscribe(history => { this.history.set(history.filter(item => this.historyFormatService.isActive(item))); }) } @@ -149,7 +166,7 @@ export class AppealComponent implements OnInit, AfterViewInit { username: this.authService.username()!, uuid: uuid } - this.appealApi.submitMinecraftAppeal(appeal).subscribe() + this.appealsService.submitMinecraftAppeal(appeal).subscribe() } public currentPageIndex: number = 0; diff --git a/frontend/src/app/pages/forms/sent/sent.component.html b/frontend/src/app/pages/forms/sent/sent.component.html new file mode 100644 index 0000000..ad46281 --- /dev/null +++ b/frontend/src/app/pages/forms/sent/sent.component.html @@ -0,0 +1,48 @@ +

Email Verification

+
+

Please enter the 6-character verification code sent to: {{ email }}

+ +
+ + Verification Code + + @if (form.controls.code.invalid && form.controls.code.touched) { + + @if (form.controls.code.errors?.['required']) { + Verification code is required + } @else if (form.controls.code.errors?.['minlength'] || form.controls.code.errors?.['maxlength']) { + Code must be exactly 6 characters + } @else { + Please enter the 6-character code we sent you. + } + + } + +
+ + @if (mailVerified()) { +

Email verified successfully!

+ } +
+ +
+ + + + + +
diff --git a/frontend/src/app/pages/forms/sent/sent.component.scss b/frontend/src/app/pages/forms/sent/sent.component.scss new file mode 100644 index 0000000..1d85519 --- /dev/null +++ b/frontend/src/app/pages/forms/sent/sent.component.scss @@ -0,0 +1,23 @@ +:host { + display: block; +} + +.mat-dialog-content { + min-height: 120px; + max-width: 400px; +} + +.success-message { + color: #4caf50; + font-weight: 500; + margin-top: 16px; +} + +mat-form-field { + width: 100%; + margin-bottom: 16px; +} + +button { + margin-left: 8px; +} diff --git a/frontend/src/app/pages/forms/sent/sent.component.ts b/frontend/src/app/pages/forms/sent/sent.component.ts new file mode 100644 index 0000000..b120855 --- /dev/null +++ b/frontend/src/app/pages/forms/sent/sent.component.ts @@ -0,0 +1,149 @@ +import {Component, inject, Input, input, signal} from '@angular/core'; +import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms'; +import {MatInput, MatLabel} from '@angular/material/input'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MailService, SubmitEmail, VerifyCode} from '@api'; +import {AuthService} from '@services/auth.service'; +import {MatButtonModule} from '@angular/material/button'; +import {MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle} from '@angular/material/dialog'; +import {interval, Subscription} from 'rxjs'; + +@Component({ + selector: 'app-sent', + imports: [ + FormsModule, + MatFormFieldModule, + MatInput, + MatLabel, + ReactiveFormsModule, + MatButtonModule, + MatDialogTitle, + MatDialogContent, + MatDialogActions + ], + templateUrl: './sent.component.html', + styleUrl: './sent.component.scss' +}) +export class SentComponent { + protected form: FormGroup; + + protected readonly completionMessage = input("Thank you for completing your form!"); + protected readonly verifyMail = input(null); + protected mailVerified = signal(false); + + // For resend cooldown + protected resendCooldown = signal(false); + protected cooldownSeconds = signal(60); + private cooldownSubscription: Subscription | null = null; + + private mailService = inject(MailService); + private authService = inject(AuthService); + + constructor( + public dialogRef: MatDialogRef, + @Input() public email: string + ) { + this.form = new FormGroup({ + code: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.minLength(6), Validators.maxLength(6)] + }) + }); + } + + public onSubmit() { + if (this.form === undefined) { + console.error('Form is undefined'); + return; + } + if (this.form.valid) { + this.sendForm(); + } else { + Object.keys(this.form.controls).forEach(field => { + const control = this.form!.get(field); + control?.markAsTouched({onlySelf: true}); + }); + } + } + + public onCancel() { + this.dialogRef.close(false); + } + + public onResend() { + if (this.resendCooldown()) { + return; + } + + const submitEmail: SubmitEmail = { + email: this.email + }; + + this.mailService.resendVerificationEmail(submitEmail).subscribe({ + next: (response) => { + // Start cooldown timer + this.startResendCooldown(); + }, + error: (error) => { + console.error('Error resending verification email', error); + } + }); + } + + private startResendCooldown() { + this.resendCooldown.set(true); + this.cooldownSeconds.set(60); + + if (this.cooldownSubscription) { + this.cooldownSubscription.unsubscribe(); + } + + this.cooldownSubscription = interval(1000).subscribe(() => { + const currentSeconds = this.cooldownSeconds(); + if (currentSeconds <= 1) { + this.resendCooldown.set(false); + this.cooldownSubscription?.unsubscribe(); + this.cooldownSubscription = null; + } else { + this.cooldownSeconds.set(currentSeconds - 1); + } + }); + } + + private sendForm() { + const rawValue = this.form.getRawValue(); + if (this.authService.isAuthenticated$()) { + const form: VerifyCode = { + code: rawValue.code, + }; + this.mailService.verifyEmailCode(form).subscribe({ + next: (mailResponse) => { + this.mailVerified.set(mailResponse.verified); + if (mailResponse.verified) { + this.dialogRef.close(true); + } + }, + error: (error) => { + console.error('Error verifying email code', error); + } + }); + } else { + throw new Error('User not logged in'); + } + } + + ngOnDestroy() { + if (this.cooldownSubscription) { + this.cooldownSubscription.unsubscribe(); + } + } +} + +interface VerifyMail { + code: FormControl; +} + +interface VerifyMailData { + verified: boolean; + mail: string; +} diff --git a/frontend/src/app/pages/header/header/header.component.html b/frontend/src/app/pages/header/header/header.component.html index ba34a86..9d872b8 100644 --- a/frontend/src/app/pages/header/header/header.component.html +++ b/frontend/src/app/pages/header/header/header.component.html @@ -62,7 +62,7 @@
  • Blog
  • - @if (!isAuthenticated) { + @if (!isAuthenticated()) {
  • Login @@ -137,7 +137,7 @@
  • - @if (isAuthenticated) { + @if (isAuthenticated()) { } - @if (!isAuthenticated) { + @if (!isAuthenticated()) {