fix(file): 校验企业资质实际存储目录
This commit is contained in:
@@ -27,31 +27,25 @@ public class LocalQualificationStorage {
|
|||||||
private static final long MAX_SIZE = 5L * 1024 * 1024;
|
private static final long MAX_SIZE = 5L * 1024 * 1024;
|
||||||
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png");
|
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png");
|
||||||
private final Path root;
|
private final Path root;
|
||||||
|
private final Path qualificationRoot;
|
||||||
|
|
||||||
public LocalQualificationStorage(@Value("${baiyue.file.storage-root}") String root) {
|
public LocalQualificationStorage(@Value("${baiyue.file.storage-root}") String root) {
|
||||||
this.root = Path.of(root).toAbsolutePath().normalize();
|
this.root = Path.of(root).toAbsolutePath().normalize();
|
||||||
|
this.qualificationRoot = this.root.resolve("qualifications").normalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */
|
/** 服务启动时校验存储根目录,避免健康检查正常但首次上传才暴露挂载或权限问题。 */
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void initialize() {
|
public void initialize() {
|
||||||
Path probe = null;
|
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(root);
|
Files.createDirectories(root);
|
||||||
if (!Files.isDirectory(root) || !Files.isWritable(root)) {
|
Files.createDirectories(qualificationRoot);
|
||||||
|
if (!qualificationRoot.startsWith(root)
|
||||||
|
|| !Files.isDirectory(qualificationRoot) || !Files.isWritable(qualificationRoot)) {
|
||||||
throw new IOException("存储根目录不可写");
|
throw new IOException("存储根目录不可写");
|
||||||
}
|
}
|
||||||
probe = root.resolve(".qualification-storage-probe-" + UUID.randomUUID());
|
verifyWriteAccess();
|
||||||
Files.write(probe, new byte[] {0x42}, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
|
|
||||||
Files.delete(probe);
|
|
||||||
} catch (IOException exception) {
|
} catch (IOException exception) {
|
||||||
if (probe != null) {
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(probe);
|
|
||||||
} catch (IOException cleanupException) {
|
|
||||||
exception.addSuppressed(cleanupException);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LOGGER.error("企业资质存储根目录不可用:{}", root, exception);
|
LOGGER.error("企业资质存储根目录不可用:{}", root, exception);
|
||||||
throw new IllegalStateException("企业资质存储根目录不可用", exception);
|
throw new IllegalStateException("企业资质存储根目录不可用", exception);
|
||||||
}
|
}
|
||||||
@@ -67,7 +61,7 @@ public class LocalQualificationStorage {
|
|||||||
}
|
}
|
||||||
String extension = "image/png".equals(contentType) ? ".png" : ".jpg";
|
String extension = "image/png".equals(contentType) ? ".png" : ".jpg";
|
||||||
String fileName = UUID.randomUUID() + extension;
|
String fileName = UUID.randomUUID() + extension;
|
||||||
Path directory = root.resolve("qualifications").resolve(Long.toString(tenantId)).normalize();
|
Path directory = tenantDirectory(tenantId);
|
||||||
Path target = directory.resolve(fileName).normalize();
|
Path target = directory.resolve(fileName).normalize();
|
||||||
if (!target.startsWith(directory)) throw new BizException("Invalid upload path");
|
if (!target.startsWith(directory)) throw new BizException("Invalid upload path");
|
||||||
try {
|
try {
|
||||||
@@ -106,7 +100,7 @@ public class LocalQualificationStorage {
|
|||||||
|
|
||||||
public Resource load(long tenantId, String fileName) {
|
public Resource load(long tenantId, String fileName) {
|
||||||
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png)")) throw new BizException("Invalid file name");
|
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png)")) throw new BizException("Invalid file name");
|
||||||
Path directory = root.resolve("qualifications").resolve(Long.toString(tenantId)).normalize();
|
Path directory = tenantDirectory(tenantId);
|
||||||
Path target = directory.resolve(fileName).normalize();
|
Path target = directory.resolve(fileName).normalize();
|
||||||
if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw new BizException("File not found");
|
if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw new BizException("File not found");
|
||||||
try { return new UrlResource(target.toUri()); }
|
try { return new UrlResource(target.toUri()); }
|
||||||
@@ -117,5 +111,26 @@ public class LocalQualificationStorage {
|
|||||||
try { Files.deleteIfExists(file.path()); } catch (IOException ignored) { }
|
try { Files.deleteIfExists(file.path()); } catch (IOException ignored) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Path tenantDirectory(long tenantId) {
|
||||||
|
Path directory = qualificationRoot.resolve(Long.toString(tenantId)).normalize();
|
||||||
|
if (!directory.startsWith(qualificationRoot)) {
|
||||||
|
throw new BizException("Invalid upload path");
|
||||||
|
}
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证实际挂载目录可创建和删除文件,避免仅凭 isWritable 掩盖 Docker bind mount 权限或只读问题。 */
|
||||||
|
private void verifyWriteAccess() throws IOException {
|
||||||
|
Path probe = Files.createTempFile(qualificationRoot, ".file-service-probe-", ".tmp");
|
||||||
|
try {
|
||||||
|
Files.delete(probe);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
try { Files.deleteIfExists(probe); } catch (IOException cleanupException) {
|
||||||
|
exception.addSuppressed(cleanupException);
|
||||||
|
}
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record StoredFile(Path path, String fileName, String documentUrl) { }
|
public record StoredFile(Path path, String fileName, String documentUrl) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ class LocalQualificationStorageTest {
|
|||||||
@TempDir
|
@TempDir
|
||||||
Path storageRoot;
|
Path storageRoot;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void initializeVerifiesTheActualQualificationMountAndCleansUpItsProbe() throws Exception {
|
||||||
|
var storage = new LocalQualificationStorage(storageRoot.toString());
|
||||||
|
|
||||||
|
storage.initialize();
|
||||||
|
|
||||||
|
Path qualificationRoot = storageRoot.resolve("qualifications");
|
||||||
|
assertTrue(Files.isDirectory(qualificationRoot));
|
||||||
|
try (var files = Files.list(qualificationRoot)) {
|
||||||
|
assertEquals(0, files.count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void storeReturnsFileServiceUrlBackedByARealTenantFile() throws Exception {
|
void storeReturnsFileServiceUrlBackedByARealTenantFile() throws Exception {
|
||||||
var storage = new LocalQualificationStorage(storageRoot.toString());
|
var storage = new LocalQualificationStorage(storageRoot.toString());
|
||||||
@@ -60,7 +73,7 @@ class LocalQualificationStorageTest {
|
|||||||
void writeFailureReturnsServiceUnavailableInsteadOfBusinessError() throws Exception {
|
void writeFailureReturnsServiceUnavailableInsteadOfBusinessError() throws Exception {
|
||||||
var storage = new LocalQualificationStorage(storageRoot.toString());
|
var storage = new LocalQualificationStorage(storageRoot.toString());
|
||||||
storage.initialize();
|
storage.initialize();
|
||||||
Files.writeString(storageRoot.resolve("qualifications"), "阻止创建租户目录");
|
Files.writeString(storageRoot.resolve("qualifications").resolve("10001"), "阻止创建租户目录");
|
||||||
byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01};
|
byte[] png = {(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x01};
|
||||||
var upload = new MockMultipartFile("file", "license.png", "image/png", png);
|
var upload = new MockMultipartFile("file", "license.png", "image/png", png);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user