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));
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,7 @@
|
||||
<view class="resource-copy">
|
||||
<text class="resource-title">{{ resource.title }}</text>
|
||||
<text class="resource-meta">
|
||||
{{ resource.loading ? '正在安全读取…' : resource.type === 'VIDEO' ? (resource.localUrl ? '已加载,可直接播放' : '点击加载操作视频') : '点击查看或下载原文件' }}
|
||||
{{ resource.loading ? (resource.type === 'VIDEO' ? '正在安全读取…' : '正在生成安全下载链接…') : resource.type === 'VIDEO' ? (resource.localUrl ? '已加载,可直接播放' : '点击加载操作视频') : '点击下载原文件' }}
|
||||
</text>
|
||||
</view>
|
||||
<text class="resource-arrow">›</text>
|
||||
@@ -423,6 +423,7 @@ import {
|
||||
chooseKnowledgeMedia,
|
||||
generateSummaryCard,
|
||||
queryKnowledgeTool,
|
||||
requestKnowledgeResourceDownloadLink,
|
||||
searchKnowledge,
|
||||
searchKnowledgeWithMedia,
|
||||
submitAnswerFeedback
|
||||
@@ -1206,24 +1207,36 @@ const openKnowledgeResource = async (resource: KnowledgeResource) => {
|
||||
if (resource.type === 'VIDEO' && resource.localUrl) return;
|
||||
resource.loading = true;
|
||||
try {
|
||||
if (resource.type === 'FILE') {
|
||||
const downloadUrl = await requestKnowledgeResourceDownloadLink(resource.attachmentId);
|
||||
if (typeof document !== 'undefined') {
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
return;
|
||||
}
|
||||
uni.downloadFile({
|
||||
url: downloadUrl,
|
||||
success: (response) => {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300 && response.tempFilePath) {
|
||||
uni.openDocument({ filePath: response.tempFilePath, showMenu: true });
|
||||
return;
|
||||
}
|
||||
uni.showToast({ title: '文件下载失败,请稍后重试', icon: 'none' });
|
||||
},
|
||||
fail: () => uni.showToast({ title: '文件下载失败,请稍后重试', icon: 'none' })
|
||||
});
|
||||
return;
|
||||
}
|
||||
const localUrl = await authenticatedFileUrl(resource.contentUrl);
|
||||
if (resource.localUrl && resource.localUrl !== localUrl) {
|
||||
releaseAuthenticatedFileUrl(resource.localUrl);
|
||||
}
|
||||
resource.localUrl = localUrl;
|
||||
if (resource.type === 'FILE') {
|
||||
if (typeof document !== 'undefined') {
|
||||
const link = document.createElement('a');
|
||||
link.href = localUrl;
|
||||
link.download = resource.title || '资料文件';
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
} else {
|
||||
uni.openDocument({ filePath: localUrl, showMenu: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
uni.showToast({ title: requestErrorText(error, '资料读取失败'), icon: 'none' });
|
||||
} finally {
|
||||
|
||||
@@ -167,6 +167,17 @@ export const queryKnowledgeTool = async (
|
||||
return normalizeUnifiedResponse(response);
|
||||
};
|
||||
|
||||
export const requestKnowledgeResourceDownloadLink = async (attachmentId: number) => {
|
||||
const response = await apiRequest<{ url: string }>({
|
||||
url: `/api/knowledge/resources/${encodeURIComponent(String(attachmentId))}/download-link`,
|
||||
method: 'POST'
|
||||
});
|
||||
if (!response.url?.startsWith('/api/knowledge/resources/')) {
|
||||
throw new Error('资料下载链接无效');
|
||||
}
|
||||
return apiUrl(response.url);
|
||||
};
|
||||
|
||||
const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): KnowledgeSearchResponse => {
|
||||
const legacy = response.legacy || ({} as KnowledgeSearchResponse);
|
||||
const citationSnippets = (response.citations || []).map((item) => normalizeSnippet({
|
||||
|
||||
@@ -24,9 +24,13 @@ test('问师傅支持显式新对话、版本回写和受控原文件或视频
|
||||
assert.match(header, /class="new-chat-text">新对话/);
|
||||
assert.match(page, /const contextVersion = ref\(0\)/);
|
||||
assert.match(page, /applyConversationState\(nextResult\)/);
|
||||
assert.match(page, /requestKnowledgeResourceDownloadLink\(resource\.attachmentId\)/);
|
||||
assert.match(page, /authenticatedFileUrl\(resource\.contentUrl\)/);
|
||||
assert.match(page, /releaseAuthenticatedFileUrl\(resource\.localUrl\)/);
|
||||
assert.match(page, /resource\.type === 'VIDEO' && resource\.localUrl/);
|
||||
assert.doesNotMatch(page, /link\.href = localUrl/);
|
||||
assert.match(page, /link\.rel = 'noreferrer'/);
|
||||
assert.match(page, /点击下载原文件/);
|
||||
assert.match(page, /msg\.result\.rewrittenQuery !== msg\.result\.queryText/);
|
||||
assert.match(page, /已结合本次对话理解/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user