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