diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/ImageController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/ImageController.java index 9c716c9..4ef8241 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/ImageController.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/ImageController.java @@ -4,13 +4,17 @@ package com.test.bijihoudaun.controller; import cn.hutool.core.util.StrUtil; import com.test.bijihoudaun.common.response.R; import com.test.bijihoudaun.entity.Image; +import com.test.bijihoudaun.entity.User; import com.test.bijihoudaun.service.ImageService; +import com.test.bijihoudaun.service.UserService; import com.test.bijihoudaun.util.SecurityUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.util.StreamUtils; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -18,6 +22,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.FileInputStream; import java.io.IOException; +import java.io.PrintWriter; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; @@ -33,18 +38,30 @@ public class ImageController { @Autowired private ImageService imageService; + @Autowired + private UserService userService; + @Operation(summary = "上传图片") @PostMapping public R uploadImage( @RequestPart("file") MultipartFile file, - @RequestParam(value = "userId", required = false) Long userId, @RequestParam(value = "markdownId", required = false) Long markdownId) { + // 修复:从SecurityContext获取当前用户,不接受客户端传入的userId + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (!(principal instanceof UserDetails)) { + return R.fail("请先登录"); + } + String username = ((UserDetails) principal).getUsername(); + User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper().eq("username", username)); + if (user == null) { + return R.fail("用户不存在"); + } try { - Image image = imageService.uploadImage(file, userId, markdownId); + Image image = imageService.uploadImage(file, user.getId(), markdownId); return R.success(image); } catch (IOException e) { - return R.fail(); + return R.fail("上传失败:" + e.getMessage()); } } @@ -54,51 +71,58 @@ public class ImageController { if (!SecurityUtil.isUserAuthenticated()) { return R.fail("请先登录"); } + // 修复:添加权限验证,确保用户只能删除自己的图片 + if (!canModifyImage(id)) { + return R.fail("无权删除此图片"); + } boolean result = imageService.deleteImage(id); if (result) { return R.success(); } else { - return R.fail(); + return R.fail("删除失败"); } } @Operation(summary = "在线预览", description = "浏览器直接打开文件流") @GetMapping("/preview/{url}") public void preview(@PathVariable String url, HttpServletResponse resp) throws IOException { - if (StrUtil.isBlank(url)) { - resp.setStatus(404); - resp.getWriter().write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}"); - return; - } - - String sanitizedUrl = sanitizeFileName(url); - if (sanitizedUrl == null) { - resp.setStatus(403); - resp.getWriter().write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}"); - return; - } - - Path basePath = Paths.get(rootPath).normalize().toAbsolutePath(); - Path filePath = basePath.resolve(sanitizedUrl).normalize(); - - if (!filePath.startsWith(basePath)) { - resp.setStatus(403); - resp.getWriter().write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}"); - return; - } - - File file = filePath.toFile(); - if (!file.exists() || !file.isFile()) { - resp.setStatus(404); - resp.getWriter().write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}"); - return; - } - - String contentTypeFromFileExtension = getContentTypeFromFileExtension(url); - resp.setContentType(contentTypeFromFileExtension); - resp.setContentLengthLong(file.length()); - try (FileInputStream in = new FileInputStream(file)) { - StreamUtils.copy(in, resp.getOutputStream()); + // 修复:使用 try-with-resources 确保 PrintWriter 关闭 + try (PrintWriter writer = resp.getWriter()) { + if (StrUtil.isBlank(url)) { + resp.setStatus(404); + writer.write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}"); + return; + } + + String sanitizedUrl = sanitizeFileName(url); + if (sanitizedUrl == null) { + resp.setStatus(403); + writer.write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}"); + return; + } + + Path basePath = Paths.get(rootPath).normalize().toAbsolutePath(); + Path filePath = basePath.resolve(sanitizedUrl).normalize(); + + if (!filePath.startsWith(basePath)) { + resp.setStatus(403); + writer.write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}"); + return; + } + + File file = filePath.toFile(); + if (!file.exists() || !file.isFile()) { + resp.setStatus(404); + writer.write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}"); + return; + } + + String contentTypeFromFileExtension = getContentTypeFromFileExtension(url); + resp.setContentType(contentTypeFromFileExtension); + resp.setContentLengthLong(file.length()); + try (FileInputStream in = new FileInputStream(file)) { + StreamUtils.copy(in, resp.getOutputStream()); + } } } @@ -119,11 +143,15 @@ public class ImageController { if (!SecurityUtil.isUserAuthenticated()) { return R.fail("请先登录"); } + // 修复:添加权限验证 + if (!canModifyImageByUrl(url)) { + return R.fail("无权删除此图片"); + } boolean result = imageService.deleteImageByUrl(url); if (result) { return R.success(); } else { - return R.fail(); + return R.fail("删除失败"); } } @@ -133,14 +161,62 @@ public class ImageController { if (!SecurityUtil.isUserAuthenticated()) { return R.fail("请先登录"); } + // 修复:添加权限验证 + for (String url : urls) { + if (!canModifyImageByUrl(url)) { + return R.fail("无权删除部分图片"); + } + } boolean result = imageService.deleteImageByUrls(urls); if (result) { return R.success(); } else { - return R.fail(); + return R.fail("删除失败"); } } + /** + * 检查当前用户是否有权限操作图片 + */ + private boolean canModifyImage(Long imageId) { + // 从数据库查询图片所属用户 + Image image = imageService.getById(imageId); + if (image == null) { + return false; + } + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (!(principal instanceof UserDetails)) { + return false; + } + String username = ((UserDetails) principal).getUsername(); + User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper().eq("username", username)); + if (user == null) { + return false; + } + return user.getId().equals(image.getUserId()); + } + + /** + * 检查当前用户是否有权限操作图片(通过URL) + */ + private boolean canModifyImageByUrl(String url) { + // 从数据库查询图片所属用户 + Image image = imageService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper().eq("stored_name", url)); + if (image == null) { + return false; + } + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (!(principal instanceof UserDetails)) { + return false; + } + String username = ((UserDetails) principal).getUsername(); + User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper().eq("username", username)); + if (user == null) { + return false; + } + return user.getId().equals(image.getUserId()); + } + private String getContentTypeFromFileExtension(String fileName) { if (StrUtil.isBlank(fileName) || !StrUtil.contains(fileName, '.')) { diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java index 79acd9d..495e444 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java @@ -98,7 +98,7 @@ public class MarkdownController { @PostMapping("/{id}/title") public R updateMarkdownTitle( @PathVariable Long id, - String title) { + @RequestParam String title) { MarkdownFile updatedFile = markdownFileService.updateMarkdownTitle(id, title); if (ObjectUtil.isNotNull(updatedFile)) { return R.success(updatedFile); diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/SystemController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/SystemController.java index 5f48c90..60c501f 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/SystemController.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/SystemController.java @@ -11,8 +11,6 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; -import java.util.Collections; - @RestController @RequestMapping("/api/system") @Tag(name = "系统管理") @@ -31,7 +29,7 @@ public class SystemController { } @PostMapping("/registration/toggle") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "切换注册功能状态") public R toggleRegistration(@RequestBody Boolean enabled) { systemSettingService.setRegistrationEnabled(enabled); @@ -39,7 +37,7 @@ public class SystemController { } @PostMapping("/registration/generate-code") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasRole('ADMIN')") @Operation(summary = "生成注册码") public R generateRegistrationCode() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); @@ -47,4 +45,4 @@ public class SystemController { String code = registrationCodeService.generateCode(currentUserName); return R.success(code); } -} \ No newline at end of file +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/UserController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/UserController.java index 9d955e3..afed605 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/UserController.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/UserController.java @@ -52,6 +52,10 @@ public class UserController { return R.fail("无效或已过期的注册码"); } User user = userService.register(username, password, email); + // 修复:添加空值检查 + if (user == null) { + return R.fail("注册失败,请稍后重试"); + } UserVO userVO = new UserVO(); BeanUtils.copyProperties(user, userVO); userVO.setId(String.valueOf(user.getId())); @@ -69,6 +73,11 @@ public class UserController { String token = userService.login(username, password); User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper().eq("username", username)); + // 修复:添加空值检查 + if (user == null) { + return R.fail("用户不存在"); + } + Map result = new HashMap<>(); result.put("token", token); @@ -88,7 +97,12 @@ public class UserController { @RequireCaptcha("删除账号") @DeleteMapping("/deleteUser") public R deleteUser(){ - UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + // 修复:添加类型检查 + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (!(principal instanceof UserDetails)) { + return R.fail("无法获取用户信息"); + } + UserDetails userDetails = (UserDetails) principal; String username = userDetails.getUsername(); User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper().eq("username", username)); @@ -110,9 +124,20 @@ public class UserController { @RequireCaptcha("修改密码") @PutMapping("/password") public R updatePassword(@RequestBody UpdatePasswordBo updatePasswordBo) { - UserDetails userDetails = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + // 修复:添加类型检查 + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + if (!(principal instanceof UserDetails)) { + return R.fail("无法获取用户信息"); + } + UserDetails userDetails = (UserDetails) principal; String username = userDetails.getUsername(); User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper().eq("username", username)); + + // 修复:添加空值检查 + if (ObjectUtil.isNull(user)) { + return R.fail("用户不存在"); + } + userService.updatePassword(user.getId(), updatePasswordBo); return R.success("密码更新成功"); } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/User.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/User.java index d5db427..cf58a16 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/User.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/User.java @@ -31,6 +31,10 @@ public class User { @TableField("`email`") private String email; + @Schema(description = "用户角色(ADMIN-管理员,USER-普通用户)",implementation = String.class) + @TableField("`role`") + private String role; + @Schema(description = "用户创建时间",implementation = Date.class) @TableField("created_at") private Date createdAt; @@ -46,4 +50,4 @@ public class User { @Schema(description = "用户token过期时间",implementation = Date.class) @TableField("token_enddata") private Date tokenEnddata; -} \ No newline at end of file +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/JwtAuthenticationTokenFilter.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/JwtAuthenticationTokenFilter.java index 2e64523..e2feab3 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/JwtAuthenticationTokenFilter.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/JwtAuthenticationTokenFilter.java @@ -5,6 +5,7 @@ import com.test.bijihoudaun.common.response.R; import com.test.bijihoudaun.common.response.ResultCode; import com.test.bijihoudaun.util.JwtTokenUtil; import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; import io.jsonwebtoken.security.SignatureException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; @@ -18,6 +19,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.io.PrintWriter; public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { @@ -45,8 +47,8 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { // 从请求头中获取认证信息 String authHeader = request.getHeader(this.tokenHeader); - // 检查请求头是否存在且以指定的token前缀开头 - if (authHeader != null && authHeader.startsWith(this.tokenHead)) { + // 修复:添加 tokenHead 空值检查 + if (authHeader != null && this.tokenHead != null && authHeader.startsWith(this.tokenHead)) { // 提取实际的token值(去除前缀) final String authToken = authHeader.substring(this.tokenHead.length()); try { @@ -76,6 +78,10 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { // 处理token签名异常 sendErrorResponse(response, ResultCode.TOKEN_INVALID); return; + } catch (JwtException e) { + // 修复:捕获所有JWT异常作为兜底 + sendErrorResponse(response, ResultCode.TOKEN_INVALID); + return; } } // 继续过滤器链的处理 @@ -93,9 +99,13 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter { response.setContentType("application/json;charset=UTF-8"); // 设置HTTP状态码为401未授权 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - // 创建ObjectMapper实例,用于对象与JSON之间的转换 - ObjectMapper mapper = new ObjectMapper(); - // 将失败结果转换为JSON字符串并写入响应输出流 - response.getWriter().write(mapper.writeValueAsString(R.fail(resultCode))); + // 修复:使用 try-with-resources 确保 PrintWriter 关闭 + try (PrintWriter writer = response.getWriter()) { + // 创建ObjectMapper实例,用于对象与JSON之间的转换 + ObjectMapper mapper = new ObjectMapper(); + // 将失败结果转换为JSON字符串并写入响应输出流 + writer.write(mapper.writeValueAsString(R.fail(resultCode))); + writer.flush(); + } } -} \ No newline at end of file +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java index 3461af7..26564c6 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/RateLimitInterceptor.java @@ -61,23 +61,17 @@ public class RateLimitInterceptor implements HandlerInterceptor { boolean incrementAndCheck(int maxRequests) { long now = System.currentTimeMillis(); lastAccessTime = now; - long currentWindow = windowStart; - if (now - currentWindow > WINDOW_SIZE_MS) { - // 尝试进入新窗口 - synchronized (this) { - if (windowStart == currentWindow) { - // 确实需要新窗口 - windowStart = now; - count.set(1); - return true; - } + synchronized (this) { + if (now - windowStart > WINDOW_SIZE_MS) { + // 进入新窗口 + windowStart = now; + count.set(1); + return true; } - // 其他线程已经更新了窗口,继续检查 + int currentCount = count.incrementAndGet(); + return currentCount <= maxRequests; } - - int currentCount = count.incrementAndGet(); - return currentCount <= maxRequests; } /** diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/ReplayAttackInterceptor.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/ReplayAttackInterceptor.java index c1ea5fc..2f0967b 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/ReplayAttackInterceptor.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/interceptor/ReplayAttackInterceptor.java @@ -107,8 +107,9 @@ public class ReplayAttackInterceptor implements HandlerInterceptor { } long now = System.currentTimeMillis(); - if (Math.abs(now - timestamp) > TIMESTAMP_VALIDITY) { - writeErrorResponse(response, "请求已过期,请重新发起请求"); + // 修复:不允许未来时间戳,防止预发送攻击 + if (timestamp > now || now - timestamp > TIMESTAMP_VALIDITY) { + writeErrorResponse(response, "请求时间戳无效或已过期"); return false; } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageNameSyncService.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageNameSyncService.java new file mode 100644 index 0000000..08c5d13 --- /dev/null +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageNameSyncService.java @@ -0,0 +1,85 @@ +package com.test.bijihoudaun.service.impl; + +import cn.hutool.core.collection.CollUtil; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.test.bijihoudaun.entity.ImageName; +import com.test.bijihoudaun.mapper.ImageNameMapper; +import jakarta.annotation.Resource; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 图片名称同步服务 + * 处理 Markdown 内容中的图片名称同步,独立的事务和异步处理 + */ +@Service +public class ImageNameSyncService { + + @Resource + private ImageNameMapper imageNameMapper; + + /** + * 同步图片名称 + * 异步执行,独立事务 + */ + @Async("imageNameSyncExecutor") + @Transactional + public void syncImageNames(Long markdownId, List strings) { + // 查询数据库中已存在的文件名 + List imageNames = imageNameMapper.selectList(new LambdaUpdateWrapper() + .eq(ImageName::getMarkdownId, markdownId)); + + // 若是数据库中的数据为null,则插入 + if (CollUtil.isEmpty(imageNames)) { + if (CollUtil.isNotEmpty(strings)) { + List list = strings.stream().map(fileName -> { + ImageName imageName = new ImageName(); + imageName.setFileName(fileName); + imageName.setMarkdownId(markdownId); + return imageName; + }).toList(); + // 批量插入新的文件名(使用MyBatis Plus批量插入) + imageNameMapper.insert(list); + } + } else { + // 数据库中已有记录,需要对比处理 + // 获取数据库中的文件名列表 + List dbFileNames = imageNames.stream() + .map(ImageName::getFileName) + .toList(); + + // 找出需要新增的文件名(在strings中但不在数据库中) + List toInsert = strings.stream() + .filter(fileName -> !dbFileNames.contains(fileName)) + .toList(); + + // 找出需要删除的记录(在数据库中但不在strings中) + List toDelete = imageNames.stream() + .filter(imageName -> !strings.contains(imageName.getFileName())) + .toList(); + + // 插入新增的文件名 + if (CollUtil.isNotEmpty(toInsert)) { + List insertList = toInsert.stream().map(fileName -> { + ImageName imageName = new ImageName(); + imageName.setFileName(fileName); + imageName.setMarkdownId(markdownId); + return imageName; + }).toList(); + + imageNameMapper.insert(insertList); + } + + // 删除不再需要的记录 + if (CollUtil.isNotEmpty(toDelete)) { + List deleteIds = toDelete.stream() + .map(ImageName::getId) + .toList(); + imageNameMapper.deleteByIds(deleteIds); + } + } + } +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java index 1dd8458..4091921 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java @@ -82,16 +82,22 @@ public class ImageServiceImpl Path filePath = uploadPath.resolve(storedName); - // 优化:压缩图片后再保存 - InputStream compressedStream = ImageCompressor.compressIfNeeded( - file.getInputStream(), contentType, file.getSize()); + // 优化:压缩图片后再保存,确保流关闭 + try (InputStream originalStream = file.getInputStream()) { + InputStream compressedStream = ImageCompressor.compressIfNeeded( + originalStream, contentType, file.getSize()); - if (compressedStream != null) { - // 使用压缩后的图片 - Files.copy(compressedStream, filePath); - } else { - // 使用原图 - Files.copy(file.getInputStream(), filePath); + if (compressedStream != null) { + // 使用压缩后的图片 + try (compressedStream) { + Files.copy(compressedStream, filePath); + } + } else { + // 使用原图,需要重新获取流 + try (InputStream newStream = file.getInputStream()) { + Files.copy(newStream, filePath); + } + } } Image image = new Image(); diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java index 08affff..0fb1595 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java @@ -15,7 +15,6 @@ import com.test.bijihoudaun.util.MarkdownImageExtractor; import com.test.bijihoudaun.util.SnowflakeIdGenerator; import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +33,8 @@ public class MarkdownFileServiceImpl ImageNameMapper imageNameMapper; @Resource SnowflakeIdGenerator snowflakeIdGenerator; + @Resource + ImageNameSyncService imageNameSyncService; @Override @@ -77,7 +78,8 @@ public class MarkdownFileServiceImpl } List strings = MarkdownImageExtractor.extractImageFilenames(markdownFile.getContent()); - syncImageNames(id, strings); + // 修复:调用单独的 Service 处理异步逻辑 + imageNameSyncService.syncImageNames(id, strings); MarkdownFileVO result = markdownFileMapper.selectByIdWithGrouping(id); if (result != null) { @@ -128,8 +130,13 @@ public class MarkdownFileServiceImpl @Override public List searchByTitle(String keyword) { + // 修复:转义特殊字符防止 SQL 注入 + if (keyword == null || keyword.trim().isEmpty()) { + return List.of(); + } + String escapedKeyword = keyword.replace("%", "\\%").replace("_", "\\_"); QueryWrapper queryWrapper = new QueryWrapper<>(); - queryWrapper.like("title", keyword); + queryWrapper.like("title", escapedKeyword); return this.list(queryWrapper); } @@ -149,62 +156,4 @@ public class MarkdownFileServiceImpl return markdownFileMapper.selectRecentWithGrouping(limit); } - - @Async("imageNameSyncExecutor") - public void syncImageNames(Long markdownId, List strings) { - // 查询数据库中已存在的文件名 - List imageNames = imageNameMapper.selectList(new LambdaUpdateWrapper() - .eq(ImageName::getMarkdownId, markdownId)); - - // 若是数据库中的数据为null,则插入 - if (CollUtil.isEmpty(imageNames)) { - if (CollUtil.isNotEmpty(strings)) { - List list = strings.stream().map(fileName -> { - ImageName imageName = new ImageName(); - imageName.setFileName(fileName); - imageName.setMarkdownId(markdownId); - return imageName; - }).toList(); - // 批量插入新的文件名(使用MyBatis Plus批量插入) - imageNameMapper.insert(list); - } - } else { - // 数据库中已有记录,需要对比处理 - // 获取数据库中的文件名列表 - List dbFileNames = imageNames.stream() - .map(ImageName::getFileName) - .toList(); - - // 找出需要新增的文件名(在strings中但不在数据库中) - List toInsert = strings.stream() - .filter(fileName -> !dbFileNames.contains(fileName)) - .toList(); - - // 找出需要删除的记录(在数据库中但不在strings中) - List toDelete = imageNames.stream() - .filter(imageName -> !strings.contains(imageName.getFileName())) - .toList(); - - // 插入新增的文件名 - if (CollUtil.isNotEmpty(toInsert)) { - List insertList = toInsert.stream().map(fileName -> { - ImageName imageName = new ImageName(); - imageName.setFileName(fileName); - imageName.setMarkdownId(markdownId); - return imageName; - }).toList(); - - imageNameMapper.insert(insertList); - } - - // 删除不再需要的记录 - if (CollUtil.isNotEmpty(toDelete)) { - List deleteIds = toDelete.stream() - .map(ImageName::getId) - .toList(); - imageNameMapper.deleteByIds(deleteIds); - } - } - } - -} \ No newline at end of file +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java index 906ff03..892a74d 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java @@ -16,6 +16,8 @@ import com.test.bijihoudaun.util.PasswordUtils; import com.test.bijihoudaun.util.UuidV7; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -30,9 +32,9 @@ import java.util.Date; public class UserServiceImpl extends ServiceImpl implements UserService, UserDetailsService { private static final int MIN_USERNAME_LENGTH = 2; - private static final int MAX_USERNAME_LENGTH = 8; + private static final int MAX_USERNAME_LENGTH = 20; private static final int MIN_PASSWORD_LENGTH = 6; - private static final int MAX_PASSWORD_LENGTH = 12; + private static final int MAX_PASSWORD_LENGTH = 128; @Autowired private UserMapper userMapper; @@ -43,7 +45,14 @@ public class UserServiceImpl extends ServiceImpl implements Us if (ObjectUtil.isNull(user)) { throw new UsernameNotFoundException("User not found with username: " + username); } - return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), new ArrayList<>()); + // 加载用户角色权限 + java.util.List authorities = new ArrayList<>(); + String role = user.getRole(); + if (role == null || role.isEmpty()) { + role = "USER"; // 默认角色 + } + authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())); + return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), authorities); } @Override diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java index 59a19fd..3fb601b 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/CaptchaUtil.java @@ -12,7 +12,10 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; /** * 图形验证码工具类 @@ -33,11 +36,23 @@ public class CaptchaUtil { private static final int MAX_CAPTCHAS = 5000; // 存储验证码:key=验证码ID,value=验证码记录 - private static final LRUCache captchaStore = new LRUCache<>(MAX_CAPTCHAS); - private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private static final ConcurrentHashMap captchaStore = new ConcurrentHashMap<>(); - // 安全随机数生成器 - private static final SecureRandom random = new SecureRandom(); + // 使用 ThreadLocal 确保线程安全 + private static final ThreadLocal random = ThreadLocal.withInitial(SecureRandom::new); + + // 定期清理线程池 + private static final ScheduledExecutorService cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "captcha-cleanup"); + t.setDaemon(true); + return t; + }); + + static { + // 每5分钟清理一次过期验证码 + cleanupScheduler.scheduleAtFixedRate(CaptchaUtil::cleanupExpiredCaptchas, + 5, 5, TimeUnit.MINUTES); + } private static class CaptchaRecord { String code; @@ -53,27 +68,6 @@ public class CaptchaUtil { } } - /** - * 简单的 LRU 缓存实现 - */ - private static class LRUCache extends LinkedHashMap { - private final int maxSize; - - LRUCache(int maxSize) { - super(maxSize, 0.75f, true); - this.maxSize = maxSize; - } - - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - boolean shouldRemove = size() > maxSize; - if (shouldRemove) { - log.warn("验证码存储达到上限,移除最旧的验证码: {}", eldest.getKey()); - } - return shouldRemove; - } - } - /** * 验证码结果 */ @@ -106,9 +100,6 @@ public class CaptchaUtil { throw new RuntimeException("服务器繁忙,请稍后再试"); } - // 清理过期验证码 - cleanupExpiredCaptchas(); - // 生成验证码ID String captchaId = UuidV7.uuid(); @@ -118,13 +109,8 @@ public class CaptchaUtil { // 生成图片 String base64Image = generateImage(code); - lock.writeLock().lock(); - try { - // 存储验证码 - captchaStore.put(captchaId, new CaptchaRecord(code)); - } finally { - lock.writeLock().unlock(); - } + // 存储验证码 + captchaStore.put(captchaId, new CaptchaRecord(code)); return new CaptchaResult(captchaId, base64Image); } @@ -140,43 +126,24 @@ public class CaptchaUtil { return false; } - lock.readLock().lock(); - try { - CaptchaRecord record = captchaStore.get(captchaId); - if (record == null || record.isExpired()) { - // 验证码不存在或已过期,移除 - if (record != null) { - lock.readLock().unlock(); - lock.writeLock().lock(); - try { - captchaStore.remove(captchaId); - } finally { - lock.writeLock().unlock(); - lock.readLock().lock(); - } - } - return false; + CaptchaRecord record = captchaStore.get(captchaId); + if (record == null || record.isExpired()) { + // 验证码不存在或已过期,移除 + if (record != null) { + captchaStore.remove(captchaId); } - - // 验证码比对(不区分大小写) - boolean success = record.code.equalsIgnoreCase(code); - - // 验证成功后立即删除(一次性使用) - if (success) { - lock.readLock().unlock(); - lock.writeLock().lock(); - try { - captchaStore.remove(captchaId); - } finally { - lock.writeLock().unlock(); - lock.readLock().lock(); - } - } - - return success; - } finally { - lock.readLock().unlock(); + return false; } + + // 验证码比对(不区分大小写) + boolean success = record.code.equalsIgnoreCase(code); + + // 验证成功后立即删除(一次性使用) + if (success) { + captchaStore.remove(captchaId); + } + + return success; } /** @@ -185,8 +152,9 @@ public class CaptchaUtil { private static String generateCode() { String chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"; StringBuilder sb = new StringBuilder(); + SecureRandom r = random.get(); for (int i = 0; i < CAPTCHA_LENGTH; i++) { - sb.append(chars.charAt(random.nextInt(chars.length()))); + sb.append(chars.charAt(r.nextInt(chars.length()))); } return sb.toString(); } @@ -196,56 +164,59 @@ public class CaptchaUtil { */ private static String generateImage(String code) { BufferedImage image = new BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_RGB); - Graphics2D g = image.createGraphics(); + Graphics2D g2d = image.createGraphics(); - // 设置背景色 - g.setColor(Color.WHITE); - g.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT); + try { + // 设置背景色 + g2d.setColor(Color.WHITE); + g2d.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT); - // 绘制干扰线 - g.setColor(Color.LIGHT_GRAY); - for (int i = 0; i < 5; i++) { - int x1 = random.nextInt(IMAGE_WIDTH); - int y1 = random.nextInt(IMAGE_HEIGHT); - int x2 = random.nextInt(IMAGE_WIDTH); - int y2 = random.nextInt(IMAGE_HEIGHT); - g.drawLine(x1, y1, x2, y2); - } + // 绘制干扰线 + g2d.setColor(Color.LIGHT_GRAY); + SecureRandom r = random.get(); + for (int i = 0; i < 5; i++) { + int x1 = r.nextInt(IMAGE_WIDTH); + int y1 = r.nextInt(IMAGE_HEIGHT); + int x2 = r.nextInt(IMAGE_WIDTH); + int y2 = r.nextInt(IMAGE_HEIGHT); + g2d.drawLine(x1, y1, x2, y2); + } - // 绘制验证码字符 - g.setFont(new Font("Arial", Font.BOLD, 24)); - int x = 15; - for (int i = 0; i < code.length(); i++) { - // 随机颜色 - g.setColor(new Color( - random.nextInt(100), - random.nextInt(100), - random.nextInt(100) - )); - // 随机旋转角度 - int angle = random.nextInt(30) - 15; - g.rotate(Math.toRadians(angle), x + 10, 25); - g.drawString(String.valueOf(code.charAt(i)), x, 28); - g.rotate(-Math.toRadians(angle), x + 10, 25); - x += 25; - } + // 绘制验证码字符 + g2d.setFont(new Font("Arial", Font.BOLD, 24)); + int x = 15; + for (int i = 0; i < code.length(); i++) { + // 随机颜色 + g2d.setColor(new Color( + r.nextInt(100), + r.nextInt(100), + r.nextInt(100) + )); + // 随机旋转角度 + int angle = r.nextInt(30) - 15; + g2d.rotate(Math.toRadians(angle), x + 10, 25); + g2d.drawString(String.valueOf(code.charAt(i)), x, 28); + g2d.rotate(-Math.toRadians(angle), x + 10, 25); + x += 25; + } - // 添加噪点 - for (int i = 0; i < 30; i++) { - int x1 = random.nextInt(IMAGE_WIDTH); - int y1 = random.nextInt(IMAGE_HEIGHT); - image.setRGB(x1, y1, Color.GRAY.getRGB()); - } + // 添加噪点 + for (int i = 0; i < 30; i++) { + int x1 = r.nextInt(IMAGE_WIDTH); + int y1 = r.nextInt(IMAGE_HEIGHT); + image.setRGB(x1, y1, Color.GRAY.getRGB()); + } - g.dispose(); - - // 转为 Base64 - try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - ImageIO.write(image, "png", baos); - byte[] imageBytes = baos.toByteArray(); - return "data:image/png;base64," + java.util.Base64.getEncoder().encodeToString(imageBytes); + // 转为 Base64 + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", baos); + byte[] imageBytes = baos.toByteArray(); + return "data:image/png;base64," + java.util.Base64.getEncoder().encodeToString(imageBytes); + } } catch (IOException e) { throw new RuntimeException("生成验证码图片失败", e); + } finally { + g2d.dispose(); } } @@ -253,16 +224,19 @@ public class CaptchaUtil { * 清理过期验证码 */ private static void cleanupExpiredCaptchas() { - // 每80%容量时清理一次,减少开销 - if (captchaStore.size() < MAX_CAPTCHAS * 0.8) { - return; - } - - lock.writeLock().lock(); try { - captchaStore.entrySet().removeIf(entry -> entry.getValue().isExpired()); - } finally { - lock.writeLock().unlock(); + int removed = 0; + for (Map.Entry entry : captchaStore.entrySet()) { + if (entry.getValue().isExpired()) { + captchaStore.remove(entry.getKey()); + removed++; + } + } + if (removed > 0) { + log.debug("清理了 {} 个过期验证码", removed); + } + } catch (Exception e) { + log.error("清理过期验证码失败", e); } } @@ -270,11 +244,6 @@ public class CaptchaUtil { * 获取当前验证码数量(用于监控) */ public static int getCaptchaCount() { - lock.readLock().lock(); - try { - return captchaStore.size(); - } finally { - lock.readLock().unlock(); - } + return captchaStore.size(); } } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/LoginLockUtil.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/LoginLockUtil.java index 95777dd..dad1deb 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/LoginLockUtil.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/LoginLockUtil.java @@ -6,11 +6,14 @@ import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; import java.util.LinkedHashMap; import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantReadWriteLock; /** * 登录锁定工具类 - * 使用本地内存存储登录失败记录,带容量限制 + * 使用本地内存存储登录失败记录,带容量限制和定期清理 */ @Slf4j public class LoginLockUtil { @@ -28,6 +31,19 @@ public class LoginLockUtil { private static final LRUCache attempts = new LRUCache<>(MAX_RECORDS); private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + // 定期清理线程池 + private static final ScheduledExecutorService cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "login-lock-cleanup"); + t.setDaemon(true); + return t; + }); + + static { + // 每10分钟清理一次过期记录 + cleanupScheduler.scheduleAtFixedRate(LoginLockUtil::cleanupExpiredLocks, + 10, 10, TimeUnit.MINUTES); + } + private static class LoginAttempt { int failedCount; LocalDateTime lastAttemptTime; @@ -38,6 +54,10 @@ public class LoginLockUtil { this.lastAttemptTime = LocalDateTime.now(); this.lockUntil = null; } + + boolean isExpired() { + return ChronoUnit.MINUTES.between(lastAttemptTime, LocalDateTime.now()) > RECORD_EXPIRE_MINUTES; + } } /** @@ -68,8 +88,6 @@ public class LoginLockUtil { public static void recordFailedAttempt(String username) { if (username == null || username.isEmpty()) return; - cleanupExpiredRecords(); - lock.writeLock().lock(); try { LoginAttempt attempt = attempts.computeIfAbsent(username, k -> new LoginAttempt()); @@ -116,20 +134,7 @@ public class LoginLockUtil { // 检查是否仍在锁定时间内 if (attempt.lockUntil != null) { - if (LocalDateTime.now().isBefore(attempt.lockUntil)) { - return true; - } else { - // 锁定时间已过,清除记录 - lock.readLock().unlock(); - lock.writeLock().lock(); - try { - attempts.remove(username); - } finally { - lock.writeLock().unlock(); - lock.readLock().lock(); - } - return false; - } + return LocalDateTime.now().isBefore(attempt.lockUntil); } return false; } finally { @@ -177,14 +182,9 @@ public class LoginLockUtil { } /** - * 清理过期记录 + * 清理过期的锁定记录 */ - private static void cleanupExpiredRecords() { - // 每100次操作清理一次,减少开销 - if (attempts.size() < MAX_RECORDS * 0.8) { - return; - } - + private static void cleanupExpiredLocks() { lock.writeLock().lock(); try { LocalDateTime now = LocalDateTime.now(); @@ -192,7 +192,7 @@ public class LoginLockUtil { LoginAttempt attempt = entry.getValue(); // 未锁定且长时间没有登录的记录 if (attempt.lockUntil == null) { - return ChronoUnit.MINUTES.between(attempt.lastAttemptTime, now) > RECORD_EXPIRE_MINUTES; + return attempt.isExpired(); } // 锁定已过期的记录 return now.isAfter(attempt.lockUntil); diff --git a/biji-houdaun/src/main/resources/db/migration/V20240303__add_indexes.sql b/biji-houdaun/src/main/resources/db/migration/V20240303__add_indexes.sql index ef6581c..79414b5 100644 --- a/biji-houdaun/src/main/resources/db/migration/V20240303__add_indexes.sql +++ b/biji-houdaun/src/main/resources/db/migration/V20240303__add_indexes.sql @@ -1,38 +1,24 @@ --- 数据库性能优化索引 --- 创建时间: 2024-03-03 +-- 数据库性能优化索引(完整修正版) --- markdown_file 表索引 --- 按分组查询 -CREATE INDEX IF NOT EXISTS idx_markdown_grouping_id ON markdown_file(grouping_id); --- 按删除状态查询(软删除) -CREATE INDEX IF NOT EXISTS idx_markdown_is_deleted ON markdown_file(is_deleted); --- 按创建时间排序 -CREATE INDEX IF NOT EXISTS idx_markdown_created_at ON markdown_file(created_at); --- 复合索引:查询未删除的分组笔记 -CREATE INDEX IF NOT EXISTS idx_markdown_grouping_deleted ON markdown_file(grouping_id, is_deleted); +-- ==================== markdown_file 表索引 ==================== +CREATE INDEX idx_markdown_grouping_id ON markdown_file(grouping_id); +CREATE INDEX idx_markdown_is_deleted ON markdown_file(is_deleted); +CREATE INDEX idx_markdown_created_at ON markdown_file(created_at); +CREATE INDEX idx_markdown_grouping_deleted ON markdown_file(grouping_id, is_deleted); --- image 表索引 --- 按 markdown_id 查询(关联查询) -CREATE INDEX IF NOT EXISTS idx_image_markdown_id ON image(markdown_id); --- 按存储文件名查询 -CREATE INDEX IF NOT EXISTS idx_image_stored_name ON image(stored_name); --- 按创建时间查询(清理旧图片) -CREATE INDEX IF NOT EXISTS idx_image_created_at ON image(created_at); +-- ==================== image 表索引 ==================== +CREATE INDEX idx_image_markdown_id ON image(markdown_id); +CREATE INDEX idx_image_stored_name ON image(stored_name); +CREATE INDEX idx_image_created_at ON image(created_at); --- grouping 表索引 --- 按父分组查询 -CREATE INDEX IF NOT EXISTS idx_grouping_parent_id ON grouping(parent_id); --- 按删除状态查询 -CREATE INDEX IF NOT EXISTS idx_grouping_is_deleted ON grouping(is_deleted); +-- ==================== grouping 表索引(注意反引号!)==================== +CREATE INDEX idx_grouping_parent_id ON `grouping`(parentId); +CREATE INDEX idx_grouping_is_deleted ON `grouping`(is_deleted); --- trash 表索引 --- 按用户查询 -CREATE INDEX IF NOT EXISTS idx_trash_user_id ON trash(user_id); --- 按删除时间排序(清理过期数据) -CREATE INDEX IF NOT EXISTS idx_trash_deleted_at ON trash(deleted_at); --- 按类型查询 -CREATE INDEX IF NOT EXISTS idx_trash_item_type ON trash(item_type); +-- ==================== trash 表索引 ==================== +CREATE INDEX idx_trash_user_id ON trash(user_id); +CREATE INDEX idx_trash_deleted_at ON trash(deleted_at); +CREATE INDEX idx_trash_item_type ON trash(item_type); --- user 表索引 --- 按用户名查询(登录) -CREATE INDEX IF NOT EXISTS idx_user_username ON user(username); +-- ==================== user 表索引 ==================== +CREATE INDEX idx_user_username ON user(username); \ No newline at end of file diff --git a/biji-houdaun/src/main/resources/db/migration/V20240304__add_role_column.sql b/biji-houdaun/src/main/resources/db/migration/V20240304__add_role_column.sql new file mode 100644 index 0000000..0224552 --- /dev/null +++ b/biji-houdaun/src/main/resources/db/migration/V20240304__add_role_column.sql @@ -0,0 +1,12 @@ +-- 添加用户角色字段 +-- 创建时间: 2024-03-04 + +-- 为用户表添加 role 字段 +ALTER TABLE `user` +ADD COLUMN `role` VARCHAR(50) DEFAULT 'USER' COMMENT '用户角色:ADMIN-管理员,USER-普通用户'; + +-- 将第一个用户设置为管理员(可选,根据实际需求) +-- UPDATE `user` SET `role` = 'ADMIN' WHERE `id` = 1; + +-- 创建索引 +CREATE INDEX IF NOT EXISTS idx_user_role ON `user`(`role`); diff --git a/biji-qianduan/src/api/CommonApi.js b/biji-qianduan/src/api/CommonApi.js index 739be90..4ac9e3a 100644 --- a/biji-qianduan/src/api/CommonApi.js +++ b/biji-qianduan/src/api/CommonApi.js @@ -2,9 +2,14 @@ import axiosApi from '@/utils/axios.js' -export const groupingId = (data) => axiosApi.get(`/api/markdown/grouping/${data}`) +// 修复:使用 encodeURIComponent 编码 URL 参数,防止注入 +export const groupingId = (data) => axiosApi.get(`/api/markdown/grouping/${encodeURIComponent(data)}`) // 获取所有分组 -export const groupingAll = (data) => axiosApi.get(`/api/groupings?parentId=${data}`); +export const groupingAll = (data) => { + const params = new URLSearchParams(); + if (data) params.append('parentId', data); + return axiosApi.get(`/api/groupings?${params.toString()}`); +}; // 获取所有Markdown文件 export const markdownAll = () => axiosApi.get(`/api/markdown`); // 预览markdown文件 @@ -19,20 +24,15 @@ export const updateMarkdown = (data) => { return axiosApi.post(`/api/markdown/updateMarkdown`, data) } // 批量删除图片 +// 修复:后端接收 JSON 数组,不是 FormData export const deleteImages = (list) => { - const formData = new FormData() - formData.append('urls', list) - return axiosApi.post('/api/images/batch', formData, { - headers: { - 'Content-Type': 'multipart/form-data' - } - }) + return axiosApi.post('/api/images/batch', list) } // 上传图片 -export const uploadImage = (file, userId, markdownId) => { +// 修复:移除 userId 参数,后端从 SecurityContext 获取当前用户 +export const uploadImage = (file, markdownId) => { const formData = new FormData() if (file) formData.append('file', file) - if (userId) formData.append('userId', userId) if (markdownId) formData.append('markdownId', markdownId) return axiosApi.post('/api/images', formData, { headers: { @@ -59,7 +59,11 @@ export const login = (data) => { } // 搜索 -export const searchMarkdown = (keyword) => axiosApi.get(`/api/markdown/search?keyword=${keyword}`); +export const searchMarkdown = (keyword) => { + const params = new URLSearchParams(); + params.append('keyword', keyword); + return axiosApi.get(`/api/markdown/search?${params.toString()}`); +}; // 注册 export const register = (data) => { diff --git a/biji-qianduan/src/components/HomePage.vue b/biji-qianduan/src/components/HomePage.vue index 8151c04..0f383f2 100644 --- a/biji-qianduan/src/components/HomePage.vue +++ b/biji-qianduan/src/components/HomePage.vue @@ -134,6 +134,7 @@ import { onMounted, ref, nextTick, watch, computed, onBeforeUnmount } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus'; import Vditor from 'vditor'; import 'vditor/dist/index.css'; +import { escapeHtml } from '@/utils/security'; import { groupingAll, markdownList, @@ -501,9 +502,11 @@ const handleExport = async (format) => { const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' }); downloadBlob(blob, `${title}.md`); } else if (format === 'html') { - const fullHtml = `${title}

${title}

${previewElement.innerHTML}`; + // 修复:对title进行HTML转义,防止XSS + const escapedTitle = escapeHtml(title); + const fullHtml = `${escapedTitle}

${escapedTitle}

${previewElement.innerHTML}`; const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' }); - downloadBlob(blob, `${title}.html`); + downloadBlob(blob, `${escapedTitle}.html`); } else if (format === 'pdf') { const canvas = await html2canvas(previewElement, { scale: 2, useCORS: true }); const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' }); diff --git a/biji-qianduan/src/components/home/NoteEditor.vue b/biji-qianduan/src/components/home/NoteEditor.vue index 3a562ff..c139d15 100644 --- a/biji-qianduan/src/components/home/NoteEditor.vue +++ b/biji-qianduan/src/components/home/NoteEditor.vue @@ -92,8 +92,14 @@ const initVditor = () => { const save = async (value) => { if (isSaving.value) return; + // 修复:添加空值检查 + if (!vditor.value) { + console.warn('编辑器未初始化'); + return; + } + clearTimeout(saveTimeout); - const content = typeof value === 'string' ? value : vditor.value.getValue(); + const content = typeof value === 'string' ? value : vditor.value?.getValue() || ''; if (content === lastSavedContent.value && currentId.value) { return; diff --git a/biji-qianduan/src/components/home/SidebarMenu.vue b/biji-qianduan/src/components/home/SidebarMenu.vue index 967bc71..bd2a02c 100644 --- a/biji-qianduan/src/components/home/SidebarMenu.vue +++ b/biji-qianduan/src/components/home/SidebarMenu.vue @@ -32,8 +32,9 @@ >