fix(knowledge): make protected file downloads WebView-safe

This commit is contained in:
2026-07-24 13:31:30 +08:00
parent 83c497c5f1
commit 442ba66848
9 changed files with 233 additions and 20 deletions
@@ -1,11 +1,14 @@
package org.dromara.aihr.knowledge.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.ResourceDownloadLink;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeResourceDownloadService;
import org.dromara.aihr.memory.AihrMemoryDto.SourceSnapshot;
import org.dromara.aihr.memory.AihrMemoryService;
import org.dromara.common.core.domain.R;
@@ -31,6 +34,7 @@ import java.util.List;
public class AihrKnowledgeQueryController {
private final AihrKnowledgeQueryService queryService;
private final AihrKnowledgeResourceDownloadService resourceDownloadService;
private final ISysOssService ossService;
private final AihrMemoryService memoryService;
@@ -90,4 +94,18 @@ public class AihrKnowledgeQueryController {
}
ossService.download(ossId, response);
}
@SaCheckLogin
@PostMapping("/resources/{attachmentId}/download-link")
public R<ResourceDownloadLink> resourceDownloadLink(@PathVariable Long attachmentId) {
return R.ok(resourceDownloadService.issue(attachmentId));
}
@SaIgnore
@GetMapping("/resources/{attachmentId}/download")
public void resourceDownload(@PathVariable Long attachmentId,
@RequestParam(value = "ticket", required = false) String ticket,
HttpServletResponse response) throws IOException {
resourceDownloadService.download(attachmentId, ticket, response);
}
}
@@ -70,6 +70,13 @@ public final class AihrKnowledgeQueryDto {
) {
}
/**
* A short-lived, opaque download URL issued only after the current employee's
* knowledge-space access has been checked. It deliberately contains no login token.
*/
public record ResourceDownloadLink(String url) {
}
/**
* A response-safe reference to the company message used as trusted question context.
* The message body is intentionally absent: the employee can only obtain it from the
@@ -488,11 +488,13 @@ public class AihrKnowledgeQueryService {
order by a.id
""".formatted(placeholders(spaceIds.size()), placeholders(docIds.size())),
(rs, rowNum) -> new AttachmentResource(rs.getLong("id"), rs.getString("name")), args.toArray());
return rows.stream()
Map<String, AttachmentResource> firstByTitle = new LinkedHashMap<>();
rows.stream()
.filter(row -> "VIDEO".equals(intent) == isVideo(row.title()))
.forEach(row -> firstByTitle.putIfAbsent(resourceTitleKey(row), row));
return firstByTitle.values().stream()
.map(row -> new Resource(row.attachmentId(), row.title(), intent,
"/api/knowledge/resources/" + row.attachmentId() + "/content"))
.distinct()
.limit(5)
.toList();
}
@@ -653,6 +655,13 @@ public class AihrKnowledgeQueryService {
|| value.endsWith(".mkv") || value.endsWith(".webm") || value.endsWith(".m4v");
}
private static String resourceTitleKey(AttachmentResource resource) {
String title = resource.title();
return hasText(title)
? title.trim().toLowerCase(Locale.ROOT)
: "attachment:" + resource.attachmentId();
}
private static final String PROJECT_SERVICE = "PROJECT_SERVICE";
private static final String BROADCAST_PROMPT_VERSION = "broadcast-context-v1";
private static final int BROADCAST_MODEL_ANSWER_MAX = 1200;
@@ -0,0 +1,73 @@
package org.dromara.aihr.knowledge.service;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.ResourceDownloadLink;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.system.service.ISysOssService;
import org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
/**
* Bridges an authenticated knowledge lookup to a browser-downloadable URL.
* The opaque ticket is intentionally short-lived so a mobile WebView never needs a
* bearer token in its download URL.
*/
@Service
@RequiredArgsConstructor
public class AihrKnowledgeResourceDownloadService {
static final Duration TICKET_TTL = Duration.ofMinutes(2);
private static final String TICKET_PREFIX = "aihr:knowledge:resource-download:";
private static final SecureRandom TICKET_RANDOM = new SecureRandom();
private final AihrKnowledgeQueryService queryService;
private final ISysOssService ossService;
private final RedissonClient redissonClient;
public ResourceDownloadLink issue(Long attachmentId) {
Long ossId = queryService.authorizedResourceOssId(attachmentId);
if (ossId == null) {
throw new ServiceException("资料不存在或无权访问", HttpStatus.NOT_FOUND);
}
String ticket = ticket();
bucket(attachmentId, ticket).set(ossId, TICKET_TTL);
return new ResourceDownloadLink("/api/knowledge/resources/" + attachmentId + "/download?ticket=" + ticket);
}
public void download(Long attachmentId, String ticket, HttpServletResponse response) throws IOException {
if (attachmentId == null || attachmentId <= 0 || !validTicket(ticket)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问");
return;
}
Long ossId = bucket(attachmentId, ticket).get();
if (ossId == null || ossId <= 0) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问");
return;
}
response.setHeader("Cache-Control", "no-store");
response.setHeader("Referrer-Policy", "no-referrer");
ossService.download(ossId, response);
}
private RBucket<Long> bucket(Long attachmentId, String ticket) {
return redissonClient.getBucket(TICKET_PREFIX + attachmentId + ":" + ticket);
}
private static String ticket() {
byte[] bytes = new byte[32];
TICKET_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private static boolean validTicket(String value) {
return value != null && value.length() == 43 && value.matches("[A-Za-z0-9_-]+");
}
}
@@ -237,11 +237,14 @@ class AihrKnowledgeQueryServiceTest {
when(rs.getString("title")).thenReturn("自动扶梯困人应急操作 SOP.pdf");
when(rs.getString("content")).thenReturn("立即停梯并设置警示");
when(rs.getLong("fragment_id")).thenReturn(88L);
} else {
when(rs.getLong("id")).thenReturn(321L);
when(rs.getString("name")).thenReturn("自动扶梯困人应急操作 SOP.pdf");
return List.of(mapper.mapRow(rs, 0));
}
return List.of(mapper.mapRow(rs, 0));
when(rs.getLong("id")).thenReturn(321L);
when(rs.getString("name")).thenReturn("自动扶梯困人应急操作 SOP.pdf");
ResultSet duplicate = mock(ResultSet.class);
when(duplicate.getLong("id")).thenReturn(322L);
when(duplicate.getString("name")).thenReturn("自动扶梯困人应急操作 SOP.pdf");
return List.of(mapper.mapRow(rs, 0), mapper.mapRow(duplicate, 1));
});
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations,
@@ -0,0 +1,75 @@
package org.dromara.aihr.knowledge;
import jakarta.servlet.http.HttpServletResponse;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeResourceDownloadService;
import org.dromara.system.service.ISysOssService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrKnowledgeResourceDownloadServiceTest {
@Test
void issuesOpaqueShortLivedLinkOnlyAfterAuthorizedLookup() {
AihrKnowledgeQueryService queryService = mock(AihrKnowledgeQueryService.class);
ISysOssService ossService = mock(ISysOssService.class);
RedissonClient redisson = mock(RedissonClient.class);
@SuppressWarnings("unchecked")
RBucket<Long> bucket = mock(RBucket.class);
when(queryService.authorizedResourceOssId(321L)).thenReturn(777L);
when(redisson.<Long>getBucket(anyString())).thenReturn(bucket);
var link = new AihrKnowledgeResourceDownloadService(queryService, ossService, redisson).issue(321L);
assertTrue(link.url().matches("/api/knowledge/resources/321/download\\?ticket=[A-Za-z0-9_-]{43}"));
verify(bucket).set(eq(777L), eq(Duration.ofMinutes(2)));
}
@Test
void downloadsOnlyWhenTheAttachmentScopedTicketIsPresent() throws Exception {
AihrKnowledgeQueryService queryService = mock(AihrKnowledgeQueryService.class);
ISysOssService ossService = mock(ISysOssService.class);
RedissonClient redisson = mock(RedissonClient.class);
@SuppressWarnings("unchecked")
RBucket<Long> bucket = mock(RBucket.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String ticket = "a".repeat(43);
when(redisson.<Long>getBucket(anyString())).thenReturn(bucket);
when(bucket.get()).thenReturn(777L);
new AihrKnowledgeResourceDownloadService(queryService, ossService, redisson)
.download(321L, ticket, response);
verify(response).setHeader("Cache-Control", "no-store");
verify(response).setHeader("Referrer-Policy", "no-referrer");
verify(ossService).download(777L, response);
}
@Test
void rejectsMalformedTicketBeforeItCanReadStorage() throws Exception {
AihrKnowledgeQueryService queryService = mock(AihrKnowledgeQueryService.class);
ISysOssService ossService = mock(ISysOssService.class);
RedissonClient redisson = mock(RedissonClient.class);
HttpServletResponse response = mock(HttpServletResponse.class);
new AihrKnowledgeResourceDownloadService(queryService, ossService, redisson)
.download(321L, "bad", response);
verify(response).sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问");
verify(redisson, never()).getBucket(anyString());
verify(ossService, never()).download(org.mockito.ArgumentMatchers.anyLong(), eq(response));
}
}