mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Unify formatting
This commit is contained in:
@@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
|
||||
@EnableAsync
|
||||
@SpringBootApplication
|
||||
public class ImgfloatApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ImgfloatApplication.class, args);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepo
|
||||
public class OAuth2AuthorizedClientPersistenceConfig {
|
||||
|
||||
@Bean
|
||||
OAuth2AuthorizedClientService oauth2AuthorizedClientService(JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository) {
|
||||
OAuth2AuthorizedClientService oauth2AuthorizedClientService(
|
||||
JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository
|
||||
) {
|
||||
return new SQLiteOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.http.converter.FormHttpMessageConverter;
|
||||
@@ -10,14 +9,12 @@ import org.springframework.web.client.RestTemplate;
|
||||
|
||||
final class OAuth2RestTemplateFactory {
|
||||
|
||||
private OAuth2RestTemplateFactory() {
|
||||
}
|
||||
private OAuth2RestTemplateFactory() {}
|
||||
|
||||
static RestTemplate create() {
|
||||
RestTemplate restTemplate = new RestTemplate(Arrays.asList(
|
||||
new FormHttpMessageConverter(),
|
||||
new OAuth2AccessTokenResponseHttpMessageConverter()
|
||||
));
|
||||
RestTemplate restTemplate = new RestTemplate(
|
||||
Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter())
|
||||
);
|
||||
ClientHttpRequestFactory requestFactory = restTemplate.getRequestFactory();
|
||||
if (requestFactory instanceof SimpleClientHttpRequestFactory simple) {
|
||||
simple.setConnectTimeout(30_000);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.OAuthFlow;
|
||||
import io.swagger.v3.oas.models.security.OAuthFlows;
|
||||
@@ -18,21 +18,26 @@ public class OpenApiConfig {
|
||||
@Bean
|
||||
public OpenAPI imgfloatOpenAPI() {
|
||||
return new OpenAPI()
|
||||
.components(new Components().addSecuritySchemes(TWITCH_OAUTH_SCHEME, twitchOAuthScheme()))
|
||||
.addSecurityItem(new SecurityRequirement().addList(TWITCH_OAUTH_SCHEME))
|
||||
.info(new Info()
|
||||
.title("Imgfloat API")
|
||||
.description("OpenAPI documentation for Imgfloat admin and broadcaster APIs.")
|
||||
.version("v1"));
|
||||
.components(new Components().addSecuritySchemes(TWITCH_OAUTH_SCHEME, twitchOAuthScheme()))
|
||||
.addSecurityItem(new SecurityRequirement().addList(TWITCH_OAUTH_SCHEME))
|
||||
.info(
|
||||
new Info()
|
||||
.title("Imgfloat API")
|
||||
.description("OpenAPI documentation for Imgfloat admin and broadcaster APIs.")
|
||||
.version("v1")
|
||||
);
|
||||
}
|
||||
|
||||
private SecurityScheme twitchOAuthScheme() {
|
||||
return new SecurityScheme()
|
||||
.name(TWITCH_OAUTH_SCHEME)
|
||||
.type(SecurityScheme.Type.OAUTH2)
|
||||
.flows(new OAuthFlows()
|
||||
.authorizationCode(new OAuthFlow()
|
||||
.authorizationUrl("https://id.twitch.tv/oauth2/authorize")
|
||||
.tokenUrl("https://id.twitch.tv/oauth2/token")));
|
||||
.name(TWITCH_OAUTH_SCHEME)
|
||||
.type(SecurityScheme.Type.OAUTH2)
|
||||
.flows(
|
||||
new OAuthFlows().authorizationCode(
|
||||
new OAuthFlow()
|
||||
.authorizationUrl("https://id.twitch.tv/oauth2/authorize")
|
||||
.tokenUrl("https://id.twitch.tv/oauth2/token")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import java.time.Instant;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcOperations;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
@@ -18,6 +19,7 @@ import org.springframework.security.oauth2.core.OAuth2AccessToken;
|
||||
import org.springframework.security.oauth2.core.OAuth2RefreshToken;
|
||||
|
||||
public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClientService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SQLiteOAuth2AuthorizedClientService.class);
|
||||
private static final String TABLE_NAME = "oauth2_authorized_client";
|
||||
|
||||
@@ -25,8 +27,10 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
private final ClientRegistrationRepository clientRegistrationRepository;
|
||||
private final RowMapper<OAuth2AuthorizedClient> rowMapper;
|
||||
|
||||
public SQLiteOAuth2AuthorizedClientService(JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository) {
|
||||
public SQLiteOAuth2AuthorizedClientService(
|
||||
JdbcOperations jdbcOperations,
|
||||
ClientRegistrationRepository clientRegistrationRepository
|
||||
) {
|
||||
this.jdbcOperations = jdbcOperations;
|
||||
this.clientRegistrationRepository = clientRegistrationRepository;
|
||||
this.rowMapper = (rs, rowNum) -> {
|
||||
@@ -38,35 +42,37 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
}
|
||||
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
rs.getString("access_token_value"),
|
||||
toInstant(rs.getObject("access_token_issued_at")),
|
||||
toInstant(rs.getObject("access_token_expires_at")),
|
||||
scopesFrom(rs.getString("access_token_scopes"))
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
rs.getString("access_token_value"),
|
||||
toInstant(rs.getObject("access_token_issued_at")),
|
||||
toInstant(rs.getObject("access_token_expires_at")),
|
||||
scopesFrom(rs.getString("access_token_scopes"))
|
||||
);
|
||||
|
||||
Object refreshValue = rs.getObject("refresh_token_value");
|
||||
OAuth2RefreshToken refreshToken = refreshValue == null
|
||||
? null
|
||||
: new OAuth2RefreshToken(
|
||||
refreshValue.toString(),
|
||||
toInstant(rs.getObject("refresh_token_issued_at"))
|
||||
);
|
||||
? null
|
||||
: new OAuth2RefreshToken(refreshValue.toString(), toInstant(rs.getObject("refresh_token_issued_at")));
|
||||
|
||||
return new OAuth2AuthorizedClient(registration, principalName, accessToken, refreshToken);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(String clientRegistrationId, String principalName) {
|
||||
public <T extends OAuth2AuthorizedClient> T loadAuthorizedClient(
|
||||
String clientRegistrationId,
|
||||
String principalName
|
||||
) {
|
||||
return jdbcOperations.query(
|
||||
"SELECT client_registration_id, principal_name, access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, refresh_token_value, refresh_token_issued_at " +
|
||||
"FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?",
|
||||
ps -> {
|
||||
ps.setString(1, clientRegistrationId);
|
||||
ps.setString(2, principalName);
|
||||
},
|
||||
rs -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null
|
||||
"SELECT client_registration_id, principal_name, access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes, refresh_token_value, refresh_token_issued_at " +
|
||||
"FROM " +
|
||||
TABLE_NAME +
|
||||
" WHERE client_registration_id = ? AND principal_name = ?",
|
||||
(ps) -> {
|
||||
ps.setString(1, clientRegistrationId);
|
||||
ps.setString(2, principalName);
|
||||
},
|
||||
(rs) -> rs.next() ? (T) rowMapper.mapRow(rs, 0) : null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,51 +80,60 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
public void saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) {
|
||||
try {
|
||||
jdbcOperations.update("""
|
||||
INSERT INTO oauth2_authorized_client (
|
||||
client_registration_id, principal_name,
|
||||
access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes,
|
||||
refresh_token_value, refresh_token_issued_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(client_registration_id, principal_name) DO UPDATE SET
|
||||
access_token_value=excluded.access_token_value,
|
||||
access_token_issued_at=excluded.access_token_issued_at,
|
||||
access_token_expires_at=excluded.access_token_expires_at,
|
||||
access_token_scopes=excluded.access_token_scopes,
|
||||
refresh_token_value=excluded.refresh_token_value,
|
||||
refresh_token_issued_at=excluded.refresh_token_issued_at
|
||||
""",
|
||||
preparedStatement -> {
|
||||
preparedStatement.setString(1, authorizedClient.getClientRegistration().getRegistrationId());
|
||||
preparedStatement.setString(2, principal.getName());
|
||||
setToken(preparedStatement, 3, authorizedClient.getAccessToken());
|
||||
preparedStatement.setObject(5, toEpochMillis(authorizedClient.getAccessToken().getExpiresAt()), java.sql.Types.BIGINT);
|
||||
preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes()));
|
||||
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
|
||||
if (refreshToken != null) {
|
||||
preparedStatement.setString(7, refreshToken.getTokenValue());
|
||||
preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT);
|
||||
} else {
|
||||
preparedStatement.setNull(7, java.sql.Types.VARCHAR);
|
||||
preparedStatement.setNull(8, java.sql.Types.BIGINT);
|
||||
}
|
||||
});
|
||||
INSERT INTO oauth2_authorized_client (
|
||||
client_registration_id, principal_name,
|
||||
access_token_value, access_token_issued_at, access_token_expires_at, access_token_scopes,
|
||||
refresh_token_value, refresh_token_issued_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(client_registration_id, principal_name) DO UPDATE SET
|
||||
access_token_value=excluded.access_token_value,
|
||||
access_token_issued_at=excluded.access_token_issued_at,
|
||||
access_token_expires_at=excluded.access_token_expires_at,
|
||||
access_token_scopes=excluded.access_token_scopes,
|
||||
refresh_token_value=excluded.refresh_token_value,
|
||||
refresh_token_issued_at=excluded.refresh_token_issued_at
|
||||
""", (preparedStatement) -> {
|
||||
preparedStatement.setString(1, authorizedClient.getClientRegistration().getRegistrationId());
|
||||
preparedStatement.setString(2, principal.getName());
|
||||
setToken(preparedStatement, 3, authorizedClient.getAccessToken());
|
||||
preparedStatement.setObject(
|
||||
5,
|
||||
toEpochMillis(authorizedClient.getAccessToken().getExpiresAt()),
|
||||
java.sql.Types.BIGINT
|
||||
);
|
||||
preparedStatement.setString(6, scopesToString(authorizedClient.getAccessToken().getScopes()));
|
||||
OAuth2RefreshToken refreshToken = authorizedClient.getRefreshToken();
|
||||
if (refreshToken != null) {
|
||||
preparedStatement.setString(7, refreshToken.getTokenValue());
|
||||
preparedStatement.setObject(8, toEpochMillis(refreshToken.getIssuedAt()), java.sql.Types.BIGINT);
|
||||
} else {
|
||||
preparedStatement.setNull(7, java.sql.Types.VARCHAR);
|
||||
preparedStatement.setNull(8, java.sql.Types.BIGINT);
|
||||
}
|
||||
});
|
||||
} catch (DataAccessException ex) {
|
||||
LOG.error("Failed to save authorized client for registration ID '{}' and principal '{}'",
|
||||
authorizedClient.getClientRegistration().getRegistrationId(),
|
||||
principal.getName(), ex);
|
||||
LOG.error(
|
||||
"Failed to save authorized client for registration ID '{}' and principal '{}'",
|
||||
authorizedClient.getClientRegistration().getRegistrationId(),
|
||||
principal.getName(),
|
||||
ex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAuthorizedClient(String clientRegistrationId, String principalName) {
|
||||
jdbcOperations.update("DELETE FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?",
|
||||
preparedStatement -> {
|
||||
preparedStatement.setString(1, clientRegistrationId);
|
||||
preparedStatement.setString(2, principalName);
|
||||
});
|
||||
jdbcOperations.update(
|
||||
"DELETE FROM " + TABLE_NAME + " WHERE client_registration_id = ? AND principal_name = ?",
|
||||
(preparedStatement) -> {
|
||||
preparedStatement.setString(1, clientRegistrationId);
|
||||
preparedStatement.setString(2, principalName);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private void setToken(java.sql.PreparedStatement ps, int startIndex, OAuth2AccessToken token) throws java.sql.SQLException {
|
||||
private void setToken(java.sql.PreparedStatement ps, int startIndex, OAuth2AccessToken token)
|
||||
throws java.sql.SQLException {
|
||||
ps.setString(startIndex, token.getTokenValue());
|
||||
ps.setObject(startIndex + 1, toEpochMillis(token.getIssuedAt()), java.sql.Types.BIGINT);
|
||||
}
|
||||
@@ -151,9 +166,9 @@ public class SQLiteOAuth2AuthorizedClientService implements OAuth2AuthorizedClie
|
||||
return Set.of();
|
||||
}
|
||||
return Stream.of(scopeString.split(" "))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
.map(String::trim)
|
||||
.filter((s) -> !s.isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private String scopesToString(Set<String> scopes) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import java.util.List;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
@@ -8,8 +9,6 @@ import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class SchemaMigration implements ApplicationRunner {
|
||||
|
||||
@@ -32,7 +31,8 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
|
||||
private void ensureSessionAttributeUpsertTrigger() {
|
||||
try {
|
||||
jdbcTemplate.execute("""
|
||||
jdbcTemplate.execute(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS SPRING_SESSION_ATTRIBUTES_UPSERT
|
||||
BEFORE INSERT ON SPRING_SESSION_ATTRIBUTES
|
||||
FOR EACH ROW
|
||||
@@ -41,7 +41,8 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
WHERE SESSION_PRIMARY_ID = NEW.SESSION_PRIMARY_ID
|
||||
AND ATTRIBUTE_NAME = NEW.ATTRIBUTE_NAME;
|
||||
END;
|
||||
""");
|
||||
"""
|
||||
);
|
||||
logger.info("Ensured SPRING_SESSION_ATTRIBUTES upsert trigger exists");
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Unable to ensure SPRING_SESSION_ATTRIBUTES upsert trigger", ex);
|
||||
@@ -91,14 +92,32 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
addColumnIfMissing(table, columns, "preview", "TEXT", "NULL");
|
||||
}
|
||||
|
||||
private void addColumnIfMissing(String tableName, List<String> existingColumns, String columnName, String dataType, String defaultValue) {
|
||||
private void addColumnIfMissing(
|
||||
String tableName,
|
||||
List<String> existingColumns,
|
||||
String columnName,
|
||||
String dataType,
|
||||
String defaultValue
|
||||
) {
|
||||
if (existingColumns.contains(columnName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
jdbcTemplate.execute("ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue);
|
||||
jdbcTemplate.execute("UPDATE " + tableName + " SET " + columnName + " = " + defaultValue + " WHERE " + columnName + " IS NULL");
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + dataType + " DEFAULT " + defaultValue
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"UPDATE " +
|
||||
tableName +
|
||||
" SET " +
|
||||
columnName +
|
||||
" = " +
|
||||
defaultValue +
|
||||
" WHERE " +
|
||||
columnName +
|
||||
" IS NULL"
|
||||
);
|
||||
logger.info("Added missing column '{}' to {} table", columnName, tableName);
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Failed to add column '{}' to {} table", columnName, tableName, ex);
|
||||
@@ -107,7 +126,8 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
|
||||
private void ensureAuthorizedClientTable() {
|
||||
try {
|
||||
jdbcTemplate.execute("""
|
||||
jdbcTemplate.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oauth2_authorized_client (
|
||||
client_registration_id VARCHAR(100) NOT NULL,
|
||||
principal_name VARCHAR(200) NOT NULL,
|
||||
@@ -120,7 +140,8 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
refresh_token_issued_at INTEGER,
|
||||
PRIMARY KEY (client_registration_id, principal_name)
|
||||
)
|
||||
""");
|
||||
"""
|
||||
);
|
||||
logger.info("Ensured oauth2_authorized_client table exists");
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Unable to ensure oauth2_authorized_client table", ex);
|
||||
@@ -136,13 +157,34 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
private void normalizeTimestampColumn(String column) {
|
||||
try {
|
||||
int updated = jdbcTemplate.update(
|
||||
"UPDATE oauth2_authorized_client " +
|
||||
"SET " + column + " = CASE " +
|
||||
"WHEN " + column + " LIKE '%-%' THEN CAST(strftime('%s', " + column + ") AS INTEGER) * 1000 " +
|
||||
"WHEN typeof(" + column + ") = 'text' AND " + column + " GLOB '[0-9]*' THEN CAST(" + column + " AS INTEGER) " +
|
||||
"WHEN typeof(" + column + ") = 'integer' THEN " + column + " " +
|
||||
"ELSE " + column + " END " +
|
||||
"WHERE " + column + " IS NOT NULL");
|
||||
"UPDATE oauth2_authorized_client " +
|
||||
"SET " +
|
||||
column +
|
||||
" = CASE " +
|
||||
"WHEN " +
|
||||
column +
|
||||
" LIKE '%-%' THEN CAST(strftime('%s', " +
|
||||
column +
|
||||
") AS INTEGER) * 1000 " +
|
||||
"WHEN typeof(" +
|
||||
column +
|
||||
") = 'text' AND " +
|
||||
column +
|
||||
" GLOB '[0-9]*' THEN CAST(" +
|
||||
column +
|
||||
" AS INTEGER) " +
|
||||
"WHEN typeof(" +
|
||||
column +
|
||||
") = 'integer' THEN " +
|
||||
column +
|
||||
" " +
|
||||
"ELSE " +
|
||||
column +
|
||||
" END " +
|
||||
"WHERE " +
|
||||
column +
|
||||
" IS NOT NULL"
|
||||
);
|
||||
if (updated > 0) {
|
||||
logger.info("Normalized {} rows in oauth2_authorized_client.{}", updated, column);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.config;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
@@ -14,7 +15,6 @@ import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@@ -22,11 +22,14 @@ import org.springframework.http.HttpStatus;
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain securityFilterChain(HttpSecurity http,
|
||||
OAuth2AuthorizedClientRepository authorizedClientRepository) throws Exception {
|
||||
SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http,
|
||||
OAuth2AuthorizedClientRepository authorizedClientRepository
|
||||
) throws Exception {
|
||||
http
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(
|
||||
.authorizeHttpRequests((auth) ->
|
||||
auth
|
||||
.requestMatchers(
|
||||
"/",
|
||||
"/favicon.ico",
|
||||
"/img/**",
|
||||
@@ -38,26 +41,37 @@ public class SecurityConfig {
|
||||
"/swagger-ui.html",
|
||||
"/swagger-ui/**",
|
||||
"/channels"
|
||||
).permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/view/*/broadcast").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/visible").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content").permitAll()
|
||||
.requestMatchers("/ws/**").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/view/*/broadcast")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/visible")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas")
|
||||
.permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content")
|
||||
.permitAll()
|
||||
.requestMatchers("/ws/**")
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated()
|
||||
)
|
||||
.oauth2Login(oauth -> oauth
|
||||
.authorizedClientRepository(authorizedClientRepository)
|
||||
.tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient()))
|
||||
.userInfoEndpoint(user -> user.userService(twitchOAuth2UserService())))
|
||||
.logout(logout -> logout.logoutSuccessUrl("/").permitAll())
|
||||
.exceptionHandling(exceptions -> exceptions
|
||||
.defaultAuthenticationEntryPointFor(
|
||||
.oauth2Login((oauth) ->
|
||||
oauth
|
||||
.authorizedClientRepository(authorizedClientRepository)
|
||||
.tokenEndpoint((token) -> token.accessTokenResponseClient(twitchAccessTokenResponseClient()))
|
||||
.userInfoEndpoint((user) -> user.userService(twitchOAuth2UserService()))
|
||||
)
|
||||
.logout((logout) -> logout.logoutSuccessUrl("/").permitAll())
|
||||
.exceptionHandling((exceptions) ->
|
||||
exceptions.defaultAuthenticationEntryPointFor(
|
||||
new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
|
||||
new AntPathRequestMatcher("/api/**")
|
||||
))
|
||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**"));
|
||||
)
|
||||
)
|
||||
.csrf((csrf) -> csrf.ignoringRequestMatchers("/ws/**", "/api/**"));
|
||||
return http.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,31 +4,39 @@ import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
@Component
|
||||
public class SystemEnvironmentValidator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SystemEnvironmentValidator.class);
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.twitch.client-id:#{null}}")
|
||||
private String twitchClientId;
|
||||
|
||||
@Value("${spring.security.oauth2.client.registration.twitch.client-secret:#{null}}")
|
||||
private String twitchClientSecret;
|
||||
|
||||
@Value("${spring.servlet.multipart.max-file-size:#{null}}")
|
||||
private String springMaxFileSize;
|
||||
|
||||
@Value("${spring.servlet.multipart.max-request-size:#{null}}")
|
||||
private String springMaxRequestSize;
|
||||
|
||||
@Value("${IMGFLOAT_ASSETS_PATH:#{null}}")
|
||||
private String assetsPath;
|
||||
|
||||
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}")
|
||||
private String previewsPath;
|
||||
|
||||
@Value("${IMGFLOAT_DB_PATH:#{null}}")
|
||||
private String dbPath;
|
||||
|
||||
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}")
|
||||
private String initialSysadmin;
|
||||
|
||||
@@ -41,7 +49,11 @@ public class SystemEnvironmentValidator {
|
||||
|
||||
@PostConstruct
|
||||
public void validate() {
|
||||
if (Boolean.parseBoolean(environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper"))) {
|
||||
if (
|
||||
Boolean.parseBoolean(
|
||||
environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper")
|
||||
)
|
||||
) {
|
||||
log.info("Skipping environment validation in test context");
|
||||
return;
|
||||
}
|
||||
@@ -60,9 +72,7 @@ public class SystemEnvironmentValidator {
|
||||
checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing);
|
||||
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"Missing or invalid environment variables:\n" + missing
|
||||
);
|
||||
throw new IllegalStateException("Missing or invalid environment variables:\n" + missing);
|
||||
}
|
||||
|
||||
log.info("Environment validation successful:");
|
||||
@@ -93,7 +103,7 @@ public class SystemEnvironmentValidator {
|
||||
private String redact(String value) {
|
||||
if (value != null && StringUtils.hasText(value)) {
|
||||
return "**************";
|
||||
};
|
||||
}
|
||||
return "<not set>";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package dev.kruhlmann.imgfloat.config;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.convert.converter.Converter;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.RequestEntity;
|
||||
@@ -19,11 +18,11 @@ import org.springframework.util.MultiValueMap;
|
||||
* request body. Twitch ignores HTTP Basic authentication and responds with "missing client id" if
|
||||
* those parameters are absent.
|
||||
*/
|
||||
final class TwitchAuthorizationCodeGrantRequestEntityConverter implements
|
||||
Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {
|
||||
final class TwitchAuthorizationCodeGrantRequestEntityConverter
|
||||
implements Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {
|
||||
|
||||
private final Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> delegate =
|
||||
new OAuth2AuthorizationCodeGrantRequestEntityConverter();
|
||||
new OAuth2AuthorizationCodeGrantRequestEntityConverter();
|
||||
|
||||
@Override
|
||||
public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest request) {
|
||||
@@ -50,8 +49,7 @@ final class TwitchAuthorizationCodeGrantRequestEntityConverter implements
|
||||
|
||||
private MultiValueMap<String, String> cloneBody(MultiValueMap<?, ?> existingBody) {
|
||||
MultiValueMap<String, String> copy = new LinkedMultiValueMap<>();
|
||||
existingBody.forEach((key, value) ->
|
||||
copy.put(String.valueOf(key), new ArrayList<>((List<String>) value)));
|
||||
existingBody.forEach((key, value) -> copy.put(String.valueOf(key), new ArrayList<>((List<String>) value)));
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package dev.kruhlmann.imgfloat.config;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
|
||||
@@ -24,6 +23,7 @@ import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(OAuth2ClientProperties.class)
|
||||
class TwitchClientRegistrationConfig {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TwitchClientRegistrationConfig.class);
|
||||
|
||||
@Bean
|
||||
@@ -37,7 +37,8 @@ class TwitchClientRegistrationConfig {
|
||||
OAuth2ClientProperties.Provider provider = properties.getProvider().get(providerId);
|
||||
if (provider == null) {
|
||||
throw new IllegalStateException(
|
||||
"Missing OAuth2 provider configuration for registration '" + registrationId + "'.");
|
||||
"Missing OAuth2 provider configuration for registration '" + registrationId + "'."
|
||||
);
|
||||
}
|
||||
if (!"twitch".equals(registrationId)) {
|
||||
LOG.warn("Unexpected OAuth2 registration '{}' found; only Twitch is supported.", registrationId);
|
||||
@@ -49,24 +50,25 @@ class TwitchClientRegistrationConfig {
|
||||
}
|
||||
|
||||
private ClientRegistration buildTwitchRegistration(
|
||||
String registrationId,
|
||||
OAuth2ClientProperties.Registration registration,
|
||||
OAuth2ClientProperties.Provider provider) {
|
||||
String registrationId,
|
||||
OAuth2ClientProperties.Registration registration,
|
||||
OAuth2ClientProperties.Provider provider
|
||||
) {
|
||||
String clientId = sanitize(registration.getClientId(), "TWITCH_CLIENT_ID");
|
||||
String clientSecret = sanitize(registration.getClientSecret(), "TWITCH_CLIENT_SECRET");
|
||||
return ClientRegistration.withRegistrationId(registrationId)
|
||||
.clientName(registration.getClientName())
|
||||
.clientId(clientId)
|
||||
.clientSecret(clientSecret)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.authorizationGrantType(new AuthorizationGrantType(registration.getAuthorizationGrantType()))
|
||||
.redirectUri(registration.getRedirectUri())
|
||||
.scope(registration.getScope())
|
||||
.authorizationUri(provider.getAuthorizationUri())
|
||||
.tokenUri(provider.getTokenUri())
|
||||
.userInfoUri(provider.getUserInfoUri())
|
||||
.userNameAttributeName(provider.getUserNameAttribute())
|
||||
.build();
|
||||
.clientName(registration.getClientName())
|
||||
.clientId(clientId)
|
||||
.clientSecret(clientSecret)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.authorizationGrantType(new AuthorizationGrantType(registration.getAuthorizationGrantType()))
|
||||
.redirectUri(registration.getRedirectUri())
|
||||
.scope(registration.getScope())
|
||||
.authorizationUri(provider.getAuthorizationUri())
|
||||
.tokenUri(provider.getTokenUri())
|
||||
.userInfoUri(provider.getUserInfoUri())
|
||||
.userNameAttributeName(provider.getUserNameAttribute())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String sanitize(String value, String name) {
|
||||
@@ -74,7 +76,9 @@ class TwitchClientRegistrationConfig {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
if (
|
||||
(trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
String unquoted = trimmed.substring(1, trimmed.length() - 1).trim();
|
||||
LOG.info("Sanitizing {} by stripping surrounding quotes.", name);
|
||||
return unquoted;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
@@ -31,20 +30,24 @@ class TwitchOAuth2ErrorResponseErrorHandler extends OAuth2ErrorResponseErrorHand
|
||||
String body = new String(bodyBytes, StandardCharsets.UTF_8);
|
||||
|
||||
if (body.isBlank()) {
|
||||
LOG.warn("Failed to parse Twitch OAuth error response (status: {}, headers: {}): <empty body>",
|
||||
response.getStatusCode(),
|
||||
response.getHeaders());
|
||||
LOG.warn(
|
||||
"Failed to parse Twitch OAuth error response (status: {}, headers: {}): <empty body>",
|
||||
response.getStatusCode(),
|
||||
response.getHeaders()
|
||||
);
|
||||
throw asAuthorizationException(body, null);
|
||||
}
|
||||
|
||||
try {
|
||||
super.handleError(new CachedBodyClientHttpResponse(response, bodyBytes));
|
||||
} catch (HttpMessageNotReadableException | IllegalArgumentException ex) {
|
||||
LOG.warn("Failed to parse Twitch OAuth error response (status: {}, headers: {}): {}",
|
||||
response.getStatusCode(),
|
||||
response.getHeaders(),
|
||||
body,
|
||||
ex);
|
||||
LOG.warn(
|
||||
"Failed to parse Twitch OAuth error response (status: {}, headers: {}): {}",
|
||||
response.getStatusCode(),
|
||||
response.getHeaders(),
|
||||
body,
|
||||
ex
|
||||
);
|
||||
throw asAuthorizationException(body, ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||
import org.springframework.http.client.ClientHttpRequestInterceptor;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
@@ -46,18 +45,19 @@ class TwitchOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OA
|
||||
|
||||
private OAuth2UserRequest twitchUserRequest(OAuth2UserRequest userRequest) {
|
||||
return new OAuth2UserRequest(
|
||||
twitchUserRegistration(userRequest),
|
||||
userRequest.getAccessToken(),
|
||||
userRequest.getAdditionalParameters());
|
||||
twitchUserRegistration(userRequest),
|
||||
userRequest.getAccessToken(),
|
||||
userRequest.getAdditionalParameters()
|
||||
);
|
||||
}
|
||||
|
||||
private ClientRegistration twitchUserRegistration(OAuth2UserRequest userRequest) {
|
||||
ClientRegistration registration = userRequest.getClientRegistration();
|
||||
return ClientRegistration.withClientRegistration(registration)
|
||||
// The Twitch response nests user details under a "data" array, so accept that
|
||||
// shape for the initial parsing step.
|
||||
.userNameAttributeName("data")
|
||||
.build();
|
||||
// The Twitch response nests user details under a "data" array, so accept that
|
||||
// shape for the initial parsing step.
|
||||
.userNameAttributeName("data")
|
||||
.build();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
@@ -35,6 +35,8 @@ public class UploadLimitsConfig {
|
||||
}
|
||||
|
||||
private boolean isTestContext() {
|
||||
return Boolean.parseBoolean(environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper"));
|
||||
return Boolean.parseBoolean(
|
||||
environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.AdminRequest;
|
||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
||||
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.TwitchUserLookupService;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -30,25 +40,14 @@ import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.io.IOException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/channels/{broadcaster}")
|
||||
@SecurityRequirement(name = "twitchOAuth")
|
||||
public class ChannelApiController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
|
||||
private final ChannelDirectoryService channelDirectoryService;
|
||||
private final OAuth2AuthorizedClientService authorizedClientService;
|
||||
@@ -71,9 +70,11 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PostMapping("/admins")
|
||||
public ResponseEntity<?> addAdmin(@PathVariable("broadcaster") String broadcaster,
|
||||
@Valid @RequestBody AdminRequest request,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
public ResponseEntity<?> addAdmin(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@Valid @RequestBody AdminRequest request,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("User {} adding admin {} to {}", sessionUsername, request.getUsername(), broadcaster);
|
||||
@@ -85,32 +86,34 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@GetMapping("/admins")
|
||||
public Collection<TwitchUserProfile> listAdmins(@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
HttpServletRequest request) {
|
||||
public Collection<TwitchUserProfile> listAdmins(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.debug("Listing admins for {} by {}", broadcaster, sessionUsername);
|
||||
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
||||
List<String> admins = channel.getAdmins().stream()
|
||||
.sorted(Comparator.naturalOrder())
|
||||
.toList();
|
||||
List<String> admins = channel.getAdmins().stream().sorted(Comparator.naturalOrder()).toList();
|
||||
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request);
|
||||
String accessToken = Optional.ofNullable(authorizedClient)
|
||||
.map(OAuth2AuthorizedClient::getAccessToken)
|
||||
.map(token -> token.getTokenValue())
|
||||
.orElse(null);
|
||||
.map(OAuth2AuthorizedClient::getAccessToken)
|
||||
.map((token) -> token.getTokenValue())
|
||||
.orElse(null);
|
||||
String clientId = Optional.ofNullable(authorizedClient)
|
||||
.map(OAuth2AuthorizedClient::getClientRegistration)
|
||||
.map(registration -> registration.getClientId())
|
||||
.orElse(null);
|
||||
.map(OAuth2AuthorizedClient::getClientRegistration)
|
||||
.map((registration) -> registration.getClientId())
|
||||
.orElse(null);
|
||||
return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId);
|
||||
}
|
||||
|
||||
@GetMapping("/admins/suggestions")
|
||||
public Collection<TwitchUserProfile> listAdminSuggestions(@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
HttpServletRequest request) {
|
||||
public Collection<TwitchUserProfile> listAdminSuggestions(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.debug("Listing admin suggestions for {} by {}", broadcaster, sessionUsername);
|
||||
@@ -118,28 +121,38 @@ public class ChannelApiController {
|
||||
OAuth2AuthorizedClient authorizedClient = resolveAuthorizedClient(oauthToken, null, request);
|
||||
|
||||
if (authorizedClient == null) {
|
||||
LOG.warn("No authorized Twitch client found for {} while fetching admin suggestions for {}", sessionUsername, broadcaster);
|
||||
LOG.warn(
|
||||
"No authorized Twitch client found for {} while fetching admin suggestions for {}",
|
||||
sessionUsername,
|
||||
broadcaster
|
||||
);
|
||||
return List.of();
|
||||
}
|
||||
String accessToken = Optional.ofNullable(authorizedClient)
|
||||
.map(OAuth2AuthorizedClient::getAccessToken)
|
||||
.map(token -> token.getTokenValue())
|
||||
.orElse(null);
|
||||
.map(OAuth2AuthorizedClient::getAccessToken)
|
||||
.map((token) -> token.getTokenValue())
|
||||
.orElse(null);
|
||||
String clientId = Optional.ofNullable(authorizedClient)
|
||||
.map(OAuth2AuthorizedClient::getClientRegistration)
|
||||
.map(registration -> registration.getClientId())
|
||||
.orElse(null);
|
||||
.map(OAuth2AuthorizedClient::getClientRegistration)
|
||||
.map((registration) -> registration.getClientId())
|
||||
.orElse(null);
|
||||
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
|
||||
LOG.warn("Missing Twitch credentials for {} while fetching admin suggestions for {}", sessionUsername, broadcaster);
|
||||
LOG.warn(
|
||||
"Missing Twitch credentials for {} while fetching admin suggestions for {}",
|
||||
sessionUsername,
|
||||
broadcaster
|
||||
);
|
||||
return List.of();
|
||||
}
|
||||
return twitchUserLookupService.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId);
|
||||
}
|
||||
|
||||
@DeleteMapping("/admins/{username}")
|
||||
public ResponseEntity<?> removeAdmin(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("username") String username,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
public ResponseEntity<?> removeAdmin(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("username") String username,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("User {} removing admin {} from {}", sessionUsername, username, broadcaster);
|
||||
@@ -163,30 +176,44 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PutMapping("/canvas")
|
||||
public CanvasSettingsRequest updateCanvas(@PathVariable("broadcaster") String broadcaster,
|
||||
@Valid @RequestBody CanvasSettingsRequest request,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
public CanvasSettingsRequest updateCanvas(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@Valid @RequestBody CanvasSettingsRequest request,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("Updating canvas for {} by {}: {}x{}", broadcaster, sessionUsername, request.getWidth(), request.getHeight());
|
||||
LOG.info(
|
||||
"Updating canvas for {} by {}: {}x{}",
|
||||
broadcaster,
|
||||
sessionUsername,
|
||||
request.getWidth(),
|
||||
request.getHeight()
|
||||
);
|
||||
return channelDirectoryService.updateCanvasSettings(broadcaster, request);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<AssetView> createAsset(@PathVariable("broadcaster") String broadcaster,
|
||||
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
public ResponseEntity<AssetView> createAsset(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
if (file == null || file.isEmpty()) {
|
||||
LOG.warn("User {} attempted to upload empty file to {}", sessionUsername, broadcaster);
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Asset file is required");
|
||||
}
|
||||
try {
|
||||
LOG.info("User {} uploading asset {} to {}", sessionUsername, file.getOriginalFilename(), broadcaster);
|
||||
return channelDirectoryService.createAsset(broadcaster, file)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
|
||||
return channelDirectoryService
|
||||
.createAsset(broadcaster, file)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to process asset upload for {} by {}", broadcaster, sessionUsername, e);
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Failed to process image", e);
|
||||
@@ -194,88 +221,130 @@ public class ChannelApiController {
|
||||
}
|
||||
|
||||
@PutMapping("/assets/{assetId}/transform")
|
||||
public ResponseEntity<AssetView> transform(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@Valid @RequestBody TransformRequest request,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
public ResponseEntity<AssetView> transform(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@Valid @RequestBody TransformRequest request,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
LOG.debug("Applying transform to asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||
return channelDirectoryService.updateTransform(broadcaster, assetId, request)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> {
|
||||
LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
||||
});
|
||||
return channelDirectoryService
|
||||
.updateTransform(broadcaster, assetId, request)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> {
|
||||
LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping("/assets/{assetId}/play")
|
||||
public ResponseEntity<AssetView> play(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@RequestBody(required = false) PlaybackRequest request,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
public ResponseEntity<AssetView> play(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@RequestBody(required = false) PlaybackRequest request,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
LOG.info("Triggering playback for asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||
return channelDirectoryService.triggerPlayback(broadcaster, assetId, request)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||
return channelDirectoryService
|
||||
.triggerPlayback(broadcaster, assetId, request)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||
}
|
||||
|
||||
@PutMapping("/assets/{assetId}/visibility")
|
||||
public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@RequestBody VisibilityRequest request,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
public ResponseEntity<AssetView> visibility(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@RequestBody VisibilityRequest request,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("Updating visibility for asset {} on {} by {} to hidden={} ", assetId, broadcaster, sessionUsername , request.isHidden());
|
||||
return channelDirectoryService.updateVisibility(broadcaster, assetId, request)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> {
|
||||
LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
||||
});
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
LOG.info(
|
||||
"Updating visibility for asset {} on {} by {} to hidden={} ",
|
||||
assetId,
|
||||
broadcaster,
|
||||
sessionUsername,
|
||||
request.isHidden()
|
||||
);
|
||||
return channelDirectoryService
|
||||
.updateVisibility(broadcaster, assetId, request)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> {
|
||||
LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
||||
});
|
||||
}
|
||||
|
||||
@GetMapping("/assets/{assetId}/content")
|
||||
public ResponseEntity<byte[]> getAssetContent(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId) {
|
||||
public ResponseEntity<byte[]> getAssetContent(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId
|
||||
) {
|
||||
LOG.debug("Serving asset {} for broadcaster {}", assetId, broadcaster);
|
||||
return channelDirectoryService.getAssetContent(assetId)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
return channelDirectoryService
|
||||
.getAssetContent(assetId)
|
||||
.map((content) ->
|
||||
ResponseEntity.ok()
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType()))
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.body(content.bytes())
|
||||
)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
|
||||
}
|
||||
|
||||
@GetMapping("/assets/{assetId}/preview")
|
||||
public ResponseEntity<byte[]> getAssetPreview(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId) {
|
||||
public ResponseEntity<byte[]> getAssetPreview(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId
|
||||
) {
|
||||
LOG.debug("Serving preview for asset {} for broadcaster {}", assetId, broadcaster);
|
||||
return channelDirectoryService.getAssetPreview(assetId, true)
|
||||
.map(content -> ResponseEntity.ok()
|
||||
return channelDirectoryService
|
||||
.getAssetPreview(assetId, true)
|
||||
.map((content) ->
|
||||
ResponseEntity.ok()
|
||||
.header("X-Content-Type-Options", "nosniff")
|
||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||
.body(content.bytes()))
|
||||
.body(content.bytes())
|
||||
)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Preview not found"));
|
||||
}
|
||||
|
||||
private String contentDispositionFor(String mediaType) {
|
||||
if (mediaType != null && dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)) {
|
||||
if (
|
||||
mediaType != null &&
|
||||
dev.kruhlmann.imgfloat.service.media.MediaDetectionService.isInlineDisplayType(mediaType)
|
||||
) {
|
||||
return "inline";
|
||||
}
|
||||
return "attachment";
|
||||
}
|
||||
|
||||
@DeleteMapping("/assets/{assetId}")
|
||||
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
OAuth2AuthenticationToken oauthToken) {
|
||||
public ResponseEntity<?> delete(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
boolean removed = channelDirectoryService.deleteAsset(assetId);
|
||||
if (!removed) {
|
||||
LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, sessionUsername);
|
||||
@@ -285,9 +354,11 @@ public class ChannelApiController {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
private OAuth2AuthorizedClient resolveAuthorizedClient(OAuth2AuthenticationToken oauthToken,
|
||||
OAuth2AuthorizedClient authorizedClient,
|
||||
HttpServletRequest request) {
|
||||
private OAuth2AuthorizedClient resolveAuthorizedClient(
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
OAuth2AuthorizedClient authorizedClient,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
if (authorizedClient != null) {
|
||||
return authorizedClient;
|
||||
}
|
||||
@@ -295,12 +366,16 @@ public class ChannelApiController {
|
||||
return null;
|
||||
}
|
||||
OAuth2AuthorizedClient sessionClient = authorizedClientRepository.loadAuthorizedClient(
|
||||
oauthToken.getAuthorizedClientRegistrationId(),
|
||||
oauthToken,
|
||||
request);
|
||||
oauthToken.getAuthorizedClientRegistrationId(),
|
||||
oauthToken,
|
||||
request
|
||||
);
|
||||
if (sessionClient != null) {
|
||||
return sessionClient;
|
||||
}
|
||||
return authorizedClientService.loadAuthorizedClient(oauthToken.getAuthorizedClientRegistrationId(), oauthToken.getName());
|
||||
return authorizedClientService.loadAuthorizedClient(
|
||||
oauthToken.getAuthorizedClientRegistrationId(),
|
||||
oauthToken.getName()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import java.util.List;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/channels")
|
||||
public class ChannelDirectoryApiController {
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import jakarta.validation.Valid;
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -24,20 +32,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.io.IOException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/settings")
|
||||
@SecurityRequirement(name = "administrator")
|
||||
public class SettingsApiController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
|
||||
|
||||
private final SettingsService settingsService;
|
||||
@@ -49,7 +48,10 @@ public class SettingsApiController {
|
||||
}
|
||||
|
||||
@PutMapping("/set")
|
||||
public ResponseEntity<Settings> setSettings(@Valid @RequestBody Settings newSettings, OAuth2AuthenticationToken oauthToken) {
|
||||
public ResponseEntity<Settings> setSettings(
|
||||
@Valid @RequestBody Settings newSettings,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername);
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.VersionService;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.service.VersionService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -19,11 +21,9 @@ import org.springframework.ui.Model;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
@Controller
|
||||
public class ViewController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(ViewController.class);
|
||||
private final ChannelDirectoryService channelDirectoryService;
|
||||
private final VersionService versionService;
|
||||
@@ -85,11 +85,16 @@ public class ViewController {
|
||||
}
|
||||
|
||||
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin")
|
||||
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
Model model) {
|
||||
public String adminView(
|
||||
@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken,
|
||||
Model model
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
LOG.info("Rendering admin console for {} (requested by {})", broadcaster, sessionUsername);
|
||||
Settings settings = settingsService.get();
|
||||
model.addAttribute("broadcaster", broadcaster.toLowerCase());
|
||||
@@ -106,8 +111,10 @@ public class ViewController {
|
||||
}
|
||||
|
||||
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast")
|
||||
public String broadcastView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||
Model model) {
|
||||
public String broadcastView(
|
||||
@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||
Model model
|
||||
) {
|
||||
LOG.debug("Rendering broadcast overlay for {}", broadcaster);
|
||||
model.addAttribute("broadcaster", broadcaster.toLowerCase());
|
||||
return "broadcast";
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.kruhlmann.imgfloat.model;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class AdminRequest {
|
||||
|
||||
@NotBlank
|
||||
private String username;
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
@@ -14,18 +13,25 @@ import java.util.UUID;
|
||||
@Entity
|
||||
@Table(name = "assets")
|
||||
public class Asset {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String broadcaster;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String url;
|
||||
|
||||
@Column(columnDefinition = "TEXT", nullable = false)
|
||||
private String preview;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
private double x;
|
||||
private double y;
|
||||
private double width;
|
||||
@@ -43,8 +49,7 @@ public class Asset {
|
||||
private Double audioVolume;
|
||||
private boolean hidden;
|
||||
|
||||
public Asset() {
|
||||
}
|
||||
public Asset() {}
|
||||
|
||||
public Asset(String broadcaster, String name, String url, double width, double height) {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
|
||||
@@ -4,12 +4,13 @@ import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class AssetEvent {
|
||||
|
||||
public enum Type {
|
||||
CREATED,
|
||||
UPDATED,
|
||||
VISIBILITY,
|
||||
PLAY,
|
||||
DELETED
|
||||
DELETED,
|
||||
}
|
||||
|
||||
private Type type;
|
||||
|
||||
@@ -8,37 +8,37 @@ import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record AssetPatch(
|
||||
String id,
|
||||
Double x,
|
||||
Double y,
|
||||
Double width,
|
||||
Double height,
|
||||
Double rotation,
|
||||
Double speed,
|
||||
Boolean muted,
|
||||
Integer zIndex,
|
||||
Boolean hidden,
|
||||
Boolean audioLoop,
|
||||
Integer audioDelayMillis,
|
||||
Double audioSpeed,
|
||||
Double audioPitch,
|
||||
Double audioVolume
|
||||
String id,
|
||||
Double x,
|
||||
Double y,
|
||||
Double width,
|
||||
Double height,
|
||||
Double rotation,
|
||||
Double speed,
|
||||
Boolean muted,
|
||||
Integer zIndex,
|
||||
Boolean hidden,
|
||||
Boolean audioLoop,
|
||||
Integer audioDelayMillis,
|
||||
Double audioSpeed,
|
||||
Double audioPitch,
|
||||
Double audioVolume
|
||||
) {
|
||||
public static TransformSnapshot capture(Asset asset) {
|
||||
return new TransformSnapshot(
|
||||
asset.getX(),
|
||||
asset.getY(),
|
||||
asset.getWidth(),
|
||||
asset.getHeight(),
|
||||
asset.getRotation(),
|
||||
asset.getSpeed(),
|
||||
asset.isMuted(),
|
||||
asset.getZIndex(),
|
||||
asset.isAudioLoop(),
|
||||
asset.getAudioDelayMillis(),
|
||||
asset.getAudioSpeed(),
|
||||
asset.getAudioPitch(),
|
||||
asset.getAudioVolume()
|
||||
asset.getX(),
|
||||
asset.getY(),
|
||||
asset.getWidth(),
|
||||
asset.getHeight(),
|
||||
asset.getRotation(),
|
||||
asset.getSpeed(),
|
||||
asset.isMuted(),
|
||||
asset.getZIndex(),
|
||||
asset.isAudioLoop(),
|
||||
asset.getAudioDelayMillis(),
|
||||
asset.getAudioSpeed(),
|
||||
asset.getAudioPitch(),
|
||||
asset.getAudioVolume()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,41 +48,43 @@ public record AssetPatch(
|
||||
*/
|
||||
public static AssetPatch fromTransform(TransformSnapshot before, Asset asset, TransformRequest request) {
|
||||
return new AssetPatch(
|
||||
asset.getId(),
|
||||
changed(before.x(), asset.getX()),
|
||||
changed(before.y(), asset.getY()),
|
||||
changed(before.width(), asset.getWidth()),
|
||||
changed(before.height(), asset.getHeight()),
|
||||
changed(before.rotation(), asset.getRotation()),
|
||||
request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null,
|
||||
request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null,
|
||||
request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null,
|
||||
null,
|
||||
request.getAudioLoop() != null ? changed(before.audioLoop(), asset.isAudioLoop()) : null,
|
||||
request.getAudioDelayMillis() != null ? changed(before.audioDelayMillis(), asset.getAudioDelayMillis()) : null,
|
||||
request.getAudioSpeed() != null ? changed(before.audioSpeed(), asset.getAudioSpeed()) : null,
|
||||
request.getAudioPitch() != null ? changed(before.audioPitch(), asset.getAudioPitch()) : null,
|
||||
request.getAudioVolume() != null ? changed(before.audioVolume(), asset.getAudioVolume()) : null
|
||||
asset.getId(),
|
||||
changed(before.x(), asset.getX()),
|
||||
changed(before.y(), asset.getY()),
|
||||
changed(before.width(), asset.getWidth()),
|
||||
changed(before.height(), asset.getHeight()),
|
||||
changed(before.rotation(), asset.getRotation()),
|
||||
request.getSpeed() != null ? changed(before.speed(), asset.getSpeed()) : null,
|
||||
request.getMuted() != null ? changed(before.muted(), asset.isMuted()) : null,
|
||||
request.getZIndex() != null ? changed(before.zIndex(), asset.getZIndex()) : null,
|
||||
null,
|
||||
request.getAudioLoop() != null ? changed(before.audioLoop(), asset.isAudioLoop()) : null,
|
||||
request.getAudioDelayMillis() != null
|
||||
? changed(before.audioDelayMillis(), asset.getAudioDelayMillis())
|
||||
: null,
|
||||
request.getAudioSpeed() != null ? changed(before.audioSpeed(), asset.getAudioSpeed()) : null,
|
||||
request.getAudioPitch() != null ? changed(before.audioPitch(), asset.getAudioPitch()) : null,
|
||||
request.getAudioVolume() != null ? changed(before.audioVolume(), asset.getAudioVolume()) : null
|
||||
);
|
||||
}
|
||||
|
||||
public static AssetPatch fromVisibility(Asset asset) {
|
||||
return new AssetPatch(
|
||||
asset.getId(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
asset.isHidden(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
asset.getId(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
asset.isHidden(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,18 +101,18 @@ public record AssetPatch(
|
||||
}
|
||||
|
||||
public record TransformSnapshot(
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height,
|
||||
double rotation,
|
||||
double speed,
|
||||
boolean muted,
|
||||
int zIndex,
|
||||
boolean audioLoop,
|
||||
int audioDelayMillis,
|
||||
double audioSpeed,
|
||||
double audioPitch,
|
||||
double audioVolume
|
||||
) { }
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height,
|
||||
double rotation,
|
||||
double speed,
|
||||
boolean muted,
|
||||
int zIndex,
|
||||
boolean audioLoop,
|
||||
int audioDelayMillis,
|
||||
double audioSpeed,
|
||||
double audioPitch,
|
||||
double audioVolume
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -3,57 +3,57 @@ package dev.kruhlmann.imgfloat.model;
|
||||
import java.time.Instant;
|
||||
|
||||
public record AssetView(
|
||||
String id,
|
||||
String broadcaster,
|
||||
String name,
|
||||
String url,
|
||||
String previewUrl,
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height,
|
||||
double rotation,
|
||||
Double speed,
|
||||
Boolean muted,
|
||||
String mediaType,
|
||||
String originalMediaType,
|
||||
Integer zIndex,
|
||||
Boolean audioLoop,
|
||||
Integer audioDelayMillis,
|
||||
Double audioSpeed,
|
||||
Double audioPitch,
|
||||
Double audioVolume,
|
||||
boolean hidden,
|
||||
boolean hasPreview,
|
||||
Instant createdAt
|
||||
String id,
|
||||
String broadcaster,
|
||||
String name,
|
||||
String url,
|
||||
String previewUrl,
|
||||
double x,
|
||||
double y,
|
||||
double width,
|
||||
double height,
|
||||
double rotation,
|
||||
Double speed,
|
||||
Boolean muted,
|
||||
String mediaType,
|
||||
String originalMediaType,
|
||||
Integer zIndex,
|
||||
Boolean audioLoop,
|
||||
Integer audioDelayMillis,
|
||||
Double audioSpeed,
|
||||
Double audioPitch,
|
||||
Double audioVolume,
|
||||
boolean hidden,
|
||||
boolean hasPreview,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static AssetView from(String broadcaster, Asset asset) {
|
||||
return new AssetView(
|
||||
asset.getId(),
|
||||
asset.getBroadcaster(),
|
||||
asset.getName(),
|
||||
"/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content",
|
||||
asset.getPreview() != null && !asset.getPreview().isBlank()
|
||||
? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview"
|
||||
: null,
|
||||
asset.getX(),
|
||||
asset.getY(),
|
||||
asset.getWidth(),
|
||||
asset.getHeight(),
|
||||
asset.getRotation(),
|
||||
asset.getSpeed(),
|
||||
asset.isMuted(),
|
||||
asset.getMediaType(),
|
||||
asset.getOriginalMediaType(),
|
||||
asset.getZIndex(),
|
||||
asset.isAudioLoop(),
|
||||
asset.getAudioDelayMillis(),
|
||||
asset.getAudioSpeed(),
|
||||
asset.getAudioPitch(),
|
||||
asset.getAudioVolume(),
|
||||
asset.isHidden(),
|
||||
asset.getPreview() != null && !asset.getPreview().isBlank(),
|
||||
asset.getCreatedAt()
|
||||
asset.getId(),
|
||||
asset.getBroadcaster(),
|
||||
asset.getName(),
|
||||
"/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content",
|
||||
asset.getPreview() != null && !asset.getPreview().isBlank()
|
||||
? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview"
|
||||
: null,
|
||||
asset.getX(),
|
||||
asset.getY(),
|
||||
asset.getWidth(),
|
||||
asset.getHeight(),
|
||||
asset.getRotation(),
|
||||
asset.getSpeed(),
|
||||
asset.isMuted(),
|
||||
asset.getMediaType(),
|
||||
asset.getOriginalMediaType(),
|
||||
asset.getZIndex(),
|
||||
asset.isAudioLoop(),
|
||||
asset.getAudioDelayMillis(),
|
||||
asset.getAudioSpeed(),
|
||||
asset.getAudioPitch(),
|
||||
asset.getAudioVolume(),
|
||||
asset.isHidden(),
|
||||
asset.getPreview() != null && !asset.getPreview().isBlank(),
|
||||
asset.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@ package dev.kruhlmann.imgfloat.model;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
public class CanvasSettingsRequest {
|
||||
|
||||
@Positive
|
||||
private double width;
|
||||
|
||||
@Positive
|
||||
private double height;
|
||||
|
||||
public CanvasSettingsRequest() {
|
||||
}
|
||||
public CanvasSettingsRequest() {}
|
||||
|
||||
public CanvasSettingsRequest(double width, double height) {
|
||||
this.width = width;
|
||||
|
||||
@@ -10,7 +10,6 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
@@ -20,6 +19,7 @@ import java.util.stream.Collectors;
|
||||
@Entity
|
||||
@Table(name = "channels")
|
||||
public class Channel {
|
||||
|
||||
@Id
|
||||
private String broadcaster;
|
||||
|
||||
@@ -32,8 +32,7 @@ public class Channel {
|
||||
|
||||
private double canvasHeight = 1080;
|
||||
|
||||
public Channel() {
|
||||
}
|
||||
public Channel() {}
|
||||
|
||||
public Channel(String broadcaster) {
|
||||
this.broadcaster = normalize(broadcaster);
|
||||
@@ -77,9 +76,7 @@ public class Channel {
|
||||
@PreUpdate
|
||||
public void normalizeFields() {
|
||||
this.broadcaster = normalize(broadcaster);
|
||||
this.admins = admins.stream()
|
||||
.map(Channel::normalize)
|
||||
.collect(Collectors.toSet());
|
||||
this.admins = admins.stream().map(Channel::normalize).collect(Collectors.toSet());
|
||||
if (canvasWidth <= 0) {
|
||||
canvasWidth = 1920;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.model;
|
||||
|
||||
public class PlaybackRequest {
|
||||
|
||||
private Boolean play;
|
||||
|
||||
public Boolean getPlay() {
|
||||
|
||||
@@ -4,34 +4,42 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
@Entity
|
||||
@Table(name = "settings")
|
||||
public class Settings {
|
||||
|
||||
@Id
|
||||
@Column(nullable = false)
|
||||
private int id = 1;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double minAssetPlaybackSpeedFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double maxAssetPlaybackSpeedFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double minAssetAudioPitchFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double maxAssetAudioPitchFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double minAssetVolumeFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private double maxAssetVolumeFraction;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int maxCanvasSideLengthPixels;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int canvasFramesPerSecond;
|
||||
|
||||
protected Settings() {
|
||||
}
|
||||
protected Settings() {}
|
||||
|
||||
public static Settings defaults() {
|
||||
Settings s = new Settings();
|
||||
@@ -117,5 +125,4 @@ public class Settings {
|
||||
public void setCanvasFramesPerSecond(int canvasFramesPerSecond) {
|
||||
this.canvasFramesPerSecond = canvasFramesPerSecond;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,27 +4,24 @@ import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "system_administrators",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username")
|
||||
)
|
||||
@Table(name = "system_administrators", uniqueConstraints = @UniqueConstraint(columnNames = "twitch_username"))
|
||||
public class SystemAdministrator {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(name = "twitch_username", nullable = false)
|
||||
private String twitchUsername;
|
||||
|
||||
public SystemAdministrator() {
|
||||
}
|
||||
public SystemAdministrator() {}
|
||||
|
||||
public SystemAdministrator(String twitchUsername) {
|
||||
this.twitchUsername = twitchUsername;
|
||||
@@ -43,7 +40,6 @@ public class SystemAdministrator {
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
public String getTwitchUsername() {
|
||||
return twitchUsername;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
|
||||
public class TransformRequest {
|
||||
|
||||
private double x;
|
||||
private double y;
|
||||
|
||||
@@ -25,6 +26,7 @@ public class TransformRequest {
|
||||
|
||||
@Positive(message = "zIndex must be at least 1")
|
||||
private Integer zIndex;
|
||||
|
||||
private Boolean audioLoop;
|
||||
|
||||
@PositiveOrZero(message = "Audio delay must be zero or greater")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.model;
|
||||
|
||||
public class VisibilityRequest {
|
||||
|
||||
private boolean hidden;
|
||||
|
||||
public boolean isHidden() {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package dev.kruhlmann.imgfloat.repository;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AssetRepository extends JpaRepository<Asset, String> {
|
||||
List<Asset> findByBroadcaster(String broadcaster);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package dev.kruhlmann.imgfloat.repository;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Channel;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ChannelRepository extends JpaRepository<Channel, String> {
|
||||
List<Channel> findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(String broadcasterFragment);
|
||||
|
||||
@@ -3,5 +3,4 @@ package dev.kruhlmann.imgfloat.repository;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface SettingsRepository extends JpaRepository<Settings, Integer> {
|
||||
}
|
||||
public interface SettingsRepository extends JpaRepository<Settings, Integer> {}
|
||||
|
||||
@@ -2,35 +2,29 @@ package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class AssetCleanupService {
|
||||
|
||||
private static final Logger logger =
|
||||
LoggerFactory.getLogger(AssetCleanupService.class);
|
||||
private static final Logger logger = LoggerFactory.getLogger(AssetCleanupService.class);
|
||||
|
||||
private final AssetRepository assetRepository;
|
||||
private final AssetStorageService assetStorageService;
|
||||
|
||||
public AssetCleanupService(
|
||||
AssetRepository assetRepository,
|
||||
AssetStorageService assetStorageService
|
||||
) {
|
||||
public AssetCleanupService(AssetRepository assetRepository, AssetStorageService assetStorageService) {
|
||||
this.assetRepository = assetRepository;
|
||||
this.assetStorageService = assetStorageService;
|
||||
}
|
||||
@@ -41,10 +35,7 @@ public class AssetCleanupService {
|
||||
public void cleanup() {
|
||||
logger.info("Collecting referenced assets");
|
||||
|
||||
Set<String> referencedIds = assetRepository.findAll()
|
||||
.stream()
|
||||
.map(Asset::getId)
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> referencedIds = assetRepository.findAll().stream().map(Asset::getId).collect(Collectors.toSet());
|
||||
|
||||
assetStorageService.deleteOrphanedAssets(referencedIds);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Locale;
|
||||
@@ -14,9 +9,14 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class AssetStorageService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(AssetStorageService.class);
|
||||
private static final Map<String, String> EXTENSIONS = Map.ofEntries(
|
||||
Map.entry("image/png", ".png"),
|
||||
@@ -42,15 +42,15 @@ public class AssetStorageService {
|
||||
private final Path previewRoot;
|
||||
|
||||
public AssetStorageService(
|
||||
@Value("${IMGFLOAT_ASSETS_PATH:#{null}}") String assetRoot,
|
||||
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") String previewRoot
|
||||
@Value("${IMGFLOAT_ASSETS_PATH:#{null}}") String assetRoot,
|
||||
@Value("${IMGFLOAT_PREVIEWS_PATH:#{null}}") String previewRoot
|
||||
) {
|
||||
String assetsBase = assetRoot != null
|
||||
? assetRoot
|
||||
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-assets").toString();
|
||||
? assetRoot
|
||||
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-assets").toString();
|
||||
String previewsBase = previewRoot != null
|
||||
? previewRoot
|
||||
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-previews").toString();
|
||||
? previewRoot
|
||||
: Paths.get(System.getProperty("java.io.tmpdir"), "imgfloat-previews").toString();
|
||||
|
||||
this.assetRoot = Paths.get(assetsBase).normalize().toAbsolutePath();
|
||||
this.previewRoot = Paths.get(previewsBase).normalize().toAbsolutePath();
|
||||
@@ -62,9 +62,7 @@ public class AssetStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
public void storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType)
|
||||
throws IOException {
|
||||
|
||||
public void storeAsset(String broadcaster, String assetId, byte[] assetBytes, String mediaType) throws IOException {
|
||||
if (assetBytes == null || assetBytes.length == 0) {
|
||||
throw new IOException("Asset content is empty");
|
||||
}
|
||||
@@ -72,35 +70,35 @@ public class AssetStorageService {
|
||||
Path file = assetPath(broadcaster, assetId, mediaType);
|
||||
Files.createDirectories(file.getParent());
|
||||
|
||||
Files.write(file, assetBytes,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
Files.write(
|
||||
file,
|
||||
assetBytes,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE
|
||||
);
|
||||
logger.info("Wrote asset to {}", file.toString());
|
||||
}
|
||||
|
||||
public void storePreview(String broadcaster, String assetId, byte[] previewBytes)
|
||||
throws IOException {
|
||||
|
||||
public void storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException {
|
||||
if (previewBytes == null || previewBytes.length == 0) return;
|
||||
|
||||
Path file = previewPath(broadcaster, assetId);
|
||||
Files.createDirectories(file.getParent());
|
||||
|
||||
Files.write(file, previewBytes,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE);
|
||||
Files.write(
|
||||
file,
|
||||
previewBytes,
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING,
|
||||
StandardOpenOption.WRITE
|
||||
);
|
||||
logger.info("Wrote asset to {}", file.toString());
|
||||
}
|
||||
|
||||
public Optional<AssetContent> loadAssetFile(Asset asset) {
|
||||
try {
|
||||
Path file = assetPath(
|
||||
asset.getBroadcaster(),
|
||||
asset.getId(),
|
||||
asset.getMediaType()
|
||||
);
|
||||
Path file = assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType());
|
||||
|
||||
if (!Files.exists(file)) return Optional.empty();
|
||||
|
||||
@@ -141,12 +139,8 @@ public class AssetStorageService {
|
||||
|
||||
public void deleteAsset(Asset asset) {
|
||||
try {
|
||||
Files.deleteIfExists(
|
||||
assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType())
|
||||
);
|
||||
Files.deleteIfExists(
|
||||
previewPath(asset.getBroadcaster(), asset.getId())
|
||||
);
|
||||
Files.deleteIfExists(assetPath(asset.getBroadcaster(), asset.getId(), asset.getMediaType()));
|
||||
Files.deleteIfExists(previewPath(asset.getBroadcaster(), asset.getId()));
|
||||
} catch (Exception e) {
|
||||
logger.warn("Failed to delete asset {}", asset.getId(), e);
|
||||
}
|
||||
@@ -162,16 +156,17 @@ public class AssetStorageService {
|
||||
return;
|
||||
}
|
||||
try (var paths = Files.walk(root)) {
|
||||
paths.filter(Files::isRegularFile)
|
||||
.filter(p -> isOrphan(p, referencedAssetIds))
|
||||
.forEach(p -> {
|
||||
try {
|
||||
Files.delete(p);
|
||||
logger.warn("Deleted orphan file {}", p);
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to delete {}", p, e);
|
||||
}
|
||||
});
|
||||
paths
|
||||
.filter(Files::isRegularFile)
|
||||
.filter((p) -> isOrphan(p, referencedAssetIds))
|
||||
.forEach((p) -> {
|
||||
try {
|
||||
Files.delete(p);
|
||||
logger.warn("Deleted orphan file {}", p);
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to delete {}", p, e);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed to walk {}", root, e);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.SystemAdministratorService;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
|
||||
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
|
||||
@Service
|
||||
public class AuthorizationService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AuthorizationService.class);
|
||||
|
||||
private final ChannelDirectoryService channelDirectoryService;
|
||||
private final SystemAdministratorService systemAdministratorService;
|
||||
|
||||
public AuthorizationService(ChannelDirectoryService channelDirectoryService, SystemAdministratorService systemAdministratorService) {
|
||||
public AuthorizationService(
|
||||
ChannelDirectoryService channelDirectoryService,
|
||||
SystemAdministratorService systemAdministratorService
|
||||
) {
|
||||
this.channelDirectoryService = channelDirectoryService;
|
||||
this.systemAdministratorService = systemAdministratorService;
|
||||
}
|
||||
|
||||
|
||||
public void userMatchesSessionUsernameOrThrowHttpError(String submittedUsername, String sessionUsername) {
|
||||
if (sessionUsername == null) {
|
||||
LOG.warn("Access denied for broadcaster-only action by unauthenticated user");
|
||||
@@ -35,14 +38,25 @@ public class AuthorizationService {
|
||||
throw new ResponseStatusException(NOT_FOUND, "You can only manage your own channel");
|
||||
}
|
||||
if (!sessionUsername.equals(submittedUsername)) {
|
||||
LOG.warn("User match with oauth token failed: session user {} does not match submitted user {}", sessionUsername, submittedUsername);
|
||||
LOG.warn(
|
||||
"User match with oauth token failed: session user {} does not match submitted user {}",
|
||||
sessionUsername,
|
||||
submittedUsername
|
||||
);
|
||||
throw new ResponseStatusException(FORBIDDEN, "You are not this user");
|
||||
}
|
||||
}
|
||||
|
||||
public void userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(String broadcaster, String sessionUsername) {
|
||||
public void userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
String broadcaster,
|
||||
String sessionUsername
|
||||
) {
|
||||
if (!userIsBroadcasterOrChannelAdminForBroadcaster(broadcaster, sessionUsername)) {
|
||||
LOG.warn("Access denied for broadcaster/admin-only action by user {} on broadcaster {}", sessionUsername, broadcaster);
|
||||
LOG.warn(
|
||||
"Access denied for broadcaster/admin-only action by user {} on broadcaster {}",
|
||||
sessionUsername,
|
||||
broadcaster
|
||||
);
|
||||
throw new ResponseStatusException(FORBIDDEN, "You do not have permission to manage this channel");
|
||||
}
|
||||
}
|
||||
@@ -64,15 +78,20 @@ public class AuthorizationService {
|
||||
|
||||
public boolean userIsChannelAdminForBroadcaster(String broadcaster, String sessionUsername) {
|
||||
if (sessionUsername == null || broadcaster == null) {
|
||||
LOG.warn("Channel admin check failed: broadcaster or session username is null (broadcaster: {}, sessionUsername: {})", broadcaster, sessionUsername);
|
||||
LOG.warn(
|
||||
"Channel admin check failed: broadcaster or session username is null (broadcaster: {}, sessionUsername: {})",
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return channelDirectoryService.isAdmin(broadcaster, sessionUsername);
|
||||
}
|
||||
|
||||
public boolean userIsBroadcasterOrChannelAdminForBroadcaster(String broadcaster, String sessionUser) {
|
||||
return userIsBroadcaster(sessionUser, broadcaster) ||
|
||||
userIsChannelAdminForBroadcaster(sessionUser, broadcaster);
|
||||
return (
|
||||
userIsBroadcaster(sessionUser, broadcaster) || userIsChannelAdminForBroadcaster(sessionUser, broadcaster)
|
||||
);
|
||||
}
|
||||
|
||||
public boolean userIsSystemAdministrator(String sessionUsername) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import dev.kruhlmann.imgfloat.model.AssetEvent;
|
||||
import dev.kruhlmann.imgfloat.model.AssetPatch;
|
||||
import dev.kruhlmann.imgfloat.model.Channel;
|
||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
||||
import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
||||
import dev.kruhlmann.imgfloat.model.Channel;
|
||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||
@@ -17,7 +20,9 @@ import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
|
||||
import dev.kruhlmann.imgfloat.service.media.OptimizedAsset;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -27,15 +32,9 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE;
|
||||
|
||||
@Service
|
||||
public class ChannelDirectoryService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
||||
private static final Pattern SAFE_FILENAME = Pattern.compile("[^a-zA-Z0-9._ -]");
|
||||
|
||||
@@ -68,21 +67,18 @@ public class ChannelDirectoryService {
|
||||
this.settingsService = settingsService;
|
||||
}
|
||||
|
||||
|
||||
public Channel getOrCreateChannel(String broadcaster) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return channelRepository.findById(normalized)
|
||||
.orElseGet(() -> channelRepository.save(new Channel(normalized)));
|
||||
return channelRepository.findById(normalized).orElseGet(() -> channelRepository.save(new Channel(normalized)));
|
||||
}
|
||||
|
||||
public List<String> searchBroadcasters(String query) {
|
||||
String q = normalize(query);
|
||||
return channelRepository
|
||||
.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(
|
||||
q == null ? "" : q)
|
||||
.stream()
|
||||
.map(Channel::getBroadcaster)
|
||||
.toList();
|
||||
.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(q == null ? "" : q)
|
||||
.stream()
|
||||
.map(Channel::getBroadcaster)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public boolean addAdmin(String broadcaster, String username) {
|
||||
@@ -90,8 +86,7 @@ public class ChannelDirectoryService {
|
||||
boolean added = channel.addAdmin(username);
|
||||
if (added) {
|
||||
channelRepository.save(channel);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster),
|
||||
"Admin added: " + username);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
@@ -101,22 +96,19 @@ public class ChannelDirectoryService {
|
||||
boolean removed = channel.removeAdmin(username);
|
||||
if (removed) {
|
||||
channelRepository.save(channel);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster),
|
||||
"Admin removed: " + username);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
public Collection<AssetView> getAssetsForAdmin(String broadcaster) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return sortAndMapAssets(normalized,
|
||||
assetRepository.findByBroadcaster(normalized));
|
||||
return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized));
|
||||
}
|
||||
|
||||
public Collection<AssetView> getVisibleAssets(String broadcaster) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return sortAndMapAssets(normalized,
|
||||
assetRepository.findByBroadcasterAndHiddenFalse(normalized));
|
||||
return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalized));
|
||||
}
|
||||
|
||||
public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
|
||||
@@ -137,19 +129,15 @@ public class ChannelDirectoryService {
|
||||
long maxSize = uploadLimitBytes;
|
||||
if (fileSize > maxSize) {
|
||||
throw new ResponseStatusException(
|
||||
PAYLOAD_TOO_LARGE,
|
||||
String.format(
|
||||
"Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.",
|
||||
fileSize,
|
||||
maxSize
|
||||
)
|
||||
PAYLOAD_TOO_LARGE,
|
||||
String.format("Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.", fileSize, maxSize)
|
||||
);
|
||||
}
|
||||
Channel channel = getOrCreateChannel(broadcaster);
|
||||
byte[] bytes = file.getBytes();
|
||||
String mediaType = mediaDetectionService.detectAllowedMediaType(file, bytes)
|
||||
.orElseThrow(() -> new ResponseStatusException(
|
||||
BAD_REQUEST, "Unsupported media type"));
|
||||
String mediaType = mediaDetectionService
|
||||
.detectAllowedMediaType(file, bytes)
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type"));
|
||||
|
||||
OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType);
|
||||
if (optimized == null) {
|
||||
@@ -157,32 +145,29 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
String safeName = Optional.ofNullable(file.getOriginalFilename())
|
||||
.map(this::sanitizeFilename)
|
||||
.filter(s -> !s.isBlank())
|
||||
.orElse("asset_" + System.currentTimeMillis());
|
||||
.map(this::sanitizeFilename)
|
||||
.filter((s) -> !s.isBlank())
|
||||
.orElse("asset_" + System.currentTimeMillis());
|
||||
|
||||
double width = optimized.width() > 0 ? optimized.width() :
|
||||
(optimized.mediaType().startsWith("audio/") ? 400 : 640);
|
||||
double height = optimized.height() > 0 ? optimized.height() :
|
||||
(optimized.mediaType().startsWith("audio/") ? 80 : 360);
|
||||
double width = optimized.width() > 0
|
||||
? optimized.width()
|
||||
: (optimized.mediaType().startsWith("audio/") ? 400 : 640);
|
||||
double height = optimized.height() > 0
|
||||
? optimized.height()
|
||||
: (optimized.mediaType().startsWith("audio/") ? 80 : 360);
|
||||
|
||||
Asset asset = new Asset(channel.getBroadcaster(), safeName, "",
|
||||
width, height);
|
||||
Asset asset = new Asset(channel.getBroadcaster(), safeName, "", width, height);
|
||||
asset.setOriginalMediaType(mediaType);
|
||||
asset.setMediaType(optimized.mediaType());
|
||||
|
||||
assetStorageService.storeAsset(
|
||||
channel.getBroadcaster(),
|
||||
asset.getId(),
|
||||
optimized.bytes(),
|
||||
optimized.mediaType()
|
||||
channel.getBroadcaster(),
|
||||
asset.getId(),
|
||||
optimized.bytes(),
|
||||
optimized.mediaType()
|
||||
);
|
||||
|
||||
assetStorageService.storePreview(
|
||||
channel.getBroadcaster(),
|
||||
asset.getId(),
|
||||
optimized.previewBytes()
|
||||
);
|
||||
assetStorageService.storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes());
|
||||
asset.setPreview(optimized.previewBytes() != null ? asset.getId() + ".png" : "");
|
||||
|
||||
asset.setSpeed(1.0);
|
||||
@@ -197,8 +182,7 @@ public class ChannelDirectoryService {
|
||||
assetRepository.save(asset);
|
||||
|
||||
AssetView view = AssetView.from(channel.getBroadcaster(), asset);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster),
|
||||
AssetEvent.created(broadcaster, view));
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
|
||||
|
||||
return Optional.of(view);
|
||||
}
|
||||
@@ -211,37 +195,37 @@ public class ChannelDirectoryService {
|
||||
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest req) {
|
||||
String normalized = normalize(broadcaster);
|
||||
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
||||
.map(asset -> {
|
||||
AssetPatch.TransformSnapshot before = AssetPatch.capture(asset);
|
||||
validateTransform(req);
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
.filter((asset) -> normalized.equals(asset.getBroadcaster()))
|
||||
.map((asset) -> {
|
||||
AssetPatch.TransformSnapshot before = AssetPatch.capture(asset);
|
||||
validateTransform(req);
|
||||
|
||||
asset.setX(req.getX());
|
||||
asset.setY(req.getY());
|
||||
asset.setWidth(req.getWidth());
|
||||
asset.setHeight(req.getHeight());
|
||||
asset.setRotation(req.getRotation());
|
||||
asset.setX(req.getX());
|
||||
asset.setY(req.getY());
|
||||
asset.setWidth(req.getWidth());
|
||||
asset.setHeight(req.getHeight());
|
||||
asset.setRotation(req.getRotation());
|
||||
|
||||
if (req.getZIndex() != null) asset.setZIndex(req.getZIndex());
|
||||
if (req.getSpeed() != null) asset.setSpeed(req.getSpeed());
|
||||
if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted());
|
||||
if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop());
|
||||
if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis());
|
||||
if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed());
|
||||
if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch());
|
||||
if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume());
|
||||
if (req.getZIndex() != null) asset.setZIndex(req.getZIndex());
|
||||
if (req.getSpeed() != null) asset.setSpeed(req.getSpeed());
|
||||
if (req.getMuted() != null && asset.isVideo()) asset.setMuted(req.getMuted());
|
||||
if (req.getAudioLoop() != null) asset.setAudioLoop(req.getAudioLoop());
|
||||
if (req.getAudioDelayMillis() != null) asset.setAudioDelayMillis(req.getAudioDelayMillis());
|
||||
if (req.getAudioSpeed() != null) asset.setAudioSpeed(req.getAudioSpeed());
|
||||
if (req.getAudioPitch() != null) asset.setAudioPitch(req.getAudioPitch());
|
||||
if (req.getAudioVolume() != null) asset.setAudioVolume(req.getAudioVolume());
|
||||
|
||||
assetRepository.save(asset);
|
||||
assetRepository.save(asset);
|
||||
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
AssetPatch patch = AssetPatch.fromTransform(before, asset, req);
|
||||
if (hasPatchChanges(patch)) {
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster),
|
||||
AssetEvent.updated(broadcaster, patch));
|
||||
}
|
||||
return view;
|
||||
});
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
AssetPatch patch = AssetPatch.fromTransform(before, asset, req);
|
||||
if (hasPatchChanges(patch)) {
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
|
||||
}
|
||||
return view;
|
||||
});
|
||||
}
|
||||
|
||||
private void validateTransform(TransformRequest req) {
|
||||
@@ -254,68 +238,90 @@ public class ChannelDirectoryService {
|
||||
double maxVolume = settings.getMaxAssetVolumeFraction();
|
||||
int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels();
|
||||
|
||||
if (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels)
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Canvas width out of range [0 to " + canvasMaxSizePixels + "]");
|
||||
if (req.getHeight() <= 0)
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Canvas height out of range [0 to " + canvasMaxSizePixels + "]");
|
||||
if (req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed))
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
|
||||
if (req.getZIndex() != null && req.getZIndex() < 1)
|
||||
throw new ResponseStatusException(BAD_REQUEST, "zIndex must be >= 1");
|
||||
if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0)
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Audio delay >= 0");
|
||||
if (req.getAudioSpeed() != null && (req.getAudioSpeed() < minSpeed || req.getAudioSpeed() > maxSpeed))
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Audio speed out of range");
|
||||
if (req.getAudioPitch() != null && (req.getAudioPitch() < minPitch || req.getAudioPitch() > maxPitch))
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Audio pitch out of range");
|
||||
if (req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume))
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Audio volume out of range [" + minVolume + " to " + maxVolume + "]");
|
||||
if (req.getWidth() <= 0 || req.getWidth() > canvasMaxSizePixels) throw new ResponseStatusException(
|
||||
BAD_REQUEST,
|
||||
"Canvas width out of range [0 to " + canvasMaxSizePixels + "]"
|
||||
);
|
||||
if (req.getHeight() <= 0) throw new ResponseStatusException(
|
||||
BAD_REQUEST,
|
||||
"Canvas height out of range [0 to " + canvasMaxSizePixels + "]"
|
||||
);
|
||||
if (
|
||||
req.getSpeed() != null && (req.getSpeed() < minSpeed || req.getSpeed() > maxSpeed)
|
||||
) throw new ResponseStatusException(BAD_REQUEST, "Speed out of range [" + minSpeed + " to " + maxSpeed + "]");
|
||||
if (req.getZIndex() != null && req.getZIndex() < 1) throw new ResponseStatusException(
|
||||
BAD_REQUEST,
|
||||
"zIndex must be >= 1"
|
||||
);
|
||||
if (req.getAudioDelayMillis() != null && req.getAudioDelayMillis() < 0) throw new ResponseStatusException(
|
||||
BAD_REQUEST,
|
||||
"Audio delay >= 0"
|
||||
);
|
||||
if (
|
||||
req.getAudioSpeed() != null && (req.getAudioSpeed() < minSpeed || req.getAudioSpeed() > maxSpeed)
|
||||
) throw new ResponseStatusException(BAD_REQUEST, "Audio speed out of range");
|
||||
if (
|
||||
req.getAudioPitch() != null && (req.getAudioPitch() < minPitch || req.getAudioPitch() > maxPitch)
|
||||
) throw new ResponseStatusException(BAD_REQUEST, "Audio pitch out of range");
|
||||
if (
|
||||
req.getAudioVolume() != null && (req.getAudioVolume() < minVolume || req.getAudioVolume() > maxVolume)
|
||||
) throw new ResponseStatusException(
|
||||
BAD_REQUEST,
|
||||
"Audio volume out of range [" + minVolume + " to " + maxVolume + "]"
|
||||
);
|
||||
}
|
||||
|
||||
public Optional<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(a -> normalized.equals(a.getBroadcaster()))
|
||||
.map(asset -> {
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
boolean play = req == null || req.getPlay();
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster),
|
||||
AssetEvent.play(broadcaster, view, play));
|
||||
return view;
|
||||
});
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
.filter((a) -> normalized.equals(a.getBroadcaster()))
|
||||
.map((asset) -> {
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
boolean play = req == null || req.getPlay();
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, play));
|
||||
return view;
|
||||
});
|
||||
}
|
||||
|
||||
public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(a -> normalized.equals(a.getBroadcaster()))
|
||||
.map(asset -> {
|
||||
boolean wasHidden = asset.isHidden();
|
||||
boolean hidden = request.isHidden();
|
||||
if (wasHidden == hidden) {
|
||||
return AssetView.from(normalized, asset);
|
||||
}
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
.filter((a) -> normalized.equals(a.getBroadcaster()))
|
||||
.map((asset) -> {
|
||||
boolean wasHidden = asset.isHidden();
|
||||
boolean hidden = request.isHidden();
|
||||
if (wasHidden == hidden) {
|
||||
return AssetView.from(normalized, asset);
|
||||
}
|
||||
|
||||
asset.setHidden(hidden);
|
||||
assetRepository.save(asset);
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
AssetPatch patch = AssetPatch.fromVisibility(asset);
|
||||
AssetView payload = hidden ? null : view;
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, patch, payload));
|
||||
return view;
|
||||
});
|
||||
asset.setHidden(hidden);
|
||||
assetRepository.save(asset);
|
||||
AssetView view = AssetView.from(normalized, asset);
|
||||
AssetPatch patch = AssetPatch.fromVisibility(asset);
|
||||
AssetView payload = hidden ? null : view;
|
||||
messagingTemplate.convertAndSend(
|
||||
topicFor(broadcaster),
|
||||
AssetEvent.visibility(broadcaster, patch, payload)
|
||||
);
|
||||
return view;
|
||||
});
|
||||
}
|
||||
|
||||
public boolean deleteAsset(String assetId) {
|
||||
return assetRepository.findById(assetId)
|
||||
.map(asset -> {
|
||||
assetRepository.delete(asset);
|
||||
assetStorageService.deleteAsset(asset);
|
||||
messagingTemplate.convertAndSend(topicFor(asset.getBroadcaster()),
|
||||
AssetEvent.deleted(asset.getBroadcaster(), assetId));
|
||||
return true;
|
||||
})
|
||||
.orElse(false);
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
.map((asset) -> {
|
||||
assetRepository.delete(asset);
|
||||
assetStorageService.deleteAsset(asset);
|
||||
messagingTemplate.convertAndSend(
|
||||
topicFor(asset.getBroadcaster()),
|
||||
AssetEvent.deleted(asset.getBroadcaster(), assetId)
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getAssetContent(String assetId) {
|
||||
@@ -323,25 +329,29 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
|
||||
return assetRepository.findById(assetId)
|
||||
.filter(a -> includeHidden || !a.isHidden())
|
||||
.flatMap(assetStorageService::loadPreviewSafely);
|
||||
return assetRepository
|
||||
.findById(assetId)
|
||||
.filter((a) -> includeHidden || !a.isHidden())
|
||||
.flatMap(assetStorageService::loadPreviewSafely);
|
||||
}
|
||||
|
||||
public boolean isAdmin(String broadcaster, String username) {
|
||||
return channelRepository.findById(normalize(broadcaster))
|
||||
.map(Channel::getAdmins)
|
||||
.map(admins -> admins.contains(normalize(username)))
|
||||
.orElse(false);
|
||||
return channelRepository
|
||||
.findById(normalize(broadcaster))
|
||||
.map(Channel::getAdmins)
|
||||
.map((admins) -> admins.contains(normalize(username)))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
public Collection<String> adminChannelsFor(String username) {
|
||||
if (username == null) return List.of();
|
||||
String login = username.toLowerCase();
|
||||
return channelRepository.findAll().stream()
|
||||
.filter(c -> c.getAdmins().contains(login))
|
||||
.map(Channel::getBroadcaster)
|
||||
.toList();
|
||||
return channelRepository
|
||||
.findAll()
|
||||
.stream()
|
||||
.filter((c) -> c.getAdmins().contains(login))
|
||||
.map(Channel::getBroadcaster)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
@@ -353,35 +363,46 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) {
|
||||
return assets.stream()
|
||||
.sorted(Comparator.comparingInt(Asset::getZIndex)
|
||||
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder())))
|
||||
.map(a -> AssetView.from(broadcaster, a))
|
||||
.toList();
|
||||
return assets
|
||||
.stream()
|
||||
.sorted(
|
||||
Comparator.comparingInt(Asset::getZIndex).thenComparing(
|
||||
Asset::getCreatedAt,
|
||||
Comparator.nullsFirst(Comparator.naturalOrder())
|
||||
)
|
||||
)
|
||||
.map((a) -> AssetView.from(broadcaster, a))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private int nextZIndex(String broadcaster) {
|
||||
return assetRepository.findByBroadcaster(normalize(broadcaster))
|
||||
return (
|
||||
assetRepository
|
||||
.findByBroadcaster(normalize(broadcaster))
|
||||
.stream()
|
||||
.mapToInt(Asset::getZIndex)
|
||||
.max()
|
||||
.orElse(0) + 1;
|
||||
.orElse(0) +
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private boolean hasPatchChanges(AssetPatch patch) {
|
||||
return patch.x() != null
|
||||
|| patch.y() != null
|
||||
|| patch.width() != null
|
||||
|| patch.height() != null
|
||||
|| patch.rotation() != null
|
||||
|| patch.speed() != null
|
||||
|| patch.muted() != null
|
||||
|| patch.zIndex() != null
|
||||
|| patch.hidden() != null
|
||||
|| patch.audioLoop() != null
|
||||
|| patch.audioDelayMillis() != null
|
||||
|| patch.audioSpeed() != null
|
||||
|| patch.audioPitch() != null
|
||||
|| patch.audioVolume() != null;
|
||||
return (
|
||||
patch.x() != null ||
|
||||
patch.y() != null ||
|
||||
patch.width() != null ||
|
||||
patch.height() != null ||
|
||||
patch.rotation() != null ||
|
||||
patch.speed() != null ||
|
||||
patch.muted() != null ||
|
||||
patch.zIndex() != null ||
|
||||
patch.hidden() != null ||
|
||||
patch.audioLoop() != null ||
|
||||
patch.audioDelayMillis() != null ||
|
||||
patch.audioSpeed() != null ||
|
||||
patch.audioPitch() != null ||
|
||||
patch.audioVolume() != null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.repository.SettingsRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -12,6 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SettingsService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(SettingsService.class);
|
||||
|
||||
private final SettingsRepository repo;
|
||||
@@ -44,12 +44,7 @@ public class SettingsService {
|
||||
|
||||
public void logSettings(String msg, Settings settings) {
|
||||
try {
|
||||
logger.info("{}:\n{}",
|
||||
msg,
|
||||
objectMapper
|
||||
.writerWithDefaultPrettyPrinter()
|
||||
.writeValueAsString(settings)
|
||||
);
|
||||
logger.info("{}:\n{}", msg, objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(settings));
|
||||
} catch (JsonProcessingException e) {
|
||||
logger.error("Failed to serialize settings", e);
|
||||
}
|
||||
|
||||
@@ -3,29 +3,26 @@ package dev.kruhlmann.imgfloat.service;
|
||||
import dev.kruhlmann.imgfloat.model.SystemAdministrator;
|
||||
import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import java.util.Locale;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
import java.util.Locale;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SystemAdministratorService {
|
||||
|
||||
private static final Logger logger =
|
||||
LoggerFactory.getLogger(SystemAdministratorService.class);
|
||||
private static final Logger logger = LoggerFactory.getLogger(SystemAdministratorService.class);
|
||||
|
||||
private final SystemAdministratorRepository repo;
|
||||
private final String initialSysadmin;
|
||||
private final Environment environment;
|
||||
|
||||
public SystemAdministratorService(
|
||||
SystemAdministratorRepository repo,
|
||||
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}")
|
||||
String initialSysadmin,
|
||||
Environment environment
|
||||
SystemAdministratorRepository repo,
|
||||
@Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") String initialSysadmin,
|
||||
Environment environment
|
||||
) {
|
||||
this.repo = repo;
|
||||
this.initialSysadmin = initialSysadmin;
|
||||
@@ -38,7 +35,11 @@ public class SystemAdministratorService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Boolean.parseBoolean(environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper"))) {
|
||||
if (
|
||||
Boolean.parseBoolean(
|
||||
environment.getProperty("org.springframework.boot.test.context.SpringBootTestContextBootstrapper")
|
||||
)
|
||||
) {
|
||||
logger.info("Skipping system administrator bootstrap in test context");
|
||||
return;
|
||||
}
|
||||
@@ -65,17 +66,13 @@ public class SystemAdministratorService {
|
||||
|
||||
public void removeSysadmin(String twitchUsername) {
|
||||
if (repo.count() <= 1) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot remove the last system administrator"
|
||||
);
|
||||
throw new IllegalStateException("Cannot remove the last system administrator");
|
||||
}
|
||||
|
||||
long deleted = repo.deleteByTwitchUsername(normalize(twitchUsername));
|
||||
|
||||
if (deleted == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"System administrator does not exist"
|
||||
);
|
||||
throw new IllegalArgumentException("System administrator does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
@@ -15,31 +29,17 @@ import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.Optional;
|
||||
|
||||
@Service
|
||||
public class TwitchUserLookupService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(TwitchUserLookupService.class);
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public TwitchUserLookupService(RestTemplateBuilder builder) {
|
||||
this.restTemplate = builder
|
||||
.setConnectTimeout(Duration.ofSeconds(15))
|
||||
.setReadTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
.setConnectTimeout(Duration.ofSeconds(15))
|
||||
.setReadTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
}
|
||||
|
||||
public List<TwitchUserProfile> fetchProfiles(Collection<String> logins, String accessToken, String clientId) {
|
||||
@@ -47,23 +47,27 @@ public class TwitchUserLookupService {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<String> normalizedLogins = logins.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(login -> login.toLowerCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> normalizedLogins = logins
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map((login) -> login.toLowerCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
Map<String, TwitchUserData> byLogin = fetchUsers(normalizedLogins, accessToken, clientId);
|
||||
|
||||
return normalizedLogins.stream()
|
||||
.map(login -> toProfile(login, byLogin.get(login)))
|
||||
.toList();
|
||||
return normalizedLogins
|
||||
.stream()
|
||||
.map((login) -> toProfile(login, byLogin.get(login)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<TwitchUserProfile> fetchModerators(String broadcasterLogin,
|
||||
Collection<String> existingAdmins,
|
||||
String accessToken,
|
||||
String clientId) {
|
||||
public List<TwitchUserProfile> fetchModerators(
|
||||
String broadcasterLogin,
|
||||
Collection<String> existingAdmins,
|
||||
String accessToken,
|
||||
String clientId
|
||||
) {
|
||||
if (broadcasterLogin == null || broadcasterLogin.isBlank()) {
|
||||
LOG.warn("Cannot fetch moderators without a broadcaster login");
|
||||
return List.of();
|
||||
@@ -77,8 +81,8 @@ public class TwitchUserLookupService {
|
||||
String normalizedBroadcaster = broadcasterLogin.toLowerCase(Locale.ROOT);
|
||||
Map<String, TwitchUserData> broadcasterData = fetchUsers(List.of(normalizedBroadcaster), accessToken, clientId);
|
||||
String broadcasterId = Optional.ofNullable(broadcasterData.get(normalizedBroadcaster))
|
||||
.map(TwitchUserData::id)
|
||||
.orElse(null);
|
||||
.map(TwitchUserData::id)
|
||||
.orElse(null);
|
||||
|
||||
if (broadcasterId == null || broadcasterId.isBlank()) {
|
||||
LOG.warn("No broadcaster id found for {} when fetching moderators", broadcasterLogin);
|
||||
@@ -87,10 +91,11 @@ public class TwitchUserLookupService {
|
||||
|
||||
Set<String> skipLogins = new HashSet<>();
|
||||
if (existingAdmins != null) {
|
||||
existingAdmins.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(login -> login.toLowerCase(Locale.ROOT))
|
||||
.forEach(skipLogins::add);
|
||||
existingAdmins
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map((login) -> login.toLowerCase(Locale.ROOT))
|
||||
.forEach(skipLogins::add);
|
||||
}
|
||||
skipLogins.add(normalizedBroadcaster);
|
||||
|
||||
@@ -102,36 +107,43 @@ public class TwitchUserLookupService {
|
||||
String cursor = null;
|
||||
|
||||
do {
|
||||
UriComponentsBuilder builder = UriComponentsBuilder
|
||||
.fromHttpUrl("https://api.twitch.tv/helix/moderation/moderators")
|
||||
.queryParam("broadcaster_id", broadcasterId)
|
||||
.queryParam("first", 100);
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(
|
||||
"https://api.twitch.tv/helix/moderation/moderators"
|
||||
)
|
||||
.queryParam("broadcaster_id", broadcasterId)
|
||||
.queryParam("first", 100);
|
||||
if (cursor != null && !cursor.isBlank()) {
|
||||
builder.queryParam("after", cursor);
|
||||
}
|
||||
|
||||
try {
|
||||
ResponseEntity<TwitchModeratorsResponse> response = restTemplate.exchange(
|
||||
builder.build(true).toUri(),
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
TwitchModeratorsResponse.class);
|
||||
builder.build(true).toUri(),
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
TwitchModeratorsResponse.class
|
||||
);
|
||||
|
||||
TwitchModeratorsResponse body = response.getBody();
|
||||
LOG.debug("Fetched {} moderator records for {} (cursor={})", body != null && body.data() != null ? body.data().size() : 0, broadcasterLogin, cursor);
|
||||
LOG.debug(
|
||||
"Fetched {} moderator records for {} (cursor={})",
|
||||
body != null && body.data() != null ? body.data().size() : 0,
|
||||
broadcasterLogin,
|
||||
cursor
|
||||
);
|
||||
if (body != null && body.data() != null) {
|
||||
body.data().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModeratorData::userLogin)
|
||||
.filter(Objects::nonNull)
|
||||
.map(login -> login.toLowerCase(Locale.ROOT))
|
||||
.filter(login -> !skipLogins.contains(login))
|
||||
.forEach(moderatorLogins::add);
|
||||
body
|
||||
.data()
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(ModeratorData::userLogin)
|
||||
.filter(Objects::nonNull)
|
||||
.map((login) -> login.toLowerCase(Locale.ROOT))
|
||||
.filter((login) -> !skipLogins.contains(login))
|
||||
.forEach(moderatorLogins::add);
|
||||
}
|
||||
|
||||
cursor = body != null && body.pagination() != null
|
||||
? body.pagination().cursor()
|
||||
: null;
|
||||
cursor = body != null && body.pagination() != null ? body.pagination().cursor() : null;
|
||||
} catch (RestClientException ex) {
|
||||
LOG.warn("Unable to fetch Twitch moderators for {}", broadcasterLogin, ex);
|
||||
return List.of();
|
||||
@@ -158,11 +170,12 @@ public class TwitchUserLookupService {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
List<String> normalizedLogins = logins.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(login -> login.toLowerCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> normalizedLogins = logins
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map((login) -> login.toLowerCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
|
||||
return Collections.emptyMap();
|
||||
@@ -172,27 +185,33 @@ public class TwitchUserLookupService {
|
||||
headers.setBearerAuth(accessToken);
|
||||
headers.add("Client-ID", clientId);
|
||||
|
||||
UriComponentsBuilder uriBuilder = UriComponentsBuilder
|
||||
.fromHttpUrl("https://api.twitch.tv/helix/users");
|
||||
normalizedLogins.forEach(login -> uriBuilder.queryParam("login", login));
|
||||
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl("https://api.twitch.tv/helix/users");
|
||||
normalizedLogins.forEach((login) -> uriBuilder.queryParam("login", login));
|
||||
|
||||
HttpEntity<Void> entity = new HttpEntity<>(headers);
|
||||
try {
|
||||
ResponseEntity<TwitchUsersResponse> response = restTemplate.exchange(
|
||||
uriBuilder.build(true).toUri(),
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
TwitchUsersResponse.class);
|
||||
uriBuilder.build(true).toUri(),
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
TwitchUsersResponse.class
|
||||
);
|
||||
|
||||
return response.getBody() == null
|
||||
? Collections.emptyMap()
|
||||
: response.getBody().data().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toMap(
|
||||
user -> user.login().toLowerCase(Locale.ROOT),
|
||||
Function.identity(),
|
||||
(a, b) -> a,
|
||||
HashMap::new));
|
||||
? Collections.emptyMap()
|
||||
: response
|
||||
.getBody()
|
||||
.data()
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.collect(
|
||||
Collectors.toMap(
|
||||
(user) -> user.login().toLowerCase(Locale.ROOT),
|
||||
Function.identity(),
|
||||
(a, b) -> a,
|
||||
HashMap::new
|
||||
)
|
||||
);
|
||||
} catch (RestClientException ex) {
|
||||
LOG.warn("Unable to fetch Twitch user profiles", ex);
|
||||
return Collections.emptyMap();
|
||||
@@ -200,31 +219,26 @@ public class TwitchUserLookupService {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record TwitchUsersResponse(List<TwitchUserData> data) {
|
||||
}
|
||||
private record TwitchUsersResponse(List<TwitchUserData> data) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record TwitchUserData(
|
||||
String id,
|
||||
String login,
|
||||
@JsonProperty("display_name") String displayName,
|
||||
@JsonProperty("profile_image_url") String profileImageUrl) {
|
||||
}
|
||||
String id,
|
||||
String login,
|
||||
@JsonProperty("display_name") String displayName,
|
||||
@JsonProperty("profile_image_url") String profileImageUrl
|
||||
) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record TwitchModeratorsResponse(
|
||||
List<ModeratorData> data,
|
||||
Pagination pagination) {
|
||||
}
|
||||
private record TwitchModeratorsResponse(List<ModeratorData> data, Pagination pagination) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record ModeratorData(
|
||||
@JsonProperty("user_id") String userId,
|
||||
@JsonProperty("user_login") String userLogin,
|
||||
@JsonProperty("user_name") String userName) {
|
||||
}
|
||||
@JsonProperty("user_id") String userId,
|
||||
@JsonProperty("user_login") String userLogin,
|
||||
@JsonProperty("user_name") String userName
|
||||
) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record Pagination(String cursor) {
|
||||
}
|
||||
private record Pagination(String cursor) {}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class VersionService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(VersionService.class);
|
||||
private final String version;
|
||||
private final String releaseVersion;
|
||||
@@ -58,7 +58,9 @@ public class VersionService {
|
||||
}
|
||||
|
||||
private String getPomVersion() {
|
||||
try (var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")) {
|
||||
try (
|
||||
var inputStream = getClass().getResourceAsStream("/META-INF/maven/dev.kruhlmann/imgfloat/pom.properties")
|
||||
) {
|
||||
if (inputStream == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package dev.kruhlmann.imgfloat.service.media;
|
||||
|
||||
public record AssetContent(byte[] bytes, String mediaType) { }
|
||||
public record AssetContent(byte[] bytes, String mediaType) {}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
package dev.kruhlmann.imgfloat.service.media;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URLConnection;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Service
|
||||
public class MediaDetectionService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MediaDetectionService.class);
|
||||
private static final Map<String, String> EXTENSION_TYPES = Map.ofEntries(
|
||||
Map.entry("png", "image/png"),
|
||||
Map.entry("jpg", "image/jpeg"),
|
||||
Map.entry("jpeg", "image/jpeg"),
|
||||
Map.entry("gif", "image/gif"),
|
||||
Map.entry("webp", "image/webp"),
|
||||
Map.entry("mp4", "video/mp4"),
|
||||
Map.entry("webm", "video/webm"),
|
||||
Map.entry("mov", "video/quicktime"),
|
||||
Map.entry("mp3", "audio/mpeg"),
|
||||
Map.entry("wav", "audio/wav"),
|
||||
Map.entry("ogg", "audio/ogg")
|
||||
Map.entry("png", "image/png"),
|
||||
Map.entry("jpg", "image/jpeg"),
|
||||
Map.entry("jpeg", "image/jpeg"),
|
||||
Map.entry("gif", "image/gif"),
|
||||
Map.entry("webp", "image/webp"),
|
||||
Map.entry("mp4", "video/mp4"),
|
||||
Map.entry("webm", "video/webm"),
|
||||
Map.entry("mov", "video/quicktime"),
|
||||
Map.entry("mp3", "audio/mpeg"),
|
||||
Map.entry("wav", "audio/wav"),
|
||||
Map.entry("ogg", "audio/ogg")
|
||||
);
|
||||
private static final Set<String> ALLOWED_MEDIA_TYPES = Set.copyOf(EXTENSION_TYPES.values());
|
||||
|
||||
public Optional<String> detectAllowedMediaType(MultipartFile file, byte[] bytes) {
|
||||
Optional<String> detected = detectMediaType(bytes)
|
||||
.filter(MediaDetectionService::isAllowedMediaType);
|
||||
Optional<String> detected = detectMediaType(bytes).filter(MediaDetectionService::isAllowedMediaType);
|
||||
|
||||
if (detected.isPresent()) {
|
||||
return detected;
|
||||
}
|
||||
|
||||
Optional<String> declared = Optional.ofNullable(file.getContentType())
|
||||
.filter(MediaDetectionService::isAllowedMediaType);
|
||||
Optional<String> declared = Optional.ofNullable(file.getContentType()).filter(
|
||||
MediaDetectionService::isAllowedMediaType
|
||||
);
|
||||
if (declared.isPresent()) {
|
||||
return declared;
|
||||
}
|
||||
|
||||
return Optional.ofNullable(file.getOriginalFilename())
|
||||
.map(name -> name.replaceAll("^.*\\.", "").toLowerCase())
|
||||
.map(EXTENSION_TYPES::get)
|
||||
.filter(MediaDetectionService::isAllowedMediaType);
|
||||
.map((name) -> name.replaceAll("^.*\\.", "").toLowerCase())
|
||||
.map(EXTENSION_TYPES::get)
|
||||
.filter(MediaDetectionService::isAllowedMediaType);
|
||||
}
|
||||
|
||||
private Optional<String> detectMediaType(byte[] bytes) {
|
||||
@@ -68,6 +68,9 @@ public class MediaDetectionService {
|
||||
}
|
||||
|
||||
public static boolean isInlineDisplayType(String mediaType) {
|
||||
return mediaType != null && (mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/"));
|
||||
return (
|
||||
mediaType != null &&
|
||||
(mediaType.startsWith("image/") || mediaType.startsWith("video/") || mediaType.startsWith("audio/"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
package dev.kruhlmann.imgfloat.service.media;
|
||||
|
||||
import org.jcodec.api.FrameGrab;
|
||||
import org.jcodec.api.JCodecException;
|
||||
import org.jcodec.common.io.ByteBufferSeekableByteChannel;
|
||||
import org.jcodec.common.model.Picture;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.metadata.IIOMetadata;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
@@ -24,9 +9,24 @@ import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import javax.imageio.IIOImage;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageWriteParam;
|
||||
import javax.imageio.ImageWriter;
|
||||
import javax.imageio.metadata.IIOMetadata;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import javax.imageio.stream.ImageOutputStream;
|
||||
import org.jcodec.api.FrameGrab;
|
||||
import org.jcodec.api.JCodecException;
|
||||
import org.jcodec.common.io.ByteBufferSeekableByteChannel;
|
||||
import org.jcodec.common.model.Picture;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class MediaOptimizationService {
|
||||
|
||||
private static final int MIN_GIF_DELAY_MS = 20;
|
||||
private static final Logger logger = LoggerFactory.getLogger(MediaOptimizationService.class);
|
||||
private final MediaPreviewService previewService;
|
||||
@@ -86,10 +86,11 @@ public class MediaOptimizationService {
|
||||
if (frames.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
int baseDelay = frames.stream()
|
||||
.mapToInt(frame -> normalizeDelay(frame.delayMs()))
|
||||
.reduce(this::greatestCommonDivisor)
|
||||
.orElse(100);
|
||||
int baseDelay = frames
|
||||
.stream()
|
||||
.mapToInt((frame) -> normalizeDelay(frame.delayMs()))
|
||||
.reduce(this::greatestCommonDivisor)
|
||||
.orElse(100);
|
||||
int fps = Math.max(1, (int) Math.round(1000.0 / baseDelay));
|
||||
File temp = File.createTempFile("gif-convert", ".mp4");
|
||||
temp.deleteOnExit();
|
||||
@@ -104,7 +105,13 @@ public class MediaOptimizationService {
|
||||
encoder.finish();
|
||||
BufferedImage cover = frames.get(0).image();
|
||||
byte[] video = Files.readAllBytes(temp.toPath());
|
||||
return new OptimizedAsset(video, "video/mp4", cover.getWidth(), cover.getHeight(), previewService.encodePreview(cover));
|
||||
return new OptimizedAsset(
|
||||
video,
|
||||
"video/mp4",
|
||||
cover.getWidth(),
|
||||
cover.getHeight(),
|
||||
previewService.encodePreview(cover)
|
||||
);
|
||||
} finally {
|
||||
Files.deleteIfExists(temp.toPath());
|
||||
}
|
||||
@@ -183,8 +190,10 @@ public class MediaOptimizationService {
|
||||
}
|
||||
}
|
||||
ImageWriter writer = writers.next();
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) {
|
||||
try (
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ImageOutputStream ios = ImageIO.createImageOutputStream(baos)
|
||||
) {
|
||||
writer.setOutput(ios);
|
||||
ImageWriteParam param = writer.getDefaultWriteParam();
|
||||
if (param.canWriteCompressed()) {
|
||||
@@ -211,7 +220,7 @@ public class MediaOptimizationService {
|
||||
return new Dimension(640, 360);
|
||||
}
|
||||
|
||||
private record GifFrame(BufferedImage image, int delayMs) { }
|
||||
private record GifFrame(BufferedImage image, int delayMs) {}
|
||||
|
||||
private record Dimension(int width, int height) { }
|
||||
private record Dimension(int width, int height) {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package dev.kruhlmann.imgfloat.service.media;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import javax.imageio.ImageIO;
|
||||
import org.jcodec.api.FrameGrab;
|
||||
import org.jcodec.api.JCodecException;
|
||||
import org.jcodec.common.io.ByteBufferSeekableByteChannel;
|
||||
@@ -9,14 +14,9 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
@Service
|
||||
public class MediaPreviewService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MediaPreviewService.class);
|
||||
|
||||
public byte[] encodePreview(BufferedImage image) {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package dev.kruhlmann.imgfloat.service.media;
|
||||
|
||||
public record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, byte[] previewBytes) { }
|
||||
public record OptimizedAsset(byte[] bytes, String mediaType, int width, int height, byte[] previewBytes) {}
|
||||
|
||||
@@ -2,35 +2,35 @@ const { app, BrowserWindow } = require("electron");
|
||||
const path = require("path");
|
||||
|
||||
function createWindow() {
|
||||
const url = "https://imgfloat.kruhlmann.dev/channels";
|
||||
const initialWindowWidthPx = 960;
|
||||
const initialWindowHeightPx = 640;
|
||||
const applicationWindow = new BrowserWindow({
|
||||
width: initialWindowWidthPx,
|
||||
height: initialWindowHeightPx,
|
||||
transparent: true,
|
||||
frame: true,
|
||||
backgroundColor: "#00000000",
|
||||
alwaysOnTop: false,
|
||||
icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"),
|
||||
webPreferences: { backgroundThrottling: false },
|
||||
});
|
||||
applicationWindow.setMenu(null);
|
||||
const url = "https://imgfloat.kruhlmann.dev/channels";
|
||||
const initialWindowWidthPx = 960;
|
||||
const initialWindowHeightPx = 640;
|
||||
const applicationWindow = new BrowserWindow({
|
||||
width: initialWindowWidthPx,
|
||||
height: initialWindowHeightPx,
|
||||
transparent: true,
|
||||
frame: true,
|
||||
backgroundColor: "#00000000",
|
||||
alwaysOnTop: false,
|
||||
icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"),
|
||||
webPreferences: { backgroundThrottling: false },
|
||||
});
|
||||
applicationWindow.setMenu(null);
|
||||
|
||||
let canvasSizeInterval;
|
||||
const clearCanvasSizeInterval = () => {
|
||||
if (canvasSizeInterval) {
|
||||
clearInterval(canvasSizeInterval);
|
||||
canvasSizeInterval = undefined;
|
||||
}
|
||||
};
|
||||
let canvasSizeInterval;
|
||||
const clearCanvasSizeInterval = () => {
|
||||
if (canvasSizeInterval) {
|
||||
clearInterval(canvasSizeInterval);
|
||||
canvasSizeInterval = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const lockWindowToCanvas = async () => {
|
||||
if (applicationWindow.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const size = await applicationWindow.webContents.executeJavaScript(`(() => {
|
||||
const lockWindowToCanvas = async () => {
|
||||
if (applicationWindow.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const size = await applicationWindow.webContents.executeJavaScript(`(() => {
|
||||
const canvas = document.getElementById('broadcast-canvas');
|
||||
if (!canvas || !canvas.width || !canvas.height) {
|
||||
return null;
|
||||
@@ -38,52 +38,54 @@ function createWindow() {
|
||||
return { width: Math.round(canvas.width), height: Math.round(canvas.height) };
|
||||
})();`);
|
||||
|
||||
if (size?.width && size?.height) {
|
||||
const [currentWidth, currentHeight] = applicationWindow.getSize();
|
||||
if (currentWidth !== size.width || currentHeight !== size.height) {
|
||||
applicationWindow.setSize(size.width, size.height, false);
|
||||
if (size?.width && size?.height) {
|
||||
const [currentWidth, currentHeight] = applicationWindow.getSize();
|
||||
if (currentWidth !== size.width || currentHeight !== size.height) {
|
||||
applicationWindow.setSize(size.width, size.height, false);
|
||||
}
|
||||
applicationWindow.setMinimumSize(size.width, size.height);
|
||||
applicationWindow.setMaximumSize(size.width, size.height);
|
||||
applicationWindow.setResizable(false);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Best-effort sizing; ignore errors from early navigation states.
|
||||
}
|
||||
applicationWindow.setMinimumSize(size.width, size.height);
|
||||
applicationWindow.setMaximumSize(size.width, size.height);
|
||||
applicationWindow.setResizable(false);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Best-effort sizing; ignore errors from early navigation states.
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleNavigation = (navigationUrl) => {
|
||||
try {
|
||||
const { pathname } = new URL(navigationUrl);
|
||||
const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname);
|
||||
const handleNavigation = (navigationUrl) => {
|
||||
try {
|
||||
const { pathname } = new URL(navigationUrl);
|
||||
const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname);
|
||||
|
||||
if (isBroadcast) {
|
||||
clearCanvasSizeInterval();
|
||||
canvasSizeInterval = setInterval(lockWindowToCanvas, 750);
|
||||
lockWindowToCanvas();
|
||||
} else {
|
||||
clearCanvasSizeInterval();
|
||||
applicationWindow.setResizable(true);
|
||||
applicationWindow.setMinimumSize(320, 240);
|
||||
applicationWindow.setMaximumSize(10000, 10000);
|
||||
applicationWindow.setSize(initialWindowWidthPx, initialWindowHeightPx, false);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs while navigating.
|
||||
}
|
||||
};
|
||||
if (isBroadcast) {
|
||||
clearCanvasSizeInterval();
|
||||
canvasSizeInterval = setInterval(lockWindowToCanvas, 750);
|
||||
lockWindowToCanvas();
|
||||
} else {
|
||||
clearCanvasSizeInterval();
|
||||
applicationWindow.setResizable(true);
|
||||
applicationWindow.setMinimumSize(320, 240);
|
||||
applicationWindow.setMaximumSize(10000, 10000);
|
||||
applicationWindow.setSize(initialWindowWidthPx, initialWindowHeightPx, false);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs while navigating.
|
||||
}
|
||||
};
|
||||
|
||||
applicationWindow.loadURL(url);
|
||||
applicationWindow.loadURL(url);
|
||||
|
||||
applicationWindow.webContents.on("did-finish-load", () => {
|
||||
handleNavigation(applicationWindow.webContents.getURL());
|
||||
});
|
||||
applicationWindow.webContents.on("did-finish-load", () => {
|
||||
handleNavigation(applicationWindow.webContents.getURL());
|
||||
});
|
||||
|
||||
applicationWindow.webContents.on("did-navigate", (_event, navigationUrl) => handleNavigation(navigationUrl));
|
||||
applicationWindow.webContents.on("did-navigate-in-page", (_event, navigationUrl) => handleNavigation(navigationUrl));
|
||||
applicationWindow.on("closed", clearCanvasSizeInterval);
|
||||
applicationWindow.webContents.on("did-navigate", (_event, navigationUrl) => handleNavigation(navigationUrl));
|
||||
applicationWindow.webContents.on("did-navigate-in-page", (_event, navigationUrl) =>
|
||||
handleNavigation(navigationUrl),
|
||||
);
|
||||
applicationWindow.on("closed", clearCanvasSizeInterval);
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
@@ -1,65 +1,65 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
tomcat:
|
||||
max-swallow-size: 0
|
||||
ssl:
|
||||
enabled: ${SSL_ENABLED:false}
|
||||
key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12}
|
||||
key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit}
|
||||
key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12}
|
||||
error:
|
||||
include-message: never
|
||||
include-stacktrace: never
|
||||
port: ${SERVER_PORT:8080}
|
||||
tomcat:
|
||||
max-swallow-size: 0
|
||||
ssl:
|
||||
enabled: ${SSL_ENABLED:false}
|
||||
key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12}
|
||||
key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit}
|
||||
key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12}
|
||||
error:
|
||||
include-message: never
|
||||
include-stacktrace: never
|
||||
|
||||
spring:
|
||||
config:
|
||||
import: optional:file:.env[.properties]
|
||||
application:
|
||||
name: imgfloat
|
||||
devtools:
|
||||
restart:
|
||||
enabled: true
|
||||
livereload:
|
||||
enabled: true
|
||||
thymeleaf:
|
||||
cache: false
|
||||
datasource:
|
||||
url: jdbc:sqlite:${IMGFLOAT_DB_PATH}?busy_timeout=5000&journal_mode=WAL
|
||||
driver-class-name: org.sqlite.JDBC
|
||||
hikari:
|
||||
connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;"
|
||||
maximum-pool-size: 1
|
||||
minimum-idle: 1
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
database-platform: org.hibernate.community.dialect.SQLiteDialect
|
||||
session:
|
||||
store-type: jdbc
|
||||
jdbc:
|
||||
initialize-schema: always
|
||||
platform: sqlite
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
twitch:
|
||||
client-id: ${TWITCH_CLIENT_ID}
|
||||
client-secret: ${TWITCH_CLIENT_SECRET}
|
||||
client-authentication-method: client_secret_post
|
||||
redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}"
|
||||
authorization-grant-type: authorization_code
|
||||
scope: ["user:read:email", "moderation:read"]
|
||||
provider:
|
||||
twitch:
|
||||
authorization-uri: https://id.twitch.tv/oauth2/authorize
|
||||
token-uri: https://id.twitch.tv/oauth2/token
|
||||
user-info-uri: https://api.twitch.tv/helix/users
|
||||
user-name-attribute: login
|
||||
config:
|
||||
import: optional:file:.env[.properties]
|
||||
application:
|
||||
name: imgfloat
|
||||
devtools:
|
||||
restart:
|
||||
enabled: true
|
||||
livereload:
|
||||
enabled: true
|
||||
thymeleaf:
|
||||
cache: false
|
||||
datasource:
|
||||
url: jdbc:sqlite:${IMGFLOAT_DB_PATH}?busy_timeout=5000&journal_mode=WAL
|
||||
driver-class-name: org.sqlite.JDBC
|
||||
hikari:
|
||||
connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;"
|
||||
maximum-pool-size: 1
|
||||
minimum-idle: 1
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
database-platform: org.hibernate.community.dialect.SQLiteDialect
|
||||
session:
|
||||
store-type: jdbc
|
||||
jdbc:
|
||||
initialize-schema: always
|
||||
platform: sqlite
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
twitch:
|
||||
client-id: ${TWITCH_CLIENT_ID}
|
||||
client-secret: ${TWITCH_CLIENT_SECRET}
|
||||
client-authentication-method: client_secret_post
|
||||
redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}"
|
||||
authorization-grant-type: authorization_code
|
||||
scope: ["user:read:email", "moderation:read"]
|
||||
provider:
|
||||
twitch:
|
||||
authorization-uri: https://id.twitch.tv/oauth2/authorize
|
||||
token-uri: https://id.twitch.tv/oauth2/token
|
||||
user-info-uri: https://api.twitch.tv/helix/users
|
||||
user-name-attribute: login
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
const persistDismissal = () => {
|
||||
try {
|
||||
window.localStorage.setItem(CONSENT_STORAGE_KEY, "true");
|
||||
} catch { }
|
||||
} catch {}
|
||||
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") {
|
||||
return true;
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
return readConsentCookie() === "true";
|
||||
};
|
||||
|
||||
|
||||
@@ -1,228 +1,228 @@
|
||||
function buildIdentity(admin) {
|
||||
const identity = document.createElement("div");
|
||||
identity.className = "identity-row";
|
||||
const identity = document.createElement("div");
|
||||
identity.className = "identity-row";
|
||||
|
||||
const avatar = document.createElement(admin.avatarUrl ? "img" : "div");
|
||||
avatar.className = "avatar";
|
||||
if (admin.avatarUrl) {
|
||||
avatar.src = admin.avatarUrl;
|
||||
avatar.alt = `${admin.displayName || admin.login} avatar`;
|
||||
} else {
|
||||
avatar.classList.add("avatar-fallback");
|
||||
avatar.textContent = (admin.displayName || admin.login || "?").charAt(0).toUpperCase();
|
||||
}
|
||||
const avatar = document.createElement(admin.avatarUrl ? "img" : "div");
|
||||
avatar.className = "avatar";
|
||||
if (admin.avatarUrl) {
|
||||
avatar.src = admin.avatarUrl;
|
||||
avatar.alt = `${admin.displayName || admin.login} avatar`;
|
||||
} else {
|
||||
avatar.classList.add("avatar-fallback");
|
||||
avatar.textContent = (admin.displayName || admin.login || "?").charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
const details = document.createElement("div");
|
||||
details.className = "identity-text";
|
||||
const title = document.createElement("p");
|
||||
title.className = "list-title";
|
||||
title.textContent = admin.displayName || admin.login;
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "muted";
|
||||
subtitle.textContent = `@${admin.login}`;
|
||||
const details = document.createElement("div");
|
||||
details.className = "identity-text";
|
||||
const title = document.createElement("p");
|
||||
title.className = "list-title";
|
||||
title.textContent = admin.displayName || admin.login;
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.className = "muted";
|
||||
subtitle.textContent = `@${admin.login}`;
|
||||
|
||||
details.appendChild(title);
|
||||
details.appendChild(subtitle);
|
||||
identity.appendChild(avatar);
|
||||
identity.appendChild(details);
|
||||
return identity;
|
||||
details.appendChild(title);
|
||||
details.appendChild(subtitle);
|
||||
identity.appendChild(avatar);
|
||||
identity.appendChild(details);
|
||||
return identity;
|
||||
}
|
||||
|
||||
function renderAdmins(list) {
|
||||
const adminList = document.getElementById("admin-list");
|
||||
if (!adminList) return;
|
||||
adminList.innerHTML = "";
|
||||
if (!list || list.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.textContent = "No channel admins yet";
|
||||
adminList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const adminList = document.getElementById("admin-list");
|
||||
if (!adminList) return;
|
||||
adminList.innerHTML = "";
|
||||
if (!list || list.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.textContent = "No channel admins yet";
|
||||
adminList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
list.forEach((admin) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "stacked-list-item";
|
||||
list.forEach((admin) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "stacked-list-item";
|
||||
|
||||
li.appendChild(buildIdentity(admin));
|
||||
li.appendChild(buildIdentity(admin));
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "actions";
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "actions";
|
||||
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className = "secondary";
|
||||
removeBtn.textContent = "Remove";
|
||||
removeBtn.addEventListener("click", () => removeAdmin(admin.login));
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className = "secondary";
|
||||
removeBtn.textContent = "Remove";
|
||||
removeBtn.addEventListener("click", () => removeAdmin(admin.login));
|
||||
|
||||
actions.appendChild(removeBtn);
|
||||
li.appendChild(actions);
|
||||
adminList.appendChild(li);
|
||||
});
|
||||
actions.appendChild(removeBtn);
|
||||
li.appendChild(actions);
|
||||
adminList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSuggestedAdmins(list) {
|
||||
const suggestionList = document.getElementById("admin-suggestions");
|
||||
if (!suggestionList) return;
|
||||
const suggestionList = document.getElementById("admin-suggestions");
|
||||
if (!suggestionList) return;
|
||||
|
||||
suggestionList.innerHTML = "";
|
||||
if (!list || list.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "stacked-list-item";
|
||||
empty.textContent = "No moderator suggestions right now";
|
||||
suggestionList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
suggestionList.innerHTML = "";
|
||||
if (!list || list.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "stacked-list-item";
|
||||
empty.textContent = "No moderator suggestions right now";
|
||||
suggestionList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
list.forEach((admin) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "stacked-list-item";
|
||||
list.forEach((admin) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "stacked-list-item";
|
||||
|
||||
li.appendChild(buildIdentity(admin));
|
||||
li.appendChild(buildIdentity(admin));
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "actions";
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "actions";
|
||||
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "ghost";
|
||||
addBtn.textContent = "Add as admin";
|
||||
addBtn.addEventListener("click", () => addAdmin(admin.login));
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "ghost";
|
||||
addBtn.textContent = "Add as admin";
|
||||
addBtn.addEventListener("click", () => addAdmin(admin.login));
|
||||
|
||||
actions.appendChild(addBtn);
|
||||
li.appendChild(actions);
|
||||
suggestionList.appendChild(li);
|
||||
});
|
||||
actions.appendChild(addBtn);
|
||||
li.appendChild(actions);
|
||||
suggestionList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchSuggestedAdmins() {
|
||||
fetch(`/api/channels/${broadcaster}/admins/suggestions`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load admin suggestions");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(renderSuggestedAdmins)
|
||||
.catch(() => {
|
||||
renderSuggestedAdmins([]);
|
||||
});
|
||||
fetch(`/api/channels/${broadcaster}/admins/suggestions`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load admin suggestions");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(renderSuggestedAdmins)
|
||||
.catch(() => {
|
||||
renderSuggestedAdmins([]);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchAdmins() {
|
||||
fetch(`/api/channels/${broadcaster}/admins`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load admins");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(renderAdmins)
|
||||
.catch(() => {
|
||||
renderAdmins([]);
|
||||
showToast("Unable to load admins right now. Please try again.", "error");
|
||||
});
|
||||
fetch(`/api/channels/${broadcaster}/admins`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load admins");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(renderAdmins)
|
||||
.catch(() => {
|
||||
renderAdmins([]);
|
||||
showToast("Unable to load admins right now. Please try again.", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function removeAdmin(username) {
|
||||
if (!username) return;
|
||||
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
fetchAdmins();
|
||||
fetchSuggestedAdmins();
|
||||
if (!username) return;
|
||||
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.catch(() => {
|
||||
showToast("Failed to remove admin. Please retry.", "error");
|
||||
});
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
fetchAdmins();
|
||||
fetchSuggestedAdmins();
|
||||
})
|
||||
.catch(() => {
|
||||
showToast("Failed to remove admin. Please retry.", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function addAdmin(usernameFromAction) {
|
||||
const input = document.getElementById("new-admin");
|
||||
const username = (usernameFromAction || input?.value || "").trim();
|
||||
if (!username) {
|
||||
showToast("Enter a Twitch username to add as an admin.", "info");
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById("new-admin");
|
||||
const username = (usernameFromAction || input?.value || "").trim();
|
||||
if (!username) {
|
||||
showToast("Enter a Twitch username to add as an admin.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/channels/${broadcaster}/admins`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Add admin failed");
|
||||
}
|
||||
if (input) {
|
||||
input.value = "";
|
||||
}
|
||||
showToast(`Added @${username} as an admin.`, "success");
|
||||
fetchAdmins();
|
||||
fetchSuggestedAdmins();
|
||||
fetch(`/api/channels/${broadcaster}/admins`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
})
|
||||
.catch(() => showToast("Unable to add admin right now. Please try again.", "error"));
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Add admin failed");
|
||||
}
|
||||
if (input) {
|
||||
input.value = "";
|
||||
}
|
||||
showToast(`Added @${username} as an admin.`, "success");
|
||||
fetchAdmins();
|
||||
fetchSuggestedAdmins();
|
||||
})
|
||||
.catch(() => showToast("Unable to add admin right now. Please try again.", "error"));
|
||||
}
|
||||
|
||||
function renderCanvasSettings(settings) {
|
||||
const widthInput = document.getElementById("canvas-width");
|
||||
const heightInput = document.getElementById("canvas-height");
|
||||
if (widthInput) widthInput.value = Math.round(settings.width);
|
||||
if (heightInput) heightInput.value = Math.round(settings.height);
|
||||
const widthInput = document.getElementById("canvas-width");
|
||||
const heightInput = document.getElementById("canvas-height");
|
||||
if (widthInput) widthInput.value = Math.round(settings.width);
|
||||
if (heightInput) heightInput.value = Math.round(settings.height);
|
||||
}
|
||||
|
||||
function fetchCanvasSettings() {
|
||||
fetch(`/api/channels/${broadcaster}/canvas`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load canvas settings");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(renderCanvasSettings)
|
||||
.catch(() => {
|
||||
renderCanvasSettings({ width: 1920, height: 1080 });
|
||||
showToast("Using default canvas size. Unable to load saved settings.", "warning");
|
||||
});
|
||||
fetch(`/api/channels/${broadcaster}/canvas`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load canvas settings");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(renderCanvasSettings)
|
||||
.catch(() => {
|
||||
renderCanvasSettings({ width: 1920, height: 1080 });
|
||||
showToast("Using default canvas size. Unable to load saved settings.", "warning");
|
||||
});
|
||||
}
|
||||
|
||||
function saveCanvasSettings() {
|
||||
const widthInput = document.getElementById("canvas-width");
|
||||
const heightInput = document.getElementById("canvas-height");
|
||||
const status = document.getElementById("canvas-status");
|
||||
const width = parseFloat(widthInput?.value) || 0;
|
||||
const height = parseFloat(heightInput?.value) || 0;
|
||||
if (width <= 0 || height <= 0) {
|
||||
showToast("Please enter a valid width and height.", "info");
|
||||
return;
|
||||
}
|
||||
if (status) status.textContent = "Saving...";
|
||||
fetch(`/api/channels/${broadcaster}/canvas`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ width, height }),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to save canvas");
|
||||
}
|
||||
return r.json();
|
||||
const widthInput = document.getElementById("canvas-width");
|
||||
const heightInput = document.getElementById("canvas-height");
|
||||
const status = document.getElementById("canvas-status");
|
||||
const width = parseFloat(widthInput?.value) || 0;
|
||||
const height = parseFloat(heightInput?.value) || 0;
|
||||
if (width <= 0 || height <= 0) {
|
||||
showToast("Please enter a valid width and height.", "info");
|
||||
return;
|
||||
}
|
||||
if (status) status.textContent = "Saving...";
|
||||
fetch(`/api/channels/${broadcaster}/canvas`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ width, height }),
|
||||
})
|
||||
.then((settings) => {
|
||||
renderCanvasSettings(settings);
|
||||
if (status) status.textContent = "Saved.";
|
||||
showToast("Canvas size saved successfully.", "success");
|
||||
setTimeout(() => {
|
||||
if (status) status.textContent = "";
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
if (status) status.textContent = "Unable to save right now.";
|
||||
showToast("Unable to save canvas size. Please retry.", "error");
|
||||
});
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to save canvas");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((settings) => {
|
||||
renderCanvasSettings(settings);
|
||||
if (status) status.textContent = "Saved.";
|
||||
showToast("Canvas size saved successfully.", "success");
|
||||
setTimeout(() => {
|
||||
if (status) status.textContent = "";
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
if (status) status.textContent = "Unable to save right now.";
|
||||
showToast("Unable to save canvas size. Please retry.", "error");
|
||||
});
|
||||
}
|
||||
|
||||
fetchAdmins();
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
function detectPlatform() {
|
||||
const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || "").toLowerCase();
|
||||
const userAgent = (navigator.userAgent || "").toLowerCase();
|
||||
const platformString = `${navigatorPlatform} ${userAgent}`;
|
||||
const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || "").toLowerCase();
|
||||
const userAgent = (navigator.userAgent || "").toLowerCase();
|
||||
const platformString = `${navigatorPlatform} ${userAgent}`;
|
||||
|
||||
if (platformString.includes("mac") || platformString.includes("darwin")) {
|
||||
return "mac";
|
||||
}
|
||||
if (platformString.includes("win")) {
|
||||
return "windows";
|
||||
}
|
||||
if (platformString.includes("linux")) {
|
||||
return "linux";
|
||||
}
|
||||
return null;
|
||||
if (platformString.includes("mac") || platformString.includes("darwin")) {
|
||||
return "mac";
|
||||
}
|
||||
if (platformString.includes("win")) {
|
||||
return "windows";
|
||||
}
|
||||
if (platformString.includes("linux")) {
|
||||
return "linux";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function markRecommendedDownload(section) {
|
||||
const cards = Array.from(section.querySelectorAll(".download-card"));
|
||||
if (!cards.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = detectPlatform();
|
||||
const preferredCard = cards.find((card) => card.dataset.platform === platform) || cards[0];
|
||||
|
||||
cards.forEach((card) => {
|
||||
const isPreferred = card === preferredCard;
|
||||
card.classList.toggle("download-card--active", isPreferred);
|
||||
const badge = card.querySelector(".recommended-badge");
|
||||
if (badge) {
|
||||
badge.classList.toggle("hidden", !isPreferred);
|
||||
const cards = Array.from(section.querySelectorAll(".download-card"));
|
||||
if (!cards.length) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const platform = detectPlatform();
|
||||
const preferredCard = cards.find((card) => card.dataset.platform === platform) || cards[0];
|
||||
|
||||
cards.forEach((card) => {
|
||||
const isPreferred = card === preferredCard;
|
||||
card.classList.toggle("download-card--active", isPreferred);
|
||||
const badge = card.querySelector(".recommended-badge");
|
||||
if (badge) {
|
||||
badge.classList.toggle("hidden", !isPreferred);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const downloadSections = document.querySelectorAll(".download-section, .download-card-block");
|
||||
downloadSections.forEach(markRecommendedDownload);
|
||||
const downloadSections = document.querySelectorAll(".download-section, .download-card-block");
|
||||
downloadSections.forEach(markRecommendedDownload);
|
||||
});
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const searchForm = document.getElementById("channel-search-form");
|
||||
const searchInput = document.getElementById("channel-search");
|
||||
const suggestions = document.getElementById("channel-suggestions");
|
||||
const searchForm = document.getElementById("channel-search-form");
|
||||
const searchInput = document.getElementById("channel-search");
|
||||
const suggestions = document.getElementById("channel-suggestions");
|
||||
|
||||
if (!searchForm || !searchInput || !suggestions) {
|
||||
console.error("Required elements not found in the DOM");
|
||||
return;
|
||||
}
|
||||
if (!searchForm || !searchInput || !suggestions) {
|
||||
console.error("Required elements not found in the DOM");
|
||||
return;
|
||||
}
|
||||
|
||||
let channels = [];
|
||||
let channels = [];
|
||||
|
||||
function updateSuggestions(term) {
|
||||
const normalizedTerm = term.trim().toLowerCase();
|
||||
const filtered = channels.filter((name) => !normalizedTerm || name.includes(normalizedTerm)).slice(0, 20);
|
||||
function updateSuggestions(term) {
|
||||
const normalizedTerm = term.trim().toLowerCase();
|
||||
const filtered = channels.filter((name) => !normalizedTerm || name.includes(normalizedTerm)).slice(0, 20);
|
||||
|
||||
suggestions.innerHTML = "";
|
||||
filtered.forEach((name) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = name;
|
||||
suggestions.appendChild(option);
|
||||
suggestions.innerHTML = "";
|
||||
filtered.forEach((name) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = name;
|
||||
suggestions.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
try {
|
||||
const response = await fetch("/api/channels");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load channels: ${response.status}`);
|
||||
}
|
||||
channels = await response.json();
|
||||
updateSuggestions(searchInput.value || "");
|
||||
} catch (error) {
|
||||
console.error("Could not load channel directory", error);
|
||||
}
|
||||
}
|
||||
|
||||
searchInput.focus({ preventScroll: true });
|
||||
searchInput.select();
|
||||
|
||||
searchInput.addEventListener("input", (event) => updateSuggestions(event.target.value || ""));
|
||||
|
||||
searchForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const broadcaster = (searchInput.value || "").trim().toLowerCase();
|
||||
if (!broadcaster) {
|
||||
searchInput.focus();
|
||||
return;
|
||||
}
|
||||
window.location.href = `/view/${encodeURIComponent(broadcaster)}/broadcast`;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadChannels() {
|
||||
try {
|
||||
const response = await fetch("/api/channels");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load channels: ${response.status}`);
|
||||
}
|
||||
channels = await response.json();
|
||||
updateSuggestions(searchInput.value || "");
|
||||
} catch (error) {
|
||||
console.error("Could not load channel directory", error);
|
||||
}
|
||||
}
|
||||
|
||||
searchInput.focus({ preventScroll: true });
|
||||
searchInput.select();
|
||||
|
||||
searchInput.addEventListener("input", (event) => updateSuggestions(event.target.value || ""));
|
||||
|
||||
searchForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const broadcaster = (searchInput.value || "").trim().toLowerCase();
|
||||
if (!broadcaster) {
|
||||
searchInput.focus();
|
||||
return;
|
||||
}
|
||||
window.location.href = `/view/${encodeURIComponent(broadcaster)}/broadcast`;
|
||||
});
|
||||
|
||||
loadChannels();
|
||||
loadChannels();
|
||||
});
|
||||
|
||||
@@ -19,130 +19,130 @@ const currentSettings = JSON.parse(serverRenderedSettings);
|
||||
let userSettings = { ...currentSettings };
|
||||
|
||||
function jsonEquals(a, b) {
|
||||
if (a === b) return true;
|
||||
if (a === b) return true;
|
||||
|
||||
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
for (const key of keysA) {
|
||||
if (!keysB.includes(key)) return false;
|
||||
if (!jsonEquals(a[key], b[key])) return false;
|
||||
}
|
||||
for (const key of keysA) {
|
||||
if (!keysB.includes(key)) return false;
|
||||
if (!jsonEquals(a[key], b[key])) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setFormSettings(s) {
|
||||
canvasFpsElement.value = s.canvasFramesPerSecond;
|
||||
canvasSizeElement.value = s.maxCanvasSideLengthPixels;
|
||||
canvasFpsElement.value = s.canvasFramesPerSecond;
|
||||
canvasSizeElement.value = s.maxCanvasSideLengthPixels;
|
||||
|
||||
minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction;
|
||||
maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction;
|
||||
minPitchElement.value = s.minAssetAudioPitchFraction;
|
||||
maxPitchElement.value = s.maxAssetAudioPitchFraction;
|
||||
minVolumeElement.value = s.minAssetVolumeFraction;
|
||||
maxVolumeElement.value = s.maxAssetVolumeFraction;
|
||||
minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction;
|
||||
maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction;
|
||||
minPitchElement.value = s.minAssetAudioPitchFraction;
|
||||
maxPitchElement.value = s.maxAssetAudioPitchFraction;
|
||||
minVolumeElement.value = s.minAssetVolumeFraction;
|
||||
maxVolumeElement.value = s.maxAssetVolumeFraction;
|
||||
}
|
||||
|
||||
function updateStatCards(settings) {
|
||||
if (!settings) return;
|
||||
statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`;
|
||||
statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`;
|
||||
statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} – ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`;
|
||||
statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} – ${settings.maxAssetAudioPitchFraction ?? "--"}x`;
|
||||
statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} – ${settings.maxAssetVolumeFraction ?? "--"}x`;
|
||||
if (!settings) return;
|
||||
statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`;
|
||||
statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`;
|
||||
statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} – ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`;
|
||||
statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} – ${settings.maxAssetAudioPitchFraction ?? "--"}x`;
|
||||
statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} – ${settings.maxAssetVolumeFraction ?? "--"}x`;
|
||||
}
|
||||
|
||||
function readInt(input) {
|
||||
return input.checkValidity() ? Number(input.value) : null;
|
||||
return input.checkValidity() ? Number(input.value) : null;
|
||||
}
|
||||
|
||||
function readFloat(input) {
|
||||
return input.checkValidity() ? Number(input.value) : null;
|
||||
return input.checkValidity() ? Number(input.value) : null;
|
||||
}
|
||||
|
||||
function loadUserSettingsFromDom() {
|
||||
userSettings.canvasFramesPerSecond = readInt(canvasFpsElement);
|
||||
userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement);
|
||||
userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement);
|
||||
userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement);
|
||||
userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement);
|
||||
userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement);
|
||||
userSettings.minAssetVolumeFraction = readFloat(minVolumeElement);
|
||||
userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement);
|
||||
userSettings.canvasFramesPerSecond = readInt(canvasFpsElement);
|
||||
userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement);
|
||||
userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement);
|
||||
userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement);
|
||||
userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement);
|
||||
userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement);
|
||||
userSettings.minAssetVolumeFraction = readFloat(minVolumeElement);
|
||||
userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement);
|
||||
}
|
||||
|
||||
function updateSubmitButtonDisabledState() {
|
||||
if (jsonEquals(currentSettings, userSettings)) {
|
||||
submitButtonElement.disabled = "disabled";
|
||||
statusElement.textContent = "No changes yet.";
|
||||
statusElement.classList.remove("status-success", "status-warning");
|
||||
return;
|
||||
}
|
||||
if (!formElement.checkValidity()) {
|
||||
submitButtonElement.disabled = "disabled";
|
||||
statusElement.textContent = "Fix highlighted fields.";
|
||||
statusElement.classList.add("status-warning");
|
||||
statusElement.classList.remove("status-success");
|
||||
return;
|
||||
}
|
||||
submitButtonElement.disabled = null;
|
||||
statusElement.textContent = "Ready to save.";
|
||||
statusElement.classList.remove("status-warning");
|
||||
if (jsonEquals(currentSettings, userSettings)) {
|
||||
submitButtonElement.disabled = "disabled";
|
||||
statusElement.textContent = "No changes yet.";
|
||||
statusElement.classList.remove("status-success", "status-warning");
|
||||
return;
|
||||
}
|
||||
if (!formElement.checkValidity()) {
|
||||
submitButtonElement.disabled = "disabled";
|
||||
statusElement.textContent = "Fix highlighted fields.";
|
||||
statusElement.classList.add("status-warning");
|
||||
statusElement.classList.remove("status-success");
|
||||
return;
|
||||
}
|
||||
submitButtonElement.disabled = null;
|
||||
statusElement.textContent = "Ready to save.";
|
||||
statusElement.classList.remove("status-warning");
|
||||
}
|
||||
|
||||
function submitSettingsForm() {
|
||||
if (submitButtonElement.getAttribute("disabled") != null) {
|
||||
console.warn("Attempted to submit invalid form");
|
||||
showToast("Settings not valid", "warning");
|
||||
return;
|
||||
}
|
||||
statusElement.textContent = "Saving…";
|
||||
statusElement.classList.remove("status-success", "status-warning");
|
||||
fetch("/api/settings/set", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(userSettings),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load canvas");
|
||||
}
|
||||
return r.json();
|
||||
if (submitButtonElement.getAttribute("disabled") != null) {
|
||||
console.warn("Attempted to submit invalid form");
|
||||
showToast("Settings not valid", "warning");
|
||||
return;
|
||||
}
|
||||
statusElement.textContent = "Saving…";
|
||||
statusElement.classList.remove("status-success", "status-warning");
|
||||
fetch("/api/settings/set", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(userSettings),
|
||||
})
|
||||
.then((newSettings) => {
|
||||
currentSettings = { ...newSettings };
|
||||
userSettings = { ...newSettings };
|
||||
updateStatCards(newSettings);
|
||||
showToast("Settings saved", "success");
|
||||
statusElement.textContent = "Saved.";
|
||||
statusElement.classList.add("status-success");
|
||||
updateSubmitButtonDisabledState();
|
||||
})
|
||||
.catch((error) => {
|
||||
showToast("Unable to save settings", "error");
|
||||
console.error(error);
|
||||
statusElement.textContent = "Save failed. Try again.";
|
||||
statusElement.classList.add("status-warning");
|
||||
});
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load canvas");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((newSettings) => {
|
||||
currentSettings = { ...newSettings };
|
||||
userSettings = { ...newSettings };
|
||||
updateStatCards(newSettings);
|
||||
showToast("Settings saved", "success");
|
||||
statusElement.textContent = "Saved.";
|
||||
statusElement.classList.add("status-success");
|
||||
updateSubmitButtonDisabledState();
|
||||
})
|
||||
.catch((error) => {
|
||||
showToast("Unable to save settings", "error");
|
||||
console.error(error);
|
||||
statusElement.textContent = "Save failed. Try again.";
|
||||
statusElement.classList.add("status-warning");
|
||||
});
|
||||
}
|
||||
|
||||
formElement.querySelectorAll("input").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
loadUserSettingsFromDom();
|
||||
updateSubmitButtonDisabledState();
|
||||
});
|
||||
input.addEventListener("input", () => {
|
||||
loadUserSettingsFromDom();
|
||||
updateSubmitButtonDisabledState();
|
||||
});
|
||||
});
|
||||
|
||||
formElement.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
submitSettingsForm();
|
||||
event.preventDefault();
|
||||
submitSettingsForm();
|
||||
});
|
||||
|
||||
setFormSettings(currentSettings);
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
(function () {
|
||||
const CONTAINER_ID = "toast-container";
|
||||
const DEFAULT_DURATION = 4200;
|
||||
const CONTAINER_ID = "toast-container";
|
||||
const DEFAULT_DURATION = 4200;
|
||||
|
||||
function ensureContainer() {
|
||||
let container = document.getElementById(CONTAINER_ID);
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = CONTAINER_ID;
|
||||
container.className = "toast-container";
|
||||
container.setAttribute("aria-live", "polite");
|
||||
container.setAttribute("aria-atomic", "true");
|
||||
document.body.appendChild(container);
|
||||
function ensureContainer() {
|
||||
let container = document.getElementById(CONTAINER_ID);
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = CONTAINER_ID;
|
||||
container.className = "toast-container";
|
||||
container.setAttribute("aria-live", "polite");
|
||||
container.setAttribute("aria-atomic", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
function buildToast(message, type) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast toast-${type}`;
|
||||
function buildToast(message, type) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
const indicator = document.createElement("span");
|
||||
indicator.className = "toast-indicator";
|
||||
indicator.setAttribute("aria-hidden", "true");
|
||||
const indicator = document.createElement("span");
|
||||
indicator.className = "toast-indicator";
|
||||
indicator.setAttribute("aria-hidden", "true");
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.className = "toast-message";
|
||||
content.textContent = message;
|
||||
const content = document.createElement("div");
|
||||
content.className = "toast-message";
|
||||
content.textContent = message;
|
||||
|
||||
toast.appendChild(indicator);
|
||||
toast.appendChild(content);
|
||||
return toast;
|
||||
}
|
||||
toast.appendChild(indicator);
|
||||
toast.appendChild(content);
|
||||
return toast;
|
||||
}
|
||||
|
||||
function removeToast(toast) {
|
||||
if (!toast) return;
|
||||
toast.classList.add("toast-exit");
|
||||
setTimeout(() => toast.remove(), 250);
|
||||
}
|
||||
function removeToast(toast) {
|
||||
if (!toast) return;
|
||||
toast.classList.add("toast-exit");
|
||||
setTimeout(() => toast.remove(), 250);
|
||||
}
|
||||
|
||||
window.showToast = function showToast(message, type = "info", options = {}) {
|
||||
if (!message) return;
|
||||
const normalized = ["success", "error", "warning", "info"].includes(type) ? type : "info";
|
||||
const duration = typeof options.duration === "number" ? options.duration : DEFAULT_DURATION;
|
||||
const container = ensureContainer();
|
||||
const toast = buildToast(message, normalized);
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => removeToast(toast), Math.max(1200, duration));
|
||||
toast.addEventListener("click", () => removeToast(toast));
|
||||
};
|
||||
window.showToast = function showToast(message, type = "info", options = {}) {
|
||||
if (!message) return;
|
||||
const normalized = ["success", "error", "warning", "info"].includes(type) ? type : "info";
|
||||
const duration = typeof options.duration === "number" ? options.duration : DEFAULT_DURATION;
|
||||
const container = ensureContainer();
|
||||
const toast = buildToast(message, normalized);
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => removeToast(toast), Math.max(1200, duration));
|
||||
toast.addEventListener("click", () => removeToast(toast));
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1,308 +1,358 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Admin</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<div class="admin-frame">
|
||||
<header class="admin-topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="admin-identity">
|
||||
<p class="eyebrow subtle">CHANNEL ADMIN</p>
|
||||
<h1 th:text="${broadcaster}"></h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions horizontal">
|
||||
<a class="icon-button" th:href="@{/}" title="Back to dashboard">
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||
<span class="sr-only">Back to dashboard</span>
|
||||
</a>
|
||||
<a class="button ghost" th:href="${'/view/' + broadcaster + '/broadcast'}" target="_blank" rel="noopener"
|
||||
>Broadcaster view</a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-workspace">
|
||||
<aside class="admin-rail">
|
||||
<div class="upload-row">
|
||||
<input
|
||||
id="asset-file"
|
||||
class="file-input-field"
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
onchange="handleFileSelection(this)"
|
||||
/>
|
||||
<label for="asset-file" class="file-input-trigger">
|
||||
<span class="file-input-icon"><i class="fa-solid fa-cloud-arrow-up"></i></span>
|
||||
<span class="file-input-copy">
|
||||
<strong>Upload asset</strong>
|
||||
<small id="asset-file-name">No file chosen</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="rail-body">
|
||||
<div class="rail-scroll">
|
||||
<ul id="asset-list" class="asset-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="asset-inspector" class="rail-inspector hidden">
|
||||
<div class="asset-inspector">
|
||||
<div class="selected-asset-banner">
|
||||
<div class="selected-asset-main">
|
||||
<div class="title-row">
|
||||
<strong id="selected-asset-name">Choose an asset</strong>
|
||||
<span id="selected-asset-resolution" class="asset-resolution subtle-text hidden"></span>
|
||||
</div>
|
||||
<p class="meta-text" id="selected-asset-meta">
|
||||
Pick an asset in the list to adjust its placement and playback.
|
||||
</p>
|
||||
<p class="meta-text subtle-text hidden" id="selected-asset-id"></p>
|
||||
<div class="badge-row asset-meta-badges" id="selected-asset-badges"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="asset-controls-placeholder" class="asset-controls-placeholder">
|
||||
<div id="asset-controls" class="hidden asset-settings">
|
||||
<div class="panel-section" id="layout-section">
|
||||
<div class="section-header">
|
||||
<h5>Layout & order</h5>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Admin</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<div class="admin-frame">
|
||||
<header class="admin-topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="admin-identity">
|
||||
<p class="eyebrow subtle">CHANNEL ADMIN</p>
|
||||
<h1 th:text="${broadcaster}"></h1>
|
||||
</div>
|
||||
<div class="property-list">
|
||||
<div class="property-row">
|
||||
<span class="property-label">Width</span>
|
||||
<input id="asset-width" class="number-input property-control" type="number" min="10" step="5" />
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Height</span>
|
||||
</div>
|
||||
<div class="header-actions horizontal">
|
||||
<a class="icon-button" th:href="@{/}" title="Back to dashboard">
|
||||
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
|
||||
<span class="sr-only">Back to dashboard</span>
|
||||
</a>
|
||||
<a
|
||||
class="button ghost"
|
||||
th:href="${'/view/' + broadcaster + '/broadcast'}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Broadcaster view</a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-workspace">
|
||||
<aside class="admin-rail">
|
||||
<div class="upload-row">
|
||||
<input
|
||||
id="asset-height"
|
||||
class="number-input property-control"
|
||||
type="number"
|
||||
min="10"
|
||||
step="5"
|
||||
id="asset-file"
|
||||
class="file-input-field"
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
onchange="handleFileSelection(this)"
|
||||
/>
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Maintain AR</span>
|
||||
<label class="checkbox-inline toggle inline-toggle property-control">
|
||||
<input id="maintain-aspect" type="checkbox" checked />
|
||||
<span class="toggle-track" aria-hidden="true">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<label for="asset-file" class="file-input-trigger">
|
||||
<span class="file-input-icon"><i class="fa-solid fa-cloud-arrow-up"></i></span>
|
||||
<span class="file-input-copy">
|
||||
<strong>Upload asset</strong>
|
||||
<small id="asset-file-name">No file chosen</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Layer</span>
|
||||
<div class="property-control">
|
||||
<div class="badge-row stacked">
|
||||
<span class="badge">Layer <strong id="asset-z-level">1</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail-body">
|
||||
<div class="rail-scroll">
|
||||
<ul id="asset-list" class="asset-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section" id="playback-section">
|
||||
<div class="section-header">
|
||||
<h5>Playback</h5>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Playback speed</span>
|
||||
<span class="value-hint" id="asset-speed-label">100%</span>
|
||||
</div>
|
||||
<input
|
||||
id="asset-speed"
|
||||
class="range-input"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1000"
|
||||
step="10"
|
||||
value="100"
|
||||
/>
|
||||
<div class="range-meta"><span>0%</span><span>1000%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="asset-inspector" class="rail-inspector hidden">
|
||||
<div class="asset-inspector">
|
||||
<div class="selected-asset-banner">
|
||||
<div class="selected-asset-main">
|
||||
<div class="title-row">
|
||||
<strong id="selected-asset-name">Choose an asset</strong>
|
||||
<span
|
||||
id="selected-asset-resolution"
|
||||
class="asset-resolution subtle-text hidden"
|
||||
></span>
|
||||
</div>
|
||||
<p class="meta-text" id="selected-asset-meta">
|
||||
Pick an asset in the list to adjust its placement and playback.
|
||||
</p>
|
||||
<p class="meta-text subtle-text hidden" id="selected-asset-id"></p>
|
||||
<div class="badge-row asset-meta-badges" id="selected-asset-badges"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="asset-controls-placeholder" class="asset-controls-placeholder">
|
||||
<div id="asset-controls" class="hidden asset-settings">
|
||||
<div class="panel-section" id="layout-section">
|
||||
<div class="section-header">
|
||||
<h5>Layout & order</h5>
|
||||
</div>
|
||||
<div class="property-list">
|
||||
<div class="property-row">
|
||||
<span class="property-label">Width</span>
|
||||
<input
|
||||
id="asset-width"
|
||||
class="number-input property-control"
|
||||
type="number"
|
||||
min="10"
|
||||
step="5"
|
||||
/>
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Height</span>
|
||||
<input
|
||||
id="asset-height"
|
||||
class="number-input property-control"
|
||||
type="number"
|
||||
min="10"
|
||||
step="5"
|
||||
/>
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Maintain AR</span>
|
||||
<label class="checkbox-inline toggle inline-toggle property-control">
|
||||
<input id="maintain-aspect" type="checkbox" checked />
|
||||
<span class="toggle-track" aria-hidden="true">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Layer</span>
|
||||
<div class="property-control">
|
||||
<div class="badge-row stacked">
|
||||
<span class="badge"
|
||||
>Layer <strong id="asset-z-level">1</strong></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section" id="volume-section">
|
||||
<div class="section-header">
|
||||
<h5>Volume</h5>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Playback volume</span>
|
||||
<span class="value-hint" id="asset-volume-label">100%</span>
|
||||
</div>
|
||||
<input
|
||||
id="asset-volume"
|
||||
class="range-input"
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
step="1"
|
||||
value="100"
|
||||
/>
|
||||
<div class="range-meta"><span>0%</span><span>200%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-section" id="playback-section">
|
||||
<div class="section-header">
|
||||
<h5>Playback</h5>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Playback speed</span>
|
||||
<span class="value-hint" id="asset-speed-label">100%</span>
|
||||
</div>
|
||||
<input
|
||||
id="asset-speed"
|
||||
class="range-input"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1000"
|
||||
step="10"
|
||||
value="100"
|
||||
/>
|
||||
<div class="range-meta"><span>0%</span><span>1000%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section hidden" id="audio-section">
|
||||
<div class="section-header">
|
||||
<h5>Audio</h5>
|
||||
</div>
|
||||
<div class="property-list">
|
||||
<div class="property-row">
|
||||
<span class="property-label">Loop</span>
|
||||
<label class="checkbox-inline toggle inline-toggle property-control">
|
||||
<input id="asset-audio-loop" type="checkbox" />
|
||||
<span class="toggle-track" aria-hidden="true">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Delay</span>
|
||||
<span class="value-hint" id="asset-audio-delay-label">0ms</span>
|
||||
</div>
|
||||
<input
|
||||
id="asset-audio-delay"
|
||||
class="range-input property-control"
|
||||
type="range"
|
||||
min="0"
|
||||
max="30000"
|
||||
step="100"
|
||||
value="0"
|
||||
/>
|
||||
<div class="range-meta"><span>0ms</span><span>30s</span></div>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Playback speed</span>
|
||||
<span class="value-hint" id="asset-audio-speed-label">1.0x</span>
|
||||
</div>
|
||||
<input
|
||||
id="asset-audio-speed"
|
||||
class="range-input"
|
||||
type="range"
|
||||
min="25"
|
||||
max="400"
|
||||
step="5"
|
||||
value="100"
|
||||
/>
|
||||
<div class="range-meta"><span>0.25x</span><span>4x</span></div>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Pitch</span>
|
||||
<span class="value-hint" id="asset-audio-pitch-label">100%</span>
|
||||
</div>
|
||||
<input
|
||||
id="asset-audio-pitch"
|
||||
class="range-input property-control"
|
||||
type="range"
|
||||
min="50"
|
||||
max="200"
|
||||
step="5"
|
||||
value="100"
|
||||
/>
|
||||
<div class="range-meta"><span>50%</span><span>200%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-actions compact unified-actions" id="asset-actions">
|
||||
<button type="button" onclick="sendToBack()" class="secondary" title="Send to back">
|
||||
<i class="fa-solid fa-angles-down"></i>
|
||||
</button>
|
||||
<button type="button" onclick="bringBackward()" class="secondary" title="Move backward">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
</button>
|
||||
<button type="button" onclick="bringForward()" class="secondary" title="Move forward">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
</button>
|
||||
<button type="button" onclick="bringToFront()" class="secondary" title="Bring to front">
|
||||
<i class="fa-solid fa-angles-up"></i>
|
||||
</button>
|
||||
<button type="button" onclick="recenterSelectedAsset()" class="secondary" title="Center on canvas">
|
||||
<i class="fa-solid fa-bullseye"></i>
|
||||
</button>
|
||||
<button type="button" onclick="nudgeRotation(-5)" class="secondary" title="Rotate left">
|
||||
<i class="fa-solid fa-rotate-left"></i>
|
||||
</button>
|
||||
<button type="button" onclick="nudgeRotation(5)" class="secondary" title="Rotate right">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
<button
|
||||
id="selected-asset-visibility"
|
||||
class="secondary"
|
||||
type="button"
|
||||
title="Hide asset"
|
||||
disabled
|
||||
data-audio-enabled="true"
|
||||
>
|
||||
<i class="fa-solid fa-eye-slash"></i>
|
||||
</button>
|
||||
<button
|
||||
id="selected-asset-delete"
|
||||
class="secondary danger"
|
||||
type="button"
|
||||
title="Delete asset"
|
||||
disabled
|
||||
data-audio-enabled="true"
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="panel-section" id="volume-section">
|
||||
<div class="section-header">
|
||||
<h5>Volume</h5>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Playback volume</span>
|
||||
<span class="value-hint" id="asset-volume-label">100%</span>
|
||||
</div>
|
||||
<input
|
||||
id="asset-volume"
|
||||
class="range-input"
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
step="1"
|
||||
value="100"
|
||||
/>
|
||||
<div class="range-meta"><span>0%</span><span>200%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="canvas-stack">
|
||||
<div class="canvas-topbar">
|
||||
<div>
|
||||
<p class="eyebrow subtle">Canvas</p>
|
||||
<h3 class="panel-title">Live composition</h3>
|
||||
<div class="panel-section hidden" id="audio-section">
|
||||
<div class="section-header">
|
||||
<h5>Audio</h5>
|
||||
</div>
|
||||
<div class="property-list">
|
||||
<div class="property-row">
|
||||
<span class="property-label">Loop</span>
|
||||
<label class="checkbox-inline toggle inline-toggle property-control">
|
||||
<input id="asset-audio-loop" type="checkbox" />
|
||||
<span class="toggle-track" aria-hidden="true">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Delay</span>
|
||||
<span class="value-hint" id="asset-audio-delay-label">0ms</span>
|
||||
</div>
|
||||
<input
|
||||
id="asset-audio-delay"
|
||||
class="range-input property-control"
|
||||
type="range"
|
||||
min="0"
|
||||
max="30000"
|
||||
step="100"
|
||||
value="0"
|
||||
/>
|
||||
<div class="range-meta"><span>0ms</span><span>30s</span></div>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Playback speed</span>
|
||||
<span class="value-hint" id="asset-audio-speed-label">1.0x</span>
|
||||
</div>
|
||||
<input
|
||||
id="asset-audio-speed"
|
||||
class="range-input"
|
||||
type="range"
|
||||
min="25"
|
||||
max="400"
|
||||
step="5"
|
||||
value="100"
|
||||
/>
|
||||
<div class="range-meta"><span>0.25x</span><span>4x</span></div>
|
||||
</div>
|
||||
<div class="stacked-field">
|
||||
<div class="label-row">
|
||||
<span>Pitch</span>
|
||||
<span class="value-hint" id="asset-audio-pitch-label">100%</span>
|
||||
</div>
|
||||
<input
|
||||
id="asset-audio-pitch"
|
||||
class="range-input property-control"
|
||||
type="range"
|
||||
min="50"
|
||||
max="200"
|
||||
step="5"
|
||||
value="100"
|
||||
/>
|
||||
<div class="range-meta"><span>50%</span><span>200%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-actions compact unified-actions" id="asset-actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick="sendToBack()"
|
||||
class="secondary"
|
||||
title="Send to back"
|
||||
>
|
||||
<i class="fa-solid fa-angles-down"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="bringBackward()"
|
||||
class="secondary"
|
||||
title="Move backward"
|
||||
>
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="bringForward()"
|
||||
class="secondary"
|
||||
title="Move forward"
|
||||
>
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="bringToFront()"
|
||||
class="secondary"
|
||||
title="Bring to front"
|
||||
>
|
||||
<i class="fa-solid fa-angles-up"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="recenterSelectedAsset()"
|
||||
class="secondary"
|
||||
title="Center on canvas"
|
||||
>
|
||||
<i class="fa-solid fa-bullseye"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="nudgeRotation(-5)"
|
||||
class="secondary"
|
||||
title="Rotate left"
|
||||
>
|
||||
<i class="fa-solid fa-rotate-left"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick="nudgeRotation(5)"
|
||||
class="secondary"
|
||||
title="Rotate right"
|
||||
>
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
<button
|
||||
id="selected-asset-visibility"
|
||||
class="secondary"
|
||||
type="button"
|
||||
title="Hide asset"
|
||||
disabled
|
||||
data-audio-enabled="true"
|
||||
>
|
||||
<i class="fa-solid fa-eye-slash"></i>
|
||||
</button>
|
||||
<button
|
||||
id="selected-asset-delete"
|
||||
class="secondary danger"
|
||||
type="button"
|
||||
title="Delete asset"
|
||||
disabled
|
||||
data-audio-enabled="true"
|
||||
>
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="canvas-stack">
|
||||
<div class="canvas-topbar">
|
||||
<div>
|
||||
<p class="eyebrow subtle">Canvas</p>
|
||||
<h3 class="panel-title">Live composition</h3>
|
||||
</div>
|
||||
<div class="canvas-meta">
|
||||
<span class="badge soft" id="canvas-resolution">1920 x 1080</span>
|
||||
<span class="badge outline" id="canvas-scale">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-surface">
|
||||
<div class="overlay canvas-boundary" id="admin-overlay">
|
||||
<div class="canvas-guides"></div>
|
||||
<canvas id="admin-canvas"></canvas>
|
||||
</div>
|
||||
<div class="canvas-footnote">
|
||||
<p>Edges of the canvas are outlined to match the aspect ratio of the stream.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="canvas-meta">
|
||||
<span class="badge soft" id="canvas-resolution">1920 x 1080</span>
|
||||
<span class="badge outline" id="canvas-scale">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-surface">
|
||||
<div class="overlay canvas-boundary" id="admin-overlay">
|
||||
<div class="canvas-guides"></div>
|
||||
<canvas id="admin-canvas"></canvas>
|
||||
</div>
|
||||
<div class="canvas-footnote">
|
||||
<p>Edges of the canvas are outlined to match the aspect ratio of the stream.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${broadcaster}]]*/ '';
|
||||
const username = /*[[${username}]]*/ '';
|
||||
const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
|
||||
const SETTINGS = /*[[${settingsJson}]]*/;
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</div>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${broadcaster}]]*/ '';
|
||||
const username = /*[[${username}]]*/ '';
|
||||
const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
|
||||
const SETTINGS = /*[[${settingsJson}]]*/;
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Broadcast</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||
</head>
|
||||
<body class="broadcast-body">
|
||||
<canvas id="broadcast-canvas"></canvas>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${broadcaster}]]*/ "";
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/broadcast.js"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Broadcast</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||
</head>
|
||||
<body class="broadcast-body">
|
||||
<canvas id="broadcast-canvas"></canvas>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${broadcaster}]]*/ "";
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/broadcast.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Browse channels - Imgfloat</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="channels-body">
|
||||
<div class="channels-shell">
|
||||
<header class="channels-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">IF</div>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Browse channels - Imgfloat</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="channels-body">
|
||||
<div class="channels-shell">
|
||||
<header class="channels-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">IF</div>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="channels-main">
|
||||
<section class="channel-card">
|
||||
<p class="eyebrow subtle">Broadcast overlay</p>
|
||||
<h1>Open a channel</h1>
|
||||
<p class="muted">Type the channel name to jump straight to their overlay.</p>
|
||||
<form id="channel-search-form" class="channel-form">
|
||||
<label class="sr-only" for="channel-search">Channel name</label>
|
||||
<input
|
||||
id="channel-search"
|
||||
name="channel"
|
||||
class="text-input"
|
||||
type="text"
|
||||
list="channel-suggestions"
|
||||
placeholder="Type a channel name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<datalist id="channel-suggestions"></datalist>
|
||||
<button type="submit" class="button block">Open overlay</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/landing.js"></script>
|
||||
</body>
|
||||
<main class="channels-main">
|
||||
<section class="channel-card">
|
||||
<p class="eyebrow subtle">Broadcast overlay</p>
|
||||
<h1>Open a channel</h1>
|
||||
<p class="muted">Type the channel name to jump straight to their overlay.</p>
|
||||
<form id="channel-search-form" class="channel-form">
|
||||
<label class="sr-only" for="channel-search">Channel name</label>
|
||||
<input
|
||||
id="channel-search"
|
||||
name="channel"
|
||||
class="text-input"
|
||||
type="text"
|
||||
list="channel-suggestions"
|
||||
placeholder="Type a channel name"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<datalist id="channel-suggestions"></datalist>
|
||||
<button type="submit" class="button block">Open overlay</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/landing.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,119 +1,119 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Dashboard</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="dashboard-body">
|
||||
<div class="dashboard-shell">
|
||||
<header class="dashboard-topbar">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/img/brand.png"/>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-pill">
|
||||
<span class="eyebrow subtle">Signed in as</span>
|
||||
<span class="user-display" th:text="${username}">user</span>
|
||||
</div>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Dashboard</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="dashboard-body">
|
||||
<div class="dashboard-shell">
|
||||
<header class="dashboard-topbar">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/img/brand.png" />
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-pill">
|
||||
<span class="eyebrow subtle">Signed in as</span>
|
||||
<span class="user-display" th:text="${username}">user</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<p class="eyebrow">Navigation</p>
|
||||
<h3>Shortcuts</h3>
|
||||
<p class="muted">Jump into your overlay</p>
|
||||
<div class="panel-actions">
|
||||
<a class="button block" th:href="@{'/view/' + ${channel} + '/broadcast'}">Open broadcast overlay</a>
|
||||
<a class="button ghost block" th:href="@{'/view/' + ${channel} + '/admin'}">Open admin console</a>
|
||||
<a class="button ghost block" href="/channels">Browse channels</a>
|
||||
<form class="block" th:action="@{/logout}" method="post">
|
||||
<button class="secondary block" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<p class="eyebrow">Navigation</p>
|
||||
<h3>Shortcuts</h3>
|
||||
<p class="muted">Jump into your overlay</p>
|
||||
<div class="panel-actions">
|
||||
<a class="button block" th:href="@{'/view/' + ${channel} + '/broadcast'}">Open broadcast overlay</a>
|
||||
<a class="button ghost block" th:href="@{'/view/' + ${channel} + '/admin'}">Open admin console</a>
|
||||
<a class="button ghost block" href="/channels">Browse channels</a>
|
||||
<form class="block" th:action="@{/logout}" method="post">
|
||||
<button class="secondary block" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<p class="eyebrow">Settings</p>
|
||||
<h3>Overlay dimensions</h3>
|
||||
<p class="muted">Match these with your OBS resolution.</p>
|
||||
<div class="control-grid">
|
||||
<label>
|
||||
Width
|
||||
<input id="canvas-width" type="number" min="100" step="10" />
|
||||
</label>
|
||||
<label>
|
||||
Height
|
||||
<input id="canvas-height" type="number" min="100" step="10" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button type="button" onclick="saveCanvasSettings()">Save canvas size</button>
|
||||
<span id="canvas-status" class="muted"></span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<p class="eyebrow">Settings</p>
|
||||
<h3>Overlay dimensions</h3>
|
||||
<p class="muted">Match these with your OBS resolution.</p>
|
||||
<div class="control-grid">
|
||||
<label>
|
||||
Width
|
||||
<input id="canvas-width" type="number" min="100" step="10" />
|
||||
</label>
|
||||
<label>
|
||||
Height
|
||||
<input id="canvas-height" type="number" min="100" step="10" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button type="button" onclick="saveCanvasSettings()">Save canvas size</button>
|
||||
<span id="canvas-status" class="muted"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card-grid two-col">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Collaboration</p>
|
||||
<h3>Channel admins</h3>
|
||||
<p class="muted">Invite moderators to help manage assets.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-form">
|
||||
<input id="new-admin" placeholder="Twitch username" />
|
||||
<button type="button" onclick="addAdmin()">Add admin</button>
|
||||
</div>
|
||||
<div class="card-section">
|
||||
<div class="section-header">
|
||||
<h4 class="list-title">Channel Admins</h4>
|
||||
<p class="muted">Users who can currently modify your overlay.</p>
|
||||
</div>
|
||||
<ul id="admin-list" class="stacked-list"></ul>
|
||||
</div>
|
||||
<div class="card-section">
|
||||
<div class="section-header">
|
||||
<h4 class="list-title">Your Twitch moderators</h4>
|
||||
<p class="muted">Add moderators who already help run your channel.</p>
|
||||
</div>
|
||||
<ul id="admin-suggestions" class="stacked-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card-grid two-col">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Collaboration</p>
|
||||
<h3>Channel admins</h3>
|
||||
<p class="muted">Invite moderators to help manage assets.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-form">
|
||||
<input id="new-admin" placeholder="Twitch username" />
|
||||
<button type="button" onclick="addAdmin()">Add admin</button>
|
||||
</div>
|
||||
<div class="card-section">
|
||||
<div class="section-header">
|
||||
<h4 class="list-title">Channel Admins</h4>
|
||||
<p class="muted">Users who can currently modify your overlay.</p>
|
||||
</div>
|
||||
<ul id="admin-list" class="stacked-list"></ul>
|
||||
</div>
|
||||
<div class="card-section">
|
||||
<div class="section-header">
|
||||
<h4 class="list-title">Your Twitch moderators</h4>
|
||||
<p class="muted">Add moderators who already help run your channel.</p>
|
||||
</div>
|
||||
<ul id="admin-suggestions" class="stacked-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section th:if="${adminChannels != null}" class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Your access</p>
|
||||
<h3>Channels you administer</h3>
|
||||
<p class="muted">Jump into a teammate's overlay console.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p th:if="${#lists.isEmpty(adminChannels)}">No admin invitations yet.</p>
|
||||
<ul th:if="${!#lists.isEmpty(adminChannels)}" class="stacked-list">
|
||||
<li th:each="channelName : ${adminChannels}" class="stacked-list-item">
|
||||
<div>
|
||||
<p class="list-title" th:text="${channelName}">channel</p>
|
||||
<p class="muted">Channel admin access</p>
|
||||
</div>
|
||||
<a class="button ghost" th:href="@{'/view/' + ${channelName} + '/admin'}">Open</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section th:if="${adminChannels != null}" class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<p class="eyebrow">Your access</p>
|
||||
<h3>Channels you administer</h3>
|
||||
<p class="muted">Jump into a teammate's overlay console.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p th:if="${#lists.isEmpty(adminChannels)}">No admin invitations yet.</p>
|
||||
<ul th:if="${!#lists.isEmpty(adminChannels)}" class="stacked-list">
|
||||
<li th:each="channelName : ${adminChannels}" class="stacked-list-item">
|
||||
<div>
|
||||
<p class="list-title" th:text="${channelName}">channel</p>
|
||||
<p class="muted">Channel admin access</p>
|
||||
</div>
|
||||
<a class="button ghost" th:href="@{'/view/' + ${channelName} + '/admin'}">Open</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card download-card-block" th:insert="fragments/downloads :: downloads"></section>
|
||||
</div>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/downloads.js"></script>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${channel}]]*/ "";
|
||||
</script>
|
||||
<script src="/js/dashboard.js"></script>
|
||||
</body>
|
||||
<section class="card download-card-block" th:insert="fragments/downloads :: downloads"></section>
|
||||
</div>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/downloads.js"></script>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${channel}]]*/ "";
|
||||
</script>
|
||||
<script src="/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
<th:block th:fragment="downloads">
|
||||
<div class="download-header">
|
||||
<p class="eyebrow">Desktop app</p>
|
||||
<h2>Download Imgfloat</h2>
|
||||
</div>
|
||||
<div class="download-grid">
|
||||
<div class="download-card" data-platform="mac">
|
||||
<div class="download-card-header">
|
||||
<p class="eyebrow">macOS</p>
|
||||
<span class="badge soft recommended-badge hidden">Recommended</span>
|
||||
</div>
|
||||
<p class="muted">Apple Silicon build (ARM64)</p>
|
||||
<a
|
||||
class="button block"
|
||||
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '-arm64.dmg'"
|
||||
>Download DMG</a
|
||||
>
|
||||
<div class="download-header">
|
||||
<p class="eyebrow">Desktop app</p>
|
||||
<h2>Download Imgfloat</h2>
|
||||
</div>
|
||||
<div class="download-card" data-platform="windows">
|
||||
<div class="download-card-header">
|
||||
<p class="eyebrow">Windows</p>
|
||||
<span class="badge soft recommended-badge hidden">Recommended</span>
|
||||
</div>
|
||||
<p class="muted">Installer for Windows 10 and 11</p>
|
||||
<a
|
||||
class="button block"
|
||||
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat.Setup.' + ${releaseVersion} + '.exe'"
|
||||
>Download EXE</a
|
||||
>
|
||||
<div class="download-grid">
|
||||
<div class="download-card" data-platform="mac">
|
||||
<div class="download-card-header">
|
||||
<p class="eyebrow">macOS</p>
|
||||
<span class="badge soft recommended-badge hidden">Recommended</span>
|
||||
</div>
|
||||
<p class="muted">Apple Silicon build (ARM64)</p>
|
||||
<a
|
||||
class="button block"
|
||||
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '-arm64.dmg'"
|
||||
>Download DMG</a
|
||||
>
|
||||
</div>
|
||||
<div class="download-card" data-platform="windows">
|
||||
<div class="download-card-header">
|
||||
<p class="eyebrow">Windows</p>
|
||||
<span class="badge soft recommended-badge hidden">Recommended</span>
|
||||
</div>
|
||||
<p class="muted">Installer for Windows 10 and 11</p>
|
||||
<a
|
||||
class="button block"
|
||||
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat.Setup.' + ${releaseVersion} + '.exe'"
|
||||
>Download EXE</a
|
||||
>
|
||||
</div>
|
||||
<div class="download-card" data-platform="linux">
|
||||
<div class="download-card-header">
|
||||
<p class="eyebrow">Linux</p>
|
||||
<span class="badge soft recommended-badge hidden">Recommended</span>
|
||||
</div>
|
||||
<p class="muted">AppImage for most distributions</p>
|
||||
<a
|
||||
class="button block"
|
||||
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '.AppImage'"
|
||||
>Download AppImage</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-card" data-platform="linux">
|
||||
<div class="download-card-header">
|
||||
<p class="eyebrow">Linux</p>
|
||||
<span class="badge soft recommended-badge hidden">Recommended</span>
|
||||
</div>
|
||||
<p class="muted">AppImage for most distributions</p>
|
||||
<a
|
||||
class="button block"
|
||||
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '.AppImage'"
|
||||
>Download AppImage</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
||||
|
||||
@@ -1,48 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat - Twitch overlay</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="landing-body">
|
||||
<div class="landing">
|
||||
<header class="landing-header">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/img/brand.png"/>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat - Twitch overlay</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="landing-body">
|
||||
<div class="landing">
|
||||
<header class="landing-header">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/img/brand.png" />
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="hero hero-compact">
|
||||
<div class="hero-text">
|
||||
<p class="eyebrow">Overlay toolkit</p>
|
||||
<h1>Collaborative real-time Twitch overlay</h1>
|
||||
<p class="lead">Customize your Twitch stream with audio, video and images updated by your mods in real-time</p>
|
||||
<div class="cta-row">
|
||||
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<main class="hero hero-compact">
|
||||
<div class="hero-text">
|
||||
<p class="eyebrow">Overlay toolkit</p>
|
||||
<h1>Collaborative real-time Twitch overlay</h1>
|
||||
<p class="lead">
|
||||
Customize your Twitch stream with audio, video and images updated by your mods in real-time
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="download-section" th:insert="fragments/downloads :: downloads"></section>
|
||||
<section class="download-section" th:insert="fragments/downloads :: downloads"></section>
|
||||
|
||||
<footer class="landing-meta">
|
||||
<div class="build-chip">
|
||||
<span class="muted">License</span>
|
||||
<span class="version-badge">MIT</span>
|
||||
<footer class="landing-meta">
|
||||
<div class="build-chip">
|
||||
<span class="muted">License</span>
|
||||
<span class="version-badge">MIT</span>
|
||||
</div>
|
||||
<div class="build-chip">
|
||||
<span class="muted">Build</span>
|
||||
<span class="version-badge" th:text="${version}">unknown</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="build-chip">
|
||||
<span class="muted">Build</span>
|
||||
<span class="version-badge" th:text="${version}">unknown</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/downloads.js"></script>
|
||||
</body>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/downloads.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,256 +1,269 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Admin</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||
</head>
|
||||
<body class="settings-body">
|
||||
<div class="settings-shell">
|
||||
<header class="settings-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">IF</div>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Admin</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||
</head>
|
||||
<body class="settings-body">
|
||||
<div class="settings-shell">
|
||||
<header class="settings-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">IF</div>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="settings-main">
|
||||
<section class="settings-card settings-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow subtle">System administrator settings</p>
|
||||
<h1>Application defaults</h1>
|
||||
<p class="muted">
|
||||
Configure overlay performance and audio guardrails for every channel using Imgfloat. These
|
||||
settings are applied globally.
|
||||
</p>
|
||||
<div class="badge-row">
|
||||
<span class="badge soft"><i class="fa-solid fa-gauge-high"></i> Performance tuned</span>
|
||||
<span class="badge"><i class="fa-solid fa-cloud"></i> Server-wide</span>
|
||||
<span class="badge subtle"><i class="fa-solid fa-gear"></i> Admin only</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-grid compact">
|
||||
<div class="stat">
|
||||
<p class="stat-label">Canvas FPS</p>
|
||||
<p class="stat-value" id="stat-canvas-fps">--</p>
|
||||
<p class="stat-subtitle">Longest side <span id="stat-canvas-size">--</span></p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<p class="stat-label">Playback speed</p>
|
||||
<p class="stat-value" id="stat-playback-range">--</p>
|
||||
<p class="stat-subtitle">Applies to all animations</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<p class="stat-label">Audio pitch</p>
|
||||
<p class="stat-value" id="stat-audio-range">--</p>
|
||||
<p class="stat-subtitle">Fraction of original clip</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<p class="stat-label">Volume limits</p>
|
||||
<p class="stat-value" id="stat-volume-range">--</p>
|
||||
<p class="stat-subtitle">Keeps alerts comfortable</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-layout">
|
||||
<section class="settings-card settings-panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow subtle">Overlay defaults</p>
|
||||
<h2>Performance & audio budget</h2>
|
||||
<p class="muted tiny">
|
||||
Tune the canvas and audio guardrails to keep overlays smooth and balanced.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form novalidate id="settings-form" class="settings-form">
|
||||
<div class="form-section">
|
||||
<div class="form-heading">
|
||||
<p class="eyebrow subtle">Canvas</p>
|
||||
<h3>Rendering budget</h3>
|
||||
<p class="muted tiny">
|
||||
Match FPS and max dimensions to your streaming canvas for consistent overlays.
|
||||
</p>
|
||||
</div>
|
||||
<div class="control-grid split-row">
|
||||
<label for="canvas-fps"
|
||||
>Canvas FPS
|
||||
<input
|
||||
id="canvas-fps"
|
||||
name="canvas-fps"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="^[1-9]\d*$"
|
||||
placeholder="60"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="canvas-size"
|
||||
>Canvas max side length (pixels)
|
||||
<input
|
||||
id="canvas-size"
|
||||
name="canvas-size"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="^[1-9]\d*$"
|
||||
placeholder="1920"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Use the longest edge of your OBS browser source to prevent stretching.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-heading">
|
||||
<p class="eyebrow subtle">Playback</p>
|
||||
<h3>Animation speed limits</h3>
|
||||
<p class="muted tiny">
|
||||
Bound default speeds between 0 and 1 so clips run predictably.
|
||||
</p>
|
||||
</div>
|
||||
<div class="control-grid split-row">
|
||||
<label for="min-playback-speed"
|
||||
>Min playback speed
|
||||
<input
|
||||
id="min-playback-speed"
|
||||
name="min-playback-speed"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="max-playback-speed"
|
||||
>Max playback speed
|
||||
<input
|
||||
id="max-playback-speed"
|
||||
name="max-playback-speed"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-heading">
|
||||
<p class="eyebrow subtle">Audio</p>
|
||||
<h3>Pitch & volume guardrails</h3>
|
||||
<p class="muted tiny">
|
||||
Prevent harsh audio by bounding pitch and volume as fractions of the source.
|
||||
</p>
|
||||
</div>
|
||||
<div class="control-grid split-row">
|
||||
<label for="min-audio-pitch"
|
||||
>Min audio pitch
|
||||
<input
|
||||
id="min-audio-pitch"
|
||||
name="min-audio-pitch"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="0.8"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="max-audio-pitch"
|
||||
>Max audio pitch
|
||||
<input
|
||||
id="max-audio-pitch"
|
||||
name="max-audio-pitch"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-grid split-row">
|
||||
<label for="min-volume"
|
||||
>Min volume
|
||||
<input
|
||||
id="min-volume"
|
||||
name="min-volume"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="0.2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="max-volume"
|
||||
>Max volume
|
||||
<input
|
||||
id="max-volume"
|
||||
name="max-volume"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Volume and pitch values are percentages of the original clip between 0 and 1.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<p id="settings-status" class="status-chip">No changes yet.</p>
|
||||
<button id="settings-submit-button" type="submit" class="button" disabled>
|
||||
Save settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="settings-sidebar">
|
||||
<section class="settings-card info-card">
|
||||
<p class="eyebrow subtle">Checklist</p>
|
||||
<h3>Before you save</h3>
|
||||
<ul class="hint-list">
|
||||
<li>Match canvas dimensions to the OBS browser source you embed.</li>
|
||||
<li>Use 30–60 FPS for smoother overlays without overwhelming viewers.</li>
|
||||
<li>Keep playback and pitch bounds between 0 and 1 to avoid distortion.</li>
|
||||
<li>Lower the minimum volume if alerts feel too loud on stream.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="settings-card info-card subtle">
|
||||
<p class="eyebrow subtle">Heads up</p>
|
||||
<h3>Global impact</h3>
|
||||
<p class="muted tiny">
|
||||
Changes here update every channel immediately. Save carefully and confirm with your
|
||||
team.
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="settings-main">
|
||||
<section class="settings-card settings-hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow subtle">System administrator settings</p>
|
||||
<h1>Application defaults</h1>
|
||||
<p class="muted">
|
||||
Configure overlay performance and audio guardrails for every channel using Imgfloat. These settings are
|
||||
applied globally.
|
||||
</p>
|
||||
<div class="badge-row">
|
||||
<span class="badge soft"><i class="fa-solid fa-gauge-high"></i> Performance tuned</span>
|
||||
<span class="badge"><i class="fa-solid fa-cloud"></i> Server-wide</span>
|
||||
<span class="badge subtle"><i class="fa-solid fa-gear"></i> Admin only</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-grid compact">
|
||||
<div class="stat">
|
||||
<p class="stat-label">Canvas FPS</p>
|
||||
<p class="stat-value" id="stat-canvas-fps">--</p>
|
||||
<p class="stat-subtitle">Longest side <span id="stat-canvas-size">--</span></p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<p class="stat-label">Playback speed</p>
|
||||
<p class="stat-value" id="stat-playback-range">--</p>
|
||||
<p class="stat-subtitle">Applies to all animations</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<p class="stat-label">Audio pitch</p>
|
||||
<p class="stat-value" id="stat-audio-range">--</p>
|
||||
<p class="stat-subtitle">Fraction of original clip</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<p class="stat-label">Volume limits</p>
|
||||
<p class="stat-value" id="stat-volume-range">--</p>
|
||||
<p class="stat-subtitle">Keeps alerts comfortable</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-layout">
|
||||
<section class="settings-card settings-panel">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="eyebrow subtle">Overlay defaults</p>
|
||||
<h2>Performance & audio budget</h2>
|
||||
<p class="muted tiny">Tune the canvas and audio guardrails to keep overlays smooth and balanced.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form novalidate id="settings-form" class="settings-form">
|
||||
<div class="form-section">
|
||||
<div class="form-heading">
|
||||
<p class="eyebrow subtle">Canvas</p>
|
||||
<h3>Rendering budget</h3>
|
||||
<p class="muted tiny">
|
||||
Match FPS and max dimensions to your streaming canvas for consistent overlays.
|
||||
</p>
|
||||
</div>
|
||||
<div class="control-grid split-row">
|
||||
<label for="canvas-fps"
|
||||
>Canvas FPS
|
||||
<input
|
||||
id="canvas-fps"
|
||||
name="canvas-fps"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="^[1-9]\d*$"
|
||||
placeholder="60"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="canvas-size"
|
||||
>Canvas max side length (pixels)
|
||||
<input
|
||||
id="canvas-size"
|
||||
name="canvas-size"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="^[1-9]\d*$"
|
||||
placeholder="1920"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-hint">Use the longest edge of your OBS browser source to prevent stretching.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-heading">
|
||||
<p class="eyebrow subtle">Playback</p>
|
||||
<h3>Animation speed limits</h3>
|
||||
<p class="muted tiny">Bound default speeds between 0 and 1 so clips run predictably.</p>
|
||||
</div>
|
||||
<div class="control-grid split-row">
|
||||
<label for="min-playback-speed"
|
||||
>Min playback speed
|
||||
<input
|
||||
id="min-playback-speed"
|
||||
name="min-playback-speed"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="max-playback-speed"
|
||||
>Max playback speed
|
||||
<input
|
||||
id="max-playback-speed"
|
||||
name="max-playback-speed"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="form-heading">
|
||||
<p class="eyebrow subtle">Audio</p>
|
||||
<h3>Pitch & volume guardrails</h3>
|
||||
<p class="muted tiny">Prevent harsh audio by bounding pitch and volume as fractions of the source.</p>
|
||||
</div>
|
||||
<div class="control-grid split-row">
|
||||
<label for="min-audio-pitch"
|
||||
>Min audio pitch
|
||||
<input
|
||||
id="min-audio-pitch"
|
||||
name="min-audio-pitch"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="0.8"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="max-audio-pitch"
|
||||
>Max audio pitch
|
||||
<input
|
||||
id="max-audio-pitch"
|
||||
name="max-audio-pitch"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-grid split-row">
|
||||
<label for="min-volume"
|
||||
>Min volume
|
||||
<input
|
||||
id="min-volume"
|
||||
name="min-volume"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="0.2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label for="max-volume"
|
||||
>Max volume
|
||||
<input
|
||||
id="max-volume"
|
||||
name="max-volume"
|
||||
class="text-input"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
pattern="^(0(\.\d+)?|1(\.0+)?)$"
|
||||
placeholder="1.0"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-hint">Volume and pitch values are percentages of the original clip between 0 and 1.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<p id="settings-status" class="status-chip">No changes yet.</p>
|
||||
<button id="settings-submit-button" type="submit" class="button" disabled>Save settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="settings-sidebar">
|
||||
<section class="settings-card info-card">
|
||||
<p class="eyebrow subtle">Checklist</p>
|
||||
<h3>Before you save</h3>
|
||||
<ul class="hint-list">
|
||||
<li>Match canvas dimensions to the OBS browser source you embed.</li>
|
||||
<li>Use 30–60 FPS for smoother overlays without overwhelming viewers.</li>
|
||||
<li>Keep playback and pitch bounds between 0 and 1 to avoid distortion.</li>
|
||||
<li>Lower the minimum volume if alerts feel too loud on stream.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="settings-card info-card subtle">
|
||||
<p class="eyebrow subtle">Heads up</p>
|
||||
<h3>Global impact</h3>
|
||||
<p class="muted tiny">
|
||||
Changes here update every channel immediately. Save carefully and confirm with your team.
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script th:inline="javascript">
|
||||
const serverRenderedSettings = /*[[${settingsJson}]]*/;
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/settings.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
</body>
|
||||
<script th:inline="javascript">
|
||||
const serverRenderedSettings = /*[[${settingsJson}]]*/;
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/settings.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
package dev.kruhlmann.imgfloat;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oauth2Login;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import javax.imageio.ImageIO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest(
|
||||
properties = {
|
||||
"spring.security.oauth2.client.registration.twitch.client-id=test-client-id",
|
||||
"spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret"
|
||||
})
|
||||
"spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret",
|
||||
}
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
class ChannelApiIntegrationTest {
|
||||
|
||||
@@ -42,57 +42,92 @@ class ChannelApiIntegrationTest {
|
||||
@Test
|
||||
void broadcasterManagesAdminsAndAssets() throws Exception {
|
||||
String broadcaster = "caster";
|
||||
mockMvc.perform(post("/api/channels/{broadcaster}/admins", broadcaster)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"helper\"}")
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/channels/{broadcaster}/admins", broadcaster)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"helper\"}")
|
||||
.with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)))
|
||||
)
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(get("/api/channels/{broadcaster}/admins", broadcaster)
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].login").value("helper"))
|
||||
.andExpect(jsonPath("$[0].displayName").value("helper"));
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/channels/{broadcaster}/admins", broadcaster).with(
|
||||
oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].login").value("helper"))
|
||||
.andExpect(jsonPath("$[0].displayName").value("helper"));
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
|
||||
|
||||
String assetId = objectMapper.readTree(mockMvc.perform(multipart("/api/channels/{broadcaster}/assets", broadcaster)
|
||||
.file(file)
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString()).get("id").asText();
|
||||
String assetId = objectMapper
|
||||
.readTree(
|
||||
mockMvc
|
||||
.perform(
|
||||
multipart("/api/channels/{broadcaster}/assets", broadcaster)
|
||||
.file(file)
|
||||
.with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)))
|
||||
)
|
||||
.andExpect(status().isOk())
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString()
|
||||
)
|
||||
.get("id")
|
||||
.asText();
|
||||
|
||||
mockMvc.perform(get("/api/channels/{broadcaster}/assets", broadcaster)
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(1)));
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/channels/{broadcaster}/assets", broadcaster).with(
|
||||
oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(1)));
|
||||
|
||||
VisibilityRequest visibilityRequest = new VisibilityRequest();
|
||||
visibilityRequest.setHidden(false);
|
||||
mockMvc.perform(put("/api/channels/{broadcaster}/assets/{id}/visibility", broadcaster, assetId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(visibilityRequest))
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.hidden").value(false));
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/channels/{broadcaster}/assets/{id}/visibility", broadcaster, assetId)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(visibilityRequest))
|
||||
.with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster)))
|
||||
)
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.hidden").value(false));
|
||||
|
||||
mockMvc.perform(get("/api/channels/{broadcaster}/assets/visible", broadcaster)
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(1)));
|
||||
mockMvc
|
||||
.perform(
|
||||
get("/api/channels/{broadcaster}/assets/visible", broadcaster).with(
|
||||
oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(1)));
|
||||
|
||||
mockMvc.perform(delete("/api/channels/{broadcaster}/assets/{id}", broadcaster, assetId)
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster))))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc
|
||||
.perform(
|
||||
delete("/api/channels/{broadcaster}/assets/{id}", broadcaster, assetId).with(
|
||||
oauth2Login().attributes((attrs) -> attrs.put("preferred_username", broadcaster))
|
||||
)
|
||||
)
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAdminChangesFromNonBroadcaster() throws Exception {
|
||||
mockMvc.perform(post("/api/channels/{broadcaster}/admins", "caster")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"helper\"}")
|
||||
.with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", "intruder"))))
|
||||
.andExpect(status().isForbidden());
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/channels/{broadcaster}/admins", "caster")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"helper\"}")
|
||||
.with(oauth2Login().attributes((attrs) -> attrs.put("preferred_username", "intruder")))
|
||||
)
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
private byte[] samplePng() throws IOException {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package dev.kruhlmann.imgfloat;
|
||||
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Channel;
|
||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -9,15 +14,12 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
@SpringBootTest(
|
||||
properties = {
|
||||
"spring.security.oauth2.client.registration.twitch.client-id=test-client-id",
|
||||
"spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret"
|
||||
})
|
||||
"spring.security.oauth2.client.registration.twitch.client-secret=test-client-secret",
|
||||
}
|
||||
)
|
||||
@AutoConfigureMockMvc
|
||||
class ChannelDirectoryApiIntegrationTest {
|
||||
|
||||
@@ -38,10 +40,11 @@ class ChannelDirectoryApiIntegrationTest {
|
||||
channelRepository.save(new Channel("alpha"));
|
||||
channelRepository.save(new Channel("ALPINE"));
|
||||
|
||||
mockMvc.perform(get("/api/channels").param("q", "Al"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(2)))
|
||||
.andExpect(jsonPath("$[0]").value("alpha"))
|
||||
.andExpect(jsonPath("$[1]").value("alpine"));
|
||||
mockMvc
|
||||
.perform(get("/api/channels").param("q", "Al"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$", hasSize(2)))
|
||||
.andExpect(jsonPath("$[0]").value("alpha"))
|
||||
.andExpect(jsonPath("$[1]").value("alpine"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
package dev.kruhlmann.imgfloat;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import dev.kruhlmann.imgfloat.model.AssetView;
|
||||
import dev.kruhlmann.imgfloat.model.Channel;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.AssetStorageService;
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaPreviewService;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.model.Settings;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -33,19 +34,17 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
class ChannelDirectoryServiceTest {
|
||||
|
||||
private ChannelDirectoryService service;
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
private ChannelRepository channelRepository;
|
||||
@@ -66,8 +65,15 @@ class ChannelDirectoryServiceTest {
|
||||
MediaPreviewService mediaPreviewService = new MediaPreviewService();
|
||||
MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService);
|
||||
MediaDetectionService mediaDetectionService = new MediaDetectionService();
|
||||
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate,
|
||||
assetStorageService, mediaDetectionService, mediaOptimizationService, settingsService);
|
||||
service = new ChannelDirectoryService(
|
||||
channelRepository,
|
||||
assetRepository,
|
||||
messagingTemplate,
|
||||
assetStorageService,
|
||||
mediaDetectionService,
|
||||
mediaOptimizationService,
|
||||
settingsService
|
||||
);
|
||||
ReflectionTestUtils.setField(service, "uploadLimitBytes", 5_000_000L);
|
||||
}
|
||||
|
||||
@@ -78,7 +84,10 @@ class ChannelDirectoryServiceTest {
|
||||
Optional<AssetView> created = service.createAsset("caster", file);
|
||||
assertThat(created).isPresent();
|
||||
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
|
||||
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture());
|
||||
verify(messagingTemplate).convertAndSend(
|
||||
org.mockito.ArgumentMatchers.contains("/topic/channel/caster"),
|
||||
captor.capture()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -105,8 +114,8 @@ class ChannelDirectoryServiceTest {
|
||||
transform.setWidth(0);
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransform(channel, id, transform))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Canvas width out of range");
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Canvas width out of range");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -118,15 +127,15 @@ class ChannelDirectoryServiceTest {
|
||||
speedTransform.setSpeed(5.0);
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransform(channel, id, speedTransform))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Speed out of range");
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Speed out of range");
|
||||
|
||||
TransformRequest volumeTransform = validTransform();
|
||||
volumeTransform.setAudioVolume(6.5);
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransform(channel, id, volumeTransform))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Audio volume out of range");
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("Audio volume out of range");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -178,44 +187,56 @@ class ChannelDirectoryServiceTest {
|
||||
Map<String, Channel> channels = new ConcurrentHashMap<>();
|
||||
Map<String, Asset> assets = new ConcurrentHashMap<>();
|
||||
|
||||
when(channelRepository.findById(anyString()))
|
||||
.thenAnswer(invocation -> Optional.ofNullable(channels.get(invocation.getArgument(0))));
|
||||
when(channelRepository.save(any(Channel.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Channel channel = invocation.getArgument(0);
|
||||
channels.put(channel.getBroadcaster(), channel);
|
||||
return channel;
|
||||
});
|
||||
when(channelRepository.findAll())
|
||||
.thenAnswer(invocation -> List.copyOf(channels.values()));
|
||||
when(channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(anyString()))
|
||||
.thenAnswer(invocation -> channels.values().stream()
|
||||
.filter(channel -> Optional.ofNullable(channel.getBroadcaster()).orElse("")
|
||||
.contains(Optional.ofNullable(invocation.getArgument(0, String.class)).orElse("").toLowerCase()))
|
||||
.sorted(Comparator.comparing(Channel::getBroadcaster))
|
||||
.limit(50)
|
||||
.toList());
|
||||
when(channelRepository.findById(anyString())).thenAnswer((invocation) ->
|
||||
Optional.ofNullable(channels.get(invocation.getArgument(0)))
|
||||
);
|
||||
when(channelRepository.save(any(Channel.class))).thenAnswer((invocation) -> {
|
||||
Channel channel = invocation.getArgument(0);
|
||||
channels.put(channel.getBroadcaster(), channel);
|
||||
return channel;
|
||||
});
|
||||
when(channelRepository.findAll()).thenAnswer((invocation) -> List.copyOf(channels.values()));
|
||||
when(channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(anyString())).thenAnswer(
|
||||
(invocation) ->
|
||||
channels
|
||||
.values()
|
||||
.stream()
|
||||
.filter((channel) ->
|
||||
Optional.ofNullable(channel.getBroadcaster())
|
||||
.orElse("")
|
||||
.contains(
|
||||
Optional.ofNullable(invocation.getArgument(0, String.class)).orElse("").toLowerCase()
|
||||
)
|
||||
)
|
||||
.sorted(Comparator.comparing(Channel::getBroadcaster))
|
||||
.limit(50)
|
||||
.toList()
|
||||
);
|
||||
|
||||
when(assetRepository.save(any(Asset.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
Asset asset = invocation.getArgument(0);
|
||||
assets.put(asset.getId(), asset);
|
||||
return asset;
|
||||
});
|
||||
when(assetRepository.findById(anyString()))
|
||||
.thenAnswer(invocation -> Optional.ofNullable(assets.get(invocation.getArgument(0))));
|
||||
when(assetRepository.findByBroadcaster(anyString()))
|
||||
.thenAnswer(invocation -> filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), false));
|
||||
when(assetRepository.findByBroadcasterAndHiddenFalse(anyString()))
|
||||
.thenAnswer(invocation -> filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), true));
|
||||
doAnswer(invocation -> assets.remove(invocation.getArgument(0, Asset.class).getId()))
|
||||
.when(assetRepository).delete(any(Asset.class));
|
||||
when(assetRepository.save(any(Asset.class))).thenAnswer((invocation) -> {
|
||||
Asset asset = invocation.getArgument(0);
|
||||
assets.put(asset.getId(), asset);
|
||||
return asset;
|
||||
});
|
||||
when(assetRepository.findById(anyString())).thenAnswer((invocation) ->
|
||||
Optional.ofNullable(assets.get(invocation.getArgument(0)))
|
||||
);
|
||||
when(assetRepository.findByBroadcaster(anyString())).thenAnswer((invocation) ->
|
||||
filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), false)
|
||||
);
|
||||
when(assetRepository.findByBroadcasterAndHiddenFalse(anyString())).thenAnswer((invocation) ->
|
||||
filterAssetsByBroadcaster(assets.values(), invocation.getArgument(0), true)
|
||||
);
|
||||
doAnswer((invocation) -> assets.remove(invocation.getArgument(0, Asset.class).getId()))
|
||||
.when(assetRepository)
|
||||
.delete(any(Asset.class));
|
||||
}
|
||||
|
||||
private List<Asset> filterAssetsByBroadcaster(Collection<Asset> assets, String broadcaster, boolean onlyVisible) {
|
||||
return assets.stream()
|
||||
.filter(asset -> asset.getBroadcaster().equalsIgnoreCase(broadcaster))
|
||||
.filter(asset -> !onlyVisible || !asset.isHidden())
|
||||
.toList();
|
||||
return assets
|
||||
.stream()
|
||||
.filter((asset) -> asset.getBroadcaster().equalsIgnoreCase(broadcaster))
|
||||
.filter((asset) -> !onlyVisible || !asset.isHidden())
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.RequestEntity;
|
||||
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
|
||||
@@ -12,39 +14,43 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResp
|
||||
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TwitchAuthorizationCodeGrantRequestEntityConverterTest {
|
||||
|
||||
@Test
|
||||
void addsClientIdAndSecretToTokenRequestBody() {
|
||||
ClientRegistration registration = ClientRegistration.withRegistrationId("twitch")
|
||||
.clientId("twitch-id")
|
||||
.clientSecret("twitch-secret")
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("https://example.com/redirect")
|
||||
.scope("user:read:email")
|
||||
.authorizationUri("https://id.twitch.tv/oauth2/authorize")
|
||||
.tokenUri("https://id.twitch.tv/oauth2/token")
|
||||
.userInfoUri("https://api.twitch.tv/helix/users")
|
||||
.userNameAttributeName("preferred_username")
|
||||
.build();
|
||||
.clientId("twitch-id")
|
||||
.clientSecret("twitch-secret")
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.redirectUri("https://example.com/redirect")
|
||||
.scope("user:read:email")
|
||||
.authorizationUri("https://id.twitch.tv/oauth2/authorize")
|
||||
.tokenUri("https://id.twitch.tv/oauth2/token")
|
||||
.userInfoUri("https://api.twitch.tv/helix/users")
|
||||
.userNameAttributeName("preferred_username")
|
||||
.build();
|
||||
|
||||
OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
|
||||
.authorizationUri(registration.getProviderDetails().getAuthorizationUri())
|
||||
.clientId(registration.getClientId())
|
||||
.redirectUri(registration.getRedirectUri())
|
||||
.state("state")
|
||||
.build();
|
||||
.authorizationUri(registration.getProviderDetails().getAuthorizationUri())
|
||||
.clientId(registration.getClientId())
|
||||
.redirectUri(registration.getRedirectUri())
|
||||
.state("state")
|
||||
.build();
|
||||
|
||||
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponse.success("code")
|
||||
.redirectUri(registration.getRedirectUri())
|
||||
.state("state")
|
||||
.build();
|
||||
.redirectUri(registration.getRedirectUri())
|
||||
.state("state")
|
||||
.build();
|
||||
|
||||
OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse);
|
||||
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(registration, exchange);
|
||||
OAuth2AuthorizationExchange exchange = new OAuth2AuthorizationExchange(
|
||||
authorizationRequest,
|
||||
authorizationResponse
|
||||
);
|
||||
OAuth2AuthorizationCodeGrantRequest grantRequest = new OAuth2AuthorizationCodeGrantRequest(
|
||||
registration,
|
||||
exchange
|
||||
);
|
||||
|
||||
var converter = new TwitchAuthorizationCodeGrantRequestEntityConverter();
|
||||
RequestEntity<?> requestEntity = converter.convert(grantRequest);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package dev.kruhlmann.imgfloat.config;
|
||||
|
||||
import java.net.URI;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
import java.net.URI;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -13,11 +17,6 @@ import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenRespon
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
|
||||
class TwitchOAuth2ErrorResponseErrorHandlerTest {
|
||||
|
||||
private final TwitchOAuth2ErrorResponseErrorHandler handler = new TwitchOAuth2ErrorResponseErrorHandler();
|
||||
@@ -27,12 +26,12 @@ class TwitchOAuth2ErrorResponseErrorHandlerTest {
|
||||
MockClientHttpResponse response = new MockClientHttpResponse(new byte[0], HttpStatus.BAD_REQUEST);
|
||||
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
OAuth2AuthorizationException exception = assertThrows(OAuth2AuthorizationException.class,
|
||||
() -> handler.handleError(response));
|
||||
OAuth2AuthorizationException exception = assertThrows(OAuth2AuthorizationException.class, () ->
|
||||
handler.handleError(response)
|
||||
);
|
||||
|
||||
assertThat(exception.getError().getErrorCode()).isEqualTo("invalid_token_response");
|
||||
assertThat(exception.getError().getDescription())
|
||||
.contains("Failed to parse Twitch OAuth error response");
|
||||
assertThat(exception.getError().getDescription()).contains("Failed to parse Twitch OAuth error response");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -41,13 +40,20 @@ class TwitchOAuth2ErrorResponseErrorHandlerTest {
|
||||
restTemplate.setErrorHandler(new TwitchOAuth2ErrorResponseErrorHandler());
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
|
||||
|
||||
server.expect(requestTo("https://id.twitch.tv/oauth2/token"))
|
||||
.andRespond(withSuccess(
|
||||
"{\"access_token\":\"abc\",\"token_type\":\"bearer\",\"expires_in\":3600,\"scope\":[]}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
server
|
||||
.expect(requestTo("https://id.twitch.tv/oauth2/token"))
|
||||
.andRespond(
|
||||
withSuccess(
|
||||
"{\"access_token\":\"abc\",\"token_type\":\"bearer\",\"expires_in\":3600,\"scope\":[]}",
|
||||
MediaType.APPLICATION_JSON
|
||||
)
|
||||
);
|
||||
|
||||
RequestEntity<Void> request = RequestEntity.post(URI.create("https://id.twitch.tv/oauth2/token")).build();
|
||||
ResponseEntity<OAuth2AccessTokenResponse> response = restTemplate.exchange(request, OAuth2AccessTokenResponse.class);
|
||||
ResponseEntity<OAuth2AccessTokenResponse> response = restTemplate.exchange(
|
||||
request,
|
||||
OAuth2AccessTokenResponse.class
|
||||
);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
|
||||
@@ -8,7 +8,6 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -26,51 +25,54 @@ class TwitchOAuth2UserServiceTest {
|
||||
@Test
|
||||
void unwrapsTwitchUserAndAddsClientIdHeaderToUserInfoRequest() {
|
||||
ClientRegistration registration = twitchRegistrationBuilder()
|
||||
.clientId("client-123")
|
||||
.clientSecret("secret")
|
||||
.build();
|
||||
.clientId("client-123")
|
||||
.clientSecret("secret")
|
||||
.build();
|
||||
|
||||
OAuth2UserRequest userRequest = userRequest(registration);
|
||||
RestTemplate restTemplate = TwitchOAuth2UserService.createRestTemplate(userRequest);
|
||||
MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build();
|
||||
|
||||
TwitchOAuth2UserService service = new TwitchOAuth2UserService(ignored -> restTemplate);
|
||||
TwitchOAuth2UserService service = new TwitchOAuth2UserService((ignored) -> restTemplate);
|
||||
|
||||
server.expect(requestTo("https://api.twitch.tv/helix/users"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header("Client-ID", "client-123"))
|
||||
.andRespond(withSuccess(
|
||||
"{\"data\":[{\"id\":\"42\",\"login\":\"demo\",\"display_name\":\"Demo\"}]}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
server
|
||||
.expect(requestTo("https://api.twitch.tv/helix/users"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header("Client-ID", "client-123"))
|
||||
.andRespond(
|
||||
withSuccess(
|
||||
"{\"data\":[{\"id\":\"42\",\"login\":\"demo\",\"display_name\":\"Demo\"}]}",
|
||||
MediaType.APPLICATION_JSON
|
||||
)
|
||||
);
|
||||
|
||||
OAuth2User user = service.loadUser(userRequest);
|
||||
|
||||
assertThat(user.getName()).isEqualTo("demo");
|
||||
assertThat(user.getAttributes())
|
||||
.containsEntry("id", "42")
|
||||
.containsEntry("display_name", "Demo");
|
||||
assertThat(user.getAttributes()).containsEntry("id", "42").containsEntry("display_name", "Demo");
|
||||
server.verify();
|
||||
}
|
||||
|
||||
private OAuth2UserRequest userRequest(ClientRegistration registration) {
|
||||
OAuth2AccessToken accessToken = new OAuth2AccessToken(
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(60),
|
||||
Set.of("user:read:email"));
|
||||
OAuth2AccessToken.TokenType.BEARER,
|
||||
"token",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(60),
|
||||
Set.of("user:read:email")
|
||||
);
|
||||
return new OAuth2UserRequest(registration, accessToken);
|
||||
}
|
||||
|
||||
private ClientRegistration.Builder twitchRegistrationBuilder() {
|
||||
return ClientRegistration.withRegistrationId("twitch")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.clientName("Twitch")
|
||||
.redirectUri("https://example.com/login/oauth2/code/twitch")
|
||||
.authorizationUri("https://id.twitch.tv/oauth2/authorize")
|
||||
.tokenUri("https://id.twitch.tv/oauth2/token")
|
||||
.userInfoUri("https://api.twitch.tv/helix/users")
|
||||
.userNameAttributeName("login");
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
|
||||
.clientName("Twitch")
|
||||
.redirectUri("https://example.com/login/oauth2/code/twitch")
|
||||
.authorizationUri("https://id.twitch.tv/oauth2/authorize")
|
||||
.tokenUri("https://id.twitch.tv/oauth2/token")
|
||||
.userInfoUri("https://api.twitch.tv/helix/users")
|
||||
.userNameAttributeName("login");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class AssetStorageServiceTest {
|
||||
|
||||
private AssetStorageService service;
|
||||
private Path assets;
|
||||
private Path previews;
|
||||
@@ -27,13 +27,13 @@ class AssetStorageServiceTest {
|
||||
@Test
|
||||
void refusesToStoreEmptyAsset() {
|
||||
assertThatThrownBy(() -> service.storeAsset("caster", "id", new byte[0], "image/png"))
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("empty");
|
||||
.isInstanceOf(IOException.class)
|
||||
.hasMessageContaining("empty");
|
||||
}
|
||||
|
||||
@Test
|
||||
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.setMediaType("image/png");
|
||||
|
||||
@@ -53,7 +53,7 @@ class AssetStorageServiceTest {
|
||||
|
||||
@Test
|
||||
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.setMediaType("image/png");
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
package dev.kruhlmann.imgfloat.service.media;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
class MediaDetectionServiceTest {
|
||||
|
||||
private final MediaDetectionService service = new MediaDetectionService();
|
||||
|
||||
@Test
|
||||
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);
|
||||
|
||||
assertThat(service.detectAllowedMediaType(file, file.getBytes())).contains("image/png");
|
||||
@@ -20,14 +20,14 @@ class MediaDetectionServiceTest {
|
||||
|
||||
@Test
|
||||
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");
|
||||
}
|
||||
|
||||
@Test
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
package dev.kruhlmann.imgfloat.service.media;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import javax.imageio.ImageIO;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MediaOptimizationServiceTest {
|
||||
|
||||
private MediaOptimizationService service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -38,7 +38,7 @@ class MediaOptimizationServiceTest {
|
||||
|
||||
@Test
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user