fix(file): 下载需登录,内联预览与缩略图默认公开
- /api/file/download/** 恒需登录,注册顺序排在公开模式之前 - 新增 file.public-preview(默认 true):内联预览 /api/file/*/* 与缩略图默认匿名 - file.public-dirs 保留为额外公开目录,语义限定在原图与缩略图 - 测试补三条:下载无白名单也需登录、内联默认公开、public-preview=false 时内联需登录
This commit is contained in:
@@ -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,25 +57,33 @@ 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();
|
||||
if (publicPreview) {
|
||||
List<String> patterns = new ArrayList<>(2 + publicDirs.size() * 2);
|
||||
patterns.add(PREVIEW_PATTERN);
|
||||
patterns.add(THUMBNAIL_PATTERN);
|
||||
patterns.addAll(publicDirPatterns());
|
||||
return patterns;
|
||||
}
|
||||
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;
|
||||
return publicDirPatterns();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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]))
|
||||
|
||||
@@ -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/**"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user