Add account deletion

This commit is contained in:
2026-01-15 15:35:19 +01:00
parent 8581c6a01f
commit 92c731a30f
6 changed files with 196 additions and 1 deletions

View File

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

View File

@@ -32,4 +32,6 @@ public interface MarketplaceScriptHeartRepository
long countByScriptId(String scriptId); long countByScriptId(String scriptId);
void deleteByScriptIdAndUsername(String scriptId, String username); void deleteByScriptIdAndUsername(String scriptId, String username);
void deleteByUsername(String username);
} }

View File

@@ -1,6 +1,9 @@
package dev.kruhlmann.imgfloat.repository; package dev.kruhlmann.imgfloat.repository;
import dev.kruhlmann.imgfloat.model.ScriptAssetFile; import dev.kruhlmann.imgfloat.model.ScriptAssetFile;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface ScriptAssetFileRepository extends JpaRepository<ScriptAssetFile, String> {} public interface ScriptAssetFileRepository extends JpaRepository<ScriptAssetFile, String> {
List<ScriptAssetFile> findByBroadcaster(String broadcaster);
}

View File

@@ -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<String> assetIds = assetRepository
.findByBroadcaster(normalized)
.stream()
.map(Asset::getId)
.toList();
assetIds.forEach(channelDirectoryService::deleteAsset);
List<ScriptAssetFile> 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);
}
}

View File

@@ -12,6 +12,7 @@ const elements = {
allowScriptChat: document.getElementById("allow-script-chat"), allowScriptChat: document.getElementById("allow-script-chat"),
scriptSettingsStatus: document.getElementById("script-settings-status"), scriptSettingsStatus: document.getElementById("script-settings-status"),
scriptSettingsSaveButton: document.getElementById("save-script-settings-btn"), scriptSettingsSaveButton: document.getElementById("save-script-settings-btn"),
deleteAccountButton: document.getElementById("delete-account-btn"),
}; };
const apiBase = `/api/channels/${encodeURIComponent(broadcaster)}`; 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) { if (elements.adminInput) {
elements.adminInput.addEventListener("keydown", (event) => { elements.adminInput.addEventListener("keydown", (event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -320,3 +346,7 @@ fetchAdmins();
fetchSuggestedAdmins(); fetchSuggestedAdmins();
fetchCanvasSettings(); fetchCanvasSettings();
fetchScriptSettings(); fetchScriptSettings();
if (elements.deleteAccountButton) {
elements.deleteAccountButton.addEventListener("click", deleteAccount);
}

View File

@@ -161,6 +161,23 @@
</li> </li>
</ul> </ul>
</section> </section>
<section class="card dashboard-span-full">
<div class="card-header">
<div>
<p class="eyebrow">Account</p>
<h3>Delete account</h3>
<p class="muted">
Permanently remove your account, assets, and session. This cannot be undone.
</p>
</div>
</div>
<div class="control-actions">
<button id="delete-account-btn" class="secondary danger" type="button">
Delete my account
</button>
</div>
</section>
</div> </div>
</div> </div>
<script src="/js/cookie-consent.js"></script> <script src="/js/cookie-consent.js"></script>