7 Commits
0.0.1 ... 0.0.2

Author SHA1 Message Date
fe6fb68b53 Reduce repaints of asset list 2026-02-09 16:33:25 +01:00
1c6d115181 Fix ordering 2026-02-09 16:28:05 +01:00
1c118aab0c Update AGENTS.md file 2026-02-09 16:27:59 +01:00
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
86 changed files with 900 additions and 491 deletions

102
AGENTS.md Normal file
View File

@@ -0,0 +1,102 @@
# Repository Guidelines
## Snapshot & Stack
- Java Spring Boot 3.2 service that backs the Imgfloat livestream overlays, admin UI, and dashboard.
- Uses Spring MVC controllers, WebSocket/STOMP broadcasts (`SimpMessagingTemplate`), OAuth2/Twitch authentication, and scheduled/async services (`@EnableScheduling`, `@EnableAsync` in `ImgfloatApplication`).
- Media-heavy assets are stored on disk (`assets/`, `previews/`), processed with `ffmpeg`/`ffprobe`, and surfaced through `static/js` / Thymeleaf templates under `src/main/resources`.
## Architecture & Modules
- `src/main/java/dev/kruhlmann/imgfloat/config`: Spring configuration beans (upload limits, security overrides, asset storage wiring).
- `controller`: REST + view controllers keep endpoints thin and delegate work to services and repositories.
- `service`: business logic (asset management, marketplace seeds, git info, Twitch emote syncing, audit logging, cleanup, media optimization, etc.). `service.media` contains ffmpeg helpers and media detection.
- `repository`: Spring Data JPA interfaces for the Imgfloat DB (`Asset`, `Channel`, `ScriptAsset`, etc.) and a separate audit DB under `repository.audit`.
- `model`: `db` packages mirror schema entities; `api.request`/`response` hold DTOs used by controllers.
- `templates`: Thymeleaf views (`admin.html`, `broadcast.html`, `dashboard.html`, etc.).
- `static`: frontend JavaScript modules (`static/js/admin`, `static/js/broadcast/*`, `static/js/customAssets.js`, etc.), CSS, icons, and broadcast worker scripts. Keep new assets next to the feature they support.
- `src/main/resources/db/migration` and `db/audit`: Flyway migration scripts that must stay in sync with the SQLite schemas referenced by `IMGFLOAT_DB_PATH` and `IMGFLOAT_AUDIT_DB_PATH`.
## Environment & Configuration
- `.env` files are only loaded when you run `make run`; shelling `mvn spring-boot:run` directly requires manually exporting each variable.
- Required environment variables (set them via `.env` locally or through your deployment tooling):
| Variable | Purpose |
| --- | --- |
| `IMGFLOAT_ASSETS_PATH` | Base filesystem directory for uploaded assets. `AssetStorageService` sanitizes broadcaster segments under this path. |
| `IMGFLOAT_PREVIEWS_PATH` | Where generated previews (PNG thumbnails) go. |
| `IMGFLOAT_DB_PATH` | SQLite file used by the main Flyway-managed schema (`db/migration`). |
| `IMGFLOAT_AUDIT_DB_PATH` | SQLite file for audit logs (`db/audit`) and session storage. |
| `IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN` | Twitch login that becomes the initial sysadmin and can manage all channels. |
| `IMGFLOAT_GITHUB_CLIENT_OWNER/REPO/VERSION` | GitHub release coordinates used by `GithubReleaseService` to build download links for the client bundle. |
| `IMGFLOAT_TOKEN_ENCRYPTION_KEY` | Base64/Base64URL-encoded 32-byte key used by Spring Security to encrypt OAuth tokens at rest. |
| `SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE` / `MAX_REQUEST_SIZE` | Control upload limits (defaults to 10MB in `Makefile`, also read by `UploadLimitsConfig` which exposes `uploadLimitBytes` to controllers/services). |
| `TWITCH_CLIENT_ID` & `TWITCH_CLIENT_SECRET` | Used by Spring Securitys OAuth2 client registration in `application.yml`. |
- Optional/operational variables:
| Variable | Purpose |
| --- | --- |
| `IMGFLOAT_COMMIT_URL_PREFIX` | Enables the commit chip in the UI (`GitInfoService`) when paired with `git-commit-id-plugin`. |
| `IMGFLOAT_DOCS_URL` | Base URL for documentation links rendered in templates. |
| `IMGFLOAT_IS_STAGING` | Set to `1` to surface a staging banner across non-broadcast pages. Defaults to `0`. |
| `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH` | Directory containing marketplace seed directories (`MarketplaceScriptSeedLoader` reads metadata, source.js, logo, attachments). `doc/marketplace-scripts` holds examples. |
| `IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED` | Toggle to let sysadmins manage every channel without being added as an admin. |
| `TWITCH_REDIRECT_URI` | Overrides the default redirect URI for OAuth (`{baseUrl}/login/oauth2/code/twitch`). |
| `IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS` | Comma-delimited base64 keys to decrypt tokens created before rotating `IMGFLOAT_TOKEN_ENCRYPTION_KEY`. |
- `AssetStorageService` will fall back to system temp dirs (`java.io.tmpdir/imgfloat-assets` & `-previews`) if `IMGFLOAT_ASSETS_PATH`/`PREVIEWS_PATH` are unset, but storing data on persistent storage is required for production.
- `UploadLimitsConfig` defaults to 50MB/100MB for tests if the environment properties are absent; production should honor the `spring.servlet.multipart.*` values you expose.
## Build, Run, & Watch
- `make build` / `mvn compile`: compile the server.
- `make run`: sources `.env` (if present) and runs `mvn spring-boot:run`. Use this for local dev; hot reload works via `spring-devtools`.
- `make watch`: compiles continuously using `entr` (sleep loop watches `src/main`). Requires `entr` installed and you must refresh the browser manually after the compile succeeds.
- `make test` / `mvn test`: run the JUnit 5 + Mockito suite (including integration tests that spin up the Spring context). Run this before pushing changes that touch controllers, services, or persistence logic.
- `make package` / `mvn clean package`: clean build that packages the runnable JAR.
- `springdoc-openapi` is on the classpath; the automated Swagger UI can be helpful for understanding the available REST endpoints.
## Database & Persistence
- The main SQLite DB (`IMGFLOAT_DB_PATH`) holds channel, user, asset, marketplace, and settings tables. Flyway (configured in `application.yml`) runs migrations from `classpath:db/migration`. Dont bypass these migration scripts when updating schemas.
- Audit logs live in the separate `IMGFLOAT_AUDIT_DB_PATH` with migrations under `src/main/resources/db/audit`. The audit DB also stores Spring Session tables; `spring.session.jdbc.initialize-schema` is set to `never`, so the Flyway scripts **must** include the session table definitions.
- `AuditLogService` sanitizes log entries before persisting them and is used by controllers such as `AuditLogApiController` and `AccountService`.
## Media & Asset Handling
- `ChannelDirectoryService` handles uploads. It enforces `uploadLimitBytes` (derived from `spring.servlet.multipart.max-file-size`), normalizes broadcaster names, and records audit events via `AuditLogService`.
- Uploaded assets go through `MediaDetectionService`, `MediaOptimizationService`, and `AssetStorageService` which writes to `IMGFLOAT_ASSETS_PATH`. Previews (PNGs) are written to `IMGFLOAT_PREVIEWS_PATH`.
- `AssetStorageService` sanitizes broadcaster segments (`sanitizeUserSegment`) to avoid traversal and auto-creates directories. When deleting, `AssetCleanupService` runs asynchronously after startup to remove orphan files that are not referenced by any DB record.
- Media processing uses `ffmpeg`/`ffprobe` via `FfmpegService`. Make sure those binaries are available on the system; otherwise video/preview transcodes and APNG → GIF conversions will fail with warnings logged from `FfmpegService`.
## Marketplace Scripts & Allowed Domains
- Marketplace seeds live under the path configured by `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH`. Each subfolder is a listing identifier and **must** contain `metadata.json` and `source.js`. Optional files: `logo.png` (renders as the marketplace badge) and `attachments/` for extra files (filenames must be unique per script).
- `metadata.json` combines `name`, optional `description`, optional `broadcaster`, and optional `allowedDomains`. Allowed domains control the `fetch` sandbox in the broadcast worker and are normalized via `ChannelDirectoryService.normalizeAllowedDomains`. Valid entries are hostname or hostname+port (regex `^[a-z0-9.-]+(?::[0-9]{1,5})?$`), de-duplicated, lower-cased, and capped at 32 entries per script.
- Seeds are cached at startup; `MarketplaceScriptSeedLoader` exposes `listEntriesForQuery` and `findById` so the frontend can show the marketplace catalog without hitting Twitch/SevenTV endpoints.
- Example scripts are shipped under `doc/marketplace-scripts/`.
## Security, Auth, & Tokens
- Spring Security OAuth2 login is configured in `application.yml` under `spring.security.oauth2.client`. Twitch is the only configured provider; scopes include `user:read:email` and `moderation:read`.
- OAuth tokens are encrypted using `IMGFLOAT_TOKEN_ENCRYPTION_KEY`; rotate keys by setting `IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS` to a comma list of old keys (oldest last) before swapping in a new `IMGFLOAT_TOKEN_ENCRYPTION_KEY`.
- `IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN` seeds the first sysadmin; subsequent sysadmins can be managed via the UI or API. To allow sysadmins to hop between channels without being channel admins, set `IMGFLOAT_SYSADMIN_CHANNEL_ACCESS_ENABLED=true`.
- `GitInfoService` will expose a commit link if `IMGFLOAT_COMMIT_URL_PREFIX` is configured and `git.properties` is present (generated by the `git-commit-id-plugin`), otherwise it falls back to running `git` directly.
- `GithubReleaseService` fails fast when `IMGFLOAT_GITHUB_CLIENT_OWNER`, `IMGFLOAT_GITHUB_CLIENT_REPO`, or `IMGFLOAT_GITHUB_CLIENT_VERSION` are missing; these powers the download link in the UI.
## Frontend Notes
- Broadcast client code lives in `src/main/resources/static/js/broadcast/` (renderer, workers, runtime helpers). When editing overlay scripts, keep worker changes in `script-worker.js` and keep main page logic in `renderer.js`.
- Admin/dashboard JS modules (`customAssets.js`, `settings.js`, `channels.js`, etc.) are plain ES modules bundled through Thymeleaf templates, so keep related CSS/HTML under `static/css` and `templates`.
- Templates render dynamic data via controllers such as `ViewController`, which also injects `uploadLimitBytes`, version info (`VersionService`), and feature flags (staging banner, docs URL, commit chip wrapped in `GitInfoService`/`GithubReleaseService` values).
## Testing & Validation
- Tests are under `src/test/java/dev/kruhlmann/imgfloat`. Controller integration tests (e.g., `ChannelApiIntegrationTest`) boot up the application context—mock external services carefully or rely on the in-memory SQLite test DB created from the Flyway scripts.
- Service unit tests (e.g., `ChannelDirectoryServiceTest`) cover asset normalization, allowed domain semantics, and marketplace seed behavior. Add targeted tests for new service logic, API validations, or asset handling (uploads, attachments, downloads).
- Run `make test` before shipping changes; it also generates JaCoCo reports via the Maven plugin.
## Coding Style & Contributions
- Java code uses 4-space indentation, `UpperCamelCase` for types, `lowerCamelCase` for fields/methods, and `UPPER_SNAKE_CASE` for constants. DTOs often use `record`s when immutability is feasible (`ScriptMarketplaceEntry`, `MarketplaceScriptSeedLoader.SeedScript`, etc.).
- Prefer constructor injection for Spring components. Controllers should remain thin and delegate to service-layer beans; services orchestrate repositories, asset storage, storage cleanup, Git/Twitch helpers, and messaging (websockets/events).
- Keep assets close to their feature (e.g., `static/js/broadcast/` for overlay code, `templates/` for the Thymeleaf view referencing that script).
- Avoid path traversal by relying on existing sanitizers (`AssetStorageService.sanitizeUserSegment`). When adding new file paths, reuse `safeJoin`/`sanitizeUserSegment` patterns.
- When syncing marketplace favorites or script attachments, obey `ChannelDirectoryService.loadScriptAttachments` semantics—attachments are stored in `script_asset_attachment` records and are cleaned up when orphaned.
## Operational Tips
- `AssetCleanupService` runs asynchronously once per startup (after `ApplicationReadyEvent`) and purges disk files whose IDs are not referenced by any DB row; do not rely on manual cleanup unless debugging.
- `VersionService` looks for `META-INF/maven/dev.kruhlmann/imgfloat/pom.properties` first, falls back to parsing `pom.xml` (useful when running from source), and throws if no version is available—keep your `pom.version` accurate for release builds.
- `EmoteSyncScheduler`, `SevenTvEmoteService`, `TwitchEmoteService`, and `TwitchAppAccessTokenService` orchestrate Twitch/7TV emote catalogs and should be aware of scheduling constraints if you modify emote ingestion.
- Static files, marketplace metadata, and assets are best tested locally by running `make run` with the appropriate env variables and by refreshing the admin/broadcast UI manually.

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,20 @@
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.AssetOrderRequest;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView; import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
import dev.kruhlmann.imgfloat.model.TwitchUserProfile; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; 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.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 +32,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 +86,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 +109,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 +133,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 +143,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(
@@ -331,6 +334,24 @@ public class ChannelApiController {
}); });
} }
@PostMapping("/assets/order")
public ResponseEntity<Void> reorderAssets(
@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody AssetOrderRequest request,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
String logBroadcaster = LogSanitizer.sanitize(broadcaster);
String logSessionUsername = LogSanitizer.sanitize(sessionUsername);
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
broadcaster,
sessionUsername
);
LOG.debug("Reordering assets for {} by {}", logBroadcaster, logSessionUsername);
channelDirectoryService.reorderAssets(broadcaster, request.getUpdates(), sessionUsername);
return ResponseEntity.noContent().build();
}
@PostMapping("/assets/{assetId}/play") @PostMapping("/assets/{assetId}/play")
public ResponseEntity<AssetView> play( public ResponseEntity<AssetView> play(
@PathVariable("broadcaster") String broadcaster, @PathVariable("broadcaster") String broadcaster,
@@ -406,7 +427,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 +454,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 +477,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,12 +496,11 @@ 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 +636,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

@@ -0,0 +1,29 @@
package dev.kruhlmann.imgfloat.model.api.request;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
public class AssetOrderRequest {
@Valid
@NotEmpty
private List<AssetOrderUpdate> updates;
public List<AssetOrderUpdate> getUpdates() {
return updates;
}
public void setUpdates(List<AssetOrderUpdate> updates) {
this.updates = updates;
}
public static record AssetOrderUpdate(
@NotBlank
String assetId,
@NotNull
Integer order
) {}
}

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,30 @@ 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.AssetOrderRequest;
import dev.kruhlmann.imgfloat.model.AudioAsset; import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.CanvasEvent; import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest;
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest;
import dev.kruhlmann.imgfloat.model.ChannelScriptSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest;
import dev.kruhlmann.imgfloat.model.MarketplaceScriptHeart; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.api.response.AssetEvent;
import dev.kruhlmann.imgfloat.model.ScriptAsset; import dev.kruhlmann.imgfloat.model.api.response.AssetPatch;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; import dev.kruhlmann.imgfloat.model.api.response.AssetView;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView; import dev.kruhlmann.imgfloat.model.api.response.CanvasEvent;
import dev.kruhlmann.imgfloat.model.ScriptAssetFile; import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView;
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry; import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
import dev.kruhlmann.imgfloat.model.VisualAsset; 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.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 +583,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 +604,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 +805,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 +1066,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");
@@ -1163,6 +1164,129 @@ public class ChannelDirectoryService {
}); });
} }
@Transactional
public void reorderAssets(
String broadcaster,
List<AssetOrderRequest.AssetOrderUpdate> updates,
String actor
) {
if (updates == null || updates.isEmpty()) {
return;
}
String normalized = normalize(broadcaster);
applyBulkOrderUpdates(
broadcaster,
normalized,
updates,
EnumSet.of(AssetType.SCRIPT),
actor,
true
);
applyBulkOrderUpdates(
broadcaster,
normalized,
updates,
EnumSet.of(AssetType.IMAGE, AssetType.VIDEO, AssetType.MODEL, AssetType.OTHER),
actor,
false
);
}
private void applyBulkOrderUpdates(
String broadcaster,
String normalized,
List<AssetOrderRequest.AssetOrderUpdate> updates,
EnumSet<AssetType> types,
String actor,
boolean script
) {
if (updates == null || updates.isEmpty()) {
return;
}
List<Asset> bucket = assetRepository
.findByBroadcaster(normalized)
.stream()
.filter((asset) -> types.contains(asset.getAssetType()))
.sorted(
Comparator.comparingInt(this::displayOrderValue)
.reversed()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
)
.toList();
if (bucket.isEmpty()) {
return;
}
Map<String, Integer> desiredOrder = new HashMap<>();
Set<String> bucketIds = bucket.stream().map(Asset::getId).collect(Collectors.toSet());
for (AssetOrderRequest.AssetOrderUpdate update : updates) {
if (update == null) {
continue;
}
String assetId = update.assetId();
if (!bucketIds.contains(assetId)) {
continue;
}
Integer order = update.order();
if (order == null) {
continue;
}
if (order < 1) {
throw new ResponseStatusException(BAD_REQUEST, "Order must be >= 1");
}
desiredOrder.put(assetId, order);
}
if (desiredOrder.isEmpty()) {
return;
}
Map<String, Integer> originalIndex = new HashMap<>();
for (int index = 0; index < bucket.size(); index++) {
originalIndex.put(bucket.get(index).getId(), index);
}
List<Asset> ordered = new ArrayList<>(bucket);
ordered.sort((a, b) -> {
int orderA = desiredOrder.getOrDefault(a.getId(), a.getDisplayOrder() != null ? a.getDisplayOrder() : bucket.size() - originalIndex.getOrDefault(a.getId(), bucket.size()));
int orderB = desiredOrder.getOrDefault(b.getId(), b.getDisplayOrder() != null ? b.getDisplayOrder() : bucket.size() - originalIndex.getOrDefault(b.getId(), bucket.size()));
int cmp = Integer.compare(orderB, orderA);
if (cmp != 0) {
return cmp;
}
return Integer.compare(originalIndex.getOrDefault(a.getId(), Integer.MAX_VALUE), originalIndex.getOrDefault(b.getId(), Integer.MAX_VALUE));
});
List<Asset> changed = new ArrayList<>();
for (int index = 0; index < ordered.size(); index++) {
Asset asset = ordered.get(index);
int nextOrder = ordered.size() - index;
if (asset.getDisplayOrder() == null || asset.getDisplayOrder() != nextOrder) {
asset.setDisplayOrder(nextOrder);
changed.add(asset);
}
}
if (changed.isEmpty()) {
return;
}
assetRepository.saveAll(changed);
publishOrderUpdates(broadcaster, null, changed);
for (Asset asset : changed) {
if (script) {
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"SCRIPT_ORDER_UPDATED",
formatScriptTransformDetails(asset.getId(), asset.getDisplayOrder())
);
} else {
TransformRequest logDetails = new TransformRequest();
logDetails.setOrder(asset.getDisplayOrder());
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"VISUAL_UPDATED",
formatVisualTransformDetails(asset.getId(), logDetails)
);
}
}
}
private void validateVisualTransform(TransformRequest req) { private void validateVisualTransform(TransformRequest req) {
Settings settings = settingsService.get(); Settings settings = settingsService.get();
double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction(); double maxSpeed = settings.getMaxAssetPlaybackSpeedFraction();
@@ -1172,13 +1296,13 @@ public class ChannelDirectoryService {
int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels(); int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels();
if ( if (
req.getWidth() == null || req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels req.getWidth() != null && (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels)
) throw new ResponseStatusException( ) throw new ResponseStatusException(
BAD_REQUEST, BAD_REQUEST,
"Canvas width out of range [0 to " + canvasMaxSizePixels + "]" "Canvas width out of range [0 to " + canvasMaxSizePixels + "]"
); );
if ( if (
req.getHeight() == null || req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels req.getHeight() != null && (req.getHeight() <= 0 || req.getHeight() > canvasMaxSizePixels)
) throw new ResponseStatusException( ) throw new ResponseStatusException(
BAD_REQUEST, BAD_REQUEST,
"Canvas height out of range [0 to " + canvasMaxSizePixels + "]" "Canvas height out of range [0 to " + canvasMaxSizePixels + "]"
@@ -1514,7 +1638,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 +1847,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 +1885,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();
try {
channels = channelRepository.findAll();
twitchEmoteService.refreshGlobalEmotes(); twitchEmoteService.refreshGlobalEmotes();
List<Channel> channels = channelRepository.findAll();
for (Channel channel : channels) { for (Channel channel : channels) {
String broadcaster = channel.getBroadcaster(); String broadcaster = channel.getBroadcaster();
twitchEmoteService.refreshChannelEmotes(broadcaster); twitchEmoteService.refreshChannelEmotes(broadcaster);
sevenTvEmoteService.refreshChannelEmotes(broadcaster); sevenTvEmoteService.refreshChannelEmotes(broadcaster);
} }
LOG.info("Completed emote sync for {} channels", channels.size()); LOG.info("Completed emote sync for {} channels", channels.size());
} catch (Exception ex) {
LOG.error("Emote sync failed", ex);
} finally {
settingsService.updateLastEmoteSyncAt(Instant.now());
}
} }
} }

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

@@ -26,6 +26,7 @@ export function createAdminConsole({
const assets = new Map(); const assets = new Map();
const mediaCache = new Map(); const mediaCache = new Map();
const renderStates = new Map(); const renderStates = new Map();
const transformBaseline = new Map();
const animatedCache = new Map(); const animatedCache = new Map();
const audioControllers = new Map(); const audioControllers = new Map();
const pendingAudioUnlock = new Set(); const pendingAudioUnlock = new Set();
@@ -103,6 +104,7 @@ export function createAdminConsole({
let selectedAssetId = null; let selectedAssetId = null;
let interactionState = null; let interactionState = null;
let stompClient; let stompClient;
let listNeedsRender = true;
function start() { function start() {
applyCanvasSettings(canvasSettings); applyCanvasSettings(canvasSettings);
@@ -506,7 +508,7 @@ export function createAdminConsole({
event.preventDefault(); event.preventDefault();
updateRenderState(asset); updateRenderState(asset);
schedulePersistTransform(asset); schedulePersistTransform(asset);
drawAndList(); drawAndList(false);
} }
}); });
function connect() { function connect() {
@@ -598,6 +600,7 @@ export function createAdminConsole({
layerOrder = []; layerOrder = [];
scriptLayerOrder = []; scriptLayerOrder = [];
list.forEach((item) => storeAsset(item, { placement: "append" })); list.forEach((item) => storeAsset(item, { placement: "append" }));
markListDirty();
drawAndList(); drawAndList();
} }
@@ -621,6 +624,8 @@ export function createAdminConsole({
if (!renderStates.has(asset.id)) { if (!renderStates.has(asset.id)) {
renderStates.set(asset.id, { ...merged }); renderStates.set(asset.id, { ...merged });
} }
updateTransformBaseline(merged);
markListDirty();
resolvePendingUploadByName(asset.name); resolvePendingUploadByName(asset.name);
} }
@@ -635,6 +640,35 @@ export function createAdminConsole({
renderStates.set(asset.id, state); renderStates.set(asset.id, state);
} }
function updateTransformBaseline(asset) {
if (!asset?.id) {
return;
}
const snapshot = {};
snapshot.audioVolume = Number.isFinite(asset.audioVolume) ? asset.audioVolume : undefined;
if (isAudioAsset(asset)) {
snapshot.audioLoop = asset.audioLoop;
snapshot.audioDelayMillis = Number.isFinite(asset.audioDelayMillis)
? asset.audioDelayMillis
: undefined;
snapshot.audioSpeed = Number.isFinite(asset.audioSpeed) ? asset.audioSpeed : undefined;
snapshot.audioPitch = Number.isFinite(asset.audioPitch) ? asset.audioPitch : undefined;
} else {
snapshot.x = Number.isFinite(asset.x) ? asset.x : undefined;
snapshot.y = Number.isFinite(asset.y) ? asset.y : undefined;
snapshot.width = Number.isFinite(asset.width) ? asset.width : undefined;
snapshot.height = Number.isFinite(asset.height) ? asset.height : undefined;
snapshot.rotation = Number.isFinite(asset.rotation) ? asset.rotation : undefined;
snapshot.speed = Number.isFinite(asset.speed) ? asset.speed : undefined;
snapshot.muted = asset.muted;
const order = isCodeAsset(asset) ? getScriptLayerValue(asset.id) : getLayerValue(asset.id);
if (Number.isFinite(order)) {
snapshot.order = order;
}
}
transformBaseline.set(asset.id, snapshot);
}
function handleEvent(event) { function handleEvent(event) {
if (event.type === "CANVAS" && event.payload) { if (event.type === "CANVAS" && event.payload) {
applyCanvasSettings(event.payload); applyCanvasSettings(event.payload);
@@ -647,10 +681,11 @@ export function createAdminConsole({
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== assetId); scriptLayerOrder = scriptLayerOrder.filter((id) => id !== assetId);
clearMedia(assetId); clearMedia(assetId);
renderStates.delete(assetId); renderStates.delete(assetId);
transformBaseline.delete(assetId);
loopPlaybackState.delete(assetId); loopPlaybackState.delete(assetId);
cancelPendingTransform(assetId); cancelPendingTransform(assetId);
if (selectedAssetId === assetId) { if (selectedAssetId === assetId) {
selectedAssetId = null; setSelectedAssetId(null);
} }
} else if (event.patch) { } else if (event.patch) {
applyPatch(assetId, event.patch); applyPatch(assetId, event.patch);
@@ -666,7 +701,27 @@ export function createAdminConsole({
loopPlaybackState.delete(event.payload.id); loopPlaybackState.delete(event.payload.id);
} }
} }
drawAndList(); drawAndList(shouldRenderAssetList(event));
}
function shouldRenderAssetList(event) {
if (!event) {
return true;
}
const { type, payload, patch } = event;
if (type === "DELETED" || type === "VISIBILITY") {
return true;
}
if (payload) {
return true;
}
if (patch) {
if (patch.hidden != null || patch.order != null) {
return true;
}
return false;
}
return true;
} }
function applyPatch(assetId, patch) { function applyPatch(assetId, patch) {
@@ -706,9 +761,29 @@ export function createAdminConsole({
} }
} }
function drawAndList() { function markListDirty() {
listNeedsRender = true;
}
function markListDirty() {
listNeedsRender = true;
}
function drawAndList(renderList = false) {
requestDraw(); requestDraw();
if (renderList || listNeedsRender) {
renderAssetList(); renderAssetList();
listNeedsRender = false;
}
}
function setSelectedAssetId(id) {
if (selectedAssetId === id) {
return false;
}
selectedAssetId = id;
markListDirty();
return true;
} }
function requestDraw() { function requestDraw() {
@@ -1470,7 +1545,7 @@ export function createAdminConsole({
toggleBtn.title = asset.hidden ? "Show asset" : "Hide asset"; toggleBtn.title = asset.hidden ? "Show asset" : "Hide asset";
toggleBtn.addEventListener("click", (e) => { toggleBtn.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
selectedAssetId = asset.id; setSelectedAssetId(asset.id);
updateVisibility(asset, !asset.hidden); updateVisibility(asset, !asset.hidden);
}); });
actions.appendChild(toggleBtn); actions.appendChild(toggleBtn);
@@ -1481,7 +1556,7 @@ export function createAdminConsole({
row.appendChild(actions); row.appendChild(actions);
li.addEventListener("click", () => { li.addEventListener("click", () => {
selectedAssetId = asset.id; setSelectedAssetId(asset.id);
updateRenderState(asset); updateRenderState(asset);
drawAndList(); drawAndList();
}); });
@@ -1504,7 +1579,7 @@ export function createAdminConsole({
const hasPending = pendingUploads.length > 0; const hasPending = pendingUploads.length > 0;
if (!hasAssets && !hasPending) { if (!hasAssets && !hasPending) {
selectedAssetId = null; setSelectedAssetId(null);
if (assetInspector) { if (assetInspector) {
assetInspector.classList.add("hidden"); assetInspector.classList.add("hidden");
} }
@@ -2055,7 +2130,7 @@ export function createAdminConsole({
if (media) { if (media) {
applyMediaSettings(media, asset); applyMediaSettings(media, asset);
} }
drawAndList(); drawAndList(false);
} }
function updateVolumeFromInput() { function updateVolumeFromInput() {
@@ -2074,7 +2149,7 @@ export function createAdminConsole({
applyAudioSettings(controller, asset); applyAudioSettings(controller, asset);
} }
schedulePersistTransform(asset); schedulePersistTransform(asset);
drawAndList(); drawAndList(false);
} }
function updateAudioSettingsFromInputs() { function updateAudioSettingsFromInputs() {
@@ -2104,7 +2179,7 @@ export function createAdminConsole({
const controller = ensureAudioController(asset); const controller = ensureAudioController(asset);
applyAudioSettings(controller, asset); applyAudioSettings(controller, asset);
schedulePersistTransform(asset); schedulePersistTransform(asset);
drawAndList(); drawAndList(false);
} }
function nudgeRotation(delta) { function nudgeRotation(delta) {
@@ -2114,7 +2189,7 @@ export function createAdminConsole({
asset.rotation = next; asset.rotation = next;
updateRenderState(asset); updateRenderState(asset);
persistTransform(asset); persistTransform(asset);
drawAndList(); drawAndList(false);
} }
function recenterSelectedAsset() { function recenterSelectedAsset() {
@@ -2126,7 +2201,7 @@ export function createAdminConsole({
asset.y = centerY; asset.y = centerY;
updateRenderState(asset); updateRenderState(asset);
persistTransform(asset); persistTransform(asset);
drawAndList(); drawAndList(false);
} }
function getLayeredAssets(asset) { function getLayeredAssets(asset) {
@@ -2195,20 +2270,92 @@ export function createAdminConsole({
globalThis.sendToBack = sendToBack; globalThis.sendToBack = sendToBack;
function applyLayerOrder(ordered) { function applyLayerOrder(ordered) {
if (!ordered || !ordered.length) {
return;
}
const previousOrder = [...layerOrder];
const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id)); const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id));
layerOrder = newOrder; layerOrder = newOrder;
const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean); const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean);
changed.forEach((item) => updateRenderState(item)); changed.forEach((item) => updateRenderState(item));
changed.forEach((item) => schedulePersistTransform(item, true)); const orderUpdates = buildOrderUpdates(ordered, previousOrder);
sendOrderUpdates(orderUpdates, () => {
layerOrder = previousOrder;
drawAndList();
});
markListDirty();
drawAndList(); drawAndList();
} }
function applyScriptLayerOrder(ordered) { function applyScriptLayerOrder(ordered) {
if (!ordered || !ordered.length) {
return;
}
const previousOrder = [...scriptLayerOrder];
const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id)); const newOrder = ordered.map((item) => item.id).filter((id) => assets.has(id));
scriptLayerOrder = newOrder; scriptLayerOrder = newOrder;
const changed = ordered.map((item) => assets.get(item.id)).filter(Boolean); const orderUpdates = buildOrderUpdates(ordered, previousOrder);
changed.forEach((item) => schedulePersistTransform(item, true)); sendOrderUpdates(orderUpdates, () => {
scriptLayerOrder = previousOrder;
drawAndList(); drawAndList();
});
markListDirty();
drawAndList();
}
function buildOrderUpdates(ordered, previousOrderIds) {
if (!ordered || !ordered.length) {
return [];
}
const previousLength = previousOrderIds.length || ordered.length;
const previousOrderMap = new Map();
previousOrderIds.forEach((id, index) => {
previousOrderMap.set(id, previousLength - index);
});
const updates = [];
const newLength = ordered.length;
ordered.forEach((asset, index) => {
if (!asset) {
return;
}
const newOrderValue = newLength - index;
const previousValue = previousOrderMap.get(asset.id);
if (previousValue !== newOrderValue) {
updates.push({ assetId: asset.id, order: newOrderValue });
}
});
return updates;
}
function sendOrderUpdates(updates, onError) {
if (!updates || !updates.length) {
return;
}
fetch(`/api/channels/${broadcaster}/assets/order`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updates }),
})
.then((response) => {
if (!response.ok) {
return extractErrorMessage(response, "Unable to reorder assets right now.").then((message) => {
throw new Error(message);
});
}
})
.catch((error) => {
if (onError) {
onError();
}
const assetDetails = updates
.map((update) => {
const asset = assets.get(update.assetId);
return `${asset?.name ?? "unknown"} (${update.assetId})`;
})
.join(", ");
console.warn("Asset reorder failed for", assetDetails, error?.message || error);
showToast(error?.message || "Unable to reorder assets right now.", "error");
});
} }
function getAssetAspectRatio(asset) { function getAssetAspectRatio(asset) {
@@ -2318,12 +2465,14 @@ export function createAdminConsole({
clearMedia(asset.id); clearMedia(asset.id);
assets.delete(asset.id); assets.delete(asset.id);
renderStates.delete(asset.id); renderStates.delete(asset.id);
transformBaseline.delete(asset.id);
layerOrder = layerOrder.filter((id) => id !== asset.id); layerOrder = layerOrder.filter((id) => id !== asset.id);
scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id); scriptLayerOrder = scriptLayerOrder.filter((id) => id !== asset.id);
cancelPendingTransform(asset.id); cancelPendingTransform(asset.id);
if (selectedAssetId === asset.id) { if (selectedAssetId === asset.id) {
selectedAssetId = null; setSelectedAssetId(null);
} }
markListDirty();
drawAndList(); drawAndList();
showToast("Asset deleted.", "info"); showToast("Asset deleted.", "info");
}) })
@@ -2452,29 +2601,13 @@ export function createAdminConsole({
} }
function persistTransform(asset, silent = false) { function persistTransform(asset, silent = false) {
cancelPendingTransform(asset.id); if (!asset || !asset.id) {
const payload = { return Promise.resolve();
audioLoop: asset.audioLoop,
audioDelayMillis: asset.audioDelayMillis,
audioSpeed: asset.audioSpeed,
audioPitch: asset.audioPitch,
audioVolume: asset.audioVolume,
};
if (isCodeAsset(asset)) {
const order = getScriptLayerValue(asset.id);
payload.order = order;
} else if (!isAudioAsset(asset)) {
const order = getLayerValue(asset.id);
payload.x = asset.x;
payload.y = asset.y;
payload.width = asset.width;
payload.height = asset.height;
payload.rotation = asset.rotation;
payload.speed = asset.speed;
payload.order = order;
if (isVideoAsset(asset)) {
payload.muted = asset.muted;
} }
cancelPendingTransform(asset.id);
const payload = buildTransformPayload(asset);
if (!Object.keys(payload).length) {
return Promise.resolve();
} }
fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, { fetch(`/api/channels/${broadcaster}/assets/${asset.id}/transform`, {
method: "PUT", method: "PUT",
@@ -2493,7 +2626,7 @@ export function createAdminConsole({
storeAsset(updated); storeAsset(updated);
updateRenderState(updated); updateRenderState(updated);
if (!silent) { if (!silent) {
drawAndList(); drawAndList(false);
} }
}) })
.catch((error) => { .catch((error) => {
@@ -2503,6 +2636,57 @@ export function createAdminConsole({
}); });
} }
function buildTransformPayload(asset) {
if (!asset?.id) {
return {};
}
const baseline = transformBaseline.get(asset.id) || {};
const payload = {};
const addNumber = (key, value) => {
if (!Number.isFinite(value)) {
return;
}
const previous = baseline[key];
if (Number.isFinite(previous) && previous === value) {
return;
}
payload[key] = value;
};
const addBoolean = (key, value) => {
if (value == null) {
return;
}
if (baseline[key] === value) {
return;
}
payload[key] = value;
};
addBoolean("audioLoop", asset.audioLoop);
addNumber("audioDelayMillis", asset.audioDelayMillis);
addNumber("audioSpeed", asset.audioSpeed);
addNumber("audioPitch", asset.audioPitch);
addNumber("audioVolume", asset.audioVolume);
if (!isAudioAsset(asset)) {
addNumber("x", asset.x);
addNumber("y", asset.y);
addNumber("width", asset.width);
addNumber("height", asset.height);
addNumber("rotation", asset.rotation);
addNumber("speed", asset.speed);
if (isVideoAsset(asset)) {
addBoolean("muted", asset.muted);
}
const order = isCodeAsset(asset) ? getScriptLayerValue(asset.id) : getLayerValue(asset.id);
if (Number.isFinite(order) && baseline.order !== order) {
payload.order = order;
}
}
return payload;
}
canvas.addEventListener("mousedown", (event) => { canvas.addEventListener("mousedown", (event) => {
const point = getCanvasPoint(event); const point = getCanvasPoint(event);
const current = getSelectedAsset(); const current = getSelectedAsset();
@@ -2530,7 +2714,7 @@ export function createAdminConsole({
const hit = findAssetAtPoint(point.x, point.y); const hit = findAssetAtPoint(point.x, point.y);
if (hit) { if (hit) {
selectedAssetId = hit.id; setSelectedAssetId(hit.id);
updateRenderState(hit); updateRenderState(hit);
interactionState = { interactionState = {
mode: "move", mode: "move",
@@ -2540,7 +2724,7 @@ export function createAdminConsole({
}; };
canvas.style.cursor = "grabbing"; canvas.style.cursor = "grabbing";
} else { } else {
selectedAssetId = null; setSelectedAssetId(null);
interactionState = null; interactionState = null;
canvas.style.cursor = "default"; canvas.style.cursor = "default";
} }
@@ -2606,7 +2790,7 @@ export function createAdminConsole({
} }
storeAsset(asset); storeAsset(asset);
updateRenderState(asset); updateRenderState(asset);
selectedAssetId = asset.id; setSelectedAssetId(asset.id);
updateSelectedAssetControls(asset); updateSelectedAssetControls(asset);
drawAndList(); drawAndList();
}, },

View File

@@ -10,24 +10,18 @@ export function getVisibilityState(state, asset) {
} }
export function smoothState(state, asset) { export function smoothState(state, asset) {
const previous = state.renderStates.get(asset.id) || { ...asset }; const previous = state.renderStates.get(asset.id) || {};
const factor = 0.15;
const next = { const next = {
x: lerp(previous.x, asset.x, factor), x: Number.isFinite(asset.x) ? asset.x : previous.x ?? 0,
y: lerp(previous.y, asset.y, factor), y: Number.isFinite(asset.y) ? asset.y : previous.y ?? 0,
width: lerp(previous.width, asset.width, factor), width: Number.isFinite(asset.width) ? asset.width : previous.width ?? 0,
height: lerp(previous.height, asset.height, factor), height: Number.isFinite(asset.height) ? asset.height : previous.height ?? 0,
rotation: smoothAngle(previous.rotation, asset.rotation, factor), rotation: Number.isFinite(asset.rotation) ? asset.rotation : previous.rotation ?? 0,
}; };
state.renderStates.set(asset.id, next); state.renderStates.set(asset.id, next);
return next; return next;
} }
function smoothAngle(current, target, factor) {
const delta = ((target - current + 180) % 360) - 180;
return current + delta * factor;
}
function lerp(a, b, t) { function lerp(a, b, t) {
return a + (b - a) * t; return a + (b - a) * t;
} }

View File

@@ -120,7 +120,7 @@
</div> </div>
</div> </div>
<div class="property-row"> <div class="property-row">
<span class="property-label">Maintain AR</span> <span class="property-label">Maintain aspect ratio</span>
<label class="checkbox-inline toggle inline-toggle property-control"> <label class="checkbox-inline toggle inline-toggle property-control">
<input id="maintain-aspect" type="checkbox" checked /> <input id="maintain-aspect" type="checkbox" checked />
<span class="toggle-track" aria-hidden="true"> <span class="toggle-track" aria-hidden="true">

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,7 +107,7 @@ 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,
@@ -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,6 +43,13 @@ 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
@@ -55,9 +62,6 @@ class TwitchAuthorizationCodeGrantRequestEntityConverterTest {
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);