Unify formatting

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

View File

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

View File

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

View File

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

View File

@@ -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;
@@ -18,21 +18,26 @@ public class OpenApiConfig {
@Bean
public OpenAPI imgfloatOpenAPI() {
return new OpenAPI()
.components(new Components().addSecuritySchemes(TWITCH_OAUTH_SCHEME, twitchOAuthScheme()))
.addSecurityItem(new SecurityRequirement().addList(TWITCH_OAUTH_SCHEME))
.info(new Info()
.title("Imgfloat API")
.description("OpenAPI documentation for Imgfloat admin and broadcaster APIs.")
.version("v1"));
.components(new Components().addSecuritySchemes(TWITCH_OAUTH_SCHEME, twitchOAuthScheme()))
.addSecurityItem(new SecurityRequirement().addList(TWITCH_OAUTH_SCHEME))
.info(
new Info()
.title("Imgfloat API")
.description("OpenAPI documentation for Imgfloat admin and broadcaster APIs.")
.version("v1")
);
}
private SecurityScheme twitchOAuthScheme() {
return new SecurityScheme()
.name(TWITCH_OAUTH_SCHEME)
.type(SecurityScheme.Type.OAUTH2)
.flows(new OAuthFlows()
.authorizationCode(new OAuthFlow()
.authorizationUrl("https://id.twitch.tv/oauth2/authorize")
.tokenUrl("https://id.twitch.tv/oauth2/token")));
.name(TWITCH_OAUTH_SCHEME)
.type(SecurityScheme.Type.OAUTH2)
.flows(
new OAuthFlows().authorizationCode(
new OAuthFlow()
.authorizationUrl("https://id.twitch.tv/oauth2/authorize")
.tokenUrl("https://id.twitch.tv/oauth2/token")
)
);
}
}

View File

@@ -5,7 +5,8 @@ import java.time.Instant;
import java.util.Set;
import java.util.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) -> {
@@ -38,35 +42,37 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
}
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
rs.getString("access_token_value"),
toInstant(rs.getObject("access_token_issued_at")),
toInstant(rs.getObject("access_token_expires_at")),
scopesFrom(rs.getString("access_token_scopes"))
OAuth2AccessToken.TokenType.BEARER,
rs.getString("access_token_value"),
toInstant(rs.getObject("access_token_issued_at")),
toInstant(rs.getObject("access_token_expires_at")),
scopesFrom(rs.getString("access_token_scopes"))
);
Object refreshValue = rs.getObject("refresh_token_value");
OAuth2RefreshToken refreshToken = refreshValue == null
? null
: new OAuth2RefreshToken(
refreshValue.toString(),
toInstant(rs.getObject("refresh_token_issued_at"))
);
? null
: 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 -> {
ps.setString(1, clientRegistrationId);
ps.setString(2, principalName);
},
rs -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null
"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) -> {
ps.setString(1, clientRegistrationId);
ps.setString(2, principalName);
},
(rs) -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null
);
}
@@ -74,51 +80,60 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
try {
jdbcOperations.update("""
INSERT INTO oauth2_authorized_client (
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(client_registration_id, principal_name) DO UPDATE SET
access_token_value=excluded.access_token_value,
access_token_issued_at=excluded.access_token_issued_at,
access_token_expires_at=excluded.access_token_expires_at,
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.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.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes()));
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
if (refreshToken != null) {
preparedStatement.setString(7, refreshToken.getTokenValue());
preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT);
} else {
preparedStatement.setNull(7, java.sql.Types.VARCHAR);
preparedStatement.setNull(8, java.sql.Types.BIGINT);
}
});
INSERT INTO oauth2_authorized_client (
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(client_registration_id, principal_name) DO UPDATE SET
access_token_value=excluded.access_token_value,
access_token_issued_at=excluded.access_token_issued_at,
access_token_expires_at=excluded.access_token_expires_at,
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.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.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes()));
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
if (refreshToken != null) {
preparedStatement.setString(7, refreshToken.getTokenValue());
preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT);
} else {
preparedStatement.setNull(7, java.sql.Types.VARCHAR);
preparedStatement.setNull(8, java.sql.Types.BIGINT);
}
});
} catch (DataAccessException ex) {
LOG.error("Failed to save authorized client for registration ID '{}' and principal '{}'",
authorizedClient.getClientRegistration().getRegistrationId(),
principal.getName(), ex);
LOG.error(
"Failed to save authorized client for registration ID '{}' and principal '{}'",
authorizedClient.getClientRegistration().getRegistrationId(),
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 -> {
preparedStatement.setString(1, clientRegistrationId);
preparedStatement.setString(2, principalName);
});
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);
}
@@ -151,9 +166,9 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
return Set.of();
}
return Stream.of(scopeString.split(" "))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
.map(String::trim)
.filter((s) -> !s.isEmpty())
.collect(Collectors.toSet());
}
private String scopesToString(Set<String> scopes) {

View File

@@ -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);
@@ -136,13 +157,34 @@ public class SchemaMigration implements ApplicationRunner {
private void normalizeTimestampColumn(String column) {
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");
"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"
);
if (updated > 0) {
logger.info("Normalized {} rows in oauth2_authorized_client.{}", updated, column);
}

View File

@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.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,11 +22,14 @@ 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
.requestMatchers(
.authorizeHttpRequests((auth) ->
auth
.requestMatchers(
"/",
"/favicon.ico",
"/img/**",
@@ -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()
)
.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(
.oauth2Login((oauth) ->
oauth
.authorizedClientRepository(authorizedClientRepository)
.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();
}

View File

@@ -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>";
}
}

View File

@@ -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,11 +18,11 @@ 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();
new OAuth2AuthorizationCodeGrantRequestEntityConverter();
@Override
public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest request) {
@@ -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;
}
}

View File

@@ -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);
@@ -49,24 +50,25 @@ class TwitchClientRegistrationConfig {
}
private ClientRegistration buildTwitchRegistration(
String registrationId,
OAuth2ClientProperties.Registration registration,
OAuth2ClientProperties.Provider provider) {
String registrationId,
OAuth2ClientProperties.Registration registration,
OAuth2ClientProperties.Provider provider
) {
String clientId = sanitize(registration.getClientId(), "TWITCH_CLIENT_ID");
String clientSecret = sanitize(registration.getClientSecret(), "TWITCH_CLIENT_SECRET");
return ClientRegistration.withRegistrationId(registrationId)
.clientName(registration.getClientName())
.clientId(clientId)
.clientSecret(clientSecret)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(new AuthorizationGrantType(registration.getAuthorizationGrantType()))
.redirectUri(registration.getRedirectUri())
.scope(registration.getScope())
.authorizationUri(provider.getAuthorizationUri())
.tokenUri(provider.getTokenUri())
.userInfoUri(provider.getUserInfoUri())
.userNameAttributeName(provider.getUserNameAttribute())
.build();
.clientName(registration.getClientName())
.clientId(clientId)
.clientSecret(clientSecret)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.authorizationGrantType(new AuthorizationGrantType(registration.getAuthorizationGrantType()))
.redirectUri(registration.getRedirectUri())
.scope(registration.getScope())
.authorizationUri(provider.getAuthorizationUri())
.tokenUri(provider.getTokenUri())
.userInfoUri(provider.getUserInfoUri())
.userNameAttributeName(provider.getUserNameAttribute())
.build();
}
private String sanitize(String value, String name) {
@@ -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;

View File

@@ -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>",
response.getStatusCode(),
response.getHeaders());
LOG.warn(
"Failed to parse Twitch OAuth error response (status: {}, headers: {}): <empty body>",
response.getStatusCode(),
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: {}): {}",
response.getStatusCode(),
response.getHeaders(),
body,
ex);
LOG.warn(
"Failed to parse Twitch OAuth error response (status: {}, headers: {}): {}",
response.getStatusCode(),
response.getHeaders(),
body,
ex
);
throw asAuthorizationException(body, ex);
}
}

View File

@@ -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;
@@ -46,18 +45,19 @@ class TwitchOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OA
private OAuth2UserRequest twitchUserRequest(OAuth2UserRequest userRequest) {
return new OAuth2UserRequest(
twitchUserRegistration(userRequest),
userRequest.getAccessToken(),
userRequest.getAdditionalParameters());
twitchUserRegistration(userRequest),
userRequest.getAccessToken(),
userRequest.getAdditionalParameters()
);
}
private ClientRegistration twitchUserRegistration(OAuth2UserRequest userRequest) {
ClientRegistration registration = userRequest.getClientRegistration();
return ClientRegistration.withClientRegistration(registration)
// The Twitch response nests user details under a "data" array, so accept that
// shape for the initial parsing step.
.userNameAttributeName("data")
.build();
// The Twitch response nests user details under a "data" array, so accept that
// shape for the initial parsing step.
.userNameAttributeName("data")
.build();
}
@SuppressWarnings("unchecked")

View File

@@ -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")
);
}
}

View File

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

View File

@@ -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,
@Valid @RequestBody AdminRequest request,
OAuth2AuthenticationToken oauthToken) {
public ResponseEntity<?> addAdmin(
@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody AdminRequest request,
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,
OAuth2AuthenticationToken oauthToken,
HttpServletRequest request) {
public Collection<TwitchUserProfile> listAdmins(
@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken,
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())
.orElse(null);
.map(OAuth2AuthorizedClient::getAccessToken)
.map((token) -> token.getTokenValue())
.orElse(null);
String clientId = Optional.ofNullable(authorizedClient)
.map(OAuth2AuthorizedClient::getClientRegistration)
.map(registration -> registration.getClientId())
.orElse(null);
.map(OAuth2AuthorizedClient::getClientRegistration)
.map((registration) -> registration.getClientId())
.orElse(null);
return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId);
}
@GetMapping("/admins/suggestions")
public Collection<TwitchUserProfile> listAdminSuggestions(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken,
HttpServletRequest request) {
public Collection<TwitchUserProfile> listAdminSuggestions(
@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken,
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())
.orElse(null);
.map(OAuth2AuthorizedClient::getAccessToken)
.map((token) -> token.getTokenValue())
.orElse(null);
String clientId = Optional.ofNullable(authorizedClient)
.map(OAuth2AuthorizedClient::getClientRegistration)
.map(registration -> registration.getClientId())
.orElse(null);
.map(OAuth2AuthorizedClient::getClientRegistration)
.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,
@PathVariable("username") String username,
OAuth2AuthenticationToken oauthToken) {
public ResponseEntity<?> removeAdmin(
@PathVariable("broadcaster") String broadcaster,
@PathVariable("username") String username,
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.info("User {} removing admin {} from {}", sessionUsername, username, broadcaster);
@@ -163,30 +176,44 @@ public class ChannelApiController {
}
@PutMapping("/canvas")
public CanvasSettingsRequest updateCanvas(@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody CanvasSettingsRequest request,
OAuth2AuthenticationToken oauthToken) {
public CanvasSettingsRequest updateCanvas(
@PathVariable("broadcaster") String broadcaster,
@Valid @RequestBody CanvasSettingsRequest request,
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,
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
OAuth2AuthenticationToken oauthToken) {
public ResponseEntity<AssetView> createAsset(
@PathVariable("broadcaster") String broadcaster,
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
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)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
return channelDirectoryService
.createAsset(broadcaster, file)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
} catch (IOException e) {
LOG.error("Failed to process asset upload for {} by {}", broadcaster, sessionUsername, e);
throw new ResponseStatusException(BAD_REQUEST, "Failed to process image", e);
@@ -194,88 +221,130 @@ public class ChannelApiController {
}
@PutMapping("/assets/{assetId}/transform")
public ResponseEntity<AssetView> transform(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@Valid @RequestBody TransformRequest request,
OAuth2AuthenticationToken oauthToken) {
public ResponseEntity<AssetView> transform(
@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@Valid @RequestBody TransformRequest request,
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)
.map(ResponseEntity::ok)
.orElseThrow(() -> {
LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
return new ResponseStatusException(NOT_FOUND, "Asset not found");
});
return channelDirectoryService
.updateTransform(broadcaster, assetId, request)
.map(ResponseEntity::ok)
.orElseThrow(() -> {
LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
return new ResponseStatusException(NOT_FOUND, "Asset not found");
});
}
@PostMapping("/assets/{assetId}/play")
public ResponseEntity<AssetView> play(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@RequestBody(required = false) PlaybackRequest request,
OAuth2AuthenticationToken oauthToken) {
public ResponseEntity<AssetView> play(
@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@RequestBody(required = false) PlaybackRequest request,
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)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
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,
@PathVariable("assetId") String assetId,
@RequestBody VisibilityRequest request,
OAuth2AuthenticationToken oauthToken) {
public ResponseEntity<AssetView> visibility(
@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@RequestBody VisibilityRequest request,
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)
.map(ResponseEntity::ok)
.orElseThrow(() -> {
LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
return new ResponseStatusException(NOT_FOUND, "Asset not found");
});
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);
return new ResponseStatusException(NOT_FOUND, "Asset not found");
});
}
@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,
@PathVariable("assetId") String assetId,
OAuth2AuthenticationToken oauthToken) {
public ResponseEntity<?> delete(
@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
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,
OAuth2AuthorizedClient authorizedClient,
HttpServletRequest request) {
private OAuth2AuthorizedClient resolveAuthorizedClient(
OAuth2AuthenticationToken oauthToken,
OAuth2AuthorizedClient authorizedClient,
HttpServletRequest request
) {
if (authorizedClient != null) {
return authorizedClient;
}
@@ -295,12 +366,16 @@ public class ChannelApiController {
return null;
}
OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient(
oauthToken.getAuthorizedClientRegistrationId(),
oauthToken,
request);
oauthToken.getAuthorizedClientRegistrationId(),
oauthToken,
request
);
if (sessionClient != null) {
return sessionClient;
}
return authorizedClientService.loadAuthorizedClient(oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName());
return authorizedClientService.loadAuthorizedClient(
oauthToken.getAuthorizedClientRegistrationId(),
oauthToken.getName()
);
}
}

View File

@@ -1,13 +1,12 @@
package dev.kruhlmann.imgfloat.controller;
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 {

View File

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

View File

@@ -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,
OAuth2AuthenticationToken oauthToken,
Model model) {
public String adminView(
@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken oauthToken,
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";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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")

View File

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

View File

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

View File

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

View File

@@ -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> {}

View File

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

View File

@@ -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"),
@@ -42,15 +42,15 @@ public class AssetStorageService {
private final Path previewRoot;
public AssetStorageService(
@Value("${IMGFLOAT_ASSETS_PATH:#{null}}") String assetRoot,
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") String previewRoot
@Value("${IMGFLOAT_ASSETS_PATH:#{null}}") String assetRoot,
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") String previewRoot
) {
String assetsBase = assetRoot != null
? assetRoot
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-assets").toString();
? assetRoot
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-assets").toString();
String previewsBase = previewRoot != null
? previewRoot
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-previews").toString();
? previewRoot
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-previews").toString();
this.assetRoot = Paths.get(assetsBase).normalize().toAbsolutePath();
this.previewRoot = Paths.get(previewsBase).normalize().toAbsolutePath();
@@ -62,9 +62,7 @@ public class AssetStorageService {
}
}
public void storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType)
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,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
Files.write(
file,
assetBytes,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
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,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
Files.write(
file,
previewBytes,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
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,16 +156,17 @@ public class AssetStorageService {
return;
}
try (var paths = Files.walk(root)) {
paths.filter(Files::isRegularFile)
.filter(p -> isOrphan(p, referencedAssetIds))
.forEach(p -> {
try {
Files.delete(p);
logger.warn("Deleted orphan file {}", p);
} catch (IOException e) {
logger.error("Failed to delete {}", p, e);
}
});
paths
.filter(Files::isRegularFile)
.filter((p) -> isOrphan(p, referencedAssetIds))
.forEach((p) -> {
try {
Files.delete(p);
logger.warn("Deleted orphan file {}", p);
} catch (IOException e) {
logger.error("Failed to delete {}", p, e);
}
});
} catch (IOException e) {
logger.error("Failed to walk {}", root, e);
}

View File

@@ -1,30 +1,33 @@
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;
}
public void userMatchesSessionUsernameOrThrowHttpError(String submittedUsername, String sessionUsername) {
if (sessionUsername == null) {
LOG.warn("Access denied for broadcaster-only action by unauthenticated user");
@@ -35,14 +38,25 @@ public class AuthorizationService {
throw new ResponseStatusException(NOT_FOUND, "You can only manage your own channel");
}
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) {

View File

@@ -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,21 +67,18 @@ 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)
.stream()
.map(Channel::getBroadcaster)
.toList();
.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(q == null ? "" : q)
.stream()
.map(Channel::getBroadcaster)
.toList();
}
public boolean addAdmin(String broadcaster, String username) {
@@ -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) {
@@ -137,19 +129,15 @@ public class ChannelDirectoryService {
long maxSize = uploadLimitBytes;
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
)
PAYLOAD_TOO_LARGE,
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) {
@@ -157,32 +145,29 @@ public class ChannelDirectoryService {
}
String safeName = Optional.ofNullable(file.getOriginalFilename())
.map(this::sanitizeFilename)
.filter(s -> !s.isBlank())
.orElse("asset_" + System.currentTimeMillis());
.map(this::sanitizeFilename)
.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());
assetStorageService.storeAsset(
channel.getBroadcaster(),
asset.getId(),
optimized.bytes(),
optimized.mediaType()
channel.getBroadcaster(),
asset.getId(),
optimized.bytes(),
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,37 +195,37 @@ 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 -> {
AssetPatch.TransformSnapshot before = AssetPatch.capture(asset);
validateTransform(req);
return assetRepository
.findById(assetId)
.filter((asset) -> normalized.equals(asset.getBroadcaster()))
.map((asset) -> {
AssetPatch.TransformSnapshot before = AssetPatch.capture(asset);
validateTransform(req);
asset.setX(req.getX());
asset.setY(req.getY());
asset.setWidth(req.getWidth());
asset.setHeight(req.getHeight());
asset.setRotation(req.getRotation());
asset.setX(req.getX());
asset.setY(req.getY());
asset.setWidth(req.getWidth());
asset.setHeight(req.getHeight());
asset.setRotation(req.getRotation());
if (req.getZIndex() != null) asset.setZIndex(req.getZIndex());
if (req.getSpeed() != null) asset.setSpeed(req.getSpeed());
if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted());
if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop());
if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis());
if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed());
if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch());
if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume());
if (req.getZIndex() != null) asset.setZIndex(req.getZIndex());
if (req.getSpeed() != null) asset.setSpeed(req.getSpeed());
if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted());
if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop());
if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis());
if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed());
if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch());
if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume());
assetRepository.save(asset);
assetRepository.save(asset);
AssetView view = AssetView.from(normalized, asset);
AssetPatch patch = AssetPatch.fromTransform(before, asset, req);
if (hasPatchChanges(patch)) {
messagingTemplate.convertAndSend(topicFor(broadcaster),
AssetEvent.updated(broadcaster, patch));
}
return view;
});
AssetView view = AssetView.from(normalized, asset);
AssetPatch patch = AssetPatch.fromTransform(before, asset, req);
if (hasPatchChanges(patch)) {
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
}
return view;
});
}
private void validateTransform(TransformRequest req) {
@@ -254,68 +238,90 @@ 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 -> {
AssetView view = AssetView.from(normalized, asset);
boolean play = req == null || req.getPlay();
messagingTemplate.convertAndSend(topicFor(broadcaster),
AssetEvent.play(broadcaster, view, play));
return view;
});
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));
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 -> {
boolean wasHidden = asset.isHidden();
boolean hidden = request.isHidden();
if (wasHidden == hidden) {
return AssetView.from(normalized, asset);
}
return assetRepository
.findById(assetId)
.filter((a) -> normalized.equals(a.getBroadcaster()))
.map((asset) -> {
boolean wasHidden = asset.isHidden();
boolean hidden = request.isHidden();
if (wasHidden == hidden) {
return AssetView.from(normalized, asset);
}
asset.setHidden(hidden);
assetRepository.save(asset);
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));
return view;
});
asset.setHidden(hidden);
assetRepository.save(asset);
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)
);
return view;
});
}
public boolean deleteAsset(String assetId) {
return assetRepository.findById(assetId)
.map(asset -> {
assetRepository.delete(asset);
assetStorageService.deleteAsset(asset);
messagingTemplate.convertAndSend(topicFor(asset.getBroadcaster()),
AssetEvent.deleted(asset.getBroadcaster(), assetId));
return true;
})
.orElse(false);
return assetRepository
.findById(assetId)
.map((asset) -> {
assetRepository.delete(asset);
assetStorageService.deleteAsset(asset);
messagingTemplate.convertAndSend(
topicFor(asset.getBroadcaster()),
AssetEvent.deleted(asset.getBroadcaster(), assetId)
);
return true;
})
.orElse(false);
}
public Optional<AssetContent> getAssetContent(String assetId) {
@@ -323,25 +329,29 @@ public class ChannelDirectoryService {
}
public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
return assetRepository.findById(assetId)
.filter(a -> includeHidden || !a.isHidden())
.flatMap(assetStorageService::loadPreviewSafely);
return assetRepository
.findById(assetId)
.filter((a) -> includeHidden || !a.isHidden())
.flatMap(assetStorageService::loadPreviewSafely);
}
public boolean isAdmin(String broadcaster, String username) {
return channelRepository.findById(normalize(broadcaster))
.map(Channel::getAdmins)
.map(admins -> admins.contains(normalize(username)))
.orElse(false);
return channelRepository
.findById(normalize(broadcaster))
.map(Channel::getAdmins)
.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))
.map(Channel::getBroadcaster)
.toList();
return channelRepository
.findAll()
.stream()
.filter((c) -> c.getAdmins().contains(login))
.map(Channel::getBroadcaster)
.toList();
}
private String normalize(String value) {
@@ -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))
.toList();
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
);
}
}

View File

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

View File

@@ -3,29 +3,26 @@ 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;
private final Environment environment;
public SystemAdministratorService(
SystemAdministratorRepository repo,
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}")
String initialSysadmin,
Environment environment
SystemAdministratorRepository repo,
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") String initialSysadmin,
Environment environment
) {
this.repo = repo;
this.initialSysadmin = initialSysadmin;
@@ -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");
}
}

View File

@@ -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,31 +29,17 @@ 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;
public TwitchUserLookupService(RestTemplateBuilder builder) {
this.restTemplate = builder
.setConnectTimeout(Duration.ofSeconds(15))
.setReadTimeout(Duration.ofSeconds(15))
.build();
.setConnectTimeout(Duration.ofSeconds(15))
.setReadTimeout(Duration.ofSeconds(15))
.build();
}
public List<TwitchUserProfile> fetchProfiles(Collection<String> logins, String accessToken, String clientId) {
@@ -47,23 +47,27 @@ public class TwitchUserLookupService {
return List.of();
}
List<String> normalizedLogins = logins.stream()
.filter(Objects::nonNull)
.map(login -> login.toLowerCase(Locale.ROOT))
.distinct()
.toList();
List<String> normalizedLogins = logins
.stream()
.filter(Objects::nonNull)
.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)))
.toList();
return normalizedLogins
.stream()
.map((login) -> toProfile(login, byLogin.get(login)))
.toList();
}
public List<TwitchUserProfile> fetchModerators(String broadcasterLogin,
Collection<String> existingAdmins,
String accessToken,
String clientId) {
public List<TwitchUserProfile> fetchModerators(
String broadcasterLogin,
Collection<String> existingAdmins,
String accessToken,
String clientId
) {
if (broadcasterLogin == null || broadcasterLogin.isBlank()) {
LOG.warn("Cannot fetch moderators without a broadcaster login");
return List.of();
@@ -77,8 +81,8 @@ public class TwitchUserLookupService {
String normalizedBroadcaster = broadcasterLogin.toLowerCase(Locale.ROOT);
Map<String, TwitchUserData> broadcasterData = fetchUsers(List.of(normalizedBroadcaster), accessToken, clientId);
String broadcasterId = Optional.ofNullable(broadcasterData.get(normalizedBroadcaster))
.map(TwitchUserData::id)
.orElse(null);
.map(TwitchUserData::id)
.orElse(null);
if (broadcasterId == null || broadcasterId.isBlank()) {
LOG.warn("No broadcaster id found for {} when fetching moderators", broadcasterLogin);
@@ -87,10 +91,11 @@ public class TwitchUserLookupService {
Set<String> skipLogins = new HashSet<>();
if (existingAdmins != null) {
existingAdmins.stream()
.filter(Objects::nonNull)
.map(login -> login.toLowerCase(Locale.ROOT))
.forEach(skipLogins::add);
existingAdmins
.stream()
.filter(Objects::nonNull)
.map((login) -> login.toLowerCase(Locale.ROOT))
.forEach(skipLogins::add);
}
skipLogins.add(normalizedBroadcaster);
@@ -102,36 +107,43 @@ public class TwitchUserLookupService {
String cursor = null;
do {
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl("https://api.twitch.tv/helix/moderation/moderators")
.queryParam("broadcaster_id", broadcasterId)
.queryParam("first", 100);
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(
"https://api.twitch.tv/helix/moderation/moderators"
)
.queryParam("broadcaster_id", broadcasterId)
.queryParam("first", 100);
if (cursor != null && !cursor.isBlank()) {
builder.queryParam("after", cursor);
}
try {
ResponseEntity<TwitchModeratorsResponse> response = restTemplate.exchange(
builder.build(true).toUri(),
HttpMethod.GET,
new HttpEntity<>(headers),
TwitchModeratorsResponse.class);
builder.build(true).toUri(),
HttpMethod.GET,
new HttpEntity<>(headers),
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()
.filter(Objects::nonNull)
.map(ModeratorData::userLogin)
.filter(Objects::nonNull)
.map(login -> login.toLowerCase(Locale.ROOT))
.filter(login -> !skipLogins.contains(login))
.forEach(moderatorLogins::add);
body
.data()
.stream()
.filter(Objects::nonNull)
.map(ModeratorData::userLogin)
.filter(Objects::nonNull)
.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,11 +170,12 @@ public class TwitchUserLookupService {
return Collections.emptyMap();
}
List<String> normalizedLogins = logins.stream()
.filter(Objects::nonNull)
.map(login -> login.toLowerCase(Locale.ROOT))
.distinct()
.toList();
List<String> normalizedLogins = logins
.stream()
.filter(Objects::nonNull)
.map((login) -> login.toLowerCase(Locale.ROOT))
.distinct()
.toList();
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
return Collections.emptyMap();
@@ -172,27 +185,33 @@ 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 {
ResponseEntity<TwitchUsersResponse> response = restTemplate.exchange(
uriBuilder.build(true).toUri(),
HttpMethod.GET,
entity,
TwitchUsersResponse.class);
uriBuilder.build(true).toUri(),
HttpMethod.GET,
entity,
TwitchUsersResponse.class
);
return response.getBody() == null
? Collections.emptyMap()
: response.getBody().data().stream()
.filter(Objects::nonNull)
.collect(Collectors.toMap(
user -> user.login().toLowerCase(Locale.ROOT),
Function.identity(),
(a, b) -> a,
HashMap::new));
? Collections.emptyMap()
: response
.getBody()
.data()
.stream()
.filter(Objects::nonNull)
.collect(
Collectors.toMap(
(user) -> user.login().toLowerCase(Locale.ROOT),
Function.identity(),
(a, b) -> a,
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) {
}
String id,
String login,
@JsonProperty("display_name") String displayName,
@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_id") String userId,
@JsonProperty("user_login") String userLogin,
@JsonProperty("user_name") String userName
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
private record Pagination(String cursor) {
}
private record Pagination(String cursor) {}
}

View File

@@ -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;
}

View File

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

View File

@@ -1,53 +1,53 @@
package dev.kruhlmann.imgfloat.service.media;
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"),
Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"),
Map.entry("gif", "image/gif"),
Map.entry("webp", "image/webp"),
Map.entry("mp4", "video/mp4"),
Map.entry("webm", "video/webm"),
Map.entry("mov", "video/quicktime"),
Map.entry("mp3", "audio/mpeg"),
Map.entry("wav", "audio/wav"),
Map.entry("ogg", "audio/ogg")
Map.entry("png", "image/png"),
Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"),
Map.entry("gif", "image/gif"),
Map.entry("webp", "image/webp"),
Map.entry("mp4", "video/mp4"),
Map.entry("webm", "video/webm"),
Map.entry("mov", "video/quicktime"),
Map.entry("mp3", "audio/mpeg"),
Map.entry("wav", "audio/wav"),
Map.entry("ogg", "audio/ogg")
);
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(EXTENSION_TYPES::get)
.filter(MediaDetectionService::isAllowedMediaType);
.map((name) -> name.replaceAll("^.*\\.", "").toLowerCase())
.map(EXTENSION_TYPES::get)
.filter(MediaDetectionService::isAllowedMediaType);
}
private Optional<String> detectMediaType(byte[] bytes) {
@@ -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/"))
);
}
}

View File

@@ -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,10 +86,11 @@ public class MediaOptimizationService {
if (frames.isEmpty()) {
return null;
}
int baseDelay = frames.stream()
.mapToInt(frame -> normalizeDelay(frame.delayMs()))
.reduce(this::greatestCommonDivisor)
.orElse(100);
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));
File temp = File.createTempFile("gif-convert", ".mp4");
temp.deleteOnExit();
@@ -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()) {
@@ -211,7 +220,7 @@ public class MediaOptimizationService {
return new Dimension(640, 360);
}
private record GifFrame(BufferedImage image, int delayMs) { }
private record GifFrame(BufferedImage image, int delayMs) {}
private record Dimension(int width, int height) { }
private record Dimension(int width, int height) {}
}

View File

@@ -1,5 +1,10 @@
package dev.kruhlmann.imgfloat.service.media;
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) {

View File

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