Major refactor

This commit is contained in:
2026-01-27 23:15:29 +01:00
parent 39bb599219
commit 1d48b7d5e7
38 changed files with 140 additions and 293 deletions

View File

@@ -131,6 +131,12 @@
<artifactId>spring-security-test</artifactId> <artifactId>spring-security-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>13.0</version>
<scope>compile</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -11,16 +11,18 @@ import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties; import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties;
import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings; import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings;
import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration @Configuration
@EnableTransactionManagement
@EnableJpaRepositories( @EnableJpaRepositories(
basePackages = "dev.kruhlmann.imgfloat.repository.audit", basePackages = "dev.kruhlmann.imgfloat.repository.audit",
entityManagerFactoryRef = "auditEntityManagerFactory", entityManagerFactoryRef = "auditEntityManagerFactory",
@@ -36,9 +38,7 @@ public class AuditLogDataSourceConfig {
@Bean @Bean
@ConfigurationProperties("imgfloat.audit.datasource.hikari") @ConfigurationProperties("imgfloat.audit.datasource.hikari")
public HikariDataSource auditDataSource( public HikariDataSource auditDataSource(@Qualifier("auditDataSourceProperties") DataSourceProperties properties) {
@Qualifier("auditDataSourceProperties") DataSourceProperties properties
) {
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build(); return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
} }

View File

@@ -27,9 +27,7 @@ import org.springframework.transaction.PlatformTransactionManager;
@Configuration @Configuration
@EnableJpaRepositories( @EnableJpaRepositories(
basePackages = "dev.kruhlmann.imgfloat.repository", basePackages = "dev.kruhlmann.imgfloat.repository",
excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = AuditLogRepository.class), excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = AuditLogRepository.class)
entityManagerFactoryRef = "entityManagerFactory",
transactionManagerRef = "transactionManager"
) )
public class PrimaryDataSourceConfig { public class PrimaryDataSourceConfig {

View File

@@ -24,7 +24,6 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
private static final String TABLE_NAME = "oauth2_authorized_client"; private static final String TABLE_NAME = "oauth2_authorized_client";
private final JdbcOperations jdbcOperations; private final JdbcOperations jdbcOperations;
private final ClientRegistrationRepository clientRegistrationRepository;
private final RowMapper<OAuth2AuthorizedClient> rowMapper; private final RowMapper<OAuth2AuthorizedClient> rowMapper;
private final OAuthTokenCipher tokenCipher; private final OAuthTokenCipher tokenCipher;
@@ -41,7 +40,6 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
OAuthTokenCipher tokenCipher OAuthTokenCipher tokenCipher
) { ) {
this.jdbcOperations = jdbcOperations; this.jdbcOperations = jdbcOperations;
this.clientRegistrationRepository = clientRegistrationRepository;
this.tokenCipher = tokenCipher; this.tokenCipher = tokenCipher;
this.rowMapper = (rs, rowNum) -> { this.rowMapper = (rs, rowNum) -> {
String registrationId = rs.getString("client_registration_id"); String registrationId = rs.getString("client_registration_id");

View File

@@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotNull;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -154,11 +155,12 @@ public class SecurityConfig {
@Bean @Bean
OncePerRequestFilter csrfTokenCookieFilter() { OncePerRequestFilter csrfTokenCookieFilter() {
return new OncePerRequestFilter() { return new OncePerRequestFilter() {
@NotNull
@Override @Override
protected void doFilterInternal( protected void doFilterInternal(
HttpServletRequest request, @NotNull HttpServletRequest request,
HttpServletResponse response, @NotNull HttpServletResponse response,
FilterChain filterChain @NotNull FilterChain filterChain
) throws java.io.IOException, jakarta.servlet.ServletException { ) throws java.io.IOException, jakarta.servlet.ServletException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf"); CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf");
if (csrfToken == null) { if (csrfToken == null) {

View File

@@ -52,9 +52,6 @@ public class SystemEnvironmentValidator {
@Value("${IMGFLOAT_GITHUB_CLIENT_VERSION:#{null}}") @Value("${IMGFLOAT_GITHUB_CLIENT_VERSION:#{null}}")
private String githubClientVersion; private String githubClientVersion;
private long maxUploadBytes;
private long maxRequestBytes;
public SystemEnvironmentValidator(Environment environment) { public SystemEnvironmentValidator(Environment environment) {
this.environment = environment; this.environment = environment;
} }
@@ -72,8 +69,8 @@ public class SystemEnvironmentValidator {
StringBuilder missing = new StringBuilder(); StringBuilder missing = new StringBuilder();
maxUploadBytes = DataSize.parse(springMaxFileSize).toBytes(); long maxUploadBytes = DataSize.parse(springMaxFileSize).toBytes();
maxRequestBytes = DataSize.parse(springMaxRequestSize).toBytes(); long maxRequestBytes = DataSize.parse(springMaxRequestSize).toBytes();
checkUnsignedNumeric(maxUploadBytes, "SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE", missing); checkUnsignedNumeric(maxUploadBytes, "SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE", missing);
checkUnsignedNumeric(maxRequestBytes, "SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE", missing); checkUnsignedNumeric(maxRequestBytes, "SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE", missing);
checkString(twitchClientId, "TWITCH_CLIENT_ID", missing); checkString(twitchClientId, "TWITCH_CLIENT_ID", missing);
@@ -107,7 +104,7 @@ public class SystemEnvironmentValidator {
} }
private void checkString(String value, String name, StringBuilder missing) { private void checkString(String value, String name, StringBuilder missing) {
if (value != null && StringUtils.hasText(value)) { if (StringUtils.hasText(value)) {
return; return;
} }
missing.append(" - ").append(name).append("\n"); missing.append(" - ").append(name).append("\n");
@@ -121,7 +118,7 @@ public class SystemEnvironmentValidator {
} }
private String redact(String value) { private String redact(String value) {
if (value != null && StringUtils.hasText(value)) { if (StringUtils.hasText(value)) {
return "**************"; return "**************";
} }
return "<not set>"; return "<not set>";

View File

@@ -27,6 +27,7 @@ final class TwitchAuthorizationCodeGrantRequestEntityConverter
@Override @Override
public @Nullable RequestEntity<?> convert(@Nullable OAuth2AuthorizationCodeGrantRequest request) { public @Nullable RequestEntity<?> convert(@Nullable OAuth2AuthorizationCodeGrantRequest request) {
assert request != null;
RequestEntity<?> entity = delegate.convert(request); RequestEntity<?> entity = delegate.convert(request);
if (entity == null || !(entity.getBody() instanceof MultiValueMap<?, ?> existingBody)) { if (entity == null || !(entity.getBody() instanceof MultiValueMap<?, ?> existingBody)) {
return entity; return entity;

View File

@@ -3,6 +3,8 @@ package dev.kruhlmann.imgfloat.config;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.client.ClientHttpResponse; import org.springframework.http.client.ClientHttpResponse;
@@ -58,26 +60,15 @@ class TwitchOAuth2ErrorResponseErrorHandler extends OAuth2ErrorResponseErrorHand
return new OAuth2AuthorizationException(oauth2Error, ex); return new OAuth2AuthorizationException(oauth2Error, ex);
} }
private static final class CachedBodyClientHttpResponse implements ClientHttpResponse { private record CachedBodyClientHttpResponse(ClientHttpResponse delegate, byte[] body) implements ClientHttpResponse {
private final ClientHttpResponse delegate;
private final byte[] body;
private CachedBodyClientHttpResponse(ClientHttpResponse delegate, byte[] body) {
this.delegate = delegate;
this.body = body;
}
@NotNull
@Override @Override
public org.springframework.http.HttpStatusCode getStatusCode() throws IOException { public org.springframework.http.HttpStatusCode getStatusCode() throws IOException {
return delegate.getStatusCode(); return delegate.getStatusCode();
} }
@Override @NotNull
public int getRawStatusCode() throws IOException {
return delegate.getRawStatusCode();
}
@Override @Override
public String getStatusText() throws IOException { public String getStatusText() throws IOException {
return delegate.getStatusText(); return delegate.getStatusText();
@@ -88,11 +79,13 @@ class TwitchOAuth2ErrorResponseErrorHandler extends OAuth2ErrorResponseErrorHand
delegate.close(); delegate.close();
} }
@NotNull
@Override @Override
public java.io.InputStream getBody() throws IOException { public java.io.InputStream getBody() {
return new ByteArrayInputStream(body); return new ByteArrayInputStream(body);
} }
@NotNull
@Override @Override
public org.springframework.http.HttpHeaders getHeaders() { public org.springframework.http.HttpHeaders getHeaders() {
return delegate.getHeaders(); return delegate.getHeaders();

View File

@@ -1,7 +1,6 @@
package dev.kruhlmann.imgfloat.controller; package dev.kruhlmann.imgfloat.controller;
import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.NOT_FOUND;
import dev.kruhlmann.imgfloat.model.api.request.AdminRequest; import dev.kruhlmann.imgfloat.model.api.request.AdminRequest;
@@ -32,10 +31,13 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.AbstractOAuth2Token;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -83,10 +85,10 @@ public class ChannelApiController {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
String logBroadcaster = LogSanitizer.sanitize(broadcaster); String logBroadcaster = LogSanitizer.sanitize(broadcaster);
String logSessionUsername = LogSanitizer.sanitize(sessionUsername); String logSessionUsername = LogSanitizer.sanitize(sessionUsername);
String logRequestUsername = LogSanitizer.sanitize(request.getUsername()); String logRequestUsername = LogSanitizer.sanitize(request.username());
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.info("User {} adding admin {} to {}", logSessionUsername, logRequestUsername, logBroadcaster); LOG.info("User {} adding admin {} to {}", logSessionUsername, logRequestUsername, logBroadcaster);
boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername(), sessionUsername); boolean added = channelDirectoryService.addAdmin(broadcaster, request.username(), sessionUsername);
if (!added) { if (!added) {
LOG.info("User {} already admin for {} or could not be added", logRequestUsername, logBroadcaster); LOG.info("User {} already admin for {} or could not be added", logRequestUsername, logBroadcaster);
} }
@@ -106,14 +108,14 @@ public class ChannelApiController {
LOG.debug("Listing admins for {} by {}", logBroadcaster, logSessionUsername); LOG.debug("Listing admins for {} by {}", logBroadcaster, logSessionUsername);
var channel = channelDirectoryService.getOrCreateChannel(broadcaster); var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
List<String> admins = channel.getAdmins().stream().sorted(Comparator.naturalOrder()).toList(); List<String> admins = channel.getAdmins().stream().sorted(Comparator.naturalOrder()).toList();
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request); OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, request);
String accessToken = Optional.ofNullable(authorizedClient) String accessToken = Optional.ofNullable(authorizedClient)
.map(OAuth2AuthorizedClient::getAccessToken) .map(OAuth2AuthorizedClient::getAccessToken)
.map((token) -> token.getTokenValue()) .map(AbstractOAuth2Token::getTokenValue)
.orElse(null); .orElse(null);
String clientId = Optional.ofNullable(authorizedClient) String clientId = Optional.ofNullable(authorizedClient)
.map(OAuth2AuthorizedClient::getClientRegistration) .map(OAuth2AuthorizedClient::getClientRegistration)
.map((registration) -> registration.getClientId()) .map(ClientRegistration::getClientId)
.orElse(null); .orElse(null);
return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId); return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId);
} }
@@ -130,7 +132,7 @@ public class ChannelApiController {
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.debug("Listing admin suggestions for {} by {}", logBroadcaster, logSessionUsername); LOG.debug("Listing admin suggestions for {} by {}", logBroadcaster, logSessionUsername);
var channel = channelDirectoryService.getOrCreateChannel(broadcaster); var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request); OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, request);
if (authorizedClient == null) { if (authorizedClient == null) {
LOG.warn( LOG.warn(
@@ -140,13 +142,13 @@ public class ChannelApiController {
); );
return List.of(); return List.of();
} }
String accessToken = Optional.ofNullable(authorizedClient) String accessToken = Optional.of(authorizedClient)
.map(OAuth2AuthorizedClient::getAccessToken) .map(OAuth2AuthorizedClient::getAccessToken)
.map((token) -> token.getTokenValue()) .map(AbstractOAuth2Token::getTokenValue)
.orElse(null); .orElse(null);
String clientId = Optional.ofNullable(authorizedClient) String clientId = Optional.of(authorizedClient)
.map(OAuth2AuthorizedClient::getClientRegistration) .map(OAuth2AuthorizedClient::getClientRegistration)
.map((registration) -> registration.getClientId()) .map(ClientRegistration::getClientId)
.orElse(null); .orElse(null);
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) { if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
LOG.warn( LOG.warn(
@@ -406,7 +408,7 @@ public class ChannelApiController {
.contentType(MediaType.parseMediaType(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType()))
.body(content.bytes()) .body(content.bytes())
) )
.orElseThrow(() -> createAsset404()); .orElseThrow(this::createAsset404);
} }
@GetMapping("/script-assets/{assetId}/attachments/{attachmentId}/content") @GetMapping("/script-assets/{assetId}/attachments/{attachmentId}/content")
@@ -433,7 +435,7 @@ public class ChannelApiController {
.contentType(MediaType.parseMediaType(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType()))
.body(content.bytes()) .body(content.bytes())
) )
.orElseThrow(() -> createAsset404()); .orElseThrow(this::createAsset404);
} }
@GetMapping("/assets/{assetId}/logo") @GetMapping("/assets/{assetId}/logo")
@@ -456,7 +458,7 @@ public class ChannelApiController {
.contentType(MediaType.parseMediaType(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType()))
.body(content.bytes()) .body(content.bytes())
) )
.orElseThrow(() -> createAsset404()); .orElseThrow(this::createAsset404);
} }
@GetMapping("/assets/{assetId}/preview") @GetMapping("/assets/{assetId}/preview")
@@ -475,13 +477,12 @@ public class ChannelApiController {
.contentType(MediaType.parseMediaType(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType()))
.body(content.bytes()) .body(content.bytes())
) )
.orElseThrow(() -> createAsset404()); .orElseThrow(this::createAsset404);
} }
private String contentDispositionFor(String mediaType) { private String contentDispositionFor(String mediaType) {
if ( if (
mediaType != null && dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)
dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)
) { ) {
return "inline"; return "inline";
} }
@@ -616,14 +617,11 @@ public class ChannelApiController {
} }
private OAuth2AuthorizedClient resolveAuthorizedClient( private OAuth2AuthorizedClient resolveAuthorizedClient(
OAuth2AuthenticationToken oauthToken, @Nullable OAuth2AuthenticationToken oauthToken,
OAuth2AuthorizedClient authorizedClient,
HttpServletRequest request HttpServletRequest request
) { ) {
if (authorizedClient != null) {
return authorizedClient;
}
if (oauthToken == null) { if (oauthToken == null) {
LOG.error("Attempt to resolve authorized client without oauth token");
return null; return null;
} }
OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient( OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient(

View File

@@ -78,14 +78,14 @@ public class ScriptMarketplaceController {
) { ) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
request.getTargetBroadcaster(), request.targetBroadcaster(),
sessionUsername sessionUsername
); );
String logScriptId = LogSanitizer.sanitize(scriptId); String logScriptId = LogSanitizer.sanitize(scriptId);
String logTarget = LogSanitizer.sanitize(request.getTargetBroadcaster()); String logTarget = LogSanitizer.sanitize(request.targetBroadcaster());
LOG.info("Importing marketplace script {} into {}", logScriptId, logTarget); LOG.info("Importing marketplace script {} into {}", logScriptId, logTarget);
return channelDirectoryService return channelDirectoryService
.importMarketplaceScript(request.getTargetBroadcaster(), scriptId, sessionUsername) .importMarketplaceScript(request.targetBroadcaster(), scriptId, sessionUsername)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script")); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script"));
} }

View File

@@ -39,7 +39,7 @@ public class SettingsApiController {
authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername); authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername);
Settings currentSettings = settingsService.get(); Settings currentSettings = settingsService.get();
LOG.info("Sytem administrator settings change request"); LOG.info("System administrator settings change request");
settingsService.logSettings("From: ", currentSettings); settingsService.logSettings("From: ", currentSettings);
settingsService.logSettings("To: ", newSettings); settingsService.logSettings("To: ", newSettings);

View File

@@ -83,14 +83,6 @@ public class ViewController {
return "index"; return "index";
} }
@org.springframework.web.bind.annotation.GetMapping("/channels")
public String channelDirectory(Model model) {
LOG.info("Rendering channel directory");
addStagingAttribute(model);
addVersionAttributes(model);
return "channels";
}
@org.springframework.web.bind.annotation.GetMapping("/terms") @org.springframework.web.bind.annotation.GetMapping("/terms")
public String termsOfUse(Model model) { public String termsOfUse(Model model) {
LOG.info("Rendering terms of use"); LOG.info("Rendering terms of use");

View File

@@ -2,16 +2,4 @@ package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
public class AdminRequest { public record AdminRequest(@NotBlank String username) {}
@NotBlank
private String username;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}

View File

@@ -5,12 +5,10 @@ import jakarta.validation.constraints.Positive;
public class CanvasSettingsRequest { public class CanvasSettingsRequest {
@Positive @Positive
private double width; private final double width;
@Positive @Positive
private double height; private final double height;
public CanvasSettingsRequest() {}
public CanvasSettingsRequest(double width, double height) { public CanvasSettingsRequest(double width, double height) {
this.width = width; this.width = width;
@@ -21,15 +19,8 @@ public class CanvasSettingsRequest {
return width; return width;
} }
public void setWidth(double width) {
this.width = width;
}
public double getHeight() { public double getHeight() {
return height; return height;
} }
public void setHeight(double height) {
this.height = height;
}
} }

View File

@@ -6,8 +6,6 @@ public class ChannelScriptSettingsRequest {
private boolean allowSevenTvEmotesForAssets = true; private boolean allowSevenTvEmotesForAssets = true;
private boolean allowScriptChatAccess = true; private boolean allowScriptChatAccess = true;
public ChannelScriptSettingsRequest() {}
public ChannelScriptSettingsRequest( public ChannelScriptSettingsRequest(
boolean allowChannelEmotesForAssets, boolean allowChannelEmotesForAssets,
boolean allowSevenTvEmotesForAssets, boolean allowSevenTvEmotesForAssets,
@@ -22,23 +20,12 @@ public class ChannelScriptSettingsRequest {
return allowChannelEmotesForAssets; return allowChannelEmotesForAssets;
} }
public void setAllowChannelEmotesForAssets(boolean allowChannelEmotesForAssets) {
this.allowChannelEmotesForAssets = allowChannelEmotesForAssets;
}
public boolean isAllowSevenTvEmotesForAssets() { public boolean isAllowSevenTvEmotesForAssets() {
return allowSevenTvEmotesForAssets; return allowSevenTvEmotesForAssets;
} }
public void setAllowSevenTvEmotesForAssets(boolean allowSevenTvEmotesForAssets) {
this.allowSevenTvEmotesForAssets = allowSevenTvEmotesForAssets;
}
public boolean isAllowScriptChatAccess() { public boolean isAllowScriptChatAccess() {
return allowScriptChatAccess; return allowScriptChatAccess;
} }
public void setAllowScriptChatAccess(boolean allowScriptChatAccess) {
this.allowScriptChatAccess = allowScriptChatAccess;
}
} }

View File

@@ -1,5 +1,6 @@
package dev.kruhlmann.imgfloat.model.api.request; package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.annotation.Nullable;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
public class CodeAssetRequest { public class CodeAssetRequest {
@@ -12,6 +13,7 @@ public class CodeAssetRequest {
private String description; private String description;
@Nullable
private Boolean isPublic; private Boolean isPublic;
private java.util.List<String> allowedDomains; private java.util.List<String> allowedDomains;
@@ -40,14 +42,11 @@ public class CodeAssetRequest {
this.description = description; this.description = description;
} }
@Nullable
public Boolean getIsPublic() { public Boolean getIsPublic() {
return isPublic; return isPublic;
} }
public void setIsPublic(Boolean isPublic) {
this.isPublic = isPublic;
}
public java.util.List<String> getAllowedDomains() { public java.util.List<String> getAllowedDomains() {
return allowedDomains; return allowedDomains;
} }
@@ -55,4 +54,8 @@ public class CodeAssetRequest {
public void setAllowedDomains(java.util.List<String> allowedDomains) { public void setAllowedDomains(java.util.List<String> allowedDomains) {
this.allowedDomains = allowedDomains; this.allowedDomains = allowedDomains;
} }
public void setPublic(@Nullable Boolean aPublic) {
isPublic = aPublic;
}
} }

View File

@@ -2,16 +2,4 @@ package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
public class ScriptMarketplaceImportRequest { public record ScriptMarketplaceImportRequest(@NotBlank String targetBroadcaster) { }
@NotBlank
private String targetBroadcaster;
public String getTargetBroadcaster() {
return targetBroadcaster;
}
public void setTargetBroadcaster(String targetBroadcaster) {
this.targetBroadcaster = targetBroadcaster;
}
}

View File

@@ -112,34 +112,18 @@ public class TransformRequest {
return audioLoop; return audioLoop;
} }
public void setAudioLoop(Boolean audioLoop) {
this.audioLoop = audioLoop;
}
public Integer getAudioDelayMillis() { public Integer getAudioDelayMillis() {
return audioDelayMillis; return audioDelayMillis;
} }
public void setAudioDelayMillis(Integer audioDelayMillis) {
this.audioDelayMillis = audioDelayMillis;
}
public Double getAudioSpeed() { public Double getAudioSpeed() {
return audioSpeed; return audioSpeed;
} }
public void setAudioSpeed(Double audioSpeed) {
this.audioSpeed = audioSpeed;
}
public Double getAudioPitch() { public Double getAudioPitch() {
return audioPitch; return audioPitch;
} }
public void setAudioPitch(Double audioPitch) {
this.audioPitch = audioPitch;
}
public Double getAudioVolume() { public Double getAudioVolume() {
return audioVolume; return audioVolume;
} }

View File

@@ -19,15 +19,4 @@ public class CanvasEvent {
return event; return event;
} }
public Type getType() {
return type;
}
public String getChannel() {
return channel;
}
public CanvasSettingsRequest getPayload() {
return payload;
}
} }

View File

@@ -83,26 +83,14 @@ public class Asset {
return assetType == null ? AssetType.OTHER : assetType; return assetType == null ? AssetType.OTHER : assetType;
} }
public void setAssetType(AssetType assetType) {
this.assetType = assetType == null ? AssetType.OTHER : assetType;
}
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() { public Instant getUpdatedAt() {
return updatedAt; return updatedAt;
} }
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
public Integer getDisplayOrder() { public Integer getDisplayOrder() {
return displayOrder; return displayOrder;
} }

View File

@@ -15,7 +15,6 @@ import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
@Entity @Entity
@Table(name = "channels") @Table(name = "channels")
@@ -27,7 +26,7 @@ public class Channel {
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "channel_admins", joinColumns = @JoinColumn(name = "channel_id")) @CollectionTable(name = "channel_admins", joinColumns = @JoinColumn(name = "channel_id"))
@Column(name = "admin_username") @Column(name = "admin_username")
private Set<String> admins = new HashSet<>(); private final Set<String> admins = new HashSet<>();
private double canvasWidth = 1920; private double canvasWidth = 1920;
@@ -52,8 +51,6 @@ public class Channel {
public Channel(String broadcaster) { public Channel(String broadcaster) {
this.broadcaster = normalize(broadcaster); this.broadcaster = normalize(broadcaster);
this.canvasWidth = 1920;
this.canvasHeight = 1080;
} }
public String getBroadcaster() { public String getBroadcaster() {
@@ -112,24 +109,6 @@ public class Channel {
this.allowScriptChatAccess = allowScriptChatAccess; this.allowScriptChatAccess = allowScriptChatAccess;
} }
@PrePersist
@PreUpdate
public void normalizeFields() {
Instant now = Instant.now();
if (createdAt == null) {
createdAt = now;
}
updatedAt = now;
this.broadcaster = normalize(broadcaster);
this.admins = admins.stream().map(Channel::normalize).collect(Collectors.toSet());
if (canvasWidth <= 0) {
canvasWidth = 1920;
}
if (canvasHeight <= 0) {
canvasHeight = 1080;
}
}
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; return createdAt;
} }
@@ -138,6 +117,22 @@ public class Channel {
return updatedAt; return updatedAt;
} }
@PrePersist
private void ensureCreatedAt() {
Instant now = Instant.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
private void touchUpdatedAt() {
updatedAt = Instant.now();
}
private static String normalize(String value) { private static String normalize(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT); return value == null ? null : value.toLowerCase(Locale.ROOT);
} }

View File

@@ -30,10 +30,6 @@ public class MarketplaceScriptHeart {
return scriptId; return scriptId;
} }
public void setScriptId(String scriptId) {
this.scriptId = scriptId;
}
public String getUsername() { public String getUsername() {
return username; return username;
} }

View File

@@ -1,7 +1,6 @@
package dev.kruhlmann.imgfloat.repository.audit; package dev.kruhlmann.imgfloat.repository.audit;
import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry; import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry;
import java.util.List;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
@@ -11,7 +10,6 @@ import org.springframework.stereotype.Repository;
@Repository @Repository
public interface AuditLogRepository extends JpaRepository<AuditLogEntry, String> { public interface AuditLogRepository extends JpaRepository<AuditLogEntry, String> {
List<AuditLogEntry> findTop200ByBroadcasterOrderByCreatedAtDesc(String broadcaster);
@Query( @Query(
""" """

View File

@@ -2,7 +2,6 @@ package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset; import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset; import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetAttachment;
import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.AssetRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository; import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository; import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;

View File

@@ -73,7 +73,7 @@ public class AssetStorageService {
StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE StandardOpenOption.WRITE
); );
logger.info("Wrote asset to {}", file); logger.info("Wrote asset preview to {}", file);
} }
public Optional<AssetContent> loadAssetFile(String broadcaster, String assetId, String mediaType) { public Optional<AssetContent> loadAssetFile(String broadcaster, String assetId, String mediaType) {

View File

@@ -1,10 +1,8 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry; import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry;
import dev.kruhlmann.imgfloat.model.api.response.AuditLogEntryView;
import dev.kruhlmann.imgfloat.repository.audit.AuditLogRepository; import dev.kruhlmann.imgfloat.repository.audit.AuditLogRepository;
import dev.kruhlmann.imgfloat.util.LogSanitizer; import dev.kruhlmann.imgfloat.util.LogSanitizer;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@@ -54,18 +52,6 @@ public class AuditLogService {
} }
} }
public List<AuditLogEntryView> listEntries(String broadcaster) {
String normalizedBroadcaster = normalize(broadcaster);
if (normalizedBroadcaster == null || normalizedBroadcaster.isBlank()) {
return List.of();
}
return auditLogRepository
.findTop200ByBroadcasterOrderByCreatedAtDesc(normalizedBroadcaster)
.stream()
.map(AuditLogEntryView::fromEntry)
.toList();
}
public Page<AuditLogEntry> listEntries( public Page<AuditLogEntry> listEntries(
String broadcaster, String broadcaster,
String actor, String actor,

View File

@@ -581,14 +581,14 @@ public class ChannelDirectoryService {
} }
@Transactional @Transactional
public Optional<AssetView> clearScriptLogo(String broadcaster, String assetId, String actor) { public void clearScriptLogo(String broadcaster, String assetId, String actor) {
Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId); Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId);
ScriptAsset script = scriptAssetRepository ScriptAsset script = scriptAssetRepository
.findById(asset.getId()) .findById(asset.getId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
String previousLogoFileId = script.getLogoFileId(); String previousLogoFileId = script.getLogoFileId();
if (previousLogoFileId == null) { if (previousLogoFileId == null) {
return Optional.empty(); return;
} }
script.setLogoFileId(null); script.setLogoFileId(null);
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
@@ -602,7 +602,6 @@ public class ChannelDirectoryService {
"SCRIPT_LOGO_CLEARED", "SCRIPT_LOGO_CLEARED",
"Cleared script logo for " + script.getName() + " (" + asset.getId() + ")" "Cleared script logo for " + script.getName() + " (" + asset.getId() + ")"
); );
return Optional.of(view);
} }
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query, String sessionUsername) { public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query, String sessionUsername) {
@@ -804,7 +803,7 @@ public class ChannelDirectoryService {
.findById(scriptId) .findById(scriptId)
.filter(ScriptAsset::isPublic) .filter(ScriptAsset::isPublic)
.map(ScriptAsset::getLogoFileId) .map(ScriptAsset::getLogoFileId)
.flatMap((logoFileId) -> scriptAssetFileRepository.findById(logoFileId)) .flatMap(scriptAssetFileRepository::findById)
.flatMap((file) -> .flatMap((file) ->
assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType()) assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType())
); );
@@ -1065,7 +1064,7 @@ public class ChannelDirectoryService {
.findById(asset.getId()) .findById(asset.getId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
Integer beforeOrder = asset.getDisplayOrder(); Integer beforeOrder = asset.getDisplayOrder();
List<Asset> orderUpdates = List.of(); List<Asset> orderUpdates;
if (req.getOrder() != null) { if (req.getOrder() != null) {
if (req.getOrder() < 1) { if (req.getOrder() < 1) {
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1"); throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
@@ -1514,7 +1513,7 @@ public class ChannelDirectoryService {
return scriptAssetRepository return scriptAssetRepository
.findById(asset.getId()) .findById(asset.getId())
.map(ScriptAsset::getLogoFileId) .map(ScriptAsset::getLogoFileId)
.flatMap((logoFileId) -> scriptAssetFileRepository.findById(logoFileId)) .flatMap(scriptAssetFileRepository::findById)
.flatMap((file) -> .flatMap((file) ->
assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType()) assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType())
); );
@@ -1723,7 +1722,7 @@ public class ChannelDirectoryService {
return assets return assets
.stream() .stream()
.sorted( .sorted(
Comparator.comparingInt((Asset asset) -> displayOrderValue(asset)) Comparator.comparingInt(this::displayOrderValue)
.reversed() .reversed()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder())) .thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
) )
@@ -1761,7 +1760,7 @@ public class ChannelDirectoryService {
.stream() .stream()
.filter((asset) -> types.contains(asset.getAssetType())) .filter((asset) -> types.contains(asset.getAssetType()))
.sorted( .sorted(
Comparator.comparingInt((Asset asset) -> displayOrderValue(asset)) Comparator.comparingInt(this::displayOrderValue)
.reversed() .reversed()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder())) .thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
) )

View File

@@ -50,9 +50,10 @@ public class EmoteSyncScheduler implements SchedulingConfigurer {
private Trigger buildTrigger() { private Trigger buildTrigger() {
return (TriggerContext triggerContext) -> { return (TriggerContext triggerContext) -> {
Instant lastCompletion = triggerContext.lastCompletionTime() == null Instant lastCompletion = triggerContext.lastCompletion() == null
? Instant.now() ? Instant.now()
: triggerContext.lastCompletionTime().toInstant(); : triggerContext.lastCompletion();
assert lastCompletion != null;
return lastCompletion.plus(Duration.ofMinutes(resolveIntervalMinutes())); return lastCompletion.plus(Duration.ofMinutes(resolveIntervalMinutes()));
}; };
} }

View File

@@ -41,10 +41,6 @@ public class GitInfoService {
this.commitUrlPrefix = normalize(commitUrlPrefix); this.commitUrlPrefix = normalize(commitUrlPrefix);
} }
public String getCommitSha() {
return commitSha;
}
public String getShortCommitSha() { public String getShortCommitSha() {
return shortCommitSha; return shortCommitSha;
} }

View File

@@ -134,10 +134,7 @@ public class MarketplaceScriptSeedLoader {
if (!Files.isDirectory(scriptDir)) { if (!Files.isDirectory(scriptDir)) {
continue; continue;
} }
SeedScript script = loadScriptDirectory(scriptDir).orElse(null); loadScriptDirectory(scriptDir).ifPresent(loaded::add);
if (script != null) {
loaded.add(script);
}
} }
} catch (IOException ex) { } catch (IOException ex) {
logger.warn("Failed to read marketplace script directory {}", rootPath, ex); logger.warn("Failed to read marketplace script directory {}", rootPath, ex);
@@ -179,7 +176,7 @@ public class MarketplaceScriptSeedLoader {
broadcaster, broadcaster,
sourceMediaType, sourceMediaType,
logoMediaType, logoMediaType,
Optional.ofNullable(sourcePath), Optional.of(sourcePath),
Optional.ofNullable(logoPath), Optional.ofNullable(logoPath),
allowedDomains, allowedDomains,
attachments, attachments,
@@ -207,7 +204,7 @@ public class MarketplaceScriptSeedLoader {
attachments.add( attachments.add(
new SeedAttachment( new SeedAttachment(
name, name,
mediaType == null ? "application/octet-stream" : mediaType, mediaType,
attachment, attachment,
new AtomicReference<>() new AtomicReference<>()
) )
@@ -348,7 +345,7 @@ public class MarketplaceScriptSeedLoader {
} }
try { try {
String content = Files.readString(path); String content = Files.readString(path);
return JsonSupport.read(content, ScriptSeedMetadata.class); return JsonSupport.read(content);
} catch (IOException ex) { } catch (IOException ex) {
logger.warn("Failed to read marketplace metadata {}", path, ex); logger.warn("Failed to read marketplace metadata {}", path, ex);
return null; return null;
@@ -362,8 +359,8 @@ public class MarketplaceScriptSeedLoader {
private JsonSupport() {} private JsonSupport() {}
static <T> T read(String payload, Class<T> type) throws IOException { static <T> T read(String payload) throws IOException {
return mapper().readValue(payload, type); return mapper().readValue(payload, (Class<T>) ScriptSeedMetadata.class);
} }
private static com.fasterxml.jackson.databind.ObjectMapper mapper() { private static com.fasterxml.jackson.databind.ObjectMapper mapper() {

View File

@@ -42,12 +42,12 @@ public class TwitchAppAccessTokenService {
return Optional.empty(); return Optional.empty();
} }
AccessToken current = cachedToken; AccessToken current = cachedToken;
if (current != null && !current.isExpired()) { if (current != null && current.isActive()) {
return Optional.of(current.token()); return Optional.of(current.token());
} }
synchronized (this) { synchronized (this) {
AccessToken refreshed = cachedToken; AccessToken refreshed = cachedToken;
if (refreshed != null && !refreshed.isExpired()) { if (refreshed != null && refreshed.isActive()) {
return Optional.of(refreshed.token()); return Optional.of(refreshed.token());
} }
cachedToken = requestToken(); cachedToken = requestToken();
@@ -91,8 +91,8 @@ public class TwitchAppAccessTokenService {
} }
private record AccessToken(String token, Instant expiresAt) { private record AccessToken(String token, Instant expiresAt) {
boolean isExpired() { boolean isActive() {
return expiresAt == null || expiresAt.isBefore(Instant.now()); return expiresAt != null && !expiresAt.isBefore(Instant.now());
} }
} }

View File

@@ -1,14 +1,10 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -17,32 +13,17 @@ import org.springframework.stereotype.Component;
public class VersionService { public class VersionService {
private static final Logger LOG = LoggerFactory.getLogger(VersionService.class); private static final Logger LOG = LoggerFactory.getLogger(VersionService.class);
private static final Pattern PACKAGE_VERSION_PATTERN = Pattern.compile("\"version\"\\s*:\\s*\"([^\"]+)\"");
private final String serverVersion; private final String serverVersion;
private final String releaseVersion;
public VersionService() throws IOException { public VersionService() {
this.serverVersion = resolveServerVersion(); this.serverVersion = resolveServerVersion();
this.releaseVersion = normalizeReleaseVersion(serverVersion);
} }
public String getVersion() { public String getVersion() {
return serverVersion; return serverVersion;
} }
public String getReleaseVersion() {
return releaseVersion;
}
public String getReleaseTag() {
if (releaseVersion == null || releaseVersion.isBlank()) {
throw new IllegalStateException("Release version is not available");
}
String normalized = releaseVersion.startsWith("v") ? releaseVersion.substring(1) : releaseVersion;
return "v" + normalized;
}
private String resolveServerVersion() { private String resolveServerVersion() {
String pomVersion = getPomVersion(); String pomVersion = getPomVersion();
if (pomVersion != null && !pomVersion.isBlank()) { if (pomVersion != null && !pomVersion.isBlank()) {
@@ -57,16 +38,6 @@ public class VersionService {
throw new IllegalStateException("Release version is not available"); throw new IllegalStateException("Release version is not available");
} }
private String normalizeReleaseVersion(String baseVersion) throws IllegalStateException {
String normalized = baseVersion.trim();
normalized = normalized.replaceFirst("(?i)^v", "");
normalized = normalized.replaceFirst("-SNAPSHOT$", "");
if (normalized.isBlank()) {
throw new IllegalStateException("Invalid version: " + baseVersion);
}
return normalized;
}
private String getPomVersion() { private String getPomVersion() {
try ( try (
var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties") var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")

View File

@@ -1,5 +1,7 @@
package dev.kruhlmann.imgfloat.service.media; package dev.kruhlmann.imgfloat.service.media;
import jakarta.validation.constraints.NotNull;
import java.util.Arrays; import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
@@ -19,6 +21,7 @@ public record AssetContent(byte[] bytes, String mediaType) {
return result; return result;
} }
@NotNull
@Override @Override
public String toString() { public String toString() {
return "AssetContent{" + "bytes=" + Arrays.toString(bytes) + ", mediaType='" + mediaType + '\'' + '}'; return "AssetContent{" + "bytes=" + Arrays.toString(bytes) + ", mediaType='" + mediaType + '\'' + '}';

View File

@@ -42,11 +42,7 @@ public class MediaOptimizationService {
} }
} }
if (mediaType.startsWith("image/")) { if (mediaType.startsWith("image/")) {
OptimizedAsset imageAsset = optimizeImage(bytes, mediaType); return optimizeImage(bytes, mediaType);
if (imageAsset == null) {
return null;
}
return imageAsset;
} }
if (mediaType.startsWith("video/")) { if (mediaType.startsWith("video/")) {
@@ -86,7 +82,7 @@ public class MediaOptimizationService {
return "image/png".equalsIgnoreCase(mediaType) && ApngDetector.isApng(bytes); return "image/png".equalsIgnoreCase(mediaType) && ApngDetector.isApng(bytes);
} }
private OptimizedAsset optimizeApng(byte[] bytes, String mediaType) throws IOException { private OptimizedAsset optimizeApng(byte[] bytes, String mediaType) {
return ffmpegService return ffmpegService
.transcodeApngToGif(bytes) .transcodeApngToGif(bytes)
.map(this::transcodeGifToVideo) .map(this::transcodeGifToVideo)

View File

@@ -1,5 +1,7 @@
package dev.kruhlmann.imgfloat.service.media; package dev.kruhlmann.imgfloat.service.media;
import jakarta.validation.constraints.NotNull;
import java.util.Arrays; import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
@@ -26,6 +28,7 @@ public record OptimizedAsset(byte[] bytes, String mediaType, int width, int heig
return result; return result;
} }
@NotNull
@Override @Override
public String toString() { public String toString() {
return ( return (

View File

@@ -64,12 +64,7 @@ class ChannelDirectoryServiceTest {
private VisualAssetRepository visualAssetRepository; private VisualAssetRepository visualAssetRepository;
private AudioAssetRepository audioAssetRepository; private AudioAssetRepository audioAssetRepository;
private ScriptAssetRepository scriptAssetRepository; private ScriptAssetRepository scriptAssetRepository;
private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
private ScriptAssetFileRepository scriptAssetFileRepository; private ScriptAssetFileRepository scriptAssetFileRepository;
private MarketplaceScriptHeartRepository marketplaceScriptHeartRepository;
private SettingsService settingsService;
private MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
private AuditLogService auditLogService;
@BeforeEach @BeforeEach
void setup() throws Exception { void setup() throws Exception {
@@ -79,13 +74,13 @@ class ChannelDirectoryServiceTest {
visualAssetRepository = mock(VisualAssetRepository.class); visualAssetRepository = mock(VisualAssetRepository.class);
audioAssetRepository = mock(AudioAssetRepository.class); audioAssetRepository = mock(AudioAssetRepository.class);
scriptAssetRepository = mock(ScriptAssetRepository.class); scriptAssetRepository = mock(ScriptAssetRepository.class);
scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class); ScriptAssetAttachmentRepository scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class);
scriptAssetFileRepository = mock(ScriptAssetFileRepository.class); scriptAssetFileRepository = mock(ScriptAssetFileRepository.class);
marketplaceScriptHeartRepository = mock(MarketplaceScriptHeartRepository.class); MarketplaceScriptHeartRepository marketplaceScriptHeartRepository = mock(MarketplaceScriptHeartRepository.class);
auditLogService = mock(AuditLogService.class); AuditLogService auditLogService = mock(AuditLogService.class);
when(marketplaceScriptHeartRepository.countByScriptIds(any())).thenReturn(List.of()); when(marketplaceScriptHeartRepository.countByScriptIds(any())).thenReturn(List.of());
when(marketplaceScriptHeartRepository.findByUsernameAndScriptIdIn(anyString(), any())).thenReturn(List.of()); when(marketplaceScriptHeartRepository.findByUsernameAndScriptIdIn(anyString(), any())).thenReturn(List.of());
settingsService = mock(SettingsService.class); SettingsService settingsService = mock(SettingsService.class);
when(settingsService.get()).thenReturn(Settings.defaults()); when(settingsService.get()).thenReturn(Settings.defaults());
setupInMemoryPersistence(); setupInMemoryPersistence();
Path assetRoot = Files.createTempDirectory("imgfloat-assets-test"); Path assetRoot = Files.createTempDirectory("imgfloat-assets-test");
@@ -112,24 +107,24 @@ class ChannelDirectoryServiceTest {
Files.writeString(scriptRoot.resolve("source.js"), "console.log('seeded');"); Files.writeString(scriptRoot.resolve("source.js"), "console.log('seeded');");
Files.write(scriptRoot.resolve("logo.png"), samplePng()); Files.write(scriptRoot.resolve("logo.png"), samplePng());
Files.write(scriptRoot.resolve("attachments/rotate.png"), samplePng()); Files.write(scriptRoot.resolve("attachments/rotate.png"), samplePng());
marketplaceScriptSeedLoader = new MarketplaceScriptSeedLoader(marketplaceRoot.toString()); MarketplaceScriptSeedLoader marketplaceScriptSeedLoader = new MarketplaceScriptSeedLoader(marketplaceRoot.toString());
service = new ChannelDirectoryService( service = new ChannelDirectoryService(
channelRepository, channelRepository,
assetRepository, assetRepository,
visualAssetRepository, visualAssetRepository,
audioAssetRepository, audioAssetRepository,
scriptAssetRepository, scriptAssetRepository,
scriptAssetAttachmentRepository, scriptAssetAttachmentRepository,
scriptAssetFileRepository, scriptAssetFileRepository,
marketplaceScriptHeartRepository, marketplaceScriptHeartRepository,
messagingTemplate, messagingTemplate,
assetStorageService, assetStorageService,
mediaDetectionService, mediaDetectionService,
mediaOptimizationService, mediaOptimizationService,
settingsService, settingsService,
uploadLimitBytes, uploadLimitBytes,
marketplaceScriptSeedLoader, marketplaceScriptSeedLoader,
auditLogService auditLogService
); );
} }
@@ -195,9 +190,14 @@ class ChannelDirectoryServiceTest {
} }
@Test @Test
void appliesBoundaryValues() throws Exception { void appliesBoundaryValues() {
String channel = "caster"; String channel = "caster";
String id = createSampleAsset(channel); String id = null;
try {
id = createSampleAsset(channel);
} catch (Exception e) {
throw new RuntimeException(e);
}
TransformRequest transform = validTransform(); TransformRequest transform = validTransform();
transform.setSpeed(0.1); transform.setSpeed(0.1);

View File

@@ -43,21 +43,25 @@ class TwitchAuthorizationCodeGrantRequestEntityConverterTest {
.state("state") .state("state")
.build(); .build();
MultiValueMap<String, String> body = getStringStringMultiValueMap(authorizationRequest, authorizationResponse, registration);
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo("twitch-id");
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_SECRET)).isEqualTo("twitch-secret");
}
private static MultiValueMap<String, String> getStringStringMultiValueMap(OAuth2AuthorizationRequest authorizationRequest, OAuth2AuthorizationResponse authorizationResponse, ClientRegistration registration) {
OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange( OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(
authorizationRequest, authorizationRequest,
authorizationResponse authorizationResponse
); );
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest( OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(
registration, registration,
exchange exchange
); );
var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter(); var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter();
RequestEntity<?> requestEntity = converter.convert(grantRequest); RequestEntity<?> requestEntity = converter.convert(grantRequest);
MultiValueMap<String, String> body = (MultiValueMap<String, String>) requestEntity.getBody(); return (MultiValueMap<String, String>) requestEntity.getBody();
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo("twitch-id");
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_SECRET)).isEqualTo("twitch-secret");
} }
} }

View File

@@ -22,7 +22,7 @@ class TwitchOAuth2ErrorResponseErrorHandlerTest {
private final TwitchOAuth2ErrorResponseErrorHandler handler = new TwitchOAuth2ErrorResponseErrorHandler(); private final TwitchOAuth2ErrorResponseErrorHandler handler = new TwitchOAuth2ErrorResponseErrorHandler();
@Test @Test
void fallsBackToSyntheticErrorWhenErrorBodyIsMissing() throws Exception { void fallsBackToSyntheticErrorWhenErrorBodyIsMissing() {
MockClientHttpResponse response = new MockClientHttpResponse(new byte[0], HttpStatus.BAD_REQUEST); MockClientHttpResponse response = new MockClientHttpResponse(new byte[0], HttpStatus.BAD_REQUEST);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON); response.getHeaders().setContentType(MediaType.APPLICATION_JSON);