FileStorageServiceImpl.java
package org.darkroomlibrary.service.impl;
import org.darkroomlibrary.context.CurrentUserContext;
import org.darkroomlibrary.mapper.StoredFileMapper;
import org.darkroomlibrary.web.response.ApiResponse;
import org.darkroomlibrary.web.response.PageResponse;
import org.darkroomlibrary.web.dto.query.StoredFilePageQuery;
import org.darkroomlibrary.domain.type.FileReferenceType;
import org.darkroomlibrary.domain.type.StoredFileStatus;
import org.darkroomlibrary.domain.type.UserRole;
import org.darkroomlibrary.domain.model.StoredFile;
import org.darkroomlibrary.web.view.StoredFileView;
import org.darkroomlibrary.service.FileStorageService;
import org.darkroomlibrary.utils.FileIdGenerator;
import org.darkroomlibrary.utils.TransactionCallbacks;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ContentDisposition;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Slf4j
@Service
public class FileStorageServiceImpl implements FileStorageService {
private static final long MB = 1024L * 1024L;
private static final int CLEANUP_BATCH_SIZE = 200;
private static final int DELETION_LEASE_MINUTES = 10;
private static final Pattern SAFE_FILE_NAME =
Pattern.compile("^[a-fA-F0-9]{32}\\.[a-z0-9]{2,5}$");
private static final Pattern FILE_NAME_IN_URL =
Pattern.compile("(?:[?&]fileName=)([a-fA-F0-9]{32}\\.[a-z0-9]{2,5})(?:[&#\\\"']|$)");
private static final Set<String> OCTET_STREAM_TYPES = Set.of("", "application/octet-stream");
private static final Map<String, FileRule> FILE_RULES = new HashMap<>();
static {
putRule(".jpg", 5 * MB, true, "image/jpeg");
putRule(".jpeg", 5 * MB, true, "image/jpeg");
putRule(".png", 5 * MB, true, "image/png");
putRule(".gif", 5 * MB, true, "image/gif");
putRule(".bmp", 5 * MB, true, "image/bmp", "image/x-ms-bmp");
putRule(".webp", 5 * MB, true, "image/webp");
putRule(".mp4", 10 * MB, true, "video/mp4", "application/mp4");
putRule(".avi", 10 * MB, true, "video/x-msvideo", "video/avi", "video/msvideo");
putRule(".mov", 10 * MB, true, "video/quicktime");
putRule(".wmv", 10 * MB, true, "video/x-ms-wmv");
putRule(".pdf", 10 * MB, false, "application/pdf");
putRule(".doc", 10 * MB, false, "application/msword", "application/vnd.ms-word");
putRule(".docx", 10 * MB, false,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
putRule(".xls", 10 * MB, false, "application/vnd.ms-excel");
putRule(".xlsx", 10 * MB, false,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
putRule(".ppt", 10 * MB, false, "application/vnd.ms-powerpoint");
putRule(".pptx", 10 * MB, false,
"application/vnd.openxmlformats-officedocument.presentationml.presentation");
putRule(".html", 1 * MB, false, "text/html", "application/xhtml+xml");
putRule(".htm", 1 * MB, false, "text/html", "application/xhtml+xml");
}
@Value("${app.api-prefix}")
private String apiPrefix;
@Value("${file.upload-dir:./upload/pic}")
private String uploadDir;
@Value("${file.temp-retention-hours:24}")
private long temporaryRetentionHours;
@Resource
private StoredFileMapper storedFileMapper;
@Resource
private PlatformTransactionManager transactionManager;
@Override
public ApiResponse<String> upload(MultipartFile multipartFile) {
if (multipartFile == null || multipartFile.isEmpty()) {
return ApiResponse.error("文件不能为空");
}
String originalFilename = safeOriginalFilename(multipartFile.getOriginalFilename());
if (originalFilename == null || !originalFilename.contains(".")) {
return ApiResponse.error("文件类型不支持");
}
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
FileRule rule = FILE_RULES.get(extension);
if (rule == null) {
return ApiResponse.error("不支持的文件类型: " + extension);
}
if (multipartFile.getSize() > rule.maxSize) {
return ApiResponse.error("文件大小不能超过 " + (rule.maxSize / MB) + "MB");
}
if (!rule.allowsContentType(multipartFile.getContentType())) {
return ApiResponse.error("文件 MIME 类型与扩展名不匹配");
}
try {
if (!hasExpectedFileSignature(multipartFile, extension)) {
return ApiResponse.error("文件内容与扩展名不匹配");
}
} catch (IOException e) {
return ApiResponse.error("文件读取异常");
}
String fileName = FileIdGenerator.nextId() + extension;
Path target = resolveTarget(fileName);
if (target == null) {
return ApiResponse.error("文件名不合法");
}
LocalDateTime now = LocalDateTime.now();
try {
Files.createDirectories(target.getParent());
multipartFile.transferTo(target.toFile());
StoredFile storedFile = StoredFile.builder()
.fileName(fileName)
.originalName(originalFilename)
.extension(extension.substring(1))
.contentType(normalizeContentType(multipartFile.getContentType()))
.fileSize(multipartFile.getSize())
.uploaderId(CurrentUserContext.userId())
.status(StoredFileStatus.TEMPORARY.getStatus())
.createTime(now)
.updateTime(now)
.build();
if (storedFileMapper.insert(storedFile) != 1) {
throw new IllegalStateException("文件元数据写入失败");
}
String endpoint = rule.temporaryPublicPreview ? "/file/public" : "/file/download";
return ApiResponse.success("上传成功", apiPrefix + endpoint + "?fileName=" + fileName);
} catch (Exception e) {
try {
Files.deleteIfExists(target);
} catch (IOException cleanupError) {
log.warn("上传失败后的磁盘清理失败: fileName={}", fileName, cleanupError);
}
log.warn("文件上传失败: originalName={}, error={}", originalFilename, e.getMessage());
return ApiResponse.error("文件上传异常");
}
}
@Override
public boolean bindSingle(String fileUrl, FileReferenceType refType, Integer refId) {
if (refType == null || refId == null) {
return false;
}
String fileName = extractFileName(fileUrl);
if (fileName == null) {
releaseReference(refType, refId);
return fileUrl == null || fileUrl.isBlank();
}
StoredFile target = storedFileMapper.selectById(fileName);
if (target == null) {
log.info("业务引用使用旧文件,跳过元数据绑定: fileName={}, refType={}, refId={}",
fileName, refType.getValue(), refId);
return true;
}
if (!canBind(target, refType, refId)) {
return false;
}
List<StoredFile> existing = storedFileMapper.findByReference(refType.getValue(), refId);
List<StoredFile> stale = existing.stream()
.filter(file -> !fileName.equals(file.getFileName()))
.collect(Collectors.toList());
markDeletePending(stale);
return bindManagedFile(fileName, refType, refId);
}
@Override
public boolean bindFromHtml(String html, FileReferenceType refType, Integer refId) {
if (refType == null || refId == null) {
return false;
}
Set<String> desired = extractFileNames(html);
for (String fileName : desired) {
StoredFile file = storedFileMapper.selectById(fileName);
if (file != null && !canBind(file, refType, refId)) {
return false;
}
}
List<StoredFile> existing = storedFileMapper.findByReference(refType.getValue(), refId);
List<StoredFile> stale = existing.stream()
.filter(file -> !desired.contains(file.getFileName()))
.collect(Collectors.toList());
markDeletePending(stale);
for (String fileName : desired) {
if (storedFileMapper.selectById(fileName) != null
&& !bindManagedFile(fileName, refType, refId)) {
return false;
}
}
return true;
}
@Override
public void releaseReference(FileReferenceType refType, Integer refId) {
if (refType == null || refId == null) {
return;
}
markDeletePending(storedFileMapper.findByReference(refType.getValue(), refId));
}
@Override
public void releaseReferences(FileReferenceType refType, List<Integer> refIds) {
if (refIds == null) {
return;
}
for (Integer refId : refIds) {
releaseReference(refType, refId);
}
}
@Override
public void releaseUserBusinessFiles(List<Integer> userIds) {
if (userIds == null || userIds.isEmpty()) {
return;
}
markDeletePending(storedFileMapper.findUserBusinessFiles(userIds));
}
@Override
public String toDownloadUrl(String fileUrl) {
String fileName = extractFileName(fileUrl);
return fileName == null ? fileUrl : apiPrefix + "/file/download?fileName=" + fileName;
}
@Override
public void writePublicFile(String fileName, HttpServletResponse response) throws IOException {
FileRule rule = validateRequestFile(fileName, response);
if (rule == null) {
return;
}
StoredFile storedFile = storedFileMapper.selectById(fileName);
if (!isPubliclyAccessible(storedFile, rule)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
write(fileName, storedFile, rule.responseContentType, false, response);
}
@Override
public void writeDownload(String fileName, HttpServletResponse response) throws IOException {
FileRule rule = validateRequestFile(fileName, response);
if (rule == null) {
return;
}
StoredFile storedFile = storedFileMapper.selectById(fileName);
if (!canDownload(storedFile)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
write(fileName, storedFile, "application/octet-stream", true, response);
}
@Override
public ApiResponse<List<StoredFileView>> query(StoredFilePageQuery dto) {
List<StoredFileView> files = storedFileMapper.query(dto);
for (StoredFileView file : files) {
Path target = resolveTarget(file.getFileName());
file.setDiskExists(target != null && Files.isRegularFile(target));
FileRule rule = FILE_RULES.get("." + file.getExtension().toLowerCase());
boolean publicPreview = isPubliclyAccessible(
file.getStatus(), file.getRefType(), rule);
String endpoint = publicPreview ? "/file/public" : "/file/download";
file.setAccessUrl(apiPrefix + endpoint + "?fileName=" + file.getFileName());
}
return PageResponse.success(files, storedFileMapper.queryCount(dto));
}
@Override
public ApiResponse<Void> deleteUnbound(String fileName) {
if (!isSafeFileName(fileName)) {
return ApiResponse.error("文件名不合法");
}
StoredFile storedFile = storedFileMapper.selectById(fileName);
if (storedFile == null) {
return ApiResponse.error("文件记录不存在");
}
if (Objects.equals(storedFile.getStatus(), StoredFileStatus.BOUND.getStatus())) {
return ApiResponse.error("文件仍被业务引用,不能直接删除");
}
StoredFile claimed = claimUnboundForDeletion(fileName, LocalDateTime.now());
if (claimed == null) {
return ApiResponse.error("文件状态已变化,请刷新后重试");
}
return deleteStoredFile(claimed)
? ApiResponse.success("文件已删除")
: ApiResponse.error("文件删除失败,已保留待重试记录");
}
@Override
public ApiResponse<Map<String, Object>> cleanupNow() {
LocalDateTime now = LocalDateTime.now();
LocalDateTime cutoff = now.minusHours(temporaryRetentionHours);
LocalDateTime deletingLeaseCutoff = now.minusMinutes(DELETION_LEASE_MINUTES);
List<StoredFile> candidates = storedFileMapper.findCleanupCandidates(
cutoff, deletingLeaseCutoff, CLEANUP_BATCH_SIZE);
int metadataDeleted = 0;
for (StoredFile candidate : candidates) {
StoredFile claimed = claimForCleanup(
candidate.getFileName(), cutoff, deletingLeaseCutoff, LocalDateTime.now());
if (claimed != null && deleteStoredFile(claimed)) {
metadataDeleted++;
}
}
int diskOrphansDeleted = 0;
Path root = uploadRoot();
if (Files.isDirectory(root)) {
try (Stream<Path> paths = Files.list(root)) {
List<Path> orphanCandidates = paths
.filter(Files::isRegularFile)
.filter(path -> isSafeFileName(path.getFileName().toString()))
.limit(CLEANUP_BATCH_SIZE)
.collect(Collectors.toList());
for (Path path : orphanCandidates) {
String fileName = path.getFileName().toString();
if (storedFileMapper.selectById(fileName) != null || !isOlderThan(path, cutoff)) {
continue;
}
Integer references = storedFileMapper.countLegacyReferences(fileName);
if (references != null && references > 0) {
continue;
}
if (Files.deleteIfExists(path)) {
diskOrphansDeleted++;
}
}
} catch (IOException e) {
log.warn("扫描孤儿文件失败: {}", e.getMessage());
}
}
Map<String, Object> result = new HashMap<>();
result.put("metadataDeleted", metadataDeleted);
result.put("diskOrphansDeleted", diskOrphansDeleted);
result.put("temporaryRetentionHours", temporaryRetentionHours);
result.put("deletionLeaseMinutes", DELETION_LEASE_MINUTES);
return ApiResponse.success(result);
}
@Scheduled(cron = "${file.cleanup-cron:0 30 3 * * ?}")
public void scheduledCleanup() {
ApiResponse<Map<String, Object>> result = cleanupNow();
log.info("文件清理完成: {}", result.getData());
}
private boolean bindManagedFile(String fileName, FileReferenceType refType, Integer refId) {
int updated = storedFileMapper.bind(
fileName, CurrentUserContext.userId(), refType.getValue(), refId, LocalDateTime.now());
return updated > 0;
}
private boolean canBind(StoredFile file, FileReferenceType refType, Integer refId) {
if (Objects.equals(file.getStatus(), StoredFileStatus.TEMPORARY.getStatus())) {
return Objects.equals(file.getUploaderId(), CurrentUserContext.userId());
}
return Objects.equals(file.getStatus(), StoredFileStatus.BOUND.getStatus())
&& Objects.equals(file.getRefType(), refType.getValue())
&& Objects.equals(file.getRefId(), refId);
}
private void markDeletePending(List<StoredFile> files) {
if (files == null || files.isEmpty()) {
return;
}
List<String> names = files.stream().map(StoredFile::getFileName).collect(Collectors.toList());
if (storedFileMapper.markDeletePending(names, LocalDateTime.now()) != names.size()) {
throw new IllegalStateException("文件引用状态已变化");
}
for (String fileName : names) {
TransactionCallbacks.afterCommit(() -> {
LocalDateTime now = LocalDateTime.now();
StoredFile claimed = claimForCleanup(
fileName,
now.minusHours(temporaryRetentionHours),
now.minusMinutes(DELETION_LEASE_MINUTES),
now);
if (claimed != null) {
deleteStoredFile(claimed);
}
});
}
}
private boolean deleteStoredFile(StoredFile storedFile) {
try {
Path target = resolveTarget(storedFile.getFileName());
if (target != null) {
Files.deleteIfExists(target);
}
return deleteMetadataInNewTransaction(storedFile.getFileName());
} catch (Exception e) {
log.warn("文件删除失败,等待下次重试: fileName={}, error={}",
storedFile.getFileName(), e.getMessage());
return false;
}
}
private StoredFile claimUnboundForDeletion(String fileName, LocalDateTime now) {
TransactionTemplate transactionTemplate = requiresNewTransactionTemplate();
return transactionTemplate.execute(status -> {
if (storedFileMapper.claimUnboundForDeletion(fileName, now) == 0) {
return null;
}
StoredFile claimed = storedFileMapper.selectById(fileName);
if (claimed == null
|| !Objects.equals(claimed.getStatus(), StoredFileStatus.DELETING.getStatus())) {
status.setRollbackOnly();
return null;
}
return claimed;
});
}
private StoredFile claimForCleanup(String fileName,
LocalDateTime temporaryCutoff,
LocalDateTime deletingLeaseCutoff,
LocalDateTime now) {
TransactionTemplate transactionTemplate = requiresNewTransactionTemplate();
return transactionTemplate.execute(status -> {
if (storedFileMapper.claimForCleanup(
fileName, temporaryCutoff, deletingLeaseCutoff, now) == 0) {
return null;
}
StoredFile claimed = storedFileMapper.selectById(fileName);
if (claimed == null
|| !Objects.equals(claimed.getStatus(), StoredFileStatus.DELETING.getStatus())) {
status.setRollbackOnly();
return null;
}
return claimed;
});
}
private boolean deleteMetadataInNewTransaction(String fileName) {
TransactionTemplate transactionTemplate = requiresNewTransactionTemplate();
Boolean deleted = transactionTemplate.execute(status -> {
int affected = storedFileMapper.deleteById(fileName);
return affected == 1 || storedFileMapper.selectById(fileName) == null;
});
return Boolean.TRUE.equals(deleted);
}
private TransactionTemplate requiresNewTransactionTemplate() {
TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
return transactionTemplate;
}
private boolean isPubliclyAccessible(StoredFile storedFile, FileRule rule) {
if (storedFile == null) {
return false;
}
return isPubliclyAccessible(storedFile.getStatus(), storedFile.getRefType(), rule);
}
private boolean isPubliclyAccessible(Integer status, String refTypeValue, FileRule rule) {
if (rule == null) {
return false;
}
if (Objects.equals(status, StoredFileStatus.TEMPORARY.getStatus())) {
return rule.temporaryPublicPreview;
}
if (!Objects.equals(status, StoredFileStatus.BOUND.getStatus()) || !rule.publicPreview) {
return false;
}
FileReferenceType refType = FileReferenceType.fromValue(refTypeValue);
return refType != null && refType.isPublicAccess();
}
private boolean canDownload(StoredFile storedFile) {
if (storedFile == null) {
return false;
}
Integer userId = CurrentUserContext.userId();
if (Objects.equals(storedFile.getStatus(), StoredFileStatus.TEMPORARY.getStatus())) {
return Objects.equals(storedFile.getUploaderId(), userId);
}
if (!Objects.equals(storedFile.getStatus(), StoredFileStatus.BOUND.getStatus())) {
return false;
}
if (CurrentUserContext.isAdministrator()) {
return true;
}
if (FileReferenceType.MESSAGE_ATTACHMENT.getValue().equals(storedFile.getRefType())) {
UserRole role = UserRole.fromCode(CurrentUserContext.roleCode()).orElse(null);
return role == UserRole.READER
|| role == UserRole.ADMIN
|| role == UserRole.SUPER_ADMIN;
}
FileReferenceType refType = FileReferenceType.fromValue(storedFile.getRefType());
return refType != null && refType.isPublicAccess();
}
private FileRule validateRequestFile(String fileName, HttpServletResponse response) {
if (!isSafeFileName(fileName)) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return null;
}
String extension = fileName.substring(fileName.lastIndexOf(".")).toLowerCase();
FileRule rule = FILE_RULES.get(extension);
if (rule == null) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return null;
}
Path target = resolveTarget(fileName);
if (target == null || !Files.isRegularFile(target)) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return null;
}
return rule;
}
private void write(String fileName,
StoredFile storedFile,
String contentType,
boolean forceDownload,
HttpServletResponse response) throws IOException {
Path target = resolveTarget(fileName);
if (target == null) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
response.setContentType(contentType);
if (forceDownload) {
String originalName = storedFile == null || storedFile.getOriginalName() == null
? fileName : storedFile.getOriginalName();
response.setHeader("Content-Disposition",
ContentDisposition.attachment()
.filename(originalName, StandardCharsets.UTF_8)
.build().toString());
}
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Cache-Control", forceDownload ? "no-store" : "public, max-age=3600");
Files.copy(target, response.getOutputStream());
response.getOutputStream().flush();
}
private Set<String> extractFileNames(String content) {
Set<String> names = new HashSet<>();
if (content == null || content.isBlank()) {
return names;
}
Matcher matcher = FILE_NAME_IN_URL.matcher(content);
while (matcher.find()) {
names.add(matcher.group(1));
}
return names;
}
private String extractFileName(String fileUrl) {
if (fileUrl == null || fileUrl.isBlank()) {
return null;
}
Matcher matcher = FILE_NAME_IN_URL.matcher(fileUrl.trim());
return matcher.find() ? matcher.group(1) : null;
}
private Path uploadRoot() {
return Paths.get(uploadDir).toAbsolutePath().normalize();
}
private Path resolveTarget(String fileName) {
if (!isSafeFileName(fileName)) {
return null;
}
Path root = uploadRoot();
Path target = root.resolve(fileName).normalize();
return target.startsWith(root) ? target : null;
}
private boolean isOlderThan(Path path, LocalDateTime cutoff) {
try {
Instant instant = Files.getLastModifiedTime(path).toInstant();
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()).isBefore(cutoff);
} catch (IOException e) {
return false;
}
}
private boolean isSafeFileName(String fileName) {
return fileName != null && SAFE_FILE_NAME.matcher(fileName).matches();
}
private String safeOriginalFilename(String originalFilename) {
if (originalFilename == null || originalFilename.contains("\0")) {
return null;
}
if (originalFilename.contains("..") || originalFilename.contains("/")
|| originalFilename.contains("\\")) {
return null;
}
try {
return Paths.get(originalFilename).getFileName().toString();
} catch (InvalidPathException e) {
return null;
}
}
private String normalizeContentType(String contentType) {
return contentType == null ? "application/octet-stream"
: contentType.split(";")[0].trim().toLowerCase();
}
private boolean hasExpectedFileSignature(MultipartFile multipartFile, String extension) throws IOException {
byte[] header = readHeader(multipartFile, 512);
if (header.length == 0) {
return false;
}
switch (extension) {
case ".jpg":
case ".jpeg":
return startsWith(header, 0xFF, 0xD8, 0xFF);
case ".png":
return startsWith(header, 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A);
case ".gif":
return startsWithAscii(header, "GIF87a") || startsWithAscii(header, "GIF89a");
case ".bmp":
return startsWithAscii(header, "BM");
case ".webp":
return startsWithAscii(header, "RIFF") && asciiAt(header, 8, "WEBP");
case ".pdf":
return startsWithAscii(header, "%PDF");
case ".html":
case ".htm":
return looksLikeHtml(header);
case ".doc":
case ".xls":
case ".ppt":
return startsWith(header, 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1);
case ".docx":
case ".xlsx":
case ".pptx":
return startsWithAscii(header, "PK");
case ".mp4":
case ".mov":
return asciiAt(header, 4, "ftyp");
case ".avi":
return startsWithAscii(header, "RIFF") && asciiAt(header, 8, "AVI ");
case ".wmv":
return startsWith(header, 0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11);
default:
return false;
}
}
private byte[] readHeader(MultipartFile multipartFile, int maxBytes) throws IOException {
try (InputStream inputStream = multipartFile.getInputStream()) {
byte[] buffer = new byte[maxBytes];
int length = inputStream.read(buffer);
if (length <= 0) {
return new byte[0];
}
byte[] actual = new byte[length];
System.arraycopy(buffer, 0, actual, 0, length);
return actual;
}
}
private boolean looksLikeHtml(byte[] header) {
String prefix = new String(header, StandardCharsets.UTF_8)
.replace("\uFEFF", "")
.trim()
.toLowerCase();
return prefix.startsWith("<")
&& (prefix.contains("<html") || prefix.contains("<!doctype html")
|| prefix.contains("<head") || prefix.contains("<body"));
}
private boolean startsWith(byte[] data, int... expected) {
if (data.length < expected.length) {
return false;
}
for (int i = 0; i < expected.length; i++) {
if ((data[i] & 0xFF) != expected[i]) {
return false;
}
}
return true;
}
private boolean startsWithAscii(byte[] data, String expected) {
return asciiAt(data, 0, expected);
}
private boolean asciiAt(byte[] data, int offset, String expected) {
byte[] expectedBytes = expected.getBytes(StandardCharsets.US_ASCII);
if (data.length < offset + expectedBytes.length) {
return false;
}
for (int i = 0; i < expectedBytes.length; i++) {
if (data[offset + i] != expectedBytes[i]) {
return false;
}
}
return true;
}
private static void putRule(String extension,
long maxSize,
boolean publicPreview,
String... contentTypes) {
FILE_RULES.put(extension,
new FileRule(contentTypes[0], Set.of(contentTypes), maxSize, publicPreview));
}
private static class FileRule {
private final String responseContentType;
private final Set<String> contentTypes;
private final long maxSize;
private final boolean publicPreview;
private final boolean temporaryPublicPreview;
private FileRule(String responseContentType,
Set<String> contentTypes,
long maxSize,
boolean publicPreview) {
this.responseContentType = responseContentType;
this.contentTypes = contentTypes;
this.maxSize = maxSize;
this.publicPreview = publicPreview;
this.temporaryPublicPreview = publicPreview
&& responseContentType.startsWith("image/");
}
private boolean allowsContentType(String contentType) {
if (contentType == null || contentType.trim().isEmpty()) {
return false;
}
String normalized = contentType.split(";")[0].trim().toLowerCase();
return OCTET_STREAM_TYPES.contains(normalized) || contentTypes.contains(normalized);
}
}
}