diff --git a/Makefile b/Makefile index 7cb1107..9f74a84 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ .DEFAULT_GOAL := build IMGFLOAT_DB_PATH ?= ./imgfloat.db +IMGFLOAT_AUDIT_DB_PATH ?= ./imgfloat.audit.db IMGFLOAT_GITHUB_CLIENT_OWNER ?= imgfloat IMGFLOAT_GITHUB_CLIENT_REPO ?= client IMGFLOAT_GITHUB_CLIENT_VERSION ?= 1.0.0 @@ -25,6 +26,7 @@ RUNTIME_ENV = IMGFLOAT_ASSETS_PATH=$(IMGFLOAT_ASSETS_PATH) \ IMGFLOAT_GITHUB_CLIENT_VERSION=$(IMGFLOAT_GITHUB_CLIENT_VERSION) \ IMGFLOAT_COMMIT_URL_PREFIX=$(IMGFLOAT_COMMIT_URL_PREFIX) \ IMGFLOAT_DB_PATH=$(IMGFLOAT_DB_PATH) \ + IMGFLOAT_AUDIT_DB_PATH=$(IMGFLOAT_AUDIT_DB_PATH) \ SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE) \ SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE=$(SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE) \ IMGFLOAT_TOKEN_ENCRYPTION_KEY=$(IMGFLOAT_TOKEN_ENCRYPTION_KEY) diff --git a/README.md b/README.md index 7858b48..a4b6554 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Define the following required environment variables: | `IMGFLOAT_ASSETS_PATH` | Filesystem path to store uploaded assets | /var/imgfloat/assets | | `IMGFLOAT_PREVIEWS_PATH` | Filesystem path to store generated image previews | /var/imgfloat/previews | | `IMGFLOAT_DB_PATH` | Filesystem path to the SQLite database file | /var/imgfloat/imgfloat.db | +| `IMGFLOAT_AUDIT_DB_PATH` | Filesystem path to the SQLite audit log database file | /var/imgfloat/imgfloat-audit.db | | `IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN` | Twitch username of the initial sysadmin user | example_broadcaster | | `IMGFLOAT_GITHUB_CLIENT_OWNER` | GitHub user or org which has the client repository | imgfloat | | `IMGFLOAT_GITHUB_CLIENT_REPO` | Client repository name | client | diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntry.java b/src/main/java/dev/kruhlmann/imgfloat/audit/model/AuditLogEntry.java similarity index 97% rename from src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntry.java rename to src/main/java/dev/kruhlmann/imgfloat/audit/model/AuditLogEntry.java index e1ac843..203115d 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntry.java +++ b/src/main/java/dev/kruhlmann/imgfloat/audit/model/AuditLogEntry.java @@ -1,4 +1,4 @@ -package dev.kruhlmann.imgfloat.model; +package dev.kruhlmann.imgfloat.audit.model; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/AuditLogDataSourceConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/AuditLogDataSourceConfig.java new file mode 100644 index 0000000..031cb06 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/config/AuditLogDataSourceConfig.java @@ -0,0 +1,90 @@ +package dev.kruhlmann.imgfloat.config; + +import com.zaxxer.hikari.HikariDataSource; +import jakarta.persistence.EntityManagerFactory; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.configuration.FluentConfiguration; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.flyway.FlywayProperties; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings; +import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; +import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@EnableJpaRepositories( + basePackages = "dev.kruhlmann.imgfloat.repository.audit", + entityManagerFactoryRef = "auditEntityManagerFactory", + transactionManagerRef = "auditTransactionManager" +) +public class AuditLogDataSourceConfig { + + @Bean + @ConfigurationProperties("imgfloat.audit.datasource") + public DataSourceProperties auditDataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean + @ConfigurationProperties("imgfloat.audit.datasource.hikari") + public HikariDataSource auditDataSource( + @Qualifier("auditDataSourceProperties") DataSourceProperties properties + ) { + return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } + + @Bean + public LocalContainerEntityManagerFactoryBean auditEntityManagerFactory( + EntityManagerFactoryBuilder builder, + @Qualifier("auditDataSource") DataSource dataSource, + JpaProperties jpaProperties, + HibernateProperties hibernateProperties + ) { + return builder + .dataSource(dataSource) + .packages("dev.kruhlmann.imgfloat.audit.model") + .properties(hibernateProperties.determineHibernateProperties(jpaProperties.getProperties(), new HibernateSettings())) + .persistenceUnit("audit") + .build(); + } + + @Bean + public PlatformTransactionManager auditTransactionManager( + @Qualifier("auditEntityManagerFactory") EntityManagerFactory entityManagerFactory + ) { + return new JpaTransactionManager(entityManagerFactory); + } + + @Bean + @ConfigurationProperties("imgfloat.audit.flyway") + public FlywayProperties auditFlywayProperties() { + return new FlywayProperties(); + } + + @Bean(initMethod = "migrate") + public Flyway auditFlyway( + @Qualifier("auditDataSource") DataSource dataSource, + @Qualifier("auditFlywayProperties") FlywayProperties properties + ) { + FluentConfiguration configuration = Flyway.configure().dataSource(dataSource); + if (properties.getLocations() != null && !properties.getLocations().isEmpty()) { + configuration.locations(properties.getLocations().toArray(new String[0])); + } + if (properties.isBaselineOnMigrate()) { + configuration.baselineOnMigrate(true); + } + if (properties.getBaselineVersion() != null) { + configuration.baselineVersion(properties.getBaselineVersion()); + } + return configuration.load(); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/PrimaryDataSourceConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/PrimaryDataSourceConfig.java new file mode 100644 index 0000000..0a3848f --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/config/PrimaryDataSourceConfig.java @@ -0,0 +1,99 @@ +package dev.kruhlmann.imgfloat.config; + +import com.zaxxer.hikari.HikariDataSource; +import dev.kruhlmann.imgfloat.repository.audit.AuditLogRepository; +import jakarta.persistence.EntityManagerFactory; +import javax.sql.DataSource; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.configuration.FluentConfiguration; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.flyway.FlywayProperties; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateProperties; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateSettings; +import org.springframework.boot.autoconfigure.orm.jpa.JpaProperties; +import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Primary; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@EnableJpaRepositories( + basePackages = "dev.kruhlmann.imgfloat.repository", + excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = AuditLogRepository.class), + entityManagerFactoryRef = "entityManagerFactory", + transactionManagerRef = "transactionManager" +) +public class PrimaryDataSourceConfig { + + @Bean + @Primary + @ConfigurationProperties("spring.datasource") + public DataSourceProperties dataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean + @Primary + @ConfigurationProperties("spring.datasource.hikari") + public HikariDataSource dataSource(@Qualifier("dataSourceProperties") DataSourceProperties properties) { + return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } + + @Bean + @Primary + public LocalContainerEntityManagerFactoryBean entityManagerFactory( + EntityManagerFactoryBuilder builder, + @Qualifier("dataSource") DataSource dataSource, + JpaProperties jpaProperties, + HibernateProperties hibernateProperties + ) { + return builder + .dataSource(dataSource) + .packages("dev.kruhlmann.imgfloat.model") + .properties(hibernateProperties.determineHibernateProperties(jpaProperties.getProperties(), new HibernateSettings())) + .persistenceUnit("primary") + .build(); + } + + @Bean + @Primary + public PlatformTransactionManager transactionManager( + @Qualifier("entityManagerFactory") EntityManagerFactory entityManagerFactory + ) { + return new JpaTransactionManager(entityManagerFactory); + } + + @Bean + @Primary + @ConfigurationProperties("spring.flyway") + public FlywayProperties flywayProperties() { + return new FlywayProperties(); + } + + @Bean(initMethod = "migrate") + @Primary + public Flyway flyway( + @Qualifier("dataSource") DataSource dataSource, + @Qualifier("flywayProperties") FlywayProperties properties + ) { + FluentConfiguration configuration = Flyway.configure().dataSource(dataSource); + if (properties.getLocations() != null && !properties.getLocations().isEmpty()) { + configuration.locations(properties.getLocations().toArray(new String[0])); + } + if (properties.isBaselineOnMigrate()) { + configuration.baselineOnMigrate(true); + } + if (properties.getBaselineVersion() != null) { + configuration.baselineVersion(properties.getBaselineVersion()); + } + return configuration.load(); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java index 433d72d..3a3dbf0 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java @@ -37,6 +37,9 @@ public class SystemEnvironmentValidator { @Value("${IMGFLOAT_DB_PATH:#{null}}") private String dbPath; + @Value("${IMGFLOAT_AUDIT_DB_PATH:${IMGFLOAT_DB_PATH:#{null}}}") + private String auditDbPath; + @Value("${IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN:#{null}}") private String initialSysadmin; @@ -76,6 +79,7 @@ public class SystemEnvironmentValidator { checkString(twitchClientId, "TWITCH_CLIENT_ID", missing); checkString(initialSysadmin, "IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN", missing); checkString(dbPath, "IMGFLOAT_DB_PATH", missing); + checkString(auditDbPath, "IMGFLOAT_AUDIT_DB_PATH", missing); checkString(twitchClientSecret, "TWITCH_CLIENT_SECRET", missing); checkString(assetsPath, "IMGFLOAT_ASSETS_PATH", missing); checkString(previewsPath, "IMGFLOAT_PREVIEWS_PATH", missing); @@ -93,6 +97,7 @@ public class SystemEnvironmentValidator { log.info(" - SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {} ({} bytes)", springMaxFileSize, maxUploadBytes); log.info(" - SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: {} ({} bytes)", springMaxRequestSize, maxRequestBytes); log.info(" - IMGFLOAT_DB_PATH: {}", dbPath); + log.info(" - IMGFLOAT_AUDIT_DB_PATH: {}", auditDbPath); log.info(" - IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN: {}", initialSysadmin); log.info(" - IMGFLOAT_ASSETS_PATH: {}", assetsPath); log.info(" - IMGFLOAT_PREVIEWS_PATH: {}", previewsPath); diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntryView.java b/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntryView.java index a5598c6..fae894b 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntryView.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntryView.java @@ -1,5 +1,6 @@ package dev.kruhlmann.imgfloat.model; +import dev.kruhlmann.imgfloat.audit.model.AuditLogEntry; import java.time.Instant; public record AuditLogEntryView(String id, String actor, String action, String details, Instant createdAt) { diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/audit/AuditLogRepository.java similarity index 92% rename from src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java rename to src/main/java/dev/kruhlmann/imgfloat/repository/audit/AuditLogRepository.java index 8aeb9c5..b990bdb 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/audit/AuditLogRepository.java @@ -1,6 +1,6 @@ -package dev.kruhlmann.imgfloat.repository; +package dev.kruhlmann.imgfloat.repository.audit; -import dev.kruhlmann.imgfloat.model.AuditLogEntry; +import dev.kruhlmann.imgfloat.audit.model.AuditLogEntry; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java index 1494113..18ea480 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java @@ -1,8 +1,8 @@ package dev.kruhlmann.imgfloat.service; -import dev.kruhlmann.imgfloat.model.AuditLogEntry; +import dev.kruhlmann.imgfloat.audit.model.AuditLogEntry; import dev.kruhlmann.imgfloat.model.AuditLogEntryView; -import dev.kruhlmann.imgfloat.repository.AuditLogRepository; +import dev.kruhlmann.imgfloat.repository.audit.AuditLogRepository; import dev.kruhlmann.imgfloat.util.LogSanitizer; import java.util.List; import java.util.Locale; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ab1b38c..d176377 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -20,7 +20,7 @@ spring: thymeleaf: cache: false datasource: - url: jdbc:sqlite:${IMGFLOAT_DB_PATH}?busy_timeout=5000&journal_mode=WAL + url: jdbc:sqlite:${IMGFLOAT_DB_PATH:./imgfloat.db}?busy_timeout=5000&journal_mode=WAL driver-class-name: org.sqlite.JDBC hikari: connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;" @@ -59,6 +59,20 @@ spring: user-info-uri: https://api.twitch.tv/helix/users user-name-attribute: login +imgfloat: + audit: + datasource: + url: jdbc:sqlite:${IMGFLOAT_AUDIT_DB_PATH:./imgfloat.audit.db}?busy_timeout=5000&journal_mode=WAL + driver-class-name: org.sqlite.JDBC + hikari: + connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;" + maximum-pool-size: 1 + minimum-idle: 1 + flyway: + locations: classpath:db/audit + baseline-on-migrate: true + baseline-version: 1 + management: endpoints: web: diff --git a/src/main/resources/db/audit/V1__channel_audit_log.sql b/src/main/resources/db/audit/V1__channel_audit_log.sql new file mode 100644 index 0000000..ebb5846 --- /dev/null +++ b/src/main/resources/db/audit/V1__channel_audit_log.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS channel_audit_log ( + id TEXT PRIMARY KEY, + broadcaster TEXT NOT NULL, + actor TEXT, + action TEXT NOT NULL, + details TEXT, + created_at TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS channel_audit_log_broadcaster_idx ON channel_audit_log (broadcaster); +CREATE INDEX IF NOT EXISTS channel_audit_log_created_at_idx ON channel_audit_log (created_at);