mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Unify formatting
This commit is contained in:
@@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
|
||||
@EnableAsync
|
||||
@SpringBootApplication
|
||||
public class ImgfloatApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ImgfloatApplication.class, args);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepo
|
||||
public class OAuth2AuthorizedClientPersistenceConfig {
|
||||
|
||||
@Bean
|
||||
OAuth2AuthorizedClientService oauth2AuthorizedClientService(JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository) {
|
||||
OAuth2AuthorizedClientService oauth2AuthorizedClientService(
|
||||
JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository
|
||||
) {
|
||||
return new SQLiteOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.http.converter.FormHttpMessageConverter;
|
||||
@@ -10,14 +9,12 @@ import org.springframework.web.client.RestTemplate;
|
||||
|
||||
final class OAuth2RestTemplateFactory {
|
||||
|
||||
private OAuth2RestTemplateFactory() {
|
||||
}
|
||||
private OAuth2RestTemplateFactory() {}
|
||||
|
||||
static RestTemplate create() {
|
||||
RestTemplate restTemplate = new RestTemplate(Arrays.asList(
|
||||
new FormHttpMessageConverter(),
|
||||
new OAuth2AccessTokenResponseHttpMessageConverter()
|
||||
));
|
||||
RestTemplate restTemplate = new RestTemplate(
|
||||
Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter())
|
||||
);
|
||||
ClientHttpRequestFactory requestFactory = restTemplate.getRequestFactory();
|
||||
if (requestFactory instanceof SimpleClientHttpRequestFactory simple) {
|
||||
simple.setConnectTimeout(30_000);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.OAuthFlow;
|
||||
import io.swagger.v3.oas.models.security.OAuthFlows;
|
||||
@@ -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")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,31 +4,39 @@ import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
@Component
|
||||
public class SystemEnvironmentValidator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SystemEnvironmentValidator.class);
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.twitch.client-id:#{null}}")
|
||||
private String twitchClientId;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.twitch.client-secret:#{null}}")
|
||||
private String twitchClientSecret;
|
||||
|
||||
@Value("${spring.servlet.multipart.max-file-size:#{null}}")
|
||||
private String springMaxFileSize;
|
||||
|
||||
@Value("${spring.servlet.multipart.max-request-size:#{null}}")
|
||||
private String springMaxRequestSize;
|
||||
|
||||
@Value("${IMGFLOAT_ASSETS_PATH:#{null}}")
|
||||
private String assetsPath;
|
||||
|
||||
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}")
|
||||
private String previewsPath;
|
||||
|
||||
@Value("${IMGFLOAT_DB_PATH:#{null}}")
|
||||
private String dbPath;
|
||||
|
||||
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}")
|
||||
private String initialSysadmin;
|
||||
|
||||
@@ -41,7 +49,11 @@ public class SystemEnvironmentValidator {
|
||||
|
||||
@PostConstruct
|
||||
public void validate() {
|
||||
if (Boolean.parseBoolean(environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper"))) {
|
||||
if (
|
||||
Boolean.parseBoolean(
|
||||
environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper")
|
||||
)
|
||||
) {
|
||||
log.info("Skipping environment validation in test context");
|
||||
return;
|
||||
}
|
||||
@@ -60,9 +72,7 @@ public class SystemEnvironmentValidator {
|
||||
checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing);
|
||||
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"Missing or invalid environment variables:\n" + missing
|
||||
);
|
||||
throw new IllegalStateException("Missing or invalid environment variables:\n" + missing);
|
||||
}
|
||||
|
||||
log.info("Environment validation successful:");
|
||||
@@ -93,7 +103,7 @@ public class SystemEnvironmentValidator {
|
||||
private String redact(String value) {
|
||||
if (value != null && StringUtils.hasText(value)) {
|
||||
return "**************";
|
||||
};
|
||||
}
|
||||
return "<not set>";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package dev.kruhlmann.imgfloat.config;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.RequestEntity;
|
||||
@@ -19,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -35,6 +35,8 @@ public class UploadLimitsConfig {
|
||||
}
|
||||
|
||||
private boolean isTestContext() {
|
||||
return Boolean.parseBoolean(environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper"));
|
||||
return Boolean.parseBoolean(
|
||||
environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.AdminRequest;
|
||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
||||
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.TwitchUserLookupService;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -30,25 +40,14 @@ import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.io.IOException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/channels/{broadcaster}")
|
||||
@SecurityRequirement(name = "twitchOAuth")
|
||||
public class ChannelApiController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
|
||||
private final ChannelDirectoryService channelDirectoryService;
|
||||
private final OAuth2AuthorizedClientService authorizedClientService;
|
||||
@@ -71,9 +70,11 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PostMapping("/admins")
|
||||
public ResponseEntity<?> addAdmin(@PathVariable("broadcaster") String broadcaster,
|
||||
@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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import java.util.List;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/channels")
|
||||
public class ChannelDirectoryApiController {
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import jakarta.validation.Valid;
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -24,20 +32,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.io.IOException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/settings")
|
||||
@SecurityRequirement(name = "administrator")
|
||||
public class SettingsApiController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
|
||||
|
||||
private final SettingsService settingsService;
|
||||
@@ -49,7 +48,10 @@ public class SettingsApiController {
|
||||
}
|
||||
|
||||
@PutMapping("/set")
|
||||
public ResponseEntity<Settings> setSettings(@Valid @RequestBody Settings newSettings, OAuth2AuthenticationToken oauthToken) {
|
||||
public ResponseEntity<Settings> setSettings(
|
||||
@Valid @RequestBody Settings newSettings,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername);
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.VersionService;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.service.VersionService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -19,11 +21,9 @@ import org.springframework.ui.Model;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
@Controller
|
||||
public class ViewController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ViewController.class);
|
||||
private final ChannelDirectoryService channelDirectoryService;
|
||||
private final VersionService versionService;
|
||||
@@ -85,11 +85,16 @@ public class ViewController {
|
||||
}
|
||||
|
||||
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin")
|
||||
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||
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";
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.model;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class AdminRequest {
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
@@ -14,18 +13,25 @@ import java.util.UUID;
|
||||
@Entity
|
||||
@Table(name = "assets")
|
||||
public class Asset {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String broadcaster;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String url;
|
||||
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String preview;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
private double x;
|
||||
private double y;
|
||||
private double width;
|
||||
@@ -43,8 +49,7 @@ public class Asset {
|
||||
private Double audioVolume;
|
||||
private boolean hidden;
|
||||
|
||||
public Asset() {
|
||||
}
|
||||
public Asset() {}
|
||||
|
||||
public Asset(String broadcaster, String name, String url, double width, double height) {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
|
||||
@@ -4,12 +4,13 @@ import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AssetEvent {
|
||||
|
||||
public enum Type {
|
||||
CREATED,
|
||||
UPDATED,
|
||||
VISIBILITY,
|
||||
PLAY,
|
||||
DELETED
|
||||
DELETED,
|
||||
}
|
||||
|
||||
private Type type;
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@ package dev.kruhlmann.imgfloat.model;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
public class CanvasSettingsRequest {
|
||||
|
||||
@Positive
|
||||
private double width;
|
||||
|
||||
@Positive
|
||||
private double height;
|
||||
|
||||
public CanvasSettingsRequest() {
|
||||
}
|
||||
public CanvasSettingsRequest() {}
|
||||
|
||||
public CanvasSettingsRequest(double width, double height) {
|
||||
this.width = width;
|
||||
|
||||
@@ -10,7 +10,6 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
@@ -20,6 +19,7 @@ import java.util.stream.Collectors;
|
||||
@Entity
|
||||
@Table(name = "channels")
|
||||
public class Channel {
|
||||
|
||||
@Id
|
||||
private String broadcaster;
|
||||
|
||||
@@ -32,8 +32,7 @@ public class Channel {
|
||||
|
||||
private double canvasHeight = 1080;
|
||||
|
||||
public Channel() {
|
||||
}
|
||||
public Channel() {}
|
||||
|
||||
public Channel(String broadcaster) {
|
||||
this.broadcaster = normalize(broadcaster);
|
||||
@@ -77,9 +76,7 @@ public class Channel {
|
||||
@PreUpdate
|
||||
public void normalizeFields() {
|
||||
this.broadcaster = normalize(broadcaster);
|
||||
this.admins = admins.stream()
|
||||
.map(Channel::normalize)
|
||||
.collect(Collectors.toSet());
|
||||
this.admins = admins.stream().map(Channel::normalize).collect(Collectors.toSet());
|
||||
if (canvasWidth <= 0) {
|
||||
canvasWidth = 1920;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.model;
|
||||
|
||||
public class PlaybackRequest {
|
||||
|
||||
private Boolean play;
|
||||
|
||||
public Boolean getPlay() {
|
||||
|
||||
@@ -4,34 +4,42 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "settings")
|
||||
public class Settings {
|
||||
|
||||
@Id
|
||||
@Column(nullable = false)
|
||||
private int id = 1;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double minAssetPlaybackSpeedFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double maxAssetPlaybackSpeedFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double minAssetAudioPitchFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double maxAssetAudioPitchFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double minAssetVolumeFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double maxAssetVolumeFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int maxCanvasSideLengthPixels;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int canvasFramesPerSecond;
|
||||
|
||||
protected Settings() {
|
||||
}
|
||||
protected Settings() {}
|
||||
|
||||
public static Settings defaults() {
|
||||
Settings s = new Settings();
|
||||
@@ -117,5 +125,4 @@ public class Settings {
|
||||
public void setCanvasFramesPerSecond(int canvasFramesPerSecond) {
|
||||
this.canvasFramesPerSecond = canvasFramesPerSecond;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,27 +4,24 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "system_administrators",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username")
|
||||
)
|
||||
@Table(name = "system_administrators", uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username"))
|
||||
public class SystemAdministrator {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(name = "twitch_username", nullable = false)
|
||||
private String twitchUsername;
|
||||
|
||||
public SystemAdministrator() {
|
||||
}
|
||||
public SystemAdministrator() {}
|
||||
|
||||
public SystemAdministrator(String twitchUsername) {
|
||||
this.twitchUsername = twitchUsername;
|
||||
@@ -43,7 +40,6 @@ public class SystemAdministrator {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public String getTwitchUsername() {
|
||||
return twitchUsername;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
|
||||
public class TransformRequest {
|
||||
|
||||
private double x;
|
||||
private double y;
|
||||
|
||||
@@ -25,6 +26,7 @@ public class TransformRequest {
|
||||
|
||||
@Positive(message = "zIndex must be at least 1")
|
||||
private Integer zIndex;
|
||||
|
||||
private Boolean audioLoop;
|
||||
|
||||
@PositiveOrZero(message = "Audio delay must be zero or greater")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.model;
|
||||
|
||||
public class VisibilityRequest {
|
||||
|
||||
private boolean hidden;
|
||||
|
||||
public boolean isHidden() {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package dev.kruhlmann.imgfloat.repository;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AssetRepository extends JpaRepository<Asset, String> {
|
||||
List<Asset> findByBroadcaster(String broadcaster);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package dev.kruhlmann.imgfloat.repository;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Channel;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ChannelRepository extends JpaRepository<Channel, String> {
|
||||
List<Channel> findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(String broadcasterFragment);
|
||||
|
||||
@@ -3,5 +3,4 @@ package dev.kruhlmann.imgfloat.repository;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface SettingsRepository extends JpaRepository<Settings, Integer> {
|
||||
}
|
||||
public interface SettingsRepository extends JpaRepository<Settings, Integer> {}
|
||||
|
||||
@@ -2,35 +2,29 @@ package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class AssetCleanupService {
|
||||
|
||||
private static final Logger logger =
|
||||
LoggerFactory.getLogger(AssetCleanupService.class);
|
||||
private static final Logger logger = LoggerFactory.getLogger(AssetCleanupService.class);
|
||||
|
||||
private final AssetRepository assetRepository;
|
||||
private final AssetStorageService assetStorageService;
|
||||
|
||||
public AssetCleanupService(
|
||||
AssetRepository assetRepository,
|
||||
AssetStorageService assetStorageService
|
||||
) {
|
||||
public AssetCleanupService(AssetRepository assetRepository, AssetStorageService assetStorageService) {
|
||||
this.assetRepository = assetRepository;
|
||||
this.assetStorageService = assetStorageService;
|
||||
}
|
||||
@@ -41,10 +35,7 @@ public class AssetCleanupService {
|
||||
public void cleanup() {
|
||||
logger.info("Collecting referenced assets");
|
||||
|
||||
Set<String> referencedIds = assetRepository.findAll()
|
||||
.stream()
|
||||
.map(Asset::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> referencedIds = assetRepository.findAll().stream().map(Asset::getId).collect(Collectors.toSet());
|
||||
|
||||
assetStorageService.deleteOrphanedAssets(referencedIds);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Locale;
|
||||
@@ -14,9 +9,14 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AssetStorageService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class);
|
||||
private static final Map<String, String> EXTENSIONS = Map.ofEntries(
|
||||
Map.entry("image/png", ".png"),
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -12,6 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SettingsService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SettingsService.class);
|
||||
|
||||
private final SettingsRepository repo;
|
||||
@@ -44,12 +44,7 @@ public class SettingsService {
|
||||
|
||||
public void logSettings(String msg, Settings settings) {
|
||||
try {
|
||||
logger.info("{}:\n{}",
|
||||
msg,
|
||||
objectMapper
|
||||
.writerWithDefaultPrettyPrinter()
|
||||
.writeValueAsString(settings)
|
||||
);
|
||||
logger.info("{}:\n{}", msg, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(settings));
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("Failed to serialize settings", e);
|
||||
}
|
||||
|
||||
@@ -3,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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class VersionService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(VersionService.class);
|
||||
private final String version;
|
||||
private final String releaseVersion;
|
||||
@@ -58,7 +58,9 @@ public class VersionService {
|
||||
}
|
||||
|
||||
private String getPomVersion() {
|
||||
try (var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")) {
|
||||
try (
|
||||
var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")
|
||||
) {
|
||||
if (inputStream == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package dev.kruhlmann.imgfloat.service.media;
|
||||
|
||||
public record AssetContent(byte[] bytes, String mediaType) { }
|
||||
public record AssetContent(byte[] bytes, String mediaType) {}
|
||||
|
||||
@@ -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/"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
Reference in New Issue
Block a user