修复越权删除、畸形链接 500 及两处 NPE

修复上一提交的测试所发现的缺陷。

1) 越权删除(安全,最严重):deleteGalleryByGid 的授权条件是
   `!(collector.isEmpty() || collector.size()==1 && ...)`。当画廊无任何收藏时
   collector.isEmpty() 使整个条件短路放行,下载者身份完全未校验,
   任意有效授权码用户都能删除他人任务记录(无收藏正是最常见情形)。
   改为与提示文案一致的判定:被别人收藏 或 请求者不是下载人 即拒绝。

2) 被拒请求仍删除节点文件且对外显示成功:remoteService.deleteGallery 原先位于
   授权判断之外,即使拒绝也会向节点下发删除指令;且 switch 中 case 0 的
   response.success() 会覆盖先前的 failure。现改为授权失败即提前返回。

3) 畸形链接导致 500:link.split("/")[4] 段数不足抛 ArrayIndexOutOfBoundsException,
   而只捕获 NumberFormatException;项目无 @ControllerAdvice,异常穿透为 500。
   抽出 parseGidFromLink() 统一把 null/段数不足/非数字转成业务失败。

4) Response.isSuccess()/getData()/get() 在键缺失时抛 NPE,改为安全返回
   (isSuccess 视未设置为失败)。deleteGalleryByGid 也显式处理节点无响应(-1),
   原先该分支不设置 result,末尾 response.get("result") 会 NPE。

5) getWeekUsedAmount() 在配置行缺失/值非法时给默认 0,不再 NPE。

验证:新增/改写回归用例后 143 项测试全过;并用真实数据做了新旧对照实测——
取 downloader=4 且无收藏的任务,以 id=25 用户的授权码删除:
  旧 jar:数据库记录被删(count 0),随后 500;
  新 jar:记录保留(count 1),返回「你不是下载人」并被拒。
覆盖率的 GalleryManageService 由 40.1% 升至 45.8%。
This commit is contained in:
root
2026-09-14 15:23:42 +08:00
parent 213ed4e7f6
commit 576092578d
4 changed files with 216 additions and 59 deletions
@@ -60,14 +60,20 @@ public class GalleryManageService {
User user = userMapper.selectUserByAuthCode(AuthCode);
// return Response._failure("调试中,请勿提交任务");
int gid;
try {
gid = Integer.parseInt(link.split("/")[4]);
} catch (NumberFormatException e) {
if (user == null) {
response.failure("授权码无效");
return response.toJSONString();
}
// 段数不足会先抛 ArrayIndexOutOfBoundsException,非数字段抛 NumberFormatException;
// 只捕后者会让畸形链接穿透为 500(本项目无 @ControllerAdvice)。
Integer parsedGid = parseGidFromLink(link);
if (parsedGid == null) {
response.failure("链接错误");
pushService.taskCreateReport(user.getUsername(), "未知任务", response);
return response.toJSONString();
}
int gid = parsedGid;
String taskName = "任务 [" + gid + "]";
if (remoteService.isDead()) {
@@ -126,6 +132,23 @@ public class GalleryManageService {
return response.toJSONString();
}
/**
* 从任务链接中安全提取 gid。任何畸形输入(null、段数不足、非数字)都返回 null,
* 由调用方转成业务失败,避免异常穿透为 500。
*/
static Integer parseGidFromLink(String link) {
if (link == null)
return null;
String[] segments = link.split("/");
if (segments.length <= 4)
return null;
try {
return Integer.parseInt(segments[4]);
} catch (NumberFormatException e) {
return null;
}
}
/**
* 尝试重新连接
* @return 重连结果
@@ -284,21 +307,35 @@ public class GalleryManageService {
response.failure("删除失败,该图片不存在");
return response.toJSONString();
}
if (user == null) {
response.failure("删除失败,授权码无效");
return response.toJSONString();
}
ArrayList<Integer> collector = collectMapper.selectCollectorByGid(gallery.getGid());
if (!(collector.isEmpty() || collector.size() == 1 && collector.getFirst().equals(user.getId()) //判断收藏
&& gallery.getDownloader() == user.getId())) //判断下载
// 拒绝条件与提示文案一致:被别人收藏,或者请求者不是下载人。
// 注意不能写成 collector.isEmpty() || ...:那样在「无任何收藏」时会短路放行,
// 从而完全跳过下载者校验,导致任何有效授权码都能删除他人任务。
boolean collectedByOthers = collector.stream().anyMatch(id -> id != user.getId());
boolean isDownloader = gallery.getDownloader() == user.getId();
if (collectedByOthers || !isDownloader) {
response.failure("删除失败,该图片已被别人收藏或你不是下载人");
else {
log.info("删除图片{}", gallery.getName());
galleryMapper.deleteGalleryByGid(gallery.getGid()); //删除图片记录
log.info("拒绝删除 gid={}:collectedByOthers={} isDownloader={}", gid, collectedByOthers, isDownloader);
return response.toJSONString();
}
// 通过授权后才落库并通知节点,避免被拒请求仍删除节点文件。
log.info("删除图片{}", gallery.getName());
galleryMapper.deleteGalleryByGid(gallery.getGid());
switch (remoteService.deleteGallery(gallery)) {
case ErrorCode.IO_ERROR -> response.failure("图片:" + gallery.getName() + "删除失败,IO错误");
case ErrorCode.FILE_NOT_FOUND -> response.failure("图片:" + gallery.getName() + "删除失败,文件不存在");
case 0 -> response.success();
// 节点无响应/超时会返回 -1 等非枚举值;必须显式判失败,
// 否则 result 保持未设置,末尾的 response.get("result") 会抛 NPE。
default -> response.failure("图片:" + gallery.getName() + "删除失败,节点无响应");
}
if (response.get("result").equals("failure"))
if (!response.isSuccess())
log.info(response.getData());
return response.toJSONString();
}
@@ -313,8 +350,20 @@ public class GalleryManageService {
CustomConfiguration lastResetAmountTime = configurationMapper.selectConfiguration(CustomConfiguration.LAST_RESET_AMOUNT_TIME);
Map<String, String> data = new HashMap<>();
data.put("weekUsedAmount", CustomUtil.fileSizeToString(Long.parseLong(weekUsedAmount.getValue())));
data.put("lastResetAmountTime", lastResetAmountTime.getValue());
// 配置行缺失时给出默认值,避免 NPE 让用量接口整体不可用。
String usedValue = weekUsedAmount == null || weekUsedAmount.getValue() == null
? "0" : weekUsedAmount.getValue();
long used;
try {
used = Long.parseLong(usedValue);
} catch (NumberFormatException e) {
log.warn("每周用量配置值非法,按 0 处理: {}", usedValue);
used = 0L;
}
data.put("weekUsedAmount", CustomUtil.fileSizeToString(used));
data.put("lastResetAmountTime",
lastResetAmountTime == null || lastResetAmountTime.getValue() == null
? "" : lastResetAmountTime.getValue());
response.success(new ObjectMapper().valueToTree(data).toString());
return response.toJSONString();
@@ -19,7 +19,8 @@ public class Response {
}
public String get(String key){
return result.get(key).asText();
JsonNode node = result.get(key);
return node == null ? null : node.asText();
}
public void setData(String data){
@@ -57,7 +58,8 @@ public class Response {
}
public String getData(){
return result.get("data").asText();
JsonNode node = result.get("data");
return node == null ? null : node.asText();
}
/**
@@ -69,8 +71,10 @@ public class Response {
return getData();
}
/** 未设置 result 键时视为失败,而不是抛 NPE。 */
public boolean isSuccess(){
return result.get("result").asText().equals("success");
JsonNode node = result.get("result");
return node != null && "success".equals(node.asText());
}