package com.yunhai.cms.controller.api; import com.yunhai.cms.service.impl.ThemeService; import com.yunhai.cms.theme.ThemeRegistry; import lombok.RequiredArgsConstructor; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; import org.springframework.http.ResponseEntity; 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; import javax.servlet.http.HttpServletRequest; import java.time.Duration; import java.util.List; /** * 主题管理接口(方案 §5.2,路径对齐 /api/{domain} 约定): * /api/theme/list 主题列表;/api/theme/{name}/asset/** 主题资产代理 * (screenshot 与预览资产共用,防穿越同 PortalAssetController,name 先过 Registry 白名单)。 * 走 /api/* CAS 鉴权(现状边界)。 */ @RestController @RequestMapping("/api/theme") @RequiredArgsConstructor public class ThemeController { private static final Duration ASSET_CACHE = Duration.ofHours(1); private final ThemeService themeService; private final ThemeRegistry themeRegistry; @GetMapping("/list") public List list() { return themeService.list(); } @GetMapping("/{name}/asset/**") public ResponseEntity asset(@PathVariable("name") String name, HttpServletRequest request) { if (!themeRegistry.contains(name)) { return ResponseEntity.notFound().build(); } String path = extractRelativePath(request, name); if (path == null || path.isEmpty() || path.contains("..")) { return ResponseEntity.badRequest().build(); } Resource resource = new ClassPathResource("templates/themes/" + name + "/assets/" + path); if (!resource.exists()) { return ResponseEntity.notFound().build(); } MediaType mediaType = MediaTypeFactory.getMediaType(path) .orElse(MediaType.APPLICATION_OCTET_STREAM); return ResponseEntity.ok() .contentType(mediaType) .cacheControl(CacheControl.maxAge(ASSET_CACHE).cachePublic()) .body(resource); } /** * 从请求 URI 截取 /asset/ 之后的相对路径(同 PortalAssetController: * 直接解析 URL-decode 后的 URI,兼容两种路径匹配器语义)。 */ private String extractRelativePath(HttpServletRequest request, String name) { String uri = request.getRequestURI(); String prefix = request.getContextPath() + "/api/theme/" + name + "/asset/"; int index = uri.indexOf(prefix); if (index < 0) { return null; } String relative = uri.substring(index + prefix.length()); try { relative = java.net.URLDecoder.decode(relative, "UTF-8"); } catch (java.io.UnsupportedEncodingException e) { return null; } return relative.replace('\\', '/'); } }