Add audit log

This commit is contained in:
2026-01-15 16:19:09 +01:00
parent 10507c070e
commit 18dff66373
16 changed files with 818 additions and 111 deletions

View File

@@ -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<AuditLogEntryView> 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);
}
}

View File

@@ -86,7 +86,7 @@ public class ChannelApiController {
String logRequestUsername = LogSanitizer.sanitize(request.getUsername()); String logRequestUsername = LogSanitizer.sanitize(request.getUsername());
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.info("User {} adding admin {} to {}", logSessionUsername, logRequestUsername, logBroadcaster); 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) { if (!added) {
LOG.info("User {} already admin for {} or could not be added", logRequestUsername, logBroadcaster); 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); String logUsername = LogSanitizer.sanitize(username);
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.info("User {} removing admin {} from {}", logSessionUsername, logUsername, logBroadcaster); 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); return ResponseEntity.ok(removed);
} }
@@ -207,7 +207,7 @@ public class ChannelApiController {
request.getWidth(), request.getWidth(),
request.getHeight() request.getHeight()
); );
return channelDirectoryService.updateCanvasSettings(broadcaster, request); return channelDirectoryService.updateCanvasSettings(broadcaster, request, sessionUsername);
} }
@GetMapping("/settings") @GetMapping("/settings")
@@ -226,7 +226,7 @@ public class ChannelApiController {
String logSessionUsername = LogSanitizer.sanitize(sessionUsername); String logSessionUsername = LogSanitizer.sanitize(sessionUsername);
authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername);
LOG.info("Updating script settings for {} by {}", logBroadcaster, logSessionUsername); 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) @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -250,7 +250,7 @@ public class ChannelApiController {
String logOriginalFilename = LogSanitizer.sanitize(file.getOriginalFilename()); String logOriginalFilename = LogSanitizer.sanitize(file.getOriginalFilename());
LOG.info("User {} uploading asset {} to {}", logSessionUsername, logOriginalFilename, logBroadcaster); LOG.info("User {} uploading asset {} to {}", logSessionUsername, logOriginalFilename, logBroadcaster);
return channelDirectoryService return channelDirectoryService
.createAsset(broadcaster, file) .createAsset(broadcaster, file, sessionUsername)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image")); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
} catch (IOException e) { } catch (IOException e) {
@@ -274,7 +274,7 @@ public class ChannelApiController {
); );
LOG.info("Creating custom script for {} by {}", logBroadcaster, logSessionUsername); LOG.info("Creating custom script for {} by {}", logBroadcaster, logSessionUsername);
return channelDirectoryService return channelDirectoryService
.createCodeAsset(broadcaster, request) .createCodeAsset(broadcaster, request, sessionUsername)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save custom script")); .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); LOG.info("Updating custom script {} for {} by {}", logAssetId, logBroadcaster, logSessionUsername);
return channelDirectoryService return channelDirectoryService
.updateCodeAsset(broadcaster, assetId, request) .updateCodeAsset(broadcaster, assetId, request, sessionUsername)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); .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); LOG.debug("Applying transform to asset {} on {} by {}", logAssetId, logBroadcaster, logSessionUsername);
return channelDirectoryService return channelDirectoryService
.updateTransform(broadcaster, assetId, request) .updateTransform(broadcaster, assetId, request, sessionUsername)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> { .orElseThrow(() -> {
LOG.warn( LOG.warn(
@@ -348,7 +348,7 @@ public class ChannelApiController {
); );
LOG.info("Triggering playback for asset {} on {} by {}", logAssetId, logBroadcaster, logSessionUsername); LOG.info("Triggering playback for asset {} on {} by {}", logAssetId, logBroadcaster, logSessionUsername);
return channelDirectoryService return channelDirectoryService
.triggerPlayback(broadcaster, assetId, request) .triggerPlayback(broadcaster, assetId, request, sessionUsername)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
} }
@@ -376,7 +376,7 @@ public class ChannelApiController {
request.isHidden() request.isHidden()
); );
return channelDirectoryService return channelDirectoryService
.updateVisibility(broadcaster, assetId, request) .updateVisibility(broadcaster, assetId, request, sessionUsername)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> { .orElseThrow(() -> {
LOG.warn( LOG.warn(
@@ -502,7 +502,7 @@ public class ChannelApiController {
broadcaster, broadcaster,
sessionUsername sessionUsername
); );
boolean removed = channelDirectoryService.deleteAsset(assetId); boolean removed = channelDirectoryService.deleteAsset(assetId, sessionUsername);
if (!removed) { if (!removed) {
LOG.warn("Attempt to delete missing asset {} on {} by {}", logAssetId, logBroadcaster, logSessionUsername); LOG.warn("Attempt to delete missing asset {} on {} by {}", logAssetId, logBroadcaster, logSessionUsername);
throw createAsset404(); throw createAsset404();
@@ -542,7 +542,7 @@ public class ChannelApiController {
} }
try { try {
return channelDirectoryService return channelDirectoryService
.createScriptAttachment(broadcaster, assetId, file) .createScriptAttachment(broadcaster, assetId, file, sessionUsername)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save attachment")); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save attachment"));
} catch (IOException e) { } catch (IOException e) {
@@ -568,7 +568,7 @@ public class ChannelApiController {
} }
try { try {
return channelDirectoryService return channelDirectoryService
.updateScriptLogo(broadcaster, assetId, file) .updateScriptLogo(broadcaster, assetId, file, sessionUsername)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save logo")); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save logo"));
} catch (IOException e) { } catch (IOException e) {
@@ -588,7 +588,7 @@ public class ChannelApiController {
broadcaster, broadcaster,
sessionUsername sessionUsername
); );
channelDirectoryService.clearScriptLogo(broadcaster, assetId); channelDirectoryService.clearScriptLogo(broadcaster, assetId, sessionUsername);
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@@ -604,7 +604,7 @@ public class ChannelApiController {
broadcaster, broadcaster,
sessionUsername sessionUsername
); );
boolean removed = channelDirectoryService.deleteScriptAttachment(broadcaster, assetId, attachmentId); boolean removed = channelDirectoryService.deleteScriptAttachment(broadcaster, assetId, attachmentId, sessionUsername);
if (!removed) { if (!removed) {
throw createAsset404(); throw createAsset404();
} }

View File

@@ -85,7 +85,7 @@ public class ScriptMarketplaceController {
String logTarget = LogSanitizer.sanitize(request.getTargetBroadcaster()); String logTarget = LogSanitizer.sanitize(request.getTargetBroadcaster());
LOG.info("Importing marketplace script {} into {}", logScriptId, logTarget); LOG.info("Importing marketplace script {} into {}", logScriptId, logTarget);
return channelDirectoryService return channelDirectoryService
.importMarketplaceScript(request.getTargetBroadcaster(), scriptId) .importMarketplaceScript(request.getTargetBroadcaster(), scriptId, sessionUsername)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script")); .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script"));
} }

View File

@@ -160,6 +160,24 @@ public class ViewController {
return "admin"; 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") @org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast")
public String broadcastView( public String broadcastView(
@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, @org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,

View File

@@ -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);
}
}

View File

@@ -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()
);
}
}

View File

@@ -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<AuditLogEntry, String> {
List<AuditLogEntry> findTop200ByBroadcasterOrderByCreatedAtDesc(String broadcaster);
void deleteByBroadcaster(String broadcaster);
}

View File

@@ -28,6 +28,7 @@ public class AccountService {
private final SystemAdministratorRepository systemAdministratorRepository; private final SystemAdministratorRepository systemAdministratorRepository;
private final AssetStorageService assetStorageService; private final AssetStorageService assetStorageService;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final AuditLogService auditLogService;
public AccountService( public AccountService(
ChannelDirectoryService channelDirectoryService, ChannelDirectoryService channelDirectoryService,
@@ -37,7 +38,8 @@ public class AccountService {
MarketplaceScriptHeartRepository marketplaceScriptHeartRepository, MarketplaceScriptHeartRepository marketplaceScriptHeartRepository,
SystemAdministratorRepository systemAdministratorRepository, SystemAdministratorRepository systemAdministratorRepository,
AssetStorageService assetStorageService, AssetStorageService assetStorageService,
JdbcTemplate jdbcTemplate JdbcTemplate jdbcTemplate,
AuditLogService auditLogService
) { ) {
this.channelDirectoryService = channelDirectoryService; this.channelDirectoryService = channelDirectoryService;
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
@@ -47,6 +49,7 @@ public class AccountService {
this.systemAdministratorRepository = systemAdministratorRepository; this.systemAdministratorRepository = systemAdministratorRepository;
this.assetStorageService = assetStorageService; this.assetStorageService = assetStorageService;
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.auditLogService = auditLogService;
} }
@Transactional @Transactional
@@ -61,7 +64,7 @@ public class AccountService {
.stream() .stream()
.map(Asset::getId) .map(Asset::getId)
.toList(); .toList();
assetIds.forEach(channelDirectoryService::deleteAsset); assetIds.forEach((assetId) -> channelDirectoryService.deleteAsset(assetId, normalized));
List<ScriptAssetFile> scriptFiles = scriptAssetFileRepository.findByBroadcaster(normalized); List<ScriptAssetFile> scriptFiles = scriptAssetFileRepository.findByBroadcaster(normalized);
scriptFiles.forEach(this::deleteScriptAssetFile); scriptFiles.forEach(this::deleteScriptAssetFile);
@@ -69,6 +72,7 @@ public class AccountService {
marketplaceScriptHeartRepository.deleteByUsername(normalized); marketplaceScriptHeartRepository.deleteByUsername(normalized);
systemAdministratorRepository.deleteByTwitchUsername(normalized); systemAdministratorRepository.deleteByTwitchUsername(normalized);
channelRepository.deleteById(normalized); channelRepository.deleteById(normalized);
auditLogService.deleteEntriesForBroadcaster(normalized);
deleteSessionsForUser(normalized); deleteSessionsForUser(normalized);
LOG.info("Account data deleted for {}", normalized); LOG.info("Account data deleted for {}", normalized);

View File

@@ -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<AuditLogEntryView> 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);
}
}

View File

@@ -77,6 +77,7 @@ public class ChannelDirectoryService {
private final SettingsService settingsService; private final SettingsService settingsService;
private final long uploadLimitBytes; private final long uploadLimitBytes;
private final MarketplaceScriptSeedLoader marketplaceScriptSeedLoader; private final MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
private final AuditLogService auditLogService;
@Autowired @Autowired
public ChannelDirectoryService( public ChannelDirectoryService(
@@ -94,7 +95,8 @@ public class ChannelDirectoryService {
MediaOptimizationService mediaOptimizationService, MediaOptimizationService mediaOptimizationService,
SettingsService settingsService, SettingsService settingsService,
long uploadLimitBytes, long uploadLimitBytes,
MarketplaceScriptSeedLoader marketplaceScriptSeedLoader MarketplaceScriptSeedLoader marketplaceScriptSeedLoader,
AuditLogService auditLogService
) { ) {
this.channelRepository = channelRepository; this.channelRepository = channelRepository;
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
@@ -111,6 +113,7 @@ public class ChannelDirectoryService {
this.settingsService = settingsService; this.settingsService = settingsService;
this.uploadLimitBytes = uploadLimitBytes; this.uploadLimitBytes = uploadLimitBytes;
this.marketplaceScriptSeedLoader = marketplaceScriptSeedLoader; this.marketplaceScriptSeedLoader = marketplaceScriptSeedLoader;
this.auditLogService = auditLogService;
} }
public Channel getOrCreateChannel(String broadcaster) { public Channel getOrCreateChannel(String broadcaster) {
@@ -127,22 +130,36 @@ public class ChannelDirectoryService {
.toList(); .toList();
} }
public boolean addAdmin(String broadcaster, String username) { public boolean addAdmin(String broadcaster, String username, String actor) {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
boolean added = channel.addAdmin(username); String normalizedUsername = normalize(username);
boolean added = channel.addAdmin(normalizedUsername);
if (added) { if (added) {
channelRepository.saveAndFlush(channel); channelRepository.saveAndFlush(channel);
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username); messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin added: " + username);
auditLogService.recordEntry(
channel.getBroadcaster(),
actor,
"ADMIN_ADDED",
"Added admin " + normalizedUsername
);
} }
return added; return added;
} }
public boolean removeAdmin(String broadcaster, String username) { public boolean removeAdmin(String broadcaster, String username, String actor) {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
boolean removed = channel.removeAdmin(username); String normalizedUsername = normalize(username);
boolean removed = channel.removeAdmin(normalizedUsername);
if (removed) { if (removed) {
channelRepository.saveAndFlush(channel); channelRepository.saveAndFlush(channel);
messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username); messagingTemplate.convertAndSend(topicFor(broadcaster), "Admin removed: " + username);
auditLogService.recordEntry(
channel.getBroadcaster(),
actor,
"ADMIN_REMOVED",
"Removed admin " + normalizedUsername
);
} }
return removed; return removed;
} }
@@ -184,13 +201,30 @@ public class ChannelDirectoryService {
return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); 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); Channel channel = getOrCreateChannel(broadcaster);
double beforeWidth = channel.getCanvasWidth();
double beforeHeight = channel.getCanvasHeight();
channel.setCanvasWidth(req.getWidth()); channel.setCanvasWidth(req.getWidth());
channel.setCanvasHeight(req.getHeight()); channel.setCanvasHeight(req.getHeight());
channelRepository.save(channel); channelRepository.save(channel);
CanvasSettingsRequest response = new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight()); CanvasSettingsRequest response = new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight());
messagingTemplate.convertAndSend(topicFor(broadcaster), CanvasEvent.updated(broadcaster, response)); 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; return response;
} }
@@ -205,13 +239,46 @@ public class ChannelDirectoryService {
public ChannelScriptSettingsRequest updateChannelScriptSettings( public ChannelScriptSettingsRequest updateChannelScriptSettings(
String broadcaster, String broadcaster,
ChannelScriptSettingsRequest request ChannelScriptSettingsRequest request,
String actor
) { ) {
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
boolean beforeChannelEmotes = channel.isAllowChannelEmotesForAssets();
boolean beforeSevenTv = channel.isAllowSevenTvEmotesForAssets();
boolean beforeChatAccess = channel.isAllowScriptChatAccess();
channel.setAllowChannelEmotesForAssets(request.isAllowChannelEmotesForAssets()); channel.setAllowChannelEmotesForAssets(request.isAllowChannelEmotesForAssets());
channel.setAllowSevenTvEmotesForAssets(request.isAllowSevenTvEmotesForAssets()); channel.setAllowSevenTvEmotesForAssets(request.isAllowSevenTvEmotesForAssets());
channel.setAllowScriptChatAccess(request.isAllowScriptChatAccess()); channel.setAllowScriptChatAccess(request.isAllowScriptChatAccess());
channelRepository.save(channel); channelRepository.save(channel);
if (
beforeChannelEmotes != channel.isAllowChannelEmotesForAssets() ||
beforeSevenTv != channel.isAllowSevenTvEmotesForAssets() ||
beforeChatAccess != channel.isAllowScriptChatAccess()
) {
List<String> 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( return new ChannelScriptSettingsRequest(
channel.isAllowChannelEmotesForAssets(), channel.isAllowChannelEmotesForAssets(),
channel.isAllowSevenTvEmotesForAssets(), channel.isAllowSevenTvEmotesForAssets(),
@@ -219,7 +286,7 @@ public class ChannelDirectoryService {
); );
} }
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException { public Optional<AssetView> createAsset(String broadcaster, MultipartFile file, String actor) throws IOException {
long fileSize = file.getSize(); long fileSize = file.getSize();
if (fileSize > uploadLimitBytes) { if (fileSize > uploadLimitBytes) {
throw new ResponseStatusException( throw new ResponseStatusException(
@@ -299,11 +366,17 @@ public class ChannelDirectoryService {
} }
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); 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); return Optional.of(view);
} }
public Optional<AssetView> createCodeAsset(String broadcaster, CodeAssetRequest request) { public Optional<AssetView> createCodeAsset(String broadcaster, CodeAssetRequest request, String actor) {
validateCodeAssetSource(request.getSource()); validateCodeAssetSource(request.getSource());
Channel channel = getOrCreateChannel(broadcaster); Channel channel = getOrCreateChannel(broadcaster);
byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8); byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8);
@@ -339,10 +412,16 @@ public class ChannelDirectoryService {
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script); AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); 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); return Optional.of(view);
} }
public Optional<AssetView> updateCodeAsset(String broadcaster, String assetId, CodeAssetRequest request) { public Optional<AssetView> updateCodeAsset(String broadcaster, String assetId, CodeAssetRequest request, String actor) {
validateCodeAssetSource(request.getSource()); validateCodeAssetSource(request.getSource());
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8); byte[] bytes = request.getSource().getBytes(StandardCharsets.UTF_8);
@@ -397,11 +476,17 @@ public class ChannelDirectoryService {
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
AssetView view = AssetView.fromScript(normalized, asset, script); AssetView view = AssetView.fromScript(normalized, asset, script);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view));
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"SCRIPT_UPDATED",
"Updated script " + script.getName() + " (" + asset.getId() + ")"
);
return view; return view;
}); });
} }
public Optional<AssetView> updateScriptLogo(String broadcaster, String assetId, MultipartFile file) public Optional<AssetView> updateScriptLogo(String broadcaster, String assetId, MultipartFile file, String actor)
throws IOException { throws IOException {
Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId); Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId);
byte[] bytes = file.getBytes(); byte[] bytes = file.getBytes();
@@ -442,10 +527,16 @@ public class ChannelDirectoryService {
AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script); AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); 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); return Optional.of(view);
} }
public Optional<AssetView> clearScriptLogo(String broadcaster, String assetId) { public Optional<AssetView> clearScriptLogo(String broadcaster, String assetId, String actor) {
Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId); Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId);
ScriptAsset script = scriptAssetRepository ScriptAsset script = scriptAssetRepository
.findById(asset.getId()) .findById(asset.getId())
@@ -460,6 +551,12 @@ public class ChannelDirectoryService {
removeScriptAssetFileIfOrphaned(previousLogoFileId); removeScriptAssetFileIfOrphaned(previousLogoFileId);
AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script); AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); 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); return Optional.of(view);
} }
@@ -667,77 +764,88 @@ public class ChannelDirectoryService {
} }
} }
public Optional<AssetView> importMarketplaceScript(String targetBroadcaster, String scriptId) { public Optional<AssetView> importMarketplaceScript(String targetBroadcaster, String scriptId, String actor) {
Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId); Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId);
Optional<AssetView> imported;
if (seedScript.isPresent()) { if (seedScript.isPresent()) {
return importSeedMarketplaceScript(targetBroadcaster, seedScript.get()); imported = importSeedMarketplaceScript(targetBroadcaster, seedScript.get());
} } else {
ScriptAsset sourceScript; ScriptAsset sourceScript;
try { try {
sourceScript = scriptAssetRepository.findById(scriptId).filter(ScriptAsset::isPublic).orElse(null); sourceScript = scriptAssetRepository.findById(scriptId).filter(ScriptAsset::isPublic).orElse(null);
} catch (DataAccessException ex) { } catch (DataAccessException ex) {
logger.warn("Unable to import marketplace script {}", scriptId, ex); logger.warn("Unable to import marketplace script {}", scriptId, ex);
return Optional.empty(); return Optional.empty();
} }
Asset sourceAsset = sourceScript == null ? null : assetRepository.findById(scriptId).orElse(null); Asset sourceAsset = sourceScript == null ? null : assetRepository.findById(scriptId).orElse(null);
if (sourceScript == null || sourceAsset == null) { if (sourceScript == null || sourceAsset == null) {
return Optional.empty(); return Optional.empty();
} }
AssetContent sourceContent = loadScriptSourceContent(sourceAsset, sourceScript).orElse(null); AssetContent sourceContent = loadScriptSourceContent(sourceAsset, sourceScript).orElse(null);
if (sourceContent == null) { if (sourceContent == null) {
return Optional.empty(); return Optional.empty();
} }
Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT); Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT);
ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT);
sourceFile.setId(asset.getId()); sourceFile.setId(asset.getId());
sourceFile.setMediaType(sourceContent.mediaType()); sourceFile.setMediaType(sourceContent.mediaType());
sourceFile.setOriginalMediaType(sourceContent.mediaType()); sourceFile.setOriginalMediaType(sourceContent.mediaType());
try { try {
assetStorageService.storeAsset( assetStorageService.storeAsset(
sourceFile.getBroadcaster(), sourceFile.getBroadcaster(),
sourceFile.getId(), sourceFile.getId(),
sourceContent.bytes(), sourceContent.bytes(),
sourceContent.mediaType() sourceContent.mediaType()
); );
} catch (IOException e) { } catch (IOException e) {
throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e);
} }
assetRepository.save(asset); assetRepository.save(asset);
scriptAssetFileRepository.save(sourceFile); scriptAssetFileRepository.save(sourceFile);
ScriptAsset script = new ScriptAsset(asset.getId(), sourceScript.getName()); ScriptAsset script = new ScriptAsset(asset.getId(), sourceScript.getName());
script.setDescription(sourceScript.getDescription()); script.setDescription(sourceScript.getDescription());
script.setPublic(false); script.setPublic(false);
script.setMediaType(sourceContent.mediaType()); script.setMediaType(sourceContent.mediaType());
script.setOriginalMediaType(sourceContent.mediaType()); script.setOriginalMediaType(sourceContent.mediaType());
script.setSourceFileId(sourceFile.getId()); script.setSourceFileId(sourceFile.getId());
script.setLogoFileId(sourceScript.getLogoFileId()); script.setLogoFileId(sourceScript.getLogoFileId());
script.setZIndex(nextScriptZIndex(targetBroadcaster)); script.setZIndex(nextScriptZIndex(targetBroadcaster));
script.setAttachments(List.of()); script.setAttachments(List.of());
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
List<ScriptAssetAttachment> sourceAttachments = scriptAssetAttachmentRepository List<ScriptAssetAttachment> sourceAttachments = scriptAssetAttachmentRepository
.findByScriptAssetId(sourceScript.getId()); .findByScriptAssetId(sourceScript.getId());
List<ScriptAssetAttachment> newAttachments = sourceAttachments List<ScriptAssetAttachment> newAttachments = sourceAttachments
.stream() .stream()
.map((attachment) -> { .map((attachment) -> {
ScriptAssetAttachment copy = new ScriptAssetAttachment(asset.getId(), attachment.getName()); ScriptAssetAttachment copy = new ScriptAssetAttachment(asset.getId(), attachment.getName());
String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId(); String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId();
copy.setFileId(fileId); copy.setFileId(fileId);
copy.setMediaType(attachment.getMediaType()); copy.setMediaType(attachment.getMediaType());
copy.setOriginalMediaType(attachment.getOriginalMediaType()); copy.setOriginalMediaType(attachment.getOriginalMediaType());
copy.setAssetType(attachment.getAssetType()); copy.setAssetType(attachment.getAssetType());
return copy; return copy;
}) })
.toList(); .toList();
if (!newAttachments.isEmpty()) { if (!newAttachments.isEmpty()) {
scriptAssetAttachmentRepository.saveAll(newAttachments); 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)); imported.ifPresent((view) ->
AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script); auditLogService.recordEntry(
messagingTemplate.convertAndSend(topicFor(targetBroadcaster), AssetEvent.created(targetBroadcaster, view)); view.broadcaster(),
return Optional.of(view); actor,
"MARKETPLACE_SCRIPT_IMPORTED",
"Imported marketplace script " + scriptId + " as " + view.name() + " (" + view.id() + ")"
)
);
return imported;
} }
private Optional<AssetView> importSeedMarketplaceScript( private Optional<AssetView> importSeedMarketplaceScript(
@@ -858,7 +966,7 @@ public class ChannelDirectoryService {
return SAFE_FILENAME.matcher(stripped).replaceAll("_"); return SAFE_FILENAME.matcher(stripped).replaceAll("_");
} }
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest req) { public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest req, String actor) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository return assetRepository
@@ -887,6 +995,12 @@ public class ChannelDirectoryService {
AssetPatch patch = AssetPatch.fromAudioTransform(before, audio, req); AssetPatch patch = AssetPatch.fromAudioTransform(before, audio, req);
if (hasPatchChanges(patch)) { if (hasPatchChanges(patch)) {
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"AUDIO_UPDATED",
formatAudioTransformDetails(asset.getId(), req)
);
} }
return view; return view;
} }
@@ -921,6 +1035,12 @@ public class ChannelDirectoryService {
null null
); );
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); 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)); script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
@@ -959,6 +1079,12 @@ public class ChannelDirectoryService {
AssetPatch patch = AssetPatch.fromVisualTransform(before, visual, req); AssetPatch patch = AssetPatch.fromVisualTransform(before, visual, req);
if (hasPatchChanges(patch)) { if (hasPatchChanges(patch)) {
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch)); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, patch));
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"VISUAL_UPDATED",
formatVisualTransformDetails(asset.getId(), req)
);
} }
return view; return view;
}); });
@@ -1026,7 +1152,7 @@ public class ChannelDirectoryService {
); );
} }
public Optional<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) { public Optional<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest req, String actor) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository return assetRepository
.findById(assetId) .findById(assetId)
@@ -1038,11 +1164,22 @@ public class ChannelDirectoryService {
} }
boolean play = req == null || req.getPlay(); boolean play = req == null || req.getPlay();
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.play(broadcaster, view, play)); 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; return view;
}); });
} }
public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) { public Optional<AssetView> updateVisibility(
String broadcaster,
String assetId,
VisibilityRequest request,
String actor
) {
String normalized = normalize(broadcaster); String normalized = normalize(broadcaster);
return assetRepository return assetRepository
.findById(assetId) .findById(assetId)
@@ -1065,6 +1202,12 @@ public class ChannelDirectoryService {
topicFor(broadcaster), topicFor(broadcaster),
AssetEvent.visibility(broadcaster, patch, payload) AssetEvent.visibility(broadcaster, patch, payload)
); );
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"AUDIO_VISIBILITY_UPDATED",
"Audio asset " + asset.getId() + " hidden=" + hidden
);
return view; return view;
} }
@@ -1091,12 +1234,18 @@ public class ChannelDirectoryService {
topicFor(broadcaster), topicFor(broadcaster),
AssetEvent.visibility(broadcaster, patch, payload) AssetEvent.visibility(broadcaster, patch, payload)
); );
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"VISUAL_VISIBILITY_UPDATED",
"Visual asset " + asset.getId() + " hidden=" + hidden
);
return view; return view;
}); });
} }
@Transactional @Transactional
public boolean deleteAsset(String assetId) { public boolean deleteAsset(String assetId, String actor) {
return assetRepository return assetRepository
.findById(assetId) .findById(assetId)
.map((asset) -> { .map((asset) -> {
@@ -1128,6 +1277,12 @@ public class ChannelDirectoryService {
topicFor(asset.getBroadcaster()), topicFor(asset.getBroadcaster()),
AssetEvent.deleted(asset.getBroadcaster(), assetId) AssetEvent.deleted(asset.getBroadcaster(), assetId)
); );
auditLogService.recordEntry(
asset.getBroadcaster(),
actor,
"ASSET_DELETED",
"Deleted asset " + asset.getId() + " (" + asset.getAssetType() + ")"
);
return true; return true;
}) })
.orElse(false); .orElse(false);
@@ -1145,7 +1300,8 @@ public class ChannelDirectoryService {
public Optional<ScriptAssetAttachmentView> createScriptAttachment( public Optional<ScriptAssetAttachmentView> createScriptAttachment(
String broadcaster, String broadcaster,
String scriptAssetId, String scriptAssetId,
MultipartFile file MultipartFile file,
String actor
) throws IOException { ) throws IOException {
long fileSize = file.getSize(); long fileSize = file.getSize();
if (fileSize > uploadLimitBytes) { if (fileSize > uploadLimitBytes) {
@@ -1214,11 +1370,22 @@ public class ChannelDirectoryService {
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
AssetView scriptView = AssetView.fromScript(asset.getBroadcaster(), asset, script); AssetView scriptView = AssetView.fromScript(asset.getBroadcaster(), asset, script);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, scriptView)); 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); 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); Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId);
ScriptAssetAttachment attachment = scriptAssetAttachmentRepository ScriptAssetAttachment attachment = scriptAssetAttachmentRepository
.findById(attachmentId) .findById(attachmentId)
@@ -1227,6 +1394,7 @@ public class ChannelDirectoryService {
if (attachment == null) { if (attachment == null) {
return false; return false;
} }
String attachmentName = attachment.getName();
String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId(); String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId();
scriptAssetAttachmentRepository.deleteById(attachment.getId()); scriptAssetAttachmentRepository.deleteById(attachment.getId());
removeScriptAssetFileIfOrphaned(fileId); removeScriptAssetFileIfOrphaned(fileId);
@@ -1237,6 +1405,12 @@ public class ChannelDirectoryService {
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
AssetView scriptView = AssetView.fromScript(asset.getBroadcaster(), asset, script); AssetView scriptView = AssetView.fromScript(asset.getBroadcaster(), asset, script);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, scriptView)); 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; return true;
} }
@@ -1637,6 +1811,45 @@ public class ChannelDirectoryService {
} }
} }
private String formatVisualTransformDetails(String assetId, TransformRequest req) {
List<String> 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<String> 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<String> parts) {
if (parts == null || parts.isEmpty()) {
return summary;
}
return summary + " (" + String.join(", ", parts) + ")";
}
private boolean hasPatchChanges(AssetPatch patch) { private boolean hasPatchChanges(AssetPatch patch) {
return ( return (
patch.x() != null || patch.x() != null ||

View File

@@ -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);

View File

@@ -2386,3 +2386,98 @@ button:disabled:hover {
justify-content: flex-start; 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;
}

View File

@@ -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();

View File

@@ -44,6 +44,12 @@
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i> <i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span class="sr-only">Back to dashboard</span> <span class="sr-only">Back to dashboard</span>
</a> </a>
<a
class="button ghost"
th:if="${#strings.equalsIgnoreCase(username, broadcaster)}"
th:href="${'/view/' + broadcaster + '/audit'}"
>Audit log</a
>
<a <a
class="button ghost" class="button ghost"
th:href="${'/view/' + broadcaster + '/broadcast'}" th:href="${'/view/' + broadcaster + '/broadcast'}"

View File

@@ -0,0 +1,52 @@
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title>Imgfloat Audit Log</title>
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body class="audit-body" th:classappend="${isStaging} ? ' has-staging-banner' : ''">
<div th:insert="~{fragments/staging :: banner}"></div>
<div class="audit-frame">
<header class="audit-topbar">
<div class="audit-title">
<p class="eyebrow subtle">CHANNEL AUDIT LOG</p>
<h1 th:text="${broadcaster}"></h1>
</div>
<div class="audit-actions">
<a class="button ghost" th:href="${'/view/' + broadcaster + '/admin'}">Back to admin</a>
<a class="button" th:href="@{/}">Dashboard</a>
</div>
</header>
<main class="audit-content">
<section class="audit-panel">
<div class="audit-panel-header">
<div>
<h2>Recent activity</h2>
<p class="subtle">Latest 200 entries for your channel.</p>
</div>
</div>
<div class="audit-table-wrapper">
<table class="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Details</th>
</tr>
</thead>
<tbody id="audit-log-body"></tbody>
</table>
<div id="audit-empty" class="audit-empty hidden">No audit entries yet.</div>
</div>
</section>
</main>
</div>
<script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ "";
</script>
<script type="module" src="/js/audit-log.js"></script>
</body>
</html>

View File

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