From 92c731a30f911439f808e01e543cd2d8126f1b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 15 Jan 2026 15:35:19 +0100 Subject: [PATCH] Add account deletion --- .../controller/AccountApiController.java | 46 +++++++++ .../MarketplaceScriptHeartRepository.java | 2 + .../repository/ScriptAssetFileRepository.java | 5 +- .../imgfloat/service/AccountService.java | 97 +++++++++++++++++++ src/main/resources/static/js/dashboard.js | 30 ++++++ src/main/resources/templates/dashboard.html | 17 ++++ 6 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dev/kruhlmann/imgfloat/controller/AccountApiController.java create mode 100644 src/main/java/dev/kruhlmann/imgfloat/service/AccountService.java diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/AccountApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/AccountApiController.java new file mode 100644 index 0000000..ff8bb94 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/AccountApiController.java @@ -0,0 +1,46 @@ +package dev.kruhlmann.imgfloat.controller; + +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.service.AccountService; +import dev.kruhlmann.imgfloat.util.LogSanitizer; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/account") +public class AccountApiController { + + private static final Logger LOG = LoggerFactory.getLogger(AccountApiController.class); + + private final AccountService accountService; + + public AccountApiController(AccountService accountService) { + this.accountService = accountService; + } + + @DeleteMapping + public void deleteAccount( + OAuth2AuthenticationToken oauthToken, + HttpServletRequest request, + HttpServletResponse response + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + String logSessionUsername = LogSanitizer.sanitize(sessionUsername); + LOG.info("Deleting account for {}", logSessionUsername); + accountService.deleteAccount(sessionUsername); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null) { + new SecurityContextLogoutHandler().logout(request, response, auth); + } + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/MarketplaceScriptHeartRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/MarketplaceScriptHeartRepository.java index 1c26d34..ad67ac7 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/MarketplaceScriptHeartRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/MarketplaceScriptHeartRepository.java @@ -32,4 +32,6 @@ public interface MarketplaceScriptHeartRepository long countByScriptId(String scriptId); void deleteByScriptIdAndUsername(String scriptId, String username); + + void deleteByUsername(String username); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetFileRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetFileRepository.java index 6a4f46b..59e43f2 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetFileRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetFileRepository.java @@ -1,6 +1,9 @@ package dev.kruhlmann.imgfloat.repository; import dev.kruhlmann.imgfloat.model.ScriptAssetFile; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -public interface ScriptAssetFileRepository extends JpaRepository {} +public interface ScriptAssetFileRepository extends JpaRepository { + List findByBroadcaster(String broadcaster); +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AccountService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AccountService.java new file mode 100644 index 0000000..b39bd45 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AccountService.java @@ -0,0 +1,97 @@ +package dev.kruhlmann.imgfloat.service; + +import dev.kruhlmann.imgfloat.model.Asset; +import dev.kruhlmann.imgfloat.model.ScriptAssetFile; +import dev.kruhlmann.imgfloat.repository.AssetRepository; +import dev.kruhlmann.imgfloat.repository.ChannelRepository; +import dev.kruhlmann.imgfloat.repository.MarketplaceScriptHeartRepository; +import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository; +import dev.kruhlmann.imgfloat.repository.SystemAdministratorRepository; +import java.util.List; +import java.util.Locale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AccountService { + + private static final Logger LOG = LoggerFactory.getLogger(AccountService.class); + + private final ChannelDirectoryService channelDirectoryService; + private final AssetRepository assetRepository; + private final ChannelRepository channelRepository; + private final ScriptAssetFileRepository scriptAssetFileRepository; + private final MarketplaceScriptHeartRepository marketplaceScriptHeartRepository; + private final SystemAdministratorRepository systemAdministratorRepository; + private final AssetStorageService assetStorageService; + private final JdbcTemplate jdbcTemplate; + + public AccountService( + ChannelDirectoryService channelDirectoryService, + AssetRepository assetRepository, + ChannelRepository channelRepository, + ScriptAssetFileRepository scriptAssetFileRepository, + MarketplaceScriptHeartRepository marketplaceScriptHeartRepository, + SystemAdministratorRepository systemAdministratorRepository, + AssetStorageService assetStorageService, + JdbcTemplate jdbcTemplate + ) { + this.channelDirectoryService = channelDirectoryService; + this.assetRepository = assetRepository; + this.channelRepository = channelRepository; + this.scriptAssetFileRepository = scriptAssetFileRepository; + this.marketplaceScriptHeartRepository = marketplaceScriptHeartRepository; + this.systemAdministratorRepository = systemAdministratorRepository; + this.assetStorageService = assetStorageService; + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + public void deleteAccount(String username) { + String normalized = normalize(username); + if (normalized == null || normalized.isBlank()) { + return; + } + + List assetIds = assetRepository + .findByBroadcaster(normalized) + .stream() + .map(Asset::getId) + .toList(); + assetIds.forEach(channelDirectoryService::deleteAsset); + + List scriptFiles = scriptAssetFileRepository.findByBroadcaster(normalized); + scriptFiles.forEach(this::deleteScriptAssetFile); + + marketplaceScriptHeartRepository.deleteByUsername(normalized); + systemAdministratorRepository.deleteByTwitchUsername(normalized); + channelRepository.deleteById(normalized); + + deleteSessionsForUser(normalized); + LOG.info("Account data deleted for {}", normalized); + } + + private void deleteScriptAssetFile(ScriptAssetFile file) { + if (file == null) { + return; + } + assetStorageService.deleteAsset(file.getBroadcaster(), file.getId(), file.getMediaType(), false); + scriptAssetFileRepository.delete(file); + } + + private void deleteSessionsForUser(String username) { + jdbcTemplate.update( + "DELETE FROM SPRING_SESSION_ATTRIBUTES WHERE SESSION_PRIMARY_ID IN (" + + "SELECT PRIMARY_ID FROM SPRING_SESSION WHERE PRINCIPAL_NAME = ?)", + username + ); + jdbcTemplate.update("DELETE FROM SPRING_SESSION WHERE PRINCIPAL_NAME = ?", username); + } + + private String normalize(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index ed05a7e..1eb478c 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -12,6 +12,7 @@ const elements = { allowScriptChat: document.getElementById("allow-script-chat"), scriptSettingsStatus: document.getElementById("script-settings-status"), scriptSettingsSaveButton: document.getElementById("save-script-settings-btn"), + deleteAccountButton: document.getElementById("delete-account-btn"), }; const apiBase = `/api/channels/${encodeURIComponent(broadcaster)}`; @@ -307,6 +308,31 @@ async function saveScriptSettings() { } } +async function deleteAccount() { + const confirmation = window.prompt( + "Type DELETE to permanently remove your account, assets, and session.", + ); + if (confirmation !== "DELETE") { + if (confirmation !== null) { + showToast("Account deletion cancelled.", "info"); + } + return; + } + + setButtonBusy(elements.deleteAccountButton, true, "Deleting..."); + try { + const response = await fetch("/api/account", { method: "DELETE" }); + if (!response.ok) { + throw new Error("Delete account failed"); + } + showToast("Account deleted. Redirecting...", "success"); + window.location.href = "/"; + } catch (error) { + showToast("Unable to delete account right now. Please retry.", "error"); + setButtonBusy(elements.deleteAccountButton, false, "Deleting..."); + } +} + if (elements.adminInput) { elements.adminInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { @@ -320,3 +346,7 @@ fetchAdmins(); fetchSuggestedAdmins(); fetchCanvasSettings(); fetchScriptSettings(); + +if (elements.deleteAccountButton) { + elements.deleteAccountButton.addEventListener("click", deleteAccount); +} diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index fc2f1db..20d9261 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -161,6 +161,23 @@ + +
+
+
+

Account

+

Delete account

+

+ Permanently remove your account, assets, and session. This cannot be undone. +

+
+
+
+ +
+