feat: 添加用户角色字段并实现权限控制
fix(security): 修复重放攻击拦截器的时间戳验证漏洞 refactor(security): 重构验证码工具类使用线程安全实现 perf(login): 优化登录锁定工具类性能并添加定期清理 fix(editor): 修复笔记编辑器空指针问题 style: 清理数据库索引脚本中的冗余注释 fix(api): 修复前端API调用参数编码问题 feat(image): 实现图片名称同步服务 refactor(markdown): 重构Markdown服务分离图片名称同步逻辑 fix(xss): 添加HTML转义函数防止XSS攻击 fix(user): 修复用户服务权限加载问题 fix(rate-limit): 修复速率限制拦截器并发问题 fix(axios): 生产环境隐藏详细错误信息 fix(image): 修复图片上传和删除的权限验证 refactor(captcha): 重构验证码工具类使用并发安全实现 fix(jwt): 修复JWT过滤器空指针问题 fix(export): 修复笔记导出XSS漏洞 fix(search): 修复Markdown搜索SQL注入问题 fix(interceptor): 修复重放攻击拦截器逻辑错误 fix(controller): 修复用户控制器空指针问题 fix(security): 修复nonce生成使用密码学安全方法
This commit is contained in:
@@ -4,13 +4,17 @@ package com.test.bijihoudaun.controller;
|
|||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import com.test.bijihoudaun.common.response.R;
|
import com.test.bijihoudaun.common.response.R;
|
||||||
import com.test.bijihoudaun.entity.Image;
|
import com.test.bijihoudaun.entity.Image;
|
||||||
|
import com.test.bijihoudaun.entity.User;
|
||||||
import com.test.bijihoudaun.service.ImageService;
|
import com.test.bijihoudaun.service.ImageService;
|
||||||
|
import com.test.bijihoudaun.service.UserService;
|
||||||
import com.test.bijihoudaun.util.SecurityUtil;
|
import com.test.bijihoudaun.util.SecurityUtil;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
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.util.StreamUtils;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -18,6 +22,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileInputStream;
|
import java.io.FileInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -33,18 +38,30 @@ public class ImageController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ImageService imageService;
|
private ImageService imageService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
@Operation(summary = "上传图片")
|
@Operation(summary = "上传图片")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public R<Image> uploadImage(
|
public R<Image> uploadImage(
|
||||||
@RequestPart("file") MultipartFile file,
|
@RequestPart("file") MultipartFile file,
|
||||||
@RequestParam(value = "userId", required = false) Long userId,
|
|
||||||
@RequestParam(value = "markdownId", required = false) Long markdownId) {
|
@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<User>().eq("username", username));
|
||||||
|
if (user == null) {
|
||||||
|
return R.fail("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Image image = imageService.uploadImage(file, userId, markdownId);
|
Image image = imageService.uploadImage(file, user.getId(), markdownId);
|
||||||
return R.success(image);
|
return R.success(image);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return R.fail();
|
return R.fail("上传失败:" + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,51 +71,58 @@ public class ImageController {
|
|||||||
if (!SecurityUtil.isUserAuthenticated()) {
|
if (!SecurityUtil.isUserAuthenticated()) {
|
||||||
return R.fail("请先登录");
|
return R.fail("请先登录");
|
||||||
}
|
}
|
||||||
|
// 修复:添加权限验证,确保用户只能删除自己的图片
|
||||||
|
if (!canModifyImage(id)) {
|
||||||
|
return R.fail("无权删除此图片");
|
||||||
|
}
|
||||||
boolean result = imageService.deleteImage(id);
|
boolean result = imageService.deleteImage(id);
|
||||||
if (result) {
|
if (result) {
|
||||||
return R.success();
|
return R.success();
|
||||||
} else {
|
} else {
|
||||||
return R.fail();
|
return R.fail("删除失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "在线预览", description = "浏览器直接打开文件流")
|
@Operation(summary = "在线预览", description = "浏览器直接打开文件流")
|
||||||
@GetMapping("/preview/{url}")
|
@GetMapping("/preview/{url}")
|
||||||
public void preview(@PathVariable String url, HttpServletResponse resp) throws IOException {
|
public void preview(@PathVariable String url, HttpServletResponse resp) throws IOException {
|
||||||
if (StrUtil.isBlank(url)) {
|
// 修复:使用 try-with-resources 确保 PrintWriter 关闭
|
||||||
resp.setStatus(404);
|
try (PrintWriter writer = resp.getWriter()) {
|
||||||
resp.getWriter().write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
|
if (StrUtil.isBlank(url)) {
|
||||||
return;
|
resp.setStatus(404);
|
||||||
}
|
writer.write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
|
||||||
|
return;
|
||||||
String sanitizedUrl = sanitizeFileName(url);
|
}
|
||||||
if (sanitizedUrl == null) {
|
|
||||||
resp.setStatus(403);
|
String sanitizedUrl = sanitizeFileName(url);
|
||||||
resp.getWriter().write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
|
if (sanitizedUrl == null) {
|
||||||
return;
|
resp.setStatus(403);
|
||||||
}
|
writer.write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
|
||||||
|
return;
|
||||||
Path basePath = Paths.get(rootPath).normalize().toAbsolutePath();
|
}
|
||||||
Path filePath = basePath.resolve(sanitizedUrl).normalize();
|
|
||||||
|
Path basePath = Paths.get(rootPath).normalize().toAbsolutePath();
|
||||||
if (!filePath.startsWith(basePath)) {
|
Path filePath = basePath.resolve(sanitizedUrl).normalize();
|
||||||
resp.setStatus(403);
|
|
||||||
resp.getWriter().write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
|
if (!filePath.startsWith(basePath)) {
|
||||||
return;
|
resp.setStatus(403);
|
||||||
}
|
writer.write("{\"code\":403,\"msg\":\"非法文件路径\",\"data\":null}");
|
||||||
|
return;
|
||||||
File file = filePath.toFile();
|
}
|
||||||
if (!file.exists() || !file.isFile()) {
|
|
||||||
resp.setStatus(404);
|
File file = filePath.toFile();
|
||||||
resp.getWriter().write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
|
if (!file.exists() || !file.isFile()) {
|
||||||
return;
|
resp.setStatus(404);
|
||||||
}
|
writer.write("{\"code\":404,\"msg\":\"文件不存在\",\"data\":null}");
|
||||||
|
return;
|
||||||
String contentTypeFromFileExtension = getContentTypeFromFileExtension(url);
|
}
|
||||||
resp.setContentType(contentTypeFromFileExtension);
|
|
||||||
resp.setContentLengthLong(file.length());
|
String contentTypeFromFileExtension = getContentTypeFromFileExtension(url);
|
||||||
try (FileInputStream in = new FileInputStream(file)) {
|
resp.setContentType(contentTypeFromFileExtension);
|
||||||
StreamUtils.copy(in, resp.getOutputStream());
|
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()) {
|
if (!SecurityUtil.isUserAuthenticated()) {
|
||||||
return R.fail("请先登录");
|
return R.fail("请先登录");
|
||||||
}
|
}
|
||||||
|
// 修复:添加权限验证
|
||||||
|
if (!canModifyImageByUrl(url)) {
|
||||||
|
return R.fail("无权删除此图片");
|
||||||
|
}
|
||||||
boolean result = imageService.deleteImageByUrl(url);
|
boolean result = imageService.deleteImageByUrl(url);
|
||||||
if (result) {
|
if (result) {
|
||||||
return R.success();
|
return R.success();
|
||||||
} else {
|
} else {
|
||||||
return R.fail();
|
return R.fail("删除失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,14 +161,62 @@ public class ImageController {
|
|||||||
if (!SecurityUtil.isUserAuthenticated()) {
|
if (!SecurityUtil.isUserAuthenticated()) {
|
||||||
return R.fail("请先登录");
|
return R.fail("请先登录");
|
||||||
}
|
}
|
||||||
|
// 修复:添加权限验证
|
||||||
|
for (String url : urls) {
|
||||||
|
if (!canModifyImageByUrl(url)) {
|
||||||
|
return R.fail("无权删除部分图片");
|
||||||
|
}
|
||||||
|
}
|
||||||
boolean result = imageService.deleteImageByUrls(urls);
|
boolean result = imageService.deleteImageByUrls(urls);
|
||||||
if (result) {
|
if (result) {
|
||||||
return R.success();
|
return R.success();
|
||||||
} else {
|
} 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<User>().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<Image>().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<User>().eq("username", username));
|
||||||
|
if (user == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return user.getId().equals(image.getUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private String getContentTypeFromFileExtension(String fileName) {
|
private String getContentTypeFromFileExtension(String fileName) {
|
||||||
if (StrUtil.isBlank(fileName) || !StrUtil.contains(fileName, '.')) {
|
if (StrUtil.isBlank(fileName) || !StrUtil.contains(fileName, '.')) {
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ public class MarkdownController {
|
|||||||
@PostMapping("/{id}/title")
|
@PostMapping("/{id}/title")
|
||||||
public R<MarkdownFile> updateMarkdownTitle(
|
public R<MarkdownFile> updateMarkdownTitle(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
String title) {
|
@RequestParam String title) {
|
||||||
MarkdownFile updatedFile = markdownFileService.updateMarkdownTitle(id, title);
|
MarkdownFile updatedFile = markdownFileService.updateMarkdownTitle(id, title);
|
||||||
if (ObjectUtil.isNotNull(updatedFile)) {
|
if (ObjectUtil.isNotNull(updatedFile)) {
|
||||||
return R.success(updatedFile);
|
return R.success(updatedFile);
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import org.springframework.security.core.Authentication;
|
|||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/system")
|
@RequestMapping("/api/system")
|
||||||
@Tag(name = "系统管理")
|
@Tag(name = "系统管理")
|
||||||
@@ -31,7 +29,7 @@ public class SystemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/registration/toggle")
|
@PostMapping("/registration/toggle")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@Operation(summary = "切换注册功能状态")
|
@Operation(summary = "切换注册功能状态")
|
||||||
public R<Void> toggleRegistration(@RequestBody Boolean enabled) {
|
public R<Void> toggleRegistration(@RequestBody Boolean enabled) {
|
||||||
systemSettingService.setRegistrationEnabled(enabled);
|
systemSettingService.setRegistrationEnabled(enabled);
|
||||||
@@ -39,7 +37,7 @@ public class SystemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/registration/generate-code")
|
@PostMapping("/registration/generate-code")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
@Operation(summary = "生成注册码")
|
@Operation(summary = "生成注册码")
|
||||||
public R<String> generateRegistrationCode() {
|
public R<String> generateRegistrationCode() {
|
||||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
@@ -47,4 +45,4 @@ public class SystemController {
|
|||||||
String code = registrationCodeService.generateCode(currentUserName);
|
String code = registrationCodeService.generateCode(currentUserName);
|
||||||
return R.success(code);
|
return R.success(code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ public class UserController {
|
|||||||
return R.fail("无效或已过期的注册码");
|
return R.fail("无效或已过期的注册码");
|
||||||
}
|
}
|
||||||
User user = userService.register(username, password, email);
|
User user = userService.register(username, password, email);
|
||||||
|
// 修复:添加空值检查
|
||||||
|
if (user == null) {
|
||||||
|
return R.fail("注册失败,请稍后重试");
|
||||||
|
}
|
||||||
UserVO userVO = new UserVO();
|
UserVO userVO = new UserVO();
|
||||||
BeanUtils.copyProperties(user, userVO);
|
BeanUtils.copyProperties(user, userVO);
|
||||||
userVO.setId(String.valueOf(user.getId()));
|
userVO.setId(String.valueOf(user.getId()));
|
||||||
@@ -69,6 +73,11 @@ public class UserController {
|
|||||||
String token = userService.login(username, password);
|
String token = userService.login(username, password);
|
||||||
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
||||||
|
|
||||||
|
// 修复:添加空值检查
|
||||||
|
if (user == null) {
|
||||||
|
return R.fail("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("token", token);
|
result.put("token", token);
|
||||||
|
|
||||||
@@ -88,7 +97,12 @@ public class UserController {
|
|||||||
@RequireCaptcha("删除账号")
|
@RequireCaptcha("删除账号")
|
||||||
@DeleteMapping("/deleteUser")
|
@DeleteMapping("/deleteUser")
|
||||||
public R<String> deleteUser(){
|
public R<String> 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();
|
String username = userDetails.getUsername();
|
||||||
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
||||||
|
|
||||||
@@ -110,9 +124,20 @@ public class UserController {
|
|||||||
@RequireCaptcha("修改密码")
|
@RequireCaptcha("修改密码")
|
||||||
@PutMapping("/password")
|
@PutMapping("/password")
|
||||||
public R<String> updatePassword(@RequestBody UpdatePasswordBo updatePasswordBo) {
|
public R<String> 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();
|
String username = userDetails.getUsername();
|
||||||
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
User user = userService.getOne(new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<User>().eq("username", username));
|
||||||
|
|
||||||
|
// 修复:添加空值检查
|
||||||
|
if (ObjectUtil.isNull(user)) {
|
||||||
|
return R.fail("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
userService.updatePassword(user.getId(), updatePasswordBo);
|
userService.updatePassword(user.getId(), updatePasswordBo);
|
||||||
return R.success("密码更新成功");
|
return R.success("密码更新成功");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ public class User {
|
|||||||
@TableField("`email`")
|
@TableField("`email`")
|
||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
|
@Schema(description = "用户角色(ADMIN-管理员,USER-普通用户)",implementation = String.class)
|
||||||
|
@TableField("`role`")
|
||||||
|
private String role;
|
||||||
|
|
||||||
@Schema(description = "用户创建时间",implementation = Date.class)
|
@Schema(description = "用户创建时间",implementation = Date.class)
|
||||||
@TableField("created_at")
|
@TableField("created_at")
|
||||||
private Date createdAt;
|
private Date createdAt;
|
||||||
@@ -46,4 +50,4 @@ public class User {
|
|||||||
@Schema(description = "用户token过期时间",implementation = Date.class)
|
@Schema(description = "用户token过期时间",implementation = Date.class)
|
||||||
@TableField("token_enddata")
|
@TableField("token_enddata")
|
||||||
private Date tokenEnddata;
|
private Date tokenEnddata;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.test.bijihoudaun.common.response.R;
|
|||||||
import com.test.bijihoudaun.common.response.ResultCode;
|
import com.test.bijihoudaun.common.response.ResultCode;
|
||||||
import com.test.bijihoudaun.util.JwtTokenUtil;
|
import com.test.bijihoudaun.util.JwtTokenUtil;
|
||||||
import io.jsonwebtoken.ExpiredJwtException;
|
import io.jsonwebtoken.ExpiredJwtException;
|
||||||
|
import io.jsonwebtoken.JwtException;
|
||||||
import io.jsonwebtoken.security.SignatureException;
|
import io.jsonwebtoken.security.SignatureException;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
@@ -18,6 +19,7 @@ import jakarta.servlet.ServletException;
|
|||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
|
||||||
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
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 {
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
|
||||||
// 从请求头中获取认证信息
|
// 从请求头中获取认证信息
|
||||||
String authHeader = request.getHeader(this.tokenHeader);
|
String authHeader = request.getHeader(this.tokenHeader);
|
||||||
// 检查请求头是否存在且以指定的token前缀开头
|
// 修复:添加 tokenHead 空值检查
|
||||||
if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
|
if (authHeader != null && this.tokenHead != null && authHeader.startsWith(this.tokenHead)) {
|
||||||
// 提取实际的token值(去除前缀)
|
// 提取实际的token值(去除前缀)
|
||||||
final String authToken = authHeader.substring(this.tokenHead.length());
|
final String authToken = authHeader.substring(this.tokenHead.length());
|
||||||
try {
|
try {
|
||||||
@@ -76,6 +78,10 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
|||||||
// 处理token签名异常
|
// 处理token签名异常
|
||||||
sendErrorResponse(response, ResultCode.TOKEN_INVALID);
|
sendErrorResponse(response, ResultCode.TOKEN_INVALID);
|
||||||
return;
|
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");
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
// 设置HTTP状态码为401未授权
|
// 设置HTTP状态码为401未授权
|
||||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
// 创建ObjectMapper实例,用于对象与JSON之间的转换
|
// 修复:使用 try-with-resources 确保 PrintWriter 关闭
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
try (PrintWriter writer = response.getWriter()) {
|
||||||
// 将失败结果转换为JSON字符串并写入响应输出流
|
// 创建ObjectMapper实例,用于对象与JSON之间的转换
|
||||||
response.getWriter().write(mapper.writeValueAsString(R.fail(resultCode)));
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
// 将失败结果转换为JSON字符串并写入响应输出流
|
||||||
|
writer.write(mapper.writeValueAsString(R.fail(resultCode)));
|
||||||
|
writer.flush();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,23 +61,17 @@ public class RateLimitInterceptor implements HandlerInterceptor {
|
|||||||
boolean incrementAndCheck(int maxRequests) {
|
boolean incrementAndCheck(int maxRequests) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
lastAccessTime = now;
|
lastAccessTime = now;
|
||||||
long currentWindow = windowStart;
|
|
||||||
|
|
||||||
if (now - currentWindow > WINDOW_SIZE_MS) {
|
synchronized (this) {
|
||||||
// 尝试进入新窗口
|
if (now - windowStart > WINDOW_SIZE_MS) {
|
||||||
synchronized (this) {
|
// 进入新窗口
|
||||||
if (windowStart == currentWindow) {
|
windowStart = now;
|
||||||
// 确实需要新窗口
|
count.set(1);
|
||||||
windowStart = now;
|
return true;
|
||||||
count.set(1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 其他线程已经更新了窗口,继续检查
|
int currentCount = count.incrementAndGet();
|
||||||
|
return currentCount <= maxRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
int currentCount = count.incrementAndGet();
|
|
||||||
return currentCount <= maxRequests;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -107,8 +107,9 @@ public class ReplayAttackInterceptor implements HandlerInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
if (Math.abs(now - timestamp) > TIMESTAMP_VALIDITY) {
|
// 修复:不允许未来时间戳,防止预发送攻击
|
||||||
writeErrorResponse(response, "请求已过期,请重新发起请求");
|
if (timestamp > now || now - timestamp > TIMESTAMP_VALIDITY) {
|
||||||
|
writeErrorResponse(response, "请求时间戳无效或已过期");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<String> strings) {
|
||||||
|
// 查询数据库中已存在的文件名
|
||||||
|
List<ImageName> imageNames = imageNameMapper.selectList(new LambdaUpdateWrapper<ImageName>()
|
||||||
|
.eq(ImageName::getMarkdownId, markdownId));
|
||||||
|
|
||||||
|
// 若是数据库中的数据为null,则插入
|
||||||
|
if (CollUtil.isEmpty(imageNames)) {
|
||||||
|
if (CollUtil.isNotEmpty(strings)) {
|
||||||
|
List<ImageName> 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<String> dbFileNames = imageNames.stream()
|
||||||
|
.map(ImageName::getFileName)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 找出需要新增的文件名(在strings中但不在数据库中)
|
||||||
|
List<String> toInsert = strings.stream()
|
||||||
|
.filter(fileName -> !dbFileNames.contains(fileName))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 找出需要删除的记录(在数据库中但不在strings中)
|
||||||
|
List<ImageName> toDelete = imageNames.stream()
|
||||||
|
.filter(imageName -> !strings.contains(imageName.getFileName()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 插入新增的文件名
|
||||||
|
if (CollUtil.isNotEmpty(toInsert)) {
|
||||||
|
List<ImageName> 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<Long> deleteIds = toDelete.stream()
|
||||||
|
.map(ImageName::getId)
|
||||||
|
.toList();
|
||||||
|
imageNameMapper.deleteByIds(deleteIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,16 +82,22 @@ public class ImageServiceImpl
|
|||||||
|
|
||||||
Path filePath = uploadPath.resolve(storedName);
|
Path filePath = uploadPath.resolve(storedName);
|
||||||
|
|
||||||
// 优化:压缩图片后再保存
|
// 优化:压缩图片后再保存,确保流关闭
|
||||||
InputStream compressedStream = ImageCompressor.compressIfNeeded(
|
try (InputStream originalStream = file.getInputStream()) {
|
||||||
file.getInputStream(), contentType, file.getSize());
|
InputStream compressedStream = ImageCompressor.compressIfNeeded(
|
||||||
|
originalStream, contentType, file.getSize());
|
||||||
|
|
||||||
if (compressedStream != null) {
|
if (compressedStream != null) {
|
||||||
// 使用压缩后的图片
|
// 使用压缩后的图片
|
||||||
Files.copy(compressedStream, filePath);
|
try (compressedStream) {
|
||||||
} else {
|
Files.copy(compressedStream, filePath);
|
||||||
// 使用原图
|
}
|
||||||
Files.copy(file.getInputStream(), filePath);
|
} else {
|
||||||
|
// 使用原图,需要重新获取流
|
||||||
|
try (InputStream newStream = file.getInputStream()) {
|
||||||
|
Files.copy(newStream, filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Image image = new Image();
|
Image image = new Image();
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import com.test.bijihoudaun.util.MarkdownImageExtractor;
|
|||||||
import com.test.bijihoudaun.util.SnowflakeIdGenerator;
|
import com.test.bijihoudaun.util.SnowflakeIdGenerator;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -34,6 +33,8 @@ public class MarkdownFileServiceImpl
|
|||||||
ImageNameMapper imageNameMapper;
|
ImageNameMapper imageNameMapper;
|
||||||
@Resource
|
@Resource
|
||||||
SnowflakeIdGenerator snowflakeIdGenerator;
|
SnowflakeIdGenerator snowflakeIdGenerator;
|
||||||
|
@Resource
|
||||||
|
ImageNameSyncService imageNameSyncService;
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -77,7 +78,8 @@ public class MarkdownFileServiceImpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> strings = MarkdownImageExtractor.extractImageFilenames(markdownFile.getContent());
|
List<String> strings = MarkdownImageExtractor.extractImageFilenames(markdownFile.getContent());
|
||||||
syncImageNames(id, strings);
|
// 修复:调用单独的 Service 处理异步逻辑
|
||||||
|
imageNameSyncService.syncImageNames(id, strings);
|
||||||
|
|
||||||
MarkdownFileVO result = markdownFileMapper.selectByIdWithGrouping(id);
|
MarkdownFileVO result = markdownFileMapper.selectByIdWithGrouping(id);
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
@@ -128,8 +130,13 @@ public class MarkdownFileServiceImpl
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<MarkdownFile> searchByTitle(String keyword) {
|
public List<MarkdownFile> searchByTitle(String keyword) {
|
||||||
|
// 修复:转义特殊字符防止 SQL 注入
|
||||||
|
if (keyword == null || keyword.trim().isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
String escapedKeyword = keyword.replace("%", "\\%").replace("_", "\\_");
|
||||||
QueryWrapper<MarkdownFile> queryWrapper = new QueryWrapper<>();
|
QueryWrapper<MarkdownFile> queryWrapper = new QueryWrapper<>();
|
||||||
queryWrapper.like("title", keyword);
|
queryWrapper.like("title", escapedKeyword);
|
||||||
return this.list(queryWrapper);
|
return this.list(queryWrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,62 +156,4 @@ public class MarkdownFileServiceImpl
|
|||||||
return markdownFileMapper.selectRecentWithGrouping(limit);
|
return markdownFileMapper.selectRecentWithGrouping(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@Async("imageNameSyncExecutor")
|
|
||||||
public void syncImageNames(Long markdownId, List<String> strings) {
|
|
||||||
// 查询数据库中已存在的文件名
|
|
||||||
List<ImageName> imageNames = imageNameMapper.selectList(new LambdaUpdateWrapper<ImageName>()
|
|
||||||
.eq(ImageName::getMarkdownId, markdownId));
|
|
||||||
|
|
||||||
// 若是数据库中的数据为null,则插入
|
|
||||||
if (CollUtil.isEmpty(imageNames)) {
|
|
||||||
if (CollUtil.isNotEmpty(strings)) {
|
|
||||||
List<ImageName> 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<String> dbFileNames = imageNames.stream()
|
|
||||||
.map(ImageName::getFileName)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// 找出需要新增的文件名(在strings中但不在数据库中)
|
|
||||||
List<String> toInsert = strings.stream()
|
|
||||||
.filter(fileName -> !dbFileNames.contains(fileName))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// 找出需要删除的记录(在数据库中但不在strings中)
|
|
||||||
List<ImageName> toDelete = imageNames.stream()
|
|
||||||
.filter(imageName -> !strings.contains(imageName.getFileName()))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// 插入新增的文件名
|
|
||||||
if (CollUtil.isNotEmpty(toInsert)) {
|
|
||||||
List<ImageName> 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<Long> deleteIds = toDelete.stream()
|
|
||||||
.map(ImageName::getId)
|
|
||||||
.toList();
|
|
||||||
imageNameMapper.deleteByIds(deleteIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import com.test.bijihoudaun.util.PasswordUtils;
|
|||||||
import com.test.bijihoudaun.util.UuidV7;
|
import com.test.bijihoudaun.util.UuidV7;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.security.authentication.BadCredentialsException;
|
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.UserDetails;
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
@@ -30,9 +32,9 @@ import java.util.Date;
|
|||||||
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService, UserDetailsService {
|
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService, UserDetailsService {
|
||||||
|
|
||||||
private static final int MIN_USERNAME_LENGTH = 2;
|
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 MIN_PASSWORD_LENGTH = 6;
|
||||||
private static final int MAX_PASSWORD_LENGTH = 12;
|
private static final int MAX_PASSWORD_LENGTH = 128;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private UserMapper userMapper;
|
private UserMapper userMapper;
|
||||||
@@ -43,7 +45,14 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
if (ObjectUtil.isNull(user)) {
|
if (ObjectUtil.isNull(user)) {
|
||||||
throw new UsernameNotFoundException("User not found with username: " + username);
|
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<GrantedAuthority> 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
|
@Override
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
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;
|
private static final int MAX_CAPTCHAS = 5000;
|
||||||
|
|
||||||
// 存储验证码:key=验证码ID,value=验证码记录
|
// 存储验证码:key=验证码ID,value=验证码记录
|
||||||
private static final LRUCache<String, CaptchaRecord> captchaStore = new LRUCache<>(MAX_CAPTCHAS);
|
private static final ConcurrentHashMap<String, CaptchaRecord> captchaStore = new ConcurrentHashMap<>();
|
||||||
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
|
|
||||||
|
|
||||||
// 安全随机数生成器
|
// 使用 ThreadLocal 确保线程安全
|
||||||
private static final SecureRandom random = new SecureRandom();
|
private static final ThreadLocal<SecureRandom> 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 {
|
private static class CaptchaRecord {
|
||||||
String code;
|
String code;
|
||||||
@@ -53,27 +68,6 @@ public class CaptchaUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 简单的 LRU 缓存实现
|
|
||||||
*/
|
|
||||||
private static class LRUCache<K, V> extends LinkedHashMap<K, V> {
|
|
||||||
private final int maxSize;
|
|
||||||
|
|
||||||
LRUCache(int maxSize) {
|
|
||||||
super(maxSize, 0.75f, true);
|
|
||||||
this.maxSize = maxSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
|
|
||||||
boolean shouldRemove = size() > maxSize;
|
|
||||||
if (shouldRemove) {
|
|
||||||
log.warn("验证码存储达到上限,移除最旧的验证码: {}", eldest.getKey());
|
|
||||||
}
|
|
||||||
return shouldRemove;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证码结果
|
* 验证码结果
|
||||||
*/
|
*/
|
||||||
@@ -106,9 +100,6 @@ public class CaptchaUtil {
|
|||||||
throw new RuntimeException("服务器繁忙,请稍后再试");
|
throw new RuntimeException("服务器繁忙,请稍后再试");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理过期验证码
|
|
||||||
cleanupExpiredCaptchas();
|
|
||||||
|
|
||||||
// 生成验证码ID
|
// 生成验证码ID
|
||||||
String captchaId = UuidV7.uuid();
|
String captchaId = UuidV7.uuid();
|
||||||
|
|
||||||
@@ -118,13 +109,8 @@ public class CaptchaUtil {
|
|||||||
// 生成图片
|
// 生成图片
|
||||||
String base64Image = generateImage(code);
|
String base64Image = generateImage(code);
|
||||||
|
|
||||||
lock.writeLock().lock();
|
// 存储验证码
|
||||||
try {
|
captchaStore.put(captchaId, new CaptchaRecord(code));
|
||||||
// 存储验证码
|
|
||||||
captchaStore.put(captchaId, new CaptchaRecord(code));
|
|
||||||
} finally {
|
|
||||||
lock.writeLock().unlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new CaptchaResult(captchaId, base64Image);
|
return new CaptchaResult(captchaId, base64Image);
|
||||||
}
|
}
|
||||||
@@ -140,43 +126,24 @@ public class CaptchaUtil {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
lock.readLock().lock();
|
CaptchaRecord record = captchaStore.get(captchaId);
|
||||||
try {
|
if (record == null || record.isExpired()) {
|
||||||
CaptchaRecord record = captchaStore.get(captchaId);
|
// 验证码不存在或已过期,移除
|
||||||
if (record == null || record.isExpired()) {
|
if (record != null) {
|
||||||
// 验证码不存在或已过期,移除
|
captchaStore.remove(captchaId);
|
||||||
if (record != null) {
|
|
||||||
lock.readLock().unlock();
|
|
||||||
lock.writeLock().lock();
|
|
||||||
try {
|
|
||||||
captchaStore.remove(captchaId);
|
|
||||||
} finally {
|
|
||||||
lock.writeLock().unlock();
|
|
||||||
lock.readLock().lock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
// 验证码比对(不区分大小写)
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证码比对(不区分大小写)
|
||||||
|
boolean success = record.code.equalsIgnoreCase(code);
|
||||||
|
|
||||||
|
// 验证成功后立即删除(一次性使用)
|
||||||
|
if (success) {
|
||||||
|
captchaStore.remove(captchaId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,8 +152,9 @@ public class CaptchaUtil {
|
|||||||
private static String generateCode() {
|
private static String generateCode() {
|
||||||
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
String chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
|
SecureRandom r = random.get();
|
||||||
for (int i = 0; i < CAPTCHA_LENGTH; i++) {
|
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();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
@@ -196,56 +164,59 @@ public class CaptchaUtil {
|
|||||||
*/
|
*/
|
||||||
private static String generateImage(String code) {
|
private static String generateImage(String code) {
|
||||||
BufferedImage image = new BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_RGB);
|
BufferedImage image = new BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||||
Graphics2D g = image.createGraphics();
|
Graphics2D g2d = image.createGraphics();
|
||||||
|
|
||||||
// 设置背景色
|
try {
|
||||||
g.setColor(Color.WHITE);
|
// 设置背景色
|
||||||
g.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
|
g2d.setColor(Color.WHITE);
|
||||||
|
g2d.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
|
||||||
|
|
||||||
// 绘制干扰线
|
// 绘制干扰线
|
||||||
g.setColor(Color.LIGHT_GRAY);
|
g2d.setColor(Color.LIGHT_GRAY);
|
||||||
for (int i = 0; i < 5; i++) {
|
SecureRandom r = random.get();
|
||||||
int x1 = random.nextInt(IMAGE_WIDTH);
|
for (int i = 0; i < 5; i++) {
|
||||||
int y1 = random.nextInt(IMAGE_HEIGHT);
|
int x1 = r.nextInt(IMAGE_WIDTH);
|
||||||
int x2 = random.nextInt(IMAGE_WIDTH);
|
int y1 = r.nextInt(IMAGE_HEIGHT);
|
||||||
int y2 = random.nextInt(IMAGE_HEIGHT);
|
int x2 = r.nextInt(IMAGE_WIDTH);
|
||||||
g.drawLine(x1, y1, x2, y2);
|
int y2 = r.nextInt(IMAGE_HEIGHT);
|
||||||
}
|
g2d.drawLine(x1, y1, x2, y2);
|
||||||
|
}
|
||||||
|
|
||||||
// 绘制验证码字符
|
// 绘制验证码字符
|
||||||
g.setFont(new Font("Arial", Font.BOLD, 24));
|
g2d.setFont(new Font("Arial", Font.BOLD, 24));
|
||||||
int x = 15;
|
int x = 15;
|
||||||
for (int i = 0; i < code.length(); i++) {
|
for (int i = 0; i < code.length(); i++) {
|
||||||
// 随机颜色
|
// 随机颜色
|
||||||
g.setColor(new Color(
|
g2d.setColor(new Color(
|
||||||
random.nextInt(100),
|
r.nextInt(100),
|
||||||
random.nextInt(100),
|
r.nextInt(100),
|
||||||
random.nextInt(100)
|
r.nextInt(100)
|
||||||
));
|
));
|
||||||
// 随机旋转角度
|
// 随机旋转角度
|
||||||
int angle = random.nextInt(30) - 15;
|
int angle = r.nextInt(30) - 15;
|
||||||
g.rotate(Math.toRadians(angle), x + 10, 25);
|
g2d.rotate(Math.toRadians(angle), x + 10, 25);
|
||||||
g.drawString(String.valueOf(code.charAt(i)), x, 28);
|
g2d.drawString(String.valueOf(code.charAt(i)), x, 28);
|
||||||
g.rotate(-Math.toRadians(angle), x + 10, 25);
|
g2d.rotate(-Math.toRadians(angle), x + 10, 25);
|
||||||
x += 25;
|
x += 25;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加噪点
|
// 添加噪点
|
||||||
for (int i = 0; i < 30; i++) {
|
for (int i = 0; i < 30; i++) {
|
||||||
int x1 = random.nextInt(IMAGE_WIDTH);
|
int x1 = r.nextInt(IMAGE_WIDTH);
|
||||||
int y1 = random.nextInt(IMAGE_HEIGHT);
|
int y1 = r.nextInt(IMAGE_HEIGHT);
|
||||||
image.setRGB(x1, y1, Color.GRAY.getRGB());
|
image.setRGB(x1, y1, Color.GRAY.getRGB());
|
||||||
}
|
}
|
||||||
|
|
||||||
g.dispose();
|
// 转为 Base64
|
||||||
|
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||||
// 转为 Base64
|
ImageIO.write(image, "png", baos);
|
||||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
byte[] imageBytes = baos.toByteArray();
|
||||||
ImageIO.write(image, "png", baos);
|
return "data:image/png;base64," + java.util.Base64.getEncoder().encodeToString(imageBytes);
|
||||||
byte[] imageBytes = baos.toByteArray();
|
}
|
||||||
return "data:image/png;base64," + java.util.Base64.getEncoder().encodeToString(imageBytes);
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("生成验证码图片失败", e);
|
throw new RuntimeException("生成验证码图片失败", e);
|
||||||
|
} finally {
|
||||||
|
g2d.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,16 +224,19 @@ public class CaptchaUtil {
|
|||||||
* 清理过期验证码
|
* 清理过期验证码
|
||||||
*/
|
*/
|
||||||
private static void cleanupExpiredCaptchas() {
|
private static void cleanupExpiredCaptchas() {
|
||||||
// 每80%容量时清理一次,减少开销
|
|
||||||
if (captchaStore.size() < MAX_CAPTCHAS * 0.8) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock.writeLock().lock();
|
|
||||||
try {
|
try {
|
||||||
captchaStore.entrySet().removeIf(entry -> entry.getValue().isExpired());
|
int removed = 0;
|
||||||
} finally {
|
for (Map.Entry<String, CaptchaRecord> entry : captchaStore.entrySet()) {
|
||||||
lock.writeLock().unlock();
|
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() {
|
public static int getCaptchaCount() {
|
||||||
lock.readLock().lock();
|
return captchaStore.size();
|
||||||
try {
|
|
||||||
return captchaStore.size();
|
|
||||||
} finally {
|
|
||||||
lock.readLock().unlock();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import java.time.LocalDateTime;
|
|||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
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;
|
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录锁定工具类
|
* 登录锁定工具类
|
||||||
* 使用本地内存存储登录失败记录,带容量限制
|
* 使用本地内存存储登录失败记录,带容量限制和定期清理
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class LoginLockUtil {
|
public class LoginLockUtil {
|
||||||
@@ -28,6 +31,19 @@ public class LoginLockUtil {
|
|||||||
private static final LRUCache<String, LoginAttempt> attempts = new LRUCache<>(MAX_RECORDS);
|
private static final LRUCache<String, LoginAttempt> attempts = new LRUCache<>(MAX_RECORDS);
|
||||||
private static final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
|
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 {
|
private static class LoginAttempt {
|
||||||
int failedCount;
|
int failedCount;
|
||||||
LocalDateTime lastAttemptTime;
|
LocalDateTime lastAttemptTime;
|
||||||
@@ -38,6 +54,10 @@ public class LoginLockUtil {
|
|||||||
this.lastAttemptTime = LocalDateTime.now();
|
this.lastAttemptTime = LocalDateTime.now();
|
||||||
this.lockUntil = null;
|
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) {
|
public static void recordFailedAttempt(String username) {
|
||||||
if (username == null || username.isEmpty()) return;
|
if (username == null || username.isEmpty()) return;
|
||||||
|
|
||||||
cleanupExpiredRecords();
|
|
||||||
|
|
||||||
lock.writeLock().lock();
|
lock.writeLock().lock();
|
||||||
try {
|
try {
|
||||||
LoginAttempt attempt = attempts.computeIfAbsent(username, k -> new LoginAttempt());
|
LoginAttempt attempt = attempts.computeIfAbsent(username, k -> new LoginAttempt());
|
||||||
@@ -116,20 +134,7 @@ public class LoginLockUtil {
|
|||||||
|
|
||||||
// 检查是否仍在锁定时间内
|
// 检查是否仍在锁定时间内
|
||||||
if (attempt.lockUntil != null) {
|
if (attempt.lockUntil != null) {
|
||||||
if (LocalDateTime.now().isBefore(attempt.lockUntil)) {
|
return 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 false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -177,14 +182,9 @@ public class LoginLockUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理过期记录
|
* 清理过期的锁定记录
|
||||||
*/
|
*/
|
||||||
private static void cleanupExpiredRecords() {
|
private static void cleanupExpiredLocks() {
|
||||||
// 每100次操作清理一次,减少开销
|
|
||||||
if (attempts.size() < MAX_RECORDS * 0.8) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock.writeLock().lock();
|
lock.writeLock().lock();
|
||||||
try {
|
try {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@@ -192,7 +192,7 @@ public class LoginLockUtil {
|
|||||||
LoginAttempt attempt = entry.getValue();
|
LoginAttempt attempt = entry.getValue();
|
||||||
// 未锁定且长时间没有登录的记录
|
// 未锁定且长时间没有登录的记录
|
||||||
if (attempt.lockUntil == null) {
|
if (attempt.lockUntil == null) {
|
||||||
return ChronoUnit.MINUTES.between(attempt.lastAttemptTime, now) > RECORD_EXPIRE_MINUTES;
|
return attempt.isExpired();
|
||||||
}
|
}
|
||||||
// 锁定已过期的记录
|
// 锁定已过期的记录
|
||||||
return now.isAfter(attempt.lockUntil);
|
return now.isAfter(attempt.lockUntil);
|
||||||
|
|||||||
@@ -1,38 +1,24 @@
|
|||||||
-- 数据库性能优化索引
|
-- 数据库性能优化索引(完整修正版)
|
||||||
-- 创建时间: 2024-03-03
|
|
||||||
|
|
||||||
-- markdown_file 表索引
|
-- ==================== markdown_file 表索引 ====================
|
||||||
-- 按分组查询
|
CREATE INDEX idx_markdown_grouping_id ON markdown_file(grouping_id);
|
||||||
CREATE INDEX IF NOT EXISTS 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 IF NOT EXISTS idx_markdown_is_deleted ON markdown_file(is_deleted);
|
CREATE INDEX idx_markdown_grouping_deleted ON markdown_file(grouping_id, 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);
|
|
||||||
|
|
||||||
-- image 表索引
|
-- ==================== image 表索引 ====================
|
||||||
-- 按 markdown_id 查询(关联查询)
|
CREATE INDEX idx_image_markdown_id ON image(markdown_id);
|
||||||
CREATE INDEX IF NOT EXISTS 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);
|
||||||
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);
|
|
||||||
|
|
||||||
-- grouping 表索引
|
-- ==================== grouping 表索引(注意反引号!)====================
|
||||||
-- 按父分组查询
|
CREATE INDEX idx_grouping_parent_id ON `grouping`(parentId);
|
||||||
CREATE INDEX IF NOT EXISTS idx_grouping_parent_id ON grouping(parent_id);
|
CREATE INDEX idx_grouping_is_deleted ON `grouping`(is_deleted);
|
||||||
-- 按删除状态查询
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_grouping_is_deleted ON grouping(is_deleted);
|
|
||||||
|
|
||||||
-- trash 表索引
|
-- ==================== trash 表索引 ====================
|
||||||
-- 按用户查询
|
CREATE INDEX idx_trash_user_id ON trash(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS 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);
|
||||||
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);
|
|
||||||
|
|
||||||
-- user 表索引
|
-- ==================== user 表索引 ====================
|
||||||
-- 按用户名查询(登录)
|
CREATE INDEX idx_user_username ON user(username);
|
||||||
CREATE INDEX IF NOT EXISTS idx_user_username ON user(username);
|
|
||||||
@@ -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`);
|
||||||
@@ -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文件
|
// 获取所有Markdown文件
|
||||||
export const markdownAll = () => axiosApi.get(`/api/markdown`);
|
export const markdownAll = () => axiosApi.get(`/api/markdown`);
|
||||||
// 预览markdown文件
|
// 预览markdown文件
|
||||||
@@ -19,20 +24,15 @@ export const updateMarkdown = (data) => {
|
|||||||
return axiosApi.post(`/api/markdown/updateMarkdown`, data)
|
return axiosApi.post(`/api/markdown/updateMarkdown`, data)
|
||||||
}
|
}
|
||||||
// 批量删除图片
|
// 批量删除图片
|
||||||
|
// 修复:后端接收 JSON 数组,不是 FormData
|
||||||
export const deleteImages = (list) => {
|
export const deleteImages = (list) => {
|
||||||
const formData = new FormData()
|
return axiosApi.post('/api/images/batch', list)
|
||||||
formData.append('urls', list)
|
|
||||||
return axiosApi.post('/api/images/batch', formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// 上传图片
|
// 上传图片
|
||||||
export const uploadImage = (file, userId, markdownId) => {
|
// 修复:移除 userId 参数,后端从 SecurityContext 获取当前用户
|
||||||
|
export const uploadImage = (file, markdownId) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
if (file) formData.append('file', file)
|
if (file) formData.append('file', file)
|
||||||
if (userId) formData.append('userId', userId)
|
|
||||||
if (markdownId) formData.append('markdownId', markdownId)
|
if (markdownId) formData.append('markdownId', markdownId)
|
||||||
return axiosApi.post('/api/images', formData, {
|
return axiosApi.post('/api/images', formData, {
|
||||||
headers: {
|
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) => {
|
export const register = (data) => {
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ import { onMounted, ref, nextTick, watch, computed, onBeforeUnmount } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import Vditor from 'vditor';
|
import Vditor from 'vditor';
|
||||||
import 'vditor/dist/index.css';
|
import 'vditor/dist/index.css';
|
||||||
|
import { escapeHtml } from '@/utils/security';
|
||||||
import {
|
import {
|
||||||
groupingAll,
|
groupingAll,
|
||||||
markdownList,
|
markdownList,
|
||||||
@@ -501,9 +502,11 @@ const handleExport = async (format) => {
|
|||||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||||
downloadBlob(blob, `${title}.md`);
|
downloadBlob(blob, `${title}.md`);
|
||||||
} else if (format === 'html') {
|
} else if (format === 'html') {
|
||||||
const fullHtml = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${title}</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css"><style>body{box-sizing:border-box;min-width:200px;max-width:980px;margin:0 auto;padding:45px;}</style></head><body class="markdown-body"><h1>${title}</h1>${previewElement.innerHTML}</body></html>`;
|
// 修复:对title进行HTML转义,防止XSS
|
||||||
|
const escapedTitle = escapeHtml(title);
|
||||||
|
const fullHtml = `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>${escapedTitle}</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css"><style>body{box-sizing:border-box;min-width:200px;max-width:980px;margin:0 auto;padding:45px;}</style></head><body class="markdown-body"><h1>${escapedTitle}</h1>${previewElement.innerHTML}</body></html>`;
|
||||||
const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' });
|
const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' });
|
||||||
downloadBlob(blob, `${title}.html`);
|
downloadBlob(blob, `${escapedTitle}.html`);
|
||||||
} else if (format === 'pdf') {
|
} else if (format === 'pdf') {
|
||||||
const canvas = await html2canvas(previewElement, { scale: 2, useCORS: true });
|
const canvas = await html2canvas(previewElement, { scale: 2, useCORS: true });
|
||||||
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' });
|
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' });
|
||||||
|
|||||||
@@ -92,8 +92,14 @@ const initVditor = () => {
|
|||||||
const save = async (value) => {
|
const save = async (value) => {
|
||||||
if (isSaving.value) return;
|
if (isSaving.value) return;
|
||||||
|
|
||||||
|
// 修复:添加空值检查
|
||||||
|
if (!vditor.value) {
|
||||||
|
console.warn('编辑器未初始化');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(saveTimeout);
|
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) {
|
if (content === lastSavedContent.value && currentId.value) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -32,8 +32,9 @@
|
|||||||
>
|
>
|
||||||
<div class="menu-section" v-if="isMobile">
|
<div class="menu-section" v-if="isMobile">
|
||||||
<div v-if="userStore.isLoggedIn" class="user-info">
|
<div v-if="userStore.isLoggedIn" class="user-info">
|
||||||
<el-avatar :size="40" class="user-avatar">{{ userStore.userInfo?.username?.charAt(0)?.toUpperCase() }}</el-avatar>
|
<!-- 修复:添加默认值防止空指针 -->
|
||||||
<span class="username">{{ userStore.userInfo?.username }}</span>
|
<el-avatar :size="40" class="user-avatar">{{ userStore.userInfo?.username?.charAt(0)?.toUpperCase() || '?' }}</el-avatar>
|
||||||
|
<span class="username">{{ userStore.userInfo?.username || '访客' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="guest-info">
|
<div v-else class="guest-info">
|
||||||
<el-button type="primary" @click="goToLogin">登录</el-button>
|
<el-button type="primary" @click="goToLogin">登录</el-button>
|
||||||
|
|||||||
@@ -92,8 +92,11 @@ instance.interceptors.response.use(
|
|||||||
return Promise.reject(new Error(msg));
|
return Promise.reject(new Error(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他错误,显示后端返回的消息
|
// 其他错误,生产环境隐藏详细错误信息
|
||||||
const msg = data?.msg || error.message;
|
const isDev = import.meta.env.DEV;
|
||||||
|
const msg = isDev
|
||||||
|
? (data?.msg || error.message)
|
||||||
|
: (data?.msg || '操作失败,请稍后重试');
|
||||||
ElMessage.error(msg);
|
ElMessage.error(msg);
|
||||||
} else {
|
} else {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
|
|||||||
@@ -5,15 +5,15 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成随机 nonce (32位随机字符串)
|
* 生成随机 nonce (32位随机字符串)
|
||||||
|
* 使用 crypto.getRandomValues 确保密码学安全
|
||||||
* @returns {string} nonce
|
* @returns {string} nonce
|
||||||
*/
|
*/
|
||||||
export function generateNonce() {
|
export function generateNonce() {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
const array = new Uint8Array(24); // 24 bytes = 32 base64 chars
|
||||||
let nonce = '';
|
crypto.getRandomValues(array);
|
||||||
for (let i = 0; i < 32; i++) {
|
return btoa(String.fromCharCode(...array))
|
||||||
nonce += chars.charAt(Math.floor(Math.random() * chars.length));
|
.replace(/[+/=]/g, '') // 移除特殊字符
|
||||||
}
|
.substring(0, 32);
|
||||||
return nonce;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,3 +61,15 @@ export function needsReplayAttackValidation(method, url) {
|
|||||||
}
|
}
|
||||||
return REPLAY_ATTACK_METHODS.includes(method.toUpperCase());
|
return REPLAY_ATTACK_METHODS.includes(method.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML 转义函数,防止 XSS
|
||||||
|
* @param {string} text 需要转义的文本
|
||||||
|
* @returns {string} 转义后的文本
|
||||||
|
*/
|
||||||
|
export function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user