Unify formatting

This commit is contained in:
2026-01-05 15:50:23 +01:00
parent 7aa3f96b3f
commit 864aeb86eb
73 changed files with 6436 additions and 6047 deletions

View File

@@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
@EnableAsync @EnableAsync
@SpringBootApplication @SpringBootApplication
public class ImgfloatApplication { public class ImgfloatApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(ImgfloatApplication.class, args); SpringApplication.run(ImgfloatApplication.class, args);
} }

View File

@@ -12,8 +12,10 @@ import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepo
public class OAuth2AuthorizedClientPersistenceConfig { public class OAuth2AuthorizedClientPersistenceConfig {
@Bean @Bean
OAuth2AuthorizedClientService oauth2AuthorizedClientService(JdbcOperations jdbcOperations, OAuth2AuthorizedClientService oauth2AuthorizedClientService(
ClientRegistrationRepository clientRegistrationRepository) { JdbcOperations jdbcOperations,
ClientRegistrationRepository clientRegistrationRepository
) {
return new SQLiteOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository); return new SQLiteOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
} }

View File

@@ -1,7 +1,6 @@
package dev.kruhlmann.imgfloat.config; package dev.kruhlmann.imgfloat.config;
import java.util.Arrays; import java.util.Arrays;
import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.converter.FormHttpMessageConverter;
@@ -10,14 +9,12 @@ import org.springframework.web.client.RestTemplate;
final class OAuth2RestTemplateFactory { final class OAuth2RestTemplateFactory {
private OAuth2RestTemplateFactory() { private OAuth2RestTemplateFactory() {}
}
static RestTemplate create() { static RestTemplate create() {
RestTemplate restTemplate = new RestTemplate(Arrays.asList( RestTemplate restTemplate = new RestTemplate(
new FormHttpMessageConverter(), Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter())
new OAuth2AccessTokenResponseHttpMessageConverter() );
));
ClientHttpRequestFactory requestFactory = restTemplate.getRequestFactory(); ClientHttpRequestFactory requestFactory = restTemplate.getRequestFactory();
if (requestFactory instanceof SimpleClientHttpRequestFactory simple) { if (requestFactory instanceof SimpleClientHttpRequestFactory simple) {
simple.setConnectTimeout(30_000); simple.setConnectTimeout(30_000);

View File

@@ -1,7 +1,7 @@
package dev.kruhlmann.imgfloat.config; package dev.kruhlmann.imgfloat.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.OAuthFlow; import io.swagger.v3.oas.models.security.OAuthFlow;
import io.swagger.v3.oas.models.security.OAuthFlows; import io.swagger.v3.oas.models.security.OAuthFlows;
@@ -18,21 +18,26 @@ public class OpenApiConfig {
@Bean @Bean
public OpenAPI imgfloatOpenAPI() { public OpenAPI imgfloatOpenAPI() {
return new OpenAPI() return new OpenAPI()
.components(new Components().addSecuritySchemes(TWITCH_OAUTH_SCHEME, twitchOAuthScheme())) .components(new Components().addSecuritySchemes(TWITCH_OAUTH_SCHEME, twitchOAuthScheme()))
.addSecurityItem(new SecurityRequirement().addList(TWITCH_OAUTH_SCHEME)) .addSecurityItem(new SecurityRequirement().addList(TWITCH_OAUTH_SCHEME))
.info(new Info() .info(
.title("Imgfloat API") new Info()
.description("OpenAPI documentation for Imgfloat admin and broadcaster APIs.") .title("Imgfloat API")
.version("v1")); .description("OpenAPI documentation for Imgfloat admin and broadcaster APIs.")
.version("v1")
);
} }
private SecurityScheme twitchOAuthScheme() { private SecurityScheme twitchOAuthScheme() {
return new SecurityScheme() return new SecurityScheme()
.name(TWITCH_OAUTH_SCHEME) .name(TWITCH_OAUTH_SCHEME)
.type(SecurityScheme.Type.OAUTH2) .type(SecurityScheme.Type.OAUTH2)
.flows(new OAuthFlows() .flows(
.authorizationCode(new OAuthFlow() new OAuthFlows().authorizationCode(
.authorizationUrl("https://id.twitch.tv/oauth2/authorize") new OAuthFlow()
.tokenUrl("https://id.twitch.tv/oauth2/token"))); .authorizationUrl("https://id.twitch.tv/oauth2/authorize")
.tokenUrl("https://id.twitch.tv/oauth2/token")
)
);
} }
} }

View File

@@ -5,7 +5,8 @@ import java.time.Instant;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcOperations; import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
@@ -18,6 +19,7 @@ import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.OAuth2RefreshToken;
public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClientService { public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClientService {
private static final Logger LOG = LoggerFactory.getLogger(SQLiteOAuth2AuthorizedClientService.class); private static final Logger LOG = LoggerFactory.getLogger(SQLiteOAuth2AuthorizedClientService.class);
private static final String TABLE_NAME = "oauth2_authorized_client"; private static final String TABLE_NAME = "oauth2_authorized_client";
@@ -25,8 +27,10 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
private final ClientRegistrationRepository clientRegistrationRepository; private final ClientRegistrationRepository clientRegistrationRepository;
private final RowMapper<OAuth2AuthorizedClient> rowMapper; private final RowMapper<OAuth2AuthorizedClient> rowMapper;
public SQLiteOAuth2AuthorizedClientService(JdbcOperations jdbcOperations, public SQLiteOAuth2AuthorizedClientService(
ClientRegistrationRepository clientRegistrationRepository) { JdbcOperations jdbcOperations,
ClientRegistrationRepository clientRegistrationRepository
) {
this.jdbcOperations = jdbcOperations; this.jdbcOperations = jdbcOperations;
this.clientRegistrationRepository = clientRegistrationRepository; this.clientRegistrationRepository = clientRegistrationRepository;
this.rowMapper = (rs, rowNum) -> { this.rowMapper = (rs, rowNum) -> {
@@ -38,35 +42,37 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
} }
OAuth2AccessToken accessToken = new OAuth2AccessToken( OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER, OAuth2AccessToken.TokenType.BEARER,
rs.getString("access_token_value"), rs.getString("access_token_value"),
toInstant(rs.getObject("access_token_issued_at")), toInstant(rs.getObject("access_token_issued_at")),
toInstant(rs.getObject("access_token_expires_at")), toInstant(rs.getObject("access_token_expires_at")),
scopesFrom(rs.getString("access_token_scopes")) scopesFrom(rs.getString("access_token_scopes"))
); );
Object refreshValue = rs.getObject("refresh_token_value"); Object refreshValue = rs.getObject("refresh_token_value");
OAuth2RefreshToken refreshToken = refreshValue == null OAuth2RefreshToken refreshToken = refreshValue == null
? null ? null
: new OAuth2RefreshToken( : new OAuth2RefreshToken(refreshValue.toString(), toInstant(rs.getObject("refresh_token_issued_at")));
refreshValue.toString(),
toInstant(rs.getObject("refresh_token_issued_at"))
);
return new OAuth2AuthorizedClient(registration, principalName, accessToken, refreshToken); return new OAuth2AuthorizedClient(registration, principalName, accessToken, refreshToken);
}; };
} }
@Override @Override
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, String principalName) { public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(
String clientRegistrationId,
String principalName
) {
return jdbcOperations.query( return jdbcOperations.query(
"SELECT client_registration_id, principal_name, access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, refresh_token_value, refresh_token_issued_at " + "SELECT client_registration_id, principal_name, access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, refresh_token_value, refresh_token_issued_at " +
"FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?", "FROM " +
ps -> { TABLE_NAME +
ps.setString(1, clientRegistrationId); " WHERE client_registration_id = ? AND principal_name = ?",
ps.setString(2, principalName); (ps) -> {
}, ps.setString(1, clientRegistrationId);
rs -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null ps.setString(2, principalName);
},
(rs) -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null
); );
} }
@@ -74,51 +80,60 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) { public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
try { try {
jdbcOperations.update(""" jdbcOperations.update("""
INSERT INTO oauth2_authorized_client ( INSERT INTO oauth2_authorized_client (
client_registration_id, principal_name, client_registration_id, principal_name,
access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes,
refresh_token_value, refresh_token_issued_at refresh_token_value, refresh_token_issued_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(client_registration_id, principal_name) DO UPDATE SET ON CONFLICT(client_registration_id, principal_name) DO UPDATE SET
access_token_value=excluded.access_token_value, access_token_value=excluded.access_token_value,
access_token_issued_at=excluded.access_token_issued_at, access_token_issued_at=excluded.access_token_issued_at,
access_token_expires_at=excluded.access_token_expires_at, access_token_expires_at=excluded.access_token_expires_at,
access_token_scopes=excluded.access_token_scopes, access_token_scopes=excluded.access_token_scopes,
refresh_token_value=excluded.refresh_token_value, refresh_token_value=excluded.refresh_token_value,
refresh_token_issued_at=excluded.refresh_token_issued_at refresh_token_issued_at=excluded.refresh_token_issued_at
""", """, (preparedStatement) -> {
preparedStatement -> { preparedStatement.setString(1, authorizedClient.getClientRegistration().getRegistrationId());
preparedStatement.setString(1, authorizedClient.getClientRegistration().getRegistrationId()); preparedStatement.setString(2, principal.getName());
preparedStatement.setString(2, principal.getName()); setToken(preparedStatement, 3, authorizedClient.getAccessToken());
setToken(preparedStatement, 3, authorizedClient.getAccessToken()); preparedStatement.setObject(
preparedStatement.setObject(5, toEpochMillis(authorizedClient.getAccessToken().getExpiresAt()), java.sql.Types.BIGINT); 5,
preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes())); toEpochMillis(authorizedClient.getAccessToken().getExpiresAt()),
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken(); java.sql.Types.BIGINT
if (refreshToken != null) { );
preparedStatement.setString(7, refreshToken.getTokenValue()); preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes()));
preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT); OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
} else { if (refreshToken != null) {
preparedStatement.setNull(7, java.sql.Types.VARCHAR); preparedStatement.setString(7, refreshToken.getTokenValue());
preparedStatement.setNull(8, java.sql.Types.BIGINT); preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT);
} } else {
}); preparedStatement.setNull(7, java.sql.Types.VARCHAR);
preparedStatement.setNull(8, java.sql.Types.BIGINT);
}
});
} catch (DataAccessException ex) { } catch (DataAccessException ex) {
LOG.error("Failed to save authorized client for registration ID '{}' and principal '{}'", LOG.error(
authorizedClient.getClientRegistration().getRegistrationId(), "Failed to save authorized client for registration ID '{}' and principal '{}'",
principal.getName(), ex); authorizedClient.getClientRegistration().getRegistrationId(),
principal.getName(),
ex
);
} }
} }
@Override @Override
public void removeAuthorizedClient(String clientRegistrationId, String principalName) { public void removeAuthorizedClient(String clientRegistrationId, String principalName) {
jdbcOperations.update("DELETE FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?", jdbcOperations.update(
preparedStatement -> { "DELETE FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?",
preparedStatement.setString(1, clientRegistrationId); (preparedStatement) -> {
preparedStatement.setString(2, principalName); preparedStatement.setString(1, clientRegistrationId);
}); preparedStatement.setString(2, principalName);
}
);
} }
private void setToken(java.sql.PreparedStatement ps, int startIndex, OAuth2AccessToken token) throws java.sql.SQLException { private void setToken(java.sql.PreparedStatement ps, int startIndex, OAuth2AccessToken token)
throws java.sql.SQLException {
ps.setString(startIndex, token.getTokenValue()); ps.setString(startIndex, token.getTokenValue());
ps.setObject(startIndex + 1, toEpochMillis(token.getIssuedAt()), java.sql.Types.BIGINT); ps.setObject(startIndex + 1, toEpochMillis(token.getIssuedAt()), java.sql.Types.BIGINT);
} }
@@ -151,9 +166,9 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
return Set.of(); return Set.of();
} }
return Stream.of(scopeString.split(" ")) return Stream.of(scopeString.split(" "))
.map(String::trim) .map(String::trim)
.filter(s -> !s.isEmpty()) .filter((s) -> !s.isEmpty())
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
private String scopesToString(Set<String> scopes) { private String scopesToString(Set<String> scopes) {

View File

@@ -1,5 +1,6 @@
package dev.kruhlmann.imgfloat.config; package dev.kruhlmann.imgfloat.config;
import java.util.List;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationArguments;
@@ -8,8 +9,6 @@ import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.List;
@Component @Component
public class SchemaMigration implements ApplicationRunner { public class SchemaMigration implements ApplicationRunner {
@@ -32,7 +31,8 @@ public class SchemaMigration implements ApplicationRunner {
private void ensureSessionAttributeUpsertTrigger() { private void ensureSessionAttributeUpsertTrigger() {
try { try {
jdbcTemplate.execute(""" jdbcTemplate.execute(
"""
CREATE TRIGGER IF NOT EXISTS SPRING_SESSION_ATTRIBUTES_UPSERT CREATE TRIGGER IF NOT EXISTS SPRING_SESSION_ATTRIBUTES_UPSERT
BEFORE INSERT ON SPRING_SESSION_ATTRIBUTES BEFORE INSERT ON SPRING_SESSION_ATTRIBUTES
FOR EACH ROW FOR EACH ROW
@@ -41,7 +41,8 @@ public class SchemaMigration implements ApplicationRunner {
WHERE SESSION_PRIMARY_ID = NEW.SESSION_PRIMARY_ID WHERE SESSION_PRIMARY_ID = NEW.SESSION_PRIMARY_ID
AND ATTRIBUTE_NAME = NEW.ATTRIBUTE_NAME; AND ATTRIBUTE_NAME = NEW.ATTRIBUTE_NAME;
END; END;
"""); """
);
logger.info("Ensured SPRING_SESSION_ATTRIBUTES upsert trigger exists"); logger.info("Ensured SPRING_SESSION_ATTRIBUTES upsert trigger exists");
} catch (DataAccessException ex) { } catch (DataAccessException ex) {
logger.warn("Unable to ensure SPRING_SESSION_ATTRIBUTES upsert trigger", ex); logger.warn("Unable to ensure SPRING_SESSION_ATTRIBUTES upsert trigger", ex);
@@ -91,14 +92,32 @@ public class SchemaMigration implements ApplicationRunner {
addColumnIfMissing(table, columns, "preview", "TEXT", "NULL"); addColumnIfMissing(table, columns, "preview", "TEXT", "NULL");
} }
private void addColumnIfMissing(String tableName, List<String> existingColumns, String columnName, String dataType, String defaultValue) { private void addColumnIfMissing(
String tableName,
List<String> existingColumns,
String columnName,
String dataType,
String defaultValue
) {
if (existingColumns.contains(columnName)) { if (existingColumns.contains(columnName)) {
return; return;
} }
try { try {
jdbcTemplate.execute("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue); jdbcTemplate.execute(
jdbcTemplate.execute("UPDATE " + tableName + " SET " + columnName + " = " + defaultValue + " WHERE " + columnName + " IS NULL"); "ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue
);
jdbcTemplate.execute(
"UPDATE " +
tableName +
" SET " +
columnName +
" = " +
defaultValue +
" WHERE " +
columnName +
" IS NULL"
);
logger.info("Added missing column '{}' to {} table", columnName, tableName); logger.info("Added missing column '{}' to {} table", columnName, tableName);
} catch (DataAccessException ex) { } catch (DataAccessException ex) {
logger.warn("Failed to add column '{}' to {} table", columnName, tableName, ex); logger.warn("Failed to add column '{}' to {} table", columnName, tableName, ex);
@@ -107,7 +126,8 @@ public class SchemaMigration implements ApplicationRunner {
private void ensureAuthorizedClientTable() { private void ensureAuthorizedClientTable() {
try { try {
jdbcTemplate.execute(""" jdbcTemplate.execute(
"""
CREATE TABLE IF NOT EXISTS oauth2_authorized_client ( CREATE TABLE IF NOT EXISTS oauth2_authorized_client (
client_registration_id VARCHAR(100) NOT NULL, client_registration_id VARCHAR(100) NOT NULL,
principal_name VARCHAR(200) NOT NULL, principal_name VARCHAR(200) NOT NULL,
@@ -120,7 +140,8 @@ public class SchemaMigration implements ApplicationRunner {
refresh_token_issued_at INTEGER, refresh_token_issued_at INTEGER,
PRIMARY KEY (client_registration_id, principal_name) PRIMARY KEY (client_registration_id, principal_name)
) )
"""); """
);
logger.info("Ensured oauth2_authorized_client table exists"); logger.info("Ensured oauth2_authorized_client table exists");
} catch (DataAccessException ex) { } catch (DataAccessException ex) {
logger.warn("Unable to ensure oauth2_authorized_client table", ex); logger.warn("Unable to ensure oauth2_authorized_client table", ex);
@@ -136,13 +157,34 @@ public class SchemaMigration implements ApplicationRunner {
private void normalizeTimestampColumn(String column) { private void normalizeTimestampColumn(String column) {
try { try {
int updated = jdbcTemplate.update( int updated = jdbcTemplate.update(
"UPDATE oauth2_authorized_client " + "UPDATE oauth2_authorized_client " +
"SET " + column + " = CASE " + "SET " +
"WHEN " + column + " LIKE '%-%' THEN CAST(strftime('%s', " + column + ") AS INTEGER) * 1000 " + column +
"WHEN typeof(" + column + ") = 'text' AND " + column + " GLOB '[0-9]*' THEN CAST(" + column + " AS INTEGER) " + " = CASE " +
"WHEN typeof(" + column + ") = 'integer' THEN " + column + " " + "WHEN " +
"ELSE " + column + " END " + column +
"WHERE " + column + " IS NOT NULL"); " LIKE '%-%' THEN CAST(strftime('%s', " +
column +
") AS INTEGER) * 1000 " +
"WHEN typeof(" +
column +
") = 'text' AND " +
column +
" GLOB '[0-9]*' THEN CAST(" +
column +
" AS INTEGER) " +
"WHEN typeof(" +
column +
") = 'integer' THEN " +
column +
" " +
"ELSE " +
column +
" END " +
"WHERE " +
column +
" IS NOT NULL"
);
if (updated > 0) { if (updated > 0) {
logger.info("Normalized {} rows in oauth2_authorized_client.{}", updated, column); logger.info("Normalized {} rows in oauth2_authorized_client.{}", updated, column);
} }

View File

@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.config;
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.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -14,7 +15,6 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint; import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.http.HttpStatus;
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@@ -22,11 +22,14 @@ import org.springframework.http.HttpStatus;
public class SecurityConfig { public class SecurityConfig {
@Bean @Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, SecurityFilterChain securityFilterChain(
OAuth2AuthorizedClientRepository authorizedClientRepository) throws Exception { HttpSecurity http,
OAuth2AuthorizedClientRepository authorizedClientRepository
) throws Exception {
http http
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests((auth) ->
.requestMatchers( auth
.requestMatchers(
"/", "/",
"/favicon.ico", "/favicon.ico",
"/img/**", "/img/**",
@@ -38,26 +41,37 @@ public class SecurityConfig {
"/swagger-ui.html", "/swagger-ui.html",
"/swagger-ui/**", "/swagger-ui/**",
"/channels" "/channels"
).permitAll() )
.requestMatchers(HttpMethod.GET, "/view/*/broadcast").permitAll() .permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll() .requestMatchers(HttpMethod.GET, "/view/*/broadcast")
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/visible").permitAll() .permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas").permitAll() .requestMatchers(HttpMethod.GET, "/api/channels")
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content").permitAll() .permitAll()
.requestMatchers("/ws/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/channels/*/assets/visible")
.anyRequest().authenticated() .permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas")
.permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content")
.permitAll()
.requestMatchers("/ws/**")
.permitAll()
.anyRequest()
.authenticated()
) )
.oauth2Login(oauth -> oauth .oauth2Login((oauth) ->
.authorizedClientRepository(authorizedClientRepository) oauth
.tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient())) .authorizedClientRepository(authorizedClientRepository)
.userInfoEndpoint(user -> user.userService(twitchOAuth2UserService()))) .tokenEndpoint((token) -> token.accessTokenResponseClient(twitchAccessTokenResponseClient()))
.logout(logout -> logout.logoutSuccessUrl("/").permitAll()) .userInfoEndpoint((user) -> user.userService(twitchOAuth2UserService()))
.exceptionHandling(exceptions -> exceptions )
.defaultAuthenticationEntryPointFor( .logout((logout) -> logout.logoutSuccessUrl("/").permitAll())
.exceptionHandling((exceptions) ->
exceptions.defaultAuthenticationEntryPointFor(
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
new AntPathRequestMatcher("/api/**") new AntPathRequestMatcher("/api/**")
)) )
.csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**")); )
.csrf((csrf) -> csrf.ignoringRequestMatchers("/ws/**", "/api/**"));
return http.build(); return http.build();
} }

View File

@@ -4,31 +4,39 @@ import jakarta.annotation.PostConstruct;
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;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.util.unit.DataSize; import org.springframework.util.unit.DataSize;
import org.springframework.core.env.Environment;
@Component @Component
public class SystemEnvironmentValidator { public class SystemEnvironmentValidator {
private static final Logger log = LoggerFactory.getLogger(SystemEnvironmentValidator.class); private static final Logger log = LoggerFactory.getLogger(SystemEnvironmentValidator.class);
private final Environment environment; private final Environment environment;
@Value("${spring.security.oauth2.client.registration.twitch.client-id:#{null}}") @Value("${spring.security.oauth2.client.registration.twitch.client-id:#{null}}")
private String twitchClientId; private String twitchClientId;
@Value("${spring.security.oauth2.client.registration.twitch.client-secret:#{null}}") @Value("${spring.security.oauth2.client.registration.twitch.client-secret:#{null}}")
private String twitchClientSecret; private String twitchClientSecret;
@Value("${spring.servlet.multipart.max-file-size:#{null}}") @Value("${spring.servlet.multipart.max-file-size:#{null}}")
private String springMaxFileSize; private String springMaxFileSize;
@Value("${spring.servlet.multipart.max-request-size:#{null}}") @Value("${spring.servlet.multipart.max-request-size:#{null}}")
private String springMaxRequestSize; private String springMaxRequestSize;
@Value("${IMGFLOAT_ASSETS_PATH:#{null}}") @Value("${IMGFLOAT_ASSETS_PATH:#{null}}")
private String assetsPath; private String assetsPath;
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}")
private String previewsPath; private String previewsPath;
@Value("${IMGFLOAT_DB_PATH:#{null}}") @Value("${IMGFLOAT_DB_PATH:#{null}}")
private String dbPath; private String dbPath;
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") @Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}")
private String initialSysadmin; private String initialSysadmin;
@@ -41,7 +49,11 @@ public class SystemEnvironmentValidator {
@PostConstruct @PostConstruct
public void validate() { public void validate() {
if (Boolean.parseBoolean(environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper"))) { if (
Boolean.parseBoolean(
environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper")
)
) {
log.info("Skipping environment validation in test context"); log.info("Skipping environment validation in test context");
return; return;
} }
@@ -60,9 +72,7 @@ public class SystemEnvironmentValidator {
checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing); checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing);
if (!missing.isEmpty()) { if (!missing.isEmpty()) {
throw new IllegalStateException( throw new IllegalStateException("Missing or invalid environment variables:\n" + missing);
"Missing or invalid environment variables:\n" + missing
);
} }
log.info("Environment validation successful:"); log.info("Environment validation successful:");
@@ -93,7 +103,7 @@ public class SystemEnvironmentValidator {
private String redact(String value) { private String redact(String value) {
if (value != null && StringUtils.hasText(value)) { if (value != null && StringUtils.hasText(value)) {
return "**************"; return "**************";
}; }
return "<not set>"; return "<not set>";
} }
} }

View File

@@ -3,7 +3,6 @@ package dev.kruhlmann.imgfloat.config;
import java.net.URI; import java.net.URI;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.springframework.core.convert.converter.Converter; import org.springframework.core.convert.converter.Converter;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.RequestEntity; import org.springframework.http.RequestEntity;
@@ -19,11 +18,11 @@ import org.springframework.util.MultiValueMap;
* request body. Twitch ignores HTTP Basic authentication and responds with "missing client id" if * request body. Twitch ignores HTTP Basic authentication and responds with "missing client id" if
* those parameters are absent. * those parameters are absent.
*/ */
final class TwitchAuthorizationCodeGrantRequestEntityConverter implements final class TwitchAuthorizationCodeGrantRequestEntityConverter
Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> { implements Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {
private final Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> delegate = private final Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> delegate =
new OAuth2AuthorizationCodeGrantRequestEntityConverter(); new OAuth2AuthorizationCodeGrantRequestEntityConverter();
@Override @Override
public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest request) { public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest request) {
@@ -50,8 +49,7 @@ final class TwitchAuthorizationCodeGrantRequestEntityConverter implements
private MultiValueMap<String, String> cloneBody(MultiValueMap<?, ?> existingBody) { private MultiValueMap<String, String> cloneBody(MultiValueMap<?, ?> existingBody) {
MultiValueMap<String, String> copy = new LinkedMultiValueMap<>(); MultiValueMap<String, String> copy = new LinkedMultiValueMap<>();
existingBody.forEach((key, value) -> existingBody.forEach((key, value) -> copy.put(String.valueOf(key), new ArrayList<>((List<String>) value)));
copy.put(String.valueOf(key), new ArrayList<>((List<String>) value)));
return copy; return copy;
} }
} }

View File

@@ -3,7 +3,6 @@ package dev.kruhlmann.imgfloat.config;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
@@ -24,6 +23,7 @@ import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
@Configuration @Configuration
@EnableConfigurationProperties(OAuth2ClientProperties.class) @EnableConfigurationProperties(OAuth2ClientProperties.class)
class TwitchClientRegistrationConfig { class TwitchClientRegistrationConfig {
private static final Logger LOG = LoggerFactory.getLogger(TwitchClientRegistrationConfig.class); private static final Logger LOG = LoggerFactory.getLogger(TwitchClientRegistrationConfig.class);
@Bean @Bean
@@ -37,7 +37,8 @@ class TwitchClientRegistrationConfig {
OAuth2ClientProperties.Provider provider = properties.getProvider().get(providerId); OAuth2ClientProperties.Provider provider = properties.getProvider().get(providerId);
if (provider == null) { if (provider == null) {
throw new IllegalStateException( throw new IllegalStateException(
"Missing OAuth2 provider configuration for registration '" + registrationId + "'."); "Missing OAuth2 provider configuration for registration '" + registrationId + "'."
);
} }
if (!"twitch".equals(registrationId)) { if (!"twitch".equals(registrationId)) {
LOG.warn("Unexpected OAuth2 registration '{}' found; only Twitch is supported.", registrationId); LOG.warn("Unexpected OAuth2 registration '{}' found; only Twitch is supported.", registrationId);
@@ -49,24 +50,25 @@ class TwitchClientRegistrationConfig {
} }
private ClientRegistration buildTwitchRegistration( private ClientRegistration buildTwitchRegistration(
String registrationId, String registrationId,
OAuth2ClientProperties.Registration registration, OAuth2ClientProperties.Registration registration,
OAuth2ClientProperties.Provider provider) { OAuth2ClientProperties.Provider provider
) {
String clientId = sanitize(registration.getClientId(), "TWITCH_CLIENT_ID"); String clientId = sanitize(registration.getClientId(), "TWITCH_CLIENT_ID");
String clientSecret = sanitize(registration.getClientSecret(), "TWITCH_CLIENT_SECRET"); String clientSecret = sanitize(registration.getClientSecret(), "TWITCH_CLIENT_SECRET");
return ClientRegistration.withRegistrationId(registrationId) return ClientRegistration.withRegistrationId(registrationId)
.clientName(registration.getClientName()) .clientName(registration.getClientName())
.clientId(clientId) .clientId(clientId)
.clientSecret(clientSecret) .clientSecret(clientSecret)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(new AuthorizationGrantType(registration.getAuthorizationGrantType())) .authorizationGrantType(new AuthorizationGrantType(registration.getAuthorizationGrantType()))
.redirectUri(registration.getRedirectUri()) .redirectUri(registration.getRedirectUri())
.scope(registration.getScope()) .scope(registration.getScope())
.authorizationUri(provider.getAuthorizationUri()) .authorizationUri(provider.getAuthorizationUri())
.tokenUri(provider.getTokenUri()) .tokenUri(provider.getTokenUri())
.userInfoUri(provider.getUserInfoUri()) .userInfoUri(provider.getUserInfoUri())
.userNameAttributeName(provider.getUserNameAttribute()) .userNameAttributeName(provider.getUserNameAttribute())
.build(); .build();
} }
private String sanitize(String value, String name) { private String sanitize(String value, String name) {
@@ -74,7 +76,9 @@ class TwitchClientRegistrationConfig {
return null; return null;
} }
String trimmed = value.trim(); String trimmed = value.trim();
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { if (
(trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
String unquoted = trimmed.substring(1, trimmed.length() - 1).trim(); String unquoted = trimmed.substring(1, trimmed.length() - 1).trim();
LOG.info("Sanitizing {} by stripping surrounding quotes.", name); LOG.info("Sanitizing {} by stripping surrounding quotes.", name);
return unquoted; return unquoted;

View File

@@ -1,9 +1,8 @@
package dev.kruhlmann.imgfloat.config; package dev.kruhlmann.imgfloat.config;
import java.io.IOException;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
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;
@@ -31,20 +30,24 @@ class TwitchOAuth2ErrorResponseErrorHandler extends OAuth2ErrorResponseErrorHand
String body = new String(bodyBytes, StandardCharsets.UTF_8); String body = new String(bodyBytes, StandardCharsets.UTF_8);
if (body.isBlank()) { if (body.isBlank()) {
LOG.warn("Failed to parse Twitch OAuth error response (status: {}, headers: {}): <empty body>", LOG.warn(
response.getStatusCode(), "Failed to parse Twitch OAuth error response (status: {}, headers: {}): <empty body>",
response.getHeaders()); response.getStatusCode(),
response.getHeaders()
);
throw asAuthorizationException(body, null); throw asAuthorizationException(body, null);
} }
try { try {
super.handleError(new CachedBodyClientHttpResponse(response, bodyBytes)); super.handleError(new CachedBodyClientHttpResponse(response, bodyBytes));
} catch (HttpMessageNotReadableException | IllegalArgumentException ex) { } catch (HttpMessageNotReadableException | IllegalArgumentException ex) {
LOG.warn("Failed to parse Twitch OAuth error response (status: {}, headers: {}): {}", LOG.warn(
response.getStatusCode(), "Failed to parse Twitch OAuth error response (status: {}, headers: {}): {}",
response.getHeaders(), response.getStatusCode(),
body, response.getHeaders(),
ex); body,
ex
);
throw asAuthorizationException(body, ex); throw asAuthorizationException(body, ex);
} }
} }

View File

@@ -5,7 +5,6 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Function; import java.util.function.Function;
import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory;
@@ -46,18 +45,19 @@ class TwitchOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OA
private OAuth2UserRequest twitchUserRequest(OAuth2UserRequest userRequest) { private OAuth2UserRequest twitchUserRequest(OAuth2UserRequest userRequest) {
return new OAuth2UserRequest( return new OAuth2UserRequest(
twitchUserRegistration(userRequest), twitchUserRegistration(userRequest),
userRequest.getAccessToken(), userRequest.getAccessToken(),
userRequest.getAdditionalParameters()); userRequest.getAdditionalParameters()
);
} }
private ClientRegistration twitchUserRegistration(OAuth2UserRequest userRequest) { private ClientRegistration twitchUserRegistration(OAuth2UserRequest userRequest) {
ClientRegistration registration = userRequest.getClientRegistration(); ClientRegistration registration = userRequest.getClientRegistration();
return ClientRegistration.withClientRegistration(registration) return ClientRegistration.withClientRegistration(registration)
// The Twitch response nests user details under a "data" array, so accept that // The Twitch response nests user details under a "data" array, so accept that
// shape for the initial parsing step. // shape for the initial parsing step.
.userNameAttributeName("data") .userNameAttributeName("data")
.build(); .build();
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")

View File

@@ -35,6 +35,8 @@ public class UploadLimitsConfig {
} }
private boolean isTestContext() { private boolean isTestContext() {
return Boolean.parseBoolean(environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper")); return Boolean.parseBoolean(
environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper")
);
} }
} }

View File

@@ -9,6 +9,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
@Configuration @Configuration
@EnableWebSocketMessageBroker @EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override @Override
public void registerStompEndpoints(StompEndpointRegistry registry) { public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS(); registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();

View File

@@ -1,18 +1,28 @@
package dev.kruhlmann.imgfloat.controller; package dev.kruhlmann.imgfloat.controller;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import dev.kruhlmann.imgfloat.model.AdminRequest; import dev.kruhlmann.imgfloat.model.AdminRequest;
import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.AssetView;
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.TransformRequest;
import dev.kruhlmann.imgfloat.model.TwitchUserProfile; import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; import dev.kruhlmann.imgfloat.model.VisibilityRequest;
import dev.kruhlmann.imgfloat.model.OauthSessionUser; 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;
import dev.kruhlmann.imgfloat.service.AuthorizationService;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.io.IOException;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -30,25 +40,14 @@ import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Collection;
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
@RequestMapping("/api/channels/{broadcaster}") @RequestMapping("/api/channels/{broadcaster}")
@SecurityRequirement(name = "twitchOAuth") @SecurityRequirement(name = "twitchOAuth")
public class ChannelApiController { public class ChannelApiController {
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class); private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
private final ChannelDirectoryService channelDirectoryService; private final ChannelDirectoryService channelDirectoryService;
private final OAuth2AuthorizedClientService authorizedClientService; private final OAuth2AuthorizedClientService authorizedClientService;
@@ -71,9 +70,11 @@ public class ChannelApiController {
} }
@PostMapping("/admins") @PostMapping("/admins")
public ResponseEntity<?> addAdmin(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<?> addAdmin(
@Valid @RequestBody AdminRequest request, @PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken) { @Valid @RequestBody AdminRequest request,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.info("User {} adding admin {} to {}", sessionUsername, request.getUsername(), broadcaster); LOG.info("User {} adding admin {} to {}", sessionUsername, request.getUsername(), broadcaster);
@@ -85,32 +86,34 @@ public class ChannelApiController {
} }
@GetMapping("/admins") @GetMapping("/admins")
public Collection<TwitchUserProfile> listAdmins(@PathVariable("broadcaster") String broadcaster, public Collection<TwitchUserProfile> listAdmins(
OAuth2AuthenticationToken oauthToken, @PathVariable("broadcaster") String broadcaster,
HttpServletRequest request) { OAuth2AuthenticationToken oauthToken,
HttpServletRequest request
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.debug("Listing admins for {} by {}", broadcaster, sessionUsername); LOG.debug("Listing admins for {} by {}", broadcaster, sessionUsername);
var channel = channelDirectoryService.getOrCreateChannel(broadcaster); var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
List<String> admins = channel.getAdmins().stream() List<String> admins = channel.getAdmins().stream().sorted(Comparator.naturalOrder()).toList();
.sorted(Comparator.naturalOrder())
.toList();
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request); OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request);
String accessToken = Optional.ofNullable(authorizedClient) String accessToken = Optional.ofNullable(authorizedClient)
.map(OAuth2AuthorizedClient::getAccessToken) .map(OAuth2AuthorizedClient::getAccessToken)
.map(token -> token.getTokenValue()) .map((token) -> token.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((registration) -> registration.getClientId())
.orElse(null); .orElse(null);
return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId); return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId);
} }
@GetMapping("/admins/suggestions") @GetMapping("/admins/suggestions")
public Collection<TwitchUserProfile> listAdminSuggestions(@PathVariable("broadcaster") String broadcaster, public Collection<TwitchUserProfile> listAdminSuggestions(
OAuth2AuthenticationToken oauthToken, @PathVariable("broadcaster") String broadcaster,
HttpServletRequest request) { OAuth2AuthenticationToken oauthToken,
HttpServletRequest request
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.debug("Listing admin suggestions for {} by {}", broadcaster, sessionUsername); LOG.debug("Listing admin suggestions for {} by {}", broadcaster, sessionUsername);
@@ -118,28 +121,38 @@ public class ChannelApiController {
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request); OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request);
if (authorizedClient == null) { if (authorizedClient == null) {
LOG.warn("No authorized Twitch client found for {} while fetching admin suggestions for {}", sessionUsername, broadcaster); LOG.warn(
"No authorized Twitch client found for {} while fetching admin suggestions for {}",
sessionUsername,
broadcaster
);
return List.of(); return List.of();
} }
String accessToken = Optional.ofNullable(authorizedClient) String accessToken = Optional.ofNullable(authorizedClient)
.map(OAuth2AuthorizedClient::getAccessToken) .map(OAuth2AuthorizedClient::getAccessToken)
.map(token -> token.getTokenValue()) .map((token) -> token.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((registration) -> registration.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("Missing Twitch credentials for {} while fetching admin suggestions for {}", sessionUsername, broadcaster); LOG.warn(
"Missing Twitch credentials for {} while fetching admin suggestions for {}",
sessionUsername,
broadcaster
);
return List.of(); return List.of();
} }
return twitchUserLookupService.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId); return twitchUserLookupService.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId);
} }
@DeleteMapping("/admins/{username}") @DeleteMapping("/admins/{username}")
public ResponseEntity<?> removeAdmin(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<?> removeAdmin(
@PathVariable("username") String username, @PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken) { @PathVariable("username") String username,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.info("User {} removing admin {} from {}", sessionUsername, username, broadcaster); LOG.info("User {} removing admin {} from {}", sessionUsername, username, broadcaster);
@@ -163,30 +176,44 @@ public class ChannelApiController {
} }
@PutMapping("/canvas") @PutMapping("/canvas")
public CanvasSettingsRequest updateCanvas(@PathVariable("broadcaster") String broadcaster, public CanvasSettingsRequest updateCanvas(
@Valid @RequestBody CanvasSettingsRequest request, @PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken) { @Valid @RequestBody CanvasSettingsRequest request,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.info("Updating canvas for {} by {}: {}x{}", broadcaster, sessionUsername, request.getWidth(), request.getHeight()); LOG.info(
"Updating canvas for {} by {}: {}x{}",
broadcaster,
sessionUsername,
request.getWidth(),
request.getHeight()
);
return channelDirectoryService.updateCanvasSettings(broadcaster, request); return channelDirectoryService.updateCanvasSettings(broadcaster, request);
} }
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<AssetView> createAsset(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<AssetView> createAsset(
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file, @PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken) { @org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
broadcaster,
sessionUsername
);
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
LOG.warn("User {} attempted to upload empty file to {}", sessionUsername, broadcaster); LOG.warn("User {} attempted to upload empty file to {}", sessionUsername, broadcaster);
throw new ResponseStatusException(BAD_REQUEST, "Asset file is required"); throw new ResponseStatusException(BAD_REQUEST, "Asset file is required");
} }
try { try {
LOG.info("User {} uploading asset {} to {}", sessionUsername, file.getOriginalFilename(), broadcaster); LOG.info("User {} uploading asset {} to {}", sessionUsername, file.getOriginalFilename(), broadcaster);
return channelDirectoryService.createAsset(broadcaster, file) return channelDirectoryService
.map(ResponseEntity::ok) .createAsset(broadcaster, file)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image")); .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
} catch (IOException e) { } catch (IOException e) {
LOG.error("Failed to process asset upload for {} by {}", broadcaster, sessionUsername, e); LOG.error("Failed to process asset upload for {} by {}", broadcaster, sessionUsername, e);
throw new ResponseStatusException(BAD_REQUEST, "Failed to process image", e); throw new ResponseStatusException(BAD_REQUEST, "Failed to process image", e);
@@ -194,88 +221,130 @@ public class ChannelApiController {
} }
@PutMapping("/assets/{assetId}/transform") @PutMapping("/assets/{assetId}/transform")
public ResponseEntity<AssetView> transform(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<AssetView> transform(
@PathVariable("assetId") String assetId, @PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody TransformRequest request, @PathVariable("assetId") String assetId,
OAuth2AuthenticationToken oauthToken) { @Valid @RequestBody TransformRequest request,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
broadcaster,
sessionUsername
);
LOG.debug("Applying transform to asset {} on {} by {}", assetId, broadcaster, sessionUsername); LOG.debug("Applying transform to asset {} on {} by {}", assetId, broadcaster, sessionUsername);
return channelDirectoryService.updateTransform(broadcaster, assetId, request) return channelDirectoryService
.map(ResponseEntity::ok) .updateTransform(broadcaster, assetId, request)
.orElseThrow(() -> { .map(ResponseEntity::ok)
LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername); .orElseThrow(() -> {
return new ResponseStatusException(NOT_FOUND, "Asset not found"); LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
}); return new ResponseStatusException(NOT_FOUND, "Asset not found");
});
} }
@PostMapping("/assets/{assetId}/play") @PostMapping("/assets/{assetId}/play")
public ResponseEntity<AssetView> play(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<AssetView> play(
@PathVariable("assetId") String assetId, @PathVariable("broadcaster") String broadcaster,
@RequestBody(required = false) PlaybackRequest request, @PathVariable("assetId") String assetId,
OAuth2AuthenticationToken oauthToken) { @RequestBody(required = false) PlaybackRequest request,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
broadcaster,
sessionUsername
);
LOG.info("Triggering playback for asset {} on {} by {}", assetId, broadcaster, sessionUsername); LOG.info("Triggering playback for asset {} on {} by {}", assetId, broadcaster, sessionUsername);
return channelDirectoryService.triggerPlayback(broadcaster, assetId, request) return channelDirectoryService
.map(ResponseEntity::ok) .triggerPlayback(broadcaster, assetId, request)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
} }
@PutMapping("/assets/{assetId}/visibility") @PutMapping("/assets/{assetId}/visibility")
public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<AssetView> visibility(
@PathVariable("assetId") String assetId, @PathVariable("broadcaster") String broadcaster,
@RequestBody VisibilityRequest request, @PathVariable("assetId") String assetId,
OAuth2AuthenticationToken oauthToken) { @RequestBody VisibilityRequest request,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
LOG.info("Updating visibility for asset {} on {} by {} to hidden={} ", assetId, broadcaster, sessionUsername , request.isHidden()); broadcaster,
return channelDirectoryService.updateVisibility(broadcaster, assetId, request) sessionUsername
.map(ResponseEntity::ok) );
.orElseThrow(() -> { LOG.info(
LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername); "Updating visibility for asset {} on {} by {} to hidden={} ",
return new ResponseStatusException(NOT_FOUND, "Asset not found"); assetId,
}); broadcaster,
sessionUsername,
request.isHidden()
);
return channelDirectoryService
.updateVisibility(broadcaster, assetId, request)
.map(ResponseEntity::ok)
.orElseThrow(() -> {
LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
return new ResponseStatusException(NOT_FOUND, "Asset not found");
});
} }
@GetMapping("/assets/{assetId}/content") @GetMapping("/assets/{assetId}/content")
public ResponseEntity<byte[]> getAssetContent(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<byte[]> getAssetContent(
@PathVariable("assetId") String assetId) { @PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId
) {
LOG.debug("Serving asset {} for broadcaster {}", assetId, broadcaster); LOG.debug("Serving asset {} for broadcaster {}", assetId, broadcaster);
return channelDirectoryService.getAssetContent(assetId) return channelDirectoryService
.map(content -> ResponseEntity.ok() .getAssetContent(assetId)
.map((content) ->
ResponseEntity.ok()
.header("X-Content-Type-Options", "nosniff") .header("X-Content-Type-Options", "nosniff")
.header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType()))
.contentType(MediaType.parseMediaType(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType()))
.body(content.bytes())) .body(content.bytes())
)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
} }
@GetMapping("/assets/{assetId}/preview") @GetMapping("/assets/{assetId}/preview")
public ResponseEntity<byte[]> getAssetPreview(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<byte[]> getAssetPreview(
@PathVariable("assetId") String assetId) { @PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId
) {
LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster); LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster);
return channelDirectoryService.getAssetPreview(assetId, true) return channelDirectoryService
.map(content -> ResponseEntity.ok() .getAssetPreview(assetId, true)
.map((content) ->
ResponseEntity.ok()
.header("X-Content-Type-Options", "nosniff") .header("X-Content-Type-Options", "nosniff")
.contentType(MediaType.parseMediaType(content.mediaType())) .contentType(MediaType.parseMediaType(content.mediaType()))
.body(content.bytes())) .body(content.bytes())
)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found"));
} }
private String contentDispositionFor(String mediaType) { private String contentDispositionFor(String mediaType) {
if (mediaType != null && dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)) { if (
mediaType != null &&
dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)
) {
return "inline"; return "inline";
} }
return "attachment"; return "attachment";
} }
@DeleteMapping("/assets/{assetId}") @DeleteMapping("/assets/{assetId}")
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster, public ResponseEntity<?> delete(
@PathVariable("assetId") String assetId, @PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken) { @PathVariable("assetId") String assetId,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
broadcaster,
sessionUsername
);
boolean removed = channelDirectoryService.deleteAsset(assetId); boolean removed = channelDirectoryService.deleteAsset(assetId);
if (!removed) { if (!removed) {
LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, sessionUsername); LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
@@ -285,9 +354,11 @@ public class ChannelApiController {
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
private OAuth2AuthorizedClient resolveAuthorizedClient(OAuth2AuthenticationToken oauthToken, private OAuth2AuthorizedClient resolveAuthorizedClient(
OAuth2AuthorizedClient authorizedClient, OAuth2AuthenticationToken oauthToken,
HttpServletRequest request) { OAuth2AuthorizedClient authorizedClient,
HttpServletRequest request
) {
if (authorizedClient != null) { if (authorizedClient != null) {
return authorizedClient; return authorizedClient;
} }
@@ -295,12 +366,16 @@ public class ChannelApiController {
return null; return null;
} }
OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient( OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient(
oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getAuthorizedClientRegistrationId(),
oauthToken, oauthToken,
request); request
);
if (sessionClient != null) { if (sessionClient != null) {
return sessionClient; return sessionClient;
} }
return authorizedClientService.loadAuthorizedClient(oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName()); return authorizedClientService.loadAuthorizedClient(
oauthToken.getAuthorizedClientRegistrationId(),
oauthToken.getName()
);
} }
} }

View File

@@ -1,13 +1,12 @@
package dev.kruhlmann.imgfloat.controller; package dev.kruhlmann.imgfloat.controller;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/channels") @RequestMapping("/api/channels")
public class ChannelDirectoryApiController { public class ChannelDirectoryApiController {

View File

@@ -1,19 +1,27 @@
package dev.kruhlmann.imgfloat.controller; package dev.kruhlmann.imgfloat.controller;
import dev.kruhlmann.imgfloat.model.Settings; import static org.springframework.http.HttpStatus.BAD_REQUEST;
import dev.kruhlmann.imgfloat.model.OauthSessionUser; import static org.springframework.http.HttpStatus.FORBIDDEN;
import dev.kruhlmann.imgfloat.service.SettingsService; import static org.springframework.http.HttpStatus.NOT_FOUND;
import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.SettingsService;
import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.io.IOException;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
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.annotation.RegisteredOAuth2AuthorizedClient; import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
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;
@@ -24,20 +32,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.Collection;
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
@RestController @RestController
@RequestMapping("/api/settings") @RequestMapping("/api/settings")
@SecurityRequirement(name = "administrator") @SecurityRequirement(name = "administrator")
public class SettingsApiController { public class SettingsApiController {
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class); private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
private final SettingsService settingsService; private final SettingsService settingsService;
@@ -49,7 +48,10 @@ public class SettingsApiController {
} }
@PutMapping("/set") @PutMapping("/set")
public ResponseEntity<Settings> setSettings(@Valid @RequestBody Settings newSettings, OAuth2AuthenticationToken oauthToken) { public ResponseEntity<Settings> setSettings(
@Valid @RequestBody Settings newSettings,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername); authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername);

View File

@@ -1,14 +1,16 @@
package dev.kruhlmann.imgfloat.controller; package dev.kruhlmann.imgfloat.controller;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import static org.springframework.http.HttpStatus.FORBIDDEN;
import dev.kruhlmann.imgfloat.service.VersionService; import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import dev.kruhlmann.imgfloat.service.SettingsService;
import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
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.Settings;
import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.service.SettingsService;
import dev.kruhlmann.imgfloat.service.VersionService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -19,11 +21,9 @@ import org.springframework.ui.Model;
import org.springframework.util.unit.DataSize; import org.springframework.util.unit.DataSize;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
@Controller @Controller
public class ViewController { public class ViewController {
private static final Logger LOG = LoggerFactory.getLogger(ViewController.class); private static final Logger LOG = LoggerFactory.getLogger(ViewController.class);
private final ChannelDirectoryService channelDirectoryService; private final ChannelDirectoryService channelDirectoryService;
private final VersionService versionService; private final VersionService versionService;
@@ -85,11 +85,16 @@ public class ViewController {
} }
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin") @org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin")
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, public String adminView(
OAuth2AuthenticationToken oauthToken, @org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
Model model) { OAuth2AuthenticationToken oauthToken,
Model model
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
broadcaster,
sessionUsername
);
LOG.info("Rendering admin console for {} (requested by {})", broadcaster, sessionUsername); LOG.info("Rendering admin console for {} (requested by {})", broadcaster, sessionUsername);
Settings settings = settingsService.get(); Settings settings = settingsService.get();
model.addAttribute("broadcaster", broadcaster.toLowerCase()); model.addAttribute("broadcaster", broadcaster.toLowerCase());
@@ -106,8 +111,10 @@ public class ViewController {
} }
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast") @org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast")
public String broadcastView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, public String broadcastView(
Model model) { @org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
Model model
) {
LOG.debug("Rendering broadcast overlay for {}", broadcaster); LOG.debug("Rendering broadcast overlay for {}", broadcaster);
model.addAttribute("broadcaster", broadcaster.toLowerCase()); model.addAttribute("broadcaster", broadcaster.toLowerCase());
return "broadcast"; return "broadcast";

View File

@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.model;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
public class AdminRequest { public class AdminRequest {
@NotBlank @NotBlank
private String username; private String username;

View File

@@ -4,9 +4,8 @@ import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.PreUpdate; import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.Instant; import java.time.Instant;
import java.util.Locale; import java.util.Locale;
import java.util.UUID; import java.util.UUID;
@@ -14,18 +13,25 @@ import java.util.UUID;
@Entity @Entity
@Table(name = "assets") @Table(name = "assets")
public class Asset { public class Asset {
@Id @Id
private String id; private String id;
@Column(nullable = false) @Column(nullable = false)
private String broadcaster; private String broadcaster;
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@Column(columnDefinition = "TEXT", nullable = false) @Column(columnDefinition = "TEXT", nullable = false)
private String url; private String url;
@Column(columnDefinition = "TEXT", nullable = false) @Column(columnDefinition = "TEXT", nullable = false)
private String preview; private String preview;
@Column(nullable = false) @Column(nullable = false)
private Instant createdAt; private Instant createdAt;
private double x; private double x;
private double y; private double y;
private double width; private double width;
@@ -43,8 +49,7 @@ public class Asset {
private Double audioVolume; private Double audioVolume;
private boolean hidden; private boolean hidden;
public Asset() { public Asset() {}
}
public Asset(String broadcaster, String name, String url, double width, double height) { public Asset(String broadcaster, String name, String url, double width, double height) {
this.id = UUID.randomUUID().toString(); this.id = UUID.randomUUID().toString();

View File

@@ -4,12 +4,13 @@ import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public class AssetEvent { public class AssetEvent {
public enum Type { public enum Type {
CREATED, CREATED,
UPDATED, UPDATED,
VISIBILITY, VISIBILITY,
PLAY, PLAY,
DELETED DELETED,
} }
private Type type; private Type type;

View File

@@ -8,37 +8,37 @@ import com.fasterxml.jackson.annotation.JsonInclude;
*/ */
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
public record AssetPatch( public record AssetPatch(
String id, String id,
Double x, Double x,
Double y, Double y,
Double width, Double width,
Double height, Double height,
Double rotation, Double rotation,
Double speed, Double speed,
Boolean muted, Boolean muted,
Integer zIndex, Integer zIndex,
Boolean hidden, Boolean hidden,
Boolean audioLoop, Boolean audioLoop,
Integer audioDelayMillis, Integer audioDelayMillis,
Double audioSpeed, Double audioSpeed,
Double audioPitch, Double audioPitch,
Double audioVolume Double audioVolume
) { ) {
public static TransformSnapshot capture(Asset asset) { public static TransformSnapshot capture(Asset asset) {
return new TransformSnapshot( return new TransformSnapshot(
asset.getX(), asset.getX(),
asset.getY(), asset.getY(),
asset.getWidth(), asset.getWidth(),
asset.getHeight(), asset.getHeight(),
asset.getRotation(), asset.getRotation(),
asset.getSpeed(), asset.getSpeed(),
asset.isMuted(), asset.isMuted(),
asset.getZIndex(), asset.getZIndex(),
asset.isAudioLoop(), asset.isAudioLoop(),
asset.getAudioDelayMillis(), asset.getAudioDelayMillis(),
asset.getAudioSpeed(), asset.getAudioSpeed(),
asset.getAudioPitch(), asset.getAudioPitch(),
asset.getAudioVolume() asset.getAudioVolume()
); );
} }
@@ -48,41 +48,43 @@ public record AssetPatch(
*/ */
public static AssetPatch fromTransform(TransformSnapshot before, Asset asset, TransformRequest request) { public static AssetPatch fromTransform(TransformSnapshot before, Asset asset, TransformRequest request) {
return new AssetPatch( return new AssetPatch(
asset.getId(), asset.getId(),
changed(before.x(), asset.getX()), changed(before.x(), asset.getX()),
changed(before.y(), asset.getY()), changed(before.y(), asset.getY()),
changed(before.width(), asset.getWidth()), changed(before.width(), asset.getWidth()),
changed(before.height(), asset.getHeight()), changed(before.height(), asset.getHeight()),
changed(before.rotation(), asset.getRotation()), changed(before.rotation(), asset.getRotation()),
request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null, request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null,
request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null, request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null,
request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null, request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null,
null, null,
request.getAudioLoop() != null ? changed(before.audioLoop(), asset.isAudioLoop()) : null, request.getAudioLoop() != null ? changed(before.audioLoop(), asset.isAudioLoop()) : null,
request.getAudioDelayMillis() != null ? changed(before.audioDelayMillis(), asset.getAudioDelayMillis()) : null, request.getAudioDelayMillis() != null
request.getAudioSpeed() != null ? changed(before.audioSpeed(), asset.getAudioSpeed()) : null, ? changed(before.audioDelayMillis(), asset.getAudioDelayMillis())
request.getAudioPitch() != null ? changed(before.audioPitch(), asset.getAudioPitch()) : null, : null,
request.getAudioVolume() != null ? changed(before.audioVolume(), asset.getAudioVolume()) : null request.getAudioSpeed() != null ? changed(before.audioSpeed(), asset.getAudioSpeed()) : null,
request.getAudioPitch() != null ? changed(before.audioPitch(), asset.getAudioPitch()) : null,
request.getAudioVolume() != null ? changed(before.audioVolume(), asset.getAudioVolume()) : null
); );
} }
public static AssetPatch fromVisibility(Asset asset) { public static AssetPatch fromVisibility(Asset asset) {
return new AssetPatch( return new AssetPatch(
asset.getId(), asset.getId(),
null, null,
null, null,
null, null,
null, null,
null, null,
null, null,
null, null,
null, null,
asset.isHidden(), asset.isHidden(),
null, null,
null, null,
null, null,
null, null,
null null
); );
} }
@@ -99,18 +101,18 @@ public record AssetPatch(
} }
public record TransformSnapshot( public record TransformSnapshot(
double x, double x,
double y, double y,
double width, double width,
double height, double height,
double rotation, double rotation,
double speed, double speed,
boolean muted, boolean muted,
int zIndex, int zIndex,
boolean audioLoop, boolean audioLoop,
int audioDelayMillis, int audioDelayMillis,
double audioSpeed, double audioSpeed,
double audioPitch, double audioPitch,
double audioVolume double audioVolume
) { } ) {}
} }

View File

@@ -3,57 +3,57 @@ package dev.kruhlmann.imgfloat.model;
import java.time.Instant; import java.time.Instant;
public record AssetView( public record AssetView(
String id, String id,
String broadcaster, String broadcaster,
String name, String name,
String url, String url,
String previewUrl, String previewUrl,
double x, double x,
double y, double y,
double width, double width,
double height, double height,
double rotation, double rotation,
Double speed, Double speed,
Boolean muted, Boolean muted,
String mediaType, String mediaType,
String originalMediaType, String originalMediaType,
Integer zIndex, Integer zIndex,
Boolean audioLoop, Boolean audioLoop,
Integer audioDelayMillis, Integer audioDelayMillis,
Double audioSpeed, Double audioSpeed,
Double audioPitch, Double audioPitch,
Double audioVolume, Double audioVolume,
boolean hidden, boolean hidden,
boolean hasPreview, boolean hasPreview,
Instant createdAt Instant createdAt
) { ) {
public static AssetView from(String broadcaster, Asset asset) { public static AssetView from(String broadcaster, Asset asset) {
return new AssetView( return new AssetView(
asset.getId(), asset.getId(),
asset.getBroadcaster(), asset.getBroadcaster(),
asset.getName(), asset.getName(),
"/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content",
asset.getPreview() != null && !asset.getPreview().isBlank() asset.getPreview() != null && !asset.getPreview().isBlank()
? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview"
: null, : null,
asset.getX(), asset.getX(),
asset.getY(), asset.getY(),
asset.getWidth(), asset.getWidth(),
asset.getHeight(), asset.getHeight(),
asset.getRotation(), asset.getRotation(),
asset.getSpeed(), asset.getSpeed(),
asset.isMuted(), asset.isMuted(),
asset.getMediaType(), asset.getMediaType(),
asset.getOriginalMediaType(), asset.getOriginalMediaType(),
asset.getZIndex(), asset.getZIndex(),
asset.isAudioLoop(), asset.isAudioLoop(),
asset.getAudioDelayMillis(), asset.getAudioDelayMillis(),
asset.getAudioSpeed(), asset.getAudioSpeed(),
asset.getAudioPitch(), asset.getAudioPitch(),
asset.getAudioVolume(), asset.getAudioVolume(),
asset.isHidden(), asset.isHidden(),
asset.getPreview() != null && !asset.getPreview().isBlank(), asset.getPreview() != null && !asset.getPreview().isBlank(),
asset.getCreatedAt() asset.getCreatedAt()
); );
} }
} }

View File

@@ -3,14 +3,14 @@ package dev.kruhlmann.imgfloat.model;
import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Positive;
public class CanvasSettingsRequest { public class CanvasSettingsRequest {
@Positive @Positive
private double width; private double width;
@Positive @Positive
private double height; private double height;
public CanvasSettingsRequest() { public CanvasSettingsRequest() {}
}
public CanvasSettingsRequest(double width, double height) { public CanvasSettingsRequest(double width, double height) {
this.width = width; this.width = width;

View File

@@ -10,7 +10,6 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate; import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet; import java.util.HashSet;
import java.util.Locale; import java.util.Locale;
@@ -20,6 +19,7 @@ import java.util.stream.Collectors;
@Entity @Entity
@Table(name = "channels") @Table(name = "channels")
public class Channel { public class Channel {
@Id @Id
private String broadcaster; private String broadcaster;
@@ -32,8 +32,7 @@ public class Channel {
private double canvasHeight = 1080; private double canvasHeight = 1080;
public Channel() { public Channel() {}
}
public Channel(String broadcaster) { public Channel(String broadcaster) {
this.broadcaster = normalize(broadcaster); this.broadcaster = normalize(broadcaster);
@@ -77,9 +76,7 @@ public class Channel {
@PreUpdate @PreUpdate
public void normalizeFields() { public void normalizeFields() {
this.broadcaster = normalize(broadcaster); this.broadcaster = normalize(broadcaster);
this.admins = admins.stream() this.admins = admins.stream().map(Channel::normalize).collect(Collectors.toSet());
.map(Channel::normalize)
.collect(Collectors.toSet());
if (canvasWidth <= 0) { if (canvasWidth <= 0) {
canvasWidth = 1920; canvasWidth = 1920;
} }

View File

@@ -1,6 +1,7 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model;
public class PlaybackRequest { public class PlaybackRequest {
private Boolean play; private Boolean play;
public Boolean getPlay() { public Boolean getPlay() {

View File

@@ -4,34 +4,42 @@ import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.PreUpdate; import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
@Entity @Entity
@Table(name = "settings") @Table(name = "settings")
public class Settings { public class Settings {
@Id @Id
@Column(nullable = false) @Column(nullable = false)
private int id = 1; private int id = 1;
@Column(nullable = false) @Column(nullable = false)
private double minAssetPlaybackSpeedFraction; private double minAssetPlaybackSpeedFraction;
@Column(nullable = false) @Column(nullable = false)
private double maxAssetPlaybackSpeedFraction; private double maxAssetPlaybackSpeedFraction;
@Column(nullable = false) @Column(nullable = false)
private double minAssetAudioPitchFraction; private double minAssetAudioPitchFraction;
@Column(nullable = false) @Column(nullable = false)
private double maxAssetAudioPitchFraction; private double maxAssetAudioPitchFraction;
@Column(nullable = false) @Column(nullable = false)
private double minAssetVolumeFraction; private double minAssetVolumeFraction;
@Column(nullable = false) @Column(nullable = false)
private double maxAssetVolumeFraction; private double maxAssetVolumeFraction;
@Column(nullable = false) @Column(nullable = false)
private int maxCanvasSideLengthPixels; private int maxCanvasSideLengthPixels;
@Column(nullable = false) @Column(nullable = false)
private int canvasFramesPerSecond; private int canvasFramesPerSecond;
protected Settings() { protected Settings() {}
}
public static Settings defaults() { public static Settings defaults() {
Settings s = new Settings(); Settings s = new Settings();
@@ -117,5 +125,4 @@ public class Settings {
public void setCanvasFramesPerSecond(int canvasFramesPerSecond) { public void setCanvasFramesPerSecond(int canvasFramesPerSecond) {
this.canvasFramesPerSecond = canvasFramesPerSecond; this.canvasFramesPerSecond = canvasFramesPerSecond;
} }
} }

View File

@@ -4,27 +4,24 @@ import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.PreUpdate; import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint; import jakarta.persistence.UniqueConstraint;
import java.time.Instant; import java.time.Instant;
import java.util.Locale; import java.util.Locale;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@Table( @Table(name = "system_administrators", uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username"))
name = "system_administrators",
uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username")
)
public class SystemAdministrator { public class SystemAdministrator {
@Id @Id
private String id; private String id;
@Column(name = "twitch_username", nullable = false) @Column(name = "twitch_username", nullable = false)
private String twitchUsername; private String twitchUsername;
public SystemAdministrator() { public SystemAdministrator() {}
}
public SystemAdministrator(String twitchUsername) { public SystemAdministrator(String twitchUsername) {
this.twitchUsername = twitchUsername; this.twitchUsername = twitchUsername;
@@ -43,7 +40,6 @@ public class SystemAdministrator {
return id; return id;
} }
public String getTwitchUsername() { public String getTwitchUsername() {
return twitchUsername; return twitchUsername;
} }

View File

@@ -6,6 +6,7 @@ import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.PositiveOrZero;
public class TransformRequest { public class TransformRequest {
private double x; private double x;
private double y; private double y;
@@ -25,6 +26,7 @@ public class TransformRequest {
@Positive(message = "zIndex must be at least 1") @Positive(message = "zIndex must be at least 1")
private Integer zIndex; private Integer zIndex;
private Boolean audioLoop; private Boolean audioLoop;
@PositiveOrZero(message = "Audio delay must be zero or greater") @PositiveOrZero(message = "Audio delay must be zero or greater")

View File

@@ -1,6 +1,7 @@
package dev.kruhlmann.imgfloat.model; package dev.kruhlmann.imgfloat.model;
public class VisibilityRequest { public class VisibilityRequest {
private boolean hidden; private boolean hidden;
public boolean isHidden() { public boolean isHidden() {

View File

@@ -1,9 +1,8 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.Asset;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AssetRepository extends JpaRepository<Asset, String> { public interface AssetRepository extends JpaRepository<Asset, String> {
List<Asset> findByBroadcaster(String broadcaster); List<Asset> findByBroadcaster(String broadcaster);

View File

@@ -1,9 +1,8 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.Channel;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ChannelRepository extends JpaRepository<Channel, String> { public interface ChannelRepository extends JpaRepository<Channel, String> {
List<Channel> findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(String broadcasterFragment); List<Channel> findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(String broadcasterFragment);

View File

@@ -3,5 +3,4 @@ package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.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

@@ -2,35 +2,29 @@ package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.Asset;
import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.AssetRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.scheduling.annotation.Async;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service @Service
public class AssetCleanupService { public class AssetCleanupService {
private static final Logger logger = private static final Logger logger = LoggerFactory.getLogger(AssetCleanupService.class);
LoggerFactory.getLogger(AssetCleanupService.class);
private final AssetRepository assetRepository; private final AssetRepository assetRepository;
private final AssetStorageService assetStorageService; private final AssetStorageService assetStorageService;
public AssetCleanupService( public AssetCleanupService(AssetRepository assetRepository, AssetStorageService assetStorageService) {
AssetRepository assetRepository,
AssetStorageService assetStorageService
) {
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
this.assetStorageService = assetStorageService; this.assetStorageService = assetStorageService;
} }
@@ -41,10 +35,7 @@ public class AssetCleanupService {
public void cleanup() { public void cleanup() {
logger.info("Collecting referenced assets"); logger.info("Collecting referenced assets");
Set<String> referencedIds = assetRepository.findAll() Set<String> referencedIds = assetRepository.findAll().stream().map(Asset::getId).collect(Collectors.toSet());
.stream()
.map(Asset::getId)
.collect(Collectors.toSet());
assetStorageService.deleteOrphanedAssets(referencedIds); assetStorageService.deleteOrphanedAssets(referencedIds);
} }

View File

@@ -1,12 +1,7 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.service.media.AssetContent;
import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.Asset;
import org.slf4j.Logger; import dev.kruhlmann.imgfloat.service.media.AssetContent;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.nio.file.*; import java.nio.file.*;
import java.util.Locale; import java.util.Locale;
@@ -14,9 +9,14 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service @Service
public class AssetStorageService { public class AssetStorageService {
private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class); private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class);
private static final Map<String, String> EXTENSIONS = Map.ofEntries( private static final Map<String, String> EXTENSIONS = Map.ofEntries(
Map.entry("image/png", ".png"), Map.entry("image/png", ".png"),
@@ -42,15 +42,15 @@ public class AssetStorageService {
private final Path previewRoot; private final Path previewRoot;
public AssetStorageService( public AssetStorageService(
@Value("${IMGFLOAT_ASSETS_PATH:#{null}}") String assetRoot, @Value("${IMGFLOAT_ASSETS_PATH:#{null}}") String assetRoot,
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") String previewRoot @Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") String previewRoot
) { ) {
String assetsBase = assetRoot != null String assetsBase = assetRoot != null
? assetRoot ? assetRoot
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-assets").toString(); : Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-assets").toString();
String previewsBase = previewRoot != null String previewsBase = previewRoot != null
? previewRoot ? previewRoot
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-previews").toString(); : Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-previews").toString();
this.assetRoot = Paths.get(assetsBase).normalize().toAbsolutePath(); this.assetRoot = Paths.get(assetsBase).normalize().toAbsolutePath();
this.previewRoot = Paths.get(previewsBase).normalize().toAbsolutePath(); this.previewRoot = Paths.get(previewsBase).normalize().toAbsolutePath();
@@ -62,9 +62,7 @@ public class AssetStorageService {
} }
} }
public void storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) public void storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) throws IOException {
throws IOException {
if (assetBytes == null || assetBytes.length == 0) { if (assetBytes == null || assetBytes.length == 0) {
throw new IOException("Asset content is empty"); throw new IOException("Asset content is empty");
} }
@@ -72,35 +70,35 @@ public class AssetStorageService {
Path file = assetPath(broadcaster, assetId, mediaType); Path file = assetPath(broadcaster, assetId, mediaType);
Files.createDirectories(file.getParent()); Files.createDirectories(file.getParent());
Files.write(file, assetBytes, Files.write(
StandardOpenOption.CREATE, file,
StandardOpenOption.TRUNCATE_EXISTING, assetBytes,
StandardOpenOption.WRITE); StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
);
logger.info("Wrote asset to {}", file.toString()); logger.info("Wrote asset to {}", file.toString());
} }
public void storePreview(String broadcaster, String assetId, byte[] previewBytes) public void storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException {
throws IOException {
if (previewBytes == null || previewBytes.length == 0) return; if (previewBytes == null || previewBytes.length == 0) return;
Path file = previewPath(broadcaster, assetId); Path file = previewPath(broadcaster, assetId);
Files.createDirectories(file.getParent()); Files.createDirectories(file.getParent());
Files.write(file, previewBytes, Files.write(
StandardOpenOption.CREATE, file,
StandardOpenOption.TRUNCATE_EXISTING, previewBytes,
StandardOpenOption.WRITE); StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
);
logger.info("Wrote asset to {}", file.toString()); logger.info("Wrote asset to {}", file.toString());
} }
public Optional<AssetContent> loadAssetFile(Asset asset) { public Optional<AssetContent> loadAssetFile(Asset asset) {
try { try {
Path file = assetPath( Path file = assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType());
asset.getBroadcaster(),
asset.getId(),
asset.getMediaType()
);
if (!Files.exists(file)) return Optional.empty(); if (!Files.exists(file)) return Optional.empty();
@@ -141,12 +139,8 @@ public class AssetStorageService {
public void deleteAsset(Asset asset) { public void deleteAsset(Asset asset) {
try { try {
Files.deleteIfExists( Files.deleteIfExists(assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType()));
assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType()) Files.deleteIfExists(previewPath(asset.getBroadcaster(), asset.getId()));
);
Files.deleteIfExists(
previewPath(asset.getBroadcaster(), asset.getId())
);
} catch (Exception e) { } catch (Exception e) {
logger.warn("Failed to delete asset {}", asset.getId(), e); logger.warn("Failed to delete asset {}", asset.getId(), e);
} }
@@ -162,16 +156,17 @@ public class AssetStorageService {
return; return;
} }
try (var paths = Files.walk(root)) { try (var paths = Files.walk(root)) {
paths.filter(Files::isRegularFile) paths
.filter(p -> isOrphan(p, referencedAssetIds)) .filter(Files::isRegularFile)
.forEach(p -> { .filter((p) -> isOrphan(p, referencedAssetIds))
try { .forEach((p) -> {
Files.delete(p); try {
logger.warn("Deleted orphan file {}", p); Files.delete(p);
} catch (IOException e) { logger.warn("Deleted orphan file {}", p);
logger.error("Failed to delete {}", p, e); } catch (IOException e) {
} logger.error("Failed to delete {}", p, e);
}); }
});
} catch (IOException e) { } catch (IOException e) {
logger.error("Failed to walk {}", root, e); logger.error("Failed to walk {}", root, e);
} }

View File

@@ -1,30 +1,33 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import dev.kruhlmann.imgfloat.model.OauthSessionUser; import dev.kruhlmann.imgfloat.model.OauthSessionUser;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.service.SystemAdministratorService; import dev.kruhlmann.imgfloat.service.SystemAdministratorService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@Service @Service
public class AuthorizationService { public class AuthorizationService {
private static final Logger LOG = LoggerFactory.getLogger(AuthorizationService.class); private static final Logger LOG = LoggerFactory.getLogger(AuthorizationService.class);
private final ChannelDirectoryService channelDirectoryService; private final ChannelDirectoryService channelDirectoryService;
private final SystemAdministratorService systemAdministratorService; private final SystemAdministratorService systemAdministratorService;
public AuthorizationService(ChannelDirectoryService channelDirectoryService, SystemAdministratorService systemAdministratorService) { public AuthorizationService(
ChannelDirectoryService channelDirectoryService,
SystemAdministratorService systemAdministratorService
) {
this.channelDirectoryService = channelDirectoryService; this.channelDirectoryService = channelDirectoryService;
this.systemAdministratorService = systemAdministratorService; this.systemAdministratorService = systemAdministratorService;
} }
public void userMatchesSessionUsernameOrThrowHttpError(String submittedUsername, String sessionUsername) { public void userMatchesSessionUsernameOrThrowHttpError(String submittedUsername, String sessionUsername) {
if (sessionUsername == null) { if (sessionUsername == null) {
LOG.warn("Access denied for broadcaster-only action by unauthenticated user"); LOG.warn("Access denied for broadcaster-only action by unauthenticated user");
@@ -35,14 +38,25 @@ public class AuthorizationService {
throw new ResponseStatusException(NOT_FOUND, "You can only manage your own channel"); throw new ResponseStatusException(NOT_FOUND, "You can only manage your own channel");
} }
if (!sessionUsername.equals(submittedUsername)) { if (!sessionUsername.equals(submittedUsername)) {
LOG.warn("User match with oauth token failed: session user {} does not match submitted user {}", sessionUsername, submittedUsername); LOG.warn(
"User match with oauth token failed: session user {} does not match submitted user {}",
sessionUsername,
submittedUsername
);
throw new ResponseStatusException(FORBIDDEN, "You are not this user"); throw new ResponseStatusException(FORBIDDEN, "You are not this user");
} }
} }
public void userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(String broadcaster, String sessionUsername) { public void userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
String broadcaster,
String sessionUsername
) {
if (!userIsBroadcasterOrChannelAdminForBroadcaster(broadcaster, sessionUsername)) { if (!userIsBroadcasterOrChannelAdminForBroadcaster(broadcaster, sessionUsername)) {
LOG.warn("Access denied for broadcaster/admin-only action by user {} on broadcaster {}", sessionUsername, broadcaster); LOG.warn(
"Access denied for broadcaster/admin-only action by user {} on broadcaster {}",
sessionUsername,
broadcaster
);
throw new ResponseStatusException(FORBIDDEN, "You do not have permission to manage this channel"); throw new ResponseStatusException(FORBIDDEN, "You do not have permission to manage this channel");
} }
} }
@@ -64,15 +78,20 @@ public class AuthorizationService {
public boolean userIsChannelAdminForBroadcaster(String broadcaster, String sessionUsername) { public boolean userIsChannelAdminForBroadcaster(String broadcaster, String sessionUsername) {
if (sessionUsername == null || broadcaster == null) { if (sessionUsername == null || broadcaster == null) {
LOG.warn("Channel admin check failed: broadcaster or session username is null (broadcaster: {}, sessionUsername: {})", broadcaster, sessionUsername); LOG.warn(
"Channel admin check failed: broadcaster or session username is null (broadcaster: {}, sessionUsername: {})",
broadcaster,
sessionUsername
);
return false; return false;
} }
return channelDirectoryService.isAdmin(broadcaster, sessionUsername); return channelDirectoryService.isAdmin(broadcaster, sessionUsername);
} }
public boolean userIsBroadcasterOrChannelAdminForBroadcaster(String broadcaster, String sessionUser) { public boolean userIsBroadcasterOrChannelAdminForBroadcaster(String broadcaster, String sessionUser) {
return userIsBroadcaster(sessionUser, broadcaster) || return (
userIsChannelAdminForBroadcaster(sessionUser, broadcaster); userIsBroadcaster(sessionUser, broadcaster) || userIsChannelAdminForBroadcaster(sessionUser, broadcaster)
);
} }
public boolean userIsSystemAdministrator(String sessionUsername) { public boolean userIsSystemAdministrator(String sessionUsername) {

View File

@@ -1,11 +1,14 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.Asset;
import dev.kruhlmann.imgfloat.model.AssetEvent; import dev.kruhlmann.imgfloat.model.AssetEvent;
import dev.kruhlmann.imgfloat.model.AssetPatch; import dev.kruhlmann.imgfloat.model.AssetPatch;
import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.AssetView;
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.PlaybackRequest;
import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.TransformRequest;
@@ -17,7 +20,9 @@ import dev.kruhlmann.imgfloat.service.media.AssetContent;
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
import dev.kruhlmann.imgfloat.service.media.OptimizedAsset; import dev.kruhlmann.imgfloat.service.media.OptimizedAsset;
import java.io.IOException;
import java.util.*;
import java.util.regex.Pattern;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -27,15 +32,9 @@ import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.*;
import java.util.regex.Pattern;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
@Service @Service
public class ChannelDirectoryService { public class ChannelDirectoryService {
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
private static final Pattern SAFE_FILENAME = Pattern.compile("[^a-zA-Z0-9._ -]"); private static final Pattern SAFE_FILENAME = Pattern.compile("[^a-zA-Z0-9._ -]");
@@ -68,21 +67,18 @@ public class ChannelDirectoryService {
this.settingsService = settingsService; this.settingsService = settingsService;
} }
public Channel getOrCreateChannel(String broadcaster) { public Channel getOrCreateChannel(String broadcaster) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return channelRepository.findById(normalized) return channelRepository.findById(normalized).orElseGet(() -> channelRepository.save(new Channel(normalized)));
.orElseGet(() -> channelRepository.save(new Channel(normalized)));
} }
public List<String> searchBroadcasters(String query) { public List<String> searchBroadcasters(String query) {
String q = normalize(query); String q = normalize(query);
return channelRepository return channelRepository
.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc( .findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(q == null ? "" : q)
q == null ? "" : q) .stream()
.stream() .map(Channel::getBroadcaster)
.map(Channel::getBroadcaster) .toList();
.toList();
} }
public boolean addAdmin(String broadcaster, String username) { public boolean addAdmin(String broadcaster, String username) {
@@ -90,8 +86,7 @@ public class ChannelDirectoryService {
boolean added = channel.addAdmin(username); boolean added = channel.addAdmin(username);
if (added) { if (added) {
channelRepository.save(channel); channelRepository.save(channel);
messagingTemplate.convertAndSend(topicFor(broadcaster), messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username);
"Admin added: " + username);
} }
return added; return added;
} }
@@ -101,22 +96,19 @@ public class ChannelDirectoryService {
boolean removed = channel.removeAdmin(username); boolean removed = channel.removeAdmin(username);
if (removed) { if (removed) {
channelRepository.save(channel); channelRepository.save(channel);
messagingTemplate.convertAndSend(topicFor(broadcaster), messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username);
"Admin removed: " + username);
} }
return removed; return removed;
} }
public Collection<AssetView> getAssetsForAdmin(String broadcaster) { public Collection<AssetView> getAssetsForAdmin(String broadcaster) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return sortAndMapAssets(normalized, return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized));
assetRepository.findByBroadcaster(normalized));
} }
public Collection<AssetView> getVisibleAssets(String broadcaster) { public Collection<AssetView> getVisibleAssets(String broadcaster) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return sortAndMapAssets(normalized, return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalized));
assetRepository.findByBroadcasterAndHiddenFalse(normalized));
} }
public CanvasSettingsRequest getCanvasSettings(String broadcaster) { public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
@@ -137,19 +129,15 @@ public class ChannelDirectoryService {
long maxSize = uploadLimitBytes; long maxSize = uploadLimitBytes;
if (fileSize > maxSize) { if (fileSize > maxSize) {
throw new ResponseStatusException( throw new ResponseStatusException(
PAYLOAD_TOO_LARGE, PAYLOAD_TOO_LARGE,
String.format( String.format("Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.", fileSize, maxSize)
"Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.",
fileSize,
maxSize
)
); );
} }
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes) String mediaType = mediaDetectionService
.orElseThrow(() -> new ResponseStatusException( .detectAllowedMediaType(file, bytes)
BAD_REQUEST, "Unsupported media type")); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type"));
OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
if (optimized == null) { if (optimized == null) {
@@ -157,32 +145,29 @@ public class ChannelDirectoryService {
} }
String safeName = Optional.ofNullable(file.getOriginalFilename()) String safeName = Optional.ofNullable(file.getOriginalFilename())
.map(this::sanitizeFilename) .map(this::sanitizeFilename)
.filter(s -> !s.isBlank()) .filter((s) -> !s.isBlank())
.orElse("asset_" + System.currentTimeMillis()); .orElse("asset_" + System.currentTimeMillis());
double width = optimized.width() > 0 ? optimized.width() : double width = optimized.width() > 0
(optimized.mediaType().startsWith("audio/") ? 400 : 640); ? optimized.width()
double height = optimized.height() > 0 ? optimized.height() : : (optimized.mediaType().startsWith("audio/") ? 400 : 640);
(optimized.mediaType().startsWith("audio/") ? 80 : 360); double height = optimized.height() > 0
? optimized.height()
: (optimized.mediaType().startsWith("audio/") ? 80 : 360);
Asset asset = new Asset(channel.getBroadcaster(), safeName, "", Asset asset = new Asset(channel.getBroadcaster(), safeName, "", width, height);
width, height);
asset.setOriginalMediaType(mediaType); asset.setOriginalMediaType(mediaType);
asset.setMediaType(optimized.mediaType()); asset.setMediaType(optimized.mediaType());
assetStorageService.storeAsset( assetStorageService.storeAsset(
channel.getBroadcaster(), channel.getBroadcaster(),
asset.getId(), asset.getId(),
optimized.bytes(), optimized.bytes(),
optimized.mediaType() optimized.mediaType()
); );
assetStorageService.storePreview( assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes());
channel.getBroadcaster(),
asset.getId(),
optimized.previewBytes()
);
asset.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : ""); asset.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : "");
asset.setSpeed(1.0); asset.setSpeed(1.0);
@@ -197,8 +182,7 @@ public class ChannelDirectoryService {
assetRepository.save(asset); assetRepository.save(asset);
AssetView view = AssetView.from(channel.getBroadcaster(), asset); AssetView view = AssetView.from(channel.getBroadcaster(), asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
AssetEvent.created(broadcaster, view));
return Optional.of(view); return Optional.of(view);
} }
@@ -211,37 +195,37 @@ public class ChannelDirectoryService {
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest req) { public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest req) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository
.filter(asset -> normalized.equals(asset.getBroadcaster())) .findById(assetId)
.map(asset -> { .filter((asset) -> normalized.equals(asset.getBroadcaster()))
AssetPatch.TransformSnapshot before = AssetPatch.capture(asset); .map((asset) -> {
validateTransform(req); AssetPatch.TransformSnapshot before = AssetPatch.capture(asset);
validateTransform(req);
asset.setX(req.getX()); asset.setX(req.getX());
asset.setY(req.getY()); asset.setY(req.getY());
asset.setWidth(req.getWidth()); asset.setWidth(req.getWidth());
asset.setHeight(req.getHeight()); asset.setHeight(req.getHeight());
asset.setRotation(req.getRotation()); asset.setRotation(req.getRotation());
if (req.getZIndex() != null) asset.setZIndex(req.getZIndex()); if (req.getZIndex() != null) asset.setZIndex(req.getZIndex());
if (req.getSpeed() != null) asset.setSpeed(req.getSpeed()); if (req.getSpeed() != null) asset.setSpeed(req.getSpeed());
if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted()); if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted());
if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop()); if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop());
if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis()); if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis());
if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed()); if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed());
if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch()); if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch());
if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume()); if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume());
assetRepository.save(asset); assetRepository.save(asset);
AssetView view = AssetView.from(normalized, asset); AssetView view = AssetView.from(normalized, asset);
AssetPatch patch = AssetPatch.fromTransform(before, asset, req); AssetPatch patch = AssetPatch.fromTransform(before, asset, req);
if (hasPatchChanges(patch)) { if (hasPatchChanges(patch)) {
messagingTemplate.convertAndSend(topicFor(broadcaster), messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
AssetEvent.updated(broadcaster, patch)); }
} return view;
return view; });
});
} }
private void validateTransform(TransformRequest req) { private void validateTransform(TransformRequest req) {
@@ -254,68 +238,90 @@ public class ChannelDirectoryService {
double maxVolume = settings.getMaxAssetVolumeFraction(); double maxVolume = settings.getMaxAssetVolumeFraction();
int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels(); int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels();
if (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels) if (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels) throw new ResponseStatusException(
throw new ResponseStatusException(BAD_REQUEST, "Canvas width out of range [0 to " + canvasMaxSizePixels + "]"); BAD_REQUEST,
if (req.getHeight() <= 0) "Canvas width out of range [0 to " + canvasMaxSizePixels + "]"
throw new ResponseStatusException(BAD_REQUEST, "Canvas height out of range [0 to " + canvasMaxSizePixels + "]"); );
if (req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)) if (req.getHeight() <= 0) throw new ResponseStatusException(
throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]"); BAD_REQUEST,
if (req.getZIndex() != null && req.getZIndex() < 1) "Canvas height out of range [0 to " + canvasMaxSizePixels + "]"
throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1"); );
if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0) if (
throw new ResponseStatusException(BAD_REQUEST, "Audio delay >= 0"); req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)
if (req.getAudioSpeed() != null && (req.getAudioSpeed() < minSpeed || req.getAudioSpeed() > maxSpeed)) ) throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
throw new ResponseStatusException(BAD_REQUEST, "Audio speed out of range"); if (req.getZIndex() != null && req.getZIndex() < 1) throw new ResponseStatusException(
if (req.getAudioPitch() != null && (req.getAudioPitch() < minPitch || req.getAudioPitch() > maxPitch)) BAD_REQUEST,
throw new ResponseStatusException(BAD_REQUEST, "Audio pitch out of range"); "zIndex must be >= 1"
if (req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)) );
throw new ResponseStatusException(BAD_REQUEST, "Audio volume out of range [" + minVolume + " to " + maxVolume + "]"); if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0) throw new ResponseStatusException(
BAD_REQUEST,
"Audio delay >= 0"
);
if (
req.getAudioSpeed() != null && (req.getAudioSpeed() < minSpeed || req.getAudioSpeed() > maxSpeed)
) throw new ResponseStatusException(BAD_REQUEST, "Audio speed out of range");
if (
req.getAudioPitch() != null && (req.getAudioPitch() < minPitch || req.getAudioPitch() > maxPitch)
) throw new ResponseStatusException(BAD_REQUEST, "Audio pitch out of range");
if (
req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)
) throw new ResponseStatusException(
BAD_REQUEST,
"Audio volume out of range [" + minVolume + " to " + maxVolume + "]"
);
} }
public Optional<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) { public Optional<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository
.filter(a -> normalized.equals(a.getBroadcaster())) .findById(assetId)
.map(asset -> { .filter((a) -> normalized.equals(a.getBroadcaster()))
AssetView view = AssetView.from(normalized, asset); .map((asset) -> {
boolean play = req == null || req.getPlay(); AssetView view = AssetView.from(normalized, asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), boolean play = req == null || req.getPlay();
AssetEvent.play(broadcaster, view, play)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, play));
return view; return view;
}); });
} }
public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) { public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository.findById(assetId) return assetRepository
.filter(a -> normalized.equals(a.getBroadcaster())) .findById(assetId)
.map(asset -> { .filter((a) -> normalized.equals(a.getBroadcaster()))
boolean wasHidden = asset.isHidden(); .map((asset) -> {
boolean hidden = request.isHidden(); boolean wasHidden = asset.isHidden();
if (wasHidden == hidden) { boolean hidden = request.isHidden();
return AssetView.from(normalized, asset); if (wasHidden == hidden) {
} return AssetView.from(normalized, asset);
}
asset.setHidden(hidden); asset.setHidden(hidden);
assetRepository.save(asset); assetRepository.save(asset);
AssetView view = AssetView.from(normalized, asset); AssetView view = AssetView.from(normalized, asset);
AssetPatch patch = AssetPatch.fromVisibility(asset); AssetPatch patch = AssetPatch.fromVisibility(asset);
AssetView payload = hidden ? null : view; AssetView payload = hidden ? null : view;
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, patch, payload)); messagingTemplate.convertAndSend(
return view; topicFor(broadcaster),
}); AssetEvent.visibility(broadcaster, patch, payload)
);
return view;
});
} }
public boolean deleteAsset(String assetId) { public boolean deleteAsset(String assetId) {
return assetRepository.findById(assetId) return assetRepository
.map(asset -> { .findById(assetId)
assetRepository.delete(asset); .map((asset) -> {
assetStorageService.deleteAsset(asset); assetRepository.delete(asset);
messagingTemplate.convertAndSend(topicFor(asset.getBroadcaster()), assetStorageService.deleteAsset(asset);
AssetEvent.deleted(asset.getBroadcaster(), assetId)); messagingTemplate.convertAndSend(
return true; topicFor(asset.getBroadcaster()),
}) AssetEvent.deleted(asset.getBroadcaster(), assetId)
.orElse(false); );
return true;
})
.orElse(false);
} }
public Optional<AssetContent> getAssetContent(String assetId) { public Optional<AssetContent> getAssetContent(String assetId) {
@@ -323,25 +329,29 @@ public class ChannelDirectoryService {
} }
public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) { public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
return assetRepository.findById(assetId) return assetRepository
.filter(a -> includeHidden || !a.isHidden()) .findById(assetId)
.flatMap(assetStorageService::loadPreviewSafely); .filter((a) -> includeHidden || !a.isHidden())
.flatMap(assetStorageService::loadPreviewSafely);
} }
public boolean isAdmin(String broadcaster, String username) { public boolean isAdmin(String broadcaster, String username) {
return channelRepository.findById(normalize(broadcaster)) return channelRepository
.map(Channel::getAdmins) .findById(normalize(broadcaster))
.map(admins -> admins.contains(normalize(username))) .map(Channel::getAdmins)
.orElse(false); .map((admins) -> admins.contains(normalize(username)))
.orElse(false);
} }
public Collection<String> adminChannelsFor(String username) { public Collection<String> adminChannelsFor(String username) {
if (username == null) return List.of(); if (username == null) return List.of();
String login = username.toLowerCase(); String login = username.toLowerCase();
return channelRepository.findAll().stream() return channelRepository
.filter(c -> c.getAdmins().contains(login)) .findAll()
.map(Channel::getBroadcaster) .stream()
.toList(); .filter((c) -> c.getAdmins().contains(login))
.map(Channel::getBroadcaster)
.toList();
} }
private String normalize(String value) { private String normalize(String value) {
@@ -353,35 +363,46 @@ public class ChannelDirectoryService {
} }
private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) { private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) {
return assets.stream() return assets
.sorted(Comparator.comparingInt(Asset::getZIndex) .stream()
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))) .sorted(
.map(a -> AssetView.from(broadcaster, a)) Comparator.comparingInt(Asset::getZIndex).thenComparing(
.toList(); Asset::getCreatedAt,
Comparator.nullsFirst(Comparator.naturalOrder())
)
)
.map((a) -> AssetView.from(broadcaster, a))
.toList();
} }
private int nextZIndex(String broadcaster) { private int nextZIndex(String broadcaster) {
return assetRepository.findByBroadcaster(normalize(broadcaster)) return (
assetRepository
.findByBroadcaster(normalize(broadcaster))
.stream() .stream()
.mapToInt(Asset::getZIndex) .mapToInt(Asset::getZIndex)
.max() .max()
.orElse(0) + 1; .orElse(0) +
1
);
} }
private boolean hasPatchChanges(AssetPatch patch) { private boolean hasPatchChanges(AssetPatch patch) {
return patch.x() != null return (
|| patch.y() != null patch.x() != null ||
|| patch.width() != null patch.y() != null ||
|| patch.height() != null patch.width() != null ||
|| patch.rotation() != null patch.height() != null ||
|| patch.speed() != null patch.rotation() != null ||
|| patch.muted() != null patch.speed() != null ||
|| patch.zIndex() != null patch.muted() != null ||
|| patch.hidden() != null patch.zIndex() != null ||
|| patch.audioLoop() != null patch.hidden() != null ||
|| patch.audioDelayMillis() != null patch.audioLoop() != null ||
|| patch.audioSpeed() != null patch.audioDelayMillis() != null ||
|| patch.audioPitch() != null patch.audioSpeed() != null ||
|| patch.audioVolume() != null; patch.audioPitch() != null ||
patch.audioVolume() != null
);
} }
} }

View File

@@ -1,10 +1,9 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
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.Settings;
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -12,6 +11,7 @@ import org.springframework.stereotype.Service;
@Service @Service
public class SettingsService { public class SettingsService {
private static final Logger logger = LoggerFactory.getLogger(SettingsService.class); private static final Logger logger = LoggerFactory.getLogger(SettingsService.class);
private final SettingsRepository repo; private final SettingsRepository repo;
@@ -44,12 +44,7 @@ public class SettingsService {
public void logSettings(String msg, Settings settings) { public void logSettings(String msg, Settings settings) {
try { try {
logger.info("{}:\n{}", logger.info("{}:\n{}", msg, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(settings));
msg,
objectMapper
.writerWithDefaultPrettyPrinter()
.writeValueAsString(settings)
);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
logger.error("Failed to serialize settings", e); logger.error("Failed to serialize settings", e);
} }

View File

@@ -3,29 +3,26 @@ package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.SystemAdministrator; import dev.kruhlmann.imgfloat.model.SystemAdministrator;
import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository; import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import java.util.Locale;
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;
import org.springframework.stereotype.Service;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import java.util.Locale;
@Service @Service
public class SystemAdministratorService { public class SystemAdministratorService {
private static final Logger logger = private static final Logger logger = LoggerFactory.getLogger(SystemAdministratorService.class);
LoggerFactory.getLogger(SystemAdministratorService.class);
private final SystemAdministratorRepository repo; private final SystemAdministratorRepository repo;
private final String initialSysadmin; private final String initialSysadmin;
private final Environment environment; private final Environment environment;
public SystemAdministratorService( public SystemAdministratorService(
SystemAdministratorRepository repo, SystemAdministratorRepository repo,
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") @Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") String initialSysadmin,
String initialSysadmin, Environment environment
Environment environment
) { ) {
this.repo = repo; this.repo = repo;
this.initialSysadmin = initialSysadmin; this.initialSysadmin = initialSysadmin;
@@ -38,7 +35,11 @@ public class SystemAdministratorService {
return; return;
} }
if (Boolean.parseBoolean(environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper"))) { if (
Boolean.parseBoolean(
environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper")
)
) {
logger.info("Skipping system administrator bootstrap in test context"); logger.info("Skipping system administrator bootstrap in test context");
return; return;
} }
@@ -65,17 +66,13 @@ public class SystemAdministratorService {
public void removeSysadmin(String twitchUsername) { public void removeSysadmin(String twitchUsername) {
if (repo.count() <= 1) { if (repo.count() <= 1) {
throw new IllegalStateException( throw new IllegalStateException("Cannot remove the last system administrator");
"Cannot remove the last system administrator"
);
} }
long deleted = repo.deleteByTwitchUsername(normalize(twitchUsername)); long deleted = repo.deleteByTwitchUsername(normalize(twitchUsername));
if (deleted == 0) { if (deleted == 0) {
throw new IllegalArgumentException( throw new IllegalArgumentException("System administrator does not exist");
"System administrator does not exist"
);
} }
} }

View File

@@ -1,8 +1,22 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
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 java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.boot.web.client.RestTemplateBuilder;
@@ -15,31 +29,17 @@ import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Set;
import java.util.Optional;
@Service @Service
public class TwitchUserLookupService { public class TwitchUserLookupService {
private static final Logger LOG = LoggerFactory.getLogger(TwitchUserLookupService.class); private static final Logger LOG = LoggerFactory.getLogger(TwitchUserLookupService.class);
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
public TwitchUserLookupService(RestTemplateBuilder builder) { public TwitchUserLookupService(RestTemplateBuilder builder) {
this.restTemplate = builder this.restTemplate = builder
.setConnectTimeout(Duration.ofSeconds(15)) .setConnectTimeout(Duration.ofSeconds(15))
.setReadTimeout(Duration.ofSeconds(15)) .setReadTimeout(Duration.ofSeconds(15))
.build(); .build();
} }
public List<TwitchUserProfile> fetchProfiles(Collection<String> logins, String accessToken, String clientId) { public List<TwitchUserProfile> fetchProfiles(Collection<String> logins, String accessToken, String clientId) {
@@ -47,23 +47,27 @@ public class TwitchUserLookupService {
return List.of(); return List.of();
} }
List<String> normalizedLogins = logins.stream() List<String> normalizedLogins = logins
.filter(Objects::nonNull) .stream()
.map(login -> login.toLowerCase(Locale.ROOT)) .filter(Objects::nonNull)
.distinct() .map((login) -> login.toLowerCase(Locale.ROOT))
.toList(); .distinct()
.toList();
Map<String, TwitchUserData> byLogin = fetchUsers(normalizedLogins, accessToken, clientId); Map<String, TwitchUserData> byLogin = fetchUsers(normalizedLogins, accessToken, clientId);
return normalizedLogins.stream() return normalizedLogins
.map(login -> toProfile(login, byLogin.get(login))) .stream()
.toList(); .map((login) -> toProfile(login, byLogin.get(login)))
.toList();
} }
public List<TwitchUserProfile> fetchModerators(String broadcasterLogin, public List<TwitchUserProfile> fetchModerators(
Collection<String> existingAdmins, String broadcasterLogin,
String accessToken, Collection<String> existingAdmins,
String clientId) { String accessToken,
String clientId
) {
if (broadcasterLogin == null || broadcasterLogin.isBlank()) { if (broadcasterLogin == null || broadcasterLogin.isBlank()) {
LOG.warn("Cannot fetch moderators without a broadcaster login"); LOG.warn("Cannot fetch moderators without a broadcaster login");
return List.of(); return List.of();
@@ -77,8 +81,8 @@ public class TwitchUserLookupService {
String normalizedBroadcaster = broadcasterLogin.toLowerCase(Locale.ROOT); String normalizedBroadcaster = broadcasterLogin.toLowerCase(Locale.ROOT);
Map<String, TwitchUserData> broadcasterData = fetchUsers(List.of(normalizedBroadcaster), accessToken, clientId); Map<String, TwitchUserData> broadcasterData = fetchUsers(List.of(normalizedBroadcaster), accessToken, clientId);
String broadcasterId = Optional.ofNullable(broadcasterData.get(normalizedBroadcaster)) String broadcasterId = Optional.ofNullable(broadcasterData.get(normalizedBroadcaster))
.map(TwitchUserData::id) .map(TwitchUserData::id)
.orElse(null); .orElse(null);
if (broadcasterId == null || broadcasterId.isBlank()) { if (broadcasterId == null || broadcasterId.isBlank()) {
LOG.warn("No broadcaster id found for {} when fetching moderators", broadcasterLogin); LOG.warn("No broadcaster id found for {} when fetching moderators", broadcasterLogin);
@@ -87,10 +91,11 @@ public class TwitchUserLookupService {
Set<String> skipLogins = new HashSet<>(); Set<String> skipLogins = new HashSet<>();
if (existingAdmins != null) { if (existingAdmins != null) {
existingAdmins.stream() existingAdmins
.filter(Objects::nonNull) .stream()
.map(login -> login.toLowerCase(Locale.ROOT)) .filter(Objects::nonNull)
.forEach(skipLogins::add); .map((login) -> login.toLowerCase(Locale.ROOT))
.forEach(skipLogins::add);
} }
skipLogins.add(normalizedBroadcaster); skipLogins.add(normalizedBroadcaster);
@@ -102,36 +107,43 @@ public class TwitchUserLookupService {
String cursor = null; String cursor = null;
do { do {
UriComponentsBuilder builder = UriComponentsBuilder UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(
.fromHttpUrl("https://api.twitch.tv/helix/moderation/moderators") "https://api.twitch.tv/helix/moderation/moderators"
.queryParam("broadcaster_id", broadcasterId) )
.queryParam("first", 100); .queryParam("broadcaster_id", broadcasterId)
.queryParam("first", 100);
if (cursor != null && !cursor.isBlank()) { if (cursor != null && !cursor.isBlank()) {
builder.queryParam("after", cursor); builder.queryParam("after", cursor);
} }
try { try {
ResponseEntity<TwitchModeratorsResponse> response = restTemplate.exchange( ResponseEntity<TwitchModeratorsResponse> response = restTemplate.exchange(
builder.build(true).toUri(), builder.build(true).toUri(),
HttpMethod.GET, HttpMethod.GET,
new HttpEntity<>(headers), new HttpEntity<>(headers),
TwitchModeratorsResponse.class); TwitchModeratorsResponse.class
);
TwitchModeratorsResponse body = response.getBody(); TwitchModeratorsResponse body = response.getBody();
LOG.debug("Fetched {} moderator records for {} (cursor={})", body != null && body.data() != null ? body.data().size() : 0, broadcasterLogin, cursor); LOG.debug(
"Fetched {} moderator records for {} (cursor={})",
body != null && body.data() != null ? body.data().size() : 0,
broadcasterLogin,
cursor
);
if (body != null && body.data() != null) { if (body != null && body.data() != null) {
body.data().stream() body
.filter(Objects::nonNull) .data()
.map(ModeratorData::userLogin) .stream()
.filter(Objects::nonNull) .filter(Objects::nonNull)
.map(login -> login.toLowerCase(Locale.ROOT)) .map(ModeratorData::userLogin)
.filter(login -> !skipLogins.contains(login)) .filter(Objects::nonNull)
.forEach(moderatorLogins::add); .map((login) -> login.toLowerCase(Locale.ROOT))
.filter((login) -> !skipLogins.contains(login))
.forEach(moderatorLogins::add);
} }
cursor = body != null && body.pagination() != null cursor = body != null && body.pagination() != null ? body.pagination().cursor() : null;
? body.pagination().cursor()
: null;
} catch (RestClientException ex) { } catch (RestClientException ex) {
LOG.warn("Unable to fetch Twitch moderators for {}", broadcasterLogin, ex); LOG.warn("Unable to fetch Twitch moderators for {}", broadcasterLogin, ex);
return List.of(); return List.of();
@@ -158,11 +170,12 @@ public class TwitchUserLookupService {
return Collections.emptyMap(); return Collections.emptyMap();
} }
List<String> normalizedLogins = logins.stream() List<String> normalizedLogins = logins
.filter(Objects::nonNull) .stream()
.map(login -> login.toLowerCase(Locale.ROOT)) .filter(Objects::nonNull)
.distinct() .map((login) -> login.toLowerCase(Locale.ROOT))
.toList(); .distinct()
.toList();
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) { if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
return Collections.emptyMap(); return Collections.emptyMap();
@@ -172,27 +185,33 @@ public class TwitchUserLookupService {
headers.setBearerAuth(accessToken); headers.setBearerAuth(accessToken);
headers.add("Client-ID", clientId); headers.add("Client-ID", clientId);
UriComponentsBuilder uriBuilder = UriComponentsBuilder UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl("https://api.twitch.tv/helix/users");
.fromHttpUrl("https://api.twitch.tv/helix/users"); normalizedLogins.forEach((login) -> uriBuilder.queryParam("login", login));
normalizedLogins.forEach(login -> uriBuilder.queryParam("login", login));
HttpEntity<Void> entity = new HttpEntity<>(headers); HttpEntity<Void> entity = new HttpEntity<>(headers);
try { try {
ResponseEntity<TwitchUsersResponse> response = restTemplate.exchange( ResponseEntity<TwitchUsersResponse> response = restTemplate.exchange(
uriBuilder.build(true).toUri(), uriBuilder.build(true).toUri(),
HttpMethod.GET, HttpMethod.GET,
entity, entity,
TwitchUsersResponse.class); TwitchUsersResponse.class
);
return response.getBody() == null return response.getBody() == null
? Collections.emptyMap() ? Collections.emptyMap()
: response.getBody().data().stream() : response
.filter(Objects::nonNull) .getBody()
.collect(Collectors.toMap( .data()
user -> user.login().toLowerCase(Locale.ROOT), .stream()
Function.identity(), .filter(Objects::nonNull)
(a, b) -> a, .collect(
HashMap::new)); Collectors.toMap(
(user) -> user.login().toLowerCase(Locale.ROOT),
Function.identity(),
(a, b) -> a,
HashMap::new
)
);
} catch (RestClientException ex) { } catch (RestClientException ex) {
LOG.warn("Unable to fetch Twitch user profiles", ex); LOG.warn("Unable to fetch Twitch user profiles", ex);
return Collections.emptyMap(); return Collections.emptyMap();
@@ -200,31 +219,26 @@ public class TwitchUserLookupService {
} }
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
private record TwitchUsersResponse(List<TwitchUserData> data) { private record TwitchUsersResponse(List<TwitchUserData> data) {}
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
private record TwitchUserData( private record TwitchUserData(
String id, String id,
String login, String login,
@JsonProperty("display_name") String displayName, @JsonProperty("display_name") String displayName,
@JsonProperty("profile_image_url") String profileImageUrl) { @JsonProperty("profile_image_url") String profileImageUrl
} ) {}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
private record TwitchModeratorsResponse( private record TwitchModeratorsResponse(List<ModeratorData> data, Pagination pagination) {}
List<ModeratorData> data,
Pagination pagination) {
}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
private record ModeratorData( private record ModeratorData(
@JsonProperty("user_id") String userId, @JsonProperty("user_id") String userId,
@JsonProperty("user_login") String userLogin, @JsonProperty("user_login") String userLogin,
@JsonProperty("user_name") String userName) { @JsonProperty("user_name") String userName
} ) {}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
private record Pagination(String cursor) { private record Pagination(String cursor) {}
}
} }

View File

@@ -1,17 +1,17 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component @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 final String version; private final String version;
private final String releaseVersion; private final String releaseVersion;
@@ -58,7 +58,9 @@ public class VersionService {
} }
private String getPomVersion() { private String getPomVersion() {
try (var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")) { try (
var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")
) {
if (inputStream == null) { if (inputStream == null) {
return null; return null;
} }

View File

@@ -1,3 +1,3 @@
package dev.kruhlmann.imgfloat.service.media; package dev.kruhlmann.imgfloat.service.media;
public record AssetContent(byte[] bytes, String mediaType) { } public record AssetContent(byte[] bytes, String mediaType) {}

View File

@@ -1,53 +1,53 @@
package dev.kruhlmann.imgfloat.service.media; package dev.kruhlmann.imgfloat.service.media;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.net.URLConnection; import java.net.URLConnection;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service @Service
public class MediaDetectionService { public class MediaDetectionService {
private static final Logger logger = LoggerFactory.getLogger(MediaDetectionService.class); private static final Logger logger = LoggerFactory.getLogger(MediaDetectionService.class);
private static final Map<String, String> EXTENSION_TYPES = Map.ofEntries( private static final Map<String, String> EXTENSION_TYPES = Map.ofEntries(
Map.entry("png", "image/png"), Map.entry("png", "image/png"),
Map.entry("jpg", "image/jpeg"), Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"), Map.entry("jpeg", "image/jpeg"),
Map.entry("gif", "image/gif"), Map.entry("gif", "image/gif"),
Map.entry("webp", "image/webp"), Map.entry("webp", "image/webp"),
Map.entry("mp4", "video/mp4"), Map.entry("mp4", "video/mp4"),
Map.entry("webm", "video/webm"), Map.entry("webm", "video/webm"),
Map.entry("mov", "video/quicktime"), Map.entry("mov", "video/quicktime"),
Map.entry("mp3", "audio/mpeg"), Map.entry("mp3", "audio/mpeg"),
Map.entry("wav", "audio/wav"), Map.entry("wav", "audio/wav"),
Map.entry("ogg", "audio/ogg") Map.entry("ogg", "audio/ogg")
); );
private static final Set<String> ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values()); private static final Set<String> ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values());
public Optional<String> detectAllowedMediaType(MultipartFile file, byte[] bytes) { public Optional<String> detectAllowedMediaType(MultipartFile file, byte[] bytes) {
Optional<String> detected = detectMediaType(bytes) Optional<String> detected = detectMediaType(bytes).filter(MediaDetectionService::isAllowedMediaType);
.filter(MediaDetectionService::isAllowedMediaType);
if (detected.isPresent()) { if (detected.isPresent()) {
return detected; return detected;
} }
Optional<String> declared = Optional.ofNullable(file.getContentType()) Optional<String> declared = Optional.ofNullable(file.getContentType()).filter(
.filter(MediaDetectionService::isAllowedMediaType); MediaDetectionService::isAllowedMediaType
);
if (declared.isPresent()) { if (declared.isPresent()) {
return declared; return declared;
} }
return Optional.ofNullable(file.getOriginalFilename()) return Optional.ofNullable(file.getOriginalFilename())
.map(name -> name.replaceAll("^.*\\.", "").toLowerCase()) .map((name) -> name.replaceAll("^.*\\.", "").toLowerCase())
.map(EXTENSION_TYPES::get) .map(EXTENSION_TYPES::get)
.filter(MediaDetectionService::isAllowedMediaType); .filter(MediaDetectionService::isAllowedMediaType);
} }
private Optional<String> detectMediaType(byte[] bytes) { private Optional<String> detectMediaType(byte[] bytes) {
@@ -68,6 +68,9 @@ public class MediaDetectionService {
} }
public static boolean isInlineDisplayType(String mediaType) { public static boolean isInlineDisplayType(String mediaType) {
return mediaType != null && (mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/")); return (
mediaType != null &&
(mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/"))
);
} }
} }

View File

@@ -1,20 +1,5 @@
package dev.kruhlmann.imgfloat.service.media; package dev.kruhlmann.imgfloat.service.media;
import org.jcodec.api.FrameGrab;
import org.jcodec.api.JCodecException;
import org.jcodec.common.io.ByteBufferSeekableByteChannel;
import org.jcodec.common.model.Picture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.metadata.IIOMetadata;
import javax.imageio.stream.ImageInputStream;
import javax.imageio.stream.ImageOutputStream;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@@ -24,9 +9,24 @@ import java.nio.ByteBuffer;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.metadata.IIOMetadata;
import javax.imageio.stream.ImageInputStream;
import javax.imageio.stream.ImageOutputStream;
import org.jcodec.api.FrameGrab;
import org.jcodec.api.JCodecException;
import org.jcodec.common.io.ByteBufferSeekableByteChannel;
import org.jcodec.common.model.Picture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service @Service
public class MediaOptimizationService { public class MediaOptimizationService {
private static final int MIN_GIF_DELAY_MS = 20; private static final int MIN_GIF_DELAY_MS = 20;
private static final Logger logger = LoggerFactory.getLogger(MediaOptimizationService.class); private static final Logger logger = LoggerFactory.getLogger(MediaOptimizationService.class);
private final MediaPreviewService previewService; private final MediaPreviewService previewService;
@@ -86,10 +86,11 @@ public class MediaOptimizationService {
if (frames.isEmpty()) { if (frames.isEmpty()) {
return null; return null;
} }
int baseDelay = frames.stream() int baseDelay = frames
.mapToInt(frame -> normalizeDelay(frame.delayMs())) .stream()
.reduce(this::greatestCommonDivisor) .mapToInt((frame) -> normalizeDelay(frame.delayMs()))
.orElse(100); .reduce(this::greatestCommonDivisor)
.orElse(100);
int fps = Math.max(1, (int) Math.round(1000.0 / baseDelay)); int fps = Math.max(1, (int) Math.round(1000.0 / baseDelay));
File temp = File.createTempFile("gif-convert", ".mp4"); File temp = File.createTempFile("gif-convert", ".mp4");
temp.deleteOnExit(); temp.deleteOnExit();
@@ -104,7 +105,13 @@ public class MediaOptimizationService {
encoder.finish(); encoder.finish();
BufferedImage cover = frames.get(0).image(); BufferedImage cover = frames.get(0).image();
byte[] video = Files.readAllBytes(temp.toPath()); byte[] video = Files.readAllBytes(temp.toPath());
return new OptimizedAsset(video, "video/mp4", cover.getWidth(), cover.getHeight(), previewService.encodePreview(cover)); return new OptimizedAsset(
video,
"video/mp4",
cover.getWidth(),
cover.getHeight(),
previewService.encodePreview(cover)
);
} finally { } finally {
Files.deleteIfExists(temp.toPath()); Files.deleteIfExists(temp.toPath());
} }
@@ -183,8 +190,10 @@ public class MediaOptimizationService {
} }
} }
ImageWriter writer = writers.next(); ImageWriter writer = writers.next();
try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (
ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) { ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageOutputStream ios = ImageIO.createImageOutputStream(baos)
) {
writer.setOutput(ios); writer.setOutput(ios);
ImageWriteParam param = writer.getDefaultWriteParam(); ImageWriteParam param = writer.getDefaultWriteParam();
if (param.canWriteCompressed()) { if (param.canWriteCompressed()) {
@@ -211,7 +220,7 @@ public class MediaOptimizationService {
return new Dimension(640, 360); return new Dimension(640, 360);
} }
private record GifFrame(BufferedImage image, int delayMs) { } private record GifFrame(BufferedImage image, int delayMs) {}
private record Dimension(int width, int height) { } private record Dimension(int width, int height) {}
} }

View File

@@ -1,5 +1,10 @@
package dev.kruhlmann.imgfloat.service.media; package dev.kruhlmann.imgfloat.service.media;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import javax.imageio.ImageIO;
import org.jcodec.api.FrameGrab; import org.jcodec.api.FrameGrab;
import org.jcodec.api.JCodecException; import org.jcodec.api.JCodecException;
import org.jcodec.common.io.ByteBufferSeekableByteChannel; import org.jcodec.common.io.ByteBufferSeekableByteChannel;
@@ -9,14 +14,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
@Service @Service
public class MediaPreviewService { public class MediaPreviewService {
private static final Logger logger = LoggerFactory.getLogger(MediaPreviewService.class); private static final Logger logger = LoggerFactory.getLogger(MediaPreviewService.class);
public byte[] encodePreview(BufferedImage image) { public byte[] encodePreview(BufferedImage image) {

View File

@@ -1,3 +1,3 @@
package dev.kruhlmann.imgfloat.service.media; package dev.kruhlmann.imgfloat.service.media;
public record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, byte[] previewBytes) { } public record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, byte[] previewBytes) {}

View File

@@ -2,35 +2,35 @@ const { app, BrowserWindow } = require("electron");
const path = require("path"); const path = require("path");
function createWindow() { function createWindow() {
const url = "https://imgfloat.kruhlmann.dev/channels"; const url = "https://imgfloat.kruhlmann.dev/channels";
const initialWindowWidthPx = 960; const initialWindowWidthPx = 960;
const initialWindowHeightPx = 640; const initialWindowHeightPx = 640;
const applicationWindow = new BrowserWindow({ const applicationWindow = new BrowserWindow({
width: initialWindowWidthPx, width: initialWindowWidthPx,
height: initialWindowHeightPx, height: initialWindowHeightPx,
transparent: true, transparent: true,
frame: true, frame: true,
backgroundColor: "#00000000", backgroundColor: "#00000000",
alwaysOnTop: false, alwaysOnTop: false,
icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"), icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"),
webPreferences: { backgroundThrottling: false }, webPreferences: { backgroundThrottling: false },
}); });
applicationWindow.setMenu(null); applicationWindow.setMenu(null);
let canvasSizeInterval; let canvasSizeInterval;
const clearCanvasSizeInterval = () => { const clearCanvasSizeInterval = () => {
if (canvasSizeInterval) { if (canvasSizeInterval) {
clearInterval(canvasSizeInterval); clearInterval(canvasSizeInterval);
canvasSizeInterval = undefined; canvasSizeInterval = undefined;
} }
}; };
const lockWindowToCanvas = async () => { const lockWindowToCanvas = async () => {
if (applicationWindow.isDestroyed()) { if (applicationWindow.isDestroyed()) {
return false; return false;
} }
try { try {
const size = await applicationWindow.webContents.executeJavaScript(`(() => { const size = await applicationWindow.webContents.executeJavaScript(`(() => {
const canvas = document.getElementById('broadcast-canvas'); const canvas = document.getElementById('broadcast-canvas');
if (!canvas || !canvas.width || !canvas.height) { if (!canvas || !canvas.width || !canvas.height) {
return null; return null;
@@ -38,52 +38,54 @@ function createWindow() {
return { width: Math.round(canvas.width), height: Math.round(canvas.height) }; return { width: Math.round(canvas.width), height: Math.round(canvas.height) };
})();`); })();`);
if (size?.width && size?.height) { if (size?.width && size?.height) {
const [currentWidth, currentHeight] = applicationWindow.getSize(); const [currentWidth, currentHeight] = applicationWindow.getSize();
if (currentWidth !== size.width || currentHeight !== size.height) { if (currentWidth !== size.width || currentHeight !== size.height) {
applicationWindow.setSize(size.width, size.height, false); applicationWindow.setSize(size.width, size.height, false);
}
applicationWindow.setMinimumSize(size.width, size.height);
applicationWindow.setMaximumSize(size.width, size.height);
applicationWindow.setResizable(false);
return true;
}
} catch (error) {
// Best-effort sizing; ignore errors from early navigation states.
} }
applicationWindow.setMinimumSize(size.width, size.height); return false;
applicationWindow.setMaximumSize(size.width, size.height); };
applicationWindow.setResizable(false);
return true;
}
} catch (error) {
// Best-effort sizing; ignore errors from early navigation states.
}
return false;
};
const handleNavigation = (navigationUrl) => { const handleNavigation = (navigationUrl) => {
try { try {
const { pathname } = new URL(navigationUrl); const { pathname } = new URL(navigationUrl);
const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname); const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname);
if (isBroadcast) { if (isBroadcast) {
clearCanvasSizeInterval(); clearCanvasSizeInterval();
canvasSizeInterval = setInterval(lockWindowToCanvas, 750); canvasSizeInterval = setInterval(lockWindowToCanvas, 750);
lockWindowToCanvas(); lockWindowToCanvas();
} else { } else {
clearCanvasSizeInterval(); clearCanvasSizeInterval();
applicationWindow.setResizable(true); applicationWindow.setResizable(true);
applicationWindow.setMinimumSize(320, 240); applicationWindow.setMinimumSize(320, 240);
applicationWindow.setMaximumSize(10000, 10000); applicationWindow.setMaximumSize(10000, 10000);
applicationWindow.setSize(initialWindowWidthPx, initialWindowHeightPx, false); applicationWindow.setSize(initialWindowWidthPx, initialWindowHeightPx, false);
} }
} catch { } catch {
// Ignore malformed URLs while navigating. // Ignore malformed URLs while navigating.
} }
}; };
applicationWindow.loadURL(url); applicationWindow.loadURL(url);
applicationWindow.webContents.on("did-finish-load", () => { applicationWindow.webContents.on("did-finish-load", () => {
handleNavigation(applicationWindow.webContents.getURL()); handleNavigation(applicationWindow.webContents.getURL());
}); });
applicationWindow.webContents.on("did-navigate", (_event, navigationUrl) => handleNavigation(navigationUrl)); applicationWindow.webContents.on("did-navigate", (_event, navigationUrl) => handleNavigation(navigationUrl));
applicationWindow.webContents.on("did-navigate-in-page", (_event, navigationUrl) => handleNavigation(navigationUrl)); applicationWindow.webContents.on("did-navigate-in-page", (_event, navigationUrl) =>
applicationWindow.on("closed", clearCanvasSizeInterval); handleNavigation(navigationUrl),
);
applicationWindow.on("closed", clearCanvasSizeInterval);
} }
app.whenReady().then(createWindow); app.whenReady().then(createWindow);

View File

@@ -1,65 +1,65 @@
server: server:
port: ${SERVER_PORT:8080} port: ${SERVER_PORT:8080}
tomcat: tomcat:
max-swallow-size: 0 max-swallow-size: 0
ssl: ssl:
enabled: ${SSL_ENABLED:false} enabled: ${SSL_ENABLED:false}
key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12} key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12}
key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit} key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit}
key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12} key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12}
error: error:
include-message: never include-message: never
include-stacktrace: never include-stacktrace: never
spring: spring:
config: config:
import: optional:file:.env[.properties] import: optional:file:.env[.properties]
application: application:
name: imgfloat name: imgfloat
devtools: devtools:
restart: restart:
enabled: true enabled: true
livereload: livereload:
enabled: true enabled: true
thymeleaf: thymeleaf:
cache: false cache: false
datasource: datasource:
url: jdbc:sqlite:${IMGFLOAT_DB_PATH}?busy_timeout=5000&journal_mode=WAL url: jdbc:sqlite:${IMGFLOAT_DB_PATH}?busy_timeout=5000&journal_mode=WAL
driver-class-name: org.sqlite.JDBC driver-class-name: org.sqlite.JDBC
hikari: hikari:
connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;" connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;"
maximum-pool-size: 1 maximum-pool-size: 1
minimum-idle: 1 minimum-idle: 1
jpa: jpa:
open-in-view: false open-in-view: false
hibernate: hibernate:
ddl-auto: update ddl-auto: update
database-platform: org.hibernate.community.dialect.SQLiteDialect database-platform: org.hibernate.community.dialect.SQLiteDialect
session: session:
store-type: jdbc store-type: jdbc
jdbc: jdbc:
initialize-schema: always initialize-schema: always
platform: sqlite platform: sqlite
security: security:
oauth2: oauth2:
client: client:
registration: registration:
twitch: twitch:
client-id: ${TWITCH_CLIENT_ID} client-id: ${TWITCH_CLIENT_ID}
client-secret: ${TWITCH_CLIENT_SECRET} client-secret: ${TWITCH_CLIENT_SECRET}
client-authentication-method: client_secret_post client-authentication-method: client_secret_post
redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}" redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}"
authorization-grant-type: authorization_code authorization-grant-type: authorization_code
scope: ["user:read:email", "moderation:read"] scope: ["user:read:email", "moderation:read"]
provider: provider:
twitch: twitch:
authorization-uri: https://id.twitch.tv/oauth2/authorize authorization-uri: https://id.twitch.tv/oauth2/authorize
token-uri: https://id.twitch.tv/oauth2/token token-uri: https://id.twitch.tv/oauth2/token
user-info-uri: https://api.twitch.tv/helix/users user-info-uri: https://api.twitch.tv/helix/users
user-name-attribute: login user-name-attribute: login
management: management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: health,info include: health,info

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
const persistDismissal = () => { const persistDismissal = () => {
try { try {
window.localStorage.setItem(CONSENT_STORAGE_KEY, "true"); window.localStorage.setItem(CONSENT_STORAGE_KEY, "true");
} catch { } } catch {}
document.cookie = `${CONSENT_STORAGE_KEY}=true; max-age=${COOKIE_MAX_AGE_SECONDS}; path=/; SameSite=Lax`; document.cookie = `${CONSENT_STORAGE_KEY}=true; max-age=${COOKIE_MAX_AGE_SECONDS}; path=/; SameSite=Lax`;
}; };
@@ -21,7 +21,7 @@
if (window.localStorage.getItem(CONSENT_STORAGE_KEY) === "true") { if (window.localStorage.getItem(CONSENT_STORAGE_KEY) === "true") {
return true; return true;
} }
} catch { } } catch {}
return readConsentCookie() === "true"; return readConsentCookie() === "true";
}; };

View File

@@ -1,228 +1,228 @@
function buildIdentity(admin) { function buildIdentity(admin) {
const identity = document.createElement("div"); const identity = document.createElement("div");
identity.className = "identity-row"; identity.className = "identity-row";
const avatar = document.createElement(admin.avatarUrl ? "img" : "div"); const avatar = document.createElement(admin.avatarUrl ? "img" : "div");
avatar.className = "avatar"; avatar.className = "avatar";
if (admin.avatarUrl) { if (admin.avatarUrl) {
avatar.src = admin.avatarUrl; avatar.src = admin.avatarUrl;
avatar.alt = `${admin.displayName || admin.login} avatar`; avatar.alt = `${admin.displayName || admin.login} avatar`;
} else { } else {
avatar.classList.add("avatar-fallback"); avatar.classList.add("avatar-fallback");
avatar.textContent = (admin.displayName || admin.login || "?").charAt(0).toUpperCase(); avatar.textContent = (admin.displayName || admin.login || "?").charAt(0).toUpperCase();
} }
const details = document.createElement("div"); const details = document.createElement("div");
details.className = "identity-text"; details.className = "identity-text";
const title = document.createElement("p"); const title = document.createElement("p");
title.className = "list-title"; title.className = "list-title";
title.textContent = admin.displayName || admin.login; title.textContent = admin.displayName || admin.login;
const subtitle = document.createElement("p"); const subtitle = document.createElement("p");
subtitle.className = "muted"; subtitle.className = "muted";
subtitle.textContent = `@${admin.login}`; subtitle.textContent = `@${admin.login}`;
details.appendChild(title); details.appendChild(title);
details.appendChild(subtitle); details.appendChild(subtitle);
identity.appendChild(avatar); identity.appendChild(avatar);
identity.appendChild(details); identity.appendChild(details);
return identity; return identity;
} }
function renderAdmins(list) { function renderAdmins(list) {
const adminList = document.getElementById("admin-list"); const adminList = document.getElementById("admin-list");
if (!adminList) return; if (!adminList) return;
adminList.innerHTML = ""; adminList.innerHTML = "";
if (!list || list.length === 0) { if (!list || list.length === 0) {
const empty = document.createElement("li"); const empty = document.createElement("li");
empty.textContent = "No channel admins yet"; empty.textContent = "No channel admins yet";
adminList.appendChild(empty); adminList.appendChild(empty);
return; return;
} }
list.forEach((admin) => { list.forEach((admin) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "stacked-list-item"; li.className = "stacked-list-item";
li.appendChild(buildIdentity(admin)); li.appendChild(buildIdentity(admin));
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "actions"; actions.className = "actions";
const removeBtn = document.createElement("button"); const removeBtn = document.createElement("button");
removeBtn.type = "button"; removeBtn.type = "button";
removeBtn.className = "secondary"; removeBtn.className = "secondary";
removeBtn.textContent = "Remove"; removeBtn.textContent = "Remove";
removeBtn.addEventListener("click", () => removeAdmin(admin.login)); removeBtn.addEventListener("click", () => removeAdmin(admin.login));
actions.appendChild(removeBtn); actions.appendChild(removeBtn);
li.appendChild(actions); li.appendChild(actions);
adminList.appendChild(li); adminList.appendChild(li);
}); });
} }
function renderSuggestedAdmins(list) { function renderSuggestedAdmins(list) {
const suggestionList = document.getElementById("admin-suggestions"); const suggestionList = document.getElementById("admin-suggestions");
if (!suggestionList) return; if (!suggestionList) return;
suggestionList.innerHTML = ""; suggestionList.innerHTML = "";
if (!list || list.length === 0) { if (!list || list.length === 0) {
const empty = document.createElement("li"); const empty = document.createElement("li");
empty.className = "stacked-list-item"; empty.className = "stacked-list-item";
empty.textContent = "No moderator suggestions right now"; empty.textContent = "No moderator suggestions right now";
suggestionList.appendChild(empty); suggestionList.appendChild(empty);
return; return;
} }
list.forEach((admin) => { list.forEach((admin) => {
const li = document.createElement("li"); const li = document.createElement("li");
li.className = "stacked-list-item"; li.className = "stacked-list-item";
li.appendChild(buildIdentity(admin)); li.appendChild(buildIdentity(admin));
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "actions"; actions.className = "actions";
const addBtn = document.createElement("button"); const addBtn = document.createElement("button");
addBtn.type = "button"; addBtn.type = "button";
addBtn.className = "ghost"; addBtn.className = "ghost";
addBtn.textContent = "Add as admin"; addBtn.textContent = "Add as admin";
addBtn.addEventListener("click", () => addAdmin(admin.login)); addBtn.addEventListener("click", () => addAdmin(admin.login));
actions.appendChild(addBtn); actions.appendChild(addBtn);
li.appendChild(actions); li.appendChild(actions);
suggestionList.appendChild(li); suggestionList.appendChild(li);
}); });
} }
function fetchSuggestedAdmins() { function fetchSuggestedAdmins() {
fetch(`/api/channels/${broadcaster}/admins/suggestions`) fetch(`/api/channels/${broadcaster}/admins/suggestions`)
.then((r) => { .then((r) => {
if (!r.ok) { if (!r.ok) {
throw new Error("Failed to load admin suggestions"); throw new Error("Failed to load admin suggestions");
} }
return r.json(); return r.json();
}) })
.then(renderSuggestedAdmins) .then(renderSuggestedAdmins)
.catch(() => { .catch(() => {
renderSuggestedAdmins([]); renderSuggestedAdmins([]);
}); });
} }
function fetchAdmins() { function fetchAdmins() {
fetch(`/api/channels/${broadcaster}/admins`) fetch(`/api/channels/${broadcaster}/admins`)
.then((r) => { .then((r) => {
if (!r.ok) { if (!r.ok) {
throw new Error("Failed to load admins"); throw new Error("Failed to load admins");
} }
return r.json(); return r.json();
}) })
.then(renderAdmins) .then(renderAdmins)
.catch(() => { .catch(() => {
renderAdmins([]); renderAdmins([]);
showToast("Unable to load admins right now. Please try again.", "error"); showToast("Unable to load admins right now. Please try again.", "error");
}); });
} }
function removeAdmin(username) { function removeAdmin(username) {
if (!username) return; if (!username) return;
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, { fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, {
method: "DELETE", method: "DELETE",
})
.then((response) => {
if (!response.ok) {
throw new Error();
}
fetchAdmins();
fetchSuggestedAdmins();
}) })
.catch(() => { .then((response) => {
showToast("Failed to remove admin. Please retry.", "error"); if (!response.ok) {
}); throw new Error();
}
fetchAdmins();
fetchSuggestedAdmins();
})
.catch(() => {
showToast("Failed to remove admin. Please retry.", "error");
});
} }
function addAdmin(usernameFromAction) { function addAdmin(usernameFromAction) {
const input = document.getElementById("new-admin"); const input = document.getElementById("new-admin");
const username = (usernameFromAction || input?.value || "").trim(); const username = (usernameFromAction || input?.value || "").trim();
if (!username) { if (!username) {
showToast("Enter a Twitch username to add as an admin.", "info"); showToast("Enter a Twitch username to add as an admin.", "info");
return; return;
} }
fetch(`/api/channels/${broadcaster}/admins`, { fetch(`/api/channels/${broadcaster}/admins`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }), body: JSON.stringify({ username }),
})
.then((response) => {
if (!response.ok) {
throw new Error("Add admin failed");
}
if (input) {
input.value = "";
}
showToast(`Added @${username} as an admin.`, "success");
fetchAdmins();
fetchSuggestedAdmins();
}) })
.catch(() => showToast("Unable to add admin right now. Please try again.", "error")); .then((response) => {
if (!response.ok) {
throw new Error("Add admin failed");
}
if (input) {
input.value = "";
}
showToast(`Added @${username} as an admin.`, "success");
fetchAdmins();
fetchSuggestedAdmins();
})
.catch(() => showToast("Unable to add admin right now. Please try again.", "error"));
} }
function renderCanvasSettings(settings) { function renderCanvasSettings(settings) {
const widthInput = document.getElementById("canvas-width"); const widthInput = document.getElementById("canvas-width");
const heightInput = document.getElementById("canvas-height"); const heightInput = document.getElementById("canvas-height");
if (widthInput) widthInput.value = Math.round(settings.width); if (widthInput) widthInput.value = Math.round(settings.width);
if (heightInput) heightInput.value = Math.round(settings.height); if (heightInput) heightInput.value = Math.round(settings.height);
} }
function fetchCanvasSettings() { function fetchCanvasSettings() {
fetch(`/api/channels/${broadcaster}/canvas`) fetch(`/api/channels/${broadcaster}/canvas`)
.then((r) => { .then((r) => {
if (!r.ok) { if (!r.ok) {
throw new Error("Failed to load canvas settings"); throw new Error("Failed to load canvas settings");
} }
return r.json(); return r.json();
}) })
.then(renderCanvasSettings) .then(renderCanvasSettings)
.catch(() => { .catch(() => {
renderCanvasSettings({ width: 1920, height: 1080 }); renderCanvasSettings({ width: 1920, height: 1080 });
showToast("Using default canvas size. Unable to load saved settings.", "warning"); showToast("Using default canvas size. Unable to load saved settings.", "warning");
}); });
} }
function saveCanvasSettings() { function saveCanvasSettings() {
const widthInput = document.getElementById("canvas-width"); const widthInput = document.getElementById("canvas-width");
const heightInput = document.getElementById("canvas-height"); const heightInput = document.getElementById("canvas-height");
const status = document.getElementById("canvas-status"); const status = document.getElementById("canvas-status");
const width = parseFloat(widthInput?.value) || 0; const width = parseFloat(widthInput?.value) || 0;
const height = parseFloat(heightInput?.value) || 0; const height = parseFloat(heightInput?.value) || 0;
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
showToast("Please enter a valid width and height.", "info"); showToast("Please enter a valid width and height.", "info");
return; return;
} }
if (status) status.textContent = "Saving..."; if (status) status.textContent = "Saving...";
fetch(`/api/channels/${broadcaster}/canvas`, { fetch(`/api/channels/${broadcaster}/canvas`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ width, height }), body: JSON.stringify({ width, height }),
})
.then((r) => {
if (!r.ok) {
throw new Error("Failed to save canvas");
}
return r.json();
}) })
.then((settings) => { .then((r) => {
renderCanvasSettings(settings); if (!r.ok) {
if (status) status.textContent = "Saved."; throw new Error("Failed to save canvas");
showToast("Canvas size saved successfully.", "success"); }
setTimeout(() => { return r.json();
if (status) status.textContent = ""; })
}, 2000); .then((settings) => {
}) renderCanvasSettings(settings);
.catch(() => { if (status) status.textContent = "Saved.";
if (status) status.textContent = "Unable to save right now."; showToast("Canvas size saved successfully.", "success");
showToast("Unable to save canvas size. Please retry.", "error"); setTimeout(() => {
}); if (status) status.textContent = "";
}, 2000);
})
.catch(() => {
if (status) status.textContent = "Unable to save right now.";
showToast("Unable to save canvas size. Please retry.", "error");
});
} }
fetchAdmins(); fetchAdmins();

View File

@@ -1,40 +1,40 @@
function detectPlatform() { function detectPlatform() {
const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || "").toLowerCase(); const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || "").toLowerCase();
const userAgent = (navigator.userAgent || "").toLowerCase(); const userAgent = (navigator.userAgent || "").toLowerCase();
const platformString = `${navigatorPlatform} ${userAgent}`; const platformString = `${navigatorPlatform} ${userAgent}`;
if (platformString.includes("mac") || platformString.includes("darwin")) { if (platformString.includes("mac") || platformString.includes("darwin")) {
return "mac"; return "mac";
} }
if (platformString.includes("win")) { if (platformString.includes("win")) {
return "windows"; return "windows";
} }
if (platformString.includes("linux")) { if (platformString.includes("linux")) {
return "linux"; return "linux";
} }
return null; return null;
} }
function markRecommendedDownload(section) { function markRecommendedDownload(section) {
const cards = Array.from(section.querySelectorAll(".download-card")); const cards = Array.from(section.querySelectorAll(".download-card"));
if (!cards.length) { if (!cards.length) {
return; return;
}
const platform = detectPlatform();
const preferredCard = cards.find((card) => card.dataset.platform === platform) || cards[0];
cards.forEach((card) => {
const isPreferred = card === preferredCard;
card.classList.toggle("download-card--active", isPreferred);
const badge = card.querySelector(".recommended-badge");
if (badge) {
badge.classList.toggle("hidden", !isPreferred);
} }
});
const platform = detectPlatform();
const preferredCard = cards.find((card) => card.dataset.platform === platform) || cards[0];
cards.forEach((card) => {
const isPreferred = card === preferredCard;
card.classList.toggle("download-card--active", isPreferred);
const badge = card.querySelector(".recommended-badge");
if (badge) {
badge.classList.toggle("hidden", !isPreferred);
}
});
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const downloadSections = document.querySelectorAll(".download-section, .download-card-block"); const downloadSections = document.querySelectorAll(".download-section, .download-card-block");
downloadSections.forEach(markRecommendedDownload); downloadSections.forEach(markRecommendedDownload);
}); });

View File

@@ -1,54 +1,54 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const searchForm = document.getElementById("channel-search-form"); const searchForm = document.getElementById("channel-search-form");
const searchInput = document.getElementById("channel-search"); const searchInput = document.getElementById("channel-search");
const suggestions = document.getElementById("channel-suggestions"); const suggestions = document.getElementById("channel-suggestions");
if (!searchForm || !searchInput || !suggestions) { if (!searchForm || !searchInput || !suggestions) {
console.error("Required elements not found in the DOM"); console.error("Required elements not found in the DOM");
return; return;
} }
let channels = []; let channels = [];
function updateSuggestions(term) { function updateSuggestions(term) {
const normalizedTerm = term.trim().toLowerCase(); const normalizedTerm = term.trim().toLowerCase();
const filtered = channels.filter((name) => !normalizedTerm || name.includes(normalizedTerm)).slice(0, 20); const filtered = channels.filter((name) => !normalizedTerm || name.includes(normalizedTerm)).slice(0, 20);
suggestions.innerHTML = ""; suggestions.innerHTML = "";
filtered.forEach((name) => { filtered.forEach((name) => {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = name; option.value = name;
suggestions.appendChild(option); suggestions.appendChild(option);
});
}
async function loadChannels() {
try {
const response = await fetch("/api/channels");
if (!response.ok) {
throw new Error(`Failed to load channels: ${response.status}`);
}
channels = await response.json();
updateSuggestions(searchInput.value || "");
} catch (error) {
console.error("Could not load channel directory", error);
}
}
searchInput.focus({ preventScroll: true });
searchInput.select();
searchInput.addEventListener("input", (event) => updateSuggestions(event.target.value || ""));
searchForm.addEventListener("submit", (event) => {
event.preventDefault();
const broadcaster = (searchInput.value || "").trim().toLowerCase();
if (!broadcaster) {
searchInput.focus();
return;
}
window.location.href = `/view/${encodeURIComponent(broadcaster)}/broadcast`;
}); });
}
async function loadChannels() { loadChannels();
try {
const response = await fetch("/api/channels");
if (!response.ok) {
throw new Error(`Failed to load channels: ${response.status}`);
}
channels = await response.json();
updateSuggestions(searchInput.value || "");
} catch (error) {
console.error("Could not load channel directory", error);
}
}
searchInput.focus({ preventScroll: true });
searchInput.select();
searchInput.addEventListener("input", (event) => updateSuggestions(event.target.value || ""));
searchForm.addEventListener("submit", (event) => {
event.preventDefault();
const broadcaster = (searchInput.value || "").trim().toLowerCase();
if (!broadcaster) {
searchInput.focus();
return;
}
window.location.href = `/view/${encodeURIComponent(broadcaster)}/broadcast`;
});
loadChannels();
}); });

View File

@@ -19,130 +19,130 @@ const currentSettings = JSON.parse(serverRenderedSettings);
let userSettings = { ...currentSettings }; let userSettings = { ...currentSettings };
function jsonEquals(a, b) { function jsonEquals(a, b) {
if (a === b) return true; if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
return false; return false;
} }
const keysA = Object.keys(a); const keysA = Object.keys(a);
const keysB = Object.keys(b); const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false; if (keysA.length !== keysB.length) return false;
for (const key of keysA) { for (const key of keysA) {
if (!keysB.includes(key)) return false; if (!keysB.includes(key)) return false;
if (!jsonEquals(a[key], b[key])) return false; if (!jsonEquals(a[key], b[key])) return false;
} }
return true; return true;
} }
function setFormSettings(s) { function setFormSettings(s) {
canvasFpsElement.value = s.canvasFramesPerSecond; canvasFpsElement.value = s.canvasFramesPerSecond;
canvasSizeElement.value = s.maxCanvasSideLengthPixels; canvasSizeElement.value = s.maxCanvasSideLengthPixels;
minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction; minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction;
maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction; maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction;
minPitchElement.value = s.minAssetAudioPitchFraction; minPitchElement.value = s.minAssetAudioPitchFraction;
maxPitchElement.value = s.maxAssetAudioPitchFraction; maxPitchElement.value = s.maxAssetAudioPitchFraction;
minVolumeElement.value = s.minAssetVolumeFraction; minVolumeElement.value = s.minAssetVolumeFraction;
maxVolumeElement.value = s.maxAssetVolumeFraction; maxVolumeElement.value = s.maxAssetVolumeFraction;
} }
function updateStatCards(settings) { function updateStatCards(settings) {
if (!settings) return; if (!settings) return;
statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`; statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`;
statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`; statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`;
statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`; statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`;
statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} ${settings.maxAssetAudioPitchFraction ?? "--"}x`; statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} ${settings.maxAssetAudioPitchFraction ?? "--"}x`;
statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} ${settings.maxAssetVolumeFraction ?? "--"}x`; statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} ${settings.maxAssetVolumeFraction ?? "--"}x`;
} }
function readInt(input) { function readInt(input) {
return input.checkValidity() ? Number(input.value) : null; return input.checkValidity() ? Number(input.value) : null;
} }
function readFloat(input) { function readFloat(input) {
return input.checkValidity() ? Number(input.value) : null; return input.checkValidity() ? Number(input.value) : null;
} }
function loadUserSettingsFromDom() { function loadUserSettingsFromDom() {
userSettings.canvasFramesPerSecond = readInt(canvasFpsElement); userSettings.canvasFramesPerSecond = readInt(canvasFpsElement);
userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement); userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement);
userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement); userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement);
userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement); userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement);
userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement); userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement);
userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement); userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement);
userSettings.minAssetVolumeFraction = readFloat(minVolumeElement); userSettings.minAssetVolumeFraction = readFloat(minVolumeElement);
userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement); userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement);
} }
function updateSubmitButtonDisabledState() { function updateSubmitButtonDisabledState() {
if (jsonEquals(currentSettings, userSettings)) { if (jsonEquals(currentSettings, userSettings)) {
submitButtonElement.disabled = "disabled"; submitButtonElement.disabled = "disabled";
statusElement.textContent = "No changes yet."; statusElement.textContent = "No changes yet.";
statusElement.classList.remove("status-success", "status-warning"); statusElement.classList.remove("status-success", "status-warning");
return; return;
} }
if (!formElement.checkValidity()) { if (!formElement.checkValidity()) {
submitButtonElement.disabled = "disabled"; submitButtonElement.disabled = "disabled";
statusElement.textContent = "Fix highlighted fields."; statusElement.textContent = "Fix highlighted fields.";
statusElement.classList.add("status-warning"); statusElement.classList.add("status-warning");
statusElement.classList.remove("status-success"); statusElement.classList.remove("status-success");
return; return;
} }
submitButtonElement.disabled = null; submitButtonElement.disabled = null;
statusElement.textContent = "Ready to save."; statusElement.textContent = "Ready to save.";
statusElement.classList.remove("status-warning"); statusElement.classList.remove("status-warning");
} }
function submitSettingsForm() { function submitSettingsForm() {
if (submitButtonElement.getAttribute("disabled") != null) { if (submitButtonElement.getAttribute("disabled") != null) {
console.warn("Attempted to submit invalid form"); console.warn("Attempted to submit invalid form");
showToast("Settings not valid", "warning"); showToast("Settings not valid", "warning");
return; return;
} }
statusElement.textContent = "Saving…"; statusElement.textContent = "Saving…";
statusElement.classList.remove("status-success", "status-warning"); statusElement.classList.remove("status-success", "status-warning");
fetch("/api/settings/set", { fetch("/api/settings/set", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(userSettings), body: JSON.stringify(userSettings),
})
.then((r) => {
if (!r.ok) {
throw new Error("Failed to load canvas");
}
return r.json();
}) })
.then((newSettings) => { .then((r) => {
currentSettings = { ...newSettings }; if (!r.ok) {
userSettings = { ...newSettings }; throw new Error("Failed to load canvas");
updateStatCards(newSettings); }
showToast("Settings saved", "success"); return r.json();
statusElement.textContent = "Saved."; })
statusElement.classList.add("status-success"); .then((newSettings) => {
updateSubmitButtonDisabledState(); currentSettings = { ...newSettings };
}) userSettings = { ...newSettings };
.catch((error) => { updateStatCards(newSettings);
showToast("Unable to save settings", "error"); showToast("Settings saved", "success");
console.error(error); statusElement.textContent = "Saved.";
statusElement.textContent = "Save failed. Try again."; statusElement.classList.add("status-success");
statusElement.classList.add("status-warning"); updateSubmitButtonDisabledState();
}); })
.catch((error) => {
showToast("Unable to save settings", "error");
console.error(error);
statusElement.textContent = "Save failed. Try again.";
statusElement.classList.add("status-warning");
});
} }
formElement.querySelectorAll("input").forEach((input) => { formElement.querySelectorAll("input").forEach((input) => {
input.addEventListener("input", () => { input.addEventListener("input", () => {
loadUserSettingsFromDom(); loadUserSettingsFromDom();
updateSubmitButtonDisabledState(); updateSubmitButtonDisabledState();
}); });
}); });
formElement.addEventListener("submit", (event) => { formElement.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
submitSettingsForm(); submitSettingsForm();
}); });
setFormSettings(currentSettings); setFormSettings(currentSettings);

View File

@@ -1,51 +1,51 @@
(function () { (function () {
const CONTAINER_ID = "toast-container"; const CONTAINER_ID = "toast-container";
const DEFAULT_DURATION = 4200; const DEFAULT_DURATION = 4200;
function ensureContainer() { function ensureContainer() {
let container = document.getElementById(CONTAINER_ID); let container = document.getElementById(CONTAINER_ID);
if (!container) { if (!container) {
container = document.createElement("div"); container = document.createElement("div");
container.id = CONTAINER_ID; container.id = CONTAINER_ID;
container.className = "toast-container"; container.className = "toast-container";
container.setAttribute("aria-live", "polite"); container.setAttribute("aria-live", "polite");
container.setAttribute("aria-atomic", "true"); container.setAttribute("aria-atomic", "true");
document.body.appendChild(container); document.body.appendChild(container);
}
return container;
} }
return container;
}
function buildToast(message, type) { function buildToast(message, type) {
const toast = document.createElement("div"); const toast = document.createElement("div");
toast.className = `toast toast-${type}`; toast.className = `toast toast-${type}`;
const indicator = document.createElement("span"); const indicator = document.createElement("span");
indicator.className = "toast-indicator"; indicator.className = "toast-indicator";
indicator.setAttribute("aria-hidden", "true"); indicator.setAttribute("aria-hidden", "true");
const content = document.createElement("div"); const content = document.createElement("div");
content.className = "toast-message"; content.className = "toast-message";
content.textContent = message; content.textContent = message;
toast.appendChild(indicator); toast.appendChild(indicator);
toast.appendChild(content); toast.appendChild(content);
return toast; return toast;
} }
function removeToast(toast) { function removeToast(toast) {
if (!toast) return; if (!toast) return;
toast.classList.add("toast-exit"); toast.classList.add("toast-exit");
setTimeout(() => toast.remove(), 250); setTimeout(() => toast.remove(), 250);
} }
window.showToast = function showToast(message, type = "info", options = {}) { window.showToast = function showToast(message, type = "info", options = {}) {
if (!message) return; if (!message) return;
const normalized = ["success", "error", "warning", "info"].includes(type) ? type : "info"; const normalized = ["success", "error", "warning", "info"].includes(type) ? type : "info";
const duration = typeof options.duration === "number" ? options.duration : DEFAULT_DURATION; const duration = typeof options.duration === "number" ? options.duration : DEFAULT_DURATION;
const container = ensureContainer(); const container = ensureContainer();
const toast = buildToast(message, normalized); const toast = buildToast(message, normalized);
container.appendChild(toast); container.appendChild(toast);
setTimeout(() => removeToast(toast), Math.max(1200, duration)); setTimeout(() => removeToast(toast), Math.max(1200, duration));
toast.addEventListener("click", () => removeToast(toast)); toast.addEventListener("click", () => removeToast(toast));
}; };
})(); })();

View File

@@ -1,308 +1,358 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Imgfloat Admin</title> <title>Imgfloat Admin</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
crossorigin="anonymous" crossorigin="anonymous"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
/> />
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
</head> </head>
<body class="admin-body"> <body class="admin-body">
<div class="admin-frame"> <div class="admin-frame">
<header class="admin-topbar"> <header class="admin-topbar">
<div class="topbar-left"> <div class="topbar-left">
<div class="admin-identity"> <div class="admin-identity">
<p class="eyebrow subtle">CHANNEL ADMIN</p> <p class="eyebrow subtle">CHANNEL ADMIN</p>
<h1 th:text="${broadcaster}"></h1> <h1 th:text="${broadcaster}"></h1>
</div>
</div>
<div class="header-actions horizontal">
<a class="icon-button" th:href="@{/}" title="Back to dashboard">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span class="sr-only">Back to dashboard</span>
</a>
<a class="button ghost" th:href="${'/view/' + broadcaster + '/broadcast'}" target="_blank" rel="noopener"
>Broadcaster view</a
>
</div>
</header>
<div class="admin-workspace">
<aside class="admin-rail">
<div class="upload-row">
<input
id="asset-file"
class="file-input-field"
type="file"
accept="image/*,video/*,audio/*"
onchange="handleFileSelection(this)"
/>
<label for="asset-file" class="file-input-trigger">
<span class="file-input-icon"><i class="fa-solid fa-cloud-arrow-up"></i></span>
<span class="file-input-copy">
<strong>Upload asset</strong>
<small id="asset-file-name">No file chosen</small>
</span>
</label>
</div>
<div class="rail-body">
<div class="rail-scroll">
<ul id="asset-list" class="asset-list"></ul>
</div>
</div>
<div id="asset-inspector" class="rail-inspector hidden">
<div class="asset-inspector">
<div class="selected-asset-banner">
<div class="selected-asset-main">
<div class="title-row">
<strong id="selected-asset-name">Choose an asset</strong>
<span id="selected-asset-resolution" class="asset-resolution subtle-text hidden"></span>
</div>
<p class="meta-text" id="selected-asset-meta">
Pick an asset in the list to adjust its placement and playback.
</p>
<p class="meta-text subtle-text hidden" id="selected-asset-id"></p>
<div class="badge-row asset-meta-badges" id="selected-asset-badges"></div>
</div>
</div>
<div id="asset-controls-placeholder" class="asset-controls-placeholder">
<div id="asset-controls" class="hidden asset-settings">
<div class="panel-section" id="layout-section">
<div class="section-header">
<h5>Layout & order</h5>
</div> </div>
<div class="property-list"> </div>
<div class="property-row"> <div class="header-actions horizontal">
<span class="property-label">Width</span> <a class="icon-button" th:href="@{/}" title="Back to dashboard">
<input id="asset-width" class="number-input property-control" type="number" min="10" step="5" /> <i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</div> <span class="sr-only">Back to dashboard</span>
<div class="property-row"> </a>
<span class="property-label">Height</span> <a
class="button ghost"
th:href="${'/view/' + broadcaster + '/broadcast'}"
target="_blank"
rel="noopener"
>Broadcaster view</a
>
</div>
</header>
<div class="admin-workspace">
<aside class="admin-rail">
<div class="upload-row">
<input <input
id="asset-height" id="asset-file"
class="number-input property-control" class="file-input-field"
type="number" type="file"
min="10" accept="image/*,video/*,audio/*"
step="5" onchange="handleFileSelection(this)"
/> />
</div> <label for="asset-file" class="file-input-trigger">
<div class="property-row"> <span class="file-input-icon"><i class="fa-solid fa-cloud-arrow-up"></i></span>
<span class="property-label">Maintain AR</span> <span class="file-input-copy">
<label class="checkbox-inline toggle inline-toggle property-control"> <strong>Upload asset</strong>
<input id="maintain-aspect" type="checkbox" checked /> <small id="asset-file-name">No file chosen</small>
<span class="toggle-track" aria-hidden="true"> </span>
<span class="toggle-thumb"></span>
</span>
</label> </label>
</div> </div>
<div class="property-row"> <div class="rail-body">
<span class="property-label">Layer</span> <div class="rail-scroll">
<div class="property-control"> <ul id="asset-list" class="asset-list"></ul>
<div class="badge-row stacked">
<span class="badge">Layer <strong id="asset-z-level">1</strong></span>
</div>
</div> </div>
</div>
</div> </div>
</div>
<div class="panel-section" id="playback-section"> <div id="asset-inspector" class="rail-inspector hidden">
<div class="section-header"> <div class="asset-inspector">
<h5>Playback</h5> <div class="selected-asset-banner">
</div> <div class="selected-asset-main">
<div class="stacked-field"> <div class="title-row">
<div class="label-row"> <strong id="selected-asset-name">Choose an asset</strong>
<span>Playback speed</span> <span
<span class="value-hint" id="asset-speed-label">100%</span> id="selected-asset-resolution"
</div> class="asset-resolution subtle-text hidden"
<input ></span>
id="asset-speed" </div>
class="range-input" <p class="meta-text" id="selected-asset-meta">
type="range" Pick an asset in the list to adjust its placement and playback.
min="0" </p>
max="1000" <p class="meta-text subtle-text hidden" id="selected-asset-id"></p>
step="10" <div class="badge-row asset-meta-badges" id="selected-asset-badges"></div>
value="100" </div>
/> </div>
<div class="range-meta"><span>0%</span><span>1000%</span></div> <div id="asset-controls-placeholder" class="asset-controls-placeholder">
</div> <div id="asset-controls" class="hidden asset-settings">
</div> <div class="panel-section" id="layout-section">
<div class="section-header">
<h5>Layout & order</h5>
</div>
<div class="property-list">
<div class="property-row">
<span class="property-label">Width</span>
<input
id="asset-width"
class="number-input property-control"
type="number"
min="10"
step="5"
/>
</div>
<div class="property-row">
<span class="property-label">Height</span>
<input
id="asset-height"
class="number-input property-control"
type="number"
min="10"
step="5"
/>
</div>
<div class="property-row">
<span class="property-label">Maintain AR</span>
<label class="checkbox-inline toggle inline-toggle property-control">
<input id="maintain-aspect" type="checkbox" checked />
<span class="toggle-track" aria-hidden="true">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
<div class="property-row">
<span class="property-label">Layer</span>
<div class="property-control">
<div class="badge-row stacked">
<span class="badge"
>Layer <strong id="asset-z-level">1</strong></span
>
</div>
</div>
</div>
</div>
</div>
<div class="panel-section" id="volume-section"> <div class="panel-section" id="playback-section">
<div class="section-header"> <div class="section-header">
<h5>Volume</h5> <h5>Playback</h5>
</div> </div>
<div class="stacked-field"> <div class="stacked-field">
<div class="label-row"> <div class="label-row">
<span>Playback volume</span> <span>Playback speed</span>
<span class="value-hint" id="asset-volume-label">100%</span> <span class="value-hint" id="asset-speed-label">100%</span>
</div> </div>
<input <input
id="asset-volume" id="asset-speed"
class="range-input" class="range-input"
type="range" type="range"
min="0" min="0"
max="200" max="1000"
step="1" step="10"
value="100" value="100"
/> />
<div class="range-meta"><span>0%</span><span>200%</span></div> <div class="range-meta"><span>0%</span><span>1000%</span></div>
</div> </div>
</div> </div>
<div class="panel-section hidden" id="audio-section"> <div class="panel-section" id="volume-section">
<div class="section-header"> <div class="section-header">
<h5>Audio</h5> <h5>Volume</h5>
</div> </div>
<div class="property-list"> <div class="stacked-field">
<div class="property-row"> <div class="label-row">
<span class="property-label">Loop</span> <span>Playback volume</span>
<label class="checkbox-inline toggle inline-toggle property-control"> <span class="value-hint" id="asset-volume-label">100%</span>
<input id="asset-audio-loop" type="checkbox" /> </div>
<span class="toggle-track" aria-hidden="true"> <input
<span class="toggle-thumb"></span> id="asset-volume"
</span> class="range-input"
</label> type="range"
</div> min="0"
</div> max="200"
<div class="stacked-field"> step="1"
<div class="label-row"> value="100"
<span>Delay</span> />
<span class="value-hint" id="asset-audio-delay-label">0ms</span> <div class="range-meta"><span>0%</span><span>200%</span></div>
</div> </div>
<input </div>
id="asset-audio-delay"
class="range-input property-control"
type="range"
min="0"
max="30000"
step="100"
value="0"
/>
<div class="range-meta"><span>0ms</span><span>30s</span></div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Playback speed</span>
<span class="value-hint" id="asset-audio-speed-label">1.0x</span>
</div>
<input
id="asset-audio-speed"
class="range-input"
type="range"
min="25"
max="400"
step="5"
value="100"
/>
<div class="range-meta"><span>0.25x</span><span>4x</span></div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Pitch</span>
<span class="value-hint" id="asset-audio-pitch-label">100%</span>
</div>
<input
id="asset-audio-pitch"
class="range-input property-control"
type="range"
min="50"
max="200"
step="5"
value="100"
/>
<div class="range-meta"><span>50%</span><span>200%</span></div>
</div>
</div>
<div class="control-actions compact unified-actions" id="asset-actions">
<button type="button" onclick="sendToBack()" class="secondary" title="Send to back">
<i class="fa-solid fa-angles-down"></i>
</button>
<button type="button" onclick="bringBackward()" class="secondary" title="Move backward">
<i class="fa-solid fa-arrow-down"></i>
</button>
<button type="button" onclick="bringForward()" class="secondary" title="Move forward">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button type="button" onclick="bringToFront()" class="secondary" title="Bring to front">
<i class="fa-solid fa-angles-up"></i>
</button>
<button type="button" onclick="recenterSelectedAsset()" class="secondary" title="Center on canvas">
<i class="fa-solid fa-bullseye"></i>
</button>
<button type="button" onclick="nudgeRotation(-5)" class="secondary" title="Rotate left">
<i class="fa-solid fa-rotate-left"></i>
</button>
<button type="button" onclick="nudgeRotation(5)" class="secondary" title="Rotate right">
<i class="fa-solid fa-rotate-right"></i>
</button>
<button
id="selected-asset-visibility"
class="secondary"
type="button"
title="Hide asset"
disabled
data-audio-enabled="true"
>
<i class="fa-solid fa-eye-slash"></i>
</button>
<button
id="selected-asset-delete"
class="secondary danger"
type="button"
title="Delete asset"
disabled
data-audio-enabled="true"
>
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</aside>
<section class="canvas-stack"> <div class="panel-section hidden" id="audio-section">
<div class="canvas-topbar"> <div class="section-header">
<div> <h5>Audio</h5>
<p class="eyebrow subtle">Canvas</p> </div>
<h3 class="panel-title">Live composition</h3> <div class="property-list">
<div class="property-row">
<span class="property-label">Loop</span>
<label class="checkbox-inline toggle inline-toggle property-control">
<input id="asset-audio-loop" type="checkbox" />
<span class="toggle-track" aria-hidden="true">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Delay</span>
<span class="value-hint" id="asset-audio-delay-label">0ms</span>
</div>
<input
id="asset-audio-delay"
class="range-input property-control"
type="range"
min="0"
max="30000"
step="100"
value="0"
/>
<div class="range-meta"><span>0ms</span><span>30s</span></div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Playback speed</span>
<span class="value-hint" id="asset-audio-speed-label">1.0x</span>
</div>
<input
id="asset-audio-speed"
class="range-input"
type="range"
min="25"
max="400"
step="5"
value="100"
/>
<div class="range-meta"><span>0.25x</span><span>4x</span></div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Pitch</span>
<span class="value-hint" id="asset-audio-pitch-label">100%</span>
</div>
<input
id="asset-audio-pitch"
class="range-input property-control"
type="range"
min="50"
max="200"
step="5"
value="100"
/>
<div class="range-meta"><span>50%</span><span>200%</span></div>
</div>
</div>
<div class="control-actions compact unified-actions" id="asset-actions">
<button
type="button"
onclick="sendToBack()"
class="secondary"
title="Send to back"
>
<i class="fa-solid fa-angles-down"></i>
</button>
<button
type="button"
onclick="bringBackward()"
class="secondary"
title="Move backward"
>
<i class="fa-solid fa-arrow-down"></i>
</button>
<button
type="button"
onclick="bringForward()"
class="secondary"
title="Move forward"
>
<i class="fa-solid fa-arrow-up"></i>
</button>
<button
type="button"
onclick="bringToFront()"
class="secondary"
title="Bring to front"
>
<i class="fa-solid fa-angles-up"></i>
</button>
<button
type="button"
onclick="recenterSelectedAsset()"
class="secondary"
title="Center on canvas"
>
<i class="fa-solid fa-bullseye"></i>
</button>
<button
type="button"
onclick="nudgeRotation(-5)"
class="secondary"
title="Rotate left"
>
<i class="fa-solid fa-rotate-left"></i>
</button>
<button
type="button"
onclick="nudgeRotation(5)"
class="secondary"
title="Rotate right"
>
<i class="fa-solid fa-rotate-right"></i>
</button>
<button
id="selected-asset-visibility"
class="secondary"
type="button"
title="Hide asset"
disabled
data-audio-enabled="true"
>
<i class="fa-solid fa-eye-slash"></i>
</button>
<button
id="selected-asset-delete"
class="secondary danger"
type="button"
title="Delete asset"
disabled
data-audio-enabled="true"
>
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</aside>
<section class="canvas-stack">
<div class="canvas-topbar">
<div>
<p class="eyebrow subtle">Canvas</p>
<h3 class="panel-title">Live composition</h3>
</div>
<div class="canvas-meta">
<span class="badge soft" id="canvas-resolution">1920 x 1080</span>
<span class="badge outline" id="canvas-scale">100%</span>
</div>
</div>
<div class="canvas-surface">
<div class="overlay canvas-boundary" id="admin-overlay">
<div class="canvas-guides"></div>
<canvas id="admin-canvas"></canvas>
</div>
<div class="canvas-footnote">
<p>Edges of the canvas are outlined to match the aspect ratio of the stream.</p>
</div>
</div>
</section>
</div> </div>
<div class="canvas-meta"> </div>
<span class="badge soft" id="canvas-resolution">1920 x 1080</span> <script th:inline="javascript">
<span class="badge outline" id="canvas-scale">100%</span> const broadcaster = /*[[${broadcaster}]]*/ '';
</div> const username = /*[[${username}]]*/ '';
</div> const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
<div class="canvas-surface"> const SETTINGS = /*[[${settingsJson}]]*/;
<div class="overlay canvas-boundary" id="admin-overlay"> </script>
<div class="canvas-guides"></div> <script src="/js/cookie-consent.js"></script>
<canvas id="admin-canvas"></canvas> <script src="/js/toast.js"></script>
</div> <script src="/js/admin.js"></script>
<div class="canvas-footnote"> </body>
<p>Edges of the canvas are outlined to match the aspect ratio of the stream.</p>
</div>
</div>
</section>
</div>
</div>
<script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ '';
const username = /*[[${username}]]*/ '';
const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
const SETTINGS = /*[[${settingsJson}]]*/;
</script>
<script src="/js/cookie-consent.js"></script>
<script src="/js/toast.js"></script>
<script src="/js/admin.js"></script>
</body>
</html> </html>

View File

@@ -1,20 +1,20 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Imgfloat Broadcast</title> <title>Imgfloat Broadcast</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
</head> </head>
<body class="broadcast-body"> <body class="broadcast-body">
<canvas id="broadcast-canvas"></canvas> <canvas id="broadcast-canvas"></canvas>
<script th:inline="javascript"> <script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ ""; const broadcaster = /*[[${broadcaster}]]*/ "";
</script> </script>
<script src="/js/cookie-consent.js"></script> <script src="/js/cookie-consent.js"></script>
<script src="/js/toast.js"></script> <script src="/js/toast.js"></script>
<script src="/js/broadcast.js"></script> <script src="/js/broadcast.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,46 +1,46 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Browse channels - Imgfloat</title> <title>Browse channels - Imgfloat</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body class="channels-body"> <body class="channels-body">
<div class="channels-shell"> <div class="channels-shell">
<header class="channels-header"> <header class="channels-header">
<div class="brand"> <div class="brand">
<div class="brand-mark">IF</div> <div class="brand-mark">IF</div>
<div> <div>
<div class="brand-title">Imgfloat</div> <div class="brand-title">Imgfloat</div>
<div class="brand-subtitle">Twitch overlay manager</div> <div class="brand-subtitle">Twitch overlay manager</div>
</div> </div>
</div> </div>
</header> </header>
<main class="channels-main"> <main class="channels-main">
<section class="channel-card"> <section class="channel-card">
<p class="eyebrow subtle">Broadcast overlay</p> <p class="eyebrow subtle">Broadcast overlay</p>
<h1>Open a channel</h1> <h1>Open a channel</h1>
<p class="muted">Type the channel name to jump straight to their overlay.</p> <p class="muted">Type the channel name to jump straight to their overlay.</p>
<form id="channel-search-form" class="channel-form"> <form id="channel-search-form" class="channel-form">
<label class="sr-only" for="channel-search">Channel name</label> <label class="sr-only" for="channel-search">Channel name</label>
<input <input
id="channel-search" id="channel-search"
name="channel" name="channel"
class="text-input" class="text-input"
type="text" type="text"
list="channel-suggestions" list="channel-suggestions"
placeholder="Type a channel name" placeholder="Type a channel name"
autocomplete="off" autocomplete="off"
/> />
<datalist id="channel-suggestions"></datalist> <datalist id="channel-suggestions"></datalist>
<button type="submit" class="button block">Open overlay</button> <button type="submit" class="button block">Open overlay</button>
</form> </form>
</section> </section>
</main> </main>
</div> </div>
<script src="/js/cookie-consent.js"></script> <script src="/js/cookie-consent.js"></script>
<script src="/js/landing.js"></script> <script src="/js/landing.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,119 +1,119 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Imgfloat Dashboard</title> <title>Imgfloat Dashboard</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body class="dashboard-body"> <body class="dashboard-body">
<div class="dashboard-shell"> <div class="dashboard-shell">
<header class="dashboard-topbar"> <header class="dashboard-topbar">
<div class="brand"> <div class="brand">
<img class="brand-mark" src="/img/brand.png"/> <img class="brand-mark" src="/img/brand.png" />
<div> <div>
<div class="brand-title">Imgfloat</div> <div class="brand-title">Imgfloat</div>
<div class="brand-subtitle">Twitch overlay manager</div> <div class="brand-subtitle">Twitch overlay manager</div>
</div> </div>
</div> </div>
<div class="user-pill"> <div class="user-pill">
<span class="eyebrow subtle">Signed in as</span> <span class="eyebrow subtle">Signed in as</span>
<span class="user-display" th:text="${username}">user</span> <span class="user-display" th:text="${username}">user</span>
</div> </div>
</header> </header>
<section class="card"> <section class="card">
<p class="eyebrow">Navigation</p> <p class="eyebrow">Navigation</p>
<h3>Shortcuts</h3> <h3>Shortcuts</h3>
<p class="muted">Jump into your overlay</p> <p class="muted">Jump into your overlay</p>
<div class="panel-actions"> <div class="panel-actions">
<a class="button block" th:href="@{'/view/' + ${channel} + '/broadcast'}">Open broadcast overlay</a> <a class="button block" th:href="@{'/view/' + ${channel} + '/broadcast'}">Open broadcast overlay</a>
<a class="button ghost block" th:href="@{'/view/' + ${channel} + '/admin'}">Open admin console</a> <a class="button ghost block" th:href="@{'/view/' + ${channel} + '/admin'}">Open admin console</a>
<a class="button ghost block" href="/channels">Browse channels</a> <a class="button ghost block" href="/channels">Browse channels</a>
<form class="block" th:action="@{/logout}" method="post"> <form class="block" th:action="@{/logout}" method="post">
<button class="secondary block" type="submit">Logout</button> <button class="secondary block" type="submit">Logout</button>
</form> </form>
</div> </div>
</section> </section>
<section class="card"> <section class="card">
<p class="eyebrow">Settings</p> <p class="eyebrow">Settings</p>
<h3>Overlay dimensions</h3> <h3>Overlay dimensions</h3>
<p class="muted">Match these with your OBS resolution.</p> <p class="muted">Match these with your OBS resolution.</p>
<div class="control-grid"> <div class="control-grid">
<label> <label>
Width Width
<input id="canvas-width" type="number" min="100" step="10" /> <input id="canvas-width" type="number" min="100" step="10" />
</label> </label>
<label> <label>
Height Height
<input id="canvas-height" type="number" min="100" step="10" /> <input id="canvas-height" type="number" min="100" step="10" />
</label> </label>
</div> </div>
<div class="control-actions"> <div class="control-actions">
<button type="button" onclick="saveCanvasSettings()">Save canvas size</button> <button type="button" onclick="saveCanvasSettings()">Save canvas size</button>
<span id="canvas-status" class="muted"></span> <span id="canvas-status" class="muted"></span>
</div> </div>
</section> </section>
<section class="card-grid two-col"> <section class="card-grid two-col">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div> <div>
<p class="eyebrow">Collaboration</p> <p class="eyebrow">Collaboration</p>
<h3>Channel admins</h3> <h3>Channel admins</h3>
<p class="muted">Invite moderators to help manage assets.</p> <p class="muted">Invite moderators to help manage assets.</p>
</div> </div>
</div> </div>
<div class="inline-form"> <div class="inline-form">
<input id="new-admin" placeholder="Twitch username" /> <input id="new-admin" placeholder="Twitch username" />
<button type="button" onclick="addAdmin()">Add admin</button> <button type="button" onclick="addAdmin()">Add admin</button>
</div> </div>
<div class="card-section"> <div class="card-section">
<div class="section-header"> <div class="section-header">
<h4 class="list-title">Channel Admins</h4> <h4 class="list-title">Channel Admins</h4>
<p class="muted">Users who can currently modify your overlay.</p> <p class="muted">Users who can currently modify your overlay.</p>
</div> </div>
<ul id="admin-list" class="stacked-list"></ul> <ul id="admin-list" class="stacked-list"></ul>
</div> </div>
<div class="card-section"> <div class="card-section">
<div class="section-header"> <div class="section-header">
<h4 class="list-title">Your Twitch moderators</h4> <h4 class="list-title">Your Twitch moderators</h4>
<p class="muted">Add moderators who already help run your channel.</p> <p class="muted">Add moderators who already help run your channel.</p>
</div> </div>
<ul id="admin-suggestions" class="stacked-list"></ul> <ul id="admin-suggestions" class="stacked-list"></ul>
</div> </div>
</div> </div>
</section> </section>
<section th:if="${adminChannels != null}" class="card"> <section th:if="${adminChannels != null}" class="card">
<div class="card-header"> <div class="card-header">
<div> <div>
<p class="eyebrow">Your access</p> <p class="eyebrow">Your access</p>
<h3>Channels you administer</h3> <h3>Channels you administer</h3>
<p class="muted">Jump into a teammate's overlay console.</p> <p class="muted">Jump into a teammate's overlay console.</p>
</div> </div>
</div> </div>
<p th:if="${#lists.isEmpty(adminChannels)}">No admin invitations yet.</p> <p th:if="${#lists.isEmpty(adminChannels)}">No admin invitations yet.</p>
<ul th:if="${!#lists.isEmpty(adminChannels)}" class="stacked-list"> <ul th:if="${!#lists.isEmpty(adminChannels)}" class="stacked-list">
<li th:each="channelName : ${adminChannels}" class="stacked-list-item"> <li th:each="channelName : ${adminChannels}" class="stacked-list-item">
<div> <div>
<p class="list-title" th:text="${channelName}">channel</p> <p class="list-title" th:text="${channelName}">channel</p>
<p class="muted">Channel admin access</p> <p class="muted">Channel admin access</p>
</div> </div>
<a class="button ghost" th:href="@{'/view/' + ${channelName} + '/admin'}">Open</a> <a class="button ghost" th:href="@{'/view/' + ${channelName} + '/admin'}">Open</a>
</li> </li>
</ul> </ul>
</section> </section>
<section class="card download-card-block" th:insert="fragments/downloads :: downloads"></section> <section class="card download-card-block" th:insert="fragments/downloads :: downloads"></section>
</div> </div>
<script src="/js/cookie-consent.js"></script> <script src="/js/cookie-consent.js"></script>
<script src="/js/toast.js"></script> <script src="/js/toast.js"></script>
<script src="/js/downloads.js"></script> <script src="/js/downloads.js"></script>
<script th:inline="javascript"> <script th:inline="javascript">
const broadcaster = /*[[${channel}]]*/ ""; const broadcaster = /*[[${channel}]]*/ "";
</script> </script>
<script src="/js/dashboard.js"></script> <script src="/js/dashboard.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,44 +1,44 @@
<th:block th:fragment="downloads"> <th:block th:fragment="downloads">
<div class="download-header"> <div class="download-header">
<p class="eyebrow">Desktop app</p> <p class="eyebrow">Desktop app</p>
<h2>Download Imgfloat</h2> <h2>Download Imgfloat</h2>
</div>
<div class="download-grid">
<div class="download-card" data-platform="mac">
<div class="download-card-header">
<p class="eyebrow">macOS</p>
<span class="badge soft recommended-badge hidden">Recommended</span>
</div>
<p class="muted">Apple Silicon build (ARM64)</p>
<a
class="button block"
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '-arm64.dmg'"
>Download DMG</a
>
</div> </div>
<div class="download-card" data-platform="windows"> <div class="download-grid">
<div class="download-card-header"> <div class="download-card" data-platform="mac">
<p class="eyebrow">Windows</p> <div class="download-card-header">
<span class="badge soft recommended-badge hidden">Recommended</span> <p class="eyebrow">macOS</p>
</div> <span class="badge soft recommended-badge hidden">Recommended</span>
<p class="muted">Installer for Windows 10 and 11</p> </div>
<a <p class="muted">Apple Silicon build (ARM64)</p>
class="button block" <a
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat.Setup.' + ${releaseVersion} + '.exe'" class="button block"
>Download EXE</a th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '-arm64.dmg'"
> >Download DMG</a
>
</div>
<div class="download-card" data-platform="windows">
<div class="download-card-header">
<p class="eyebrow">Windows</p>
<span class="badge soft recommended-badge hidden">Recommended</span>
</div>
<p class="muted">Installer for Windows 10 and 11</p>
<a
class="button block"
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat.Setup.' + ${releaseVersion} + '.exe'"
>Download EXE</a
>
</div>
<div class="download-card" data-platform="linux">
<div class="download-card-header">
<p class="eyebrow">Linux</p>
<span class="badge soft recommended-badge hidden">Recommended</span>
</div>
<p class="muted">AppImage for most distributions</p>
<a
class="button block"
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '.AppImage'"
>Download AppImage</a
>
</div>
</div> </div>
<div class="download-card" data-platform="linux">
<div class="download-card-header">
<p class="eyebrow">Linux</p>
<span class="badge soft recommended-badge hidden">Recommended</span>
</div>
<p class="muted">AppImage for most distributions</p>
<a
class="button block"
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '.AppImage'"
>Download AppImage</a
>
</div>
</div>
</th:block> </th:block>

View File

@@ -1,48 +1,50 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Imgfloat - Twitch overlay</title> <title>Imgfloat - Twitch overlay</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body class="landing-body"> <body class="landing-body">
<div class="landing"> <div class="landing">
<header class="landing-header"> <header class="landing-header">
<div class="brand"> <div class="brand">
<img class="brand-mark" src="/img/brand.png"/> <img class="brand-mark" src="/img/brand.png" />
<div> <div>
<div class="brand-title">Imgfloat</div> <div class="brand-title">Imgfloat</div>
<div class="brand-subtitle">Twitch overlay manager</div> <div class="brand-subtitle">Twitch overlay manager</div>
</div> </div>
</div> </div>
</header> </header>
<main class="hero hero-compact"> <main class="hero hero-compact">
<div class="hero-text"> <div class="hero-text">
<p class="eyebrow">Overlay toolkit</p> <p class="eyebrow">Overlay toolkit</p>
<h1>Collaborative real-time Twitch overlay</h1> <h1>Collaborative real-time Twitch overlay</h1>
<p class="lead">Customize your Twitch stream with audio, video and images updated by your mods in real-time</p> <p class="lead">
<div class="cta-row"> Customize your Twitch stream with audio, video and images updated by your mods in real-time
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a> </p>
</div> <div class="cta-row">
</div> <a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
</main> </div>
</div>
</main>
<section class="download-section" th:insert="fragments/downloads :: downloads"></section> <section class="download-section" th:insert="fragments/downloads :: downloads"></section>
<footer class="landing-meta"> <footer class="landing-meta">
<div class="build-chip"> <div class="build-chip">
<span class="muted">License</span> <span class="muted">License</span>
<span class="version-badge">MIT</span> <span class="version-badge">MIT</span>
</div>
<div class="build-chip">
<span class="muted">Build</span>
<span class="version-badge" th:text="${version}">unknown</span>
</div>
</footer>
</div> </div>
<div class="build-chip"> <script src="/js/cookie-consent.js"></script>
<span class="muted">Build</span> <script src="/js/downloads.js"></script>
<span class="version-badge" th:text="${version}">unknown</span> </body>
</div>
</footer>
</div>
<script src="/js/cookie-consent.js"></script>
<script src="/js/downloads.js"></script>
</body>
</html> </html>

View File

@@ -1,256 +1,269 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Imgfloat Admin</title> <title>Imgfloat Admin</title>
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
crossorigin="anonymous" crossorigin="anonymous"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
/> />
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
</head> </head>
<body class="settings-body"> <body class="settings-body">
<div class="settings-shell"> <div class="settings-shell">
<header class="settings-header"> <header class="settings-header">
<div class="brand"> <div class="brand">
<div class="brand-mark">IF</div> <div class="brand-mark">IF</div>
<div> <div>
<div class="brand-title">Imgfloat</div> <div class="brand-title">Imgfloat</div>
<div class="brand-subtitle">Twitch overlay manager</div> <div class="brand-subtitle">Twitch overlay manager</div>
</div> </div>
</div>
</header>
<main class="settings-main">
<section class="settings-card settings-hero">
<div class="hero-copy">
<p class="eyebrow subtle">System administrator settings</p>
<h1>Application defaults</h1>
<p class="muted">
Configure overlay performance and audio guardrails for every channel using Imgfloat. These
settings are applied globally.
</p>
<div class="badge-row">
<span class="badge soft"><i class="fa-solid fa-gauge-high"></i> Performance tuned</span>
<span class="badge"><i class="fa-solid fa-cloud"></i> Server-wide</span>
<span class="badge subtle"><i class="fa-solid fa-gear"></i> Admin only</span>
</div>
</div>
<div class="stat-grid compact">
<div class="stat">
<p class="stat-label">Canvas FPS</p>
<p class="stat-value" id="stat-canvas-fps">--</p>
<p class="stat-subtitle">Longest side <span id="stat-canvas-size">--</span></p>
</div>
<div class="stat">
<p class="stat-label">Playback speed</p>
<p class="stat-value" id="stat-playback-range">--</p>
<p class="stat-subtitle">Applies to all animations</p>
</div>
<div class="stat">
<p class="stat-label">Audio pitch</p>
<p class="stat-value" id="stat-audio-range">--</p>
<p class="stat-subtitle">Fraction of original clip</p>
</div>
<div class="stat">
<p class="stat-label">Volume limits</p>
<p class="stat-value" id="stat-volume-range">--</p>
<p class="stat-subtitle">Keeps alerts comfortable</p>
</div>
</div>
</section>
<div class="settings-layout">
<section class="settings-card settings-panel">
<div class="section-heading">
<div>
<p class="eyebrow subtle">Overlay defaults</p>
<h2>Performance & audio budget</h2>
<p class="muted tiny">
Tune the canvas and audio guardrails to keep overlays smooth and balanced.
</p>
</div>
</div>
<form novalidate id="settings-form" class="settings-form">
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Canvas</p>
<h3>Rendering budget</h3>
<p class="muted tiny">
Match FPS and max dimensions to your streaming canvas for consistent overlays.
</p>
</div>
<div class="control-grid split-row">
<label for="canvas-fps"
>Canvas FPS
<input
id="canvas-fps"
name="canvas-fps"
class="text-input"
type="text"
inputmode="numeric"
pattern="^[1-9]\d*$"
placeholder="60"
/>
</label>
<label for="canvas-size"
>Canvas max side length (pixels)
<input
id="canvas-size"
name="canvas-size"
class="text-input"
type="text"
inputmode="numeric"
pattern="^[1-9]\d*$"
placeholder="1920"
/>
</label>
</div>
<p class="field-hint">
Use the longest edge of your OBS browser source to prevent stretching.
</p>
</div>
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Playback</p>
<h3>Animation speed limits</h3>
<p class="muted tiny">
Bound default speeds between 0 and 1 so clips run predictably.
</p>
</div>
<div class="control-grid split-row">
<label for="min-playback-speed"
>Min playback speed
<input
id="min-playback-speed"
name="min-playback-speed"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.5"
/>
</label>
<label for="max-playback-speed"
>Max playback speed
<input
id="max-playback-speed"
name="max-playback-speed"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<p class="field-hint">
Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate.
</p>
</div>
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Audio</p>
<h3>Pitch & volume guardrails</h3>
<p class="muted tiny">
Prevent harsh audio by bounding pitch and volume as fractions of the source.
</p>
</div>
<div class="control-grid split-row">
<label for="min-audio-pitch"
>Min audio pitch
<input
id="min-audio-pitch"
name="min-audio-pitch"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.8"
/>
</label>
<label for="max-audio-pitch"
>Max audio pitch
<input
id="max-audio-pitch"
name="max-audio-pitch"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<div class="control-grid split-row">
<label for="min-volume"
>Min volume
<input
id="min-volume"
name="min-volume"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.2"
/>
</label>
<label for="max-volume"
>Max volume
<input
id="max-volume"
name="max-volume"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<p class="field-hint">
Volume and pitch values are percentages of the original clip between 0 and 1.
</p>
</div>
<div class="form-footer">
<p id="settings-status" class="status-chip">No changes yet.</p>
<button id="settings-submit-button" type="submit" class="button" disabled>
Save settings
</button>
</div>
</form>
</section>
<aside class="settings-sidebar">
<section class="settings-card info-card">
<p class="eyebrow subtle">Checklist</p>
<h3>Before you save</h3>
<ul class="hint-list">
<li>Match canvas dimensions to the OBS browser source you embed.</li>
<li>Use 3060 FPS for smoother overlays without overwhelming viewers.</li>
<li>Keep playback and pitch bounds between 0 and 1 to avoid distortion.</li>
<li>Lower the minimum volume if alerts feel too loud on stream.</li>
</ul>
</section>
<section class="settings-card info-card subtle">
<p class="eyebrow subtle">Heads up</p>
<h3>Global impact</h3>
<p class="muted tiny">
Changes here update every channel immediately. Save carefully and confirm with your
team.
</p>
</section>
</aside>
</div>
</main>
</div> </div>
</header> <script th:inline="javascript">
const serverRenderedSettings = /*[[${settingsJson}]]*/;
<main class="settings-main"> </script>
<section class="settings-card settings-hero"> <script src="/js/cookie-consent.js"></script>
<div class="hero-copy"> <script src="/js/settings.js"></script>
<p class="eyebrow subtle">System administrator settings</p> <script src="/js/toast.js"></script>
<h1>Application defaults</h1> </body>
<p class="muted">
Configure overlay performance and audio guardrails for every channel using Imgfloat. These settings are
applied globally.
</p>
<div class="badge-row">
<span class="badge soft"><i class="fa-solid fa-gauge-high"></i> Performance tuned</span>
<span class="badge"><i class="fa-solid fa-cloud"></i> Server-wide</span>
<span class="badge subtle"><i class="fa-solid fa-gear"></i> Admin only</span>
</div>
</div>
<div class="stat-grid compact">
<div class="stat">
<p class="stat-label">Canvas FPS</p>
<p class="stat-value" id="stat-canvas-fps">--</p>
<p class="stat-subtitle">Longest side <span id="stat-canvas-size">--</span></p>
</div>
<div class="stat">
<p class="stat-label">Playback speed</p>
<p class="stat-value" id="stat-playback-range">--</p>
<p class="stat-subtitle">Applies to all animations</p>
</div>
<div class="stat">
<p class="stat-label">Audio pitch</p>
<p class="stat-value" id="stat-audio-range">--</p>
<p class="stat-subtitle">Fraction of original clip</p>
</div>
<div class="stat">
<p class="stat-label">Volume limits</p>
<p class="stat-value" id="stat-volume-range">--</p>
<p class="stat-subtitle">Keeps alerts comfortable</p>
</div>
</div>
</section>
<div class="settings-layout">
<section class="settings-card settings-panel">
<div class="section-heading">
<div>
<p class="eyebrow subtle">Overlay defaults</p>
<h2>Performance & audio budget</h2>
<p class="muted tiny">Tune the canvas and audio guardrails to keep overlays smooth and balanced.</p>
</div>
</div>
<form novalidate id="settings-form" class="settings-form">
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Canvas</p>
<h3>Rendering budget</h3>
<p class="muted tiny">
Match FPS and max dimensions to your streaming canvas for consistent overlays.
</p>
</div>
<div class="control-grid split-row">
<label for="canvas-fps"
>Canvas FPS
<input
id="canvas-fps"
name="canvas-fps"
class="text-input"
type="text"
inputmode="numeric"
pattern="^[1-9]\d*$"
placeholder="60"
/>
</label>
<label for="canvas-size"
>Canvas max side length (pixels)
<input
id="canvas-size"
name="canvas-size"
class="text-input"
type="text"
inputmode="numeric"
pattern="^[1-9]\d*$"
placeholder="1920"
/>
</label>
</div>
<p class="field-hint">Use the longest edge of your OBS browser source to prevent stretching.</p>
</div>
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Playback</p>
<h3>Animation speed limits</h3>
<p class="muted tiny">Bound default speeds between 0 and 1 so clips run predictably.</p>
</div>
<div class="control-grid split-row">
<label for="min-playback-speed"
>Min playback speed
<input
id="min-playback-speed"
name="min-playback-speed"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.5"
/>
</label>
<label for="max-playback-speed"
>Max playback speed
<input
id="max-playback-speed"
name="max-playback-speed"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<p class="field-hint">
Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate.
</p>
</div>
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Audio</p>
<h3>Pitch & volume guardrails</h3>
<p class="muted tiny">Prevent harsh audio by bounding pitch and volume as fractions of the source.</p>
</div>
<div class="control-grid split-row">
<label for="min-audio-pitch"
>Min audio pitch
<input
id="min-audio-pitch"
name="min-audio-pitch"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.8"
/>
</label>
<label for="max-audio-pitch"
>Max audio pitch
<input
id="max-audio-pitch"
name="max-audio-pitch"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<div class="control-grid split-row">
<label for="min-volume"
>Min volume
<input
id="min-volume"
name="min-volume"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.2"
/>
</label>
<label for="max-volume"
>Max volume
<input
id="max-volume"
name="max-volume"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<p class="field-hint">Volume and pitch values are percentages of the original clip between 0 and 1.</p>
</div>
<div class="form-footer">
<p id="settings-status" class="status-chip">No changes yet.</p>
<button id="settings-submit-button" type="submit" class="button" disabled>Save settings</button>
</div>
</form>
</section>
<aside class="settings-sidebar">
<section class="settings-card info-card">
<p class="eyebrow subtle">Checklist</p>
<h3>Before you save</h3>
<ul class="hint-list">
<li>Match canvas dimensions to the OBS browser source you embed.</li>
<li>Use 3060 FPS for smoother overlays without overwhelming viewers.</li>
<li>Keep playback and pitch bounds between 0 and 1 to avoid distortion.</li>
<li>Lower the minimum volume if alerts feel too loud on stream.</li>
</ul>
</section>
<section class="settings-card info-card subtle">
<p class="eyebrow subtle">Heads up</p>
<h3>Global impact</h3>
<p class="muted tiny">
Changes here update every channel immediately. Save carefully and confirm with your team.
</p>
</section>
</aside>
</div>
</main>
</div>
<script th:inline="javascript">
const serverRenderedSettings = /*[[${settingsJson}]]*/;
</script>
<script src="/js/cookie-consent.js"></script>
<script src="/js/settings.js"></script>
<script src="/js/toast.js"></script>
</body>
</html> </html>

View File

@@ -1,35 +1,35 @@
package dev.kruhlmann.imgfloat; package dev.kruhlmann.imgfloat;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.mock.web.MockMultipartFile;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import javax.imageio.ImageIO;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
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;
@SpringBootTest(properties = { import com.fasterxml.jackson.databind.ObjectMapper;
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(
properties = {
"spring.security.oauth2.client.registration.twitch.client-id=test-client-id", "spring.security.oauth2.client.registration.twitch.client-id=test-client-id",
"spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret" "spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret",
}) }
)
@AutoConfigureMockMvc @AutoConfigureMockMvc
class ChannelApiIntegrationTest { class ChannelApiIntegrationTest {
@@ -42,57 +42,92 @@ class ChannelApiIntegrationTest {
@Test @Test
void broadcasterManagesAdminsAndAssets() throws Exception { void broadcasterManagesAdminsAndAssets() throws Exception {
String broadcaster = "caster"; String broadcaster = "caster";
mockMvc.perform(post("/api/channels/{broadcaster}/admins", broadcaster) mockMvc
.contentType(MediaType.APPLICATION_JSON) .perform(
.content("{\"username\":\"helper\"}") post("/api/channels/{broadcaster}/admins", broadcaster)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .contentType(MediaType.APPLICATION_JSON)
.andExpect(status().isOk()); .content("{\"username\":\"helper\"}")
.with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)))
)
.andExpect(status().isOk());
mockMvc.perform(get("/api/channels/{broadcaster}/admins", broadcaster) mockMvc
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .perform(
.andExpect(status().isOk()) get("/api/channels/{broadcaster}/admins", broadcaster).with(
.andExpect(jsonPath("$[0].login").value("helper")) oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
.andExpect(jsonPath("$[0].displayName").value("helper")); )
)
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].login").value("helper"))
.andExpect(jsonPath("$[0].displayName").value("helper"));
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng()); MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
String assetId = objectMapper.readTree(mockMvc.perform(multipart("/api/channels/{broadcaster}/assets", broadcaster) String assetId = objectMapper
.file(file) .readTree(
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) mockMvc
.andExpect(status().isOk()) .perform(
.andReturn().getResponse().getContentAsString()).get("id").asText(); multipart("/api/channels/{broadcaster}/assets", broadcaster)
.file(file)
.with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)))
)
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString()
)
.get("id")
.asText();
mockMvc.perform(get("/api/channels/{broadcaster}/assets", broadcaster) mockMvc
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .perform(
.andExpect(status().isOk()) get("/api/channels/{broadcaster}/assets", broadcaster).with(
.andExpect(jsonPath("$", hasSize(1))); oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)));
VisibilityRequest visibilityRequest = new VisibilityRequest(); VisibilityRequest visibilityRequest = new VisibilityRequest();
visibilityRequest.setHidden(false); visibilityRequest.setHidden(false);
mockMvc.perform(put("/api/channels/{broadcaster}/assets/{id}/visibility", broadcaster, assetId) mockMvc
.contentType(MediaType.APPLICATION_JSON) .perform(
.content(objectMapper.writeValueAsString(visibilityRequest)) put("/api/channels/{broadcaster}/assets/{id}/visibility", broadcaster, assetId)
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .contentType(MediaType.APPLICATION_JSON)
.andExpect(status().isOk()) .content(objectMapper.writeValueAsString(visibilityRequest))
.andExpect(jsonPath("$.hidden").value(false)); .with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)))
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.hidden").value(false));
mockMvc.perform(get("/api/channels/{broadcaster}/assets/visible", broadcaster) mockMvc
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .perform(
.andExpect(status().isOk()) get("/api/channels/{broadcaster}/assets/visible", broadcaster).with(
.andExpect(jsonPath("$", hasSize(1))); oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)));
mockMvc.perform(delete("/api/channels/{broadcaster}/assets/{id}", broadcaster, assetId) mockMvc
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .perform(
.andExpect(status().isOk()); delete("/api/channels/{broadcaster}/assets/{id}", broadcaster, assetId).with(
oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
)
)
.andExpect(status().isOk());
} }
@Test @Test
void rejectsAdminChangesFromNonBroadcaster() throws Exception { void rejectsAdminChangesFromNonBroadcaster() throws Exception {
mockMvc.perform(post("/api/channels/{broadcaster}/admins", "caster") mockMvc
.contentType(MediaType.APPLICATION_JSON) .perform(
.content("{\"username\":\"helper\"}") post("/api/channels/{broadcaster}/admins", "caster")
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", "intruder")))) .contentType(MediaType.APPLICATION_JSON)
.andExpect(status().isForbidden()); .content("{\"username\":\"helper\"}")
.with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", "intruder")))
)
.andExpect(status().isForbidden());
} }
private byte[] samplePng() throws IOException { private byte[] samplePng() throws IOException {

View File

@@ -1,5 +1,10 @@
package dev.kruhlmann.imgfloat; package dev.kruhlmann.imgfloat;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.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;
@@ -9,15 +14,12 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.hasSize; @SpringBootTest(
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; properties = {
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(properties = {
"spring.security.oauth2.client.registration.twitch.client-id=test-client-id", "spring.security.oauth2.client.registration.twitch.client-id=test-client-id",
"spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret" "spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret",
}) }
)
@AutoConfigureMockMvc @AutoConfigureMockMvc
class ChannelDirectoryApiIntegrationTest { class ChannelDirectoryApiIntegrationTest {
@@ -38,10 +40,11 @@ class ChannelDirectoryApiIntegrationTest {
channelRepository.save(new Channel("alpha")); channelRepository.save(new Channel("alpha"));
channelRepository.save(new Channel("ALPINE")); channelRepository.save(new Channel("ALPINE"));
mockMvc.perform(get("/api/channels").param("q", "Al")) mockMvc
.andExpect(status().isOk()) .perform(get("/api/channels").param("q", "Al"))
.andExpect(jsonPath("$", hasSize(2))) .andExpect(status().isOk())
.andExpect(jsonPath("$[0]").value("alpha")) .andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[1]").value("alpine")); .andExpect(jsonPath("$[0]").value("alpha"))
.andExpect(jsonPath("$[1]").value("alpine"));
} }
} }

View File

@@ -1,27 +1,28 @@
package dev.kruhlmann.imgfloat; package dev.kruhlmann.imgfloat;
import dev.kruhlmann.imgfloat.model.TransformRequest; import static org.assertj.core.api.Assertions.assertThat;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.Asset;
import dev.kruhlmann.imgfloat.model.AssetView; import dev.kruhlmann.imgfloat.model.AssetView;
import dev.kruhlmann.imgfloat.model.Channel; import dev.kruhlmann.imgfloat.model.Channel;
import dev.kruhlmann.imgfloat.model.Settings;
import dev.kruhlmann.imgfloat.model.TransformRequest;
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
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.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.service.AssetStorageService; import dev.kruhlmann.imgfloat.service.AssetStorageService;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.service.SettingsService;
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
import dev.kruhlmann.imgfloat.service.media.MediaPreviewService; import dev.kruhlmann.imgfloat.service.media.MediaPreviewService;
import dev.kruhlmann.imgfloat.service.SettingsService;
import dev.kruhlmann.imgfloat.model.Settings;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
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;
@@ -33,19 +34,17 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO; import javax.imageio.ImageIO;
import org.junit.jupiter.api.BeforeEach;
import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.mockito.ArgumentCaptor;
import static org.mockito.ArgumentMatchers.any; import org.springframework.messaging.simp.SimpMessagingTemplate;
import static org.mockito.ArgumentMatchers.anyString; import org.springframework.mock.web.MockMultipartFile;
import static org.mockito.Mockito.doAnswer; import org.springframework.test.util.ReflectionTestUtils;
import static org.mockito.Mockito.mock; import org.springframework.web.server.ResponseStatusException;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
class ChannelDirectoryServiceTest { class ChannelDirectoryServiceTest {
private ChannelDirectoryService service; private ChannelDirectoryService service;
private SimpMessagingTemplate messagingTemplate; private SimpMessagingTemplate messagingTemplate;
private ChannelRepository channelRepository; private ChannelRepository channelRepository;
@@ -66,8 +65,15 @@ class ChannelDirectoryServiceTest {
MediaPreviewService mediaPreviewService = new MediaPreviewService(); MediaPreviewService mediaPreviewService = new MediaPreviewService();
MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService); MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService);
MediaDetectionService mediaDetectionService = new MediaDetectionService(); MediaDetectionService mediaDetectionService = new MediaDetectionService();
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate, service = new ChannelDirectoryService(
assetStorageService, mediaDetectionService, mediaOptimizationService, settingsService); channelRepository,
assetRepository,
messagingTemplate,
assetStorageService,
mediaDetectionService,
mediaOptimizationService,
settingsService
);
ReflectionTestUtils.setField(service, "uploadLimitBytes", 5_000_000L); ReflectionTestUtils.setField(service, "uploadLimitBytes", 5_000_000L);
} }
@@ -78,7 +84,10 @@ class ChannelDirectoryServiceTest {
Optional<AssetView> created = service.createAsset("caster", file); Optional<AssetView> created = service.createAsset("caster", file);
assertThat(created).isPresent(); assertThat(created).isPresent();
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class); ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture()); verify(messagingTemplate).convertAndSend(
org.mockito.ArgumentMatchers.contains("/topic/channel/caster"),
captor.capture()
);
} }
@Test @Test
@@ -105,8 +114,8 @@ class ChannelDirectoryServiceTest {
transform.setWidth(0); transform.setWidth(0);
assertThatThrownBy(() -> service.updateTransform(channel, id, transform)) assertThatThrownBy(() -> service.updateTransform(channel, id, transform))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Canvas width out of range"); .hasMessageContaining("Canvas width out of range");
} }
@Test @Test
@@ -118,15 +127,15 @@ class ChannelDirectoryServiceTest {
speedTransform.setSpeed(5.0); speedTransform.setSpeed(5.0);
assertThatThrownBy(() -> service.updateTransform(channel, id, speedTransform)) assertThatThrownBy(() -> service.updateTransform(channel, id, speedTransform))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Speed out of range"); .hasMessageContaining("Speed out of range");
TransformRequest volumeTransform = validTransform(); TransformRequest volumeTransform = validTransform();
volumeTransform.setAudioVolume(6.5); volumeTransform.setAudioVolume(6.5);
assertThatThrownBy(() -> service.updateTransform(channel, id, volumeTransform)) assertThatThrownBy(() -> service.updateTransform(channel, id, volumeTransform))
.isInstanceOf(ResponseStatusException.class) .isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("Audio volume out of range"); .hasMessageContaining("Audio volume out of range");
} }
@Test @Test
@@ -178,44 +187,56 @@ class ChannelDirectoryServiceTest {
Map<String, Channel> channels = new ConcurrentHashMap<>(); Map<String, Channel> channels = new ConcurrentHashMap<>();
Map<String, Asset> assets = new ConcurrentHashMap<>(); Map<String, Asset> assets = new ConcurrentHashMap<>();
when(channelRepository.findById(anyString())) when(channelRepository.findById(anyString())).thenAnswer((invocation) ->
.thenAnswer(invocation -> Optional.ofNullable(channels.get(invocation.getArgument(0)))); Optional.ofNullable(channels.get(invocation.getArgument(0)))
when(channelRepository.save(any(Channel.class))) );
.thenAnswer(invocation -> { when(channelRepository.save(any(Channel.class))).thenAnswer((invocation) -> {
Channel channel = invocation.getArgument(0); Channel channel = invocation.getArgument(0);
channels.put(channel.getBroadcaster(), channel); channels.put(channel.getBroadcaster(), channel);
return channel; return channel;
}); });
when(channelRepository.findAll()) when(channelRepository.findAll()).thenAnswer((invocation) -> List.copyOf(channels.values()));
.thenAnswer(invocation -> List.copyOf(channels.values())); when(channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(anyString())).thenAnswer(
when(channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(anyString())) (invocation) ->
.thenAnswer(invocation -> channels.values().stream() channels
.filter(channel -> Optional.ofNullable(channel.getBroadcaster()).orElse("") .values()
.contains(Optional.ofNullable(invocation.getArgument(0, String.class)).orElse("").toLowerCase())) .stream()
.sorted(Comparator.comparing(Channel::getBroadcaster)) .filter((channel) ->
.limit(50) Optional.ofNullable(channel.getBroadcaster())
.toList()); .orElse("")
.contains(
Optional.ofNullable(invocation.getArgument(0, String.class)).orElse("").toLowerCase()
)
)
.sorted(Comparator.comparing(Channel::getBroadcaster))
.limit(50)
.toList()
);
when(assetRepository.save(any(Asset.class))) when(assetRepository.save(any(Asset.class))).thenAnswer((invocation) -> {
.thenAnswer(invocation -> { Asset asset = invocation.getArgument(0);
Asset asset = invocation.getArgument(0); assets.put(asset.getId(), asset);
assets.put(asset.getId(), asset); return asset;
return asset; });
}); when(assetRepository.findById(anyString())).thenAnswer((invocation) ->
when(assetRepository.findById(anyString())) Optional.ofNullable(assets.get(invocation.getArgument(0)))
.thenAnswer(invocation -> Optional.ofNullable(assets.get(invocation.getArgument(0)))); );
when(assetRepository.findByBroadcaster(anyString())) when(assetRepository.findByBroadcaster(anyString())).thenAnswer((invocation) ->
.thenAnswer(invocation -> filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), false)); filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), false)
when(assetRepository.findByBroadcasterAndHiddenFalse(anyString())) );
.thenAnswer(invocation -> filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), true)); when(assetRepository.findByBroadcasterAndHiddenFalse(anyString())).thenAnswer((invocation) ->
doAnswer(invocation -> assets.remove(invocation.getArgument(0, Asset.class).getId())) filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), true)
.when(assetRepository).delete(any(Asset.class)); );
doAnswer((invocation) -> assets.remove(invocation.getArgument(0, Asset.class).getId()))
.when(assetRepository)
.delete(any(Asset.class));
} }
private List<Asset> filterAssetsByBroadcaster(Collection<Asset> assets, String broadcaster, boolean onlyVisible) { private List<Asset> filterAssetsByBroadcaster(Collection<Asset> assets, String broadcaster, boolean onlyVisible) {
return assets.stream() return assets
.filter(asset -> asset.getBroadcaster().equalsIgnoreCase(broadcaster)) .stream()
.filter(asset -> !onlyVisible || !asset.isHidden()) .filter((asset) -> asset.getBroadcaster().equalsIgnoreCase(broadcaster))
.toList(); .filter((asset) -> !onlyVisible || !asset.isHidden())
.toList();
} }
} }

View File

@@ -1,5 +1,7 @@
package dev.kruhlmann.imgfloat.config; package dev.kruhlmann.imgfloat.config;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.RequestEntity; import org.springframework.http.RequestEntity;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
@@ -12,39 +14,43 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResp
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import static org.assertj.core.api.Assertions.assertThat;
class TwitchAuthorizationCodeGrantRequestEntityConverterTest { class TwitchAuthorizationCodeGrantRequestEntityConverterTest {
@Test @Test
void addsClientIdAndSecretToTokenRequestBody() { void addsClientIdAndSecretToTokenRequestBody() {
ClientRegistration registration = ClientRegistration.withRegistrationId("twitch") ClientRegistration registration = ClientRegistration.withRegistrationId("twitch")
.clientId("twitch-id") .clientId("twitch-id")
.clientSecret("twitch-secret") .clientSecret("twitch-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("https://example.com/redirect") .redirectUri("https://example.com/redirect")
.scope("user:read:email") .scope("user:read:email")
.authorizationUri("https://id.twitch.tv/oauth2/authorize") .authorizationUri("https://id.twitch.tv/oauth2/authorize")
.tokenUri("https://id.twitch.tv/oauth2/token") .tokenUri("https://id.twitch.tv/oauth2/token")
.userInfoUri("https://api.twitch.tv/helix/users") .userInfoUri("https://api.twitch.tv/helix/users")
.userNameAttributeName("preferred_username") .userNameAttributeName("preferred_username")
.build(); .build();
OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode() OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
.authorizationUri(registration.getProviderDetails().getAuthorizationUri()) .authorizationUri(registration.getProviderDetails().getAuthorizationUri())
.clientId(registration.getClientId()) .clientId(registration.getClientId())
.redirectUri(registration.getRedirectUri()) .redirectUri(registration.getRedirectUri())
.state("state") .state("state")
.build(); .build();
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code") OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code")
.redirectUri(registration.getRedirectUri()) .redirectUri(registration.getRedirectUri())
.state("state") .state("state")
.build(); .build();
OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse); OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(registration, exchange); authorizationRequest,
authorizationResponse
);
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(
registration,
exchange
);
var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter(); var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter();
RequestEntity<?> requestEntity = converter.convert(grantRequest); RequestEntity<?> requestEntity = converter.convert(grantRequest);

View File

@@ -1,7 +1,11 @@
package dev.kruhlmann.imgfloat.config; package dev.kruhlmann.imgfloat.config;
import java.net.URI; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import java.net.URI;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -13,11 +17,6 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenRespon
import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class TwitchOAuth2ErrorResponseErrorHandlerTest { class TwitchOAuth2ErrorResponseErrorHandlerTest {
private final TwitchOAuth2ErrorResponseErrorHandler handler = new TwitchOAuth2ErrorResponseErrorHandler(); private final TwitchOAuth2ErrorResponseErrorHandler handler = new TwitchOAuth2ErrorResponseErrorHandler();
@@ -27,12 +26,12 @@ class TwitchOAuth2ErrorResponseErrorHandlerTest {
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);
OAuth2AuthorizationException exception = assertThrows(OAuth2AuthorizationException.class, OAuth2AuthorizationException exception = assertThrows(OAuth2AuthorizationException.class, () ->
() -> handler.handleError(response)); handler.handleError(response)
);
assertThat(exception.getError().getErrorCode()).isEqualTo("invalid_token_response"); assertThat(exception.getError().getErrorCode()).isEqualTo("invalid_token_response");
assertThat(exception.getError().getDescription()) assertThat(exception.getError().getDescription()).contains("Failed to parse Twitch OAuth error response");
.contains("Failed to parse Twitch OAuth error response");
} }
@Test @Test
@@ -41,13 +40,20 @@ class TwitchOAuth2ErrorResponseErrorHandlerTest {
restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler()); restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler());
MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
server.expect(requestTo("https://id.twitch.tv/oauth2/token")) server
.andRespond(withSuccess( .expect(requestTo("https://id.twitch.tv/oauth2/token"))
"{\"access_token\":\"abc\",\"token_type\":\"bearer\",\"expires_in\":3600,\"scope\":[]}", .andRespond(
MediaType.APPLICATION_JSON)); withSuccess(
"{\"access_token\":\"abc\",\"token_type\":\"bearer\",\"expires_in\":3600,\"scope\":[]}",
MediaType.APPLICATION_JSON
)
);
RequestEntity<Void> request = RequestEntity.post(URI.create("https://id.twitch.tv/oauth2/token")).build(); RequestEntity<Void> request = RequestEntity.post(URI.create("https://id.twitch.tv/oauth2/token")).build();
ResponseEntity<OAuth2AccessTokenResponse> response = restTemplate.exchange(request, OAuth2AccessTokenResponse.class); ResponseEntity<OAuth2AccessTokenResponse> response = restTemplate.exchange(
request,
OAuth2AccessTokenResponse.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull(); assertThat(response.getBody()).isNotNull();

View File

@@ -8,7 +8,6 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat
import java.time.Instant; import java.time.Instant;
import java.util.Set; import java.util.Set;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -26,51 +25,54 @@ class TwitchOAuth2UserServiceTest {
@Test @Test
void unwrapsTwitchUserAndAddsClientIdHeaderToUserInfoRequest() { void unwrapsTwitchUserAndAddsClientIdHeaderToUserInfoRequest() {
ClientRegistration registration = twitchRegistrationBuilder() ClientRegistration registration = twitchRegistrationBuilder()
.clientId("client-123") .clientId("client-123")
.clientSecret("secret") .clientSecret("secret")
.build(); .build();
OAuth2UserRequest userRequest = userRequest(registration); OAuth2UserRequest userRequest = userRequest(registration);
RestTemplate restTemplate = TwitchOAuth2UserService.createRestTemplate(userRequest); RestTemplate restTemplate = TwitchOAuth2UserService.createRestTemplate(userRequest);
MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
TwitchOAuth2UserService service = new TwitchOAuth2UserService(ignored -> restTemplate); TwitchOAuth2UserService service = new TwitchOAuth2UserService((ignored) -> restTemplate);
server.expect(requestTo("https://api.twitch.tv/helix/users")) server
.andExpect(method(HttpMethod.GET)) .expect(requestTo("https://api.twitch.tv/helix/users"))
.andExpect(header("Client-ID", "client-123")) .andExpect(method(HttpMethod.GET))
.andRespond(withSuccess( .andExpect(header("Client-ID", "client-123"))
"{\"data\":[{\"id\":\"42\",\"login\":\"demo\",\"display_name\":\"Demo\"}]}", .andRespond(
MediaType.APPLICATION_JSON)); withSuccess(
"{\"data\":[{\"id\":\"42\",\"login\":\"demo\",\"display_name\":\"Demo\"}]}",
MediaType.APPLICATION_JSON
)
);
OAuth2User user = service.loadUser(userRequest); OAuth2User user = service.loadUser(userRequest);
assertThat(user.getName()).isEqualTo("demo"); assertThat(user.getName()).isEqualTo("demo");
assertThat(user.getAttributes()) assertThat(user.getAttributes()).containsEntry("id", "42").containsEntry("display_name", "Demo");
.containsEntry("id", "42")
.containsEntry("display_name", "Demo");
server.verify(); server.verify();
} }
private OAuth2UserRequest userRequest(ClientRegistration registration) { private OAuth2UserRequest userRequest(ClientRegistration registration) {
OAuth2AccessToken accessToken = new OAuth2AccessToken( OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER, OAuth2AccessToken.TokenType.BEARER,
"token", "token",
Instant.now(), Instant.now(),
Instant.now().plusSeconds(60), Instant.now().plusSeconds(60),
Set.of("user:read:email")); Set.of("user:read:email")
);
return new OAuth2UserRequest(registration, accessToken); return new OAuth2UserRequest(registration, accessToken);
} }
private ClientRegistration.Builder twitchRegistrationBuilder() { private ClientRegistration.Builder twitchRegistrationBuilder() {
return ClientRegistration.withRegistrationId("twitch") return ClientRegistration.withRegistrationId("twitch")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.clientName("Twitch") .clientName("Twitch")
.redirectUri("https://example.com/login/oauth2/code/twitch") .redirectUri("https://example.com/login/oauth2/code/twitch")
.authorizationUri("https://id.twitch.tv/oauth2/authorize") .authorizationUri("https://id.twitch.tv/oauth2/authorize")
.tokenUri("https://id.twitch.tv/oauth2/token") .tokenUri("https://id.twitch.tv/oauth2/token")
.userInfoUri("https://api.twitch.tv/helix/users") .userInfoUri("https://api.twitch.tv/helix/users")
.userNameAttributeName("login"); .userNameAttributeName("login");
} }
} }

View File

@@ -1,18 +1,18 @@
package dev.kruhlmann.imgfloat.service; package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.service.media.AssetContent;
import dev.kruhlmann.imgfloat.model.Asset;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.kruhlmann.imgfloat.model.Asset;
import dev.kruhlmann.imgfloat.service.media.AssetContent;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class AssetStorageServiceTest { class AssetStorageServiceTest {
private AssetStorageService service; private AssetStorageService service;
private Path assets; private Path assets;
private Path previews; private Path previews;
@@ -27,13 +27,13 @@ class AssetStorageServiceTest {
@Test @Test
void refusesToStoreEmptyAsset() { void refusesToStoreEmptyAsset() {
assertThatThrownBy(() -> service.storeAsset("caster", "id", new byte[0], "image/png")) assertThatThrownBy(() -> service.storeAsset("caster", "id", new byte[0], "image/png"))
.isInstanceOf(IOException.class) .isInstanceOf(IOException.class)
.hasMessageContaining("empty"); .hasMessageContaining("empty");
} }
@Test @Test
void storesAndLoadsAssets() throws IOException { void storesAndLoadsAssets() throws IOException {
byte[] bytes = new byte[]{1, 2, 3}; byte[] bytes = new byte[] { 1, 2, 3 };
Asset asset = new Asset("caster", "asset", "http://example.com", 10, 10); Asset asset = new Asset("caster", "asset", "http://example.com", 10, 10);
asset.setMediaType("image/png"); asset.setMediaType("image/png");
@@ -53,7 +53,7 @@ class AssetStorageServiceTest {
@Test @Test
void storesAndLoadsPreviews() throws IOException { void storesAndLoadsPreviews() throws IOException {
byte[] preview = new byte[]{9, 8, 7}; byte[] preview = new byte[] { 9, 8, 7 };
Asset asset = new Asset("caster", "asset", "http://example.com", 10, 10); Asset asset = new Asset("caster", "asset", "http://example.com", 10, 10);
asset.setMediaType("image/png"); asset.setMediaType("image/png");

View File

@@ -1,18 +1,18 @@
package dev.kruhlmann.imgfloat.service.media; package dev.kruhlmann.imgfloat.service.media;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
class MediaDetectionServiceTest { class MediaDetectionServiceTest {
private final MediaDetectionService service = new MediaDetectionService(); private final MediaDetectionService service = new MediaDetectionService();
@Test @Test
void acceptsMagicBytesOverDeclaredType() throws IOException { void acceptsMagicBytesOverDeclaredType() throws IOException {
byte[] png = new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47}; byte[] png = new byte[] { (byte) 0x89, 0x50, 0x4E, 0x47 };
MockMultipartFile file = new MockMultipartFile("file", "image.png", "text/plain", png); MockMultipartFile file = new MockMultipartFile("file", "image.png", "text/plain", png);
assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png"); assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png");
@@ -20,14 +20,14 @@ class MediaDetectionServiceTest {
@Test @Test
void fallsBackToFilenameAllowlist() throws IOException { void fallsBackToFilenameAllowlist() throws IOException {
MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, new byte[]{1, 2, 3}); MockMultipartFile file = new MockMultipartFile("file", "picture.png", null, new byte[] { 1, 2, 3 });
assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png"); assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png");
} }
@Test @Test
void rejectsUnknownTypes() throws IOException { void rejectsUnknownTypes() throws IOException {
MockMultipartFile file = new MockMultipartFile("file", "unknown.bin", null, new byte[]{1, 2, 3}); MockMultipartFile file = new MockMultipartFile("file", "unknown.bin", null, new byte[] { 1, 2, 3 });
assertThat(service.detectAllowedMediaType(file, file.getBytes())).isEmpty(); assertThat(service.detectAllowedMediaType(file, file.getBytes())).isEmpty();
} }

View File

@@ -1,16 +1,16 @@
package dev.kruhlmann.imgfloat.service.media; package dev.kruhlmann.imgfloat.service.media;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class MediaOptimizationServiceTest { class MediaOptimizationServiceTest {
private MediaOptimizationService service; private MediaOptimizationService service;
@BeforeEach @BeforeEach
@@ -38,7 +38,7 @@ class MediaOptimizationServiceTest {
@Test @Test
void returnsNullForUnsupportedBytes() throws IOException { void returnsNullForUnsupportedBytes() throws IOException {
OptimizedAsset optimized = service.optimizeAsset(new byte[]{1, 2, 3}, "application/octet-stream"); OptimizedAsset optimized = service.optimizeAsset(new byte[] { 1, 2, 3 }, "application/octet-stream");
assertThat(optimized).isNull(); assertThat(optimized).isNull();
} }