mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Unify formatting
This commit is contained in:
@@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
|
||||
@EnableAsync
|
||||
@SpringBootApplication
|
||||
public class ImgfloatApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ImgfloatApplication.class, args);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepo
|
||||
public class OAuth2AuthorizedClientPersistenceConfig {
|
||||
|
||||
@Bean
|
||||
OAuth2AuthorizedClientService oauth2AuthorizedClientService(JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository) {
|
||||
OAuth2AuthorizedClientService oauth2AuthorizedClientService(
|
||||
JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository
|
||||
) {
|
||||
return new SQLiteOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.http.converter.FormHttpMessageConverter;
|
||||
@@ -10,14 +9,12 @@ import org.springframework.web.client.RestTemplate;
|
||||
|
||||
final class OAuth2RestTemplateFactory {
|
||||
|
||||
private OAuth2RestTemplateFactory() {
|
||||
}
|
||||
private OAuth2RestTemplateFactory() {}
|
||||
|
||||
static RestTemplate create() {
|
||||
RestTemplate restTemplate = new RestTemplate(Arrays.asList(
|
||||
new FormHttpMessageConverter(),
|
||||
new OAuth2AccessTokenResponseHttpMessageConverter()
|
||||
));
|
||||
RestTemplate restTemplate = new RestTemplate(
|
||||
Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter())
|
||||
);
|
||||
ClientHttpRequestFactory requestFactory = restTemplate.getRequestFactory();
|
||||
if (requestFactory instanceof SimpleClientHttpRequestFactory simple) {
|
||||
simple.setConnectTimeout(30_000);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.OAuthFlow;
|
||||
import io.swagger.v3.oas.models.security.OAuthFlows;
|
||||
@@ -20,19 +20,24 @@ public class OpenApiConfig {
|
||||
return new OpenAPI()
|
||||
.components(new Components().addSecuritySchemes(TWITCH_OAUTH_SCHEME, twitchOAuthScheme()))
|
||||
.addSecurityItem(new SecurityRequirement().addList(TWITCH_OAUTH_SCHEME))
|
||||
.info(new Info()
|
||||
.info(
|
||||
new Info()
|
||||
.title("Imgfloat API")
|
||||
.description("OpenAPI documentation for Imgfloat admin and broadcaster APIs.")
|
||||
.version("v1"));
|
||||
.version("v1")
|
||||
);
|
||||
}
|
||||
|
||||
private SecurityScheme twitchOAuthScheme() {
|
||||
return new SecurityScheme()
|
||||
.name(TWITCH_OAUTH_SCHEME)
|
||||
.type(SecurityScheme.Type.OAUTH2)
|
||||
.flows(new OAuthFlows()
|
||||
.authorizationCode(new OAuthFlow()
|
||||
.flows(
|
||||
new OAuthFlows().authorizationCode(
|
||||
new OAuthFlow()
|
||||
.authorizationUrl("https://id.twitch.tv/oauth2/authorize")
|
||||
.tokenUrl("https://id.twitch.tv/oauth2/token")));
|
||||
.tokenUrl("https://id.twitch.tv/oauth2/token")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
@@ -18,6 +19,7 @@ import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
|
||||
public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClientService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SQLiteOAuth2AuthorizedClientService.class);
|
||||
private static final String TABLE_NAME = "oauth2_authorized_client";
|
||||
|
||||
@@ -25,8 +27,10 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||
private final RowMapper<OAuth2AuthorizedClient> rowMapper;
|
||||
|
||||
public SQLiteOAuth2AuthorizedClientService(JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository) {
|
||||
public SQLiteOAuth2AuthorizedClientService(
|
||||
JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository
|
||||
) {
|
||||
this.jdbcOperations = jdbcOperations;
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
this.rowMapper = (rs, rowNum) -> {
|
||||
@@ -48,25 +52,27 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
Object refreshValue = rs.getObject("refresh_token_value");
|
||||
OAuth2RefreshToken refreshToken = refreshValue == null
|
||||
? null
|
||||
: new OAuth2RefreshToken(
|
||||
refreshValue.toString(),
|
||||
toInstant(rs.getObject("refresh_token_issued_at"))
|
||||
);
|
||||
: new OAuth2RefreshToken(refreshValue.toString(), toInstant(rs.getObject("refresh_token_issued_at")));
|
||||
|
||||
return new OAuth2AuthorizedClient(registration, principalName, accessToken, refreshToken);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, String principalName) {
|
||||
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(
|
||||
String clientRegistrationId,
|
||||
String principalName
|
||||
) {
|
||||
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 " +
|
||||
"FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?",
|
||||
ps -> {
|
||||
"FROM " +
|
||||
TABLE_NAME +
|
||||
" WHERE client_registration_id = ? AND principal_name = ?",
|
||||
(ps) -> {
|
||||
ps.setString(1, clientRegistrationId);
|
||||
ps.setString(2, principalName);
|
||||
},
|
||||
rs -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null
|
||||
(rs) -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,12 +92,15 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
access_token_scopes=excluded.access_token_scopes,
|
||||
refresh_token_value=excluded.refresh_token_value,
|
||||
refresh_token_issued_at=excluded.refresh_token_issued_at
|
||||
""",
|
||||
preparedStatement -> {
|
||||
""", (preparedStatement) -> {
|
||||
preparedStatement.setString(1, authorizedClient.getClientRegistration().getRegistrationId());
|
||||
preparedStatement.setString(2, principal.getName());
|
||||
setToken(preparedStatement, 3, authorizedClient.getAccessToken());
|
||||
preparedStatement.setObject(5, toEpochMillis(authorizedClient.getAccessToken().getExpiresAt()), java.sql.Types.BIGINT);
|
||||
preparedStatement.setObject(
|
||||
5,
|
||||
toEpochMillis(authorizedClient.getAccessToken().getExpiresAt()),
|
||||
java.sql.Types.BIGINT
|
||||
);
|
||||
preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes()));
|
||||
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
|
||||
if (refreshToken != null) {
|
||||
@@ -103,22 +112,28 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
}
|
||||
});
|
||||
} catch (DataAccessException ex) {
|
||||
LOG.error("Failed to save authorized client for registration ID '{}' and principal '{}'",
|
||||
LOG.error(
|
||||
"Failed to save authorized client for registration ID '{}' and principal '{}'",
|
||||
authorizedClient.getClientRegistration().getRegistrationId(),
|
||||
principal.getName(), ex);
|
||||
principal.getName(),
|
||||
ex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAuthorizedClient(String clientRegistrationId, String principalName) {
|
||||
jdbcOperations.update("DELETE FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?",
|
||||
preparedStatement -> {
|
||||
jdbcOperations.update(
|
||||
"DELETE FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?",
|
||||
(preparedStatement) -> {
|
||||
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.setObject(startIndex + 1, toEpochMillis(token.getIssuedAt()), java.sql.Types.BIGINT);
|
||||
}
|
||||
@@ -152,7 +167,7 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
}
|
||||
return Stream.of(scopeString.split(" "))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.filter((s) -> !s.isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
@@ -8,8 +9,6 @@ import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class SchemaMigration implements ApplicationRunner {
|
||||
|
||||
@@ -32,7 +31,8 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
|
||||
private void ensureSessionAttributeUpsertTrigger() {
|
||||
try {
|
||||
jdbcTemplate.execute("""
|
||||
jdbcTemplate.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS SPRING_SESSION_ATTRIBUTES_UPSERT
|
||||
BEFORE INSERT ON SPRING_SESSION_ATTRIBUTES
|
||||
FOR EACH ROW
|
||||
@@ -41,7 +41,8 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
WHERE SESSION_PRIMARY_ID = NEW.SESSION_PRIMARY_ID
|
||||
AND ATTRIBUTE_NAME = NEW.ATTRIBUTE_NAME;
|
||||
END;
|
||||
""");
|
||||
"""
|
||||
);
|
||||
logger.info("Ensured SPRING_SESSION_ATTRIBUTES upsert trigger exists");
|
||||
} catch (DataAccessException 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");
|
||||
}
|
||||
|
||||
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)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue);
|
||||
jdbcTemplate.execute("UPDATE " + tableName + " SET " + columnName + " = " + defaultValue + " WHERE " + columnName + " IS NULL");
|
||||
jdbcTemplate.execute(
|
||||
"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);
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Failed to add column '{}' to {} table", columnName, tableName, ex);
|
||||
@@ -107,7 +126,8 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
|
||||
private void ensureAuthorizedClientTable() {
|
||||
try {
|
||||
jdbcTemplate.execute("""
|
||||
jdbcTemplate.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oauth2_authorized_client (
|
||||
client_registration_id VARCHAR(100) NOT NULL,
|
||||
principal_name VARCHAR(200) NOT NULL,
|
||||
@@ -120,7 +140,8 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
refresh_token_issued_at INTEGER,
|
||||
PRIMARY KEY (client_registration_id, principal_name)
|
||||
)
|
||||
""");
|
||||
"""
|
||||
);
|
||||
logger.info("Ensured oauth2_authorized_client table exists");
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Unable to ensure oauth2_authorized_client table", ex);
|
||||
@@ -137,12 +158,33 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
try {
|
||||
int updated = jdbcTemplate.update(
|
||||
"UPDATE oauth2_authorized_client " +
|
||||
"SET " + column + " = CASE " +
|
||||
"WHEN " + column + " 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");
|
||||
"SET " +
|
||||
column +
|
||||
" = CASE " +
|
||||
"WHEN " +
|
||||
column +
|
||||
" 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) {
|
||||
logger.info("Normalized {} rows in oauth2_authorized_client.{}", updated, column);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.config;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
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.web.builders.HttpSecurity;
|
||||
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.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -22,10 +22,13 @@ import org.springframework.http.HttpStatus;
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http,
|
||||
OAuth2AuthorizedClientRepository authorizedClientRepository) throws Exception {
|
||||
SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http,
|
||||
OAuth2AuthorizedClientRepository authorizedClientRepository
|
||||
) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.authorizeHttpRequests((auth) ->
|
||||
auth
|
||||
.requestMatchers(
|
||||
"/",
|
||||
"/favicon.ico",
|
||||
@@ -38,26 +41,37 @@ public class SecurityConfig {
|
||||
"/swagger-ui.html",
|
||||
"/swagger-ui/**",
|
||||
"/channels"
|
||||
).permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/view/*/broadcast").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/visible").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content").permitAll()
|
||||
.requestMatchers("/ws/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.oauth2Login(oauth -> oauth
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/view/*/broadcast")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/visible")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content")
|
||||
.permitAll()
|
||||
.requestMatchers("/ws/**")
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated()
|
||||
)
|
||||
.oauth2Login((oauth) ->
|
||||
oauth
|
||||
.authorizedClientRepository(authorizedClientRepository)
|
||||
.tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient()))
|
||||
.userInfoEndpoint(user -> user.userService(twitchOAuth2UserService())))
|
||||
.logout(logout -> logout.logoutSuccessUrl("/").permitAll())
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
.defaultAuthenticationEntryPointFor(
|
||||
.tokenEndpoint((token) -> token.accessTokenResponseClient(twitchAccessTokenResponseClient()))
|
||||
.userInfoEndpoint((user) -> user.userService(twitchOAuth2UserService()))
|
||||
)
|
||||
.logout((logout) -> logout.logoutSuccessUrl("/").permitAll())
|
||||
.exceptionHandling((exceptions) ->
|
||||
exceptions.defaultAuthenticationEntryPointFor(
|
||||
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
|
||||
new AntPathRequestMatcher("/api/**")
|
||||
))
|
||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**"));
|
||||
)
|
||||
)
|
||||
.csrf((csrf) -> csrf.ignoringRequestMatchers("/ws/**", "/api/**"));
|
||||
return http.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,31 +4,39 @@ import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
@Component
|
||||
public class SystemEnvironmentValidator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SystemEnvironmentValidator.class);
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.twitch.client-id:#{null}}")
|
||||
private String twitchClientId;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.twitch.client-secret:#{null}}")
|
||||
private String twitchClientSecret;
|
||||
|
||||
@Value("${spring.servlet.multipart.max-file-size:#{null}}")
|
||||
private String springMaxFileSize;
|
||||
|
||||
@Value("${spring.servlet.multipart.max-request-size:#{null}}")
|
||||
private String springMaxRequestSize;
|
||||
|
||||
@Value("${IMGFLOAT_ASSETS_PATH:#{null}}")
|
||||
private String assetsPath;
|
||||
|
||||
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}")
|
||||
private String previewsPath;
|
||||
|
||||
@Value("${IMGFLOAT_DB_PATH:#{null}}")
|
||||
private String dbPath;
|
||||
|
||||
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}")
|
||||
private String initialSysadmin;
|
||||
|
||||
@@ -41,7 +49,11 @@ public class SystemEnvironmentValidator {
|
||||
|
||||
@PostConstruct
|
||||
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");
|
||||
return;
|
||||
}
|
||||
@@ -60,9 +72,7 @@ public class SystemEnvironmentValidator {
|
||||
checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing);
|
||||
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"Missing or invalid environment variables:\n" + missing
|
||||
);
|
||||
throw new IllegalStateException("Missing or invalid environment variables:\n" + missing);
|
||||
}
|
||||
|
||||
log.info("Environment validation successful:");
|
||||
@@ -93,7 +103,7 @@ public class SystemEnvironmentValidator {
|
||||
private String redact(String value) {
|
||||
if (value != null && StringUtils.hasText(value)) {
|
||||
return "**************";
|
||||
};
|
||||
}
|
||||
return "<not set>";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package dev.kruhlmann.imgfloat.config;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.RequestEntity;
|
||||
@@ -19,8 +18,8 @@ import org.springframework.util.MultiValueMap;
|
||||
* request body. Twitch ignores HTTP Basic authentication and responds with "missing client id" if
|
||||
* those parameters are absent.
|
||||
*/
|
||||
final class TwitchAuthorizationCodeGrantRequestEntityConverter implements
|
||||
Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {
|
||||
final class TwitchAuthorizationCodeGrantRequestEntityConverter
|
||||
implements Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {
|
||||
|
||||
private final Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> delegate =
|
||||
new OAuth2AuthorizationCodeGrantRequestEntityConverter();
|
||||
@@ -50,8 +49,7 @@ final class TwitchAuthorizationCodeGrantRequestEntityConverter implements
|
||||
|
||||
private MultiValueMap<String, String> cloneBody(MultiValueMap<?, ?> existingBody) {
|
||||
MultiValueMap<String, String> copy = new LinkedMultiValueMap<>();
|
||||
existingBody.forEach((key, value) ->
|
||||
copy.put(String.valueOf(key), new ArrayList<>((List<String>) value)));
|
||||
existingBody.forEach((key, value) -> copy.put(String.valueOf(key), new ArrayList<>((List<String>) value)));
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package dev.kruhlmann.imgfloat.config;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
|
||||
@@ -24,6 +23,7 @@ import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(OAuth2ClientProperties.class)
|
||||
class TwitchClientRegistrationConfig {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TwitchClientRegistrationConfig.class);
|
||||
|
||||
@Bean
|
||||
@@ -37,7 +37,8 @@ class TwitchClientRegistrationConfig {
|
||||
OAuth2ClientProperties.Provider provider = properties.getProvider().get(providerId);
|
||||
if (provider == null) {
|
||||
throw new IllegalStateException(
|
||||
"Missing OAuth2 provider configuration for registration '" + registrationId + "'.");
|
||||
"Missing OAuth2 provider configuration for registration '" + registrationId + "'."
|
||||
);
|
||||
}
|
||||
if (!"twitch".equals(registrationId)) {
|
||||
LOG.warn("Unexpected OAuth2 registration '{}' found; only Twitch is supported.", registrationId);
|
||||
@@ -51,7 +52,8 @@ class TwitchClientRegistrationConfig {
|
||||
private ClientRegistration buildTwitchRegistration(
|
||||
String registrationId,
|
||||
OAuth2ClientProperties.Registration registration,
|
||||
OAuth2ClientProperties.Provider provider) {
|
||||
OAuth2ClientProperties.Provider provider
|
||||
) {
|
||||
String clientId = sanitize(registration.getClientId(), "TWITCH_CLIENT_ID");
|
||||
String clientSecret = sanitize(registration.getClientSecret(), "TWITCH_CLIENT_SECRET");
|
||||
return ClientRegistration.withRegistrationId(registrationId)
|
||||
@@ -74,7 +76,9 @@ class TwitchClientRegistrationConfig {
|
||||
return null;
|
||||
}
|
||||
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();
|
||||
LOG.info("Sanitizing {} by stripping surrounding quotes.", name);
|
||||
return unquoted;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
@@ -31,20 +30,24 @@ class TwitchOAuth2ErrorResponseErrorHandler extends OAuth2ErrorResponseErrorHand
|
||||
String body = new String(bodyBytes, StandardCharsets.UTF_8);
|
||||
|
||||
if (body.isBlank()) {
|
||||
LOG.warn("Failed to parse Twitch OAuth error response (status: {}, headers: {}): <empty body>",
|
||||
LOG.warn(
|
||||
"Failed to parse Twitch OAuth error response (status: {}, headers: {}): <empty body>",
|
||||
response.getStatusCode(),
|
||||
response.getHeaders());
|
||||
response.getHeaders()
|
||||
);
|
||||
throw asAuthorizationException(body, null);
|
||||
}
|
||||
|
||||
try {
|
||||
super.handleError(new CachedBodyClientHttpResponse(response, bodyBytes));
|
||||
} catch (HttpMessageNotReadableException | IllegalArgumentException ex) {
|
||||
LOG.warn("Failed to parse Twitch OAuth error response (status: {}, headers: {}): {}",
|
||||
LOG.warn(
|
||||
"Failed to parse Twitch OAuth error response (status: {}, headers: {}): {}",
|
||||
response.getStatusCode(),
|
||||
response.getHeaders(),
|
||||
body,
|
||||
ex);
|
||||
ex
|
||||
);
|
||||
throw asAuthorizationException(body, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
@@ -48,7 +47,8 @@ class TwitchOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OA
|
||||
return new OAuth2UserRequest(
|
||||
twitchUserRegistration(userRequest),
|
||||
userRequest.getAccessToken(),
|
||||
userRequest.getAdditionalParameters());
|
||||
userRequest.getAdditionalParameters()
|
||||
);
|
||||
}
|
||||
|
||||
private ClientRegistration twitchUserRegistration(OAuth2UserRequest userRequest) {
|
||||
|
||||
@@ -35,6 +35,8 @@ public class UploadLimitsConfig {
|
||||
}
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.AdminRequest;
|
||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
||||
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
||||
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.TwitchUserLookupService;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
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.LoggerFactory;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
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;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/channels/{broadcaster}")
|
||||
@SecurityRequirement(name = "twitchOAuth")
|
||||
public class ChannelApiController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
|
||||
private final ChannelDirectoryService channelDirectoryService;
|
||||
private final OAuth2AuthorizedClientService authorizedClientService;
|
||||
@@ -71,9 +70,11 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PostMapping("/admins")
|
||||
public ResponseEntity<?> addAdmin(@PathVariable("broadcaster") String broadcaster,
|
||||
public ResponseEntity<?> addAdmin(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@Valid @RequestBody AdminRequest request,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("User {} adding admin {} to {}", sessionUsername, request.getUsername(), broadcaster);
|
||||
@@ -85,32 +86,34 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@GetMapping("/admins")
|
||||
public Collection<TwitchUserProfile> listAdmins(@PathVariable("broadcaster") String broadcaster,
|
||||
public Collection<TwitchUserProfile> listAdmins(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
HttpServletRequest request) {
|
||||
HttpServletRequest request
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.debug("Listing admins for {} by {}", broadcaster, sessionUsername);
|
||||
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
||||
List<String> admins = channel.getAdmins().stream()
|
||||
.sorted(Comparator.naturalOrder())
|
||||
.toList();
|
||||
List<String> admins = channel.getAdmins().stream().sorted(Comparator.naturalOrder()).toList();
|
||||
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request);
|
||||
String accessToken = Optional.ofNullable(authorizedClient)
|
||||
.map(OAuth2AuthorizedClient::getAccessToken)
|
||||
.map(token -> token.getTokenValue())
|
||||
.map((token) -> token.getTokenValue())
|
||||
.orElse(null);
|
||||
String clientId = Optional.ofNullable(authorizedClient)
|
||||
.map(OAuth2AuthorizedClient::getClientRegistration)
|
||||
.map(registration -> registration.getClientId())
|
||||
.map((registration) -> registration.getClientId())
|
||||
.orElse(null);
|
||||
return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId);
|
||||
}
|
||||
|
||||
@GetMapping("/admins/suggestions")
|
||||
public Collection<TwitchUserProfile> listAdminSuggestions(@PathVariable("broadcaster") String broadcaster,
|
||||
public Collection<TwitchUserProfile> listAdminSuggestions(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
HttpServletRequest request) {
|
||||
HttpServletRequest request
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.debug("Listing admin suggestions for {} by {}", broadcaster, sessionUsername);
|
||||
@@ -118,28 +121,38 @@ public class ChannelApiController {
|
||||
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request);
|
||||
|
||||
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();
|
||||
}
|
||||
String accessToken = Optional.ofNullable(authorizedClient)
|
||||
.map(OAuth2AuthorizedClient::getAccessToken)
|
||||
.map(token -> token.getTokenValue())
|
||||
.map((token) -> token.getTokenValue())
|
||||
.orElse(null);
|
||||
String clientId = Optional.ofNullable(authorizedClient)
|
||||
.map(OAuth2AuthorizedClient::getClientRegistration)
|
||||
.map(registration -> registration.getClientId())
|
||||
.map((registration) -> registration.getClientId())
|
||||
.orElse(null);
|
||||
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 twitchUserLookupService.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/admins/{username}")
|
||||
public ResponseEntity<?> removeAdmin(@PathVariable("broadcaster") String broadcaster,
|
||||
public ResponseEntity<?> removeAdmin(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("username") String username,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("User {} removing admin {} from {}", sessionUsername, username, broadcaster);
|
||||
@@ -163,28 +176,42 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PutMapping("/canvas")
|
||||
public CanvasSettingsRequest updateCanvas(@PathVariable("broadcaster") String broadcaster,
|
||||
public CanvasSettingsRequest updateCanvas(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@Valid @RequestBody CanvasSettingsRequest request,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
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);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<AssetView> createAsset(@PathVariable("broadcaster") String broadcaster,
|
||||
public ResponseEntity<AssetView> createAsset(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
if (file == null || file.isEmpty()) {
|
||||
LOG.warn("User {} attempted to upload empty file to {}", sessionUsername, broadcaster);
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Asset file is required");
|
||||
}
|
||||
try {
|
||||
LOG.info("User {} uploading asset {} to {}", sessionUsername, file.getOriginalFilename(), broadcaster);
|
||||
return channelDirectoryService.createAsset(broadcaster, file)
|
||||
return channelDirectoryService
|
||||
.createAsset(broadcaster, file)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
|
||||
} catch (IOException e) {
|
||||
@@ -194,14 +221,20 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PutMapping("/assets/{assetId}/transform")
|
||||
public ResponseEntity<AssetView> transform(@PathVariable("broadcaster") String broadcaster,
|
||||
public ResponseEntity<AssetView> transform(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@Valid @RequestBody TransformRequest request,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
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);
|
||||
return channelDirectoryService.updateTransform(broadcaster, assetId, request)
|
||||
return channelDirectoryService
|
||||
.updateTransform(broadcaster, assetId, request)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> {
|
||||
LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||
@@ -210,27 +243,45 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PostMapping("/assets/{assetId}/play")
|
||||
public ResponseEntity<AssetView> play(@PathVariable("broadcaster") String broadcaster,
|
||||
public ResponseEntity<AssetView> play(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@RequestBody(required = false) PlaybackRequest request,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
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);
|
||||
return channelDirectoryService.triggerPlayback(broadcaster, assetId, request)
|
||||
return channelDirectoryService
|
||||
.triggerPlayback(broadcaster, assetId, request)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||
}
|
||||
|
||||
@PutMapping("/assets/{assetId}/visibility")
|
||||
public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster,
|
||||
public ResponseEntity<AssetView> visibility(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@RequestBody VisibilityRequest request,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("Updating visibility for asset {} on {} by {} to hidden={} ", assetId, broadcaster, sessionUsername , request.isHidden());
|
||||
return channelDirectoryService.updateVisibility(broadcaster, assetId, request)
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
LOG.info(
|
||||
"Updating visibility for asset {} on {} by {} to hidden={} ",
|
||||
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);
|
||||
@@ -239,43 +290,61 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@GetMapping("/assets/{assetId}/content")
|
||||
public ResponseEntity<byte[]> getAssetContent(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId) {
|
||||
public ResponseEntity<byte[]> getAssetContent(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId
|
||||
) {
|
||||
LOG.debug("Serving asset {} for broadcaster {}", assetId, broadcaster);
|
||||
return channelDirectoryService.getAssetContent(assetId)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
return channelDirectoryService
|
||||
.getAssetContent(assetId)
|
||||
.map((content) ->
|
||||
ResponseEntity.ok()
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType()))
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.body(content.bytes())
|
||||
)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||
}
|
||||
|
||||
@GetMapping("/assets/{assetId}/preview")
|
||||
public ResponseEntity<byte[]> getAssetPreview(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId) {
|
||||
public ResponseEntity<byte[]> getAssetPreview(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId
|
||||
) {
|
||||
LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster);
|
||||
return channelDirectoryService.getAssetPreview(assetId, true)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
return channelDirectoryService
|
||||
.getAssetPreview(assetId, true)
|
||||
.map((content) ->
|
||||
ResponseEntity.ok()
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.body(content.bytes())
|
||||
)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found"));
|
||||
}
|
||||
|
||||
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 "attachment";
|
||||
}
|
||||
|
||||
@DeleteMapping("/assets/{assetId}")
|
||||
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
|
||||
public ResponseEntity<?> delete(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
boolean removed = channelDirectoryService.deleteAsset(assetId);
|
||||
if (!removed) {
|
||||
LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||
@@ -285,9 +354,11 @@ public class ChannelApiController {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
private OAuth2AuthorizedClient resolveAuthorizedClient(OAuth2AuthenticationToken oauthToken,
|
||||
private OAuth2AuthorizedClient resolveAuthorizedClient(
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
OAuth2AuthorizedClient authorizedClient,
|
||||
HttpServletRequest request) {
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (authorizedClient != null) {
|
||||
return authorizedClient;
|
||||
}
|
||||
@@ -297,10 +368,14 @@ public class ChannelApiController {
|
||||
OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient(
|
||||
oauthToken.getAuthorizedClientRegistrationId(),
|
||||
oauthToken,
|
||||
request);
|
||||
request
|
||||
);
|
||||
if (sessionClient != null) {
|
||||
return sessionClient;
|
||||
}
|
||||
return authorizedClientService.loadAuthorizedClient(oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName());
|
||||
return authorizedClientService.loadAuthorizedClient(
|
||||
oauthToken.getAuthorizedClientRegistrationId(),
|
||||
oauthToken.getName()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import java.util.List;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/channels")
|
||||
public class ChannelDirectoryApiController {
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
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.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 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.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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.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
|
||||
@RequestMapping("/api/settings")
|
||||
@SecurityRequirement(name = "administrator")
|
||||
public class SettingsApiController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
|
||||
|
||||
private final SettingsService settingsService;
|
||||
@@ -49,7 +48,10 @@ public class SettingsApiController {
|
||||
}
|
||||
|
||||
@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();
|
||||
authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername);
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.VersionService;
|
||||
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 static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.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.LoggerFactory;
|
||||
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.web.server.ResponseStatusException;
|
||||
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
@Controller
|
||||
public class ViewController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ViewController.class);
|
||||
private final ChannelDirectoryService channelDirectoryService;
|
||||
private final VersionService versionService;
|
||||
@@ -85,11 +85,16 @@ public class ViewController {
|
||||
}
|
||||
|
||||
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin")
|
||||
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||
public String adminView(
|
||||
@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
Model model) {
|
||||
Model model
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
LOG.info("Rendering admin console for {} (requested by {})", broadcaster, sessionUsername);
|
||||
Settings settings = settingsService.get();
|
||||
model.addAttribute("broadcaster", broadcaster.toLowerCase());
|
||||
@@ -106,8 +111,10 @@ public class ViewController {
|
||||
}
|
||||
|
||||
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast")
|
||||
public String broadcastView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||
Model model) {
|
||||
public String broadcastView(
|
||||
@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||
Model model
|
||||
) {
|
||||
LOG.debug("Rendering broadcast overlay for {}", broadcaster);
|
||||
model.addAttribute("broadcaster", broadcaster.toLowerCase());
|
||||
return "broadcast";
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.model;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class AdminRequest {
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
@@ -14,18 +13,25 @@ import java.util.UUID;
|
||||
@Entity
|
||||
@Table(name = "assets")
|
||||
public class Asset {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String broadcaster;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String url;
|
||||
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String preview;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
private double x;
|
||||
private double y;
|
||||
private double width;
|
||||
@@ -43,8 +49,7 @@ public class Asset {
|
||||
private Double audioVolume;
|
||||
private boolean hidden;
|
||||
|
||||
public Asset() {
|
||||
}
|
||||
public Asset() {}
|
||||
|
||||
public Asset(String broadcaster, String name, String url, double width, double height) {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
|
||||
@@ -4,12 +4,13 @@ import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AssetEvent {
|
||||
|
||||
public enum Type {
|
||||
CREATED,
|
||||
UPDATED,
|
||||
VISIBILITY,
|
||||
PLAY,
|
||||
DELETED
|
||||
DELETED,
|
||||
}
|
||||
|
||||
private Type type;
|
||||
|
||||
@@ -59,7 +59,9 @@ public record AssetPatch(
|
||||
request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null,
|
||||
null,
|
||||
request.getAudioLoop() != null ? changed(before.audioLoop(), asset.isAudioLoop()) : null,
|
||||
request.getAudioDelayMillis() != null ? changed(before.audioDelayMillis(), asset.getAudioDelayMillis()) : null,
|
||||
request.getAudioDelayMillis() != null
|
||||
? changed(before.audioDelayMillis(), asset.getAudioDelayMillis())
|
||||
: 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
|
||||
|
||||
@@ -3,14 +3,14 @@ package dev.kruhlmann.imgfloat.model;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
public class CanvasSettingsRequest {
|
||||
|
||||
@Positive
|
||||
private double width;
|
||||
|
||||
@Positive
|
||||
private double height;
|
||||
|
||||
public CanvasSettingsRequest() {
|
||||
}
|
||||
public CanvasSettingsRequest() {}
|
||||
|
||||
public CanvasSettingsRequest(double width, double height) {
|
||||
this.width = width;
|
||||
|
||||
@@ -10,7 +10,6 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
@@ -20,6 +19,7 @@ import java.util.stream.Collectors;
|
||||
@Entity
|
||||
@Table(name = "channels")
|
||||
public class Channel {
|
||||
|
||||
@Id
|
||||
private String broadcaster;
|
||||
|
||||
@@ -32,8 +32,7 @@ public class Channel {
|
||||
|
||||
private double canvasHeight = 1080;
|
||||
|
||||
public Channel() {
|
||||
}
|
||||
public Channel() {}
|
||||
|
||||
public Channel(String broadcaster) {
|
||||
this.broadcaster = normalize(broadcaster);
|
||||
@@ -77,9 +76,7 @@ public class Channel {
|
||||
@PreUpdate
|
||||
public void normalizeFields() {
|
||||
this.broadcaster = normalize(broadcaster);
|
||||
this.admins = admins.stream()
|
||||
.map(Channel::normalize)
|
||||
.collect(Collectors.toSet());
|
||||
this.admins = admins.stream().map(Channel::normalize).collect(Collectors.toSet());
|
||||
if (canvasWidth <= 0) {
|
||||
canvasWidth = 1920;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.model;
|
||||
|
||||
public class PlaybackRequest {
|
||||
|
||||
private Boolean play;
|
||||
|
||||
public Boolean getPlay() {
|
||||
|
||||
@@ -4,34 +4,42 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "settings")
|
||||
public class Settings {
|
||||
|
||||
@Id
|
||||
@Column(nullable = false)
|
||||
private int id = 1;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double minAssetPlaybackSpeedFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double maxAssetPlaybackSpeedFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double minAssetAudioPitchFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double maxAssetAudioPitchFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double minAssetVolumeFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double maxAssetVolumeFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int maxCanvasSideLengthPixels;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int canvasFramesPerSecond;
|
||||
|
||||
protected Settings() {
|
||||
}
|
||||
protected Settings() {}
|
||||
|
||||
public static Settings defaults() {
|
||||
Settings s = new Settings();
|
||||
@@ -117,5 +125,4 @@ public class Settings {
|
||||
public void setCanvasFramesPerSecond(int canvasFramesPerSecond) {
|
||||
this.canvasFramesPerSecond = canvasFramesPerSecond;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,27 +4,24 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "system_administrators",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username")
|
||||
)
|
||||
@Table(name = "system_administrators", uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username"))
|
||||
public class SystemAdministrator {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(name = "twitch_username", nullable = false)
|
||||
private String twitchUsername;
|
||||
|
||||
public SystemAdministrator() {
|
||||
}
|
||||
public SystemAdministrator() {}
|
||||
|
||||
public SystemAdministrator(String twitchUsername) {
|
||||
this.twitchUsername = twitchUsername;
|
||||
@@ -43,7 +40,6 @@ public class SystemAdministrator {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public String getTwitchUsername() {
|
||||
return twitchUsername;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
|
||||
public class TransformRequest {
|
||||
|
||||
private double x;
|
||||
private double y;
|
||||
|
||||
@@ -25,6 +26,7 @@ public class TransformRequest {
|
||||
|
||||
@Positive(message = "zIndex must be at least 1")
|
||||
private Integer zIndex;
|
||||
|
||||
private Boolean audioLoop;
|
||||
|
||||
@PositiveOrZero(message = "Audio delay must be zero or greater")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.model;
|
||||
|
||||
public class VisibilityRequest {
|
||||
|
||||
private boolean hidden;
|
||||
|
||||
public boolean isHidden() {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package dev.kruhlmann.imgfloat.repository;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AssetRepository extends JpaRepository<Asset, String> {
|
||||
List<Asset> findByBroadcaster(String broadcaster);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package dev.kruhlmann.imgfloat.repository;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Channel;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ChannelRepository extends JpaRepository<Channel, String> {
|
||||
List<Channel> findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(String broadcasterFragment);
|
||||
|
||||
@@ -3,5 +3,4 @@ package dev.kruhlmann.imgfloat.repository;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface SettingsRepository extends JpaRepository<Settings, Integer> {
|
||||
}
|
||||
public interface SettingsRepository extends JpaRepository<Settings, Integer> {}
|
||||
|
||||
@@ -2,35 +2,29 @@ package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
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.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
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
|
||||
public class AssetCleanupService {
|
||||
|
||||
private static final Logger logger =
|
||||
LoggerFactory.getLogger(AssetCleanupService.class);
|
||||
private static final Logger logger = LoggerFactory.getLogger(AssetCleanupService.class);
|
||||
|
||||
private final AssetRepository assetRepository;
|
||||
private final AssetStorageService assetStorageService;
|
||||
|
||||
public AssetCleanupService(
|
||||
AssetRepository assetRepository,
|
||||
AssetStorageService assetStorageService
|
||||
) {
|
||||
public AssetCleanupService(AssetRepository assetRepository, AssetStorageService assetStorageService) {
|
||||
this.assetRepository = assetRepository;
|
||||
this.assetStorageService = assetStorageService;
|
||||
}
|
||||
@@ -41,10 +35,7 @@ public class AssetCleanupService {
|
||||
public void cleanup() {
|
||||
logger.info("Collecting referenced assets");
|
||||
|
||||
Set<String> referencedIds = assetRepository.findAll()
|
||||
.stream()
|
||||
.map(Asset::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> referencedIds = assetRepository.findAll().stream().map(Asset::getId).collect(Collectors.toSet());
|
||||
|
||||
assetStorageService.deleteOrphanedAssets(referencedIds);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Locale;
|
||||
@@ -14,9 +9,14 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
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
|
||||
public class AssetStorageService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class);
|
||||
private static final Map<String, String> EXTENSIONS = Map.ofEntries(
|
||||
Map.entry("image/png", ".png"),
|
||||
@@ -62,9 +62,7 @@ public class AssetStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
public void storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType)
|
||||
throws IOException {
|
||||
|
||||
public void storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) throws IOException {
|
||||
if (assetBytes == null || assetBytes.length == 0) {
|
||||
throw new IOException("Asset content is empty");
|
||||
}
|
||||
@@ -72,35 +70,35 @@ public class AssetStorageService {
|
||||
Path file = assetPath(broadcaster, assetId, mediaType);
|
||||
Files.createDirectories(file.getParent());
|
||||
|
||||
Files.write(file, assetBytes,
|
||||
Files.write(
|
||||
file,
|
||||
assetBytes,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
StandardOpenOption.WRITE
|
||||
);
|
||||
logger.info("Wrote asset to {}", file.toString());
|
||||
}
|
||||
|
||||
public void storePreview(String broadcaster, String assetId, byte[] previewBytes)
|
||||
throws IOException {
|
||||
|
||||
public void storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException {
|
||||
if (previewBytes == null || previewBytes.length == 0) return;
|
||||
|
||||
Path file = previewPath(broadcaster, assetId);
|
||||
Files.createDirectories(file.getParent());
|
||||
|
||||
Files.write(file, previewBytes,
|
||||
Files.write(
|
||||
file,
|
||||
previewBytes,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
StandardOpenOption.WRITE
|
||||
);
|
||||
logger.info("Wrote asset to {}", file.toString());
|
||||
}
|
||||
|
||||
public Optional<AssetContent> loadAssetFile(Asset asset) {
|
||||
try {
|
||||
Path file = assetPath(
|
||||
asset.getBroadcaster(),
|
||||
asset.getId(),
|
||||
asset.getMediaType()
|
||||
);
|
||||
Path file = assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType());
|
||||
|
||||
if (!Files.exists(file)) return Optional.empty();
|
||||
|
||||
@@ -141,12 +139,8 @@ public class AssetStorageService {
|
||||
|
||||
public void deleteAsset(Asset asset) {
|
||||
try {
|
||||
Files.deleteIfExists(
|
||||
assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType())
|
||||
);
|
||||
Files.deleteIfExists(
|
||||
previewPath(asset.getBroadcaster(), asset.getId())
|
||||
);
|
||||
Files.deleteIfExists(assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType()));
|
||||
Files.deleteIfExists(previewPath(asset.getBroadcaster(), asset.getId()));
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to delete asset {}", asset.getId(), e);
|
||||
}
|
||||
@@ -162,9 +156,10 @@ public class AssetStorageService {
|
||||
return;
|
||||
}
|
||||
try (var paths = Files.walk(root)) {
|
||||
paths.filter(Files::isRegularFile)
|
||||
.filter(p -> isOrphan(p, referencedAssetIds))
|
||||
.forEach(p -> {
|
||||
paths
|
||||
.filter(Files::isRegularFile)
|
||||
.filter((p) -> isOrphan(p, referencedAssetIds))
|
||||
.forEach((p) -> {
|
||||
try {
|
||||
Files.delete(p);
|
||||
logger.warn("Deleted orphan file {}", p);
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
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.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.SystemAdministratorService;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
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
|
||||
public class AuthorizationService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AuthorizationService.class);
|
||||
|
||||
private final ChannelDirectoryService channelDirectoryService;
|
||||
private final SystemAdministratorService systemAdministratorService;
|
||||
|
||||
public AuthorizationService(ChannelDirectoryService channelDirectoryService, SystemAdministratorService systemAdministratorService) {
|
||||
public AuthorizationService(
|
||||
ChannelDirectoryService channelDirectoryService,
|
||||
SystemAdministratorService systemAdministratorService
|
||||
) {
|
||||
this.channelDirectoryService = channelDirectoryService;
|
||||
this.systemAdministratorService = systemAdministratorService;
|
||||
}
|
||||
@@ -35,14 +38,25 @@ public class AuthorizationService {
|
||||
throw new ResponseStatusException(NOT_FOUND, "You can only manage your own channel");
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
public void userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(String broadcaster, String sessionUsername) {
|
||||
public void userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
String broadcaster,
|
||||
String 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");
|
||||
}
|
||||
}
|
||||
@@ -64,15 +78,20 @@ public class AuthorizationService {
|
||||
|
||||
public boolean userIsChannelAdminForBroadcaster(String broadcaster, String sessionUsername) {
|
||||
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 channelDirectoryService.isAdmin(broadcaster, sessionUsername);
|
||||
}
|
||||
|
||||
public boolean userIsBroadcasterOrChannelAdminForBroadcaster(String broadcaster, String sessionUser) {
|
||||
return userIsBroadcaster(sessionUser, broadcaster) ||
|
||||
userIsChannelAdminForBroadcaster(sessionUser, broadcaster);
|
||||
return (
|
||||
userIsBroadcaster(sessionUser, broadcaster) || userIsChannelAdminForBroadcaster(sessionUser, broadcaster)
|
||||
);
|
||||
}
|
||||
|
||||
public boolean userIsSystemAdministrator(String sessionUsername) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import dev.kruhlmann.imgfloat.model.AssetEvent;
|
||||
import dev.kruhlmann.imgfloat.model.AssetPatch;
|
||||
import dev.kruhlmann.imgfloat.model.Channel;
|
||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
||||
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
||||
import dev.kruhlmann.imgfloat.model.Channel;
|
||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
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.MediaOptimizationService;
|
||||
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.LoggerFactory;
|
||||
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.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
|
||||
public class ChannelDirectoryService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
||||
private static final Pattern SAFE_FILENAME = Pattern.compile("[^a-zA-Z0-9._ -]");
|
||||
|
||||
@@ -68,18 +67,15 @@ public class ChannelDirectoryService {
|
||||
this.settingsService = settingsService;
|
||||
}
|
||||
|
||||
|
||||
public Channel getOrCreateChannel(String broadcaster) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return channelRepository.findById(normalized)
|
||||
.orElseGet(() -> channelRepository.save(new Channel(normalized)));
|
||||
return channelRepository.findById(normalized).orElseGet(() -> channelRepository.save(new Channel(normalized)));
|
||||
}
|
||||
|
||||
public List<String> searchBroadcasters(String query) {
|
||||
String q = normalize(query);
|
||||
return channelRepository
|
||||
.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(
|
||||
q == null ? "" : q)
|
||||
.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(q == null ? "" : q)
|
||||
.stream()
|
||||
.map(Channel::getBroadcaster)
|
||||
.toList();
|
||||
@@ -90,8 +86,7 @@ public class ChannelDirectoryService {
|
||||
boolean added = channel.addAdmin(username);
|
||||
if (added) {
|
||||
channelRepository.save(channel);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster),
|
||||
"Admin added: " + username);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
@@ -101,22 +96,19 @@ public class ChannelDirectoryService {
|
||||
boolean removed = channel.removeAdmin(username);
|
||||
if (removed) {
|
||||
channelRepository.save(channel);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster),
|
||||
"Admin removed: " + username);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
public Collection<AssetView> getAssetsForAdmin(String broadcaster) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return sortAndMapAssets(normalized,
|
||||
assetRepository.findByBroadcaster(normalized));
|
||||
return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized));
|
||||
}
|
||||
|
||||
public Collection<AssetView> getVisibleAssets(String broadcaster) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return sortAndMapAssets(normalized,
|
||||
assetRepository.findByBroadcasterAndHiddenFalse(normalized));
|
||||
return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalized));
|
||||
}
|
||||
|
||||
public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
|
||||
@@ -138,18 +130,14 @@ public class ChannelDirectoryService {
|
||||
if (fileSize > maxSize) {
|
||||
throw new ResponseStatusException(
|
||||
PAYLOAD_TOO_LARGE,
|
||||
String.format(
|
||||
"Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.",
|
||||
fileSize,
|
||||
maxSize
|
||||
)
|
||||
String.format("Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.", fileSize, maxSize)
|
||||
);
|
||||
}
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
byte[] bytes = file.getBytes();
|
||||
String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes)
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
BAD_REQUEST, "Unsupported media type"));
|
||||
String mediaType = mediaDetectionService
|
||||
.detectAllowedMediaType(file, bytes)
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type"));
|
||||
|
||||
OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
|
||||
if (optimized == null) {
|
||||
@@ -158,16 +146,17 @@ public class ChannelDirectoryService {
|
||||
|
||||
String safeName = Optional.ofNullable(file.getOriginalFilename())
|
||||
.map(this::sanitizeFilename)
|
||||
.filter(s -> !s.isBlank())
|
||||
.filter((s) -> !s.isBlank())
|
||||
.orElse("asset_" + System.currentTimeMillis());
|
||||
|
||||
double width = optimized.width() > 0 ? optimized.width() :
|
||||
(optimized.mediaType().startsWith("audio/") ? 400 : 640);
|
||||
double height = optimized.height() > 0 ? optimized.height() :
|
||||
(optimized.mediaType().startsWith("audio/") ? 80 : 360);
|
||||
double width = optimized.width() > 0
|
||||
? optimized.width()
|
||||
: (optimized.mediaType().startsWith("audio/") ? 400 : 640);
|
||||
double height = optimized.height() > 0
|
||||
? optimized.height()
|
||||
: (optimized.mediaType().startsWith("audio/") ? 80 : 360);
|
||||
|
||||
Asset asset = new Asset(channel.getBroadcaster(), safeName, "",
|
||||
width, height);
|
||||
Asset asset = new Asset(channel.getBroadcaster(), safeName, "", width, height);
|
||||
asset.setOriginalMediaType(mediaType);
|
||||
asset.setMediaType(optimized.mediaType());
|
||||
|
||||
@@ -178,11 +167,7 @@ public class ChannelDirectoryService {
|
||||
optimized.mediaType()
|
||||
);
|
||||
|
||||
assetStorageService.storePreview(
|
||||
channel.getBroadcaster(),
|
||||
asset.getId(),
|
||||
optimized.previewBytes()
|
||||
);
|
||||
assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes());
|
||||
asset.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : "");
|
||||
|
||||
asset.setSpeed(1.0);
|
||||
@@ -197,8 +182,7 @@ public class ChannelDirectoryService {
|
||||
assetRepository.save(asset);
|
||||
|
||||
AssetView view = AssetView.from(channel.getBroadcaster(), asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster),
|
||||
AssetEvent.created(broadcaster, view));
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
|
||||
|
||||
return Optional.of(view);
|
||||
}
|
||||
@@ -211,9 +195,10 @@ public class ChannelDirectoryService {
|
||||
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest req) {
|
||||
String normalized = normalize(broadcaster);
|
||||
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
||||
.map(asset -> {
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
.filter((asset) -> normalized.equals(asset.getBroadcaster()))
|
||||
.map((asset) -> {
|
||||
AssetPatch.TransformSnapshot before = AssetPatch.capture(asset);
|
||||
validateTransform(req);
|
||||
|
||||
@@ -237,8 +222,7 @@ public class ChannelDirectoryService {
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
AssetPatch patch = AssetPatch.fromTransform(before, asset, req);
|
||||
if (hasPatchChanges(patch)) {
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster),
|
||||
AssetEvent.updated(broadcaster, patch));
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
|
||||
}
|
||||
return view;
|
||||
});
|
||||
@@ -254,42 +238,58 @@ public class ChannelDirectoryService {
|
||||
double maxVolume = settings.getMaxAssetVolumeFraction();
|
||||
int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels();
|
||||
|
||||
if (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels)
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Canvas width out of range [0 to " + canvasMaxSizePixels + "]");
|
||||
if (req.getHeight() <= 0)
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Canvas height out of range [0 to " + canvasMaxSizePixels + "]");
|
||||
if (req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed))
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
|
||||
if (req.getZIndex() != null && req.getZIndex() < 1)
|
||||
throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1");
|
||||
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 + "]");
|
||||
if (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels) throw new ResponseStatusException(
|
||||
BAD_REQUEST,
|
||||
"Canvas width out of range [0 to " + canvasMaxSizePixels + "]"
|
||||
);
|
||||
if (req.getHeight() <= 0) throw new ResponseStatusException(
|
||||
BAD_REQUEST,
|
||||
"Canvas height out of range [0 to " + canvasMaxSizePixels + "]"
|
||||
);
|
||||
if (
|
||||
req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)
|
||||
) throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
|
||||
if (req.getZIndex() != null && req.getZIndex() < 1) throw new ResponseStatusException(
|
||||
BAD_REQUEST,
|
||||
"zIndex must be >= 1"
|
||||
);
|
||||
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) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(a -> normalized.equals(a.getBroadcaster()))
|
||||
.map(asset -> {
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
.filter((a) -> normalized.equals(a.getBroadcaster()))
|
||||
.map((asset) -> {
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
boolean play = req == null || req.getPlay();
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster),
|
||||
AssetEvent.play(broadcaster, view, play));
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, play));
|
||||
return view;
|
||||
});
|
||||
}
|
||||
|
||||
public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(a -> normalized.equals(a.getBroadcaster()))
|
||||
.map(asset -> {
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
.filter((a) -> normalized.equals(a.getBroadcaster()))
|
||||
.map((asset) -> {
|
||||
boolean wasHidden = asset.isHidden();
|
||||
boolean hidden = request.isHidden();
|
||||
if (wasHidden == hidden) {
|
||||
@@ -301,18 +301,24 @@ public class ChannelDirectoryService {
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
AssetPatch patch = AssetPatch.fromVisibility(asset);
|
||||
AssetView payload = hidden ? null : view;
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, patch, payload));
|
||||
messagingTemplate.convertAndSend(
|
||||
topicFor(broadcaster),
|
||||
AssetEvent.visibility(broadcaster, patch, payload)
|
||||
);
|
||||
return view;
|
||||
});
|
||||
}
|
||||
|
||||
public boolean deleteAsset(String assetId) {
|
||||
return assetRepository.findById(assetId)
|
||||
.map(asset -> {
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
.map((asset) -> {
|
||||
assetRepository.delete(asset);
|
||||
assetStorageService.deleteAsset(asset);
|
||||
messagingTemplate.convertAndSend(topicFor(asset.getBroadcaster()),
|
||||
AssetEvent.deleted(asset.getBroadcaster(), assetId));
|
||||
messagingTemplate.convertAndSend(
|
||||
topicFor(asset.getBroadcaster()),
|
||||
AssetEvent.deleted(asset.getBroadcaster(), assetId)
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.orElse(false);
|
||||
@@ -323,23 +329,27 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(a -> includeHidden || !a.isHidden())
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
.filter((a) -> includeHidden || !a.isHidden())
|
||||
.flatMap(assetStorageService::loadPreviewSafely);
|
||||
}
|
||||
|
||||
public boolean isAdmin(String broadcaster, String username) {
|
||||
return channelRepository.findById(normalize(broadcaster))
|
||||
return channelRepository
|
||||
.findById(normalize(broadcaster))
|
||||
.map(Channel::getAdmins)
|
||||
.map(admins -> admins.contains(normalize(username)))
|
||||
.map((admins) -> admins.contains(normalize(username)))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
public Collection<String> adminChannelsFor(String username) {
|
||||
if (username == null) return List.of();
|
||||
String login = username.toLowerCase();
|
||||
return channelRepository.findAll().stream()
|
||||
.filter(c -> c.getAdmins().contains(login))
|
||||
return channelRepository
|
||||
.findAll()
|
||||
.stream()
|
||||
.filter((c) -> c.getAdmins().contains(login))
|
||||
.map(Channel::getBroadcaster)
|
||||
.toList();
|
||||
}
|
||||
@@ -353,35 +363,46 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) {
|
||||
return assets.stream()
|
||||
.sorted(Comparator.comparingInt(Asset::getZIndex)
|
||||
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder())))
|
||||
.map(a -> AssetView.from(broadcaster, a))
|
||||
return assets
|
||||
.stream()
|
||||
.sorted(
|
||||
Comparator.comparingInt(Asset::getZIndex).thenComparing(
|
||||
Asset::getCreatedAt,
|
||||
Comparator.nullsFirst(Comparator.naturalOrder())
|
||||
)
|
||||
)
|
||||
.map((a) -> AssetView.from(broadcaster, a))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private int nextZIndex(String broadcaster) {
|
||||
return assetRepository.findByBroadcaster(normalize(broadcaster))
|
||||
return (
|
||||
assetRepository
|
||||
.findByBroadcaster(normalize(broadcaster))
|
||||
.stream()
|
||||
.mapToInt(Asset::getZIndex)
|
||||
.max()
|
||||
.orElse(0) + 1;
|
||||
.orElse(0) +
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private boolean hasPatchChanges(AssetPatch patch) {
|
||||
return patch.x() != null
|
||||
|| patch.y() != null
|
||||
|| patch.width() != null
|
||||
|| patch.height() != null
|
||||
|| patch.rotation() != null
|
||||
|| patch.speed() != null
|
||||
|| patch.muted() != null
|
||||
|| patch.zIndex() != null
|
||||
|| patch.hidden() != null
|
||||
|| patch.audioLoop() != null
|
||||
|| patch.audioDelayMillis() != null
|
||||
|| patch.audioSpeed() != null
|
||||
|| patch.audioPitch() != null
|
||||
|| patch.audioVolume() != null;
|
||||
return (
|
||||
patch.x() != null ||
|
||||
patch.y() != null ||
|
||||
patch.width() != null ||
|
||||
patch.height() != null ||
|
||||
patch.rotation() != null ||
|
||||
patch.speed() != null ||
|
||||
patch.muted() != null ||
|
||||
patch.zIndex() != null ||
|
||||
patch.hidden() != null ||
|
||||
patch.audioLoop() != null ||
|
||||
patch.audioDelayMillis() != null ||
|
||||
patch.audioSpeed() != null ||
|
||||
patch.audioPitch() != null ||
|
||||
patch.audioVolume() != null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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.databind.ObjectMapper;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -12,6 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SettingsService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SettingsService.class);
|
||||
|
||||
private final SettingsRepository repo;
|
||||
@@ -44,12 +44,7 @@ public class SettingsService {
|
||||
|
||||
public void logSettings(String msg, Settings settings) {
|
||||
try {
|
||||
logger.info("{}:\n{}",
|
||||
msg,
|
||||
objectMapper
|
||||
.writerWithDefaultPrettyPrinter()
|
||||
.writeValueAsString(settings)
|
||||
);
|
||||
logger.info("{}:\n{}", msg, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(settings));
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("Failed to serialize settings", e);
|
||||
}
|
||||
|
||||
@@ -3,19 +3,17 @@ package dev.kruhlmann.imgfloat.service;
|
||||
import dev.kruhlmann.imgfloat.model.SystemAdministrator;
|
||||
import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.Locale;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
import java.util.Locale;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SystemAdministratorService {
|
||||
|
||||
private static final Logger logger =
|
||||
LoggerFactory.getLogger(SystemAdministratorService.class);
|
||||
private static final Logger logger = LoggerFactory.getLogger(SystemAdministratorService.class);
|
||||
|
||||
private final SystemAdministratorRepository repo;
|
||||
private final String initialSysadmin;
|
||||
@@ -23,8 +21,7 @@ public class SystemAdministratorService {
|
||||
|
||||
public SystemAdministratorService(
|
||||
SystemAdministratorRepository repo,
|
||||
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}")
|
||||
String initialSysadmin,
|
||||
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") String initialSysadmin,
|
||||
Environment environment
|
||||
) {
|
||||
this.repo = repo;
|
||||
@@ -38,7 +35,11 @@ public class SystemAdministratorService {
|
||||
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");
|
||||
return;
|
||||
}
|
||||
@@ -65,17 +66,13 @@ public class SystemAdministratorService {
|
||||
|
||||
public void removeSysadmin(String twitchUsername) {
|
||||
if (repo.count() <= 1) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot remove the last system administrator"
|
||||
);
|
||||
throw new IllegalStateException("Cannot remove the last system administrator");
|
||||
}
|
||||
|
||||
long deleted = repo.deleteByTwitchUsername(normalize(twitchUsername));
|
||||
|
||||
if (deleted == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"System administrator does not exist"
|
||||
);
|
||||
throw new IllegalArgumentException("System administrator does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
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.LoggerFactory;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
@@ -15,23 +29,9 @@ import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
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
|
||||
public class TwitchUserLookupService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TwitchUserLookupService.class);
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
@@ -47,23 +47,27 @@ public class TwitchUserLookupService {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> normalizedLogins = logins.stream()
|
||||
List<String> normalizedLogins = logins
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(login -> login.toLowerCase(Locale.ROOT))
|
||||
.map((login) -> login.toLowerCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
Map<String, TwitchUserData> byLogin = fetchUsers(normalizedLogins, accessToken, clientId);
|
||||
|
||||
return normalizedLogins.stream()
|
||||
.map(login -> toProfile(login, byLogin.get(login)))
|
||||
return normalizedLogins
|
||||
.stream()
|
||||
.map((login) -> toProfile(login, byLogin.get(login)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<TwitchUserProfile> fetchModerators(String broadcasterLogin,
|
||||
public List<TwitchUserProfile> fetchModerators(
|
||||
String broadcasterLogin,
|
||||
Collection<String> existingAdmins,
|
||||
String accessToken,
|
||||
String clientId) {
|
||||
String clientId
|
||||
) {
|
||||
if (broadcasterLogin == null || broadcasterLogin.isBlank()) {
|
||||
LOG.warn("Cannot fetch moderators without a broadcaster login");
|
||||
return List.of();
|
||||
@@ -87,9 +91,10 @@ public class TwitchUserLookupService {
|
||||
|
||||
Set<String> skipLogins = new HashSet<>();
|
||||
if (existingAdmins != null) {
|
||||
existingAdmins.stream()
|
||||
existingAdmins
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(login -> login.toLowerCase(Locale.ROOT))
|
||||
.map((login) -> login.toLowerCase(Locale.ROOT))
|
||||
.forEach(skipLogins::add);
|
||||
}
|
||||
skipLogins.add(normalizedBroadcaster);
|
||||
@@ -102,8 +107,9 @@ public class TwitchUserLookupService {
|
||||
String cursor = null;
|
||||
|
||||
do {
|
||||
UriComponentsBuilder builder = UriComponentsBuilder
|
||||
.fromHttpUrl("https://api.twitch.tv/helix/moderation/moderators")
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(
|
||||
"https://api.twitch.tv/helix/moderation/moderators"
|
||||
)
|
||||
.queryParam("broadcaster_id", broadcasterId)
|
||||
.queryParam("first", 100);
|
||||
if (cursor != null && !cursor.isBlank()) {
|
||||
@@ -115,23 +121,29 @@ public class TwitchUserLookupService {
|
||||
builder.build(true).toUri(),
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
TwitchModeratorsResponse.class);
|
||||
TwitchModeratorsResponse.class
|
||||
);
|
||||
|
||||
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) {
|
||||
body.data().stream()
|
||||
body
|
||||
.data()
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModeratorData::userLogin)
|
||||
.filter(Objects::nonNull)
|
||||
.map(login -> login.toLowerCase(Locale.ROOT))
|
||||
.filter(login -> !skipLogins.contains(login))
|
||||
.map((login) -> login.toLowerCase(Locale.ROOT))
|
||||
.filter((login) -> !skipLogins.contains(login))
|
||||
.forEach(moderatorLogins::add);
|
||||
}
|
||||
|
||||
cursor = body != null && body.pagination() != null
|
||||
? body.pagination().cursor()
|
||||
: null;
|
||||
cursor = body != null && body.pagination() != null ? body.pagination().cursor() : null;
|
||||
} catch (RestClientException ex) {
|
||||
LOG.warn("Unable to fetch Twitch moderators for {}", broadcasterLogin, ex);
|
||||
return List.of();
|
||||
@@ -158,9 +170,10 @@ public class TwitchUserLookupService {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
List<String> normalizedLogins = logins.stream()
|
||||
List<String> normalizedLogins = logins
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(login -> login.toLowerCase(Locale.ROOT))
|
||||
.map((login) -> login.toLowerCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
@@ -172,9 +185,8 @@ public class TwitchUserLookupService {
|
||||
headers.setBearerAuth(accessToken);
|
||||
headers.add("Client-ID", clientId);
|
||||
|
||||
UriComponentsBuilder uriBuilder = UriComponentsBuilder
|
||||
.fromHttpUrl("https://api.twitch.tv/helix/users");
|
||||
normalizedLogins.forEach(login -> uriBuilder.queryParam("login", login));
|
||||
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl("https://api.twitch.tv/helix/users");
|
||||
normalizedLogins.forEach((login) -> uriBuilder.queryParam("login", login));
|
||||
|
||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||
try {
|
||||
@@ -182,17 +194,24 @@ public class TwitchUserLookupService {
|
||||
uriBuilder.build(true).toUri(),
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
TwitchUsersResponse.class);
|
||||
TwitchUsersResponse.class
|
||||
);
|
||||
|
||||
return response.getBody() == null
|
||||
? Collections.emptyMap()
|
||||
: response.getBody().data().stream()
|
||||
: response
|
||||
.getBody()
|
||||
.data()
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toMap(
|
||||
user -> user.login().toLowerCase(Locale.ROOT),
|
||||
.collect(
|
||||
Collectors.toMap(
|
||||
(user) -> user.login().toLowerCase(Locale.ROOT),
|
||||
Function.identity(),
|
||||
(a, b) -> a,
|
||||
HashMap::new));
|
||||
HashMap::new
|
||||
)
|
||||
);
|
||||
} catch (RestClientException ex) {
|
||||
LOG.warn("Unable to fetch Twitch user profiles", ex);
|
||||
return Collections.emptyMap();
|
||||
@@ -200,31 +219,26 @@ public class TwitchUserLookupService {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record TwitchUsersResponse(List<TwitchUserData> data) {
|
||||
}
|
||||
private record TwitchUsersResponse(List<TwitchUserData> data) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record TwitchUserData(
|
||||
String id,
|
||||
String login,
|
||||
@JsonProperty("display_name") String displayName,
|
||||
@JsonProperty("profile_image_url") String profileImageUrl) {
|
||||
}
|
||||
@JsonProperty("profile_image_url") String profileImageUrl
|
||||
) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record TwitchModeratorsResponse(
|
||||
List<ModeratorData> data,
|
||||
Pagination pagination) {
|
||||
}
|
||||
private record TwitchModeratorsResponse(List<ModeratorData> data, Pagination pagination) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record ModeratorData(
|
||||
@JsonProperty("user_id") String userId,
|
||||
@JsonProperty("user_login") String userLogin,
|
||||
@JsonProperty("user_name") String userName) {
|
||||
}
|
||||
@JsonProperty("user_name") String userName
|
||||
) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record Pagination(String cursor) {
|
||||
}
|
||||
private record Pagination(String cursor) {}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
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.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class VersionService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(VersionService.class);
|
||||
private final String version;
|
||||
private final String releaseVersion;
|
||||
@@ -58,7 +58,9 @@ public class VersionService {
|
||||
}
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
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.IOException;
|
||||
import java.net.URLConnection;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Service
|
||||
public class MediaDetectionService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MediaDetectionService.class);
|
||||
private static final Map<String, String> EXTENSION_TYPES = Map.ofEntries(
|
||||
Map.entry("png", "image/png"),
|
||||
@@ -31,21 +31,21 @@ public class MediaDetectionService {
|
||||
private static final Set<String> ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values());
|
||||
|
||||
public Optional<String> detectAllowedMediaType(MultipartFile file, byte[] bytes) {
|
||||
Optional<String> detected = detectMediaType(bytes)
|
||||
.filter(MediaDetectionService::isAllowedMediaType);
|
||||
Optional<String> detected = detectMediaType(bytes).filter(MediaDetectionService::isAllowedMediaType);
|
||||
|
||||
if (detected.isPresent()) {
|
||||
return detected;
|
||||
}
|
||||
|
||||
Optional<String> declared = Optional.ofNullable(file.getContentType())
|
||||
.filter(MediaDetectionService::isAllowedMediaType);
|
||||
Optional<String> declared = Optional.ofNullable(file.getContentType()).filter(
|
||||
MediaDetectionService::isAllowedMediaType
|
||||
);
|
||||
if (declared.isPresent()) {
|
||||
return declared;
|
||||
}
|
||||
|
||||
return Optional.ofNullable(file.getOriginalFilename())
|
||||
.map(name -> name.replaceAll("^.*\\.", "").toLowerCase())
|
||||
.map((name) -> name.replaceAll("^.*\\.", "").toLowerCase())
|
||||
.map(EXTENSION_TYPES::get)
|
||||
.filter(MediaDetectionService::isAllowedMediaType);
|
||||
}
|
||||
@@ -68,6 +68,9 @@ public class MediaDetectionService {
|
||||
}
|
||||
|
||||
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/"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
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.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
@@ -24,9 +9,24 @@ import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
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
|
||||
public class MediaOptimizationService {
|
||||
|
||||
private static final int MIN_GIF_DELAY_MS = 20;
|
||||
private static final Logger logger = LoggerFactory.getLogger(MediaOptimizationService.class);
|
||||
private final MediaPreviewService previewService;
|
||||
@@ -86,8 +86,9 @@ public class MediaOptimizationService {
|
||||
if (frames.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int baseDelay = frames.stream()
|
||||
.mapToInt(frame -> normalizeDelay(frame.delayMs()))
|
||||
int baseDelay = frames
|
||||
.stream()
|
||||
.mapToInt((frame) -> normalizeDelay(frame.delayMs()))
|
||||
.reduce(this::greatestCommonDivisor)
|
||||
.orElse(100);
|
||||
int fps = Math.max(1, (int) Math.round(1000.0 / baseDelay));
|
||||
@@ -104,7 +105,13 @@ public class MediaOptimizationService {
|
||||
encoder.finish();
|
||||
BufferedImage cover = frames.get(0).image();
|
||||
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 {
|
||||
Files.deleteIfExists(temp.toPath());
|
||||
}
|
||||
@@ -183,8 +190,10 @@ public class MediaOptimizationService {
|
||||
}
|
||||
}
|
||||
ImageWriter writer = writers.next();
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
|
||||
try (
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageOutputStream ios = ImageIO.createImageOutputStream(baos)
|
||||
) {
|
||||
writer.setOutput(ios);
|
||||
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
if (param.canWriteCompressed()) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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.JCodecException;
|
||||
import org.jcodec.common.io.ByteBufferSeekableByteChannel;
|
||||
@@ -9,14 +14,9 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
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
|
||||
public class MediaPreviewService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MediaPreviewService.class);
|
||||
|
||||
public byte[] encodePreview(BufferedImage image) {
|
||||
|
||||
@@ -82,7 +82,9 @@ function createWindow() {
|
||||
});
|
||||
|
||||
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) =>
|
||||
handleNavigation(navigationUrl),
|
||||
);
|
||||
applicationWindow.on("closed", clearCanvasSizeInterval);
|
||||
}
|
||||
|
||||
|
||||
@@ -248,7 +248,10 @@ function recordDuration(assetId, seconds) {
|
||||
|
||||
function hasDuration(asset) {
|
||||
return (
|
||||
asset && Number.isFinite(asset.durationMs) && asset.durationMs > 0 && (isAudioAsset(asset) || isVideoAsset(asset))
|
||||
asset &&
|
||||
Number.isFinite(asset.durationMs) &&
|
||||
asset.durationMs > 0 &&
|
||||
(isAudioAsset(asset) || isVideoAsset(asset))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -435,7 +438,10 @@ function connect() {
|
||||
},
|
||||
(error) => {
|
||||
console.warn("WebSocket connection issue", error);
|
||||
setTimeout(() => showToast("Live updates connection interrupted. Retrying may be necessary.", "warning"), 1000);
|
||||
setTimeout(
|
||||
() => showToast("Live updates connection interrupted. Retrying may be necessary.", "warning"),
|
||||
1000,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -576,7 +582,11 @@ function applyPatch(assetId, patch) {
|
||||
clearMedia(assetId);
|
||||
loopPlaybackState.delete(assetId);
|
||||
}
|
||||
const targetLayer = Number.isFinite(patch.layer) ? patch.layer : Number.isFinite(patch.zIndex) ? patch.zIndex : null;
|
||||
const targetLayer = Number.isFinite(patch.layer)
|
||||
? patch.layer
|
||||
: Number.isFinite(patch.zIndex)
|
||||
? patch.zIndex
|
||||
: null;
|
||||
if (!isAudio && Number.isFinite(targetLayer)) {
|
||||
const currentOrder = getLayerOrder().filter((id) => id !== assetId);
|
||||
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
|
||||
@@ -842,7 +852,9 @@ function resizeFromHandle(state, point) {
|
||||
nextHeight = basis.height - (local.y - state.startLocal.y);
|
||||
}
|
||||
|
||||
const ratio = isAspectLocked(asset.id) ? getAssetAspectRatio(asset) || basis.width / Math.max(basis.height, 1) : null;
|
||||
const ratio = isAspectLocked(asset.id)
|
||||
? getAssetAspectRatio(asset) || basis.width / Math.max(basis.height, 1)
|
||||
: null;
|
||||
if (ratio) {
|
||||
const widthChanged = handle.includes("e") || handle.includes("w");
|
||||
const heightChanged = handle.includes("n") || handle.includes("s");
|
||||
|
||||
@@ -276,7 +276,11 @@ function applyPatch(assetId, patch) {
|
||||
hideAssetWithTransition(merged);
|
||||
return;
|
||||
}
|
||||
const targetLayer = Number.isFinite(patch.layer) ? patch.layer : Number.isFinite(patch.zIndex) ? patch.zIndex : null;
|
||||
const targetLayer = Number.isFinite(patch.layer)
|
||||
? patch.layer
|
||||
: Number.isFinite(patch.zIndex)
|
||||
? patch.zIndex
|
||||
: null;
|
||||
if (!isAudio && Number.isFinite(targetLayer)) {
|
||||
const currentOrder = getLayerOrder().filter((id) => id !== assetId);
|
||||
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
<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"
|
||||
<a
|
||||
class="button ghost"
|
||||
th:href="${'/view/' + broadcaster + '/broadcast'}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Broadcaster view</a
|
||||
>
|
||||
</div>
|
||||
@@ -65,7 +69,10 @@
|
||||
<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>
|
||||
<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.
|
||||
@@ -83,7 +90,13 @@
|
||||
<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" />
|
||||
<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>
|
||||
@@ -108,7 +121,9 @@
|
||||
<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>
|
||||
<span class="badge"
|
||||
>Layer <strong id="asset-z-level">1</strong></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,25 +239,60 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-actions compact unified-actions" id="asset-actions">
|
||||
<button type="button" onclick="sendToBack()" class="secondary" title="Send to back">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onclick="nudgeRotation(5)"
|
||||
class="secondary"
|
||||
title="Rotate right"
|
||||
>
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
<div class="hero-text">
|
||||
<p class="eyebrow">Overlay toolkit</p>
|
||||
<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">
|
||||
Customize your Twitch stream with audio, video and images updated by your mods in real-time
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
|
||||
</div>
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
<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.
|
||||
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>
|
||||
@@ -72,7 +72,9 @@
|
||||
<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>
|
||||
<p class="muted tiny">
|
||||
Tune the canvas and audio guardrails to keep overlays smooth and balanced.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,14 +114,18 @@
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-hint">Use the longest edge of your OBS browser source to prevent stretching.</p>
|
||||
<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>
|
||||
<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"
|
||||
@@ -157,7 +163,9 @@
|
||||
<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>
|
||||
<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"
|
||||
@@ -213,12 +221,16 @@
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-hint">Volume and pitch values are percentages of the original clip between 0 and 1.</p>
|
||||
<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>
|
||||
<button id="settings-submit-button" type="submit" class="button" disabled>
|
||||
Save settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -239,7 +251,8 @@
|
||||
<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.
|
||||
Changes here update every channel immediately. Save carefully and confirm with your
|
||||
team.
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
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.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.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.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.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-secret=test-client-secret"
|
||||
})
|
||||
"spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret",
|
||||
}
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
class ChannelApiIntegrationTest {
|
||||
|
||||
@@ -42,56 +42,91 @@ class ChannelApiIntegrationTest {
|
||||
@Test
|
||||
void broadcasterManagesAdminsAndAssets() throws Exception {
|
||||
String broadcaster = "caster";
|
||||
mockMvc.perform(post("/api/channels/{broadcaster}/admins", broadcaster)
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/channels/{broadcaster}/admins", broadcaster)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"helper\"}")
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
.with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)))
|
||||
)
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(get("/api/channels/{broadcaster}/admins", broadcaster)
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/channels/{broadcaster}/admins", broadcaster).with(
|
||||
oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
|
||||
)
|
||||
)
|
||||
.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());
|
||||
|
||||
String assetId = objectMapper.readTree(mockMvc.perform(multipart("/api/channels/{broadcaster}/assets", broadcaster)
|
||||
String assetId = objectMapper
|
||||
.readTree(
|
||||
mockMvc
|
||||
.perform(
|
||||
multipart("/api/channels/{broadcaster}/assets", broadcaster)
|
||||
.file(file)
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
.with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)))
|
||||
)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString()).get("id").asText();
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString()
|
||||
)
|
||||
.get("id")
|
||||
.asText();
|
||||
|
||||
mockMvc.perform(get("/api/channels/{broadcaster}/assets", broadcaster)
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/channels/{broadcaster}/assets", broadcaster).with(
|
||||
oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(1)));
|
||||
|
||||
VisibilityRequest visibilityRequest = new VisibilityRequest();
|
||||
visibilityRequest.setHidden(false);
|
||||
mockMvc.perform(put("/api/channels/{broadcaster}/assets/{id}/visibility", broadcaster, assetId)
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/channels/{broadcaster}/assets/{id}/visibility", broadcaster, assetId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(visibilityRequest))
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
.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)
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/channels/{broadcaster}/assets/visible", broadcaster).with(
|
||||
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)
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
mockMvc
|
||||
.perform(
|
||||
delete("/api/channels/{broadcaster}/assets/{id}", broadcaster, assetId).with(
|
||||
oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAdminChangesFromNonBroadcaster() throws Exception {
|
||||
mockMvc.perform(post("/api/channels/{broadcaster}/admins", "caster")
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/channels/{broadcaster}/admins", "caster")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"helper\"}")
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", "intruder"))))
|
||||
.with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", "intruder")))
|
||||
)
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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.repository.ChannelRepository;
|
||||
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.test.web.servlet.MockMvc;
|
||||
|
||||
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;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
@SpringBootTest(
|
||||
properties = {
|
||||
"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
|
||||
class ChannelDirectoryApiIntegrationTest {
|
||||
|
||||
@@ -38,7 +40,8 @@ class ChannelDirectoryApiIntegrationTest {
|
||||
channelRepository.save(new Channel("alpha"));
|
||||
channelRepository.save(new Channel("ALPINE"));
|
||||
|
||||
mockMvc.perform(get("/api/channels").param("q", "Al"))
|
||||
mockMvc
|
||||
.perform(get("/api/channels").param("q", "Al"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(2)))
|
||||
.andExpect(jsonPath("$[0]").value("alpha"))
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
package dev.kruhlmann.imgfloat;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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.AssetView;
|
||||
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.ChannelRepository;
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
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.MediaOptimizationService;
|
||||
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.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -33,19 +34,17 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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.when;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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;
|
||||
|
||||
class ChannelDirectoryServiceTest {
|
||||
|
||||
private ChannelDirectoryService service;
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
private ChannelRepository channelRepository;
|
||||
@@ -66,8 +65,15 @@ class ChannelDirectoryServiceTest {
|
||||
MediaPreviewService mediaPreviewService = new MediaPreviewService();
|
||||
MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService);
|
||||
MediaDetectionService mediaDetectionService = new MediaDetectionService();
|
||||
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate,
|
||||
assetStorageService, mediaDetectionService, mediaOptimizationService, settingsService);
|
||||
service = new ChannelDirectoryService(
|
||||
channelRepository,
|
||||
assetRepository,
|
||||
messagingTemplate,
|
||||
assetStorageService,
|
||||
mediaDetectionService,
|
||||
mediaOptimizationService,
|
||||
settingsService
|
||||
);
|
||||
ReflectionTestUtils.setField(service, "uploadLimitBytes", 5_000_000L);
|
||||
}
|
||||
|
||||
@@ -78,7 +84,10 @@ class ChannelDirectoryServiceTest {
|
||||
Optional<AssetView> created = service.createAsset("caster", file);
|
||||
assertThat(created).isPresent();
|
||||
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
|
||||
@@ -178,44 +187,56 @@ class ChannelDirectoryServiceTest {
|
||||
Map<String, Channel> channels = new ConcurrentHashMap<>();
|
||||
Map<String, Asset> assets = new ConcurrentHashMap<>();
|
||||
|
||||
when(channelRepository.findById(anyString()))
|
||||
.thenAnswer(invocation -> Optional.ofNullable(channels.get(invocation.getArgument(0))));
|
||||
when(channelRepository.save(any(Channel.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
when(channelRepository.findById(anyString())).thenAnswer((invocation) ->
|
||||
Optional.ofNullable(channels.get(invocation.getArgument(0)))
|
||||
);
|
||||
when(channelRepository.save(any(Channel.class))).thenAnswer((invocation) -> {
|
||||
Channel channel = invocation.getArgument(0);
|
||||
channels.put(channel.getBroadcaster(), channel);
|
||||
return channel;
|
||||
});
|
||||
when(channelRepository.findAll())
|
||||
.thenAnswer(invocation -> List.copyOf(channels.values()));
|
||||
when(channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(anyString()))
|
||||
.thenAnswer(invocation -> channels.values().stream()
|
||||
.filter(channel -> Optional.ofNullable(channel.getBroadcaster()).orElse("")
|
||||
.contains(Optional.ofNullable(invocation.getArgument(0, String.class)).orElse("").toLowerCase()))
|
||||
when(channelRepository.findAll()).thenAnswer((invocation) -> List.copyOf(channels.values()));
|
||||
when(channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(anyString())).thenAnswer(
|
||||
(invocation) ->
|
||||
channels
|
||||
.values()
|
||||
.stream()
|
||||
.filter((channel) ->
|
||||
Optional.ofNullable(channel.getBroadcaster())
|
||||
.orElse("")
|
||||
.contains(
|
||||
Optional.ofNullable(invocation.getArgument(0, String.class)).orElse("").toLowerCase()
|
||||
)
|
||||
)
|
||||
.sorted(Comparator.comparing(Channel::getBroadcaster))
|
||||
.limit(50)
|
||||
.toList());
|
||||
.toList()
|
||||
);
|
||||
|
||||
when(assetRepository.save(any(Asset.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
when(assetRepository.save(any(Asset.class))).thenAnswer((invocation) -> {
|
||||
Asset asset = invocation.getArgument(0);
|
||||
assets.put(asset.getId(), asset);
|
||||
return asset;
|
||||
});
|
||||
when(assetRepository.findById(anyString()))
|
||||
.thenAnswer(invocation -> Optional.ofNullable(assets.get(invocation.getArgument(0))));
|
||||
when(assetRepository.findByBroadcaster(anyString()))
|
||||
.thenAnswer(invocation -> filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), false));
|
||||
when(assetRepository.findByBroadcasterAndHiddenFalse(anyString()))
|
||||
.thenAnswer(invocation -> filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), true));
|
||||
doAnswer(invocation -> assets.remove(invocation.getArgument(0, Asset.class).getId()))
|
||||
.when(assetRepository).delete(any(Asset.class));
|
||||
when(assetRepository.findById(anyString())).thenAnswer((invocation) ->
|
||||
Optional.ofNullable(assets.get(invocation.getArgument(0)))
|
||||
);
|
||||
when(assetRepository.findByBroadcaster(anyString())).thenAnswer((invocation) ->
|
||||
filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), false)
|
||||
);
|
||||
when(assetRepository.findByBroadcasterAndHiddenFalse(anyString())).thenAnswer((invocation) ->
|
||||
filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), true)
|
||||
);
|
||||
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) {
|
||||
return assets.stream()
|
||||
.filter(asset -> asset.getBroadcaster().equalsIgnoreCase(broadcaster))
|
||||
.filter(asset -> !onlyVisible || !asset.isHidden())
|
||||
return assets
|
||||
.stream()
|
||||
.filter((asset) -> asset.getBroadcaster().equalsIgnoreCase(broadcaster))
|
||||
.filter((asset) -> !onlyVisible || !asset.isHidden())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.RequestEntity;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
@@ -12,8 +14,6 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResp
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TwitchAuthorizationCodeGrantRequestEntityConverterTest {
|
||||
|
||||
@Test
|
||||
@@ -43,8 +43,14 @@ class TwitchAuthorizationCodeGrantRequestEntityConverterTest {
|
||||
.state("state")
|
||||
.build();
|
||||
|
||||
OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse);
|
||||
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(registration, exchange);
|
||||
OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(
|
||||
authorizationRequest,
|
||||
authorizationResponse
|
||||
);
|
||||
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(
|
||||
registration,
|
||||
exchange
|
||||
);
|
||||
|
||||
var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter();
|
||||
RequestEntity<?> requestEntity = converter.convert(grantRequest);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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.springframework.http.HttpStatus;
|
||||
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.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 {
|
||||
|
||||
private final TwitchOAuth2ErrorResponseErrorHandler handler = new TwitchOAuth2ErrorResponseErrorHandler();
|
||||
@@ -27,12 +26,12 @@ class TwitchOAuth2ErrorResponseErrorHandlerTest {
|
||||
MockClientHttpResponse response = new MockClientHttpResponse(new byte[0], HttpStatus.BAD_REQUEST);
|
||||
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
OAuth2AuthorizationException exception = assertThrows(OAuth2AuthorizationException.class,
|
||||
() -> handler.handleError(response));
|
||||
OAuth2AuthorizationException exception = assertThrows(OAuth2AuthorizationException.class, () ->
|
||||
handler.handleError(response)
|
||||
);
|
||||
|
||||
assertThat(exception.getError().getErrorCode()).isEqualTo("invalid_token_response");
|
||||
assertThat(exception.getError().getDescription())
|
||||
.contains("Failed to parse Twitch OAuth error response");
|
||||
assertThat(exception.getError().getDescription()).contains("Failed to parse Twitch OAuth error response");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -41,13 +40,20 @@ class TwitchOAuth2ErrorResponseErrorHandlerTest {
|
||||
restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler());
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
|
||||
|
||||
server.expect(requestTo("https://id.twitch.tv/oauth2/token"))
|
||||
.andRespond(withSuccess(
|
||||
server
|
||||
.expect(requestTo("https://id.twitch.tv/oauth2/token"))
|
||||
.andRespond(
|
||||
withSuccess(
|
||||
"{\"access_token\":\"abc\",\"token_type\":\"bearer\",\"expires_in\":3600,\"scope\":[]}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
MediaType.APPLICATION_JSON
|
||||
)
|
||||
);
|
||||
|
||||
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.getBody()).isNotNull();
|
||||
|
||||
@@ -8,7 +8,6 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -34,21 +33,23 @@ class TwitchOAuth2UserServiceTest {
|
||||
RestTemplate restTemplate = TwitchOAuth2UserService.createRestTemplate(userRequest);
|
||||
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
|
||||
.expect(requestTo("https://api.twitch.tv/helix/users"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header("Client-ID", "client-123"))
|
||||
.andRespond(withSuccess(
|
||||
.andRespond(
|
||||
withSuccess(
|
||||
"{\"data\":[{\"id\":\"42\",\"login\":\"demo\",\"display_name\":\"Demo\"}]}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
MediaType.APPLICATION_JSON
|
||||
)
|
||||
);
|
||||
|
||||
OAuth2User user = service.loadUser(userRequest);
|
||||
|
||||
assertThat(user.getName()).isEqualTo("demo");
|
||||
assertThat(user.getAttributes())
|
||||
.containsEntry("id", "42")
|
||||
.containsEntry("display_name", "Demo");
|
||||
assertThat(user.getAttributes()).containsEntry("id", "42").containsEntry("display_name", "Demo");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@@ -58,7 +59,8 @@ class TwitchOAuth2UserServiceTest {
|
||||
"token",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(60),
|
||||
Set.of("user:read:email"));
|
||||
Set.of("user:read:email")
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
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.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 {
|
||||
|
||||
private AssetStorageService service;
|
||||
private Path assets;
|
||||
private Path previews;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
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 java.io.IOException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
class MediaDetectionServiceTest {
|
||||
|
||||
private final MediaDetectionService service = new MediaDetectionService();
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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 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 {
|
||||
|
||||
private MediaOptimizationService service;
|
||||
|
||||
@BeforeEach
|
||||
|
||||
Reference in New Issue
Block a user