From 18dff66373d8973132abcbde2d83041931eb917f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 15 Jan 2026 16:19:09 +0100 Subject: [PATCH] Add audit log --- .../controller/AuditLogApiController.java | 46 +++ .../controller/ChannelApiController.java | 30 +- .../ScriptMarketplaceController.java | 2 +- .../imgfloat/controller/ViewController.java | 18 + .../imgfloat/model/AuditLogEntry.java | 87 ++++ .../imgfloat/model/AuditLogEntryView.java | 18 + .../repository/AuditLogRepository.java | 13 + .../imgfloat/service/AccountService.java | 8 +- .../imgfloat/service/AuditLogService.java | 77 ++++ .../service/ChannelDirectoryService.java | 379 ++++++++++++++---- .../db/migration/V7__channel_audit_log.sql | 12 + src/main/resources/static/css/styles.css | 95 +++++ src/main/resources/static/js/audit-log.js | 62 +++ src/main/resources/templates/admin.html | 6 + src/main/resources/templates/audit-log.html | 52 +++ .../imgfloat/ChannelDirectoryServiceTest.java | 24 +- 16 files changed, 818 insertions(+), 111 deletions(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/controller/AuditLogApiController.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntry.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntryView.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java create mode 100644 src/main/resources/db/migration/V7__channel_audit_log.sql create mode 100644 src/main/resources/static/js/audit-log.js create mode 100644 src/main/resources/templates/audit-log.html diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/AuditLogApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/AuditLogApiController.java new file mode 100644 index 0000000..c5bdfbf --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/AuditLogApiController.java @@ -0,0 +1,46 @@ +package dev.kruhlmann.imgfloat.controller; + +import dev.kruhlmann.imgfloat.model.AuditLogEntryView; +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.service.AuditLogService; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.util.LogSanitizer; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/channels/{broadcaster}/audit") +@SecurityRequirement(name = "twitchOAuth") +public class AuditLogApiController { + + private static final Logger LOG = LoggerFactory.getLogger(AuditLogApiController.class); + private final AuditLogService auditLogService; + private final AuthorizationService authorizationService; + + public AuditLogApiController(AuditLogService auditLogService, AuthorizationService authorizationService) { + this.auditLogService = auditLogService; + this.authorizationService = authorizationService; + } + + @GetMapping + public List listAuditEntries( + @PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + LOG.info( + "Listing audit log entries for {} by {}", + LogSanitizer.sanitize(broadcaster), + LogSanitizer.sanitize(sessionUsername) + ); + return auditLogService.listEntries(broadcaster); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index ec7be05..2eee9ae 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -86,7 +86,7 @@ public class ChannelApiController { String logRequestUsername = LogSanitizer.sanitize(request.getUsername()); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); LOG.info("User {} adding admin {} to {}", logSessionUsername, logRequestUsername, logBroadcaster); - boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername()); + boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername(), sessionUsername); if (!added) { LOG.info("User {} already admin for {} or could not be added", logRequestUsername, logBroadcaster); } @@ -171,7 +171,7 @@ public class ChannelApiController { String logUsername = LogSanitizer.sanitize(username); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); LOG.info("User {} removing admin {} from {}", logSessionUsername, logUsername, logBroadcaster); - boolean removed = channelDirectoryService.removeAdmin(broadcaster, username); + boolean removed = channelDirectoryService.removeAdmin(broadcaster, username, sessionUsername); return ResponseEntity.ok(removed); } @@ -207,7 +207,7 @@ public class ChannelApiController { request.getWidth(), request.getHeight() ); - return channelDirectoryService.updateCanvasSettings(broadcaster, request); + return channelDirectoryService.updateCanvasSettings(broadcaster, request, sessionUsername); } @GetMapping("/settings") @@ -226,7 +226,7 @@ public class ChannelApiController { String logSessionUsername = LogSanitizer.sanitize(sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); LOG.info("Updating script settings for {} by {}", logBroadcaster, logSessionUsername); - return channelDirectoryService.updateChannelScriptSettings(broadcaster, request); + return channelDirectoryService.updateChannelScriptSettings(broadcaster, request, sessionUsername); } @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -250,7 +250,7 @@ public class ChannelApiController { String logOriginalFilename = LogSanitizer.sanitize(file.getOriginalFilename()); LOG.info("User {} uploading asset {} to {}", logSessionUsername, logOriginalFilename, logBroadcaster); return channelDirectoryService - .createAsset(broadcaster, file) + .createAsset(broadcaster, file, sessionUsername) .map(ResponseEntity::ok) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image")); } catch (IOException e) { @@ -274,7 +274,7 @@ public class ChannelApiController { ); LOG.info("Creating custom script for {} by {}", logBroadcaster, logSessionUsername); return channelDirectoryService - .createCodeAsset(broadcaster, request) + .createCodeAsset(broadcaster, request, sessionUsername) .map(ResponseEntity::ok) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save custom script")); } @@ -296,7 +296,7 @@ public class ChannelApiController { ); LOG.info("Updating custom script {} for {} by {}", logAssetId, logBroadcaster, logSessionUsername); return channelDirectoryService - .updateCodeAsset(broadcaster, assetId, request) + .updateCodeAsset(broadcaster, assetId, request, sessionUsername) .map(ResponseEntity::ok) .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); } @@ -318,7 +318,7 @@ public class ChannelApiController { ); LOG.debug("Applying transform to asset {} on {} by {}", logAssetId, logBroadcaster, logSessionUsername); return channelDirectoryService - .updateTransform(broadcaster, assetId, request) + .updateTransform(broadcaster, assetId, request, sessionUsername) .map(ResponseEntity::ok) .orElseThrow(() -> { LOG.warn( @@ -348,7 +348,7 @@ public class ChannelApiController { ); LOG.info("Triggering playback for asset {} on {} by {}", logAssetId, logBroadcaster, logSessionUsername); return channelDirectoryService - .triggerPlayback(broadcaster, assetId, request) + .triggerPlayback(broadcaster, assetId, request, sessionUsername) .map(ResponseEntity::ok) .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); } @@ -376,7 +376,7 @@ public class ChannelApiController { request.isHidden() ); return channelDirectoryService - .updateVisibility(broadcaster, assetId, request) + .updateVisibility(broadcaster, assetId, request, sessionUsername) .map(ResponseEntity::ok) .orElseThrow(() -> { LOG.warn( @@ -502,7 +502,7 @@ public class ChannelApiController { broadcaster, sessionUsername ); - boolean removed = channelDirectoryService.deleteAsset(assetId); + boolean removed = channelDirectoryService.deleteAsset(assetId, sessionUsername); if (!removed) { LOG.warn("Attempt to delete missing asset {} on {} by {}", logAssetId, logBroadcaster, logSessionUsername); throw createAsset404(); @@ -542,7 +542,7 @@ public class ChannelApiController { } try { return channelDirectoryService - .createScriptAttachment(broadcaster, assetId, file) + .createScriptAttachment(broadcaster, assetId, file, sessionUsername) .map(ResponseEntity::ok) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save attachment")); } catch (IOException e) { @@ -568,7 +568,7 @@ public class ChannelApiController { } try { return channelDirectoryService - .updateScriptLogo(broadcaster, assetId, file) + .updateScriptLogo(broadcaster, assetId, file, sessionUsername) .map(ResponseEntity::ok) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save logo")); } catch (IOException e) { @@ -588,7 +588,7 @@ public class ChannelApiController { broadcaster, sessionUsername ); - channelDirectoryService.clearScriptLogo(broadcaster, assetId); + channelDirectoryService.clearScriptLogo(broadcaster, assetId, sessionUsername); return ResponseEntity.ok().build(); } @@ -604,7 +604,7 @@ public class ChannelApiController { broadcaster, sessionUsername ); - boolean removed = channelDirectoryService.deleteScriptAttachment(broadcaster, assetId, attachmentId); + boolean removed = channelDirectoryService.deleteScriptAttachment(broadcaster, assetId, attachmentId, sessionUsername); if (!removed) { throw createAsset404(); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ScriptMarketplaceController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ScriptMarketplaceController.java index 655c30a..34968ee 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ScriptMarketplaceController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ScriptMarketplaceController.java @@ -85,7 +85,7 @@ public class ScriptMarketplaceController { String logTarget = LogSanitizer.sanitize(request.getTargetBroadcaster()); LOG.info("Importing marketplace script {} into {}", logScriptId, logTarget); return channelDirectoryService - .importMarketplaceScript(request.getTargetBroadcaster(), scriptId) + .importMarketplaceScript(request.getTargetBroadcaster(), scriptId, sessionUsername) .map(ResponseEntity::ok) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script")); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java index 45a251a..1468c00 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java @@ -160,6 +160,24 @@ public class ViewController { return "admin"; } + @org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/audit") + public String auditLogView( + @org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken oauthToken, + Model model + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); + String logBroadcaster = LogSanitizer.sanitize(broadcaster); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + LOG.info("Rendering audit log for {} by {}", logBroadcaster, logSessionUsername); + model.addAttribute("broadcaster", broadcaster.toLowerCase()); + model.addAttribute("username", sessionUsername); + addStagingAttribute(model); + addVersionAttributes(model); + return "audit-log"; + } + @org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast") public String broadcastView( @org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntry.java b/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntry.java new file mode 100644 index 0000000..e1ac843 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntry.java @@ -0,0 +1,87 @@ +package dev.kruhlmann.imgfloat.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Locale; +import java.util.UUID; + +@Entity +@Table(name = "channel_audit_log") +public class AuditLogEntry { + + @Id + private String id; + + @Column(nullable = false) + private String broadcaster; + + @Column(nullable = false) + private String action; + + @Column + private String actor; + + @Column + private String details; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + public AuditLogEntry() {} + + public AuditLogEntry(String broadcaster, String actor, String action, String details) { + this.id = UUID.randomUUID().toString(); + this.broadcaster = normalize(broadcaster); + this.actor = normalize(actor); + this.action = action == null || action.isBlank() ? "UNKNOWN" : action; + this.details = details; + this.createdAt = Instant.now(); + } + + @PrePersist + public void prepare() { + if (id == null) { + id = UUID.randomUUID().toString(); + } + if (createdAt == null) { + createdAt = Instant.now(); + } + broadcaster = normalize(broadcaster); + actor = normalize(actor); + if (action == null || action.isBlank()) { + action = "UNKNOWN"; + } + } + + public String getId() { + return id; + } + + public String getBroadcaster() { + return broadcaster; + } + + public String getAction() { + return action; + } + + public String getActor() { + return actor; + } + + public String getDetails() { + return details; + } + + public Instant getCreatedAt() { + return createdAt; + } + + private static String normalize(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntryView.java b/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntryView.java new file mode 100644 index 0000000..a5598c6 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AuditLogEntryView.java @@ -0,0 +1,18 @@ +package dev.kruhlmann.imgfloat.model; + +import java.time.Instant; + +public record AuditLogEntryView(String id, String actor, String action, String details, Instant createdAt) { + public static AuditLogEntryView fromEntry(AuditLogEntry entry) { + if (entry == null) { + return null; + } + return new AuditLogEntryView( + entry.getId(), + entry.getActor(), + entry.getAction(), + entry.getDetails(), + entry.getCreatedAt() + ); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java new file mode 100644 index 0000000..b27eb79 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/AuditLogRepository.java @@ -0,0 +1,13 @@ +package dev.kruhlmann.imgfloat.repository; + +import dev.kruhlmann.imgfloat.model.AuditLogEntry; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AuditLogRepository extends JpaRepository { + List findTop200ByBroadcasterOrderByCreatedAtDesc(String broadcaster); + + void deleteByBroadcaster(String broadcaster); +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AccountService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AccountService.java index b39bd45..5e0e2e6 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AccountService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AccountService.java @@ -28,6 +28,7 @@ public class AccountService { private final SystemAdministratorRepository systemAdministratorRepository; private final AssetStorageService assetStorageService; private final JdbcTemplate jdbcTemplate; + private final AuditLogService auditLogService; public AccountService( ChannelDirectoryService channelDirectoryService, @@ -37,7 +38,8 @@ public class AccountService { MarketplaceScriptHeartRepository marketplaceScriptHeartRepository, SystemAdministratorRepository systemAdministratorRepository, AssetStorageService assetStorageService, - JdbcTemplate jdbcTemplate + JdbcTemplate jdbcTemplate, + AuditLogService auditLogService ) { this.channelDirectoryService = channelDirectoryService; this.assetRepository = assetRepository; @@ -47,6 +49,7 @@ public class AccountService { this.systemAdministratorRepository = systemAdministratorRepository; this.assetStorageService = assetStorageService; this.jdbcTemplate = jdbcTemplate; + this.auditLogService = auditLogService; } @Transactional @@ -61,7 +64,7 @@ public class AccountService { .stream() .map(Asset::getId) .toList(); - assetIds.forEach(channelDirectoryService::deleteAsset); + assetIds.forEach((assetId) -> channelDirectoryService.deleteAsset(assetId, normalized)); List scriptFiles = scriptAssetFileRepository.findByBroadcaster(normalized); scriptFiles.forEach(this::deleteScriptAssetFile); @@ -69,6 +72,7 @@ public class AccountService { marketplaceScriptHeartRepository.deleteByUsername(normalized); systemAdministratorRepository.deleteByTwitchUsername(normalized); channelRepository.deleteById(normalized); + auditLogService.deleteEntriesForBroadcaster(normalized); deleteSessionsForUser(normalized); LOG.info("Account data deleted for {}", normalized); diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java new file mode 100644 index 0000000..3e0c8e4 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AuditLogService.java @@ -0,0 +1,77 @@ +package dev.kruhlmann.imgfloat.service; + +import dev.kruhlmann.imgfloat.model.AuditLogEntry; +import dev.kruhlmann.imgfloat.model.AuditLogEntryView; +import dev.kruhlmann.imgfloat.repository.AuditLogRepository; +import dev.kruhlmann.imgfloat.util.LogSanitizer; +import java.util.List; +import java.util.Locale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; + +@Service +public class AuditLogService { + + private static final Logger LOG = LoggerFactory.getLogger(AuditLogService.class); + private static final String DEFAULT_ACTOR = "system"; + + private final AuditLogRepository auditLogRepository; + + public AuditLogService(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + public void recordEntry(String broadcaster, String actor, String action, String details) { + String normalizedBroadcaster = normalize(broadcaster); + if (normalizedBroadcaster == null || normalizedBroadcaster.isBlank()) { + return; + } + String normalizedActor = normalize(actor); + if (normalizedActor == null || normalizedActor.isBlank()) { + normalizedActor = DEFAULT_ACTOR; + } + try { + AuditLogEntry entry = new AuditLogEntry(normalizedBroadcaster, normalizedActor, action, details); + auditLogRepository.save(entry); + LOG.info( + "Audit log entry created for broadcaster {} by {}: {}", + LogSanitizer.sanitize(normalizedBroadcaster), + LogSanitizer.sanitize(normalizedActor), + action + ); + } catch (DataAccessException ex) { + LOG.warn( + "Unable to save audit log entry for broadcaster {} by {}", + LogSanitizer.sanitize(normalizedBroadcaster), + LogSanitizer.sanitize(normalizedActor), + ex + ); + } + } + + public List listEntries(String broadcaster) { + String normalizedBroadcaster = normalize(broadcaster); + if (normalizedBroadcaster == null || normalizedBroadcaster.isBlank()) { + return List.of(); + } + return auditLogRepository + .findTop200ByBroadcasterOrderByCreatedAtDesc(normalizedBroadcaster) + .stream() + .map(AuditLogEntryView::fromEntry) + .toList(); + } + + public void deleteEntriesForBroadcaster(String broadcaster) { + String normalizedBroadcaster = normalize(broadcaster); + if (normalizedBroadcaster == null || normalizedBroadcaster.isBlank()) { + return; + } + auditLogRepository.deleteByBroadcaster(normalizedBroadcaster); + } + + private String normalize(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index e2b750a..7926622 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -77,6 +77,7 @@ public class ChannelDirectoryService { private final SettingsService settingsService; private final long uploadLimitBytes; private final MarketplaceScriptSeedLoader marketplaceScriptSeedLoader; + private final AuditLogService auditLogService; @Autowired public ChannelDirectoryService( @@ -94,7 +95,8 @@ public class ChannelDirectoryService { MediaOptimizationService mediaOptimizationService, SettingsService settingsService, long uploadLimitBytes, - MarketplaceScriptSeedLoader marketplaceScriptSeedLoader + MarketplaceScriptSeedLoader marketplaceScriptSeedLoader, + AuditLogService auditLogService ) { this.channelRepository = channelRepository; this.assetRepository = assetRepository; @@ -111,6 +113,7 @@ public class ChannelDirectoryService { this.settingsService = settingsService; this.uploadLimitBytes = uploadLimitBytes; this.marketplaceScriptSeedLoader = marketplaceScriptSeedLoader; + this.auditLogService = auditLogService; } public Channel getOrCreateChannel(String broadcaster) { @@ -127,22 +130,36 @@ public class ChannelDirectoryService { .toList(); } - public boolean addAdmin(String broadcaster, String username) { + public boolean addAdmin(String broadcaster, String username, String actor) { Channel channel = getOrCreateChannel(broadcaster); - boolean added = channel.addAdmin(username); + String normalizedUsername = normalize(username); + boolean added = channel.addAdmin(normalizedUsername); if (added) { channelRepository.saveAndFlush(channel); messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username); + auditLogService.recordEntry( + channel.getBroadcaster(), + actor, + "ADMIN_ADDED", + "Added admin " + normalizedUsername + ); } return added; } - public boolean removeAdmin(String broadcaster, String username) { + public boolean removeAdmin(String broadcaster, String username, String actor) { Channel channel = getOrCreateChannel(broadcaster); - boolean removed = channel.removeAdmin(username); + String normalizedUsername = normalize(username); + boolean removed = channel.removeAdmin(normalizedUsername); if (removed) { channelRepository.saveAndFlush(channel); messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username); + auditLogService.recordEntry( + channel.getBroadcaster(), + actor, + "ADMIN_REMOVED", + "Removed admin " + normalizedUsername + ); } return removed; } @@ -184,13 +201,30 @@ public class ChannelDirectoryService { return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); } - public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest req) { + public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest req, String actor) { Channel channel = getOrCreateChannel(broadcaster); + double beforeWidth = channel.getCanvasWidth(); + double beforeHeight = channel.getCanvasHeight(); channel.setCanvasWidth(req.getWidth()); channel.setCanvasHeight(req.getHeight()); channelRepository.save(channel); CanvasSettingsRequest response = new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); messagingTemplate.convertAndSend(topicFor(broadcaster), CanvasEvent.updated(broadcaster, response)); + if (beforeWidth != channel.getCanvasWidth() || beforeHeight != channel.getCanvasHeight()) { + auditLogService.recordEntry( + channel.getBroadcaster(), + actor, + "CANVAS_UPDATED", + String.format( + Locale.ROOT, + "Canvas updated to %.0fx%.0f (was %.0fx%.0f)", + channel.getCanvasWidth(), + channel.getCanvasHeight(), + beforeWidth, + beforeHeight + ) + ); + } return response; } @@ -205,13 +239,46 @@ public class ChannelDirectoryService { public ChannelScriptSettingsRequest updateChannelScriptSettings( String broadcaster, - ChannelScriptSettingsRequest request + ChannelScriptSettingsRequest request, + String actor ) { Channel channel = getOrCreateChannel(broadcaster); + boolean beforeChannelEmotes = channel.isAllowChannelEmotesForAssets(); + boolean beforeSevenTv = channel.isAllowSevenTvEmotesForAssets(); + boolean beforeChatAccess = channel.isAllowScriptChatAccess(); channel.setAllowChannelEmotesForAssets(request.isAllowChannelEmotesForAssets()); channel.setAllowSevenTvEmotesForAssets(request.isAllowSevenTvEmotesForAssets()); channel.setAllowScriptChatAccess(request.isAllowScriptChatAccess()); channelRepository.save(channel); + if ( + beforeChannelEmotes != channel.isAllowChannelEmotesForAssets() || + beforeSevenTv != channel.isAllowSevenTvEmotesForAssets() || + beforeChatAccess != channel.isAllowScriptChatAccess() + ) { + List changes = new ArrayList<>(); + if (beforeChannelEmotes != channel.isAllowChannelEmotesForAssets()) { + changes.add( + "channelEmotes: " + beforeChannelEmotes + " -> " + channel.isAllowChannelEmotesForAssets() + ); + } + if (beforeSevenTv != channel.isAllowSevenTvEmotesForAssets()) { + changes.add( + "sevenTvEmotes: " + beforeSevenTv + " -> " + channel.isAllowSevenTvEmotesForAssets() + ); + } + if (beforeChatAccess != channel.isAllowScriptChatAccess()) { + changes.add( + "scriptChatAccess: " + beforeChatAccess + " -> " + channel.isAllowScriptChatAccess() + ); + } + String detailSuffix = changes.isEmpty() ? "" : " (" + String.join(", ", changes) + ")"; + auditLogService.recordEntry( + channel.getBroadcaster(), + actor, + "SCRIPT_SETTINGS_UPDATED", + "Script settings updated" + detailSuffix + ); + } return new ChannelScriptSettingsRequest( channel.isAllowChannelEmotesForAssets(), channel.isAllowSevenTvEmotesForAssets(), @@ -219,7 +286,7 @@ public class ChannelDirectoryService { ); } - public Optional createAsset(String broadcaster, MultipartFile file) throws IOException { + public Optional createAsset(String broadcaster, MultipartFile file, String actor) throws IOException { long fileSize = file.getSize(); if (fileSize > uploadLimitBytes) { throw new ResponseStatusException( @@ -299,11 +366,17 @@ public class ChannelDirectoryService { } messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); + auditLogService.recordEntry( + channel.getBroadcaster(), + actor, + "ASSET_CREATED", + "Created asset " + view.name() + " (" + view.assetType() + ")" + ); return Optional.of(view); } - public Optional createCodeAsset(String broadcaster, CodeAssetRequest request) { + public Optional createCodeAsset(String broadcaster, CodeAssetRequest request, String actor) { validateCodeAssetSource(request.getSource()); Channel channel = getOrCreateChannel(broadcaster); byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8); @@ -339,10 +412,16 @@ public class ChannelDirectoryService { scriptAssetRepository.save(script); AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); + auditLogService.recordEntry( + channel.getBroadcaster(), + actor, + "SCRIPT_CREATED", + "Created script " + view.name() + " (" + view.id() + ")" + ); return Optional.of(view); } - public Optional updateCodeAsset(String broadcaster, String assetId, CodeAssetRequest request) { + public Optional updateCodeAsset(String broadcaster, String assetId, CodeAssetRequest request, String actor) { validateCodeAssetSource(request.getSource()); String normalized = normalize(broadcaster); byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8); @@ -397,11 +476,17 @@ public class ChannelDirectoryService { scriptAssetRepository.save(script); AssetView view = AssetView.fromScript(normalized, asset, script); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "SCRIPT_UPDATED", + "Updated script " + script.getName() + " (" + asset.getId() + ")" + ); return view; }); } - public Optional updateScriptLogo(String broadcaster, String assetId, MultipartFile file) + public Optional updateScriptLogo(String broadcaster, String assetId, MultipartFile file, String actor) throws IOException { Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId); byte[] bytes = file.getBytes(); @@ -442,10 +527,16 @@ public class ChannelDirectoryService { AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "SCRIPT_LOGO_UPDATED", + "Updated script logo for " + script.getName() + " (" + asset.getId() + ")" + ); return Optional.of(view); } - public Optional clearScriptLogo(String broadcaster, String assetId) { + public Optional clearScriptLogo(String broadcaster, String assetId, String actor) { Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId); ScriptAsset script = scriptAssetRepository .findById(asset.getId()) @@ -460,6 +551,12 @@ public class ChannelDirectoryService { removeScriptAssetFileIfOrphaned(previousLogoFileId); AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "SCRIPT_LOGO_CLEARED", + "Cleared script logo for " + script.getName() + " (" + asset.getId() + ")" + ); return Optional.of(view); } @@ -667,77 +764,88 @@ public class ChannelDirectoryService { } } - public Optional importMarketplaceScript(String targetBroadcaster, String scriptId) { + public Optional importMarketplaceScript(String targetBroadcaster, String scriptId, String actor) { Optional seedScript = marketplaceScriptSeedLoader.findById(scriptId); + Optional imported; if (seedScript.isPresent()) { - return importSeedMarketplaceScript(targetBroadcaster, seedScript.get()); - } - ScriptAsset sourceScript; - try { - sourceScript = scriptAssetRepository.findById(scriptId).filter(ScriptAsset::isPublic).orElse(null); - } catch (DataAccessException ex) { - logger.warn("Unable to import marketplace script {}", scriptId, ex); - return Optional.empty(); - } - Asset sourceAsset = sourceScript == null ? null : assetRepository.findById(scriptId).orElse(null); - if (sourceScript == null || sourceAsset == null) { - return Optional.empty(); - } - AssetContent sourceContent = loadScriptSourceContent(sourceAsset, sourceScript).orElse(null); - if (sourceContent == null) { - return Optional.empty(); - } + imported = importSeedMarketplaceScript(targetBroadcaster, seedScript.get()); + } else { + ScriptAsset sourceScript; + try { + sourceScript = scriptAssetRepository.findById(scriptId).filter(ScriptAsset::isPublic).orElse(null); + } catch (DataAccessException ex) { + logger.warn("Unable to import marketplace script {}", scriptId, ex); + return Optional.empty(); + } + Asset sourceAsset = sourceScript == null ? null : assetRepository.findById(scriptId).orElse(null); + if (sourceScript == null || sourceAsset == null) { + return Optional.empty(); + } + AssetContent sourceContent = loadScriptSourceContent(sourceAsset, sourceScript).orElse(null); + if (sourceContent == null) { + return Optional.empty(); + } - Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT); - ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); - sourceFile.setId(asset.getId()); - sourceFile.setMediaType(sourceContent.mediaType()); - sourceFile.setOriginalMediaType(sourceContent.mediaType()); - try { - assetStorageService.storeAsset( - sourceFile.getBroadcaster(), - sourceFile.getId(), - sourceContent.bytes(), - sourceContent.mediaType() - ); - } catch (IOException e) { - throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); - } - assetRepository.save(asset); - scriptAssetFileRepository.save(sourceFile); + Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT); + ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); + sourceFile.setId(asset.getId()); + sourceFile.setMediaType(sourceContent.mediaType()); + sourceFile.setOriginalMediaType(sourceContent.mediaType()); + try { + assetStorageService.storeAsset( + sourceFile.getBroadcaster(), + sourceFile.getId(), + sourceContent.bytes(), + sourceContent.mediaType() + ); + } catch (IOException e) { + throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); + } + assetRepository.save(asset); + scriptAssetFileRepository.save(sourceFile); - ScriptAsset script = new ScriptAsset(asset.getId(), sourceScript.getName()); - script.setDescription(sourceScript.getDescription()); - script.setPublic(false); - script.setMediaType(sourceContent.mediaType()); - script.setOriginalMediaType(sourceContent.mediaType()); - script.setSourceFileId(sourceFile.getId()); - script.setLogoFileId(sourceScript.getLogoFileId()); - script.setZIndex(nextScriptZIndex(targetBroadcaster)); - script.setAttachments(List.of()); - scriptAssetRepository.save(script); + ScriptAsset script = new ScriptAsset(asset.getId(), sourceScript.getName()); + script.setDescription(sourceScript.getDescription()); + script.setPublic(false); + script.setMediaType(sourceContent.mediaType()); + script.setOriginalMediaType(sourceContent.mediaType()); + script.setSourceFileId(sourceFile.getId()); + script.setLogoFileId(sourceScript.getLogoFileId()); + script.setZIndex(nextScriptZIndex(targetBroadcaster)); + script.setAttachments(List.of()); + scriptAssetRepository.save(script); - List sourceAttachments = scriptAssetAttachmentRepository - .findByScriptAssetId(sourceScript.getId()); - List newAttachments = sourceAttachments - .stream() - .map((attachment) -> { - ScriptAssetAttachment copy = new ScriptAssetAttachment(asset.getId(), attachment.getName()); - String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId(); - copy.setFileId(fileId); - copy.setMediaType(attachment.getMediaType()); - copy.setOriginalMediaType(attachment.getOriginalMediaType()); - copy.setAssetType(attachment.getAssetType()); - return copy; - }) - .toList(); - if (!newAttachments.isEmpty()) { - scriptAssetAttachmentRepository.saveAll(newAttachments); + List sourceAttachments = scriptAssetAttachmentRepository + .findByScriptAssetId(sourceScript.getId()); + List newAttachments = sourceAttachments + .stream() + .map((attachment) -> { + ScriptAssetAttachment copy = new ScriptAssetAttachment(asset.getId(), attachment.getName()); + String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId(); + copy.setFileId(fileId); + copy.setMediaType(attachment.getMediaType()); + copy.setOriginalMediaType(attachment.getOriginalMediaType()); + copy.setAssetType(attachment.getAssetType()); + return copy; + }) + .toList(); + if (!newAttachments.isEmpty()) { + scriptAssetAttachmentRepository.saveAll(newAttachments); + } + script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); + AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script); + messagingTemplate.convertAndSend(topicFor(targetBroadcaster), AssetEvent.created(targetBroadcaster, view)); + imported = Optional.of(view); } - script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); - AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script); - messagingTemplate.convertAndSend(topicFor(targetBroadcaster), AssetEvent.created(targetBroadcaster, view)); - return Optional.of(view); + imported.ifPresent((view) -> + auditLogService.recordEntry( + view.broadcaster(), + actor, + "MARKETPLACE_SCRIPT_IMPORTED", + "Imported marketplace script " + scriptId + " as " + view.name() + " (" + view.id() + ")" + ) + ); + return imported; } private Optional importSeedMarketplaceScript( @@ -858,7 +966,7 @@ public class ChannelDirectoryService { return SAFE_FILENAME.matcher(stripped).replaceAll("_"); } - public Optional updateTransform(String broadcaster, String assetId, TransformRequest req) { + public Optional updateTransform(String broadcaster, String assetId, TransformRequest req, String actor) { String normalized = normalize(broadcaster); return assetRepository @@ -887,6 +995,12 @@ public class ChannelDirectoryService { AssetPatch patch = AssetPatch.fromAudioTransform(before, audio, req); if (hasPatchChanges(patch)) { messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "AUDIO_UPDATED", + formatAudioTransformDetails(asset.getId(), req) + ); } return view; } @@ -921,6 +1035,12 @@ public class ChannelDirectoryService { null ); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "SCRIPT_LAYER_UPDATED", + formatScriptTransformDetails(asset.getId(), script.getZIndex()) + ); } } script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null)); @@ -959,6 +1079,12 @@ public class ChannelDirectoryService { AssetPatch patch = AssetPatch.fromVisualTransform(before, visual, req); if (hasPatchChanges(patch)) { messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "VISUAL_UPDATED", + formatVisualTransformDetails(asset.getId(), req) + ); } return view; }); @@ -1026,7 +1152,7 @@ public class ChannelDirectoryService { ); } - public Optional triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) { + public Optional triggerPlayback(String broadcaster, String assetId, PlaybackRequest req, String actor) { String normalized = normalize(broadcaster); return assetRepository .findById(assetId) @@ -1038,11 +1164,22 @@ public class ChannelDirectoryService { } boolean play = req == null || req.getPlay(); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, play)); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "ASSET_PLAYBACK_TRIGGERED", + "Playback " + (play ? "started" : "stopped") + " for asset " + asset.getId() + ); return view; }); } - public Optional updateVisibility(String broadcaster, String assetId, VisibilityRequest request) { + public Optional updateVisibility( + String broadcaster, + String assetId, + VisibilityRequest request, + String actor + ) { String normalized = normalize(broadcaster); return assetRepository .findById(assetId) @@ -1065,6 +1202,12 @@ public class ChannelDirectoryService { topicFor(broadcaster), AssetEvent.visibility(broadcaster, patch, payload) ); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "AUDIO_VISIBILITY_UPDATED", + "Audio asset " + asset.getId() + " hidden=" + hidden + ); return view; } @@ -1091,12 +1234,18 @@ public class ChannelDirectoryService { topicFor(broadcaster), AssetEvent.visibility(broadcaster, patch, payload) ); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "VISUAL_VISIBILITY_UPDATED", + "Visual asset " + asset.getId() + " hidden=" + hidden + ); return view; }); } @Transactional - public boolean deleteAsset(String assetId) { + public boolean deleteAsset(String assetId, String actor) { return assetRepository .findById(assetId) .map((asset) -> { @@ -1128,6 +1277,12 @@ public class ChannelDirectoryService { topicFor(asset.getBroadcaster()), AssetEvent.deleted(asset.getBroadcaster(), assetId) ); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "ASSET_DELETED", + "Deleted asset " + asset.getId() + " (" + asset.getAssetType() + ")" + ); return true; }) .orElse(false); @@ -1145,7 +1300,8 @@ public class ChannelDirectoryService { public Optional createScriptAttachment( String broadcaster, String scriptAssetId, - MultipartFile file + MultipartFile file, + String actor ) throws IOException { long fileSize = file.getSize(); if (fileSize > uploadLimitBytes) { @@ -1214,11 +1370,22 @@ public class ChannelDirectoryService { script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); AssetView scriptView = AssetView.fromScript(asset.getBroadcaster(), asset, script); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, scriptView)); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "SCRIPT_ATTACHMENT_ADDED", + "Added attachment " + attachment.getName() + " to script " + asset.getId() + ); return Optional.of(view); } - public boolean deleteScriptAttachment(String broadcaster, String scriptAssetId, String attachmentId) { + public boolean deleteScriptAttachment( + String broadcaster, + String scriptAssetId, + String attachmentId, + String actor + ) { Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId); ScriptAssetAttachment attachment = scriptAssetAttachmentRepository .findById(attachmentId) @@ -1227,6 +1394,7 @@ public class ChannelDirectoryService { if (attachment == null) { return false; } + String attachmentName = attachment.getName(); String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId(); scriptAssetAttachmentRepository.deleteById(attachment.getId()); removeScriptAssetFileIfOrphaned(fileId); @@ -1237,6 +1405,12 @@ public class ChannelDirectoryService { script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); AssetView scriptView = AssetView.fromScript(asset.getBroadcaster(), asset, script); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, scriptView)); + auditLogService.recordEntry( + asset.getBroadcaster(), + actor, + "SCRIPT_ATTACHMENT_REMOVED", + "Removed attachment " + attachmentName + " from script " + asset.getId() + ); return true; } @@ -1637,6 +1811,45 @@ public class ChannelDirectoryService { } } + private String formatVisualTransformDetails(String assetId, TransformRequest req) { + List parts = new ArrayList<>(); + if (req.getX() != null) parts.add("x=" + req.getX()); + if (req.getY() != null) parts.add("y=" + req.getY()); + if (req.getWidth() != null) parts.add("width=" + req.getWidth()); + if (req.getHeight() != null) parts.add("height=" + req.getHeight()); + if (req.getRotation() != null) parts.add("rotation=" + req.getRotation()); + if (req.getZIndex() != null) parts.add("zIndex=" + req.getZIndex()); + if (req.getSpeed() != null) parts.add("speed=" + req.getSpeed()); + if (req.getMuted() != null) parts.add("muted=" + req.getMuted()); + if (req.getAudioVolume() != null) parts.add("audioVolume=" + req.getAudioVolume()); + return formatTransformDetails("Updated visual asset " + assetId, parts); + } + + private String formatAudioTransformDetails(String assetId, TransformRequest req) { + List parts = new ArrayList<>(); + if (req.getAudioLoop() != null) parts.add("loop=" + req.getAudioLoop()); + if (req.getAudioDelayMillis() != null) parts.add("delayMs=" + req.getAudioDelayMillis()); + if (req.getAudioSpeed() != null) parts.add("speed=" + req.getAudioSpeed()); + if (req.getAudioPitch() != null) parts.add("pitch=" + req.getAudioPitch()); + if (req.getAudioVolume() != null) parts.add("volume=" + req.getAudioVolume()); + return formatTransformDetails("Updated audio asset " + assetId, parts); + } + + private String formatScriptTransformDetails(String assetId, Integer zIndex) { + String detail = "Updated script asset " + assetId; + if (zIndex != null) { + return detail + " (zIndex=" + zIndex + ")"; + } + return detail; + } + + private String formatTransformDetails(String summary, List parts) { + if (parts == null || parts.isEmpty()) { + return summary; + } + return summary + " (" + String.join(", ", parts) + ")"; + } + private boolean hasPatchChanges(AssetPatch patch) { return ( patch.x() != null || diff --git a/src/main/resources/db/migration/V7__channel_audit_log.sql b/src/main/resources/db/migration/V7__channel_audit_log.sql new file mode 100644 index 0000000..4bf7725 --- /dev/null +++ b/src/main/resources/db/migration/V7__channel_audit_log.sql @@ -0,0 +1,12 @@ +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, + FOREIGN KEY (broadcaster) REFERENCES channels(broadcaster) ON DELETE CASCADE +); + +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); diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index c893d2e..11f6c5a 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -2386,3 +2386,98 @@ button:disabled:hover { justify-content: flex-start; } } + +.audit-body { + background: + radial-gradient(circle at 0% 30%, rgba(14, 116, 144, 0.12), transparent 32%), + radial-gradient(circle at 85% 0%, rgba(59, 130, 246, 0.16), transparent 30%), #0f172a; +} + +.audit-frame { + margin: 0 auto; + min-height: 100vh; + display: flex; + flex-direction: column; + gap: 24px; + padding: 24px clamp(20px, 5vw, 48px) 48px; +} + +.audit-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px 20px; + background: rgba(15, 23, 42, 0.9); + border: 1px solid #1f2937; + border-radius: 16px; + box-shadow: 0 14px 35px rgba(0, 0, 0, 0.35); +} + +.audit-title { + display: flex; + flex-direction: column; + gap: 6px; +} + +.audit-title h1 { + margin: 0; +} + +.audit-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.audit-content { + display: flex; +} + +.audit-panel { + width: 100%; + background: rgba(11, 18, 32, 0.92); + border: 1px solid #1f2937; + border-radius: 18px; + padding: 24px; + box-shadow: 0 20px 45px rgba(0, 0, 0, 0.4); +} + +.audit-panel-header h2 { + margin: 0 0 6px; +} + +.audit-table-wrapper { + margin-top: 16px; + overflow-x: auto; +} + +.audit-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.audit-table thead { + text-align: left; + background: rgba(15, 23, 42, 0.9); +} + +.audit-table th, +.audit-table td { + padding: 12px 14px; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); + vertical-align: top; +} + +.audit-table tbody tr:hover { + background: rgba(30, 41, 59, 0.45); +} + +.audit-empty { + margin-top: 16px; + padding: 16px; + border-radius: 12px; + background: rgba(30, 41, 59, 0.4); + color: #cbd5e1; +} diff --git a/src/main/resources/static/js/audit-log.js b/src/main/resources/static/js/audit-log.js new file mode 100644 index 0000000..c7e0815 --- /dev/null +++ b/src/main/resources/static/js/audit-log.js @@ -0,0 +1,62 @@ +const auditBody = document.getElementById("audit-log-body"); +const auditEmpty = document.getElementById("audit-empty"); + +const formatTimestamp = (value) => { + if (!value) { + return ""; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); +}; + +const renderEntries = (entries) => { + auditBody.innerHTML = ""; + if (!entries || entries.length === 0) { + auditEmpty.classList.remove("hidden"); + return; + } + auditEmpty.classList.add("hidden"); + entries.forEach((entry) => { + const row = document.createElement("tr"); + + const timeCell = document.createElement("td"); + timeCell.textContent = formatTimestamp(entry.createdAt); + row.appendChild(timeCell); + + const actorCell = document.createElement("td"); + actorCell.textContent = entry.actor || "system"; + row.appendChild(actorCell); + + const actionCell = document.createElement("td"); + actionCell.textContent = entry.action; + row.appendChild(actionCell); + + const detailCell = document.createElement("td"); + detailCell.textContent = entry.details || ""; + row.appendChild(detailCell); + + auditBody.appendChild(row); + }); +}; + +const loadAuditLog = () => + fetch(`/api/channels/${encodeURIComponent(broadcaster)}/audit`) + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to load audit log (${response.status})`); + } + return response.json(); + }) + .then((entries) => { + renderEntries(entries); + }) + .catch((error) => { + console.error(error); + auditEmpty.textContent = "Unable to load audit entries."; + auditEmpty.classList.remove("hidden"); + }); + +loadAuditLog(); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 9186053..8e87152 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -44,6 +44,12 @@ Back to dashboard + Audit log + + + + Imgfloat Audit Log + + + + +
+
+
+
+

CHANNEL AUDIT LOG

+

+
+
+
+
+
+
+
+

Recent activity

+

Latest 200 entries for your channel.

+
+
+
+ + + + + + + + + + +
TimeActorActionDetails
+ +
+
+
+
+ + + + diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java index d1947bf..0a61d34 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java @@ -26,6 +26,7 @@ import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository; import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository; import dev.kruhlmann.imgfloat.repository.VisualAssetRepository; import dev.kruhlmann.imgfloat.service.AssetStorageService; +import dev.kruhlmann.imgfloat.service.AuditLogService; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; import dev.kruhlmann.imgfloat.service.MarketplaceScriptSeedLoader; import dev.kruhlmann.imgfloat.service.SettingsService; @@ -65,6 +66,7 @@ class ChannelDirectoryServiceTest { private MarketplaceScriptHeartRepository marketplaceScriptHeartRepository; private SettingsService settingsService; private MarketplaceScriptSeedLoader marketplaceScriptSeedLoader; + private AuditLogService auditLogService; @BeforeEach void setup() throws Exception { @@ -77,6 +79,7 @@ class ChannelDirectoryServiceTest { scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class); scriptAssetFileRepository = mock(ScriptAssetFileRepository.class); marketplaceScriptHeartRepository = mock(MarketplaceScriptHeartRepository.class); + auditLogService = mock(AuditLogService.class); when(marketplaceScriptHeartRepository.countByScriptIds(any())).thenReturn(List.of()); when(marketplaceScriptHeartRepository.findByUsernameAndScriptIdIn(anyString(), any())).thenReturn(List.of()); settingsService = mock(SettingsService.class); @@ -121,7 +124,8 @@ class ChannelDirectoryServiceTest { mediaOptimizationService, settingsService, uploadLimitBytes, - marketplaceScriptSeedLoader + marketplaceScriptSeedLoader, + auditLogService ); } @@ -129,7 +133,7 @@ class ChannelDirectoryServiceTest { void createsAssetsAndBroadcastsEvents() throws Exception { MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng()); - Optional created = service.createAsset("caster", file); + Optional created = service.createAsset("caster", file, "caster"); assertThat(created).isPresent(); ArgumentCaptor captor = ArgumentCaptor.forClass(Object.class); verify(messagingTemplate).convertAndSend( @@ -142,15 +146,15 @@ class ChannelDirectoryServiceTest { void updatesTransformAndVisibility() throws Exception { MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng()); String channel = "caster"; - String id = service.createAsset(channel, file).orElseThrow().id(); + String id = service.createAsset(channel, file, "caster").orElseThrow().id(); TransformRequest transform = validTransform(); - assertThat(service.updateTransform(channel, id, transform)).isPresent(); + assertThat(service.updateTransform(channel, id, transform, "caster")).isPresent(); VisibilityRequest visibilityRequest = new VisibilityRequest(); visibilityRequest.setHidden(false); - assertThat(service.updateVisibility(channel, id, visibilityRequest)).isPresent(); + assertThat(service.updateVisibility(channel, id, visibilityRequest, "caster")).isPresent(); } @Test @@ -161,7 +165,7 @@ class ChannelDirectoryServiceTest { TransformRequest transform = validTransform(); transform.setWidth(0.0); - assertThatThrownBy(() -> service.updateTransform(channel, id, transform)) + assertThatThrownBy(() -> service.updateTransform(channel, id, transform, "caster")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("Canvas width out of range"); } @@ -174,14 +178,14 @@ class ChannelDirectoryServiceTest { TransformRequest speedTransform = validTransform(); speedTransform.setSpeed(5.0); - assertThatThrownBy(() -> service.updateTransform(channel, id, speedTransform)) + assertThatThrownBy(() -> service.updateTransform(channel, id, speedTransform, "caster")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("Speed out of range"); TransformRequest volumeTransform = validTransform(); volumeTransform.setAudioVolume(6.5); - assertThatThrownBy(() -> service.updateTransform(channel, id, volumeTransform)) + assertThatThrownBy(() -> service.updateTransform(channel, id, volumeTransform, "caster")) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("Audio volume out of range"); } @@ -196,7 +200,7 @@ class ChannelDirectoryServiceTest { transform.setAudioVolume(0.01); transform.setZIndex(1); - AssetView view = service.updateTransform(channel, id, transform).orElseThrow(); + AssetView view = service.updateTransform(channel, id, transform, "caster").orElseThrow(); assertThat(view.speed()).isEqualTo(0.1); assertThat(view.audioVolume()).isEqualTo(0.01); @@ -222,7 +226,7 @@ class ChannelDirectoryServiceTest { private String createSampleAsset(String channel) throws Exception { MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng()); - return service.createAsset(channel, file).orElseThrow().id(); + return service.createAsset(channel, file, "caster").orElseThrow().id(); } private TransformRequest validTransform() {