fix(knowledge): make protected file downloads WebView-safe
This commit is contained in:
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
+11
-2
@@ -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;
|
||||
|
||||
+73
@@ -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_-]+");
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -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,
|
||||
|
||||
+75
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user