mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add audit log
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ||
|
||||||
|
|||||||
12
src/main/resources/db/migration/V7__channel_audit_log.sql
Normal file
12
src/main/resources/db/migration/V7__channel_audit_log.sql
Normal 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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
62
src/main/resources/static/js/audit-log.js
Normal file
62
src/main/resources/static/js/audit-log.js
Normal 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();
|
||||||
@@ -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'}"
|
||||||
|
|||||||
52
src/main/resources/templates/audit-log.html
Normal file
52
src/main/resources/templates/audit-log.html
Normal 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>
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user