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