fix(file): 下载需登录,内联预览与缩略图默认公开

- /api/file/download/** 恒需登录,注册顺序排在公开模式之前
- 新增 file.public-preview(默认 true):内联预览 /api/file/*/* 与缩略图默认匿名
- file.public-dirs 保留为额外公开目录,语义限定在原图与缩略图
- 测试补三条:下载无白名单也需登录、内联默认公开、public-preview=false 时内联需登录
This commit is contained in:
Codex
2026-11-03 22:26:52 +08:00
parent b8c9c6c853
commit d65e8453f5
4 changed files with 127 additions and 49 deletions
@@ -13,9 +13,13 @@ import java.util.List;
* 文件访问规则:{@code /api/file/**} 下哪些路径可以匿名读取。
*
* <p>改造前整段 {@code GET /api/file/**} 都是 {@code permitAll},盖章证明材料
* 这类私人文件可以被未登录者按 URL 直接取走。现在默认全部需要登录,只有
* {@code file.public-dirs} 里显式登记的目录(原图与缩略图)才放行;下载接口
* {@code /api/file/download/**} 不在放行之列——"取走原件"一律要登录。
* 这类私人文件可以被未登录者按 URL 直接取走。现在按三类动作分开:
* <ul>
* <li>下载 {@code /api/file/download/**}:一律需要登录——"取走原件"不再是匿名动作;</li>
* <li>内联预览 {@code /api/file/{dir}/{name}} 与缩略图 {@code /api/file/thumbnail/**}:
* 默认公开({@code file.public-preview=true}),否则现有公开页面的配图会整片变成需要 token;</li>
* <li>其余文件接口:默认需要登录;命中 {@code file.public-dirs} 的目录额外公开。</li>
* </ul>
*
* <p>规则以字符串模式产出,交给 {@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,26 +57,34 @@ public class FileAccessRules {
*/
private final List<String> 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 模式,白名单为空时返回空列表
* <p>内联公开时是预览 + 缩略图;否则是 {@code file.public-dirs} 里的目录(原图与缩略图)。
*
* @return antMatchers 模式,没有任何公开项时返回空列表
*/
public List<String> permitAllPatterns() {
if (publicDirs.isEmpty()) {
return Collections.emptyList();
}
List<String> patterns = new ArrayList<>(publicDirs.size() * 2);
for (String dir : publicDirs) {
patterns.add(FILE_PATH_PREFIX + "/" + dir + "/**");
patterns.add(FILE_PATH_PREFIX + "/thumbnail/" + dir + "/**");
}
if (publicPreview) {
List<String> patterns = new ArrayList<>(2 + publicDirs.size() * 2);
patterns.add(PREVIEW_PATTERN);
patterns.add(THUMBNAIL_PATTERN);
patterns.addAll(publicDirPatterns());
return patterns;
}
return publicDirPatterns();
}
/**
* 需要登录才能访问的文件路径模式(兜底规则,注册时必须排在 {@code GET /**} 之前)。
@@ -68,11 +95,20 @@ public class FileAccessRules {
return Collections.singletonList(FILE_PATH_PREFIX + "/**");
}
/**
* 下载接口的路径模式:必须在公开模式之前注册,保证下载永远要登录。
*
* @return antMatchers 模式
*/
public List<String> 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<String> publicDirPatterns() {
if (publicDirs.isEmpty()) {
return Collections.emptyList();
}
List<String> 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<String> normalize(String raw) {
if (StrUtil.isBlank(raw)) {
return Collections.emptyList();
@@ -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<String> downloadPatterns = fileAccessRules.downloadPatterns();
if (!downloadPatterns.isEmpty()) {
urlRegistry.antMatchers(HttpMethod.GET, downloadPatterns.toArray(new String[0]))
.authenticated();
}
List<String> publicFilePatterns = fileAccessRules.permitAllPatterns();
if (!publicFilePatterns.isEmpty()) {
urlRegistry.antMatchers(HttpMethod.GET, publicFilePatterns.toArray(new String[0]))
+11 -7
View File
@@ -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/<dir>/** 与缩略图 /api/file/thumbnail/<dir>/** 允许匿名读取;
# 下载接口 /api/file/download/** 永远需要登录。留空 = 所有文件都需要登录。
# 文件访问规则(spec #58)
# 下载接口 /api/file/download/** 永远需要登录,「取走原件」不再匿名。
#
# 注意:当前上传一律落在 /yyyyMMdd/ 日期目录(见 FileStorageService.store),
# 目录本身不区分公开/私有,所以生产要放开公开站点图片,只能按日期目录逐条登记,
# 或后续给上传链路加语义前缀。不要图省事写成 /api/file/**。
# 确需公开的内联资源(预览 /api/file/<dir>/<name> 与缩略图 /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:
@@ -11,54 +11,65 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* 文件访问规则(spec #58 API 契约、用户故事 14)。
*
* <p>改造前 {@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<String> 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/**"));
}
}