Compare commits

4 Commits

Author SHA1 Message Date
d6271b1758 Deferred emote sync 2026-01-30 08:51:55 +01:00
f9613c7c2f Add emote sync 2026-01-29 16:56:20 +01:00
1d48b7d5e7 Major refactor 2026-01-27 23:15:29 +01:00
39bb599219 Refactor models 2026-01-27 15:40:55 +01:00
82 changed files with 420 additions and 428 deletions

36
AGENTS.md Normal file
View File

@@ -0,0 +1,36 @@
# Repository Guidelines
## Project Structure & Module Organization
- Java Spring Boot service under `src/main/java` (controllers, services, repositories, models); configuration in `src/main/resources`.
- Frontend overlays and admin UI assets live in `src/main/resources/static/js` and `src/main/resources/templates` (Thymeleaf).
- Tests are in `src/test/java`; sample assets and previews are stored under `assets/` and `previews/` when running locally.
- Marketplace seed content (if used) sits at the path pointed to `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH`, each with `metadata.json`, `source.js`, optional `logo.png`, and `attachments/`.
## Build, Test, and Development Commands
- `make build` / `mvn compile`: compile the application.
- `make run`: load `.env` (if present) and start the Spring Boot app on port 8080 with required env vars.
- `make watch`: recompile on source changes (needs `entr`); restart browser manually.
- `make test` / `mvn test`: run the full test suite.
- `make package`: clean build a runnable JAR.
## Coding Style & Naming Conventions
- Java 17, Spring Boot 3.x. Follow standard Java conventions: 4-space indentation, `UpperCamelCase` for types, `lowerCamelCase` for methods/fields, constants in `UPPER_SNAKE_CASE`.
- Keep controllers thin, delegate logic to services, and favor immutable DTOs/records where possible.
- Place web assets next to related features (`static/js` modules, matching templates). Use descriptive filenames (e.g., `broadcast/renderer.js`).
- Prefer constructor injection for Spring components; avoid field injection.
## Testing Guidelines
- Use JUnit 5 with Mockito for unit tests; keep tests under `src/test/java` mirroring package paths.
- Name tests descriptively (`ClassNameTest`, method names expressing behavior).
- For changes touching overlays or asset handling, add tests for repository/service logic and handle edge cases (missing files, bad metadata).
- Run `make test` before opening a PR; add targeted integration tests when altering controllers or WebSocket flows.
## Commit & Pull Request Guidelines
- Commits: concise, present-tense summaries (`Fix script import domain validation`). Group related changes; avoid noisy churn.
- PRs: include a clear summary, linked issue (if any), test results, and screenshots/GIFs when UI changes affect admin or broadcast overlays.
- Call out any config/env changes (new required vars such as `IMGFLOAT_*`) and migration steps.
## Security & Configuration Tips
- Store secrets via environment variables (`.env` only for local dev). Required paths: `IMGFLOAT_ASSETS_PATH`, `IMGFLOAT_PREVIEWS_PATH`, `IMGFLOAT_DB_PATH`, `IMGFLOAT_AUDIT_DB_PATH`.
- When seeding marketplace scripts, ensure `metadata.json` is well-formed; attachment filenames must be unique per script.
- Keep OAuth keys and token encryption keys (`IMGFLOAT_TOKEN_ENCRYPTION_KEY`) in a secret manager for non-local environments.

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

@@ -0,0 +1,25 @@
package dev.kruhlmann.imgfloat.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class ApplicationLifecycleLogger {
private static final Logger LOG = LoggerFactory.getLogger(ApplicationLifecycleLogger.class);
private final String serverPort;
public ApplicationLifecycleLogger(@Value("${server.port:8080}") String serverPort) {
this.serverPort = serverPort;
}
@EventListener(ApplicationReadyEvent.class)
public void logReady() {
LOG.info("Imgfloat ready to accept connections on port {}", serverPort);
}
}

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();
} }
@@ -51,7 +51,7 @@ public class AuditLogDataSourceConfig {
) { ) {
return builder return builder
.dataSource(dataSource) .dataSource(dataSource)
.packages("dev.kruhlmann.imgfloat.audit.model") .packages("dev.kruhlmann.imgfloat.model.db.audit")
.properties(hibernateProperties.determineHibernateProperties(jpaProperties.getProperties(), new HibernateSettings())) .properties(hibernateProperties.determineHibernateProperties(jpaProperties.getProperties(), new HibernateSettings()))
.persistenceUnit("audit") .persistenceUnit("audit")
.build(); .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,7 @@
package dev.kruhlmann.imgfloat.controller; package dev.kruhlmann.imgfloat.controller;
import dev.kruhlmann.imgfloat.model.AuditLogEntryView; import dev.kruhlmann.imgfloat.model.api.response.AuditLogEntryView;
import dev.kruhlmann.imgfloat.model.AuditLogPageView; import dev.kruhlmann.imgfloat.model.api.response.AuditLogPageView;
import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.service.AuditLogService; import dev.kruhlmann.imgfloat.service.AuditLogService;
import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.AuthorizationService;

View File

@@ -1,20 +1,19 @@
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.AdminRequest; import dev.kruhlmann.imgfloat.model.api.request.AdminRequest;
import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.api.response.AssetView;
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.ChannelScriptSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest;
import dev.kruhlmann.imgfloat.model.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView; import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
import dev.kruhlmann.imgfloat.model.TwitchUserProfile; import dev.kruhlmann.imgfloat.model.api.response.TwitchUserProfile;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.service.TwitchUserLookupService; import dev.kruhlmann.imgfloat.service.TwitchUserLookupService;
@@ -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

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.controller; package dev.kruhlmann.imgfloat.controller;
import dev.kruhlmann.imgfloat.model.ErrorResponse; import dev.kruhlmann.imgfloat.model.api.response.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;

View File

@@ -3,10 +3,10 @@ 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.NOT_FOUND; import static org.springframework.http.HttpStatus.NOT_FOUND;
import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.api.response.AssetView;
import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry; import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceImportRequest; import dev.kruhlmann.imgfloat.model.api.request.ScriptMarketplaceImportRequest;
import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.util.LogSanitizer; import dev.kruhlmann.imgfloat.util.LogSanitizer;
@@ -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

@@ -1,7 +1,7 @@
package dev.kruhlmann.imgfloat.controller; package dev.kruhlmann.imgfloat.controller;
import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.SettingsService; import dev.kruhlmann.imgfloat.service.SettingsService;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -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

@@ -5,7 +5,7 @@ import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.service.GitInfoService; import dev.kruhlmann.imgfloat.service.GitInfoService;
@@ -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

@@ -1,17 +0,0 @@
package dev.kruhlmann.imgfloat.model;
import jakarta.validation.constraints.NotBlank;
public class AdminRequest {
@NotBlank
private String username;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}

View File

@@ -1,17 +0,0 @@
package dev.kruhlmann.imgfloat.model;
import jakarta.validation.constraints.NotBlank;
public class ScriptMarketplaceImportRequest {
@NotBlank
private String targetBroadcaster;
public String getTargetBroadcaster() {
return targetBroadcaster;
}
public void setTargetBroadcaster(String targetBroadcaster) {
this.targetBroadcaster = targetBroadcaster;
}
}

View File

@@ -0,0 +1,5 @@
package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.validation.constraints.NotBlank;
public record AdminRequest(@NotBlank String username) {}

View File

@@ -1,16 +1,14 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.validation.constraints.Positive; 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

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.request;
public class ChannelScriptSettingsRequest { public class ChannelScriptSettingsRequest {
@@ -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; 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

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.request;
public class PlaybackRequest { public class PlaybackRequest {

View File

@@ -0,0 +1,5 @@
package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.validation.constraints.NotBlank;
public record ScriptMarketplaceImportRequest(@NotBlank String targetBroadcaster) { }

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.DecimalMin;
@@ -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

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.request;
public class VisibilityRequest { public class VisibilityRequest {

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.response;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;

View File

@@ -1,9 +1,13 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.response;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
/** /**
* Represents a partial update for an {@link Asset}. Only the fields that changed * Represents a partial update for an {@link dev.kruhlmann.imgfloat.model.db.imgfloat.Asset}. Only the fields that changed
* for a given operation are populated to reduce payload sizes sent over WebSocket. * for a given operation are populated to reduce payload sizes sent over WebSocket.
*/ */
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)

View File

@@ -1,8 +1,14 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.response;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import dev.kruhlmann.imgfloat.model.AssetType;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
public record AssetView( public record AssetView(
String id, String id,
String broadcaster, String broadcaster,

View File

@@ -1,8 +1,9 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.response;
import dev.kruhlmann.imgfloat.audit.model.AuditLogEntry;
import java.time.Instant; import java.time.Instant;
import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry;
public record AuditLogEntryView(String id, String actor, String action, String details, Instant createdAt) { public record AuditLogEntryView(String id, String actor, String action, String details, Instant createdAt) {
public static AuditLogEntryView fromEntry(AuditLogEntry entry) { public static AuditLogEntryView fromEntry(AuditLogEntry entry) {
if (entry == null) { if (entry == null) {

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.response;
import java.util.List; import java.util.List;

View File

@@ -1,5 +1,6 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.response;
import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
public class CanvasEvent { public class CanvasEvent {
public enum Type { public enum Type {
@@ -18,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

@@ -1,3 +1,3 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.response;
public record ErrorResponse(int status, String message, String path) {} public record ErrorResponse(int status, String message, String path) {}

View File

@@ -1,5 +1,7 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.response;
import dev.kruhlmann.imgfloat.model.AssetType;
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetAttachment;
public record ScriptAssetAttachmentView( public record ScriptAssetAttachmentView(
String id, String id,
String scriptAssetId, String scriptAssetId,

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.response;
public record ScriptMarketplaceEntry( public record ScriptMarketplaceEntry(
String id, String id,

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.api.response;
/** /**
* Minimal Twitch user details used for rendering avatars and display names. * Minimal Twitch user details used for rendering avatars and display names.

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.audit.model; package dev.kruhlmann.imgfloat.model.db.audit;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
@@ -12,6 +12,8 @@ import java.time.Instant;
import java.util.Locale; import java.util.Locale;
import java.util.UUID; import java.util.UUID;
import dev.kruhlmann.imgfloat.model.AssetType;
@Entity @Entity
@Table(name = "assets") @Table(name = "assets")
public class Asset { public class Asset {
@@ -81,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

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import jakarta.persistence.CollectionTable; import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column; import jakarta.persistence.Column;
@@ -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

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
@@ -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,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import java.io.Serializable; import java.io.Serializable;
import java.util.Objects; import java.util.Objects;

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import jakarta.persistence.CollectionTable; import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column; import jakarta.persistence.Column;
@@ -14,6 +14,8 @@ import jakarta.persistence.Transient;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
@Entity @Entity
@Table(name = "script_assets") @Table(name = "script_assets")
public class ScriptAsset { public class ScriptAsset {

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
@@ -10,6 +10,8 @@ import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.util.UUID; import java.util.UUID;
import dev.kruhlmann.imgfloat.model.AssetType;
@Entity @Entity
@Table(name = "script_asset_attachments") @Table(name = "script_asset_attachments")
public class ScriptAssetAttachment { public class ScriptAssetAttachment {

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
@@ -11,6 +11,8 @@ import jakarta.persistence.Table;
import java.util.Locale; import java.util.Locale;
import java.util.UUID; import java.util.UUID;
import dev.kruhlmann.imgfloat.model.AssetType;
@Entity @Entity
@Table(name = "script_asset_files") @Table(name = "script_asset_files")
public class ScriptAssetFile { public class ScriptAssetFile {

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
@@ -43,6 +43,9 @@ public class Settings {
@Column(nullable = false) @Column(nullable = false)
private int emoteSyncIntervalMinutes; private int emoteSyncIntervalMinutes;
@Column(name = "last_emote_sync_at")
private Instant lastEmoteSyncAt;
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt; private Instant createdAt;
@@ -62,6 +65,7 @@ public class Settings {
s.setMaxCanvasSideLengthPixels(7680); s.setMaxCanvasSideLengthPixels(7680);
s.setCanvasFramesPerSecond(60); s.setCanvasFramesPerSecond(60);
s.setEmoteSyncIntervalMinutes(60); s.setEmoteSyncIntervalMinutes(60);
s.setLastEmoteSyncAt(null);
return s; return s;
} }
@@ -145,6 +149,14 @@ public class Settings {
this.emoteSyncIntervalMinutes = emoteSyncIntervalMinutes; this.emoteSyncIntervalMinutes = emoteSyncIntervalMinutes;
} }
public Instant getLastEmoteSyncAt() {
return lastEmoteSyncAt;
}
public void setLastEmoteSyncAt(Instant lastEmoteSyncAt) {
this.lastEmoteSyncAt = lastEmoteSyncAt;
}
@PrePersist @PrePersist
public void initializeTimestamps() { public void initializeTimestamps() {
Instant now = Instant.now(); Instant now = Instant.now();

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model.db.imgfloat;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;

View File

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.AudioAsset; import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,7 +1,7 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeart; import dev.kruhlmann.imgfloat.model.db.imgfloat.MarketplaceScriptHeart;
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeartId; import dev.kruhlmann.imgfloat.model.db.imgfloat.MarketplaceScriptHeartId;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetAttachment;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.ScriptAssetFile; import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.ScriptAsset; import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface SettingsRepository extends JpaRepository<Settings, Integer> {} public interface SettingsRepository extends JpaRepository<Settings, Integer> {}

View File

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.SystemAdministrator; import dev.kruhlmann.imgfloat.model.db.imgfloat.SystemAdministrator;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.VisualAsset; import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,7 +1,6 @@
package dev.kruhlmann.imgfloat.repository.audit; package dev.kruhlmann.imgfloat.repository.audit;
import dev.kruhlmann.imgfloat.audit.model.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

@@ -1,7 +1,7 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
import dev.kruhlmann.imgfloat.model.ScriptAssetFile; import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile;
import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.AssetRepository;
import dev.kruhlmann.imgfloat.repository.ChannelRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository;
import dev.kruhlmann.imgfloat.repository.MarketplaceScriptHeartRepository; import dev.kruhlmann.imgfloat.repository.MarketplaceScriptHeartRepository;

View File

@@ -1,8 +1,7 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
import dev.kruhlmann.imgfloat.model.ScriptAsset;
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.audit.model.AuditLogEntry; import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry;
import dev.kruhlmann.imgfloat.model.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

@@ -3,28 +3,28 @@ package dev.kruhlmann.imgfloat.service;
import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE; import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
import dev.kruhlmann.imgfloat.model.Asset;
import dev.kruhlmann.imgfloat.model.AssetEvent;
import dev.kruhlmann.imgfloat.model.AssetPatch;
import dev.kruhlmann.imgfloat.model.AssetType; import dev.kruhlmann.imgfloat.model.AssetType;
import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.AudioAsset; import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest;
import dev.kruhlmann.imgfloat.model.CanvasEvent; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
import dev.kruhlmann.imgfloat.model.ChannelScriptSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.api.response.AssetEvent;
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeart; import dev.kruhlmann.imgfloat.model.api.response.AssetPatch;
import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.api.response.AssetView;
import dev.kruhlmann.imgfloat.model.ScriptAsset; import dev.kruhlmann.imgfloat.model.api.response.CanvasEvent;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView; import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.model.ScriptAssetFile; import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry; import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.db.imgfloat.MarketplaceScriptHeart;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
import dev.kruhlmann.imgfloat.model.VisualAsset; import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetAttachment;
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.AssetRepository;
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository; import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
import dev.kruhlmann.imgfloat.repository.ChannelRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository;
@@ -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

@@ -1,7 +1,7 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
import dev.kruhlmann.imgfloat.repository.ChannelRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
@@ -50,13 +50,22 @@ public class EmoteSyncScheduler implements SchedulingConfigurer {
private Trigger buildTrigger() { private Trigger buildTrigger() {
return (TriggerContext triggerContext) -> { return (TriggerContext triggerContext) -> {
Instant lastCompletion = triggerContext.lastCompletionTime() == null int interval = resolveIntervalMinutes();
? Instant.now() Instant lastCompletion = resolveLastCompletion(triggerContext, interval);
: triggerContext.lastCompletionTime().toInstant(); return lastCompletion.plus(Duration.ofMinutes(interval));
return lastCompletion.plus(Duration.ofMinutes(resolveIntervalMinutes()));
}; };
} }
private Instant resolveLastCompletion(TriggerContext triggerContext, int intervalMinutes) {
Instant lastCompletion = triggerContext.lastCompletion();
if (lastCompletion != null) {
return lastCompletion;
}
Settings settings = settingsService.get();
Instant persisted = settings.getLastEmoteSyncAt();
return persisted != null ? persisted : Instant.now().minus(Duration.ofMinutes(intervalMinutes));
}
private int resolveIntervalMinutes() { private int resolveIntervalMinutes() {
Settings settings = settingsService.get(); Settings settings = settingsService.get();
int interval = settings.getEmoteSyncIntervalMinutes(); int interval = settings.getEmoteSyncIntervalMinutes();
@@ -66,15 +75,20 @@ public class EmoteSyncScheduler implements SchedulingConfigurer {
private void syncEmotes() { private void syncEmotes() {
int interval = resolveIntervalMinutes(); int interval = resolveIntervalMinutes();
LOG.info("Synchronizing emotes (interval {} minutes)", interval); LOG.info("Synchronizing emotes (interval {} minutes)", interval);
List<Channel> channels = List.of();
twitchEmoteService.refreshGlobalEmotes(); try {
List<Channel> channels = channelRepository.findAll(); channels = channelRepository.findAll();
for (Channel channel : channels) { twitchEmoteService.refreshGlobalEmotes();
String broadcaster = channel.getBroadcaster(); for (Channel channel : channels) {
twitchEmoteService.refreshChannelEmotes(broadcaster); String broadcaster = channel.getBroadcaster();
sevenTvEmoteService.refreshChannelEmotes(broadcaster); twitchEmoteService.refreshChannelEmotes(broadcaster);
sevenTvEmoteService.refreshChannelEmotes(broadcaster);
}
LOG.info("Completed emote sync for {} channels", channels.size());
} catch (Exception ex) {
LOG.error("Emote sync failed", ex);
} finally {
settingsService.updateLastEmoteSyncAt(Instant.now());
} }
LOG.info("Completed emote sync for {} channels", channels.size());
} }
} }

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

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry; import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.service.media.AssetContent; import dev.kruhlmann.imgfloat.service.media.AssetContent;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
@@ -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

@@ -2,13 +2,14 @@ package dev.kruhlmann.imgfloat.service;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import dev.kruhlmann.imgfloat.model.AudioAsset; import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
import dev.kruhlmann.imgfloat.model.VisualAsset; import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository; import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
import dev.kruhlmann.imgfloat.repository.SettingsRepository; import dev.kruhlmann.imgfloat.repository.SettingsRepository;
import dev.kruhlmann.imgfloat.repository.VisualAssetRepository; import dev.kruhlmann.imgfloat.repository.VisualAssetRepository;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -59,6 +60,12 @@ public class SettingsService {
return savedSettings; return savedSettings;
} }
public void updateLastEmoteSyncAt(Instant timestamp) {
Settings settings = get();
settings.setLastEmoteSyncAt(timestamp);
repo.save(settings);
}
public void logSettings(String msg, Settings settings) { public void logSettings(String msg, Settings settings) {
try { try {
logger.info("{}:\n{}", msg, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(settings)); logger.info("{}:\n{}", msg, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(settings));

View File

@@ -1,6 +1,6 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.SystemAdministrator; import dev.kruhlmann.imgfloat.model.db.imgfloat.SystemAdministrator;
import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository; import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;

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

@@ -12,7 +12,9 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -22,6 +24,8 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
@@ -41,6 +45,7 @@ public class TwitchEmoteService {
private final Map<String, CachedEmote> emoteCache = new ConcurrentHashMap<>(); private final Map<String, CachedEmote> emoteCache = new ConcurrentHashMap<>();
private final Map<String, List<CachedEmote>> channelEmoteCache = new ConcurrentHashMap<>(); private final Map<String, List<CachedEmote>> channelEmoteCache = new ConcurrentHashMap<>();
private volatile List<CachedEmote> globalEmotes = List.of(); private volatile List<CachedEmote> globalEmotes = List.of();
private final AtomicBoolean initialGlobalSyncScheduled = new AtomicBoolean();
public TwitchEmoteService( public TwitchEmoteService(
RestTemplateBuilder builder, RestTemplateBuilder builder,
@@ -61,12 +66,11 @@ public class TwitchEmoteService {
} catch (IOException ex) { } catch (IOException ex) {
throw new IllegalStateException("Failed to create Twitch emote cache directory", ex); throw new IllegalStateException("Failed to create Twitch emote cache directory", ex);
} }
warmGlobalEmotes();
} }
public List<EmoteDescriptor> getGlobalEmotes() { public List<EmoteDescriptor> getGlobalEmotes() {
if (globalEmotes.isEmpty()) { if (globalEmotes.isEmpty()) {
warmGlobalEmotes(); ensureInitialGlobalSyncScheduled();
} }
return globalEmotes.stream().map(CachedEmote::descriptor).toList(); return globalEmotes.stream().map(CachedEmote::descriptor).toList();
} }
@@ -127,6 +131,31 @@ public class TwitchEmoteService {
LOG.info("Loaded {} global Twitch emotes", cached.size()); LOG.info("Loaded {} global Twitch emotes", cached.size());
} }
private void ensureInitialGlobalSyncScheduled() {
if (initialGlobalSyncScheduled.compareAndSet(false, true)) {
LOG.info("Scheduling initial global Twitch emote sync in the background");
CompletableFuture.runAsync(this::safeWarmGlobalEmotes);
}
}
private void safeWarmGlobalEmotes() {
LOG.info("Initial global Twitch emote sync started");
try {
warmGlobalEmotes();
LOG.info(
"Initial global Twitch emote sync completed (cached {} emotes)",
globalEmotes.size()
);
} catch (Exception ex) {
LOG.warn("Initial global Twitch emote sync failed", ex);
}
}
@EventListener(ApplicationReadyEvent.class)
public void startInitialGlobalEmoteSync() {
ensureInitialGlobalSyncScheduled();
}
private List<CachedEmote> fetchChannelEmotes(String channelLogin) { private List<CachedEmote> fetchChannelEmotes(String channelLogin) {
String broadcasterId = fetchBroadcasterId(channelLogin).orElse(null); String broadcasterId = fetchBroadcasterId(channelLogin).orElse(null);
if (broadcasterId == null) { if (broadcasterId == null) {

View File

@@ -2,7 +2,7 @@ package dev.kruhlmann.imgfloat.service;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import dev.kruhlmann.imgfloat.model.TwitchUserProfile; import dev.kruhlmann.imgfloat.model.api.response.TwitchUserProfile;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;

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

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN last_emote_sync_at TIMESTAMP;

View File

@@ -12,7 +12,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;

View File

@@ -5,7 +5,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
import dev.kruhlmann.imgfloat.repository.ChannelRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;

View File

@@ -9,16 +9,16 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import dev.kruhlmann.imgfloat.model.Asset;
import dev.kruhlmann.imgfloat.model.AssetView;
import dev.kruhlmann.imgfloat.model.AudioAsset;
import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.ScriptAsset;
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.AssetType; import dev.kruhlmann.imgfloat.model.AssetType;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.api.response.AssetView;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.AssetRepository;
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository; import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
import dev.kruhlmann.imgfloat.repository.ChannelRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository;
@@ -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);
@@ -215,7 +215,7 @@ class ChannelDirectoryServiceTest {
void includesDefaultMarketplaceScript() { void includesDefaultMarketplaceScript() {
when(scriptAssetRepository.findByIsPublicTrue()).thenReturn(List.of()); when(scriptAssetRepository.findByIsPublicTrue()).thenReturn(List.of());
List<dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null, null); List<dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null, null);
assertThat(entries) assertThat(entries)
.anyMatch((entry) -> "rotating-logo".equals(entry.id())); .anyMatch((entry) -> "rotating-logo".equals(entry.id()));
@@ -229,7 +229,7 @@ class ChannelDirectoryServiceTest {
request.setAllowedDomains(List.of("example.com")); request.setAllowedDomains(List.of("example.com"));
AssetView created = service.createCodeAsset("caster", request, "caster").orElseThrow(); AssetView created = service.createCodeAsset("caster", request, "caster").orElseThrow();
scriptAssetRepository.findById(created.id()).ifPresent((script) -> script.setSourceFileId(created.id())); scriptAssetRepository.findById(created.id()).ifPresent((script) -> script.setSourceFileId(created.id()));
scriptAssetFileRepository.save(new dev.kruhlmann.imgfloat.model.ScriptAssetFile("caster", AssetType.SCRIPT) { scriptAssetFileRepository.save(new dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile("caster", AssetType.SCRIPT) {
{ {
setId(created.id()); setId(created.id());
} }
@@ -271,10 +271,10 @@ class ChannelDirectoryServiceTest {
private void setupInMemoryPersistence() { private void setupInMemoryPersistence() {
Map<String, Channel> channels = new ConcurrentHashMap<>(); Map<String, Channel> channels = new ConcurrentHashMap<>();
Map<String, Asset> assets = new ConcurrentHashMap<>(); Map<String, Asset> assets = new ConcurrentHashMap<>();
Map<String, dev.kruhlmann.imgfloat.model.VisualAsset> visualAssets = new ConcurrentHashMap<>(); Map<String, dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset> visualAssets = new ConcurrentHashMap<>();
Map<String, AudioAsset> audioAssets = new ConcurrentHashMap<>(); Map<String, AudioAsset> audioAssets = new ConcurrentHashMap<>();
Map<String, ScriptAsset> scriptAssets = new ConcurrentHashMap<>(); Map<String, ScriptAsset> scriptAssets = new ConcurrentHashMap<>();
Map<String, dev.kruhlmann.imgfloat.model.ScriptAssetFile> scriptFiles = new ConcurrentHashMap<>(); Map<String, dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile> scriptFiles = new ConcurrentHashMap<>();
when(channelRepository.findById(anyString())).thenAnswer((invocation) -> when(channelRepository.findById(anyString())).thenAnswer((invocation) ->
Optional.ofNullable(channels.get(invocation.getArgument(0))) Optional.ofNullable(channels.get(invocation.getArgument(0)))
@@ -317,9 +317,9 @@ class ChannelDirectoryServiceTest {
.when(assetRepository) .when(assetRepository)
.delete(any(Asset.class)); .delete(any(Asset.class));
when(visualAssetRepository.save(any(dev.kruhlmann.imgfloat.model.VisualAsset.class))).thenAnswer( when(visualAssetRepository.save(any(dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset.class))).thenAnswer(
(invocation) -> { (invocation) -> {
dev.kruhlmann.imgfloat.model.VisualAsset visual = invocation.getArgument(0); dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset visual = invocation.getArgument(0);
visualAssets.put(visual.getId(), visual); visualAssets.put(visual.getId(), visual);
return visual; return visual;
} }
@@ -388,9 +388,9 @@ class ChannelDirectoryServiceTest {
.when(scriptAssetRepository) .when(scriptAssetRepository)
.deleteById(anyString()); .deleteById(anyString());
when(scriptAssetFileRepository.save(any(dev.kruhlmann.imgfloat.model.ScriptAssetFile.class))).thenAnswer( when(scriptAssetFileRepository.save(any(dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile.class))).thenAnswer(
(invocation) -> { (invocation) -> {
dev.kruhlmann.imgfloat.model.ScriptAssetFile file = invocation.getArgument(0); dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile file = invocation.getArgument(0);
if (file.getId() == null) { if (file.getId() == null) {
file.setId(java.util.UUID.randomUUID().toString()); file.setId(java.util.UUID.randomUUID().toString());
} }

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);