feat: 添加XSS防护工具函数并优化多个功能模块
refactor(前端): 重构登录页面和用户状态管理逻辑 fix(后端): 修复用户密码更新逻辑和错误提示 feat(后端): 实现分页搜索功能并优化文件删除逻辑 perf(前端): 优化笔记编辑器自动保存和状态提示 fix(后端): 增强图片上传安全验证 style(前端): 调整笔记预览页面按钮权限控制 chore: 更新生产环境配置和测试数据库依赖 feat(前端): 添加虚拟列表组件优化性能 fix(前端): 修复笔记编辑器返回逻辑和状态保存 refactor(前端): 优化axios拦截器错误处理逻辑 docs: 更新方法注释和参数说明
This commit is contained in:
@@ -67,6 +67,13 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- H2 数据库(测试用) -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
|
||||
@@ -93,8 +93,13 @@ public class MarkdownController {
|
||||
|
||||
@Operation(summary = "根据标题模糊搜索")
|
||||
@GetMapping("/search")
|
||||
public R<List<MarkdownFile>> searchByTitle(@RequestParam String keyword) {
|
||||
List<MarkdownFile> files = markdownFileService.searchByTitle(keyword);
|
||||
public R<List<MarkdownFile>> searchByTitle(
|
||||
@RequestParam String keyword,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int pageSize) {
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 100) pageSize = 20;
|
||||
List<MarkdownFile> files = markdownFileService.searchByTitle(keyword, page, pageSize);
|
||||
return R.success(files);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,11 @@ public interface MarkdownFileService extends IService<MarkdownFile> {
|
||||
/**
|
||||
* 根据标题模糊搜索
|
||||
* @param keyword 关键词
|
||||
* @param page 页码(从1开始)
|
||||
* @param pageSize 每页数量
|
||||
* @return 文件列表
|
||||
*/
|
||||
List<MarkdownFile> searchByTitle(String keyword);
|
||||
List<MarkdownFile> searchByTitle(String keyword, int page, int pageSize);
|
||||
|
||||
/**
|
||||
* 更新Markdown文件标题
|
||||
|
||||
@@ -21,8 +21,11 @@ import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -35,9 +38,18 @@ public class ImageServiceImpl
|
||||
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
|
||||
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"
|
||||
);
|
||||
|
||||
|
||||
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
private static final Map<String, byte[]> FILE_SIGNATURES = Map.ofEntries(
|
||||
Map.entry("jpg", new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF}),
|
||||
Map.entry("jpeg", new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF}),
|
||||
Map.entry("png", new byte[]{(byte)0x89, 0x50, 0x4E, 0x47}),
|
||||
Map.entry("gif", new byte[]{0x47, 0x49, 0x46}),
|
||||
Map.entry("bmp", new byte[]{0x42, 0x4D}),
|
||||
Map.entry("webp", new byte[]{0x52, 0x49, 0x46, 0x46})
|
||||
);
|
||||
|
||||
@Value("${file.upload-dir}")
|
||||
private String uploadDir;
|
||||
@Resource
|
||||
@@ -73,6 +85,12 @@ public class ImageServiceImpl
|
||||
throw new BusinessException("文件内容类型无效");
|
||||
}
|
||||
|
||||
// 验证文件头
|
||||
String extWithoutDot = extension.substring(1).toLowerCase();
|
||||
if (!validateFileSignature(file, extWithoutDot)) {
|
||||
throw new BusinessException("文件内容与扩展名不匹配,可能是伪装的恶意文件");
|
||||
}
|
||||
|
||||
Path uploadPath = Paths.get(uploadDir);
|
||||
if (!Files.exists(uploadPath)) {
|
||||
Files.createDirectories(uploadPath);
|
||||
@@ -114,6 +132,18 @@ public class ImageServiceImpl
|
||||
return image;
|
||||
}
|
||||
|
||||
private boolean validateFileSignature(MultipartFile file, String extension) throws IOException {
|
||||
byte[] signature = FILE_SIGNATURES.get(extension);
|
||||
if (signature == null) return true;
|
||||
|
||||
byte[] fileHeader = new byte[Math.max(4, signature.length)];
|
||||
try (InputStream is = file.getInputStream()) {
|
||||
int read = is.read(fileHeader);
|
||||
if (read < signature.length) return false;
|
||||
return Arrays.equals(Arrays.copyOf(fileHeader, signature.length), signature);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteImage(Long id) {
|
||||
Image image = imageMapper.selectById(id);
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.test.bijihoudaun.mapper.ImageNameMapper;
|
||||
import com.test.bijihoudaun.mapper.MarkdownFileMapper;
|
||||
import com.test.bijihoudaun.service.MarkdownFileService;
|
||||
import com.test.bijihoudaun.util.MarkdownImageExtractor;
|
||||
import com.test.bijihoudaun.util.SecurityUtil;
|
||||
import com.test.bijihoudaun.util.SnowflakeIdGenerator;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -113,10 +114,12 @@ public class MarkdownFileServiceImpl
|
||||
|
||||
@Override
|
||||
public boolean deleteMarkdownFile(Long id) {
|
||||
Long currentUserId = SecurityUtil.getCurrentUserId();
|
||||
LambdaUpdateWrapper<MarkdownFile> updateWrapper = new LambdaUpdateWrapper<>();
|
||||
updateWrapper.eq(MarkdownFile::getId, id)
|
||||
.set(MarkdownFile::getIsDeleted, 1)
|
||||
.set(MarkdownFile::getDeletedAt, new Date());
|
||||
.set(MarkdownFile::getDeletedAt, new Date())
|
||||
.set(MarkdownFile::getDeletedBy, currentUserId);
|
||||
return this.update(updateWrapper);
|
||||
}
|
||||
|
||||
@@ -131,13 +134,16 @@ public class MarkdownFileServiceImpl
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MarkdownFile> searchByTitle(String keyword) {
|
||||
// 修复:使用 LambdaQueryWrapper 避免 SQL 注入风险
|
||||
public List<MarkdownFile> searchByTitle(String keyword, int page, int pageSize) {
|
||||
if (keyword == null || keyword.trim().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
int offset = (page - 1) * pageSize;
|
||||
LambdaQueryWrapper<MarkdownFile> queryWrapper = new LambdaQueryWrapper<>();
|
||||
queryWrapper.like(MarkdownFile::getTitle, keyword);
|
||||
queryWrapper.like(MarkdownFile::getTitle, keyword)
|
||||
.eq(MarkdownFile::getIsDeleted, 0)
|
||||
.orderByDesc(MarkdownFile::getUpdatedAt)
|
||||
.last("LIMIT " + offset + "," + pageSize);
|
||||
return this.list(queryWrapper);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
if (remainingAttempts <= 0) {
|
||||
throw new BadCredentialsException("登录失败次数过多,账号已被锁定30分钟");
|
||||
}
|
||||
throw new BadCredentialsException("用户名或密码错误,还剩" + remainingAttempts + "次机会");
|
||||
throw new BadCredentialsException("登录失败,请稍后重试");
|
||||
}
|
||||
|
||||
// 登录成功,清除失败记录
|
||||
@@ -144,7 +144,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
if (ObjectUtil.isNull(user)) {
|
||||
throw new BusinessException("用户不存在");
|
||||
}
|
||||
|
||||
|
||||
String newPassword = updatePasswordBo.getNewPassword();
|
||||
if (newPassword == null || newPassword.isEmpty()) {
|
||||
throw new BusinessException("新密码不能为空");
|
||||
@@ -152,10 +152,15 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
||||
if (newPassword.length() < MIN_PASSWORD_LENGTH || newPassword.length() > MAX_PASSWORD_LENGTH) {
|
||||
throw new BusinessException("密码长度必须在" + MIN_PASSWORD_LENGTH + "-" + MAX_PASSWORD_LENGTH + "位之间");
|
||||
}
|
||||
|
||||
|
||||
if (!PasswordUtils.verify(updatePasswordBo.getOldPassword(), user.getPassword())) {
|
||||
throw new BusinessException("旧密码不正确");
|
||||
}
|
||||
|
||||
if (PasswordUtils.verify(newPassword, user.getPassword())) {
|
||||
throw new BusinessException("新密码不能与旧密码相同");
|
||||
}
|
||||
|
||||
user.setPassword(PasswordUtils.encrypt(newPassword));
|
||||
updateById(user);
|
||||
}
|
||||
|
||||
@@ -110,10 +110,23 @@ public class SecurityUtil {
|
||||
/**
|
||||
* 检查当前用户是否为管理员
|
||||
* 在本项目中,管理员角色定义为"ADMIN"
|
||||
*
|
||||
*
|
||||
* @return 如果用户是管理员返回true,否则返回false
|
||||
*/
|
||||
public static boolean isAdmin() {
|
||||
return hasRole("ADMIN");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前已认证用户的ID
|
||||
*
|
||||
* @return 当前用户的ID,如果用户未认证则返回null
|
||||
*/
|
||||
public static Long getCurrentUserId() {
|
||||
String username = getCurrentUsername();
|
||||
if (username == null) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,47 @@ knife4j:
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 10MB
|
||||
max-request-size: 10MB
|
||||
max-request-size: 10MB
|
||||
|
||||
# 服务器配置
|
||||
server:
|
||||
port: 8084
|
||||
servlet:
|
||||
context-path: /
|
||||
|
||||
# 文件上传路径
|
||||
file:
|
||||
upload-dir: /data/uploads
|
||||
|
||||
# 内存保护阈值 (MB)
|
||||
memory:
|
||||
threshold: 200
|
||||
|
||||
# Snowflake ID 配置
|
||||
worker:
|
||||
id: 1
|
||||
datacenter:
|
||||
id: 1
|
||||
|
||||
# JWT 配置 - 使用环境变量
|
||||
jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: 86400
|
||||
header: Authorization
|
||||
tokenHead: "Bearer "
|
||||
|
||||
# 管理员用户名
|
||||
admin:
|
||||
username: ${ADMIN_USERNAME}
|
||||
|
||||
# 日志配置 - 生产环境只记录 WARN 及以上
|
||||
logging:
|
||||
level:
|
||||
root: WARN
|
||||
com.test.bijihoudaun: INFO
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file:
|
||||
name: /var/log/biji/application.log
|
||||
max-size: 100MB
|
||||
max-history: 30
|
||||
@@ -1,12 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: org.sqlite.JDBC
|
||||
url: jdbc:sqlite:/data/mydatabase.db
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
show-sql: true
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
dialect: org.hibernate.dialect.SQLiteDialect
|
||||
Reference in New Issue
Block a user