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>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jetbrains</groupId>
<artifactId>annotations</artifactId>
<version>13.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
<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.HibernateSettings;
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.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
basePackages = "dev.kruhlmann.imgfloat.repository.audit",
entityManagerFactoryRef = "auditEntityManagerFactory",
@@ -36,9 +38,7 @@ public class AuditLogDataSourceConfig {
@Bean
@ConfigurationProperties("imgfloat.audit.datasource.hikari")
public HikariDataSource auditDataSource(
@Qualifier("auditDataSourceProperties") DataSourceProperties properties
) {
public HikariDataSource auditDataSource(@Qualifier("auditDataSourceProperties") DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
}
@@ -51,7 +51,7 @@ public class AuditLogDataSourceConfig {
) {
return builder
.dataSource(dataSource)
.packages("dev.kruhlmann.imgfloat.audit.model")
.packages("dev.kruhlmann.imgfloat.model.db.audit")
.properties(hibernateProperties.determineHibernateProperties(jpaProperties.getProperties(), new HibernateSettings()))
.persistenceUnit("audit")
.build();

View File

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

View File

@@ -24,7 +24,6 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
private static final String TABLE_NAME = "oauth2_authorized_client";
private final JdbcOperations jdbcOperations;
private final ClientRegistrationRepository clientRegistrationRepository;
private final RowMapper<OAuth2AuthorizedClient> rowMapper;
private final OAuthTokenCipher tokenCipher;
@@ -41,7 +40,6 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
OAuthTokenCipher tokenCipher
) {
this.jdbcOperations = jdbcOperations;
this.clientRegistrationRepository = clientRegistrationRepository;
this.tokenCipher = tokenCipher;
this.rowMapper = (rs, rowNum) -> {
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.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
@@ -154,11 +155,12 @@ public class SecurityConfig {
@Bean
OncePerRequestFilter csrfTokenCookieFilter() {
return new OncePerRequestFilter() {
@NotNull
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
@NotNull HttpServletRequest request,
@NotNull HttpServletResponse response,
@NotNull FilterChain filterChain
) throws java.io.IOException, jakarta.servlet.ServletException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute("_csrf");
if (csrfToken == null) {

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
package dev.kruhlmann.imgfloat.controller;
import dev.kruhlmann.imgfloat.model.AuditLogEntryView;
import dev.kruhlmann.imgfloat.model.AuditLogPageView;
import dev.kruhlmann.imgfloat.model.api.response.AuditLogEntryView;
import dev.kruhlmann.imgfloat.model.api.response.AuditLogPageView;
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.service.AuditLogService;
import dev.kruhlmann.imgfloat.service.AuthorizationService;

View File

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

View File

@@ -1,6 +1,6 @@
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 org.slf4j.Logger;
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.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.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceImportRequest;
import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.model.api.request.ScriptMarketplaceImportRequest;
import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.util.LogSanitizer;
@@ -78,14 +78,14 @@ public class ScriptMarketplaceController {
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
request.getTargetBroadcaster(),
request.targetBroadcaster(),
sessionUsername
);
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);
return channelDirectoryService
.importMarketplaceScript(request.getTargetBroadcaster(), scriptId, sessionUsername)
.importMarketplaceScript(request.targetBroadcaster(), scriptId, sessionUsername)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script"));
}

View File

@@ -1,7 +1,7 @@
package dev.kruhlmann.imgfloat.controller;
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.SettingsService;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -39,7 +39,7 @@ public class SettingsApiController {
authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername);
Settings currentSettings = settingsService.get();
LOG.info("Sytem administrator settings change request");
LOG.info("System administrator settings change request");
settingsService.logSettings("From: ", currentSettings);
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.databind.ObjectMapper;
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.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.service.GitInfoService;
@@ -83,14 +83,6 @@ public class ViewController {
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")
public String termsOfUse(Model model) {
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;
public class CanvasSettingsRequest {
@Positive
private double width;
private final double width;
@Positive
private double height;
public CanvasSettingsRequest() {}
private final double height;
public CanvasSettingsRequest(double width, double height) {
this.width = width;
@@ -21,15 +19,8 @@ public class CanvasSettingsRequest {
return width;
}
public void setWidth(double width) {
this.width = width;
}
public double getHeight() {
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 {
@@ -6,8 +6,6 @@ public class ChannelScriptSettingsRequest {
private boolean allowSevenTvEmotesForAssets = true;
private boolean allowScriptChatAccess = true;
public ChannelScriptSettingsRequest() {}
public ChannelScriptSettingsRequest(
boolean allowChannelEmotesForAssets,
boolean allowSevenTvEmotesForAssets,
@@ -22,23 +20,12 @@ public class ChannelScriptSettingsRequest {
return allowChannelEmotesForAssets;
}
public void setAllowChannelEmotesForAssets(boolean allowChannelEmotesForAssets) {
this.allowChannelEmotesForAssets = allowChannelEmotesForAssets;
}
public boolean isAllowSevenTvEmotesForAssets() {
return allowSevenTvEmotesForAssets;
}
public void setAllowSevenTvEmotesForAssets(boolean allowSevenTvEmotesForAssets) {
this.allowSevenTvEmotesForAssets = allowSevenTvEmotesForAssets;
}
public boolean isAllowScriptChatAccess() {
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;
public class CodeAssetRequest {
@@ -12,6 +13,7 @@ public class CodeAssetRequest {
private String description;
@Nullable
private Boolean isPublic;
private java.util.List<String> allowedDomains;
@@ -40,14 +42,11 @@ public class CodeAssetRequest {
this.description = description;
}
@Nullable
public Boolean getIsPublic() {
return isPublic;
}
public void setIsPublic(Boolean isPublic) {
this.isPublic = isPublic;
}
public java.util.List<String> getAllowedDomains() {
return allowedDomains;
}
@@ -55,4 +54,8 @@ public class CodeAssetRequest {
public void setAllowedDomains(java.util.List<String> 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 {

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

View File

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

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 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.
*/
@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.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(
String id,
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 dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry;
public record AuditLogEntryView(String id, String actor, String action, String details, Instant createdAt) {
public static AuditLogEntryView fromEntry(AuditLogEntry entry) {
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;

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 enum Type {
@@ -18,15 +19,4 @@ public class CanvasEvent {
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) {}

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(
String id,
String scriptAssetId,

View File

@@ -1,4 +1,4 @@
package dev.kruhlmann.imgfloat.model;
package dev.kruhlmann.imgfloat.model.api.response;
public record ScriptMarketplaceEntry(
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.

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.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.Entity;
@@ -12,6 +12,8 @@ import java.time.Instant;
import java.util.Locale;
import java.util.UUID;
import dev.kruhlmann.imgfloat.model.AssetType;
@Entity
@Table(name = "assets")
public class Asset {
@@ -81,26 +83,14 @@ public class Asset {
return assetType == null ? AssetType.OTHER : assetType;
}
public void setAssetType(AssetType assetType) {
this.assetType = assetType == null ? AssetType.OTHER : assetType;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
public Integer getDisplayOrder() {
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.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.Column;
@@ -15,7 +15,6 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
@Entity
@Table(name = "channels")
@@ -27,7 +26,7 @@ public class Channel {
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "channel_admins", joinColumns = @JoinColumn(name = "channel_id"))
@Column(name = "admin_username")
private Set<String> admins = new HashSet<>();
private final Set<String> admins = new HashSet<>();
private double canvasWidth = 1920;
@@ -52,8 +51,6 @@ public class Channel {
public Channel(String broadcaster) {
this.broadcaster = normalize(broadcaster);
this.canvasWidth = 1920;
this.canvasHeight = 1080;
}
public String getBroadcaster() {
@@ -112,24 +109,6 @@ public class Channel {
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() {
return createdAt;
}
@@ -138,6 +117,22 @@ public class Channel {
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) {
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.Entity;
@@ -30,10 +30,6 @@ public class MarketplaceScriptHeart {
return scriptId;
}
public void setScriptId(String scriptId) {
this.scriptId = scriptId;
}
public String getUsername() {
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.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.Column;
@@ -14,6 +14,8 @@ import jakarta.persistence.Transient;
import java.util.ArrayList;
import java.util.List;
import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
@Entity
@Table(name = "script_assets")
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.Entity;
@@ -10,6 +10,8 @@ import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.util.UUID;
import dev.kruhlmann.imgfloat.model.AssetType;
@Entity
@Table(name = "script_asset_attachments")
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.Entity;
@@ -11,6 +11,8 @@ import jakarta.persistence.Table;
import java.util.Locale;
import java.util.UUID;
import dev.kruhlmann.imgfloat.model.AssetType;
@Entity
@Table(name = "script_asset_files")
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.Entity;
@@ -43,6 +43,9 @@ public class Settings {
@Column(nullable = false)
private int emoteSyncIntervalMinutes;
@Column(name = "last_emote_sync_at")
private Instant lastEmoteSyncAt;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@@ -62,6 +65,7 @@ public class Settings {
s.setMaxCanvasSideLengthPixels(7680);
s.setCanvasFramesPerSecond(60);
s.setEmoteSyncIntervalMinutes(60);
s.setLastEmoteSyncAt(null);
return s;
}
@@ -145,6 +149,14 @@ public class Settings {
this.emoteSyncIntervalMinutes = emoteSyncIntervalMinutes;
}
public Instant getLastEmoteSyncAt() {
return lastEmoteSyncAt;
}
public void setLastEmoteSyncAt(Instant lastEmoteSyncAt) {
this.lastEmoteSyncAt = lastEmoteSyncAt;
}
@PrePersist
public void initializeTimestamps() {
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.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.Entity;

View File

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

View File

@@ -1,6 +1,6 @@
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.List;
import org.springframework.data.jpa.repository.JpaRepository;

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
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.List;
import org.springframework.data.jpa.repository.JpaRepository;

View File

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

View File

@@ -1,6 +1,6 @@
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.List;
import org.springframework.data.jpa.repository.JpaRepository;

View File

@@ -1,6 +1,6 @@
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;
public interface SettingsRepository extends JpaRepository<Settings, Integer> {}

View File

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

View File

@@ -1,6 +1,6 @@
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.List;
import org.springframework.data.jpa.repository.JpaRepository;

View File

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

View File

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

View File

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

View File

@@ -73,7 +73,7 @@ public class AssetStorageService {
StandardOpenOption.TRUNCATE_EXISTING,
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) {

View File

@@ -1,10 +1,8 @@
package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.audit.model.AuditLogEntry;
import dev.kruhlmann.imgfloat.model.AuditLogEntryView;
import dev.kruhlmann.imgfloat.model.db.audit.AuditLogEntry;
import dev.kruhlmann.imgfloat.repository.audit.AuditLogRepository;
import dev.kruhlmann.imgfloat.util.LogSanitizer;
import java.util.List;
import java.util.Locale;
import org.springframework.data.domain.Page;
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(
String broadcaster,
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.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.AssetView;
import dev.kruhlmann.imgfloat.model.AudioAsset;
import dev.kruhlmann.imgfloat.model.CanvasEvent;
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.ChannelScriptSettingsRequest;
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeart;
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.ScriptAsset;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView;
import dev.kruhlmann.imgfloat.model.ScriptAssetFile;
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.model.TransformRequest;
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.VisualAsset;
import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest;
import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.api.response.AssetEvent;
import dev.kruhlmann.imgfloat.model.api.response.AssetPatch;
import dev.kruhlmann.imgfloat.model.api.response.AssetView;
import dev.kruhlmann.imgfloat.model.api.response.CanvasEvent;
import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
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.MarketplaceScriptHeart;
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
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.AudioAssetRepository;
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
@@ -581,14 +581,14 @@ public class ChannelDirectoryService {
}
@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);
ScriptAsset script = scriptAssetRepository
.findById(asset.getId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
String previousLogoFileId = script.getLogoFileId();
if (previousLogoFileId == null) {
return Optional.empty();
return;
}
script.setLogoFileId(null);
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
@@ -602,7 +602,6 @@ public class ChannelDirectoryService {
"SCRIPT_LOGO_CLEARED",
"Cleared script logo for " + script.getName() + " (" + asset.getId() + ")"
);
return Optional.of(view);
}
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query, String sessionUsername) {
@@ -804,7 +803,7 @@ public class ChannelDirectoryService {
.findById(scriptId)
.filter(ScriptAsset::isPublic)
.map(ScriptAsset::getLogoFileId)
.flatMap((logoFileId) -> scriptAssetFileRepository.findById(logoFileId))
.flatMap(scriptAssetFileRepository::findById)
.flatMap((file) ->
assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType())
);
@@ -1065,7 +1064,7 @@ public class ChannelDirectoryService {
.findById(asset.getId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
Integer beforeOrder = asset.getDisplayOrder();
List<Asset> orderUpdates = List.of();
List<Asset> orderUpdates;
if (req.getOrder() != null) {
if (req.getOrder() < 1) {
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
@@ -1514,7 +1513,7 @@ public class ChannelDirectoryService {
return scriptAssetRepository
.findById(asset.getId())
.map(ScriptAsset::getLogoFileId)
.flatMap((logoFileId) -> scriptAssetFileRepository.findById(logoFileId))
.flatMap(scriptAssetFileRepository::findById)
.flatMap((file) ->
assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType())
);
@@ -1723,7 +1722,7 @@ public class ChannelDirectoryService {
return assets
.stream()
.sorted(
Comparator.comparingInt((Asset asset) -> displayOrderValue(asset))
Comparator.comparingInt(this::displayOrderValue)
.reversed()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)
@@ -1761,7 +1760,7 @@ public class ChannelDirectoryService {
.stream()
.filter((asset) -> types.contains(asset.getAssetType()))
.sorted(
Comparator.comparingInt((Asset asset) -> displayOrderValue(asset))
Comparator.comparingInt(this::displayOrderValue)
.reversed()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)

View File

@@ -1,7 +1,7 @@
package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
import java.time.Duration;
import java.time.Instant;
@@ -50,13 +50,22 @@ public class EmoteSyncScheduler implements SchedulingConfigurer {
private Trigger buildTrigger() {
return (TriggerContext triggerContext) -> {
Instant lastCompletion = triggerContext.lastCompletionTime() == null
? Instant.now()
: triggerContext.lastCompletionTime().toInstant();
return lastCompletion.plus(Duration.ofMinutes(resolveIntervalMinutes()));
int interval = resolveIntervalMinutes();
Instant lastCompletion = resolveLastCompletion(triggerContext, interval);
return lastCompletion.plus(Duration.ofMinutes(interval));
};
}
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() {
Settings settings = settingsService.get();
int interval = settings.getEmoteSyncIntervalMinutes();
@@ -66,15 +75,20 @@ public class EmoteSyncScheduler implements SchedulingConfigurer {
private void syncEmotes() {
int interval = resolveIntervalMinutes();
LOG.info("Synchronizing emotes (interval {} minutes)", interval);
twitchEmoteService.refreshGlobalEmotes();
List<Channel> channels = channelRepository.findAll();
for (Channel channel : channels) {
String broadcaster = channel.getBroadcaster();
twitchEmoteService.refreshChannelEmotes(broadcaster);
sevenTvEmoteService.refreshChannelEmotes(broadcaster);
List<Channel> channels = List.of();
try {
channels = channelRepository.findAll();
twitchEmoteService.refreshGlobalEmotes();
for (Channel channel : channels) {
String broadcaster = channel.getBroadcaster();
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);
}
public String getCommitSha() {
return commitSha;
}
public String getShortCommitSha() {
return shortCommitSha;
}

View File

@@ -1,6 +1,6 @@
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 java.io.IOException;
import java.net.URI;
@@ -134,10 +134,7 @@ public class MarketplaceScriptSeedLoader {
if (!Files.isDirectory(scriptDir)) {
continue;
}
SeedScript script = loadScriptDirectory(scriptDir).orElse(null);
if (script != null) {
loaded.add(script);
}
loadScriptDirectory(scriptDir).ifPresent(loaded::add);
}
} catch (IOException ex) {
logger.warn("Failed to read marketplace script directory {}", rootPath, ex);
@@ -179,7 +176,7 @@ public class MarketplaceScriptSeedLoader {
broadcaster,
sourceMediaType,
logoMediaType,
Optional.ofNullable(sourcePath),
Optional.of(sourcePath),
Optional.ofNullable(logoPath),
allowedDomains,
attachments,
@@ -207,7 +204,7 @@ public class MarketplaceScriptSeedLoader {
attachments.add(
new SeedAttachment(
name,
mediaType == null ? "application/octet-stream" : mediaType,
mediaType,
attachment,
new AtomicReference<>()
)
@@ -348,7 +345,7 @@ public class MarketplaceScriptSeedLoader {
}
try {
String content = Files.readString(path);
return JsonSupport.read(content, ScriptSeedMetadata.class);
return JsonSupport.read(content);
} catch (IOException ex) {
logger.warn("Failed to read marketplace metadata {}", path, ex);
return null;
@@ -362,8 +359,8 @@ public class MarketplaceScriptSeedLoader {
private JsonSupport() {}
static <T> T read(String payload, Class<T> type) throws IOException {
return mapper().readValue(payload, type);
static <T> T read(String payload) throws IOException {
return mapper().readValue(payload, (Class<T>) ScriptSeedMetadata.class);
}
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.databind.ObjectMapper;
import dev.kruhlmann.imgfloat.model.AudioAsset;
import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.model.VisualAsset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings;
import dev.kruhlmann.imgfloat.model.db.imgfloat.VisualAsset;
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
import dev.kruhlmann.imgfloat.repository.VisualAssetRepository;
import jakarta.annotation.PostConstruct;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
@@ -59,6 +60,12 @@ public class SettingsService {
return savedSettings;
}
public void updateLastEmoteSyncAt(Instant timestamp) {
Settings settings = get();
settings.setLastEmoteSyncAt(timestamp);
repo.save(settings);
}
public void logSettings(String msg, Settings settings) {
try {
logger.info("{}:\n{}", msg, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(settings));

View File

@@ -1,6 +1,6 @@
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 java.util.List;
import java.util.Locale;

View File

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

View File

@@ -12,7 +12,9 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.MediaType;
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.web.client.RestClientException;
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, List<CachedEmote>> channelEmoteCache = new ConcurrentHashMap<>();
private volatile List<CachedEmote> globalEmotes = List.of();
private final AtomicBoolean initialGlobalSyncScheduled = new AtomicBoolean();
public TwitchEmoteService(
RestTemplateBuilder builder,
@@ -61,12 +66,11 @@ public class TwitchEmoteService {
} catch (IOException ex) {
throw new IllegalStateException("Failed to create Twitch emote cache directory", ex);
}
warmGlobalEmotes();
}
public List<EmoteDescriptor> getGlobalEmotes() {
if (globalEmotes.isEmpty()) {
warmGlobalEmotes();
ensureInitialGlobalSyncScheduled();
}
return globalEmotes.stream().map(CachedEmote::descriptor).toList();
}
@@ -127,6 +131,31 @@ public class TwitchEmoteService {
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) {
String broadcasterId = fetchBroadcasterId(channelLogin).orElse(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.JsonProperty;
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
import dev.kruhlmann.imgfloat.model.api.response.TwitchUserProfile;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;

View File

@@ -1,14 +1,10 @@
package dev.kruhlmann.imgfloat.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@@ -17,32 +13,17 @@ import org.springframework.stereotype.Component;
public class VersionService {
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 releaseVersion;
public VersionService() throws IOException {
public VersionService() {
this.serverVersion = resolveServerVersion();
this.releaseVersion = normalizeReleaseVersion(serverVersion);
}
public String getVersion() {
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() {
String pomVersion = getPomVersion();
if (pomVersion != null && !pomVersion.isBlank()) {
@@ -57,16 +38,6 @@ public class VersionService {
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() {
try (
var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")

View File

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

View File

@@ -42,11 +42,7 @@ public class MediaOptimizationService {
}
}
if (mediaType.startsWith("image/")) {
OptimizedAsset imageAsset = optimizeImage(bytes, mediaType);
if (imageAsset == null) {
return null;
}
return imageAsset;
return optimizeImage(bytes, mediaType);
}
if (mediaType.startsWith("video/")) {
@@ -86,7 +82,7 @@ public class MediaOptimizationService {
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
.transcodeApngToGif(bytes)
.map(this::transcodeGifToVideo)

View File

@@ -1,5 +1,7 @@
package dev.kruhlmann.imgfloat.service.media;
import jakarta.validation.constraints.NotNull;
import java.util.Arrays;
import java.util.Objects;
@@ -26,6 +28,7 @@ public record OptimizedAsset(byte[] bytes, String mediaType, int width, int heig
return result;
}
@NotNull
@Override
public String toString() {
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 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.io.ByteArrayOutputStream;
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.status;
import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
import org.junit.jupiter.api.BeforeEach;
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.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.Settings;
import dev.kruhlmann.imgfloat.model.TransformRequest;
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
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.AudioAssetRepository;
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
@@ -64,12 +64,7 @@ class ChannelDirectoryServiceTest {
private VisualAssetRepository visualAssetRepository;
private AudioAssetRepository audioAssetRepository;
private ScriptAssetRepository scriptAssetRepository;
private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
private ScriptAssetFileRepository scriptAssetFileRepository;
private MarketplaceScriptHeartRepository marketplaceScriptHeartRepository;
private SettingsService settingsService;
private MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
private AuditLogService auditLogService;
@BeforeEach
void setup() throws Exception {
@@ -79,13 +74,13 @@ class ChannelDirectoryServiceTest {
visualAssetRepository = mock(VisualAssetRepository.class);
audioAssetRepository = mock(AudioAssetRepository.class);
scriptAssetRepository = mock(ScriptAssetRepository.class);
scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class);
ScriptAssetAttachmentRepository scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class);
scriptAssetFileRepository = mock(ScriptAssetFileRepository.class);
marketplaceScriptHeartRepository = mock(MarketplaceScriptHeartRepository.class);
auditLogService = mock(AuditLogService.class);
MarketplaceScriptHeartRepository marketplaceScriptHeartRepository = mock(MarketplaceScriptHeartRepository.class);
AuditLogService auditLogService = mock(AuditLogService.class);
when(marketplaceScriptHeartRepository.countByScriptIds(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());
setupInMemoryPersistence();
Path assetRoot = Files.createTempDirectory("imgfloat-assets-test");
@@ -112,24 +107,24 @@ class ChannelDirectoryServiceTest {
Files.writeString(scriptRoot.resolve("source.js"), "console.log('seeded');");
Files.write(scriptRoot.resolve("logo.png"), samplePng());
Files.write(scriptRoot.resolve("attachments/rotate.png"), samplePng());
marketplaceScriptSeedLoader = new MarketplaceScriptSeedLoader(marketplaceRoot.toString());
MarketplaceScriptSeedLoader marketplaceScriptSeedLoader = new MarketplaceScriptSeedLoader(marketplaceRoot.toString());
service = new ChannelDirectoryService(
channelRepository,
assetRepository,
visualAssetRepository,
audioAssetRepository,
scriptAssetRepository,
scriptAssetAttachmentRepository,
scriptAssetAttachmentRepository,
scriptAssetFileRepository,
marketplaceScriptHeartRepository,
marketplaceScriptHeartRepository,
messagingTemplate,
assetStorageService,
mediaDetectionService,
mediaOptimizationService,
settingsService,
settingsService,
uploadLimitBytes,
marketplaceScriptSeedLoader,
auditLogService
marketplaceScriptSeedLoader,
auditLogService
);
}
@@ -195,9 +190,14 @@ class ChannelDirectoryServiceTest {
}
@Test
void appliesBoundaryValues() throws Exception {
void appliesBoundaryValues() {
String channel = "caster";
String id = createSampleAsset(channel);
String id = null;
try {
id = createSampleAsset(channel);
} catch (Exception e) {
throw new RuntimeException(e);
}
TransformRequest transform = validTransform();
transform.setSpeed(0.1);
@@ -215,7 +215,7 @@ class ChannelDirectoryServiceTest {
void includesDefaultMarketplaceScript() {
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)
.anyMatch((entry) -> "rotating-logo".equals(entry.id()));
@@ -229,7 +229,7 @@ class ChannelDirectoryServiceTest {
request.setAllowedDomains(List.of("example.com"));
AssetView created = service.createCodeAsset("caster", request, "caster").orElseThrow();
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());
}
@@ -271,10 +271,10 @@ class ChannelDirectoryServiceTest {
private void setupInMemoryPersistence() {
Map<String, Channel> channels = 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, 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) ->
Optional.ofNullable(channels.get(invocation.getArgument(0)))
@@ -317,9 +317,9 @@ class ChannelDirectoryServiceTest {
.when(assetRepository)
.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) -> {
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);
return visual;
}
@@ -388,9 +388,9 @@ class ChannelDirectoryServiceTest {
.when(scriptAssetRepository)
.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) -> {
dev.kruhlmann.imgfloat.model.ScriptAssetFile file = invocation.getArgument(0);
dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile file = invocation.getArgument(0);
if (file.getId() == null) {
file.setId(java.util.UUID.randomUUID().toString());
}

View File

@@ -43,21 +43,25 @@ class TwitchAuthorizationCodeGrantRequestEntityConverterTest {
.state("state")
.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(
authorizationRequest,
authorizationResponse
authorizationRequest,
authorizationResponse
);
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(
registration,
registration,
exchange
);
var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter();
RequestEntity<?> requestEntity = converter.convert(grantRequest);
MultiValueMap<String, String> body = (MultiValueMap<String, String>) requestEntity.getBody();
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_ID)).isEqualTo("twitch-id");
assertThat(body.getFirst(OAuth2ParameterNames.CLIENT_SECRET)).isEqualTo("twitch-secret");
return (MultiValueMap<String, String>) requestEntity.getBody();
}
}

View File

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