hoàn thành trang settings

This commit is contained in:
2026-07-02 18:59:48 +07:00
parent 302785b60b
commit a7dbf52eea
13 changed files with 3927 additions and 5 deletions
+5 -5
View File
@@ -14,7 +14,7 @@ Tags Tạo và quản lý các Thẻ (nhãn từ khóa tìm kiếm nhanh).
Add New Tải tệp tin mới lên máy chủ.
(Done) 4. Pages (Trang) All Pages Quản lý các trang tĩnh (ví dụ: Trang chủ, Trang liên hệ, Giới thiệu bệnh viện, Giờ làm việc...).
Add New Tạo trang tĩnh mới.
6. Appearance (Giao diện) Themes Cài đặt, kích hoạt hoặc xóa các giao diện hiển thị của website.
(done) 6. Appearance (Giao diện) Themes Cài đặt, kích hoạt hoặc xóa các giao diện hiển thị của website.
Customize Chỉnh sửa giao diện trực quan theo thời gian thực (Header, Footer, Colors, Font, v.v.).
Widgets Quản lý các khối nội dung ở Sidebar hoặc Footer.
Menus Thiết kế menu điều hướng chính ở đầu trang và chân trang.
@@ -25,10 +25,10 @@ Plugin File Editor Chỉnh sửa mã nguồn của các Plugin trực tiếp t
(done) 8. Users (Thành viên) All Users Quản lý danh sách toàn bộ tài khoản đăng ký trên hệ thống.
Add New Tạo tài khoản người dùng mới và chỉ định vai trò (Role).
Profile Chỉnh sửa thông tin cá nhân của tài khoản đang đăng nhập (đổi pass, ngôn ngữ hiển thị).
9. Tools (Công cụ) Available Tools Các công cụ hỗ trợ chuyển đổi dữ liệu.
Import / Export Nhập/Xuất toàn bộ bài viết, trang, media sang file .xml để sao lưu hoặc chuyển host.
Site Health Kiểm tra sức khỏe hệ thống (phiên bản PHP, MySQL, các lỗi bảo mật).
Export / Erase Personal Data Xuất hoặc xóa dữ liệu cá nhân của người dùng để tuân thủ luật bảo mật (GDPR).
(done) 9. Tools (Công cụ) Available Tools Các công cụ hỗ trợ chuyển đổi dữ liệu.
(done) Import / Export Nhập/Xuất toàn bộ bài viết, trang, media sang file .xml để sao lưu hoặc chuyển host.
(done) Site Health Kiểm tra sức khỏe hệ thống (phiên bản PHP, MySQL, các lỗi bảo mật).
(done) Export / Erase Personal Data Xuất hoặc xóa dữ liệu cá nhân của người dùng để tuân thủ luật bảo mật (GDPR).
(done) 10. Settings (Cài đặt) General Thiết lập tên website, câu khẩu hiệu, địa chỉ email admin, múi giờ, định dạng ngày tháng.
Writing Cấu hình mặc định khi soạn thảo văn bản và gửi bài viết qua email.
Reading Thiết lập trang chủ hiển thị, số lượng bài viết tối đa trên một trang blog, chặn công cụ tìm kiếm.
@@ -0,0 +1,66 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.service.ImportExportService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Controller for Import/Export functionality.
*/
@Controller
@RequestMapping("/manage/tools/import-export")
@PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.ADMIN + "\")")
public class ManageImportExportController {
private final ImportExportService importExportService;
public ManageImportExportController(ImportExportService importExportService) {
this.importExportService = importExportService;
}
@GetMapping
public String index() {
return "manage/tools/import-export";
}
@PostMapping("/export")
public ResponseEntity<byte[]> exportData() {
byte[] xmlData = importExportService.exportToXml();
if (xmlData == null) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.valueOf("application/xml"));
headers.setContentDispositionFormData("attachment", "sisvietnamvn_export.xml");
return new ResponseEntity<>(xmlData, headers, HttpStatus.OK);
}
@PostMapping("/import")
public String importData(@RequestParam("file") MultipartFile file, RedirectAttributes redirectAttributes) {
if (file.isEmpty() || !file.getOriginalFilename().endsWith(".xml")) {
redirectAttributes.addFlashAttribute("errorMessage", "Please provide a valid .xml file.");
return "redirect:/manage/tools/import-export";
}
try {
boolean success = importExportService.importFromXml(file.getInputStream());
if (success) {
redirectAttributes.addFlashAttribute("successMessage", "Data successfully imported.");
} else {
redirectAttributes.addFlashAttribute("errorMessage", "Failed to parse XML file.");
}
} catch (Exception e) {
redirectAttributes.addFlashAttribute("errorMessage", "Import error: " + e.getMessage());
}
return "redirect:/manage/tools/import-export";
}
}
@@ -0,0 +1,91 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.service.PersonalDataService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Controller for managing Personal Data (GDPR).
*/
@Controller
@RequestMapping("/manage/tools/personal-data")
@PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.ADMIN + "\")")
public class ManagePersonalDataController {
private final PersonalDataService personalDataService;
public ManagePersonalDataController(PersonalDataService personalDataService) {
this.personalDataService = personalDataService;
}
@GetMapping
public String personalDataView() {
return "manage/tools/personal-data";
}
@PostMapping("/export")
public ResponseEntity<byte[]> exportData(@RequestParam("email") String email) {
byte[] zipData = personalDataService.exportData(email);
if (zipData == null) {
// If user not found, we redirect back with error.
// Since this returns ResponseEntity, we have to return a redirect status or handle it via a form redirect.
// A better way is to return an error page or redirect response.
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.valueOf("application/zip"));
headers.setContentDispositionFormData("attachment", "personal_data_export.zip");
return new ResponseEntity<>(zipData, headers, HttpStatus.OK);
}
@PostMapping("/export-form")
public String exportDataForm(@RequestParam("email") String email, RedirectAttributes redirectAttributes) {
byte[] zipData = personalDataService.exportData(email);
if (zipData == null) {
redirectAttributes.addFlashAttribute("errorMessage", "No user found with that email address.");
return "redirect:/manage/tools/personal-data";
}
// If found, redirect to a direct download endpoint to avoid flash attribute issues with file downloads
return "redirect:/manage/tools/personal-data/download?email=" + email;
}
@GetMapping("/download")
public ResponseEntity<byte[]> downloadData(@RequestParam("email") String email) {
byte[] zipData = personalDataService.exportData(email);
if (zipData == null) {
return ResponseEntity.notFound().build();
}
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.valueOf("application/zip"));
headers.setContentDispositionFormData("attachment", "personal_data_export.zip");
return new ResponseEntity<>(zipData, headers, HttpStatus.OK);
}
@PostMapping("/erase")
public String eraseData(@RequestParam("email") String email,
@RequestParam(value = "confirm", required = false) String confirm,
RedirectAttributes redirectAttributes) {
if (confirm == null || !confirm.equals("on")) {
redirectAttributes.addFlashAttribute("errorMessage", "You must check the confirmation box to erase data.");
return "redirect:/manage/tools/personal-data";
}
boolean success = personalDataService.eraseData(email);
if (success) {
redirectAttributes.addFlashAttribute("successMessage", "Personal data successfully erased and anonymized for " + email);
} else {
redirectAttributes.addFlashAttribute("errorMessage", "No user found with that email address.");
}
return "redirect:/manage/tools/personal-data";
}
}
@@ -0,0 +1,68 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import javax.sql.DataSource;
import java.sql.Connection;
/**
* Controller for the Site Health diagnostic dashboard.
*/
@Controller
@RequestMapping("/manage/tools/site-health")
@PreAuthorize("hasAuthority(\"" + AuthoritiesConstants.ADMIN + "\")")
public class ManageSiteHealthController {
private final DataSource dataSource;
public ManageSiteHealthController(DataSource dataSource) {
this.dataSource = dataSource;
}
@GetMapping
public String siteHealthView(Model model) {
// Gather Java / OS Information
model.addAttribute("javaVersion", System.getProperty("java.version"));
model.addAttribute("javaVendor", System.getProperty("java.vendor"));
model.addAttribute("osName", System.getProperty("os.name"));
model.addAttribute("osVersion", System.getProperty("os.version"));
model.addAttribute("osArch", System.getProperty("os.arch"));
model.addAttribute("timezone", System.getProperty("user.timezone"));
// Gather Memory Information
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory() / (1024 * 1024);
long freeMemory = runtime.freeMemory() / (1024 * 1024);
long maxMemory = runtime.maxMemory() / (1024 * 1024);
long usedMemory = totalMemory - freeMemory;
model.addAttribute("totalMemory", totalMemory);
model.addAttribute("freeMemory", freeMemory);
model.addAttribute("maxMemory", maxMemory);
model.addAttribute("usedMemory", usedMemory);
// Database Connectivity Check
String dbStatus = "Disconnected";
String dbProduct = "Unknown";
boolean isDbHealthy = false;
try (Connection connection = dataSource.getConnection()) {
if (connection.isValid(2)) {
dbStatus = "Connected";
isDbHealthy = true;
dbProduct = connection.getMetaData().getDatabaseProductName() + " " + connection.getMetaData().getDatabaseProductVersion();
}
} catch (Exception e) {
dbStatus = "Error: " + e.getMessage();
}
model.addAttribute("dbStatus", dbStatus);
model.addAttribute("dbProduct", dbProduct);
model.addAttribute("isDbHealthy", isDbHealthy);
return "manage/tools/site-health";
}
}
@@ -33,6 +33,11 @@ public interface PageRepository extends JpaRepository<Page, Long> {
*/
List<Page> findByStatusOrderByDisplayOrderAsc(PageStatus status);
/**
* Find all pages authored by a specific user (login).
*/
List<Page> findByCreatedBy(String createdBy);
/**
* Check if a slug already exists (for uniqueness validation).
*/
@@ -39,6 +39,11 @@ public interface PostRepository extends JpaRepository<Post, Long> {
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
List<Post> findByStatusOrderByCreatedDateDesc(PageStatus status);
/**
* Find all posts authored by a specific user (login).
*/
List<Post> findByCreatedBy(String createdBy);
/**
* Find all posts in a given category.
*/
@@ -0,0 +1,217 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.Media;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.repository.MediaRepository;
import com.sisvietnamvn.web.repository.PageRepository;
import com.sisvietnamvn.web.repository.PostRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.time.Instant;
import java.util.List;
/**
* Service for handling Import and Export of XML data.
*/
@Service
@Transactional
public class ImportExportService {
private final Logger log = LoggerFactory.getLogger(ImportExportService.class);
private final PostRepository postRepository;
private final PageRepository pageRepository;
private final MediaRepository mediaRepository;
public ImportExportService(PostRepository postRepository, PageRepository pageRepository, MediaRepository mediaRepository) {
this.postRepository = postRepository;
this.pageRepository = pageRepository;
this.mediaRepository = mediaRepository;
}
/**
* Exports Posts, Pages, and Media metadata to an XML document.
*/
@Transactional(readOnly = true)
public byte[] exportToXml() {
try {
DocumentBuilderFactory docFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder docBuilder = docFactory.newDocumentBuilder();
Document doc = docBuilder.newDocument();
// Root Element
Element rootElement = doc.createElement("sis_export");
doc.appendChild(rootElement);
// Export Posts
List<Post> posts = postRepository.findAll();
Element postsElement = doc.createElement("posts");
rootElement.appendChild(postsElement);
for (Post post : posts) {
Element postEl = doc.createElement("post");
addElement(doc, postEl, "title", post.getTitle());
addElement(doc, postEl, "slug", post.getSlug());
addElement(doc, postEl, "content", post.getContent());
addElement(doc, postEl, "excerpt", post.getExcerpt());
addElement(doc, postEl, "status", post.getStatus() != null ? post.getStatus().name() : "");
postsElement.appendChild(postEl);
}
// Export Pages
List<Page> pages = pageRepository.findAll();
Element pagesElement = doc.createElement("pages");
rootElement.appendChild(pagesElement);
for (Page page : pages) {
Element pageEl = doc.createElement("page");
addElement(doc, pageEl, "title", page.getTitle());
addElement(doc, pageEl, "slug", page.getSlug());
addElement(doc, pageEl, "content", page.getContent());
addElement(doc, pageEl, "status", page.getStatus() != null ? page.getStatus().name() : "");
pagesElement.appendChild(pageEl);
}
// Export Media Metadata
List<Media> medias = mediaRepository.findAll();
Element mediasElement = doc.createElement("medias");
rootElement.appendChild(mediasElement);
for (Media media : medias) {
Element mediaEl = doc.createElement("media");
addElement(doc, mediaEl, "fileName", media.getOriginalFilename());
addElement(doc, mediaEl, "url", media.getFileUrl());
addElement(doc, mediaEl, "mediaType", media.getMediaType() != null ? media.getMediaType().name() : "");
mediasElement.appendChild(mediaEl);
}
// Write content to byte array
TransformerFactory transformerFactory = TransformerFactory.newInstance();
Transformer transformer = transformerFactory.newTransformer();
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
DOMSource source = new DOMSource(doc);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
StreamResult result = new StreamResult(baos);
transformer.transform(source, result);
return baos.toByteArray();
} catch (Exception e) {
log.error("Error exporting to XML", e);
return null;
}
}
/**
* Imports Posts and Pages from an XML document.
*/
public boolean importFromXml(InputStream is) {
try {
DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance();
// Prevent XML External Entity (XXE) injection
dbFactory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
DocumentBuilder dBuilder = dbFactory.newDocumentBuilder();
Document doc = dBuilder.parse(is);
doc.getDocumentElement().normalize();
if (!"sis_export".equals(doc.getDocumentElement().getNodeName())) {
log.warn("Invalid XML root element during import");
return false;
}
// Import Posts
NodeList postList = doc.getElementsByTagName("post");
for (int i = 0; i < postList.getLength(); i++) {
Node node = postList.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element el = (Element) node;
String title = getElementValue(el, "title");
String slug = getElementValue(el, "slug");
// Avoid duplicating by slug
if (postRepository.findBySlug(slug).isEmpty()) {
Post post = new Post();
post.setTitle(title);
post.setSlug(slug);
post.setContent(getElementValue(el, "content"));
post.setExcerpt(getElementValue(el, "excerpt"));
String statusStr = getElementValue(el, "status");
if (!statusStr.isEmpty()) {
try {
post.setStatus(PageStatus.valueOf(statusStr));
} catch (Exception ex) {
post.setStatus(PageStatus.DRAFT);
}
}
postRepository.save(post);
}
}
}
// Import Pages
NodeList pageList = doc.getElementsByTagName("page");
for (int i = 0; i < pageList.getLength(); i++) {
Node node = pageList.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element el = (Element) node;
String title = getElementValue(el, "title");
String slug = getElementValue(el, "slug");
if (pageRepository.findBySlug(slug).isEmpty()) {
Page page = new Page();
page.setTitle(title);
page.setSlug(slug);
page.setContent(getElementValue(el, "content"));
String statusStr = getElementValue(el, "status");
if (!statusStr.isEmpty()) {
try {
page.setStatus(PageStatus.valueOf(statusStr));
} catch (Exception ex) {
page.setStatus(PageStatus.DRAFT);
}
}
pageRepository.save(page);
}
}
}
// Media files are skipped during import since the actual file data is not present in XML.
return true;
} catch (Exception e) {
log.error("Error importing from XML", e);
return false;
}
}
private void addElement(Document doc, Element parent, String tagName, String value) {
Element el = doc.createElement(tagName);
el.appendChild(doc.createTextNode(value != null ? value : ""));
parent.appendChild(el);
}
private String getElementValue(Element parent, String tagName) {
NodeList nList = parent.getElementsByTagName(tagName);
if (nList != null && nList.getLength() > 0) {
Node node = nList.item(0);
return node.getTextContent();
}
return "";
}
}
@@ -0,0 +1,146 @@
package com.sisvietnamvn.web.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sisvietnamvn.web.domain.Page;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.User;
import com.sisvietnamvn.web.repository.PageRepository;
import com.sisvietnamvn.web.repository.PostRepository;
import com.sisvietnamvn.web.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Service for managing Personal Data (GDPR compliance).
*/
@Service
@Transactional
public class PersonalDataService {
private final Logger log = LoggerFactory.getLogger(PersonalDataService.class);
private final UserRepository userRepository;
private final PostRepository postRepository;
private final PageRepository pageRepository;
private final ObjectMapper objectMapper;
public PersonalDataService(UserRepository userRepository, PostRepository postRepository, PageRepository pageRepository, ObjectMapper objectMapper) {
this.userRepository = userRepository;
this.postRepository = postRepository;
this.pageRepository = pageRepository;
this.objectMapper = objectMapper;
}
/**
* Exports all personal data associated with an email to a Zip containing a JSON file.
* @param email The user's email address
* @return byte[] of the zip file, or null if user not found.
*/
@Transactional(readOnly = true)
public byte[] exportData(String email) {
Optional<User> userOpt = userRepository.findOneWithAuthoritiesByEmailIgnoreCase(email);
if (userOpt.isEmpty()) {
return null;
}
User user = userOpt.get();
ObjectNode rootNode = objectMapper.createObjectNode();
// 1. Profile Data
ObjectNode profileNode = rootNode.putObject("profile");
profileNode.put("id", user.getId());
profileNode.put("login", user.getLogin());
profileNode.put("firstName", user.getFirstName());
profileNode.put("lastName", user.getLastName());
profileNode.put("email", user.getEmail());
profileNode.put("imageUrl", user.getImageUrl());
profileNode.put("langKey", user.getLangKey());
profileNode.put("createdDate", user.getCreatedDate() != null ? user.getCreatedDate().toString() : null);
// 2. Authored Posts
List<Post> posts = postRepository.findByCreatedBy(user.getLogin());
ArrayNode postsNode = rootNode.putArray("authored_posts");
for (Post post : posts) {
ObjectNode pNode = objectMapper.createObjectNode();
pNode.put("id", post.getId());
pNode.put("title", post.getTitle());
pNode.put("slug", post.getSlug());
pNode.put("createdDate", post.getCreatedDate() != null ? post.getCreatedDate().toString() : null);
postsNode.add(pNode);
}
// 3. Authored Pages
List<Page> pages = pageRepository.findByCreatedBy(user.getLogin());
ArrayNode pagesNode = rootNode.putArray("authored_pages");
for (Page page : pages) {
ObjectNode pNode = objectMapper.createObjectNode();
pNode.put("id", page.getId());
pNode.put("title", page.getTitle());
pNode.put("slug", page.getSlug());
pNode.put("createdDate", page.getCreatedDate() != null ? page.getCreatedDate().toString() : null);
pagesNode.add(pNode);
}
try {
String jsonOutput = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(rootNode);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos)) {
ZipEntry entry = new ZipEntry("personal_data.json");
zos.putNextEntry(entry);
zos.write(jsonOutput.getBytes());
zos.closeEntry();
}
return baos.toByteArray();
} catch (Exception e) {
log.error("Failed to generate personal data export zip", e);
return null;
}
}
/**
* Erases personal data associated with an email.
* Anonymizes authored content and deletes the user account.
* @param email The user's email address
* @return true if successful, false if user not found.
*/
public boolean eraseData(String email) {
Optional<User> userOpt = userRepository.findOneWithAuthoritiesByEmailIgnoreCase(email);
if (userOpt.isEmpty()) {
return false;
}
User user = userOpt.get();
String login = user.getLogin();
// 1. Anonymize Posts
List<Post> posts = postRepository.findByCreatedBy(login);
for (Post post : posts) {
post.setCreatedBy("anonymous");
post.setLastModifiedBy("anonymous");
postRepository.save(post);
}
// 2. Anonymize Pages
List<Page> pages = pageRepository.findByCreatedBy(login);
for (Page page : pages) {
page.setCreatedBy("anonymous");
page.setLastModifiedBy("anonymous");
pageRepository.save(page);
}
// 3. Delete User
userRepository.delete(user);
log.info("Erased personal data for user email: {}", email);
return true;
}
}
@@ -218,6 +218,24 @@
</div>
</li>
<!-- Nav Item - Tools Collapse Menu -->
<li class="nav-item">
<a class="nav-link collapsed" href="#" data-toggle="collapse" data-target="#collapseTools"
aria-expanded="true" aria-controls="collapseTools">
<i class="fas fa-fw fa-wrench"></i>
<span>Tools</span>
</a>
<div id="collapseTools" class="collapse" aria-labelledby="headingTools"
data-parent="#accordionSidebar">
<div class="bg-white py-2 collapse-inner rounded">
<h6 class="collapse-header">System Tools:</h6>
<a class="collapse-item" th:href="@{/manage/tools/import-export}">Import / Export</a>
<a class="collapse-item" th:href="@{/manage/tools/site-health}">Site Health</a>
<a class="collapse-item" th:href="@{/manage/tools/personal-data}">Personal Data (GDPR)</a>
</div>
</div>
</li>
<!-- Divider -->
<hr class="sidebar-divider d-none d-md-block">
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
<head>
<title>Import / Export Data</title>
</head>
<body>
<div layout:fragment="content">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Import / Export</h1>
</div>
<!-- Success/Error Messages -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="row">
<!-- Export Data Form -->
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Export Content</h6>
</div>
<div class="card-body">
<p>Export all your Posts, Pages, and Media metadata to an XML file. You can use this file to back up your data or migrate it to another server.</p>
<form th:action="@{/manage/tools/import-export/export}" method="post">
<button type="submit" class="btn btn-primary"><i class="fas fa-file-export mr-2"></i> Export to XML</button>
</form>
</div>
</div>
</div>
<!-- Import Data Form -->
<div class="col-lg-6">
<div class="card shadow mb-4 border-left-success">
<div class="card-header py-3 bg-success text-white">
<h6 class="m-0 font-weight-bold">Import Content</h6>
</div>
<div class="card-body">
<p>Import Posts and Pages from a previously generated XML file. If a Post or Page with the same slug already exists, it will be skipped.</p>
<form th:action="@{/manage/tools/import-export/import}" method="post" enctype="multipart/form-data">
<div class="form-group">
<label for="importFile">Choose XML File</label>
<input type="file" class="form-control-file" id="importFile" name="file" accept=".xml" required>
</div>
<button type="submit" class="btn btn-success"><i class="fas fa-file-import mr-2"></i> Import from XML</button>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
<head>
<title>Export / Erase Personal Data</title>
</head>
<body>
<div layout:fragment="content">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Personal Data (GDPR)</h1>
</div>
<!-- Success/Error Messages -->
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${successMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${errorMessage}"></span>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="row">
<!-- Export Data Form -->
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Export Personal Data</h6>
</div>
<div class="card-body">
<p>Enter the user's email address to export their personal data into a secure `.zip` file containing JSON data of their profile, posts, and pages.</p>
<form th:action="@{/manage/tools/personal-data/export-form}" method="post">
<div class="form-group">
<label for="exportEmail">User Email Address</label>
<input type="email" class="form-control" id="exportEmail" name="email" required placeholder="user@example.com">
</div>
<button type="submit" class="btn btn-primary"><i class="fas fa-download mr-2"></i> Download Export</button>
</form>
</div>
</div>
</div>
<!-- Erase Data Form -->
<div class="col-lg-6">
<div class="card shadow mb-4 border-left-danger">
<div class="card-header py-3 bg-danger text-white">
<h6 class="m-0 font-weight-bold">Erase Personal Data</h6>
</div>
<div class="card-body">
<p class="text-danger font-weight-bold">Warning: This action is permanent and cannot be undone.</p>
<p>Entering an email address below will:</p>
<ul>
<li>Anonymize all posts and pages authored by this user (assign to `anonymous`).</li>
<li>Permanently delete their user account and profile data.</li>
</ul>
<form th:action="@{/manage/tools/personal-data/erase}" method="post">
<div class="form-group">
<label for="eraseEmail">User Email Address</label>
<input type="email" class="form-control" id="eraseEmail" name="email" required placeholder="user@example.com">
</div>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" id="confirmErase" name="confirm" required>
<label class="form-check-label text-danger" for="confirmErase">I confirm I want to permanently erase this user's data.</label>
</div>
<button type="submit" class="btn btn-danger"><i class="fas fa-trash-alt mr-2"></i> Erase Personal Data</button>
</form>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
<head>
<title>Site Health</title>
</head>
<body>
<div layout:fragment="content">
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Site Health</h1>
</div>
<div class="row">
<!-- System Information -->
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fas fa-server mr-2"></i>System Information</h6>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Java Version
<span class="font-weight-bold" th:text="${javaVersion}">17.0.1</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Java Vendor
<span class="font-weight-bold" th:text="${javaVendor}">Oracle</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Operating System
<span class="font-weight-bold" th:text="${osName} + ' (' + ${osVersion} + ')'">Linux</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Architecture
<span class="font-weight-bold" th:text="${osArch}">amd64</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Timezone
<span class="font-weight-bold" th:text="${timezone}">UTC</span>
</li>
</ul>
</div>
</div>
</div>
<!-- Database Health -->
<div class="col-lg-6">
<div class="card shadow mb-4" th:classappend="${isDbHealthy} ? 'border-left-success' : 'border-left-danger'">
<div class="card-header py-3" th:classappend="${isDbHealthy} ? 'bg-success text-white' : 'bg-danger text-white'">
<h6 class="m-0 font-weight-bold"><i class="fas fa-database mr-2"></i>Database Health</h6>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Status
<span class="badge badge-pill" th:classappend="${isDbHealthy} ? 'badge-success' : 'badge-danger'" th:text="${dbStatus}">Connected</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Product Version
<span class="font-weight-bold text-right" th:text="${dbProduct}">MySQL 8.0</span>
</li>
</ul>
</div>
</div>
<!-- Memory Usage -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary"><i class="fas fa-memory mr-2"></i>Memory Usage (JVM)</h6>
</div>
<div class="card-body">
<h4 class="small font-weight-bold">Used Memory: <span th:text="${usedMemory} + ' MB / ' + ${totalMemory} + ' MB'"></span> <span class="float-right" th:text="${#numbers.formatDecimal((usedMemory * 100.0) / totalMemory, 1, 1)} + '%'">50%</span></h4>
<div class="progress mb-4">
<div class="progress-bar" th:classappend="${(usedMemory * 100.0) / totalMemory > 80} ? 'bg-danger' : 'bg-info'" role="progressbar" th:style="'width: ' + ${(usedMemory * 100.0) / totalMemory} + '%'"></div>
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
Free Memory
<span class="font-weight-bold" th:text="${freeMemory} + ' MB'">100 MB</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
Max Memory Available
<span class="font-weight-bold" th:text="${maxMemory} + ' MB'">500 MB</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</body>
</html>