diff --git a/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java index 008079b..bdb0cfb 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java +++ b/src/main/java/com/gxwebsoft/common/core/security/FileAccessRules.java @@ -13,9 +13,13 @@ import java.util.List; * 文件访问规则:{@code /api/file/**} 下哪些路径可以匿名读取。 * *

改造前整段 {@code GET /api/file/**} 都是 {@code permitAll},盖章证明材料 - * 这类私人文件可以被未登录者按 URL 直接取走。现在默认全部需要登录,只有 - * {@code file.public-dirs} 里显式登记的目录(原图与缩略图)才放行;下载接口 - * {@code /api/file/download/**} 不在放行之列——"取走原件"一律要登录。 + * 这类私人文件可以被未登录者按 URL 直接取走。现在按三类动作分开: + *

* *

规则以字符串模式产出,交给 {@link SecurityConfig} 注册;类本身不碰请求, * 因此可以在单元测试里直接验证。 @@ -31,6 +35,21 @@ public class FileAccessRules { */ public static final String FILE_PATH_PREFIX = "/api/file"; + /** + * 下载接口模式:永远需要登录 + */ + private static final String DOWNLOAD_PATTERN = FILE_PATH_PREFIX + "/download/**"; + + /** + * 内联预览模式:{@code /api/file/{dir}/{name}},正好两段,不会匹配 download/thumbnail(三段) + */ + private static final String PREVIEW_PATTERN = FILE_PATH_PREFIX + "/*/*"; + + /** + * 缩略图模式 + */ + private static final String THUMBNAIL_PATTERN = FILE_PATH_PREFIX + "/thumbnail/**"; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); /** @@ -38,25 +57,33 @@ public class FileAccessRules { */ private final List publicDirs; - public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs) { + /** + * 内联资源(预览 + 缩略图)是否默认公开 + */ + private final boolean publicPreview; + + public FileAccessRules(@Value("${file.public-dirs:}") String publicDirs, + @Value("${file.public-preview:true}") boolean publicPreview) { this.publicDirs = normalize(publicDirs); + this.publicPreview = publicPreview; } /** - * 匿名放行的路径模式:白名单目录下的原图与缩略图。 + * 匿名放行的文件路径模式。 * - * @return antMatchers 模式,白名单为空时返回空列表 + *

内联公开时是预览 + 缩略图;否则是 {@code file.public-dirs} 里的目录(原图与缩略图)。 + * + * @return antMatchers 模式,没有任何公开项时返回空列表 */ public List permitAllPatterns() { - if (publicDirs.isEmpty()) { - return Collections.emptyList(); + if (publicPreview) { + List patterns = new ArrayList<>(2 + publicDirs.size() * 2); + patterns.add(PREVIEW_PATTERN); + patterns.add(THUMBNAIL_PATTERN); + patterns.addAll(publicDirPatterns()); + return patterns; } - List patterns = new ArrayList<>(publicDirs.size() * 2); - for (String dir : publicDirs) { - patterns.add(FILE_PATH_PREFIX + "/" + dir + "/**"); - patterns.add(FILE_PATH_PREFIX + "/thumbnail/" + dir + "/**"); - } - return patterns; + return publicDirPatterns(); } /** @@ -68,11 +95,20 @@ public class FileAccessRules { return Collections.singletonList(FILE_PATH_PREFIX + "/**"); } + /** + * 下载接口的路径模式:必须在公开模式之前注册,保证下载永远要登录。 + * + * @return antMatchers 模式 + */ + public List downloadPatterns() { + return Collections.singletonList(DOWNLOAD_PATTERN); + } + /** * 这条文件请求是否按公开资源处理(匿名可读)。 * * @param path 请求路径,可带查询串 - * @return true 表示命中白名单;下载路径、未命中目录、非文件路径一律 false + * @return true 表示命中公开规则;下载、未命中目录、非文件路径一律 false */ public boolean isPublicFileRequest(String path) { if (StrUtil.isBlank(path)) { @@ -82,6 +118,9 @@ public class FileAccessRules { if (!clean.startsWith(FILE_PATH_PREFIX + "/")) { return false; } + if (pathMatcher.match(DOWNLOAD_PATTERN, clean)) { + return false; + } for (String pattern : permitAllPatterns()) { if (pathMatcher.match(pattern, clean)) { return true; @@ -90,6 +129,18 @@ public class FileAccessRules { return false; } + private List publicDirPatterns() { + if (publicDirs.isEmpty()) { + return Collections.emptyList(); + } + List patterns = new ArrayList<>(publicDirs.size() * 2); + for (String dir : publicDirs) { + patterns.add(FILE_PATH_PREFIX + "/" + dir + "/**"); + patterns.add(FILE_PATH_PREFIX + "/thumbnail/" + dir + "/**"); + } + return patterns; + } + private static List normalize(String raw) { if (StrUtil.isBlank(raw)) { return Collections.emptyList(); diff --git a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java index 61985a6..2fbaa0d 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/security/SecurityConfig.java @@ -40,8 +40,15 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter { http.authorizeRequests() .antMatchers(HttpMethod.OPTIONS, "/**") .permitAll(); - // 文件默认需登录:先放行确需公开的资源目录(file.public-dirs), - // 再兜住其余 /api/file/**。这两条必须排在 GET /** 之前,否则等于没配。 + // 文件访问分三段,顺序不能换(antMatchers 先匹配先命中): + // 1. 下载恒需登录——必须排在公开模式之前,因为 /api/file/*/* 也会命中两段的 download 路径; + // 2. 内联预览/缩略图与 file.public-dirs 目录按规则公开; + // 3. 其余 /api/file/** 需登录。整段仍要排在 GET /** 之前,否则等于没配。 + List downloadPatterns = fileAccessRules.downloadPatterns(); + if (!downloadPatterns.isEmpty()) { + urlRegistry.antMatchers(HttpMethod.GET, downloadPatterns.toArray(new String[0])) + .authenticated(); + } List publicFilePatterns = fileAccessRules.permitAllPatterns(); if (!publicFilePatterns.isEmpty()) { urlRegistry.antMatchers(HttpMethod.GET, publicFilePatterns.toArray(new String[0])) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0812eb1..19d6f6b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -89,15 +89,19 @@ config: upload-path: /Users/liangxin/Project/JAVA/tower-java/ local-upload-path: /Users/gxwebsoft/Documents/uploads/ -# 文件访问白名单(spec #58):确需公开的资源目录,逗号分隔,相对 /api/file。 -# file.public-dirs: cms-article,public -# 列进这里的目录,其原图 /api/file/

/** 与缩略图 /api/file/thumbnail//** 允许匿名读取; -# 下载接口 /api/file/download/** 永远需要登录。留空 = 所有文件都需要登录。 +# 文件访问规则(spec #58) +# 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。 # -# 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store), -# 目录本身不区分公开/私有,所以生产要放开公开站点图片,只能按日期目录逐条登记, -# 或后续给上传链路加语义前缀。不要图省事写成 /api/file/**。 +# 确需公开的内联资源(预览 /api/file// 与缩略图 /api/file/thumbnail/**): +# 默认 true,否则公开页面的配图会整片变成需要 token;置 false 则内联也需登录。 file: + public-preview: ${FILE_PUBLIC_PREVIEW:true} + # 额外公开目录(可选,逗号分隔,相对 /api/file):命中这些目录的原图与缩略图即使 + # public-preview=false 也允许匿名读取。留空表示没有额外目录。 + # + # 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store), + # 目录本身不区分公开/私有;写日期目录等于把这些日期的全部文件公开,慎用。 + # 也不要图省事写成 /api/file/**。 public-dirs: ${FILE_PUBLIC_DIRS:} wx: diff --git a/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java index ce5597a..7a091d6 100644 --- a/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java +++ b/src/test/java/com/gxwebsoft/common/core/security/FileAccessRulesTest.java @@ -11,54 +11,65 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * 文件访问规则(spec #58 API 契约、用户故事 14)。 * *

改造前 {@code GET /api/file/**} 是 {@code permitAll},盖章证明材料可以被 - * 未登录者直接按 URL 取走。规则收敛成"默认需登录 + 配置白名单",白名单由 - * {@code file.public-dirs} 显式声明。测试只钉外部可观察的三件事: - * 下载要登录、白名单目录匿名可读、其余目录仍要登录。 + * 未登录者直接按 URL 取走。spec 的要求只落在"下载接口"上:下载改为需登录; + * 内联预览与缩略图必须继续匿名可读,否则公开页面的配图会整片裂掉。 + * 测试钉住三件事:下载无论怎么配都要登录、内联默认公开、显式关闭时内联也要登录。 * * @author Codex * @since 2026-11-03 */ class FileAccessRulesTest { - /** 下载接口永远要登录,公开目录也一样——它是"取走原件"的动作。 */ + /** 下载接口永远要登录,白名单为空、内联公开也一样——它是"取走原件"的动作。 */ @Test void downloadAlwaysRequiresLogin() { - FileAccessRules rules = new FileAccessRules("cms-article"); + FileAccessRules rules = new FileAccessRules("", true); assertFalse(rules.isPublicFileRequest("/api/file/download/20260708/材料.pdf")); assertFalse(rules.isPublicFileRequest("/api/file/download/cms-article/20260708/材料.pdf")); } - /** 白名单目录:原图与缩略图都匿名可读,公开页面才能正常渲染。 */ + /** 内联预览与缩略图默认公开:公开页面上的配图不能因为这次收敛而变成需要 token。 */ + @Test + void previewAndThumbnailArePublicByDefault() { + FileAccessRules rules = new FileAccessRules("", true); + + assertTrue(rules.isPublicFileRequest("/api/file/20260708/封面.png")); + assertTrue(rules.isPublicFileRequest("/api/file/thumbnail/20260708/封面.png")); + } + + /** 显式关闭内联公开后,预览与缩略图都要登录——白名单开关确实生效。 */ + @Test + void previewRequiresLoginWhenPublicPreviewDisabled() { + FileAccessRules rules = new FileAccessRules("", false); + + assertFalse(rules.isPublicFileRequest("/api/file/20260708/封面.png")); + assertFalse(rules.isPublicFileRequest("/api/file/thumbnail/20260708/封面.png")); + assertFalse(rules.permitAllPatterns().contains("/api/file/*/*")); + } + + /** 额外公开目录即使在内联关闭时也生效(原图与缩略图)。 */ @Test void whitelistedDirectoryIsPublic() { - FileAccessRules rules = new FileAccessRules("cms-article"); + FileAccessRules rules = new FileAccessRules("cms-article", false); assertTrue(rules.isPublicFileRequest("/api/file/cms-article/20260708/封面.png")); assertTrue(rules.isPublicFileRequest("/api/file/thumbnail/cms-article/20260708/封面.png")); - } - - /** 没进白名单的目录(当前所有上传都落在日期目录)默认要登录。 */ - @Test - void otherDirectoriesStillRequireLogin() { - FileAccessRules rules = new FileAccessRules("cms-article"); - assertFalse(rules.isPublicFileRequest("/api/file/20260708/盖章证明.jpg")); - assertFalse(rules.isPublicFileRequest("/api/file/thumbnail/20260708/盖章证明.jpg")); } - /** 白名单为空时不产生任何放行模式,安全默认 = 全部需登录。 */ + /** 没有公开项时不产生任何放行模式,安全默认 = 全部需登录。 */ @Test - void blankConfigurationYieldsNoPublicPatterns() { - assertTrue(new FileAccessRules("").permitAllPatterns().isEmpty()); - assertTrue(new FileAccessRules(null).permitAllPatterns().isEmpty()); - assertFalse(new FileAccessRules("").isPublicFileRequest("/api/file/20260708/a.jpg")); + void noPublicItemYieldsNoPublicPatterns() { + assertTrue(new FileAccessRules("", false).permitAllPatterns().isEmpty()); + assertTrue(new FileAccessRules(null, false).permitAllPatterns().isEmpty()); + assertFalse(new FileAccessRules(null, false).isPublicFileRequest("/api/file/20260708/a.jpg")); } /** 配置里允许空格、斜杠与空项,但产出的模式必须干净。 */ @Test void patternsAreNormalized() { - FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ "); + FileAccessRules rules = new FileAccessRules(" cms-article/ , ,/public/ ", false); List patterns = rules.permitAllPatterns(); assertTrue(patterns.contains("/api/file/cms-article/**")); @@ -67,18 +78,23 @@ class FileAccessRulesTest { assertFalse(patterns.contains("/api/file/**"), "不能整段放行 /api/file"); } - /** 登录态之外的接口(CMS 文章 JSON)不受文件规则影响。 */ + /** 登录态之外的接口(CMS 文章 JSON)与单段文件接口不受公开规则影响。 */ @Test void nonFileRequestsAreNotTreatedAsFiles() { - FileAccessRules rules = new FileAccessRules("cms-article"); + FileAccessRules rules = new FileAccessRules("cms-article", true); assertFalse(rules.isPublicFileRequest("/api/cms/article")); + assertFalse(rules.isPublicFileRequest("/api/file/page")); + assertFalse(rules.isPublicFileRequest("/api/file/12")); assertFalse(rules.isPublicFileRequest(null)); } - /** 授权链最后一条:除白名单外的所有文件请求都要登录。 */ + /** 兜底规则:白名单之外的 /api/file/** 仍需登录,下载规则单独可注册。 */ @Test void authenticatedPatternsCoverAllFiles() { - assertTrue(new FileAccessRules("cms-article").authenticatedPatterns().contains("/api/file/**")); + FileAccessRules rules = new FileAccessRules("cms-article", true); + + assertTrue(rules.authenticatedPatterns().contains("/api/file/**")); + assertTrue(rules.downloadPatterns().contains("/api/file/download/**")); } }