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

View File

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

View File

@@ -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,

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

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 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<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(
channel.isAllowChannelEmotesForAssets(),
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();
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<AssetView> createCodeAsset(String broadcaster, CodeAssetRequest request) {
public Optional<AssetView> 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<AssetView> updateCodeAsset(String broadcaster, String assetId, CodeAssetRequest request) {
public Optional<AssetView> 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<AssetView> updateScriptLogo(String broadcaster, String assetId, MultipartFile file)
public Optional<AssetView> 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<AssetView> clearScriptLogo(String broadcaster, String assetId) {
public Optional<AssetView> 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<AssetView> importMarketplaceScript(String targetBroadcaster, String scriptId) {
public Optional<AssetView> importMarketplaceScript(String targetBroadcaster, String scriptId, String actor) {
Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId);
Optional<AssetView> 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<ScriptAssetAttachment> sourceAttachments = scriptAssetAttachmentRepository
.findByScriptAssetId(sourceScript.getId());
List<ScriptAssetAttachment> 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<ScriptAssetAttachment> sourceAttachments = scriptAssetAttachmentRepository
.findByScriptAssetId(sourceScript.getId());
List<ScriptAssetAttachment> 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<AssetView> importSeedMarketplaceScript(
@@ -858,7 +966,7 @@ public class ChannelDirectoryService {
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);
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<AssetView> triggerPlayback(String broadcaster, String assetId, PlaybackRequest req) {
public Optional<AssetView> 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<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
public Optional<AssetView> 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<ScriptAssetAttachmentView> 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<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) {
return (
patch.x() != null ||