feat: add UMass Amherst theme support, implement HTML snippet management system, and update page rendering logic

This commit is contained in:
2026-07-06 23:50:55 +07:00
parent 3dc5a03484
commit e6d98edc45
364 changed files with 9967 additions and 36960 deletions
@@ -57,7 +57,7 @@ public class SecurityConfiguration {
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
"/manage/login", "/css/**", "/images/**", "/js/**", "/vendor/**", "/fonts/**", "/login-assets/**", "/UMass*/**", "/Undergraduate*/**",
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/api/manage/snippets/**", "/page/**", "/news/article/**", "/post/**", "/error",
"/about-us", "/specialty", "/doctor", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us")
"/about-us", "/specialty", "/doctor", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us", "/theme-assets/**")
.permitAll()
.requestMatchers(HttpMethod.POST, "/api/manage/media/upload").permitAll()
.requestMatchers(HttpMethod.GET, "/swagger-ui/**", "/v3/api-docs/**").permitAll()
@@ -17,12 +17,18 @@ public class GlobalControllerAdvice {
private final SettingService settingService;
private final com.sisvietnamvn.web.service.MenuService menuService;
private final com.sisvietnamvn.web.hook.HookManager hookManager;
private final com.sisvietnamvn.web.security.AdminContext adminContext;
private final com.sisvietnamvn.web.security.AdminMenuManager adminMenuManager;
private final com.sisvietnamvn.web.security.AdminSettingsManager adminSettingsManager;
private final PathMatchingResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver();
public GlobalControllerAdvice(SettingService settingService, com.sisvietnamvn.web.service.MenuService menuService, com.sisvietnamvn.web.hook.HookManager hookManager) {
public GlobalControllerAdvice(SettingService settingService, com.sisvietnamvn.web.service.MenuService menuService, com.sisvietnamvn.web.hook.HookManager hookManager, com.sisvietnamvn.web.security.AdminContext adminContext, com.sisvietnamvn.web.security.AdminMenuManager adminMenuManager, com.sisvietnamvn.web.security.AdminSettingsManager adminSettingsManager) {
this.settingService = settingService;
this.menuService = menuService;
this.hookManager = hookManager;
this.adminContext = adminContext;
this.adminMenuManager = adminMenuManager;
this.adminSettingsManager = adminSettingsManager;
}
/**
@@ -71,6 +77,26 @@ public class GlobalControllerAdvice {
return hookManager;
}
@ModelAttribute("adminCtx")
public com.sisvietnamvn.web.security.AdminContext getAdminContext() {
return adminContext;
}
@ModelAttribute("adminScreen")
public com.sisvietnamvn.web.security.AdminScreen getAdminScreen() {
return adminContext.getCurrentScreen();
}
@ModelAttribute("dynamicAdminMenus")
public java.util.List<com.sisvietnamvn.web.security.AdminMenuItem> getDynamicAdminMenus() {
return adminMenuManager.getAuthorizedMenus();
}
@ModelAttribute("settingsManager")
public com.sisvietnamvn.web.security.AdminSettingsManager getSettingsManager() {
return adminSettingsManager;
}
@ModelAttribute("themeModPrimaryColor")
public String getThemeModPrimaryColor() {
return settingService.getValue("theme_mod_primaryColor", "#007bff");
@@ -33,11 +33,13 @@ public class PageController {
private final PageService pageService;
private final ObjectMapper objectMapper;
private final HookManager hookManager;
private final com.sisvietnamvn.web.service.HtmlSnippetService snippetService;
public PageController(PageService pageService, ObjectMapper objectMapper, HookManager hookManager) {
public PageController(PageService pageService, ObjectMapper objectMapper, HookManager hookManager, com.sisvietnamvn.web.service.HtmlSnippetService snippetService) {
this.pageService = pageService;
this.objectMapper = objectMapper;
this.hookManager = hookManager;
this.snippetService = snippetService;
}
@GetMapping("/page/{slug}")
@@ -109,6 +111,15 @@ public class PageController {
Map<String, Object> editorData = objectMapper.readValue(page.getContent(), new TypeReference<>() {});
if (editorData.containsKey("blocks")) {
blocks = (List<Map<String, Object>>) editorData.get("blocks");
for (Map<String, Object> block : blocks) {
if ("snippet".equals(block.get("type"))) {
Map<String, Object> data = (Map<String, Object>) block.get("data");
if (data != null && data.containsKey("id")) {
String snippetId = (String) data.get("id");
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
}
}
}
}
} catch (JsonProcessingException e) {
LOG.error("Failed to parse Editor.js JSON for page ID: {}", page.getId(), e);
@@ -0,0 +1,66 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.HtmlSnippet;
import com.sisvietnamvn.web.service.HtmlSnippetService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
@RequestMapping("/manage/snippets")
public class ManageSnippetController {
private final HtmlSnippetService snippetService;
public ManageSnippetController(HtmlSnippetService snippetService) {
this.snippetService = snippetService;
}
@GetMapping
public String listSnippets(Model model) {
model.addAttribute("snippets", snippetService.findAll());
return "manage/snippets/list";
}
@GetMapping("/new")
public String newSnippetForm(Model model) {
model.addAttribute("snippet", new HtmlSnippet());
model.addAttribute("isNew", true);
return "manage/snippets/form";
}
@PostMapping("/create")
public String createSnippet(@ModelAttribute HtmlSnippet snippet, RedirectAttributes redirectAttributes) {
snippetService.save(snippet);
redirectAttributes.addFlashAttribute("successMessage", "Snippet created successfully.");
return "redirect:/manage/snippets";
}
@GetMapping("/{id}/edit")
public String editSnippetForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
return snippetService.findById(id).map(snippet -> {
model.addAttribute("snippet", snippet);
model.addAttribute("isNew", false);
return "manage/snippets/form";
}).orElseGet(() -> {
redirectAttributes.addFlashAttribute("errorMessage", "Snippet not found.");
return "redirect:/manage/snippets";
});
}
@PostMapping("/{id}")
public String updateSnippet(@PathVariable Long id, @ModelAttribute HtmlSnippet snippet, RedirectAttributes redirectAttributes) {
snippet.setId(id);
snippetService.save(snippet);
redirectAttributes.addFlashAttribute("successMessage", "Snippet updated successfully.");
return "redirect:/manage/snippets";
}
@PostMapping("/{id}/delete")
public String deleteSnippet(@PathVariable Long id, RedirectAttributes redirectAttributes) {
snippetService.deleteById(id);
redirectAttributes.addFlashAttribute("successMessage", "Snippet deleted successfully.");
return "redirect:/manage/snippets";
}
}
@@ -106,9 +106,17 @@ public class ManageThemeController {
try {
java.nio.file.Path themePath = java.nio.file.Paths.get("src/main/resources/templates/themes/", themeKey);
java.nio.file.Path buildThemePath = java.nio.file.Paths.get("build/resources/main/templates/themes/", themeKey);
if (java.nio.file.Files.exists(themePath)) {
// Delete directory recursively
org.springframework.util.FileSystemUtils.deleteRecursively(themePath);
// Also delete from Gradle build cache if it exists
if (java.nio.file.Files.exists(buildThemePath)) {
org.springframework.util.FileSystemUtils.deleteRecursively(buildThemePath);
}
redirectAttributes.addFlashAttribute("successMessage", "Theme deleted successfully.");
} else {
redirectAttributes.addFlashAttribute("errorMessage", "Theme folder not found.");
@@ -128,6 +136,7 @@ public class ManageThemeController {
try {
java.nio.file.Path targetDir = java.nio.file.Paths.get("src/main/resources/templates/themes/");
java.nio.file.Path buildDir = java.nio.file.Paths.get("build/resources/main/templates/themes/");
java.util.zip.ZipInputStream zis = new java.util.zip.ZipInputStream(file.getInputStream());
java.util.zip.ZipEntry zipEntry = zis.getNextEntry();
@@ -142,6 +151,15 @@ public class ManageThemeController {
}
}
java.nio.file.Files.copy(zis, newPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
// Also copy to Gradle build cache if it exists, so changes appear without restarting server
if (java.nio.file.Files.exists(java.nio.file.Paths.get("build/resources/main"))) {
java.nio.file.Path buildNewPath = zipSlipProtect(zipEntry, buildDir);
if (buildNewPath.getParent() != null && java.nio.file.Files.notExists(buildNewPath.getParent())) {
java.nio.file.Files.createDirectories(buildNewPath.getParent());
}
java.nio.file.Files.copy(newPath, buildNewPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
zipEntry = zis.getNextEntry();
}
@@ -2,16 +2,13 @@ package com.sisvietnamvn.web.controller.manage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import org.springframework.security.access.prepost.PreAuthorize;
import com.sisvietnamvn.web.security.AuthoritiesConstants;
import com.sisvietnamvn.web.service.HtmlSnippetService;
/**
* REST controller for fetching predefined HTML snippets to be previewed in Editor.js.
@@ -22,10 +19,10 @@ import com.sisvietnamvn.web.security.AuthoritiesConstants;
public class SnippetController {
private static final Logger LOG = LoggerFactory.getLogger(SnippetController.class);
private final ResourceLoader resourceLoader;
private final HtmlSnippetService snippetService;
public SnippetController(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
public SnippetController(HtmlSnippetService snippetService) {
this.snippetService = snippetService;
}
/**
@@ -35,25 +32,13 @@ public class SnippetController {
public ResponseEntity<String> getSnippet(@PathVariable("id") String id) {
LOG.debug("REST request to get Snippet : {}", id);
// Prevent path traversal attacks
if (id == null || id.contains("..") || id.contains("/")) {
return ResponseEntity.badRequest().body("Invalid Snippet ID");
String content = snippetService.getSnippetContent(id);
if (content == null || content.isEmpty()) {
LOG.warn("Snippet not found or is inactive: {}", id);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("<div style=\"padding: 20px; border: 1px dashed red; color: red;\">Snippet ID <strong>" + id + "</strong> not found or inactive.</div>");
}
String path = "classpath:templates/snippets/" + id + ".html";
Resource resource = resourceLoader.getResource(path);
if (!resource.exists() || !resource.isReadable()) {
LOG.warn("Snippet not found at path: {}", path);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("<div style=\"padding: 20px; border: 1px dashed red; color: red;\">Snippet ID <strong>" + id + "</strong> not found.</div>");
}
try {
String content = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
return ResponseEntity.ok().body(content);
} catch (IOException e) {
LOG.error("Error reading snippet file: {}", path, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error reading snippet file");
}
return ResponseEntity.ok().body(content);
}
}
@@ -0,0 +1,72 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
/**
* Entity representing a reusable HTML Snippet.
*/
@Entity
@Table(name = "html_snippet")
public class HtmlSnippet extends AbstractAuditingEntity<Long> {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String slug;
@Column(nullable = false)
private String name;
@Column(columnDefinition = "TEXT")
private String content;
private boolean active = true;
// Getters and Setters
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public boolean isActive() {
return active;
}
public void setActive(boolean active) {
this.active = active;
}
}
@@ -0,0 +1,16 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.HtmlSnippet;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Spring Data JPA repository for the HtmlSnippet entity.
*/
@Repository
public interface HtmlSnippetRepository extends JpaRepository<HtmlSnippet, Long> {
Optional<HtmlSnippet> findBySlug(String slug);
}
@@ -0,0 +1,141 @@
package com.sisvietnamvn.web.security;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Service;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Utility for managing the Admin context, providing equivalents to WordPress's
* is_admin(), current_user_can(), and get_current_screen().
*/
@Service
public class AdminContext {
private static final String MANAGE_PREFIX = "/manage";
// Regex for parsing /manage/{base}/{id}/{section}
private static final Pattern SCREEN_PATTERN = Pattern.compile("^/manage/([^/]+)(?:/(\\d+))?(?:/([^/]+))?/?$");
/**
* Map of WordPress capabilities to Spring Boot roles/authorities.
*/
private static final Map<String, String> CAPABILITY_TO_ROLE = Map.ofEntries(
Map.entry("manage_options", AuthoritiesConstants.ADMIN),
Map.entry("edit_themes", AuthoritiesConstants.ADMIN),
Map.entry("install_plugins", AuthoritiesConstants.ADMIN),
Map.entry("edit_users", AuthoritiesConstants.ADMIN),
Map.entry("edit_others_posts", AuthoritiesConstants.EDITOR),
Map.entry("manage_categories", AuthoritiesConstants.EDITOR),
Map.entry("moderate_comments", AuthoritiesConstants.EDITOR),
Map.entry("edit_pages", AuthoritiesConstants.EDITOR),
Map.entry("delete_posts", AuthoritiesConstants.EDITOR),
Map.entry("publish_posts", AuthoritiesConstants.AUTHOR),
Map.entry("upload_files", AuthoritiesConstants.AUTHOR),
Map.entry("edit_posts", AuthoritiesConstants.CONTRIBUTOR),
Map.entry("read", AuthoritiesConstants.SUBSCRIBER)
);
/**
* Equivalents to WordPress's is_admin().
* Checks if the request is for an admin page (/manage).
*/
public static boolean isAdmin(HttpServletRequest request) {
if (request == null) {
return false;
}
String requestUri = request.getRequestURI();
return requestUri != null && requestUri.startsWith(MANAGE_PREFIX);
}
/**
* Instance method for Thymeleaf usage without passing the request.
*/
public boolean isAdminRequest() {
HttpServletRequest request = getCurrentHttpRequest();
return isAdmin(request);
}
/**
* Equivalents to WordPress's current_user_can().
* Checks if the current user has the specified capability or role.
*/
public boolean currentUserCan(String capability) {
if (capability == null) {
return false;
}
// If capability maps to a known Spring Boot role, check that role
if (CAPABILITY_TO_ROLE.containsKey(capability)) {
return SecurityUtils.hasCurrentUserThisAuthority(CAPABILITY_TO_ROLE.get(capability));
}
// Otherwise, assume the capability string itself is the authority (e.g., "ROLE_ADMIN")
return SecurityUtils.hasCurrentUserThisAuthority(capability);
}
/**
* Equivalents to WordPress's get_current_screen().
* Parses the current request URI to determine the admin screen context.
*/
public AdminScreen getCurrentScreen(HttpServletRequest request) {
if (!isAdmin(request)) {
return null; // Not an admin screen
}
String requestUri = request.getRequestURI();
// Special case for root dashboard
if (MANAGE_PREFIX.equals(requestUri) || (MANAGE_PREFIX + "/").equals(requestUri)) {
return new AdminScreen("manage-dashboard", "dashboard", "index", null);
}
Matcher matcher = SCREEN_PATTERN.matcher(requestUri);
if (matcher.find()) {
String base = matcher.group(1);
String idStr = matcher.group(2);
String sectionStr = matcher.group(3);
String id = "manage-" + base;
Long entityId = null;
if (idStr != null) {
try {
entityId = Long.parseLong(idStr);
} catch (NumberFormatException ignored) {}
}
String section = "list"; // Default for /manage/posts
if (sectionStr != null) {
section = sectionStr; // e.g., "edit" or "new"
} else if (idStr == null && requestUri.endsWith("/new")) {
// Handling /manage/posts/new which might not perfectly match the numeric id pattern
section = "new";
}
return new AdminScreen(id, base, section, entityId);
}
// Fallback for unknown /manage/xyz paths
return new AdminScreen("manage-unknown", "unknown", "index", null);
}
/**
* Instance method for Thymeleaf usage without passing the request.
*/
public AdminScreen getCurrentScreen() {
HttpServletRequest request = getCurrentHttpRequest();
return getCurrentScreen(request);
}
private HttpServletRequest getCurrentHttpRequest() {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return (attrs != null) ? attrs.getRequest() : null;
}
}
@@ -0,0 +1,94 @@
package com.sisvietnamvn.web.security;
import java.util.ArrayList;
import java.util.List;
/**
* Represents a menu item in the WordPress-style admin sidebar.
*/
public class AdminMenuItem implements Comparable<AdminMenuItem> {
private String menuTitle;
private String capability;
private String menuSlug;
private String iconUrl;
private int position;
private String url;
private List<AdminMenuItem> submenus;
public AdminMenuItem(String menuTitle, String capability, String menuSlug, String iconUrl, int position, String url) {
this.menuTitle = menuTitle;
this.capability = capability;
this.menuSlug = menuSlug;
this.iconUrl = iconUrl;
this.position = position;
this.url = url;
this.submenus = new ArrayList<>();
}
public void addSubmenu(AdminMenuItem submenu) {
this.submenus.add(submenu);
this.submenus.sort(null); // Sort by position
}
public String getMenuTitle() {
return menuTitle;
}
public void setMenuTitle(String menuTitle) {
this.menuTitle = menuTitle;
}
public String getCapability() {
return capability;
}
public void setCapability(String capability) {
this.capability = capability;
}
public String getMenuSlug() {
return menuSlug;
}
public void setMenuSlug(String menuSlug) {
this.menuSlug = menuSlug;
}
public String getIconUrl() {
return iconUrl;
}
public void setIconUrl(String iconUrl) {
this.iconUrl = iconUrl;
}
public int getPosition() {
return position;
}
public void setPosition(int position) {
this.position = position;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public List<AdminMenuItem> getSubmenus() {
return submenus;
}
public void setSubmenus(List<AdminMenuItem> submenus) {
this.submenus = submenus;
}
@Override
public int compareTo(AdminMenuItem o) {
return Integer.compare(this.position, o.position);
}
}
@@ -0,0 +1,135 @@
package com.sisvietnamvn.web.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* Service for managing dynamic admin menus, equivalent to WordPress's
* add_menu_page(), add_submenu_page(), etc.
*/
@Service
public class AdminMenuManager {
private static final Logger LOG = LoggerFactory.getLogger(AdminMenuManager.class);
private final List<AdminMenuItem> menuRegistry = new ArrayList<>();
private final AdminContext adminContext;
public AdminMenuManager(AdminContext adminContext) {
this.adminContext = adminContext;
}
/**
* Equivalent to add_menu_page()
*/
public void addMenuPage(String pageTitle, String menuTitle, String capability, String menuSlug, String iconUrl, int position) {
// Find if exists to avoid duplicates
if (getMenuBySlug(menuSlug).isPresent()) {
LOG.warn("Menu slug '{}' is already registered.", menuSlug);
return;
}
String url = "/manage/" + menuSlug;
AdminMenuItem item = new AdminMenuItem(menuTitle, capability, menuSlug, iconUrl, position, url);
// In WordPress, the main menu also acts as the first submenu.
// We will add it as a submenu with the same URL, but empty title to skip rendering duplicate if needed,
// or just let the UI handle the top link. For SB Admin 2, top level is just a dropdown trigger.
// We will add the main link as the first submenu item.
item.addSubmenu(new AdminMenuItem(pageTitle, capability, menuSlug, iconUrl, 0, url));
menuRegistry.add(item);
Collections.sort(menuRegistry);
LOG.debug("Added admin menu: {}", menuSlug);
}
/**
* Equivalent to add_submenu_page()
*/
public void addSubmenuPage(String parentSlug, String pageTitle, String menuTitle, String capability, String menuSlug, int position) {
Optional<AdminMenuItem> parentOpt = getMenuBySlug(parentSlug);
if (parentOpt.isPresent()) {
String url = "/manage/" + menuSlug;
// Some special WP cases like manage-settings actually map to /manage/settings/...
if (menuSlug.contains("/")) {
url = "/manage/" + menuSlug;
} else if (parentSlug.startsWith("manage-")) {
String base = parentSlug.replace("manage-", "");
url = "/manage/" + base + "/" + menuSlug;
}
AdminMenuItem subItem = new AdminMenuItem(menuTitle, capability, menuSlug, "", position, url);
parentOpt.get().addSubmenu(subItem);
LOG.debug("Added admin submenu: {} to parent: {}", menuSlug, parentSlug);
} else {
LOG.warn("Cannot add submenu '{}'. Parent menu '{}' not found.", menuSlug, parentSlug);
}
}
public void addSubmenuPage(String parentSlug, String pageTitle, String menuTitle, String capability, String menuSlug) {
addSubmenuPage(parentSlug, pageTitle, menuTitle, capability, menuSlug, 10);
}
// --- Helper Functions ---
public void addOptionsPage(String pageTitle, String menuTitle, String capability, String menuSlug) {
addSubmenuPage("manage-settings", pageTitle, menuTitle, capability, "settings/" + menuSlug);
}
public void addThemePage(String pageTitle, String menuTitle, String capability, String menuSlug) {
addSubmenuPage("manage-themes", pageTitle, menuTitle, capability, menuSlug);
}
public void addPluginsPage(String pageTitle, String menuTitle, String capability, String menuSlug) {
addSubmenuPage("manage-plugins", pageTitle, menuTitle, capability, menuSlug);
}
public void addUsersPage(String pageTitle, String menuTitle, String capability, String menuSlug) {
addSubmenuPage("manage-users", pageTitle, menuTitle, capability, menuSlug);
}
public void addDashboardPage(String pageTitle, String menuTitle, String capability, String menuSlug) {
addSubmenuPage("manage-dashboard", pageTitle, menuTitle, capability, menuSlug);
}
public void addManagementPage(String pageTitle, String menuTitle, String capability, String menuSlug) {
addSubmenuPage("manage-tools", pageTitle, menuTitle, capability, "tools/" + menuSlug);
}
// --- Internal & Rendering Helpers ---
private Optional<AdminMenuItem> getMenuBySlug(String slug) {
return menuRegistry.stream().filter(m -> m.getMenuSlug().equals(slug)).findFirst();
}
/**
* Gets all menus that the current user has access to.
*/
public List<AdminMenuItem> getAuthorizedMenus() {
return menuRegistry.stream()
.filter(menu -> adminContext.currentUserCan(menu.getCapability()))
.map(this::filterAuthorizedSubmenus)
.collect(Collectors.toList());
}
private AdminMenuItem filterAuthorizedSubmenus(AdminMenuItem menu) {
AdminMenuItem filteredMenu = new AdminMenuItem(
menu.getMenuTitle(), menu.getCapability(), menu.getMenuSlug(),
menu.getIconUrl(), menu.getPosition(), menu.getUrl()
);
List<AdminMenuItem> authorizedSubmenus = menu.getSubmenus().stream()
.filter(sub -> adminContext.currentUserCan(sub.getCapability()))
.collect(Collectors.toList());
filteredMenu.setSubmenus(authorizedSubmenus);
return filteredMenu;
}
}
@@ -0,0 +1,50 @@
package com.sisvietnamvn.web.security;
import java.util.Objects;
/**
* Represents the current admin screen context parsed from the request URI.
* Equivalent to WordPress's WP_Screen object (returned by get_current_screen()).
*/
public record AdminScreen(
String id, // e.g., "manage-posts"
String base, // e.g., "posts", "dashboard", "media"
String section, // e.g., "list", "edit", "new", "index"
Long entityId // e.g., 42 (if editing entity with ID 42)
) {
/**
* Checks if the screen matches a specific ID.
*/
public boolean is(String screenId) {
return Objects.equals(this.id, screenId);
}
/**
* Checks if the screen belongs to a specific base function.
*/
public boolean isBase(String baseName) {
return Objects.equals(this.base, baseName);
}
/**
* Checks if this is an editing screen.
*/
public boolean isEditing() {
return "edit".equals(this.section);
}
/**
* Checks if this is a creation screen.
*/
public boolean isCreating() {
return "new".equals(this.section);
}
/**
* Checks if this is a listing screen.
*/
public boolean isList() {
return "list".equals(this.section);
}
}
@@ -0,0 +1,82 @@
package com.sisvietnamvn.web.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import com.sisvietnamvn.web.service.SettingService;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Service for managing the Settings API, equivalent to WordPress's
* register_setting(), add_settings_section(), add_settings_field().
*/
@Service
public class AdminSettingsManager {
private static final Logger LOG = LoggerFactory.getLogger(AdminSettingsManager.class);
// Map: Page Slug -> List of Sections
private final Map<String, List<SettingsSection>> registry = new HashMap<>();
private final SettingService settingService;
public AdminSettingsManager(SettingService settingService) {
this.settingService = settingService;
}
/**
* Equivalent to add_settings_section()
*/
public void addSettingsSection(String page, String sectionId, String title, String description) {
registry.putIfAbsent(page, new ArrayList<>());
List<SettingsSection> sections = registry.get(page);
// Prevent duplicate sections
boolean exists = sections.stream().anyMatch(s -> s.getId().equals(sectionId));
if (!exists) {
sections.add(new SettingsSection(sectionId, title, description));
LOG.debug("Added settings section '{}' to page '{}'", sectionId, page);
}
}
/**
* Equivalent to add_settings_field()
*/
public void addSettingsField(String page, String sectionId, SettingsField field) {
List<SettingsSection> sections = registry.get(page);
if (sections != null) {
sections.stream()
.filter(s -> s.getId().equals(sectionId))
.findFirst()
.ifPresent(section -> {
section.addField(field);
LOG.debug("Added settings field '{}' to section '{}' on page '{}'", field.getId(), sectionId, page);
});
} else {
LOG.warn("Cannot add field '{}'. Page '{}' or Section '{}' not found.", field.getId(), page, sectionId);
}
}
/**
* Equivalent to register_setting()
* In Spring, settingService handles generic keys automatically, but this ensures a default value exists.
*/
public void registerSetting(String optionGroup, String optionName, String defaultValue) {
String existingValue = settingService.getValue(optionName, null);
if (existingValue == null && defaultValue != null) {
settingService.setValue(optionName, defaultValue);
LOG.debug("Registered setting '{}' with default value '{}'", optionName, defaultValue);
}
}
/**
* Helper for Thymeleaf to get all sections for a specific page.
*/
public List<SettingsSection> getSectionsForPage(String page) {
return registry.getOrDefault(page, new ArrayList<>());
}
}
@@ -0,0 +1,65 @@
package com.sisvietnamvn.web.security;
import com.sisvietnamvn.web.security.AdminMenuManager;
import com.sisvietnamvn.web.security.AdminSettingsManager;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Registers core WordPress-like settings using the dynamic Settings API on startup.
*/
@Component
public class CoreSettingsRegistrar {
private final AdminSettingsManager settingsManager;
private final AdminMenuManager menuManager;
public CoreSettingsRegistrar(AdminSettingsManager settingsManager, AdminMenuManager menuManager) {
this.settingsManager = settingsManager;
this.menuManager = menuManager;
}
@PostConstruct
public void initCoreSettings() {
// --- General Settings ---
String page = "general";
settingsManager.addSettingsSection(page, "default", "", "");
settingsManager.addSettingsField(page, "default",
new SettingsField("site_name", "Site Title", "text", null, ""));
settingsManager.addSettingsField(page, "default",
new SettingsField("tagline", "Tagline", "text", null, "In a few words, explain what this site is about."));
settingsManager.addSettingsField(page, "default",
new SettingsField("admin_email", "Administration Email Address", "email", null, "This address is used for admin purposes."));
Map<String, String> timezones = new LinkedHashMap<>();
timezones.put("UTC", "UTC");
timezones.put("Asia/Ho_Chi_Minh", "Asia/Ho Chi Minh");
timezones.put("America/New_York", "America/New York");
timezones.put("Europe/London", "Europe/London");
settingsManager.addSettingsField(page, "default",
new SettingsField("timezone", "Timezone", "select", timezones, ""));
Map<String, String> dateFormats = new LinkedHashMap<>();
dateFormats.put("F j, Y", "November 6, 2010 (F j, Y)");
dateFormats.put("Y-m-d", "2010-11-06 (Y-m-d)");
dateFormats.put("m/d/Y", "11/06/2010 (m/d/Y)");
dateFormats.put("d/m/Y", "06/11/2010 (d/m/Y)");
settingsManager.addSettingsField(page, "default",
new SettingsField("date_format", "Date Format", "radio", dateFormats, ""));
// Register default values for some
settingsManager.registerSetting(page, "site_name", "SIS Vietnam");
settingsManager.registerSetting(page, "timezone", "UTC");
settingsManager.registerSetting(page, "date_format", "F j, Y");
// --- Menu Registration for Modules ---
menuManager.addMenuPage("HTML Snippets", "Snippets", "manage_options", "snippets", "fas fa-code", 30);
}
}
@@ -79,7 +79,14 @@ public class DomainUserDetailsService implements UserDetailsService {
}
public static UserWithId fromUser(User user) {
List<GrantedAuthority> grantedAuthorities = Collections.singletonList(new SimpleGrantedAuthority(AuthoritiesConstants.PRE_AUTH_2FA));
List<GrantedAuthority> grantedAuthorities;
if (user.isUsing2FA()) {
grantedAuthorities = Collections.singletonList(new SimpleGrantedAuthority(AuthoritiesConstants.PRE_AUTH_2FA));
} else {
grantedAuthorities = user.getAuthorities().stream()
.map(authority -> (GrantedAuthority) new SimpleGrantedAuthority(authority.getName()))
.toList();
}
return new UserWithId(
user.getLogin(),
@@ -0,0 +1,62 @@
package com.sisvietnamvn.web.security;
import java.util.Map;
/**
* Represents a single setting field in the Admin Settings API.
*/
public class SettingsField {
private String id;
private String title;
private String type;
private Map<String, String> options;
private String description;
public SettingsField(String id, String title, String type, Map<String, String> options, String description) {
this.id = id;
this.title = title;
this.type = type;
this.options = options;
this.description = description;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public Map<String, String> getOptions() {
return options;
}
public void setOptions(Map<String, String> options) {
this.options = options;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
@@ -0,0 +1,57 @@
package com.sisvietnamvn.web.security;
import java.util.ArrayList;
import java.util.List;
/**
* Represents a section of settings fields in the Admin Settings API.
*/
public class SettingsSection {
private String id;
private String title;
private String description;
private List<SettingsField> fields;
public SettingsSection(String id, String title, String description) {
this.id = id;
this.title = title;
this.description = description;
this.fields = new ArrayList<>();
}
public void addField(SettingsField field) {
this.fields.add(field);
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public List<SettingsField> getFields() {
return fields;
}
public void setFields(List<SettingsField> fields) {
this.fields = fields;
}
}
@@ -0,0 +1,58 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.HtmlSnippet;
import com.sisvietnamvn.web.repository.HtmlSnippetRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
/**
* Service for managing HtmlSnippets and providing them to Thymeleaf.
*/
@Service("snippetService") // Named bean so it can be called from Thymeleaf via @snippetService
@Transactional
public class HtmlSnippetService {
private final HtmlSnippetRepository snippetRepository;
public HtmlSnippetService(HtmlSnippetRepository snippetRepository) {
this.snippetRepository = snippetRepository;
}
public List<HtmlSnippet> findAll() {
return snippetRepository.findAll();
}
public Optional<HtmlSnippet> findById(Long id) {
return snippetRepository.findById(id);
}
public HtmlSnippet save(HtmlSnippet snippet) {
return snippetRepository.save(snippet);
}
public void deleteById(Long id) {
snippetRepository.deleteById(id);
}
/**
* Gets the content of a snippet by slug.
* If the snippet is not found or is inactive, returns an empty string.
* Can be called in Thymeleaf using: ${@snippetService.getSnippetContent('slug')}
*
* @param slug the unique slug of the snippet
* @return the HTML content or empty string
*/
@Transactional(readOnly = true)
public String getSnippetContent(String slug) {
if (slug == null || slug.isEmpty()) {
return "";
}
return snippetRepository.findBySlug(slug)
.filter(HtmlSnippet::isActive)
.map(HtmlSnippet::getContent)
.orElse("");
}
}
@@ -203,6 +203,7 @@ public class UserService {
user.setImageUrl(userDTO.getImageUrl());
user.setActivated(userDTO.isActivated());
user.setLangKey(userDTO.getLangKey());
user.setUsing2FA(userDTO.isUsing2FA());
Set<Authority> managedAuthorities = user.getAuthorities();
managedAuthorities.clear();
userDTO
@@ -53,6 +53,8 @@ public class AdminUserDTO implements Serializable {
private Set<String> authorities;
private boolean using2FA = false;
public AdminUserDTO() {
// Empty constructor needed for Jackson.
}
@@ -71,6 +73,7 @@ public class AdminUserDTO implements Serializable {
this.lastModifiedBy = user.getLastModifiedBy();
this.lastModifiedDate = user.getLastModifiedDate();
this.authorities = user.getAuthorities().stream().map(Authority::getName).collect(Collectors.toSet());
this.using2FA = user.isUsing2FA();
}
public Long getId() {
@@ -177,6 +180,14 @@ public class AdminUserDTO implements Serializable {
this.authorities = authorities;
}
public boolean isUsing2FA() {
return using2FA;
}
public void setUsing2FA(boolean using2FA) {
this.using2FA = using2FA;
}
// prettier-ignore
@Override
public String toString() {