Compare commits
101
Commits
textures
...
f886609a0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f886609a0e | ||
|
|
2a0f38aa28 | ||
|
|
4878ad9f0d | ||
|
|
80cb2d0ad1 | ||
|
|
53f67c0b67 | ||
|
|
1f1f1793e3 | ||
|
|
4962d16abd | ||
|
|
cad574b8fb | ||
|
|
c75f0cdb15 | ||
|
|
fe545972e3 | ||
|
|
eab1c9322b | ||
|
|
ffddffa8dc | ||
|
|
0b4c1ccebf | ||
|
|
2e89fcec66 | ||
|
|
42b11eecf1 | ||
|
|
d1da1296bb | ||
|
|
523bf3d43f | ||
|
|
4ccce7e190 | ||
|
|
641083732d | ||
|
|
da17cf9696 | ||
|
|
de1876c90c | ||
|
|
c3f3b20807 | ||
|
|
37fb49cda1 | ||
|
|
db642103ed | ||
|
|
f026f24263 | ||
|
|
eaee31ab2b | ||
|
|
24d7cfe913 | ||
|
|
770a2e0d14 | ||
|
|
101794d8f2 | ||
|
|
eb72ce14cc | ||
|
|
d1ba89acc8 | ||
|
|
d28b4a2b62 | ||
|
|
56f4ccf40e | ||
|
|
d73f057596 | ||
|
|
e825d83124 | ||
|
|
238c5d9644 | ||
|
|
4222df87a3 | ||
|
|
16cc57d774 | ||
|
|
c536bfbf30 | ||
|
|
f67cb50f41 | ||
|
|
bdb38e5011 | ||
|
|
ae1e972438 | ||
|
|
737b26a6c7 | ||
|
|
5013b9a204 | ||
|
|
fcb64db137 | ||
|
|
d2e064e2b4 | ||
|
|
f50f2dc6c2 | ||
|
|
c277306c2c | ||
|
|
1f03a4bdc3 | ||
|
|
7f1c59d102 | ||
|
|
f968a64dd4 | ||
|
|
c25364caf7 | ||
|
|
15c3cc7f26 | ||
|
|
2b96957876 | ||
|
|
b16fab26e7 | ||
|
|
28fd05a656 | ||
|
|
ff1b09be92 | ||
|
|
8a839ac922 | ||
|
|
3f76a98409 | ||
|
|
871615702b | ||
|
|
291c9df5c6 | ||
|
|
4150324d75 | ||
|
|
4267c782a7 | ||
|
|
343964eda8 | ||
|
|
1ce2088cae | ||
|
|
0b952e07f7 | ||
|
|
c2b9a8a574 | ||
|
|
d3ef296784 | ||
|
|
5a792463cc | ||
|
|
974d50d7cd | ||
|
|
62f837914c | ||
|
|
ace969ba3b | ||
|
|
2fc6ba53f6 | ||
|
|
4c38b070ea | ||
|
|
db394beda6 | ||
|
|
76cb3cd89c | ||
|
|
5d8ab2deef | ||
|
|
aef32a8982 | ||
|
|
42f0961f13 | ||
|
|
04310e1cce | ||
|
|
54e747118c | ||
|
|
43430cfbef | ||
|
|
cce83a08de | ||
|
|
f0faa63ca7 | ||
|
|
dfea91d8ca | ||
|
|
73916f0aae | ||
|
|
ebe66c87c0 | ||
|
|
c42fc38b2c | ||
|
|
213f9987d9 | ||
|
|
48cac607de | ||
|
|
6ed2e15017 | ||
|
|
7fc25f46f3 | ||
|
|
c72703ea32 | ||
|
|
e837a9216d | ||
|
|
d4363b3a8a | ||
|
|
1e5862bae6 | ||
|
|
daf88ea437 | ||
|
|
9abd570b87 | ||
|
|
5284d498f3 | ||
|
|
c3a7be82e9 | ||
|
|
174ed834ca |
@@ -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")
|
||||
@@ -36,6 +37,12 @@ dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.security:spring-security-oauth2-resource-server")
|
||||
implementation("org.springframework.security:spring-security-oauth2-jose")
|
||||
implementation("org.springframework.boot:spring-boot-starter-mail:3.1.5")
|
||||
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
|
||||
|
||||
//Open API
|
||||
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
|
||||
implementation("io.swagger.core.v3:swagger-models:2.2.37")
|
||||
|
||||
//AOP
|
||||
implementation("org.aspectj:aspectjrt:1.9.19")
|
||||
|
||||
@@ -5,7 +5,7 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.context.annotation.EnableAspectJAutoProxy;
|
||||
|
||||
@SpringBootApplication
|
||||
@SpringBootApplication(scanBasePackages = {"com.alttd.altitudeweb"})
|
||||
@EnableAspectJAutoProxy
|
||||
public class AltitudeWebApplication {
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.alttd.altitudeweb.config;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SecurityAuthFailureHandler implements AccessDeniedHandler, AuthenticationEntryPoint {
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response,
|
||||
AccessDeniedException accessDeniedException) throws IOException {
|
||||
log.warn("Access denied: User '{}' attempted to access '{}' without proper permissions",
|
||||
request.getUserPrincipal() != null ? request.getUserPrincipal().getName() : "unknown",
|
||||
request.getRequestURI());
|
||||
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Access Denied");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException authException) throws IOException {
|
||||
log.warn("Authentication failure: Unauthenticated user attempted to access secured endpoint '{}'",
|
||||
request.getRequestURI());
|
||||
|
||||
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication Required");
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,11 @@ 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;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
@@ -31,19 +33,39 @@ import java.security.interfaces.RSAPublicKey;
|
||||
public class SecurityConfig {
|
||||
|
||||
private final KeyPairService keyPairService;
|
||||
private final SecurityAuthFailureHandler securityAuthFailureHandler;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
return http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/login/userLogin/**", "/login/requestNewUserLogin/**").permitAll()
|
||||
.requestMatchers("/team/**", "/history/**").permitAll()
|
||||
.requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
|
||||
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
|
||||
.authorizeHttpRequests(
|
||||
auth -> auth
|
||||
.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()
|
||||
)
|
||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.oauth2ResourceServer(
|
||||
oauth2 -> oauth2
|
||||
.jwt(Customizer.withDefaults())
|
||||
.authenticationEntryPoint(securityAuthFailureHandler)
|
||||
.accessDeniedHandler(securityAuthFailureHandler)
|
||||
)
|
||||
.exceptionHandling(
|
||||
ex -> ex
|
||||
.authenticationEntryPoint(securityAuthFailureHandler)
|
||||
.accessDeniedHandler(securityAuthFailureHandler)
|
||||
)
|
||||
.sessionManagement(
|
||||
session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||
)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
package com.alttd.altitudeweb.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.servlet.resource.PathResourceResolver;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Configuration
|
||||
@Slf4j @Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.addResourceLocations("classpath:/static/browser")
|
||||
.resourceChain(true)
|
||||
.addResolver(new PathResourceResolver() {
|
||||
@Override
|
||||
@@ -23,11 +26,23 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
Resource requestedResource = location.createRelative(resourcePath);
|
||||
|
||||
if (requestedResource.exists() && requestedResource.isReadable()) {
|
||||
log.debug("Serving resource {} from {}", resourcePath, location);
|
||||
return requestedResource;
|
||||
}
|
||||
|
||||
return new ClassPathResource("/static/index.html");
|
||||
log.debug("Resource {} not found in {}, serving index.html", resourcePath, location);
|
||||
|
||||
return new ClassPathResource("/static/browser/index.html");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Controller
|
||||
public static class HomeController {
|
||||
@GetMapping("/")
|
||||
public String index() {
|
||||
return "forward:/index.html";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
package com.alttd.altitudeweb.controllers.application;
|
||||
|
||||
import com.alttd.altitudeweb.api.AppealsApi;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.model.AppealResponseDto;
|
||||
import com.alttd.altitudeweb.model.DiscordAppealDto;
|
||||
import com.alttd.altitudeweb.model.MinecraftAppealDto;
|
||||
import com.alttd.altitudeweb.model.UpdateMailDto;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RestController
|
||||
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
|
||||
public class AppealController implements AppealsApi {
|
||||
|
||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
|
||||
@Override
|
||||
public ResponseEntity<MinecraftAppealDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
|
||||
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
|
||||
}
|
||||
|
||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
|
||||
@Override
|
||||
public ResponseEntity<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
|
||||
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Minecraft appeals are not yet supported");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<AppealResponseDto> updateMail(UpdateMailDto updateMailDto) {
|
||||
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported");
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.alttd.altitudeweb.controllers.data_from_auth;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class AuthenticatedUuid {
|
||||
/**
|
||||
* Extracts and validates the authenticated user's UUID from the JWT token.
|
||||
*
|
||||
* @return The UUID of the authenticated user
|
||||
* @throws ResponseStatusException with 401 status if authentication is invalid
|
||||
*/
|
||||
public static UUID getAuthenticatedUserUuid() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt jwt)) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
|
||||
String stringUuid = jwt.getSubject();
|
||||
|
||||
try {
|
||||
return UUID.fromString(stringUuid);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid UUID format");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.alttd.altitudeweb.controllers.forms;
|
||||
|
||||
import com.alttd.altitudeweb.api.AppealsApi;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||
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.DiscordAppealDto;
|
||||
import com.alttd.altitudeweb.model.FormResponseDto;
|
||||
import com.alttd.altitudeweb.model.MinecraftAppealDto;
|
||||
import com.alttd.altitudeweb.model.UpdateMailDto;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import com.alttd.altitudeweb.services.mail.AppealMail;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
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.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
|
||||
public class AppealController implements AppealsApi {
|
||||
|
||||
private final AppealDataMapper mapper;
|
||||
private final AppealMail appealMail;
|
||||
private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord;
|
||||
|
||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
|
||||
@Override
|
||||
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
|
||||
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
|
||||
}
|
||||
|
||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
|
||||
@Override
|
||||
public ResponseEntity<FormResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
|
||||
boolean success = true;
|
||||
CompletableFuture<Appeal> appealCompletableFuture = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading history by id");
|
||||
try {
|
||||
Appeal appeal = mapper.minecraftAppealDtoToAppeal(minecraftAppealDto);
|
||||
sqlSession.getMapper(AppealMapper.class).createAppeal(appeal);
|
||||
appealCompletableFuture.complete(appeal);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load history count", e);
|
||||
appealCompletableFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
Appeal appeal = appealCompletableFuture.join();
|
||||
HistoryRecord history = getHistory(appeal.historyType(), appeal.historyId());
|
||||
if (history == null) {
|
||||
throw new ResponseStatusException(HttpStatusCode.valueOf(404), "History not found");
|
||||
}
|
||||
// Send to Discord channels
|
||||
try {
|
||||
appealDiscord.sendAppealToDiscord(appeal, history);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send appeal {} to Discord", appeal.id(), e);
|
||||
success = false;
|
||||
}
|
||||
|
||||
appealMail.sendAppealNotification(appeal, history);
|
||||
|
||||
CompletableFuture<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Retrieving mail by uuid and address");
|
||||
|
||||
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
|
||||
.findByUserAndEmail(appeal.uuid(), appeal.email().toLowerCase());
|
||||
emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail));
|
||||
});
|
||||
Optional<EmailVerification> optionalEmailVerification = emailVerificationCompletableFuture.join();
|
||||
|
||||
if (optionalEmailVerification.isEmpty()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
EmailVerification emailVerification = optionalEmailVerification.get();
|
||||
if (!emailVerification.verified()) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
if (!success) {
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Marking appeal {} as sent", appeal.id());
|
||||
sqlSession.getMapper(AppealMapper.class)
|
||||
.markAppealAsSent(appeal.id());
|
||||
});
|
||||
FormResponseDto appealResponseDto = new FormResponseDto(
|
||||
appeal.id().toString(),
|
||||
"Your appeal has been submitted. You will be notified when it has been reviewed.",
|
||||
true);
|
||||
return ResponseEntity.ok().body(appealResponseDto);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<FormResponseDto> updateMail(UpdateMailDto updateMailDto) {
|
||||
//TODO move to its own endpoint
|
||||
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported");
|
||||
}
|
||||
|
||||
private HistoryRecord getHistory(String type, int id) {
|
||||
HistoryType historyTypeEnum = HistoryType.getHistoryType(type);
|
||||
CompletableFuture<HistoryRecord> historyRecordCompletableFuture = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading history by id");
|
||||
try {
|
||||
HistoryRecord punishment = sqlSession.getMapper(IdHistoryMapper.class)
|
||||
.getRecentHistory(historyTypeEnum, id);
|
||||
historyRecordCompletableFuture.complete(punishment);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load history count", e);
|
||||
historyRecordCompletableFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return historyRecordCompletableFuture.join();
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.alttd.altitudeweb.controllers.forms;
|
||||
|
||||
import com.alttd.altitudeweb.api.ApplicationsApi;
|
||||
import com.alttd.altitudeweb.model.FormResponseDto;
|
||||
import com.alttd.altitudeweb.model.StaffApplicationDto;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
public class ApplicationController implements ApplicationsApi {
|
||||
@Override
|
||||
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
|
||||
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Staff applications are not yet supported");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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<MailResponseDto> submitEmailForVerification(SubmitEmailDto submitEmailDto) {
|
||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
boolean emailAlreadyVerified = mailVerificationService.listAll(uuid).stream()
|
||||
.filter(EmailVerification::verified)
|
||||
.map(EmailVerification::email)
|
||||
.anyMatch(mail -> mail.equalsIgnoreCase(submitEmailDto.getEmail()));
|
||||
if (emailAlreadyVerified) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email already verified for user");
|
||||
}
|
||||
|
||||
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<MailResponseDto> verifyEmailCode(VerifyCodeDto verifyCodeDto) {
|
||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
Optional<EmailVerification> 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<MailResponseDto> 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<Void> 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<List<EmailEntryDto>> getUserEmails() {
|
||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
List<EmailVerification> emails = mailVerificationService.listAll(uuid);
|
||||
List<EmailEntryDto> result = emails.stream()
|
||||
.map(ev -> new EmailEntryDto().email(ev.email()).verified(ev.verified()))
|
||||
.toList();
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
+93
-15
@@ -1,18 +1,27 @@
|
||||
package com.alttd.altitudeweb.controllers.login;
|
||||
|
||||
import com.alttd.altitudeweb.api.LoginApi;
|
||||
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||
import com.alttd.altitudeweb.database.litebans.RecentNamesMapper;
|
||||
import com.alttd.altitudeweb.database.litebans.UUIDHistoryMapper;
|
||||
import com.alttd.altitudeweb.model.PermissionClaimDto;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUser;
|
||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
||||
import com.alttd.altitudeweb.model.UsernameDto;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.security.oauth2.resource.OAuth2ResourceServerProperties;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import com.alttd.altitudeweb.services.limits.RateLimit;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||
@@ -36,7 +45,11 @@ public class LoginController implements LoginApi {
|
||||
@Value("${login.secret:#{null}}")
|
||||
private String loginSecret;
|
||||
|
||||
private record CacheEntry(UUID uuid, Instant expiry) {}
|
||||
@Value("${my-server.address:#{null}}")
|
||||
private String serverAddress;
|
||||
|
||||
private record CacheEntry(UUID uuid, Instant expiry) {
|
||||
}
|
||||
|
||||
private static final ConcurrentMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
|
||||
|
||||
@@ -80,24 +93,79 @@ public class LoginController implements LoginApi {
|
||||
return ResponseEntity.ok(loginCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<UsernameDto> getUsername() {
|
||||
log.debug("Loading username for logged in user");
|
||||
|
||||
try {
|
||||
// Get authenticated UUID using the utility method
|
||||
UUID uuid = AuthenticatedUuid.getAuthenticatedUserUuid();
|
||||
log.debug("Loaded username for logged in user {}", uuid);
|
||||
|
||||
// Create response with username
|
||||
UsernameDto usernameDto = new UsernameDto();
|
||||
usernameDto.setUsername(getUsername(uuid));
|
||||
log.debug("Loaded username for logged in user {}", usernameDto.getUsername());
|
||||
|
||||
return ResponseEntity.ok(usernameDto);
|
||||
|
||||
} catch (ResponseStatusException e) {
|
||||
// The utility method already throws proper exceptions, we just need to convert them to ResponseEntity
|
||||
return ResponseEntity.status(e.getStatusCode()).build();
|
||||
}
|
||||
}
|
||||
|
||||
private String getUsername(UUID uuid) {
|
||||
CompletableFuture<String> username = new CompletableFuture<>();
|
||||
|
||||
Connection.getConnection(Databases.LITE_BANS)
|
||||
.runQuery(sqlSession -> {
|
||||
log.debug("Loading all history through logged in uuid");
|
||||
try {
|
||||
String temp = sqlSession
|
||||
.getMapper(RecentNamesMapper.class)
|
||||
.getUsername(uuid.toString());
|
||||
username.complete(temp);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to find username for uuid {}", uuid, e);
|
||||
username.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return username.join();
|
||||
}
|
||||
|
||||
@Value("${UNSECURED:#{false}}")
|
||||
private boolean unsecured;
|
||||
|
||||
@RateLimit(limit = 5, timeValue = 1, timeUnit = TimeUnit.MINUTES, key = "login")
|
||||
@Override
|
||||
public ResponseEntity<String> login(String code) {
|
||||
CacheEntry cacheEntry1 = new CacheEntry(UUID.fromString("55e46bc3-2a29-4c53-850f-dbd944dc5c5f"), Instant.now().plusSeconds(TimeUnit.DAYS.toSeconds(1)));
|
||||
cache.put("23232323", cacheEntry1);
|
||||
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);
|
||||
|
||||
cache.remove(code);
|
||||
|
||||
log.debug("Generated token for user {}", cacheEntry.uuid);
|
||||
return ResponseEntity.ok(token);
|
||||
}
|
||||
|
||||
@@ -134,34 +202,44 @@ public class LoginController implements LoginApi {
|
||||
Instant now = Instant.now();
|
||||
//TODO make a JWT for renewing and one for storing permissions for a session (expiry 1 hour)
|
||||
Instant expiryTime = now.plusSeconds(TimeUnit.DAYS.toSeconds(30));
|
||||
CompletableFuture<PrivilegedUser> privilegedUserCompletableFuture = new CompletableFuture<>();
|
||||
CompletableFuture<Optional<PrivilegedUser>> privilegedUserCompletableFuture = new CompletableFuture<>();
|
||||
List<PermissionClaimDto> claimList = new ArrayList<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sqlSession -> {
|
||||
try {
|
||||
PrivilegedUser privilegedUser = sqlSession.getMapper(PrivilegedUserMapper.class)
|
||||
.getUserByUuid(uuid.toString());
|
||||
log.debug("Loading user by uuid {}", uuid.toString());
|
||||
PrivilegedUserMapper mapper = sqlSession.getMapper(PrivilegedUserMapper.class);
|
||||
Optional<PrivilegedUser> privilegedUser = mapper
|
||||
.getUserByUuid(uuid);
|
||||
|
||||
if (privilegedUser.isEmpty()) {
|
||||
int privilegedUserId = mapper.createPrivilegedUser(uuid);
|
||||
privilegedUserCompletableFuture.complete(
|
||||
Optional.of(new PrivilegedUser(privilegedUserId, uuid, List.of())));
|
||||
} else {
|
||||
privilegedUserCompletableFuture.complete(privilegedUser);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load user by uuid", e);
|
||||
privilegedUserCompletableFuture.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
PrivilegedUser privilegedUser = privilegedUserCompletableFuture.join();
|
||||
Optional<PrivilegedUser> privilegedUser = privilegedUserCompletableFuture.join();
|
||||
claimList.add(PermissionClaimDto.USER);
|
||||
if (privilegedUser != null) {
|
||||
privilegedUser.getPermissions().forEach(permission -> {
|
||||
privilegedUser.ifPresent(user -> user.getPermissions().forEach(permission -> {
|
||||
try {
|
||||
claimList.add(PermissionClaimDto.valueOf(permission));
|
||||
claimList.add(PermissionClaimDto.fromValue(permission));
|
||||
log.debug("Added permission claim {}", permission);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Received invalid permission claim: {}", permission);
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
log.debug("Generated token for user {} with claims {}", uuid.toString(),
|
||||
claimList.stream().map(PermissionClaimDto::getValue).toList());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
.issuer("altitudeweb")
|
||||
.claim("authorities", claimList.stream().map(PermissionClaimDto::getValue).toList())
|
||||
.issuer(serverAddress)
|
||||
.claim("authorities",
|
||||
claimList.stream().map(PermissionClaimDto::getValue).toList())
|
||||
.issuedAt(now)
|
||||
.expiresAt(expiryTime)
|
||||
.subject(uuid.toString())
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.alttd.altitudeweb.controllers.particles;
|
||||
|
||||
import com.alttd.altitudeweb.api.ParticlesApi;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
public class ParticleController implements ParticlesApi {
|
||||
|
||||
@Value("${login.secret:#{null}}")
|
||||
private String loginSecret;
|
||||
|
||||
@Value("${particles.file_path}")
|
||||
private String particlesFilePath;
|
||||
|
||||
@Value("${notification.server.url:http://localhost:8080}")
|
||||
private String notificationServerUrl;
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Resource> downloadFile(String authorization, String filename) throws Exception {
|
||||
if (authorization == null || !authorization.equals(loginSecret)) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
File targetFile = new File(file, filename);
|
||||
return getFileForDownload(targetFile, filename);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception {
|
||||
if (authorization == null || !authorization.equals(loginSecret)) {
|
||||
return ResponseEntity.status(401).build();
|
||||
}
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
File targetDir = new File(file, uuid);
|
||||
if (targetDir.exists()) {
|
||||
return getFileForDownload(targetDir, filename);
|
||||
} else {
|
||||
log.warn("User {} does not have a directory for particles files", uuid);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
|
||||
File targetFile = new File(file, filename);
|
||||
if (!targetFile.exists()) {
|
||||
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (!targetFile.isFile()) {
|
||||
log.warn("Particles file {} is not a file", targetFile.getAbsolutePath());
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
|
||||
try {
|
||||
Path path = targetFile.toPath();
|
||||
ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path));
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentLength(targetFile.length())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read particles file {}: {}", targetFile.getAbsolutePath(), e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) throws Exception {
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
|
||||
notifyServerOfFileUpload(filename);
|
||||
return voidResponseEntity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception {
|
||||
File file = new File(particlesFilePath);
|
||||
if (!file.exists() || !file.isDirectory()) {
|
||||
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
|
||||
return ResponseEntity.status(404).build();
|
||||
}
|
||||
File targetDir = new File(file, uuid);
|
||||
if (!file.exists()) {
|
||||
log.debug("Creating particles directory {}", targetDir.getAbsolutePath());
|
||||
if (targetDir.mkdirs()) {
|
||||
log.info("Created particles user directory {}", targetDir.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
|
||||
notifyServerOfFileUpload(uuid, filename);
|
||||
return voidResponseEntity;
|
||||
}
|
||||
|
||||
private void notifyServerOfFileUpload(String filename) {
|
||||
String notificationUrl = String.format("%s/notify/%s.json", notificationServerUrl, filename);
|
||||
sendNotification(notificationUrl, String.format("file upload: %s", filename));
|
||||
}
|
||||
|
||||
private void notifyServerOfFileUpload(String uuid, String filename) {
|
||||
String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename);
|
||||
sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename));
|
||||
}
|
||||
|
||||
private void sendNotification(String notificationUrl, String logDescription) {
|
||||
try {
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
log.info("Successfully notified server of {}", logDescription);
|
||||
} else {
|
||||
log.warn("Failed to notify server of {}, status: {}",
|
||||
logDescription, response.getStatusCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error notifying server of {}", logDescription, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
|
||||
File targetFile = new File(dir, filename);
|
||||
if (!Files.isWritable(targetFile.toPath())) {
|
||||
log.error("Particles file {} is not writable", targetFile.getAbsolutePath());
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
if (targetFile.exists()) {
|
||||
log.warn("Overwriting existing particles file {}", targetFile.getAbsolutePath());
|
||||
}
|
||||
|
||||
try {
|
||||
content.transferTo(targetFile);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to write particles file {}", targetFile.getAbsolutePath(), e);
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.alttd.altitudeweb.mappers;
|
||||
|
||||
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
|
||||
public class AppealDataMapper {
|
||||
public MinecraftAppealDto appealToMinecraftAppealDto(Appeal appeal) {
|
||||
MinecraftAppealDto minecraftAppealDto = new MinecraftAppealDto();
|
||||
minecraftAppealDto.setAppeal(appeal.reason());
|
||||
minecraftAppealDto.setUsername(appeal.username());
|
||||
minecraftAppealDto.setUuid(appeal.uuid());
|
||||
minecraftAppealDto.setEmail(appeal.email());
|
||||
return minecraftAppealDto;
|
||||
}
|
||||
|
||||
public Appeal minecraftAppealDtoToAppeal(MinecraftAppealDto minecraftAppealDto) {
|
||||
return new Appeal(
|
||||
UUID.randomUUID(),
|
||||
minecraftAppealDto.getUuid(),
|
||||
minecraftAppealDto.getPunishmentType().toString(),
|
||||
minecraftAppealDto.getPunishmentId(),
|
||||
minecraftAppealDto.getUsername(),
|
||||
minecraftAppealDto.getAppeal(),
|
||||
Instant.now(),
|
||||
null,
|
||||
minecraftAppealDto.getEmail(),
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.alttd.altitudeweb.services.discord;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.discord.OutputChannel;
|
||||
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryCountMapper;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryType;
|
||||
import com.alttd.altitudeweb.database.litebans.UserType;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
|
||||
import com.alttd.altitudeweb.setup.Connection;
|
||||
import com.alttd.webinterface.send_message.DiscordSender;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AppealDiscord {
|
||||
|
||||
private static final String OUTPUT_TYPE = "APPEAL";
|
||||
|
||||
public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) {
|
||||
// Fetch channels
|
||||
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
|
||||
try {
|
||||
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
|
||||
.getChannelsWithOutputType(OUTPUT_TYPE);
|
||||
channelsFuture.complete(channels);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
|
||||
channelsFuture.complete(new ArrayList<>());
|
||||
}
|
||||
});
|
||||
|
||||
CompletableFuture<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid());
|
||||
CompletableFuture<Integer> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid());
|
||||
CompletableFuture<Integer> warnsF = getCountAsync(HistoryType.WARN, appeal.uuid());
|
||||
CompletableFuture<Integer> kicksF = getCountAsync(HistoryType.KICK, appeal.uuid());
|
||||
|
||||
List<OutputChannel> channels = channelsFuture.join();
|
||||
int bans = bansF.join();
|
||||
int mutes = mutesF.join();
|
||||
int warns = warnsF.join();
|
||||
int kicks = kicksF.join();
|
||||
|
||||
if (channels.isEmpty()) {
|
||||
log.warn("No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build embed
|
||||
boolean active = history.getUntil() == null || history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis();
|
||||
String createdAt = formatInstant(appeal.createdAt());
|
||||
|
||||
List<DiscordSender.EmbedField> fields = new ArrayList<>();
|
||||
// Group: User
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"User",
|
||||
"Username: " + safe(appeal.username()) + "\n" +
|
||||
"UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" +
|
||||
"Email: " + safe(appeal.email()) + "\n" +
|
||||
"Submitted: " + createdAt,
|
||||
false
|
||||
));
|
||||
// Group: Punishment
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Punishment",
|
||||
"Type: " + safe(String.valueOf(appeal.historyType())) + "\n" +
|
||||
"ID: " + safe(String.valueOf(appeal.historyId())) + "\n" +
|
||||
"Reason: " + safe(history.getReason()) + "\n" +
|
||||
"Active: " + active,
|
||||
false
|
||||
));
|
||||
// Group: Previous punishments
|
||||
fields.add(new DiscordSender.EmbedField(
|
||||
"Previous punishments",
|
||||
"Bans: " + bans + "\n" +
|
||||
"Mutes: " + mutes + "\n" +
|
||||
"Warnings: " + warns + "\n" +
|
||||
"Kicks: " + kicks,
|
||||
true
|
||||
));
|
||||
|
||||
String description = safe(appeal.reason());
|
||||
|
||||
List<Long> channelIds = channels.stream()
|
||||
.map(OutputChannel::channel)
|
||||
.toList();
|
||||
|
||||
// colorRgb = null (use default), timestamp = appeal.createdAt if available
|
||||
Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now();
|
||||
DiscordSender.getInstance().sendEmbedToChannels(
|
||||
channelIds,
|
||||
"New Appeal Submitted",
|
||||
description,
|
||||
fields,
|
||||
null,
|
||||
timestamp,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private CompletableFuture<Integer> getCountAsync(HistoryType type, java.util.UUID uuid) {
|
||||
CompletableFuture<Integer> future = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.LITE_BANS).runQuery(sql -> {
|
||||
try {
|
||||
Integer count = sql.getMapper(HistoryCountMapper.class)
|
||||
.getUuidPunishmentCount(type, UserType.PLAYER, uuid);
|
||||
future.complete(count == null ? 0 : count);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load punishment count for {} ({})", type, uuid, e);
|
||||
future.complete(0);
|
||||
}
|
||||
});
|
||||
return future;
|
||||
}
|
||||
|
||||
private String safe(String s) {
|
||||
return s == null ? "unknown" : s;
|
||||
}
|
||||
|
||||
private String formatInstant(Instant instant) {
|
||||
if (instant == null) return "unknown";
|
||||
return instant.atZone(ZoneId.of("UTC"))
|
||||
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.alttd.altitudeweb.services.mail;
|
||||
|
||||
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
|
||||
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 org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppealMail {
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
private final SpringTemplateEngine templateEngine;
|
||||
|
||||
@Value("${spring.mail.username}")
|
||||
private String fromEmail;
|
||||
|
||||
private static final String APPEAL_EMAIL = "[email protected]";
|
||||
|
||||
/**
|
||||
* Sends an email notification about the appeal to both the user and the appeals team.
|
||||
*
|
||||
* @param appeal The appeal object containing all necessary information
|
||||
*/
|
||||
public void sendAppealNotification(Appeal appeal, HistoryRecord history) {
|
||||
try {
|
||||
sendEmailToAppealsTeam(appeal, history);
|
||||
|
||||
log.info("Appeal notification emails sent successfully for appeal ID: {}", appeal.id());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to send appeal notification emails for appeal ID: {}", appeal.id(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendEmailToAppealsTeam(Appeal appeal, HistoryRecord history) throws MessagingException {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true);
|
||||
|
||||
helper.setFrom(fromEmail);
|
||||
helper.setTo(APPEAL_EMAIL);
|
||||
helper.setReplyTo(appeal.email());
|
||||
helper.setSubject("New Appeal Submitted - " + appeal.username());
|
||||
|
||||
Context context = new Context();
|
||||
context.setVariable("appeal", appeal);
|
||||
context.setVariable("history", history);
|
||||
context.setVariable("createdAt", appeal.createdAt()
|
||||
.atZone(ZoneId.of("UTC"))
|
||||
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'")));
|
||||
context.setVariable("active", history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis());
|
||||
String content = templateEngine.process("appeal-email", context);
|
||||
|
||||
helper.setText(content, true);
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
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<EmailVerification> listAll(UUID userUuid) {
|
||||
java.util.concurrent.CompletableFuture<java.util.List<EmailVerification>> 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();
|
||||
final String finalEmail = email.toLowerCase();
|
||||
|
||||
CompletableFuture<EmailVerification> future = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sql -> {
|
||||
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
|
||||
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
|
||||
EmailVerification toPersist;
|
||||
if (existing == null) {
|
||||
toPersist = new EmailVerification(UUID.randomUUID(), userUuid, finalEmail, code, false, now, null, now);
|
||||
mapper.insert(toPersist);
|
||||
} else {
|
||||
mapper.updateCodeAndLastSent(existing.id(), code, now);
|
||||
toPersist = new EmailVerification(existing.id(), userUuid, finalEmail, code, false, existing.createdAt(), null, now);
|
||||
}
|
||||
future.complete(toPersist);
|
||||
});
|
||||
EmailVerification saved = future.join();
|
||||
sendVerificationEmail(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public Optional<EmailVerification> verifyCode(UUID userUuid, String code) {
|
||||
CompletableFuture<Optional<EmailVerification>> 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();
|
||||
final String finalEmail = email.toLowerCase();
|
||||
CompletableFuture<EmailVerification> future = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sql -> {
|
||||
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
|
||||
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
|
||||
if (existing != null) {
|
||||
mapper.updateCodeAndLastSent(existing.id(), code, now);
|
||||
future.complete(new EmailVerification(existing.id(), userUuid,
|
||||
finalEmail, 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) {
|
||||
final String finalEmail = email.toLowerCase();
|
||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||
Connection.getConnection(Databases.DEFAULT)
|
||||
.runQuery(sql -> {
|
||||
EmailVerificationMapper mapper = sql.getMapper(EmailVerificationMapper.class);
|
||||
EmailVerification existing = mapper.findByUserAndEmail(userUuid, finalEmail);
|
||||
if (existing != null) {
|
||||
mapper.deleteByUserAndEmail(userUuid, finalEmail);
|
||||
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().toLowerCase());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,5 @@ database.host=${DB_HOST:localhost}
|
||||
database.user=${DB_USER:root}
|
||||
database.password=${DB_PASSWORD:root}
|
||||
cors.allowed-origins=${CORS:https://beta.alttd.com}
|
||||
logging.level.com.alttd.altitudeweb=INFO
|
||||
my-server.address=${SERVER_ADDRESS:https://beta.alttd.com}
|
||||
logging.level.com.alttd.altitudeweb=DEBUG
|
||||
|
||||
@@ -1,8 +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}
|
||||
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
|
||||
|
||||
@@ -6,4 +6,15 @@ database.user=${DB_USER:root}
|
||||
database.password=${DB_PASSWORD:root}
|
||||
cors.allowed-origins=${CORS:https://alttd.com}
|
||||
login.secret=${LOGIN_SECRET:SET_TOKEN}
|
||||
particles.file_path=${user.home}/.altitudeweb/particles
|
||||
notification.server.url=${SERVER_IP:10.0.0.107}:${SERVER_PORT:8080}
|
||||
my-server.address=${SERVER_ADDRESS:https://alttd.com}
|
||||
logging.level.com.alttd.altitudeweb=INFO
|
||||
discord.token=${DISCORD_TOKEN}
|
||||
spring.mail.host=${MAIL_HOST:smtp.zoho.com}
|
||||
spring.mail.port=${MAIL_PORT:465}
|
||||
spring.mail.username=${MAIL_USER}
|
||||
spring.mail.password=${MAIL_PASSWORD}
|
||||
spring.mail.properties.mail.smtp.auth=true
|
||||
spring.mail.properties.mail.smtp.ssl.enable=true
|
||||
spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Appeal Notification</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'minecraft-title';
|
||||
src: url('https://beta.alttd.com/public/fonts/minecraft-title.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-title.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-title.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-title.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'minecraft-text';
|
||||
src: url('https://beta.alttd.com/public/fonts/minecraft-text.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-text.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-text.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/minecraft-text.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'opensans';
|
||||
src: url('https://beta.alttd.com/public/fonts/opensans.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'opensans-bold';
|
||||
src: url('https://beta.alttd.com/public/fonts/opensans-bold.ttf') format('truetype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans-bold.eot') format('embedded-opentype'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans-bold.svg') format('svg'),
|
||||
url('https://beta.alttd.com/public/fonts/opensans-bold.woff') format('woff');
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'minecraft-title', sans-serif;
|
||||
}
|
||||
|
||||
.columnSection {
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.columnContainer {
|
||||
flex: 1 1 200px;
|
||||
min-width: 200px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
margin: auto;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
li, p {
|
||||
font-family: 'opensans', sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1150px) {
|
||||
.columnContainer, .columnSection {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 690px) {
|
||||
.columnContainer {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img id="header-img" src="https://beta.alttd.com/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="159"
|
||||
width="275">
|
||||
<h1 style="text-align: center;" th:text="'Appeal by ' + ${appeal.username}">Appeal by Username</h1>
|
||||
<section class="columnSection">
|
||||
<div class="columnContainer">
|
||||
<div>
|
||||
<h2>User information</h2>
|
||||
<ul>
|
||||
<li><strong>Username:</strong> <span th:text="${appeal.username}">username</span></li>
|
||||
<li><strong>UUID:</strong> <span th:text="${appeal.uuid}">uuid</span></li>
|
||||
<li><strong>Email:</strong> <span th:text="${appeal.email}">email</span></li>
|
||||
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
|
||||
<li><strong>Reason:</strong> <span th:text="${history.reason}">reason</span></li>
|
||||
<li><strong>Active:</strong> <span th:text="${active}">unknown</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columnContainer">
|
||||
<div>
|
||||
<h2>Appeal:</h2>
|
||||
<p th:text="${appeal.reason}">Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.
|
||||
Lorem ipsum dolor sit amet consectetur adipiscing elit. Quisque faucibus ex sapien vitae pellentesque sem placerat. In id cursus mi pretium tellus duis convallis. Tempus leo eu aenean sed diam urna tempor. Pulvinar vivamus fringilla lacus nec metus bibendum egestas. Iaculis massa nisl malesuada lacinia integer nunc posuere. Ut hendrerit semper vel class aptent taciti sociosqu. Ad litora torquent per conubia nostra inceptos himenaeos.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.alttd.altitudeweb.database.discord;
|
||||
|
||||
public record OutputChannel(long guild, String outputType, long channel, String channelType) {
|
||||
}
|
||||
+15
@@ -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<OutputChannel> getChannelsWithOutputType(@Param("outputType") String outputType);
|
||||
}
|
||||
@@ -7,6 +7,16 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public interface RecentNamesMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT name AS username
|
||||
FROM litebans_history
|
||||
WHERE uuid = #{uuid}
|
||||
ORDER BY date DESC;
|
||||
""")
|
||||
String getUsername(@Param("uuid") String uuid);
|
||||
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT user_lookup.name AS punished_name
|
||||
FROM ${tableName} AS punishment
|
||||
|
||||
@@ -4,7 +4,12 @@ import org.apache.ibatis.annotations.*;
|
||||
|
||||
public interface KeyPairMapper {
|
||||
|
||||
@Select("SELECT * FROM key_pair ORDER BY id DESC LIMIT 1")
|
||||
@Select("""
|
||||
SELECT id, private_key AS privateKey, public_key AS publicKey, created_at AS createdAt
|
||||
FROM key_pair
|
||||
ORDER BY id
|
||||
DESC LIMIT 1
|
||||
""")
|
||||
KeyPairEntity getKeyPair();
|
||||
|
||||
@Insert("""
|
||||
|
||||
@@ -5,12 +5,13 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PrivilegedUser {
|
||||
private int id;
|
||||
private String uuid;
|
||||
private Integer id;
|
||||
private UUID uuid;
|
||||
private List<String> permissions;
|
||||
}
|
||||
|
||||
+14
-5
@@ -1,21 +1,23 @@
|
||||
package com.alttd.altitudeweb.database.web_db;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface PrivilegedUserMapper {
|
||||
|
||||
/**
|
||||
* Retrieves a user by their UUID along with their permissions
|
||||
* @param uuid The UUID of the user to retrieve
|
||||
* @return The PrivilegedUser with their permissions, or null if not found
|
||||
* @return The optional PrivilegedUser with their permissions
|
||||
*/
|
||||
@Select("""
|
||||
SELECT privileged_users.id, privileged_users.uuid, privileges.privileges as permission
|
||||
SELECT id, uuid
|
||||
FROM privileged_users
|
||||
LEFT JOIN privileges ON privileged_users.id = privileges.user_id
|
||||
WHERE privileged_users.uuid = #{uuid}
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
@Results({
|
||||
@Result(property = "id", column = "id"),
|
||||
@@ -23,7 +25,7 @@ public interface PrivilegedUserMapper {
|
||||
@Result(property = "permissions", column = "id", javaType = List.class,
|
||||
many = @Many(select = "getPermissionsForUser"))
|
||||
})
|
||||
PrivilegedUser getUserByUuid(@Param("uuid") String uuid);
|
||||
Optional<PrivilegedUser> getUserByUuid(@Param("uuid") UUID uuid);
|
||||
|
||||
/**
|
||||
* Retrieves all privileged users with their permissions
|
||||
@@ -99,4 +101,11 @@ public interface PrivilegedUserMapper {
|
||||
WHERE user_id = #{userId} AND privileges = #{permission}
|
||||
""")
|
||||
int removePermissionFromUser(@Param("userId") int userId, @Param("permission") String permission);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO privileged_users (uuid)
|
||||
VALUES (#{uuid})
|
||||
""")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
|
||||
int createPrivilegedUser(UUID uuid);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.alttd.altitudeweb.database.web_db.forms;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
public record Appeal(
|
||||
UUID id,
|
||||
UUID uuid,
|
||||
String historyType,
|
||||
Integer historyId,
|
||||
String username,
|
||||
String reason,
|
||||
Instant createdAt,
|
||||
Instant sendAt,
|
||||
String email,
|
||||
Long assignedTo
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.alttd.altitudeweb.database.web_db.forms;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AppealMapper {
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO appeals (uuid, username, historyType, historyId, reason, created_at, send_at, e_mail, assigned_to)
|
||||
VALUES (#{uuid}, #{username}, #{historyType}, #{historyId}, #{reason}, #{createdAt}, #{sendAt}, #{email}, #{assignedTo})
|
||||
""")
|
||||
void createAppeal(Appeal appeal);
|
||||
|
||||
@Select("""
|
||||
SELECT id, uuid, historyType, historyId, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo
|
||||
FROM appeals
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
Appeal getAppealById(int id);
|
||||
|
||||
@Select("""
|
||||
SELECT id, uuid, historyType, historyId, reason, created_at AS createdAt, send_at AS sendAt, e_mail AS email, assigned_to AS assignedTo
|
||||
FROM appeals
|
||||
WHERE uuid = #{uuid}
|
||||
""")
|
||||
List<Appeal> getAppealsByUuid(String uuid);
|
||||
|
||||
@Update("""
|
||||
UPDATE appeals SET send_at = NOW()
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
void markAppealAsSent(UUID id);
|
||||
}
|
||||
+16
@@ -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
|
||||
) {
|
||||
}
|
||||
+58
@@ -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<EmailVerification> 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);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.alttd.altitudeweb.setup;
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.web_db.DatabaseSettings;
|
||||
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
||||
import com.alttd.altitudeweb.type_handler.UUIDTypeHandler;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.datasource.pooled.PooledDataSource;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
@@ -11,8 +12,10 @@ import org.apache.ibatis.session.SqlSession;
|
||||
import org.apache.ibatis.session.SqlSessionFactory;
|
||||
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -33,6 +36,7 @@ public class Connection {
|
||||
InitializeWebDb.init();
|
||||
InitializeLiteBans.init();
|
||||
InitializeLuckPerms.init();
|
||||
InitializeDiscord.init();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
@@ -97,15 +101,44 @@ public class Connection {
|
||||
sqlSessionFactory = createSqlSessionFactory(settings, addMappers);
|
||||
}
|
||||
|
||||
try (SqlSession session = sqlSessionFactory.openSession()) {
|
||||
SqlSession session = null;
|
||||
try {
|
||||
session = sqlSessionFactory.openSession();
|
||||
consumer.accept(session);
|
||||
session.commit();
|
||||
} catch (Exception e) {
|
||||
if (session != null) {
|
||||
session.rollback();
|
||||
}
|
||||
log.error("Failed to run query", e);
|
||||
} finally {
|
||||
if (session != null) {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
private SqlSessionFactory createSqlSessionFactory(DatabaseSettings settings, AddMappers addMappers) {
|
||||
try {
|
||||
Configuration configuration = getConfiguration(settings);
|
||||
configuration.getTypeHandlerRegistry().register(UUID.class, UUIDTypeHandler.class);
|
||||
addMappers.apply(configuration);
|
||||
|
||||
return new SqlSessionFactoryBuilder().build(configuration);
|
||||
} catch (Exception e) {
|
||||
log.error("""
|
||||
Failed to create sql session factory with
|
||||
\thost {}
|
||||
\tport: {}
|
||||
\tname: {}
|
||||
\tusername: {}
|
||||
""", settings.host(), settings.port(), settings.name(), settings.username(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private static @NotNull Configuration getConfiguration(DatabaseSettings settings) {
|
||||
PooledDataSource dataSource = new PooledDataSource();
|
||||
dataSource.setDriver("com.mysql.cj.jdbc.Driver");
|
||||
dataSource.setUrl(String.format("jdbc:mysql://%s:%d/%s", settings.host(),
|
||||
@@ -113,10 +146,7 @@ public class Connection {
|
||||
dataSource.setUsername(settings.username());
|
||||
dataSource.setPassword(settings.password());
|
||||
Environment environment = new Environment("production", new JdbcTransactionFactory(), dataSource);
|
||||
Configuration configuration = new Configuration(environment);
|
||||
addMappers.apply(configuration);
|
||||
|
||||
return new SqlSessionFactoryBuilder().build(configuration);
|
||||
return new Configuration(environment);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package com.alttd.altitudeweb.setup;
|
||||
|
||||
import com.alttd.altitudeweb.database.Databases;
|
||||
import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
|
||||
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
@@ -18,12 +21,17 @@ public class InitializeWebDb {
|
||||
Connection.getConnection(Databases.DEFAULT, (configuration) -> {
|
||||
configuration.addMapper(SettingsMapper.class);
|
||||
configuration.addMapper(KeyPairMapper.class);
|
||||
configuration.addMapper(PrivilegedUserMapper.class);
|
||||
configuration.addMapper(AppealMapper.class);
|
||||
configuration.addMapper(EmailVerificationMapper.class);
|
||||
}).join()
|
||||
.runQuery(SqlSession -> {
|
||||
createSettingsTable(SqlSession);
|
||||
createKeyTable(SqlSession);
|
||||
createPrivilegedUsersTable(SqlSession);
|
||||
createPrivilegesTable(SqlSession);
|
||||
.runQuery(sqlSession -> {
|
||||
createSettingsTable(sqlSession);
|
||||
createKeyTable(sqlSession);
|
||||
createPrivilegedUsersTable(sqlSession);
|
||||
createPrivilegesTable(sqlSession);
|
||||
createAppealTable(sqlSession);
|
||||
createUserEmailsTable(sqlSession);
|
||||
});
|
||||
log.debug("Initialized WebDb");
|
||||
}
|
||||
@@ -68,7 +76,7 @@ public class InitializeWebDb {
|
||||
String query = """
|
||||
CREATE TABLE IF NOT EXISTS privileged_users (
|
||||
id int NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid VARCHAR(36) NOT NULL
|
||||
uuid UUID UNIQUE NOT NULL
|
||||
);
|
||||
""";
|
||||
try (Statement statement = sqlSession.getConnection().createStatement()) {
|
||||
@@ -97,4 +105,48 @@ 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 (
|
||||
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
|
||||
uuid UUID NOT NULL,
|
||||
historyType VARCHAR(16) NOT NULL,
|
||||
historyId BIGINT UNSIGNED NOT NULL,
|
||||
username VARCHAR(16) NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
send_at TIMESTAMP NULL,
|
||||
e_mail TEXT NOT NULL,
|
||||
assigned_to BIGINT UNSIGNED NULL,
|
||||
FOREIGN KEY (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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
plugins {
|
||||
id("java")
|
||||
}
|
||||
|
||||
group = "com.alttd.webinterface"
|
||||
version = "unspecified"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(platform("org.junit:junit-bom:5.10.0"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
// JDA
|
||||
implementation("net.dv8tion:JDA:6.0.0-rc.2") {
|
||||
exclude("opus-java") // exclude audio
|
||||
exclude("tink") // exclude audio
|
||||
}
|
||||
compileOnly("org.projectlombok:lombok:1.18.38")
|
||||
annotationProcessor("org.projectlombok:lombok:1.18.38")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.alttd.webinterface;
|
||||
|
||||
import com.alttd.webinterface.bot.DiscordBotInstance;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class DiscordBot {
|
||||
|
||||
public static void main(String[] args) {
|
||||
String discordToken = System.getProperty("DISCORD_TOKEN");
|
||||
if (discordToken == null) {
|
||||
log.error("Discord token not found, put it in the DISCORD_TOKEN environment variable");
|
||||
System.exit(1);
|
||||
}
|
||||
DiscordBotInstance discordBotInstance = new DiscordBotInstance();
|
||||
discordBotInstance.start(discordToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.alttd.webinterface.bot;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.JDABuilder;
|
||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||
|
||||
@Slf4j
|
||||
public class DiscordBotInstance {
|
||||
|
||||
@Getter
|
||||
private JDA jda;
|
||||
private volatile boolean ready = false;
|
||||
|
||||
public synchronized void start(String token) {
|
||||
if (jda != null) {
|
||||
return;
|
||||
}
|
||||
jda = JDABuilder.createDefault(token,
|
||||
GatewayIntent.GUILD_MEMBERS,
|
||||
GatewayIntent.GUILD_PRESENCES,
|
||||
GatewayIntent.GUILD_MESSAGES,
|
||||
GatewayIntent.MESSAGE_CONTENT)
|
||||
.addEventListeners(new ReadyListener(() -> {
|
||||
ready = true;
|
||||
}))
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean isReady() {
|
||||
return ready && jda != null && jda.getStatus() == JDA.Status.CONNECTED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.alttd.webinterface.bot;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.dv8tion.jda.api.events.session.ReadyEvent;
|
||||
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ReadyListener extends ListenerAdapter {
|
||||
|
||||
private final Runnable onReadyCallback;
|
||||
|
||||
@Override
|
||||
public void onReady(@NotNull ReadyEvent event) {
|
||||
log.info("JDA is ready. Guilds loaded: {}", event.getJDA().getGuilds().size());
|
||||
if (onReadyCallback != null) {
|
||||
try {
|
||||
onReadyCallback.run();
|
||||
} catch (Exception e) {
|
||||
log.error("Error running onReady callback", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.alttd.webinterface.send_message;
|
||||
|
||||
import com.alttd.webinterface.bot.DiscordBotInstance;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.dv8tion.jda.api.EmbedBuilder;
|
||||
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||
|
||||
import java.awt.*;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
public class DiscordSender {
|
||||
|
||||
private static final DiscordSender INSTANCE = new DiscordSender();
|
||||
|
||||
private final DiscordBotInstance botInstance = new DiscordBotInstance();
|
||||
|
||||
private DiscordSender() {}
|
||||
|
||||
public static DiscordSender getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
private void ensureStarted() {
|
||||
if (botInstance.getJda() != null) return;
|
||||
String token = Optional.ofNullable(System.getenv("DISCORD_TOKEN"))
|
||||
.orElse(System.getProperty("DISCORD_TOKEN"));
|
||||
if (token == null || token.isBlank()) {
|
||||
log.error("Discord token not found. Set DISCORD_TOKEN as an environment variable or system property.");
|
||||
return;
|
||||
}
|
||||
botInstance.start(token);
|
||||
}
|
||||
|
||||
public void sendMessageToChannels(List<Long> channelIds, String message) {
|
||||
ensureStarted();
|
||||
if (botInstance.getJda() == null) {
|
||||
log.error("JDA not initialized; cannot send Discord message.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!botInstance.isReady()) {
|
||||
botInstance.getJda().awaitReady();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error while waiting for JDA ready state", e);
|
||||
}
|
||||
|
||||
channelIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(id -> {
|
||||
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
|
||||
if (channel == null) {
|
||||
log.warn("TextChannel with id {} not found", id);
|
||||
return;
|
||||
}
|
||||
channel.sendMessage(message).queue(
|
||||
success -> log.debug("Sent message to channel {}", id),
|
||||
error -> log.error("Failed sending message to channel {}", id, error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public void sendEmbedToChannels(List<Long> channelIds, String title, String description, List<EmbedField> fields,
|
||||
Integer colorRgb, Instant timestamp, String footer) {
|
||||
ensureStarted();
|
||||
if (botInstance.getJda() == null) {
|
||||
log.error("JDA not initialized; cannot send Discord embed.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!botInstance.isReady()) {
|
||||
botInstance.getJda().awaitReady();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
} catch (Exception e) {
|
||||
log.warn("Error while waiting for JDA ready state", e);
|
||||
}
|
||||
|
||||
EmbedBuilder eb = new EmbedBuilder();
|
||||
if (title != null && !title.isBlank()) eb.setTitle(title);
|
||||
if (description != null && !description.isBlank()) eb.setDescription(description);
|
||||
if (colorRgb != null) eb.setColor(new Color(colorRgb)); else eb.setColor(new Color(0xFF8C00)); // default orange
|
||||
eb.setTimestamp(timestamp != null ? timestamp : Instant.now());
|
||||
if (footer != null && !footer.isBlank()) eb.setFooter(footer);
|
||||
|
||||
if (fields != null) {
|
||||
for (EmbedField f : fields) {
|
||||
if (f == null) continue;
|
||||
String name = f.getName() == null ? "" : f.getName();
|
||||
String value = f.getValue() == null ? "" : f.getValue();
|
||||
// JDA field value max is 1024; truncate to be safe
|
||||
if (value.length() > 1024) value = value.substring(0, 1021) + "...";
|
||||
eb.addField(new MessageEmbed.Field(name, value, f.isInline()));
|
||||
}
|
||||
}
|
||||
|
||||
MessageEmbed embed = eb.build();
|
||||
|
||||
channelIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.forEach(id -> {
|
||||
TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id);
|
||||
if (channel == null) {
|
||||
log.warn("TextChannel with id {} not found", id);
|
||||
return;
|
||||
}
|
||||
channel.sendMessageEmbeds(embed).queue(
|
||||
success -> log.debug("Sent embed to channel {}", id),
|
||||
error -> log.error("Failed sending embed to channel {}", id, error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class EmbedField {
|
||||
private String name;
|
||||
private String value;
|
||||
private boolean inline;
|
||||
}
|
||||
}
|
||||
+40
-13
@@ -15,11 +15,12 @@
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:browser",
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": "dist",
|
||||
"outputPath": {
|
||||
"base": "dist"
|
||||
},
|
||||
"index": "src/index.html",
|
||||
"main": "src/main.ts",
|
||||
"polyfills": [
|
||||
"zone.js"
|
||||
],
|
||||
@@ -34,7 +35,8 @@
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
"scripts": [],
|
||||
"browser": "src/main.ts"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -47,9 +49,7 @@
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true
|
||||
"namedChunks": false
|
||||
},
|
||||
"development": {
|
||||
"sourceMap": true,
|
||||
@@ -71,14 +71,15 @@
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"vendorChunk": false,
|
||||
"buildOptimizer": true
|
||||
"namedChunks": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
@@ -90,10 +91,10 @@
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
@@ -119,5 +120,31 @@
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"type": "component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"type": "directive"
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"type": "service"
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-26
@@ -8,8 +8,9 @@ plugins {
|
||||
|
||||
node {
|
||||
download.set(true)
|
||||
version.set("22.14.0")
|
||||
npmVersion.set("10.9.2")
|
||||
// Update to the version that's compatible with your environment requirements
|
||||
version.set("20.19.0")
|
||||
npmVersion.set("10.2.3") // A compatible npm version for Node.js 20.19.0
|
||||
workDir.set(file("${project.projectDir}/node"))
|
||||
npmWorkDir.set(file("${project.projectDir}/node"))
|
||||
}
|
||||
@@ -21,38 +22,37 @@ tasks.register<Delete>("cleanDist") {
|
||||
}
|
||||
|
||||
// Create a task that will run npm build
|
||||
tasks.register("npmBuild") {
|
||||
tasks.register<com.github.gradle.node.npm.task.NpmTask>("npmBuild") {
|
||||
description = "Run 'npm run build'"
|
||||
group = "build"
|
||||
|
||||
doLast {
|
||||
// Use nodeCommand directly from the plugin
|
||||
project.exec {
|
||||
workingDir(project.projectDir)
|
||||
|
||||
// Use node's npm to ensure it works on all environments
|
||||
val nodeDir = "${project.projectDir}/node"
|
||||
// Determine which build script to run based on the OS
|
||||
val isWindows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
|
||||
if (isWindows) {
|
||||
val npmCmd = file(nodeDir).listFiles()?.find { it.name.startsWith("npm") && it.isDirectory }?.let {
|
||||
"${it.absolutePath}/npm.cmd"
|
||||
} ?: "$nodeDir/node_modules/npm/bin/npm.cmd"
|
||||
|
||||
commandLine(npmCmd, "run", "build:dev")
|
||||
} else {
|
||||
val npmExecutable = file(nodeDir).listFiles()?.find { it.name.startsWith("npm") && it.isDirectory }?.let {
|
||||
"${it.absolutePath}/bin/npm"
|
||||
} ?: "$nodeDir/node_modules/npm/bin/npm"
|
||||
|
||||
commandLine(npmExecutable, "run", "build:beta")
|
||||
}
|
||||
}
|
||||
}
|
||||
npmCommand.set(listOf("run", if (isWindows) "build:dev" else "build:beta"))
|
||||
|
||||
dependsOn("npmInstall")
|
||||
}
|
||||
|
||||
// Add a new task to check Node.js and npm versions
|
||||
tasks.register<com.github.gradle.node.task.NodeTask>("nodeVersionCheck") {
|
||||
description = "Check Node.js and npm versions"
|
||||
script.set(file("${projectDir}/node-version-check.js"))
|
||||
|
||||
doFirst {
|
||||
// Create a temporary script to check versions
|
||||
file("${projectDir}/node-version-check.js").writeText("""
|
||||
console.log('Node.js version:', process.version);
|
||||
console.log('npm version:', require('npm/package.json').version);
|
||||
console.log('Build command that would be used:', process.platform === 'win32' ? 'build:dev' : 'build:beta');
|
||||
""".trimIndent())
|
||||
}
|
||||
|
||||
doLast {
|
||||
// Clean up the temporary script
|
||||
delete("${projectDir}/node-version-check.js")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("assemble") {
|
||||
dependsOn("npmBuild")
|
||||
}
|
||||
|
||||
+15
-14
@@ -13,26 +13,27 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^19.2.18",
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/material": "^19.2.18",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"@angular/cdk": "^20.1.3",
|
||||
"@angular/common": "^20.1.0",
|
||||
"@angular/compiler": "^20.1.0",
|
||||
"@angular/core": "^20.1.0",
|
||||
"@angular/forms": "^20.1.0",
|
||||
"@angular/material": "^20.1.3",
|
||||
"@angular/platform-browser": "^20.1.0",
|
||||
"@angular/platform-browser-dynamic": "^20.1.0",
|
||||
"@angular/router": "^20.1.0",
|
||||
"@auth0/angular-jwt": "^5.2.0",
|
||||
"@types/three": "^0.177.0",
|
||||
"ngx-cookie-service": "^19.1.2",
|
||||
"ngx-cookie-service": "^20.0.1",
|
||||
"rxjs": "~7.8.0",
|
||||
"three": "^0.177.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.5",
|
||||
"@angular/cli": "^19.2.5",
|
||||
"@angular/compiler-cli": "^19.2.0",
|
||||
"@angular/build": "^20.1.0",
|
||||
"@angular/cli": "^20.1.0",
|
||||
"@angular/compiler-cli": "^20.1.0",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.6.0",
|
||||
"karma": "~6.4.0",
|
||||
@@ -40,6 +41,6 @@
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.7.2"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8080",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {Meta, Title} from '@angular/platform-browser';
|
||||
import {ALTITUDE_VERSION} from './constant';
|
||||
import {ALTITUDE_VERSION} from '@custom-types/constant';
|
||||
import {Router, RouterOutlet} from '@angular/router';
|
||||
import {FooterComponent} from './footer/footer.component';
|
||||
import {FooterComponent} from '@pages/footer/footer/footer.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -10,8 +10,8 @@ import {FooterComponent} from './footer/footer.component';
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
imports: [
|
||||
FooterComponent,
|
||||
RouterOutlet
|
||||
RouterOutlet,
|
||||
FooterComponent
|
||||
]
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import {ApplicationConfig, provideZoneChangeDetection} from '@angular/core';
|
||||
import {provideRouter} from '@angular/router';
|
||||
|
||||
import {routes} from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideZoneChangeDetection({eventCoalescing: true}), provideRouter(routes)]
|
||||
};
|
||||
@@ -1,116 +1,149 @@
|
||||
import {Routes} from '@angular/router';
|
||||
import {AuthGuard} from './guards/auth.guard';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
|
||||
loadComponent: () => import('./pages/home/home.component').then(m => m.HomeComponent)
|
||||
},
|
||||
{
|
||||
path: 'particles',
|
||||
loadComponent: () => import('./particles/particles.component').then(m => m.ParticlesComponent)
|
||||
loadComponent: () => import('./pages/particles/particles.component').then(m => m.ParticlesComponent),
|
||||
canActivate: [AuthGuard],
|
||||
data: {
|
||||
requiredAuthorizations: ['SCOPE_head_mod']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
loadComponent: () => import('./map/map.component').then(m => m.MapComponent)
|
||||
loadComponent: () => import('./pages/features/map/map.component').then(m => m.MapComponent)
|
||||
},
|
||||
{
|
||||
path: 'rules',
|
||||
loadComponent: () => import('./rules/rules.component').then(m => m.RulesComponent)
|
||||
loadComponent: () => import('./pages/reference/rules/rules.component').then(m => m.RulesComponent)
|
||||
},
|
||||
{
|
||||
path: 'vote',
|
||||
loadComponent: () => import('./vote/vote.component').then(m => m.VoteComponent)
|
||||
loadComponent: () => import('./pages/vote/vote.component').then(m => m.VoteComponent)
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
loadComponent: () => import('./about/about.component').then(m => m.AboutComponent)
|
||||
loadComponent: () => import('./pages/altitude/about/about.component').then(m => m.AboutComponent)
|
||||
},
|
||||
{
|
||||
path: 'socials',
|
||||
loadComponent: () => import('./socials/socials.component').then(m => m.SocialsComponent)
|
||||
loadComponent: () => import('./pages/altitude/socials/socials.component').then(m => m.SocialsComponent)
|
||||
},
|
||||
{
|
||||
path: 'team',
|
||||
loadComponent: () => import('./team/team.component').then(m => m.TeamComponent)
|
||||
loadComponent: () => import('./pages/altitude/team/team.component').then(m => m.TeamComponent)
|
||||
},
|
||||
{
|
||||
path: 'birthdays',
|
||||
loadComponent: () => import('./birthdays/birthdays.component').then(m => m.BirthdaysComponent)
|
||||
loadComponent: () => import('./pages/altitude/birthdays/birthdays.component').then(m => m.BirthdaysComponent)
|
||||
},
|
||||
{
|
||||
path: 'terms',
|
||||
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent)
|
||||
loadComponent: () => import('./pages/footer/terms/terms.component').then(m => m.TermsComponent)
|
||||
},
|
||||
{
|
||||
path: 'privacy',
|
||||
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent)
|
||||
loadComponent: () => import('./pages/footer/privacy/privacy.component').then(m => m.PrivacyComponent)
|
||||
},
|
||||
{
|
||||
path: 'bans',
|
||||
loadComponent: () => import('./bans/bans.component').then(m => m.BansComponent)
|
||||
loadComponent: () => import('./pages/reference/bans/bans.component').then(m => m.BansComponent)
|
||||
},
|
||||
{
|
||||
path: 'bans/:type/:id',
|
||||
loadComponent: () => import('./bans/details/details.component').then(m => m.DetailsComponent)
|
||||
loadComponent: () => import('./pages/reference/bans/details/details.component').then(m => m.DetailsComponent)
|
||||
},
|
||||
{
|
||||
path: 'economy',
|
||||
loadComponent: () => import('./economy/economy.component').then(m => m.EconomyComponent)
|
||||
loadComponent: () => import('./pages/features/economy/economy.component').then(m => m.EconomyComponent)
|
||||
},
|
||||
{
|
||||
path: 'claiming',
|
||||
loadComponent: () => import('./claiming/claiming.component').then(m => m.ClaimingComponent)
|
||||
loadComponent: () => import('./pages/features/claiming/claiming.component').then(m => m.ClaimingComponent)
|
||||
},
|
||||
{
|
||||
path: 'mypet',
|
||||
loadComponent: () => import('./mypet/mypet.component').then(m => m.MypetComponent)
|
||||
loadComponent: () => import('./pages/features/mypet/mypet.component').then(m => m.MypetComponent)
|
||||
},
|
||||
{
|
||||
path: 'warps',
|
||||
loadComponent: () => import('./warps/warps.component').then(m => m.WarpsComponent)
|
||||
loadComponent: () => import('./pages/features/warps/warps.component').then(m => m.WarpsComponent)
|
||||
},
|
||||
{
|
||||
path: 'skyblock',
|
||||
loadComponent: () => import('./skyblock/skyblock.component').then(m => m.SkyblockComponent)
|
||||
loadComponent: () => import('./pages/features/skyblock/skyblock.component').then(m => m.SkyblockComponent)
|
||||
},
|
||||
{
|
||||
path: 'customfeatures',
|
||||
loadComponent: () => import('./customfeatures/customfeatures.component').then(m => m.CustomfeaturesComponent)
|
||||
loadComponent: () => import('./pages/features/customfeatures/customfeatures.component').then(m => m.CustomfeaturesComponent)
|
||||
},
|
||||
{
|
||||
path: 'guide',
|
||||
loadComponent: () => import('./guide/guide.component').then(m => m.GuideComponent)
|
||||
loadComponent: () => import('./pages/reference/guide/guide.component').then(m => m.GuideComponent)
|
||||
},
|
||||
{
|
||||
path: 'ranks',
|
||||
loadComponent: () => import('./ranks/ranks.component').then(m => m.RanksComponent)
|
||||
loadComponent: () => import('./pages/reference/ranks/ranks.component').then(m => m.RanksComponent)
|
||||
},
|
||||
{
|
||||
path: 'commandlist',
|
||||
loadComponent: () => import('./commandlist/commandlist.component').then(m => m.CommandlistComponent)
|
||||
loadComponent: () => import('./pages/reference/commandlist/commandlist.component').then(m => m.CommandlistComponent)
|
||||
},
|
||||
{
|
||||
path: 'mapart',
|
||||
loadComponent: () => import('./mapart/mapart.component').then(m => m.MapartComponent)
|
||||
loadComponent: () => import('./pages/reference/mapart/mapart.component').then(m => m.MapartComponent)
|
||||
},
|
||||
{
|
||||
path: 'lag',
|
||||
loadComponent: () => import('./lag/lag.component').then(m => m.LagComponent)
|
||||
loadComponent: () => import('./pages/reference/lag/lag.component').then(m => m.LagComponent)
|
||||
},
|
||||
{
|
||||
path: 'staffpowers',
|
||||
loadComponent: () => import('./staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
|
||||
},
|
||||
{
|
||||
path: 'forms/:form',
|
||||
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
|
||||
loadComponent: () => import('./pages/reference/staffpowers/staffpowers.component').then(m => m.StaffpowersComponent)
|
||||
},
|
||||
{
|
||||
path: 'forms',
|
||||
loadComponent: () => import('./forms/forms.component').then(m => m.FormsComponent)
|
||||
loadComponent: () => import('./pages/forms/forms.component').then(m => m.FormsComponent)
|
||||
},
|
||||
{
|
||||
path: 'particles',
|
||||
loadComponent: () => import('./particles/particles.component').then(m => m.ParticlesComponent)
|
||||
path: 'forms/appeal',
|
||||
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
|
||||
canActivate: [AuthGuard],
|
||||
data: {
|
||||
requiredAuthorizations: ['SCOPE_user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forms/sent',
|
||||
loadComponent: () => import('./pages/forms/sent/sent.component').then(m => m.SentComponent),
|
||||
canActivate: [AuthGuard],
|
||||
data: {
|
||||
requiredAuthorizations: ['SCOPE_user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'forms/staff-application',
|
||||
loadComponent: () => import('./pages/forms/staff-application/staff-application.component').then(m => m.StaffApplicationComponent),
|
||||
canActivate: [AuthGuard],
|
||||
data: {
|
||||
requiredAuthorizations: ['SCOPE_user']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'community',
|
||||
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)
|
||||
},
|
||||
{
|
||||
path: 'nicknames',
|
||||
loadComponent: () => import('./pages/reference/nicknames/nicknames.component').then(m => m.NicknamesComponent)
|
||||
},
|
||||
{
|
||||
path: 'nickgenerator',
|
||||
loadComponent: () => import('./pages/reference/nickgenerator/nickgenerator.component').then(m => m.NickgeneratorComponent)
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<ng-container>
|
||||
<app-header [current_page]="'bans'" height="200px" background_image="/public/img/backgrounds/staff.png"
|
||||
[overlay_gradient]="0.5">>
|
||||
<div class="title" header-content>
|
||||
<h1>Minecraft Punishments</h1>
|
||||
</div>
|
||||
</app-header>
|
||||
|
||||
<main>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container">
|
||||
<div class="columnSection">
|
||||
<div class="historyButtonContainer">
|
||||
<div [id]="getCurrentButtonId('all')" class="button-outer" (click)="changeHistoryPunishment('all')">
|
||||
<span class="button-inner"
|
||||
[ngClass]="active">All</span>
|
||||
</div>
|
||||
<div [id]="getCurrentButtonId('ban')" class="button-outer" (click)="changeHistoryPunishment('ban')">
|
||||
<span class="button-inner"
|
||||
[ngClass]="active">Bans</span>
|
||||
</div>
|
||||
<div [id]="getCurrentButtonId('mute')" class="button-outer" (click)="changeHistoryPunishment('mute')">
|
||||
<span class="button-inner"
|
||||
[ngClass]="active">Mutes</span>
|
||||
</div>
|
||||
<div [id]="getCurrentButtonId('warn')" class="button-outer" (click)="changeHistoryPunishment('warn')">
|
||||
<span class="button-inner"
|
||||
[ngClass]="active">Warnings</span>
|
||||
</div>
|
||||
<div [id]="getCurrentUserTypeButtonId('player')" class="button-outer" (click)="changeUserType('player')"
|
||||
style="margin-left: 120px;">
|
||||
<span class="button-inner"
|
||||
[ngClass]="active">Player</span>
|
||||
</div>
|
||||
<div [id]="getCurrentUserTypeButtonId('staff')" class="button-outer" (click)="changeUserType('staff')">
|
||||
<span class="button-inner"
|
||||
[ngClass]="active">Staff</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="historySearchContainer">
|
||||
<input class="historySearch"
|
||||
type="search"
|
||||
placeholder="Search.."
|
||||
[(ngModel)]="searchTerm"
|
||||
(input)="filterNames()"
|
||||
(keyup.enter)="search()"
|
||||
>
|
||||
<div class="dropdown-results" *ngIf="filteredNames.length > 0 && searchTerm">
|
||||
<div
|
||||
class="dropdown-item"
|
||||
*ngFor="let name of filteredNames"
|
||||
(mousedown)="selectName(name)">
|
||||
{{ name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="historyTable">
|
||||
<app-history [userType]="userType" [punishmentType]="punishmentType"
|
||||
[page]="page" [searchTerm]="finalSearchTerm" (pageChange)="updatePageSize($event)"
|
||||
(selectItem)="setSearch($event)">
|
||||
</app-history>
|
||||
</div>
|
||||
<div class="changePageButtons">
|
||||
<button [ngClass]="{'active': buttonActive(0), 'disabled': !buttonActive(0)}"
|
||||
[disabled]="!buttonActive(0)"
|
||||
(click)="setPage(0)" class="historyPageButton">
|
||||
First page
|
||||
</button>
|
||||
<button [ngClass]="{'active': buttonActive(0), 'disabled': !buttonActive(0)}"
|
||||
[disabled]="!buttonActive(0)"
|
||||
(click)="previousPage()" class="historyPageButton">
|
||||
Previous page
|
||||
</button>
|
||||
<span class="pageNumber">{{ this.page }} / {{ getMaxPage() }}</span>
|
||||
<button [ngClass]="{'active': buttonActive(getMaxPage()), 'disabled': !buttonActive(getMaxPage())}"
|
||||
[disabled]="!buttonActive(getMaxPage())"
|
||||
(click)="nextPage()" class="historyPageButton">
|
||||
Next page
|
||||
</button>
|
||||
<button [ngClass]="{'active': buttonActive(getMaxPage()), 'disabled': !buttonActive(getMaxPage())}"
|
||||
[disabled]="!buttonActive(getMaxPage())"
|
||||
(click)="setPage(getMaxPage())" class="historyPageButton">
|
||||
Last page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</ng-container>
|
||||
@@ -1,109 +0,0 @@
|
||||
<ng-container>
|
||||
<app-header [current_page]="'bans'" height="200px" background_image="/public/img/backgrounds/staff.png"
|
||||
[overlay_gradient]="0.5">>
|
||||
<div class="title" header-content>
|
||||
<h1>Minecraft Punishments</h1>
|
||||
</div>
|
||||
</app-header>
|
||||
|
||||
<main>
|
||||
<section class="darkmodeSection">
|
||||
<section class="columnSection">
|
||||
<div class="detailsBackButton">
|
||||
<ng-container *ngIf="punishment === undefined">
|
||||
<p>Loading...</p>
|
||||
</ng-container>
|
||||
|
||||
<a [routerLink]="['/bans']">< Back</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="columnSection center">
|
||||
<ng-container *ngIf="punishment">
|
||||
<div>
|
||||
<span class="tag tagInfo"
|
||||
[ngClass]="{
|
||||
'tagPermanent': this.historyFormat.isPermanent(punishment),
|
||||
'tagExpired': !this.historyFormat.isPermanent(punishment)
|
||||
}">
|
||||
{{ this.historyFormat.getType(punishment) }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="tag tagInfo"
|
||||
[ngClass]="{
|
||||
'tagActive': this.historyFormat.isActive(punishment),
|
||||
'tagInactive': !this.historyFormat.isActive(punishment)
|
||||
}">
|
||||
{{ this.historyFormat.isActive(punishment) ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
</section>
|
||||
<section class="columnSection">
|
||||
<div class="columnContainer">
|
||||
<div class="columnParagraph">
|
||||
<ng-container *ngIf="punishment">
|
||||
<div class="playerContainer">
|
||||
<h2>Player</h2>
|
||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(punishment.uuid, '150')"
|
||||
width="150"
|
||||
height="150"
|
||||
alt="{{punishment.username}}'s Minecraft skin"
|
||||
>
|
||||
<h3 class="detailsUsername">{{ punishment.username }}</h3>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columnContainer">
|
||||
<div class="columnParagraph">
|
||||
<ng-container *ngIf="punishment">
|
||||
<div class="playerContainer">
|
||||
<h2>Moderator</h2>
|
||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(punishment.punishedByUuid, '150')"
|
||||
width="150"
|
||||
height="150"
|
||||
alt="{{punishment.punishedBy}}'s Minecraft skin"
|
||||
>
|
||||
<h3 class="detailsUsername">{{ punishment.punishedBy }}</h3>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columnContainer">
|
||||
<div class="columnParagraph">
|
||||
<ng-container *ngIf="punishment">
|
||||
<div class="detailsInfo">
|
||||
<h2>Reason</h2>
|
||||
<p>{{ punishment.reason | removeTrailingPeriod }}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columnContainer">
|
||||
<div class="columnParagraph">
|
||||
<ng-container *ngIf="punishment">
|
||||
<div class="detailsInfo">
|
||||
<h2>Date</h2>
|
||||
<p>{{ this.historyFormat.getPunishmentTime(punishment) }}</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<section class="columnSection">
|
||||
<ng-container *ngIf="punishment">
|
||||
<span>Expires</span>
|
||||
<span>{{ this.historyFormat.getExpiredTime(punishment) }}</span>
|
||||
<ng-container *ngIf="punishment.removedBy !== undefined && punishment.removedBy.length > 0">
|
||||
<span>Un{{ this.historyFormat.getType(punishment).toLocaleLowerCase() }} reason</span>
|
||||
<span>{{ punishment.removedReason == null ? 'No reason specified' : punishment.removedReason }}</span>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</section>
|
||||
@@ -1,53 +0,0 @@
|
||||
<ng-container *ngIf="history.length === 0">
|
||||
<p>No history found</p>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="history.length > 0">
|
||||
<table [cellSpacing]="0">
|
||||
<div class="historyTableHead">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="historyType">Type</th>
|
||||
<th class="historyPlayer">Player</th>
|
||||
<th class="historyPlayer">Banned By</th>
|
||||
<th class="historyReason">Reason</th>
|
||||
<th class="historyDate">Date</th>
|
||||
<th class="historyDate">Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</div>
|
||||
<div>
|
||||
<tbody>
|
||||
<tr class="historyPlayerRow" *ngFor="let entry of history">
|
||||
<td class="historyType" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getType(entry) }}
|
||||
</td>
|
||||
<td class="historyPlayer" (click)="setSearch(entry.username, 'player')">
|
||||
<div class="playerContainer">
|
||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.uuid)" width="25" height="25"
|
||||
alt="{{entry.username}}'s Minecraft skin">
|
||||
<span class="username">{{ entry.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="historyPlayer" (click)="setSearch(entry.punishedBy, 'staff')">
|
||||
<div class="playerContainer">
|
||||
<img class="avatar" [ngSrc]="this.historyFormat.getAvatarUrl(entry.punishedByUuid)" width="25" height="25"
|
||||
alt="{{entry.punishedBy}}'s Minecraft skin">
|
||||
<span>{{ entry.punishedBy }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="historyReason" (click)="showDetailedPunishment(entry)">
|
||||
{{ entry.reason | removeTrailingPeriod }}
|
||||
</td>
|
||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getPunishmentTime(entry) }}
|
||||
</td>
|
||||
<td class="historyDate" (click)="showDetailedPunishment(entry)">
|
||||
{{ this.historyFormat.getExpiredTime(entry) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</div>
|
||||
</table>
|
||||
</ng-container>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<app-forms [currentPage]="'appeal'" [formTitle]="'Minecraft Appeal'">
|
||||
<div form-content>
|
||||
|
||||
</div>
|
||||
</app-forms>
|
||||
@@ -1,69 +0,0 @@
|
||||
import {Component, OnInit} from '@angular/core';
|
||||
import {FormsComponent} from '../forms.component';
|
||||
import {FormControl, FormGroup, Validators} from '@angular/forms';
|
||||
import {AppealsService, MinecraftAppeal} from '../../../api';
|
||||
|
||||
@Component({
|
||||
selector: 'app-appeal',
|
||||
imports: [
|
||||
FormsComponent
|
||||
],
|
||||
templateUrl: './appeal.component.html',
|
||||
styleUrl: './appeal.component.scss'
|
||||
})
|
||||
export class AppealComponent implements OnInit {
|
||||
|
||||
public form: FormGroup<Appeal>;
|
||||
|
||||
constructor(private appealApi: AppealsService) {
|
||||
this.form = new FormGroup({
|
||||
username: new FormControl('', {nonNullable: true, validators: [Validators.required]}),
|
||||
punishmentId: new FormControl('', {nonNullable: true, validators: [Validators.required]}),
|
||||
email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}),
|
||||
appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]})
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
}
|
||||
|
||||
public onSubmit() {
|
||||
if (this.form === undefined) {
|
||||
console.error('Form is undefined');
|
||||
return
|
||||
}
|
||||
if (this.form.valid) {
|
||||
this.sendForm()
|
||||
} else {
|
||||
// Mark all fields as touched to trigger validation display
|
||||
Object.keys(this.form.controls).forEach(field => {
|
||||
const control = this.form!.get(field);
|
||||
if (!(control instanceof FormGroup)) {
|
||||
console.error('Control [' + control + '] is not a FormGroup');
|
||||
return;
|
||||
}
|
||||
control.markAsTouched({onlySelf: true});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sendForm() {
|
||||
const rawValue = this.form.getRawValue();
|
||||
const appeal: MinecraftAppeal = {
|
||||
appeal: rawValue.appeal,
|
||||
email: rawValue.email,
|
||||
punishmentId: parseInt(rawValue.punishmentId),
|
||||
username: rawValue.username,
|
||||
uuid: ''//TODO
|
||||
}
|
||||
this.appealApi.submitMinecraftAppeal(appeal).subscribe()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface Appeal {
|
||||
username: FormControl<string>;
|
||||
punishmentId: FormControl<string>;
|
||||
email: FormControl<string>;
|
||||
appeal: FormControl<string>;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<ng-container>
|
||||
<app-header [current_page]="currentPage" height="200px" background_image="/public/img/backgrounds/staff.png"
|
||||
[overlay_gradient]="0.5">
|
||||
<div class="title" header-content>
|
||||
<h1>{{ formTitle }}</h1>
|
||||
</div>
|
||||
</app-header>
|
||||
<ng-container *ngIf="!type">
|
||||
<ng-container *ngFor="let formType of FormType | keyvalue">
|
||||
<button mat-raised-button (click)="setFormType(formType.value)">
|
||||
{{ formType }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<div>
|
||||
<ng-content select="[form-content]"></ng-content>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -1,68 +0,0 @@
|
||||
import {Component, Input, OnInit} from '@angular/core';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {MatDialog} from '@angular/material/dialog';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
import {LoginDialogComponent} from '../login/login.component';
|
||||
import {KeyValuePipe, NgForOf, NgIf} from '@angular/common';
|
||||
import {FormType} from './form_type';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {AuthService} from '../services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-forms',
|
||||
imports: [
|
||||
HeaderComponent,
|
||||
NgIf,
|
||||
NgForOf,
|
||||
MatButton,
|
||||
KeyValuePipe
|
||||
],
|
||||
templateUrl: './forms.component.html',
|
||||
styleUrl: './forms.component.scss'
|
||||
})
|
||||
export class FormsComponent implements OnInit {
|
||||
@Input() formTitle: string = 'Form';
|
||||
@Input() currentPage: string = 'forms';
|
||||
|
||||
public type: FormType | undefined;
|
||||
|
||||
constructor(private authService: AuthService,
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
) {
|
||||
this.route.paramMap.subscribe(async params => {
|
||||
const code = params.get('code');
|
||||
|
||||
if (code) {
|
||||
this.authService.login(code).subscribe();
|
||||
} else if (!this.authService.checkAuthStatus()) {
|
||||
const dialogRef = this.dialog.open(LoginDialogComponent, {
|
||||
width: '400px',
|
||||
disableClose: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.route.paramMap.subscribe(params => {
|
||||
switch (params.get('form')) {
|
||||
case FormType.APPEAL:
|
||||
this.type = FormType.APPEAL;
|
||||
this.currentPage = 'appeal';
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid type");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly FormType = FormType;
|
||||
protected readonly Object = Object;
|
||||
|
||||
public setFormType(formType: FormType) {
|
||||
this.type = formType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree} from '@angular/router';
|
||||
import {Observable} from 'rxjs';
|
||||
import {AuthService} from '@services/auth.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthGuard implements CanActivate {
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private router: Router
|
||||
) {
|
||||
}
|
||||
|
||||
canActivate(
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
|
||||
if (!this.authService.checkAuthStatus()) {
|
||||
return this.router.createUrlTree(['/']);
|
||||
}
|
||||
|
||||
const requiredAuthorizations = route.data['requiredAuthorizations'] as string[];
|
||||
|
||||
if (!requiredAuthorizations || requiredAuthorizations.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userAuthorizations = this.authService.getUserAuthorizations();
|
||||
const hasAccess = requiredAuthorizations.some(auth => userAuthorizations.includes(auth));
|
||||
|
||||
if (!hasAccess) {
|
||||
return this.router.createUrlTree(['/']);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
<ng-container>
|
||||
<app-header [current_page]="'home'" height="100vh"
|
||||
[background_image]="'/public/img/backgrounds/120spawn-min.png'">
|
||||
<div class="title" header-content>
|
||||
<h1 style="display: none;">Altitude</h1>
|
||||
<img id="header-img" ngSrc="/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="319"
|
||||
width="550">
|
||||
<h2 style="font-size: 2.5em;" id="homeh2">Altitude now on {{ ALTITUDE_VERSION }}!</h2>
|
||||
<a id="scroll-button" (click)="scrollToSection()">
|
||||
<span></span>
|
||||
<p style="display: none;">Scroll Down</p>
|
||||
</a>
|
||||
</div>
|
||||
</app-header>
|
||||
<main>
|
||||
<section id="scrollingpoint" style="background: #202020; text-align: center; padding: 80px 0;">
|
||||
<!-- TODO load player count from old api or backend?-->
|
||||
<h2 style="color: white;"><span class="player-count">Loading...</span></h2>
|
||||
<h2 style="color: white;">Server IP: play.alttd.com</h2>
|
||||
<div style="padding-top: 35px;">
|
||||
<app-copy-ip></app-copy-ip>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container">
|
||||
<div class="paragraph">
|
||||
<h2>Adventure Begins</h2>
|
||||
<p>You awake in a strange town, where are you? There are residents running about trading with each other and
|
||||
stories of distant realms with more towns. It's time to write your story. Welcome to Altitude, the laid-back
|
||||
community-oriented server that hosts your home for Minecraft.</p>
|
||||
</div>
|
||||
<img ngSrc="/public/img/items/bookquill.png" style="width: 150px; align-self: center; margin: 0 auto;"
|
||||
alt="Alternative Altitude Server Logo"
|
||||
height="150" width="150">
|
||||
</div>
|
||||
</section>
|
||||
<!--
|
||||
<section id="section1">
|
||||
<div class="container" id="video">
|
||||
<h2 style="display: none;">YouTube Trailer</h2>
|
||||
<div style="border-radius:5px;overflow:hidden;position:relative;width:100%">
|
||||
<img style="width: 100%" src="https://img.youtube.com/vi/Nzbj9Dbv5Wk/maxresdefault.jpg" alt="Altitude YouTube Trailer">
|
||||
<div id="youtube">
|
||||
<div class="play" onclick="playVideo()"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
-->
|
||||
<section class="darkmodeSection">
|
||||
<div class="container" style="padding: 10px 0 80px 0">
|
||||
<iframe id="discord-widget" src="https://discordapp.com/widget?id=141644560005595136&theme=dark"></iframe>
|
||||
<div class="paragraph" id="discordp">
|
||||
<h2>Meet the Community</h2>
|
||||
<p>Altitude is home to players young and old from all around the globe, and here, everyone is family. Altitude
|
||||
is your place to get together with friends and relax - and maybe enjoy some survival too. Altitude is
|
||||
intended for older players, but all are welcome!</p>
|
||||
<p>Don't have Discord? Keep up with news and announcements at the <a href="alttd.com/blog">blog</a>.</p>
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<a target="_blank" rel="noopener" href="https://discordapp.com/invite/TGqpzCJ">
|
||||
<div style="margin-top: 30px;" class="button-outer">
|
||||
<span class="button-inner">Join Our Discord</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="section2">
|
||||
<div class="customContainer">
|
||||
<h2 style="color: white; padding-bottom: 35px; font-size: 2.8em;">Survival Shaped by You</h2>
|
||||
<p style="color: white; padding-bottom: 35px; font-size: 1.1em; margin: auto;">Altitude is built by the
|
||||
community, for the community. We've added features requested by our members and several custom plugins to
|
||||
create our "perfect" survival experience.</p>
|
||||
<div class="survivalShapedContainerPlugins">
|
||||
<div class="pluginColumn">
|
||||
<h2>McMMO & MyPet</h2>
|
||||
<p>Two of the most requested plugins on Altitude, level up yourself and your pet with these MMO-based
|
||||
plugins!</p>
|
||||
<a [routerLink]="['/mypet']">
|
||||
<div class="button-outer">
|
||||
<span class="button-inner">MyPet Info</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="pluginColumn">
|
||||
<h2>Dynamic map</h2>
|
||||
<p>See the world and the players around it in real-time! The map shows the entire survival world with claims
|
||||
and warps.</p>
|
||||
<a [routerLink]="['/map']">
|
||||
<div class="button-outer">
|
||||
<span class="button-inner">Visit Map</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="pluginColumn">
|
||||
<h2>Player Shops</h2>
|
||||
<p>Our economy is built on player shops, encouraging player interaction and putting the control in your
|
||||
hands.</p>
|
||||
<a [routerLink]="['/economy']">
|
||||
<div class="button-outer">
|
||||
<span class="button-inner">Shop Guide</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="removemobile darkmodeSection">
|
||||
<div class="customContainer">
|
||||
<h2 style="padding-bottom: 35px; font-size: 2.8em;">Community Builds</h2>
|
||||
<p style="padding-bottom: 35px; font-size: 1.1em; margin: auto;">Thank you to our brilliant players for sharing
|
||||
their impressive builds. Take a look at some of them here!<br>
|
||||
If you know of any builds that should be featured, please let us know!</p>
|
||||
<div class="sliderWrapper">
|
||||
<div class="sliderContent">
|
||||
<div class="indexSlider">
|
||||
<div id="go-left" (click)="previousSlide()" class="circleBehind goLeft"></div>
|
||||
<div id="go-right" (click)="nextSlide()" class="circleBehind goRight"></div>
|
||||
<div class="display">
|
||||
<span class="slide"
|
||||
[style.background-image]="'url(' + slide + ')'"
|
||||
[style.opacity]="carouselOpacity"
|
||||
[style.display]="'block'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dots">
|
||||
<ng-container *ngFor="let slide of getSlideIndices();">
|
||||
<span class="dot" (click)="setSlide(slide)" [ngClass]="getDotClass(slide)"></span>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section style="background: #202020;">
|
||||
<div class="customContainer">
|
||||
<h2 id="quote" style="color: white; font-family: 'minecraft-text',sans-serif; line-height: 1.3em;">"Great
|
||||
community, great people, great server all round . . . If it can bring back my love for minecraft, it could
|
||||
work wonders for you."</h2>
|
||||
<p style="color: white; margin-top:30px;">- /u/seanhanley1993</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="customContainer">
|
||||
<img ngSrc="/public/img/logos/log.png" alt="Alternative Altitude Server Logo" height="129"
|
||||
width="120">
|
||||
<h2 style="margin: 20px 0; font-size: 1.8em; padding-bottom: 0 !important;">play.alttd.com</h2>
|
||||
<app-copy-ip></app-copy-ip>
|
||||
</div>
|
||||
</section>
|
||||
<a (click)="this.scrollService.scrollToTop()" class="scroll-up-button, active">
|
||||
<span></span>
|
||||
<p style="display: none;">Scroll Down</p>
|
||||
</a>
|
||||
</main>
|
||||
</ng-container>
|
||||
@@ -1,18 +0,0 @@
|
||||
<h2 mat-dialog-title>Login</h2>
|
||||
<div mat-dialog-content>
|
||||
<form [formGroup]="loginForm">
|
||||
<mat-form-field appearance="fill" style="width: 100%">
|
||||
<mat-label>Enter your code</mat-label>
|
||||
<input matInput formControlName="code" type="text">
|
||||
<mat-error *ngIf="formHasError()">
|
||||
Code is required
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</div>
|
||||
<div mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">Cancel</button>
|
||||
<button mat-flat-button color="primary" (click)="onSubmit()" [disabled]="!loginForm.valid">
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {ScrollService} from '../scroll/scroll.service';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {ScrollService} from '@services/scroll.service';
|
||||
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeaderComponent
|
||||
],
|
||||
templateUrl: './about.component.html',
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {ScrollService} from '../scroll/scroll.service';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {ScrollService} from '@services/scroll.service';
|
||||
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-birthdays',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeaderComponent
|
||||
],
|
||||
templateUrl: './birthdays.component.html',
|
||||
@@ -0,0 +1,79 @@
|
||||
<ng-container>
|
||||
<app-header [current_page]="'community'" height="460px" background_image="/public/img/backgrounds/community.jpg"
|
||||
[overlay_gradient]="0.5">
|
||||
<div class="title" header-content>
|
||||
<h1>Community</h1>
|
||||
<h2>Talented people who help Altitude in more than one way.</h2>
|
||||
</div>
|
||||
</app-header>
|
||||
|
||||
<main>
|
||||
<section class="darkmodeSection">
|
||||
<div class="customContainer">
|
||||
<h2>Current Nitro Boosters</h2>
|
||||
</div>
|
||||
</section>
|
||||
<section id="social" class="darkmodeSectionThree">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<h2 class="sectionTitle">Social Media</h2>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
|
||||
<p style="text-align: center;">We're currently not looking for more people to help manage our socials.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section id="crateTeam" class="darkmodeSection">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<h2 class="sectionTitle">Crate Team</h2>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<h2 class="sectionTitle">Event Leaders</h2>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
|
||||
<p style="text-align: center;">We're currently not looking for more Event Leaders.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<h2 class="sectionTitle">Event Team</h2>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
|
||||
<div style="flex-direction: column;">
|
||||
<p style="text-align: center;">We occasionally open applications for the event team.</p>
|
||||
<p style="text-align: center;">If you're interested in joining you simply need to keep an eye on the Discord
|
||||
announcements.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container" style="padding: 50px 0 0 0; justify-content: center;">
|
||||
<h2 class="sectionTitle">YouTubers & Streamers</h2>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: center; padding-bottom: 30px;">
|
||||
<div style="flex-direction: column;">
|
||||
<p style="text-align: center;"><a style="cursor: pointer;" id="reqButton">Show Requirements...</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="req" class="hide" style="display: flex; justify-content: center; padding-bottom: 30px;">
|
||||
<div style="flex-direction: column; justify-content: center; max-width: 800px;">
|
||||
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Requirements:</span>
|
||||
</p>
|
||||
<p style="text-align: center;">You need to have at least one recent stream/video on Altitude which we can use
|
||||
to gauge if your audience enjoys your content on Altitude.</p>
|
||||
<br>
|
||||
<p style="text-align: center;">Twitch: You need to be affiliate and get at least 5 viewers on average while
|
||||
streaming on Altitude.</p>
|
||||
<p style="text-align: center;">YouTube videos: You need at least 500 subs and have at least 200 views per
|
||||
video within a week on average for Altitude content.</p>
|
||||
<p style="text-align: center;">YouTube streamers: You need at least 500 subs and have at least 5 viewers on
|
||||
average while streaming on Altitude.</p>
|
||||
<br>
|
||||
<p style="text-align: center;"><span style="font-family: 'opensans-bold', sans-serif;">Note:</span> Before
|
||||
accepting or denying you we will watch your latest video/stream on Altitude (so keep your broadcasts public
|
||||
on twitch).</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,12 @@
|
||||
.customContainer {
|
||||
width: 80%;
|
||||
max-width: 1020px;
|
||||
margin: auto;
|
||||
padding: 80px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { CommunityComponent } from './community.component';
|
||||
|
||||
describe('CommunityComponent', () => {
|
||||
let component: CommunityComponent;
|
||||
let fixture: ComponentFixture<CommunityComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommunityComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CommunityComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {HeaderComponent} from "@header/header.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-community',
|
||||
imports: [
|
||||
HeaderComponent
|
||||
],
|
||||
templateUrl: './community.component.html',
|
||||
styleUrl: './community.component.scss'
|
||||
})
|
||||
export class CommunityComponent {
|
||||
|
||||
}
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {ScrollService} from '../scroll/scroll.service';
|
||||
import {CommonModule, NgOptimizedImage} from '@angular/common';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {ScrollService} from '@services/scroll.service';
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-socials',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeaderComponent,
|
||||
NgOptimizedImage
|
||||
],
|
||||
@@ -0,0 +1,86 @@
|
||||
<ng-container>
|
||||
<app-header [current_page]="'team'" height="450px" background_image="/public/img/backgrounds/staff.png"
|
||||
[overlay_gradient]="0.5">>
|
||||
<div class="title" header-content>
|
||||
<h1>Staffing Team</h1>
|
||||
<h2>The team that makes Altitude happen. Your owners, admins, moderators, and trainees are all working together to
|
||||
create Altitude, to create home. This is where the magic happens.</h2>
|
||||
</div>
|
||||
</app-header>
|
||||
|
||||
<main>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Management</h2>
|
||||
@for (member of getTeamMembers('owner') | async; track member) {
|
||||
<div class="member">
|
||||
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
|
||||
height="160" width="160" style="width: 160px;">
|
||||
<h2>{{ member.name }}</h2>
|
||||
<p>Owner</p>
|
||||
</div>
|
||||
}
|
||||
@for (member of getTeamMembers('manager') | async; track member) {
|
||||
<div class="member">
|
||||
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
|
||||
height="160" width="160" style="width: 160px;">
|
||||
<h2>{{ member.name }}</h2>
|
||||
<p>Manager</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Admins</h2>
|
||||
@for (member of getTeamMembers('admin') | async; track member) {
|
||||
<div class="member">
|
||||
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
|
||||
height="160" width="160" style="width: 160px;">
|
||||
<h2>{{ member.name }}</h2>
|
||||
<p>Admin</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSection">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Head Moderators</h2>
|
||||
@for (member of getTeamMembers('headmod') | async; track member) {
|
||||
<div class="member">
|
||||
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
|
||||
height="160" width="160" style="width: 160px;">
|
||||
<h2>{{ member.name }}</h2>
|
||||
<p>Head Mod</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
<section class="darkmodeSectionThree">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Moderators</h2>
|
||||
@for (member of getTeamMembers('moderator') | async; track member) {
|
||||
<div class="member">
|
||||
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
|
||||
height="160" width="160" style="width: 160px;">
|
||||
<h2>{{ member.name }}</h2>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@if ((getTeamMembers('trainee') | async)?.length ?? 0 > 0) {
|
||||
<section class="darkmodeSection">
|
||||
<div class="container teamContainer">
|
||||
<h2 class="sectionTitle">Trainees</h2>
|
||||
@for (member of getTeamMembers('trainee') | async; track member) {
|
||||
<div class="member">
|
||||
<img [ngSrc]="getAvatarUrl(member)" alt="{{member.name}}'s Minecraft skin"
|
||||
height="160" width="160" style="width: 160px;">
|
||||
<h2>{{ member.name }}</h2>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
</ng-container>
|
||||
+3
-5
@@ -1,11 +1,10 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {ScrollService} from '../scroll/scroll.service';
|
||||
import {BASE_PATH, Player, TeamService} from '../../api';
|
||||
import {ScrollService} from '@services/scroll.service';
|
||||
import {Player, TeamService} from '@api';
|
||||
import {CommonModule, NgOptimizedImage} from '@angular/common';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
import {CookieService} from 'ngx-cookie-service';
|
||||
import {map, Observable, shareReplay} from 'rxjs';
|
||||
import {environment} from '../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-team',
|
||||
@@ -17,7 +16,6 @@ import {environment} from '../../environments/environment';
|
||||
],
|
||||
providers: [
|
||||
CookieService,
|
||||
{provide: BASE_PATH, useValue: environment.apiUrl}
|
||||
],
|
||||
|
||||
templateUrl: './team.component.html',
|
||||
+2
-2
@@ -19,8 +19,8 @@
|
||||
<p>To claim land, you will need some basic tools: a golden shovel, and a stick. You can craft these items
|
||||
yourself or do <b>/claim</b> to receive them for free. The shovel is used for modifying claims and the
|
||||
stick is used for viewing claim information.</p>
|
||||
<img ngSrc="/public/img/items/shovel.png" alt="Minecraft golden shovel" style="width: 25%;" height="114"
|
||||
width="114">
|
||||
<img ngSrc="/public/img/items/shovel.png" alt="Minecraft golden shovel"
|
||||
class="shovelClaiming" style="width: 25%;" height="114" width="114">
|
||||
<p>Players start with an allowance of 500 “claim blocks” and can purchase additional “claim blocks” with
|
||||
in-game currency using <span style="font-family: 'opensans-bold', sans-serif;">/buyclaimblocks</span>. How
|
||||
many claims you can create depends on the rank you have - the amount can be found on the <a
|
||||
+7
@@ -7,3 +7,10 @@ main li {
|
||||
margin-left: 30px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 690px) {
|
||||
.shovelClaiming {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
import {NgOptimizedImage} from '@angular/common';
|
||||
import {RouterLink} from '@angular/router';
|
||||
|
||||
-1
@@ -163,7 +163,6 @@
|
||||
<div class="columnParagraph">
|
||||
<h2>Miscellaneous</h2>
|
||||
<p>Grindstones can strip shulker box & beehive NBT data</p>
|
||||
<p>Sneak click mending</p>
|
||||
<p>/iwanttobreakthisblock, required to break some natural generated blocks</p>
|
||||
<p>/sneakclickmending, allows you to mend your items by right clicking while sneaking</p>
|
||||
<p>Swift Sneak enchantment can be found in villager trades</p>
|
||||
+6
@@ -7,3 +7,9 @@ main li {
|
||||
margin-left: 30px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 690px) {
|
||||
.columnContainer {
|
||||
text-align: left !important;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {HeaderComponent} from "../header/header.component";
|
||||
import {HeaderComponent} from "@header/header.component";
|
||||
import {RouterLink} from '@angular/router';
|
||||
|
||||
@Component({
|
||||
+1
-1
@@ -104,7 +104,7 @@
|
||||
prices will become more and more extreme. You can see where these thresholds are based on your current
|
||||
points here:</p>
|
||||
<img ngSrc="/public/img/random/pointbracket.png" alt="Visualization of the point bracket."
|
||||
style="width: 100%; padding: 0 0 15px 0;"
|
||||
class="visEconomy" style="width: 100%; padding: 0 0 15px 0;"
|
||||
height="100" width="800">
|
||||
</div>
|
||||
<div class="columnParagraph">
|
||||
@@ -0,0 +1,25 @@
|
||||
main ul {
|
||||
font-family: opensans, sans-serif;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
main li {
|
||||
margin-left: 30px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 690px) {
|
||||
.title {
|
||||
height: calc(100% - 110px);
|
||||
margin-top: 100px;
|
||||
}
|
||||
|
||||
.title h2 {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.visEconomy {
|
||||
height: 50px;
|
||||
width: 357px;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
import {NgOptimizedImage} from '@angular/common';
|
||||
|
||||
@Component({
|
||||
+10
@@ -11,3 +11,13 @@
|
||||
padding-bottom: 25px;
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@media (max-width: 670px) {
|
||||
.title h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.title h2 {
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -1,11 +1,10 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeaderComponent
|
||||
],
|
||||
selector: 'app-map',
|
||||
+2
-2
@@ -24,8 +24,8 @@
|
||||
your other pets while they are in storage. To interact with your MyPet you will need to make it your
|
||||
active MyPet by doing <span style="font-family: 'opensans-bold', sans-serif;">/petswitch</span> and
|
||||
selecting the one you want to use.</p>
|
||||
<img ngSrc="/public/img/items/lead.png" alt="Minecraft lead/leash" style="width: 20%;" height="96"
|
||||
width="96">
|
||||
<img ngSrc="/public/img/items/lead.png" alt="Minecraft lead/leash"
|
||||
class="leadMyPet" style="width: 20%;" height="96" width="96">
|
||||
</div>
|
||||
<div class="columnParagraph">
|
||||
<h2>Skilltrees and Levels</h2>
|
||||
+6
@@ -13,3 +13,9 @@ main li {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 690px) {
|
||||
.leadMyPet {
|
||||
height: 70px;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
import {NgOptimizedImage} from '@angular/common';
|
||||
|
||||
@Component({
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {HeaderComponent} from '../header/header.component';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-skyblock',
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user