feat(api-security): encrypt sensitive data at rest
This commit is contained in:
@@ -63,3 +63,11 @@ MAIL_SMTP_PASS="replace-with-smtp-password"
|
|||||||
# 发件人显示名称与地址
|
# 发件人显示名称与地址
|
||||||
MAIL_FROM_NAME="TodoList"
|
MAIL_FROM_NAME="TodoList"
|
||||||
MAIL_FROM_ADDRESS="no-reply@example.com"
|
MAIL_FROM_ADDRESS="no-reply@example.com"
|
||||||
|
|
||||||
|
# [数据加密] 服务端敏感数据加密主密钥
|
||||||
|
# 用于加密 AI 配置、任务内容、同步 payload、附件元数据等数据库字段
|
||||||
|
# 请使用高强度随机字符串,生产环境务必单独保管
|
||||||
|
DATA_ENCRYPTION_SECRET="replace-with-a-long-random-secret"
|
||||||
|
|
||||||
|
# [对象存储加密] 服务端对象加密策略,默认使用 AES256;如需关闭可填写 NONE
|
||||||
|
S3_SERVER_SIDE_ENCRYPTION="AES256"
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "TodoList API service",
|
"description": "TodoList API service",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "node -e \"require('node:fs').rmSync('generated/prisma', { recursive: true, force: true })\" && prisma generate",
|
||||||
"prisma:format": "prisma format",
|
"prisma:format": "prisma format",
|
||||||
"prisma:validate": "prisma validate",
|
"prisma:validate": "prisma validate",
|
||||||
"prebuild": "pnpm run prisma:generate",
|
"prebuild": "pnpm run prisma:generate",
|
||||||
"pretypecheck": "pnpm run prisma:generate",
|
"pretypecheck": "pnpm run prisma:generate",
|
||||||
"pretest": "pnpm run prisma:generate",
|
"pretest": "pnpm run prisma:generate",
|
||||||
|
"data:reencrypt": "ts-node scripts/reencrypt-sensitive-data.ts",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"start:dev": "ts-node-dev --respawn --transpile-only src/main.ts",
|
"start:dev": "ts-node-dev --respawn --transpile-only src/main.ts",
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Prisma, PrismaClient } from "../generated/prisma/client";
|
||||||
|
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
||||||
|
|
||||||
|
type MigrationCounter = Record<
|
||||||
|
"aiBindings" | "publicPools" | "tasks" | "attachments" | "syncOperations",
|
||||||
|
number
|
||||||
|
>;
|
||||||
|
|
||||||
|
function createEncryptionService(): DataEncryptionService {
|
||||||
|
const configService = {
|
||||||
|
get: (key: string) => process.env[key]
|
||||||
|
} as ConfigService;
|
||||||
|
|
||||||
|
return new DataEncryptionService(configService);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptStringIfNeeded(
|
||||||
|
value: string | null,
|
||||||
|
dataEncryptionService: DataEncryptionService
|
||||||
|
): string | null | undefined {
|
||||||
|
if (value === null || dataEncryptionService.isEncryptedString(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataEncryptionService.encryptString(value) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptJsonIfNeeded(
|
||||||
|
value: Prisma.JsonValue | null,
|
||||||
|
dataEncryptionService: DataEncryptionService
|
||||||
|
): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | undefined {
|
||||||
|
if (value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" && dataEncryptionService.isEncryptedString(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (dataEncryptionService.encryptJson(value as Prisma.InputJsonValue) ?? Prisma.JsonNull) as
|
||||||
|
| Prisma.InputJsonValue
|
||||||
|
| Prisma.NullableJsonNullValueInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
if (!process.env["DATABASE_URL"]) {
|
||||||
|
throw new Error("缺少 DATABASE_URL,无法执行敏感数据迁移");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env["DATA_ENCRYPTION_SECRET"]) {
|
||||||
|
throw new Error("缺少 DATA_ENCRYPTION_SECRET,无法执行敏感数据迁移");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
adapter: new PrismaPg({
|
||||||
|
connectionString: process.env["DATABASE_URL"]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const dataEncryptionService = createEncryptionService();
|
||||||
|
const counter: MigrationCounter = {
|
||||||
|
aiBindings: 0,
|
||||||
|
publicPools: 0,
|
||||||
|
tasks: 0,
|
||||||
|
attachments: 0,
|
||||||
|
syncOperations: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const aiBindings = await prisma.aiProviderBinding.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
providerName: true,
|
||||||
|
model: true,
|
||||||
|
configId: true,
|
||||||
|
configName: true,
|
||||||
|
endpoint: true,
|
||||||
|
encryptedApiKey: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const binding of aiBindings) {
|
||||||
|
const data: Prisma.AiProviderBindingUpdateInput = {};
|
||||||
|
const providerName = encryptStringIfNeeded(binding.providerName, dataEncryptionService);
|
||||||
|
const model = encryptStringIfNeeded(binding.model, dataEncryptionService);
|
||||||
|
const configId = encryptStringIfNeeded(binding.configId, dataEncryptionService);
|
||||||
|
const configName = encryptStringIfNeeded(binding.configName, dataEncryptionService);
|
||||||
|
const endpoint = encryptStringIfNeeded(binding.endpoint, dataEncryptionService);
|
||||||
|
const encryptedApiKey = encryptStringIfNeeded(binding.encryptedApiKey, dataEncryptionService);
|
||||||
|
|
||||||
|
if (providerName !== undefined) {
|
||||||
|
data.providerName = providerName;
|
||||||
|
}
|
||||||
|
if (model !== undefined) {
|
||||||
|
data.model = model;
|
||||||
|
}
|
||||||
|
if (configId !== undefined) {
|
||||||
|
data.configId = configId;
|
||||||
|
}
|
||||||
|
if (configName !== undefined) {
|
||||||
|
data.configName = configName;
|
||||||
|
}
|
||||||
|
if (endpoint !== undefined) {
|
||||||
|
data.endpoint = endpoint;
|
||||||
|
}
|
||||||
|
if (encryptedApiKey !== undefined) {
|
||||||
|
data.encryptedApiKey = encryptedApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.aiProviderBinding.update({
|
||||||
|
where: {
|
||||||
|
id: binding.id
|
||||||
|
},
|
||||||
|
data
|
||||||
|
});
|
||||||
|
counter.aiBindings += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicPools = await prisma.aiPublicPoolConfig.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
providerName: true,
|
||||||
|
model: true,
|
||||||
|
endpoint: true,
|
||||||
|
encryptedApiKey: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const publicPool of publicPools) {
|
||||||
|
const data: Prisma.AiPublicPoolConfigUpdateInput = {};
|
||||||
|
const providerName = encryptStringIfNeeded(publicPool.providerName, dataEncryptionService);
|
||||||
|
const model = encryptStringIfNeeded(publicPool.model, dataEncryptionService);
|
||||||
|
const endpoint = encryptStringIfNeeded(publicPool.endpoint, dataEncryptionService);
|
||||||
|
const encryptedApiKey = encryptStringIfNeeded(
|
||||||
|
publicPool.encryptedApiKey,
|
||||||
|
dataEncryptionService
|
||||||
|
);
|
||||||
|
|
||||||
|
if (providerName !== undefined) {
|
||||||
|
data.providerName = providerName;
|
||||||
|
}
|
||||||
|
if (model !== undefined) {
|
||||||
|
data.model = model;
|
||||||
|
}
|
||||||
|
if (endpoint !== undefined) {
|
||||||
|
data.endpoint = endpoint;
|
||||||
|
}
|
||||||
|
if (encryptedApiKey !== undefined) {
|
||||||
|
data.encryptedApiKey = encryptedApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.aiPublicPoolConfig.update({
|
||||||
|
where: {
|
||||||
|
id: publicPool.id
|
||||||
|
},
|
||||||
|
data
|
||||||
|
});
|
||||||
|
counter.publicPools += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = await prisma.task.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
contentJson: true,
|
||||||
|
contentText: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const data: Prisma.TaskUpdateInput = {};
|
||||||
|
const title = encryptStringIfNeeded(task.title, dataEncryptionService);
|
||||||
|
const contentJson = encryptJsonIfNeeded(task.contentJson, dataEncryptionService);
|
||||||
|
const contentText = encryptStringIfNeeded(task.contentText, dataEncryptionService);
|
||||||
|
|
||||||
|
if (title !== undefined) {
|
||||||
|
data.title = title;
|
||||||
|
}
|
||||||
|
if (contentJson !== undefined) {
|
||||||
|
data.contentJson = contentJson;
|
||||||
|
}
|
||||||
|
if (contentText !== undefined) {
|
||||||
|
data.contentText = contentText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.task.update({
|
||||||
|
where: {
|
||||||
|
id: task.id
|
||||||
|
},
|
||||||
|
data
|
||||||
|
});
|
||||||
|
counter.tasks += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = await prisma.attachment.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
url: true,
|
||||||
|
fileName: true,
|
||||||
|
checksum: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
const data: Prisma.AttachmentUpdateInput = {};
|
||||||
|
const url = encryptStringIfNeeded(attachment.url, dataEncryptionService);
|
||||||
|
const fileName = encryptStringIfNeeded(attachment.fileName, dataEncryptionService);
|
||||||
|
const checksum = encryptStringIfNeeded(attachment.checksum, dataEncryptionService);
|
||||||
|
|
||||||
|
if (url !== undefined) {
|
||||||
|
data.url = url;
|
||||||
|
}
|
||||||
|
if (fileName !== undefined) {
|
||||||
|
data.fileName = fileName;
|
||||||
|
}
|
||||||
|
if (checksum !== undefined) {
|
||||||
|
data.checksum = checksum;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.attachment.update({
|
||||||
|
where: {
|
||||||
|
id: attachment.id
|
||||||
|
},
|
||||||
|
data
|
||||||
|
});
|
||||||
|
counter.attachments += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncOperations = await prisma.syncOperation.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
payload: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const operation of syncOperations) {
|
||||||
|
if (operation.payload === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextPayload: string | null = null;
|
||||||
|
if (typeof operation.payload === "string") {
|
||||||
|
if (dataEncryptionService.isEncryptedString(operation.payload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPayload = dataEncryptionService.encryptString(operation.payload) ?? null;
|
||||||
|
} else {
|
||||||
|
nextPayload =
|
||||||
|
dataEncryptionService.encryptString(JSON.stringify(operation.payload)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPayload === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.syncOperation.update({
|
||||||
|
where: {
|
||||||
|
id: operation.id
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
payload: nextPayload
|
||||||
|
}
|
||||||
|
});
|
||||||
|
counter.syncOperations += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("敏感数据迁移完成");
|
||||||
|
console.log(JSON.stringify(counter, null, 2));
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : "未知错误";
|
||||||
|
console.error(`敏感数据迁移失败:${message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
TaskStatus
|
TaskStatus
|
||||||
} from "../../generated/prisma/client";
|
} from "../../generated/prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { DataEncryptionService } from "../security/data-encryption.service";
|
||||||
import { AiProviderRegistryService } from "./ai-provider-registry.service";
|
import { AiProviderRegistryService } from "./ai-provider-registry.service";
|
||||||
import { AiChatDto } from "./dto/ai-chat.dto";
|
import { AiChatDto } from "./dto/ai-chat.dto";
|
||||||
import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto";
|
import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto";
|
||||||
@@ -93,7 +94,8 @@ export class AiService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly aiProviderRegistryService: AiProviderRegistryService
|
private readonly aiProviderRegistryService: AiProviderRegistryService,
|
||||||
|
private readonly dataEncryptionService: DataEncryptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listBindings(userId: string): Promise<ListAiBindingsResponse> {
|
async listBindings(userId: string): Promise<ListAiBindingsResponse> {
|
||||||
@@ -119,8 +121,8 @@ export class AiService {
|
|||||||
publicPool: publicPool
|
publicPool: publicPool
|
||||||
? {
|
? {
|
||||||
enabled: publicPool.enabled,
|
enabled: publicPool.enabled,
|
||||||
providerName: publicPool.providerName,
|
providerName: this.readDecryptedString(publicPool.providerName),
|
||||||
model: publicPool.model,
|
model: this.readDecryptedString(publicPool.model),
|
||||||
hasApiKey: Boolean(publicPool.encryptedApiKey)
|
hasApiKey: Boolean(publicPool.encryptedApiKey)
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
@@ -191,12 +193,12 @@ export class AiService {
|
|||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
channel: dto.channel,
|
channel: dto.channel,
|
||||||
providerName: this.normalizeProviderName(dto.providerName),
|
providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)),
|
||||||
model: this.normalizeOptionalString(dto.model),
|
model: this.encryptOptionalString(dto.model),
|
||||||
configId: this.normalizeOptionalString(dto.configId),
|
configId: this.encryptOptionalString(dto.configId),
|
||||||
configName: this.normalizeOptionalString(dto.configName),
|
configName: this.encryptOptionalString(dto.configName),
|
||||||
endpoint: this.normalizeOptionalString(dto.endpoint),
|
endpoint: this.encryptOptionalString(dto.endpoint),
|
||||||
encryptedApiKey: this.normalizeOptionalString(dto.apiKey),
|
encryptedApiKey: this.encryptOptionalString(dto.apiKey),
|
||||||
isEnabled: dto.isEnabled ?? true
|
isEnabled: dto.isEnabled ?? true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -204,19 +206,19 @@ export class AiService {
|
|||||||
|
|
||||||
const updateData: Prisma.AiProviderBindingUpdateInput = {
|
const updateData: Prisma.AiProviderBindingUpdateInput = {
|
||||||
channel: dto.channel,
|
channel: dto.channel,
|
||||||
providerName: this.normalizeProviderName(dto.providerName),
|
providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)),
|
||||||
model: this.normalizeOptionalString(dto.model),
|
model: this.encryptOptionalString(dto.model),
|
||||||
configId: this.normalizeOptionalString(dto.configId),
|
configId: this.encryptOptionalString(dto.configId),
|
||||||
configName: this.normalizeOptionalString(dto.configName),
|
configName: this.encryptOptionalString(dto.configName),
|
||||||
isEnabled: dto.isEnabled ?? existingBinding.isEnabled
|
isEnabled: dto.isEnabled ?? existingBinding.isEnabled
|
||||||
};
|
};
|
||||||
|
|
||||||
if (dto.endpoint !== undefined) {
|
if (dto.endpoint !== undefined) {
|
||||||
updateData.endpoint = this.normalizeOptionalString(dto.endpoint);
|
updateData.endpoint = this.encryptOptionalString(dto.endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.apiKey !== undefined) {
|
if (dto.apiKey !== undefined) {
|
||||||
updateData.encryptedApiKey = this.normalizeOptionalString(dto.apiKey);
|
updateData.encryptedApiKey = this.encryptOptionalString(dto.apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.aiProviderBinding.update({
|
return tx.aiProviderBinding.update({
|
||||||
@@ -398,12 +400,12 @@ export class AiService {
|
|||||||
channel: binding.channel,
|
channel: binding.channel,
|
||||||
source: "binding",
|
source: "binding",
|
||||||
sourceId: binding.id,
|
sourceId: binding.id,
|
||||||
providerName: binding.providerName,
|
providerName: this.readDecryptedString(binding.providerName) ?? "",
|
||||||
model: binding.model,
|
model: this.readDecryptedString(binding.model),
|
||||||
configId: binding.configId,
|
configId: this.readDecryptedString(binding.configId),
|
||||||
configName: binding.configName,
|
configName: this.readDecryptedString(binding.configName),
|
||||||
endpoint: binding.endpoint,
|
endpoint: this.readDecryptedString(binding.endpoint),
|
||||||
apiKey: binding.encryptedApiKey
|
apiKey: this.readDecryptedString(binding.encryptedApiKey)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,27 +414,34 @@ export class AiService {
|
|||||||
channel: AiChannel.PUBLIC_POOL,
|
channel: AiChannel.PUBLIC_POOL,
|
||||||
source: "public_pool",
|
source: "public_pool",
|
||||||
sourceId: publicPool.id,
|
sourceId: publicPool.id,
|
||||||
providerName: publicPool.providerName ?? "public-pool",
|
providerName: this.readDecryptedString(publicPool.providerName) ?? "public-pool",
|
||||||
model: publicPool.model,
|
model: this.readDecryptedString(publicPool.model),
|
||||||
configId: null,
|
configId: null,
|
||||||
configName: null,
|
configName: null,
|
||||||
endpoint: publicPool.endpoint,
|
endpoint: this.readDecryptedString(publicPool.endpoint),
|
||||||
apiKey: publicPool.encryptedApiKey
|
apiKey: this.readDecryptedString(publicPool.encryptedApiKey)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeBinding(binding: AiProviderBinding): AiBindingSummary {
|
private serializeBinding(binding: AiProviderBinding): AiBindingSummary {
|
||||||
|
const decryptedProviderName = this.readDecryptedString(binding.providerName) ?? "";
|
||||||
|
const decryptedModel = this.readDecryptedString(binding.model);
|
||||||
|
const decryptedConfigId = this.readDecryptedString(binding.configId);
|
||||||
|
const decryptedConfigName = this.readDecryptedString(binding.configName);
|
||||||
|
const decryptedEndpoint = this.readDecryptedString(binding.endpoint);
|
||||||
|
const decryptedApiKey = this.readDecryptedString(binding.encryptedApiKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: binding.id,
|
id: binding.id,
|
||||||
channel: binding.channel,
|
channel: binding.channel,
|
||||||
providerName: binding.providerName,
|
providerName: decryptedProviderName,
|
||||||
model: binding.model,
|
model: decryptedModel,
|
||||||
configId: binding.configId,
|
configId: decryptedConfigId,
|
||||||
configName: binding.configName,
|
configName: decryptedConfigName,
|
||||||
endpoint: binding.endpoint,
|
endpoint: decryptedEndpoint,
|
||||||
isEnabled: binding.isEnabled,
|
isEnabled: binding.isEnabled,
|
||||||
hasApiKey: Boolean(binding.encryptedApiKey),
|
hasApiKey: Boolean(binding.encryptedApiKey),
|
||||||
maskedApiKey: this.maskSecret(binding.encryptedApiKey),
|
maskedApiKey: this.maskSecret(decryptedApiKey),
|
||||||
updatedAt: binding.updatedAt.toISOString()
|
updatedAt: binding.updatedAt.toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -523,14 +532,16 @@ export class AiService {
|
|||||||
|
|
||||||
const visibleTasks = sortedTasks.slice(0, this.maxContextTasks);
|
const visibleTasks = sortedTasks.slice(0, this.maxContextTasks);
|
||||||
const lines = visibleTasks.map((task, index) => {
|
const lines = visibleTasks.map((task, index) => {
|
||||||
|
const taskTitle = this.readDecryptedString(task.title) ?? "未命名任务";
|
||||||
|
const contentText = this.readDecryptedString(task.contentText);
|
||||||
const parts = [
|
const parts = [
|
||||||
`${index + 1}. ${task.title}`,
|
`${index + 1}. ${taskTitle}`,
|
||||||
`优先级:${this.getPriorityLabel(task.priority)}`,
|
`优先级:${this.getPriorityLabel(task.priority)}`,
|
||||||
`状态:${this.getStatusLabel(task.status)}`,
|
`状态:${this.getStatusLabel(task.status)}`,
|
||||||
`DDL:${task.ddl ? task.ddl.toISOString() : "未设置"}`
|
`DDL:${task.ddl ? task.ddl.toISOString() : "未设置"}`
|
||||||
];
|
];
|
||||||
|
|
||||||
const contentSnippet = this.getContentSnippet(task.contentText);
|
const contentSnippet = this.getContentSnippet(contentText);
|
||||||
if (contentSnippet) {
|
if (contentSnippet) {
|
||||||
parts.push(`内容摘要:${contentSnippet}`);
|
parts.push(`内容摘要:${contentSnippet}`);
|
||||||
}
|
}
|
||||||
@@ -592,6 +603,25 @@ export class AiService {
|
|||||||
return this.normalizeOptionalString(value) ?? "";
|
return this.normalizeOptionalString(value) ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private encryptOptionalString(value: string | undefined): string | null | undefined {
|
||||||
|
const normalizedValue = this.normalizeOptionalString(value);
|
||||||
|
return this.dataEncryptionService.encryptString(normalizedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
private encryptRequiredString(value: string): string {
|
||||||
|
const encryptedValue = this.dataEncryptionService.encryptString(value);
|
||||||
|
if (!encryptedValue) {
|
||||||
|
throw new BadRequestException("敏感配置加密失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return encryptedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readDecryptedString(value: string | null): string | null {
|
||||||
|
const decryptedValue = this.dataEncryptionService.decryptString(value);
|
||||||
|
return typeof decryptedValue === "string" ? decryptedValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
private validateBindingInput(dto: UpsertAiProviderBindingDto): void {
|
private validateBindingInput(dto: UpsertAiProviderBindingDto): void {
|
||||||
const providerName = this.normalizeOptionalString(dto.providerName);
|
const providerName = this.normalizeOptionalString(dto.providerName);
|
||||||
const configId = this.normalizeOptionalString(dto.configId);
|
const configId = this.normalizeOptionalString(dto.configId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AiModule } from "./ai/ai.module";
|
|||||||
import { AttachmentModule } from "./attachment/attachment.module";
|
import { AttachmentModule } from "./attachment/attachment.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
|
import { SecurityModule } from "./security/security.module";
|
||||||
import { SyncModule } from "./sync/sync.module";
|
import { SyncModule } from "./sync/sync.module";
|
||||||
import { TaskModule } from "./task/task.module";
|
import { TaskModule } from "./task/task.module";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ import { TaskModule } from "./task/task.module";
|
|||||||
envFilePath: ".env"
|
envFilePath: ".env"
|
||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
SecurityModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
TaskModule,
|
TaskModule,
|
||||||
AttachmentModule,
|
AttachmentModule,
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { Injectable, NotFoundException, PayloadTooLargeException } from "@nestjs/common";
|
import {
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
PayloadTooLargeException
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||||
import { AttachmentType } from "../../generated/prisma/client";
|
import { AttachmentType } from "../../generated/prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { DataEncryptionService } from "../security/data-encryption.service";
|
||||||
import { CompleteAttachmentDto } from "./dto/complete-attachment.dto";
|
import { CompleteAttachmentDto } from "./dto/complete-attachment.dto";
|
||||||
import { PresignAttachmentDto } from "./dto/presign-attachment.dto";
|
import { PresignAttachmentDto } from "./dto/presign-attachment.dto";
|
||||||
|
|
||||||
@@ -25,9 +31,7 @@ export type PresignAttachmentResponse = {
|
|||||||
usedBytes: string;
|
usedBytes: string;
|
||||||
remainingBytes: string;
|
remainingBytes: string;
|
||||||
};
|
};
|
||||||
headers: {
|
headers: Record<string, string>;
|
||||||
"Content-Type": string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AttachmentResponse = {
|
export type AttachmentResponse = {
|
||||||
@@ -52,7 +56,8 @@ export class AttachmentService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly dataEncryptionService: DataEncryptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async presignAttachment(
|
async presignAttachment(
|
||||||
@@ -67,15 +72,17 @@ export class AttachmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bucket = this.getDefaultBucket();
|
const bucket = this.getDefaultBucket();
|
||||||
const objectKey = this.generateObjectKey(userId, body.fileName);
|
const objectKey = this.generateObjectKey(body.fileName);
|
||||||
const objectUrl = this.resolveObjectUrl(bucket, objectKey);
|
const objectUrl = this.resolveObjectUrl(bucket, objectKey);
|
||||||
const expiresInSeconds = this.getPresignExpiresInSeconds();
|
const expiresInSeconds = this.getPresignExpiresInSeconds();
|
||||||
|
const serverSideEncryption = this.getServerSideEncryptionMode();
|
||||||
|
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: bucket,
|
Bucket: bucket,
|
||||||
Key: objectKey,
|
Key: objectKey,
|
||||||
ContentType: body.mimeType,
|
ContentType: body.mimeType,
|
||||||
ContentLength: body.fileSize
|
ContentLength: body.fileSize,
|
||||||
|
ServerSideEncryption: serverSideEncryption
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadUrl = await getSignedUrl(this.getS3Client(), command, {
|
const uploadUrl = await getSignedUrl(this.getS3Client(), command, {
|
||||||
@@ -94,9 +101,7 @@ export class AttachmentService {
|
|||||||
usedBytes: quotaInfo.usedBytes.toString(),
|
usedBytes: quotaInfo.usedBytes.toString(),
|
||||||
remainingBytes: (quotaInfo.totalBytes - quotaInfo.usedBytes).toString()
|
remainingBytes: (quotaInfo.totalBytes - quotaInfo.usedBytes).toString()
|
||||||
},
|
},
|
||||||
headers: {
|
headers: this.buildUploadHeaders(body.mimeType, serverSideEncryption)
|
||||||
"Content-Type": body.mimeType
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +144,14 @@ export class AttachmentService {
|
|||||||
userId,
|
userId,
|
||||||
taskId: body.taskId ?? null,
|
taskId: body.taskId ?? null,
|
||||||
type: body.type ?? this.resolveAttachmentType(body.mimeType),
|
type: body.type ?? this.resolveAttachmentType(body.mimeType),
|
||||||
url: objectUrl,
|
url: this.encryptRequiredString(objectUrl),
|
||||||
mimeType: body.mimeType,
|
mimeType: body.mimeType,
|
||||||
fileName: body.fileName,
|
fileName: this.encryptNullableString(body.fileName),
|
||||||
fileSize: body.fileSize,
|
fileSize: body.fileSize,
|
||||||
width: body.width ?? null,
|
width: body.width ?? null,
|
||||||
height: body.height ?? null,
|
height: body.height ?? null,
|
||||||
durationMs: body.durationMs ?? null,
|
durationMs: body.durationMs ?? null,
|
||||||
checksum: body.checksum ?? null
|
checksum: this.encryptNullableString(body.checksum)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -155,14 +160,14 @@ export class AttachmentService {
|
|||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
taskId: attachment.taskId,
|
taskId: attachment.taskId,
|
||||||
type: attachment.type,
|
type: attachment.type,
|
||||||
url: attachment.url,
|
url: this.readDecryptedString(attachment.url) ?? objectUrl,
|
||||||
mimeType: attachment.mimeType,
|
mimeType: attachment.mimeType,
|
||||||
fileName: attachment.fileName,
|
fileName: this.readDecryptedString(attachment.fileName),
|
||||||
fileSize: attachment.fileSize,
|
fileSize: attachment.fileSize,
|
||||||
width: attachment.width,
|
width: attachment.width,
|
||||||
height: attachment.height,
|
height: attachment.height,
|
||||||
durationMs: attachment.durationMs,
|
durationMs: attachment.durationMs,
|
||||||
checksum: attachment.checksum,
|
checksum: this.readDecryptedString(attachment.checksum),
|
||||||
createdAt: attachment.createdAt.toISOString(),
|
createdAt: attachment.createdAt.toISOString(),
|
||||||
updatedAt: attachment.updatedAt.toISOString()
|
updatedAt: attachment.updatedAt.toISOString()
|
||||||
};
|
};
|
||||||
@@ -204,10 +209,9 @@ export class AttachmentService {
|
|||||||
return Math.min(configValue, 604800);
|
return Math.min(configValue, 604800);
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateObjectKey(userId: string, fileName: string): string {
|
private generateObjectKey(fileName: string): string {
|
||||||
const safeFileName = fileName.replace(/[^\w.-]+/g, "_");
|
|
||||||
const datePrefix = new Date().toISOString().slice(0, 10);
|
const datePrefix = new Date().toISOString().slice(0, 10);
|
||||||
return `${userId}/${datePrefix}/${randomUUID()}-${safeFileName}`;
|
return `attachments/${datePrefix}/${randomUUID()}${this.extractFileExtension(fileName)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveObjectUrl(bucket: string, objectKey: string): string {
|
private resolveObjectUrl(bucket: string, objectKey: string): string {
|
||||||
@@ -232,6 +236,37 @@ export class AttachmentService {
|
|||||||
return AttachmentType.FILE;
|
return AttachmentType.FILE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildUploadHeaders(
|
||||||
|
mimeType: string,
|
||||||
|
serverSideEncryption: "AES256" | undefined
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": mimeType
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serverSideEncryption) {
|
||||||
|
headers["x-amz-server-side-encryption"] = serverSideEncryption;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getServerSideEncryptionMode(): "AES256" | undefined {
|
||||||
|
const configValue =
|
||||||
|
this.configService.get<string>("S3_SERVER_SIDE_ENCRYPTION")?.trim().toUpperCase() ?? "AES256";
|
||||||
|
|
||||||
|
if (configValue === "NONE" || configValue === "DISABLED") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "AES256";
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractFileExtension(fileName: string): string {
|
||||||
|
const match = /\.[a-zA-Z0-9]{1,16}$/.exec(fileName);
|
||||||
|
return match?.[0]?.toLowerCase() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureTaskOwnership(userId: string, taskId: string): Promise<void> {
|
private async ensureTaskOwnership(userId: string, taskId: string): Promise<void> {
|
||||||
const task = await this.prismaService.task.findFirst({
|
const task = await this.prismaService.task.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -279,4 +314,22 @@ export class AttachmentService {
|
|||||||
throw new PayloadTooLargeException("存储配额不足");
|
throw new PayloadTooLargeException("存储配额不足");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private encryptRequiredString(value: string): string {
|
||||||
|
const encryptedValue = this.dataEncryptionService.encryptString(value);
|
||||||
|
if (!encryptedValue) {
|
||||||
|
throw new InternalServerErrorException("附件元数据加密失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return encryptedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private encryptNullableString(value: string | null | undefined): string | null | undefined {
|
||||||
|
return this.dataEncryptionService.encryptString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readDecryptedString(value: string | null): string | null {
|
||||||
|
const decryptedValue = this.dataEncryptionService.decryptString(value);
|
||||||
|
return typeof decryptedValue === "string" ? decryptedValue : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Prisma } from "../../generated/prisma/client";
|
||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
const ENCRYPTION_PREFIX = "encv1";
|
||||||
|
const ENCRYPTION_ALGORITHM = "aes-256-gcm";
|
||||||
|
const ENCRYPTION_IV_LENGTH = 12;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DataEncryptionService {
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return Boolean(this.configService.get<string>("DATA_ENCRYPTION_SECRET"));
|
||||||
|
}
|
||||||
|
|
||||||
|
isEncryptedString(value: string): boolean {
|
||||||
|
return value.startsWith(`${ENCRYPTION_PREFIX}:`);
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptString(value: string | null | undefined): string | null | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.resolveKey();
|
||||||
|
const iv = randomBytes(ENCRYPTION_IV_LENGTH);
|
||||||
|
const cipher = createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
return [
|
||||||
|
ENCRYPTION_PREFIX,
|
||||||
|
iv.toString("base64url"),
|
||||||
|
authTag.toString("base64url"),
|
||||||
|
encrypted.toString("base64url")
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptString(value: string | null | undefined): string | null | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null || !this.isEncryptedPayload(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [prefix, ivText, authTagText, encryptedText] = value.split(":");
|
||||||
|
if (prefix !== ENCRYPTION_PREFIX || !ivText || !authTagText || encryptedText === undefined) {
|
||||||
|
throw new InternalServerErrorException("加密数据格式无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = this.resolveKey();
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
ENCRYPTION_ALGORITHM,
|
||||||
|
key,
|
||||||
|
Buffer.from(ivText, "base64url")
|
||||||
|
);
|
||||||
|
decipher.setAuthTag(Buffer.from(authTagText, "base64url"));
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(encryptedText, "base64url")),
|
||||||
|
decipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return decrypted.toString("utf8");
|
||||||
|
} catch {
|
||||||
|
throw new InternalServerErrorException("加密数据解密失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptJson(
|
||||||
|
value: Prisma.InputJsonValue | null | undefined
|
||||||
|
): Prisma.InputJsonValue | null | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.encryptString(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptJson(value: Prisma.JsonValue | null): Prisma.JsonValue | null {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== "string" || !this.isEncryptedPayload(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrypted = this.decryptString(value);
|
||||||
|
if (typeof decrypted !== "string") {
|
||||||
|
throw new InternalServerErrorException("加密数据解密失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(decrypted) as Prisma.JsonValue;
|
||||||
|
} catch {
|
||||||
|
throw new InternalServerErrorException("加密 JSON 数据损坏");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptPayload(value: Prisma.JsonValue | null): string | null {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return this.decryptString(value) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isEncryptedPayload(value: string): boolean {
|
||||||
|
return this.isEncryptedString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveKey(): Buffer {
|
||||||
|
const secret = this.configService.get<string>("DATA_ENCRYPTION_SECRET");
|
||||||
|
if (!secret) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
"服务端未配置 DATA_ENCRYPTION_SECRET,无法写入加密数据"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createHash("sha256").update(secret, "utf8").digest();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { DataEncryptionService } from "./data-encryption.service";
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [DataEncryptionService],
|
||||||
|
exports: [DataEncryptionService]
|
||||||
|
})
|
||||||
|
export class SecurityModule {}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
import { Prisma } from "../../generated/prisma/client";
|
import { Prisma } from "../../generated/prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { DataEncryptionService } from "../security/data-encryption.service";
|
||||||
import { SyncPullQueryDto } from "./dto/sync-pull.dto";
|
import { SyncPullQueryDto } from "./dto/sync-pull.dto";
|
||||||
import { SyncPushDto, SyncPushOperationDto } from "./dto/sync-push.dto";
|
import { SyncPushDto, SyncPushOperationDto } from "./dto/sync-push.dto";
|
||||||
|
|
||||||
@@ -60,7 +61,10 @@ export type SyncPullResponse = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SyncService {
|
export class SyncService {
|
||||||
constructor(private readonly prismaService: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly dataEncryptionService: DataEncryptionService
|
||||||
|
) {}
|
||||||
|
|
||||||
async pullOperations(userId: string, query: SyncPullQueryDto): Promise<SyncPullResponse> {
|
async pullOperations(userId: string, query: SyncPullQueryDto): Promise<SyncPullResponse> {
|
||||||
const limit = query.limit ?? 100;
|
const limit = query.limit ?? 100;
|
||||||
@@ -137,7 +141,7 @@ export class SyncService {
|
|||||||
entityType: operation.entityType,
|
entityType: operation.entityType,
|
||||||
entityId: operation.entityId,
|
entityId: operation.entityId,
|
||||||
action: operation.action,
|
action: operation.action,
|
||||||
payload: operation.payload,
|
payload: this.dataEncryptionService.encryptString(operation.payload) ?? undefined,
|
||||||
clientTs: new Date(operation.clientTs)
|
clientTs: new Date(operation.clientTs)
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -252,15 +256,7 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private serializePayload(payload: Prisma.JsonValue | null): string | null {
|
private serializePayload(payload: Prisma.JsonValue | null): string | null {
|
||||||
if (payload === null) {
|
return this.dataEncryptionService.decryptPayload(payload);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof payload === "string") {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(payload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseCursor(cursor: string | undefined): SyncPullCursorState | null {
|
private parseCursor(cursor: string | undefined): SyncPullCursorState | null {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
|
||||||
import { Prisma, TaskPriority, TaskStatus } from "../../generated/prisma/client";
|
import { Prisma, TaskPriority, TaskStatus } from "../../generated/prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { DataEncryptionService } from "../security/data-encryption.service";
|
||||||
import { CreateTaskDto } from "./dto/create-task.dto";
|
import { CreateTaskDto } from "./dto/create-task.dto";
|
||||||
import { ListTasksQueryDto, TaskSortBy, TaskSortOrder } from "./dto/list-tasks-query.dto";
|
import { ListTasksQueryDto, TaskSortBy, TaskSortOrder } from "./dto/list-tasks-query.dto";
|
||||||
import { UpdateTaskDto } from "./dto/update-task.dto";
|
import { UpdateTaskDto } from "./dto/update-task.dto";
|
||||||
@@ -43,16 +44,48 @@ export type ListTasksResponse = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TaskService {
|
export class TaskService {
|
||||||
constructor(private readonly prismaService: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly dataEncryptionService: DataEncryptionService
|
||||||
|
) {}
|
||||||
|
|
||||||
async listTasks(userId: string, query: ListTasksQueryDto): Promise<ListTasksResponse> {
|
async listTasks(userId: string, query: ListTasksQueryDto): Promise<ListTasksResponse> {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 20;
|
const pageSize = query.pageSize ?? 20;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
const keyword = query.keyword?.trim() ?? "";
|
||||||
|
|
||||||
const where = this.buildWhereInput(userId, query);
|
const where = this.buildWhereInput(userId, query, keyword.length === 0);
|
||||||
const orderBy = this.buildOrderByInput(query);
|
const orderBy = this.buildOrderByInput(query);
|
||||||
|
|
||||||
|
if (keyword.length > 0) {
|
||||||
|
const items = await this.prismaService.task.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
include: {
|
||||||
|
taskTags: {
|
||||||
|
include: {
|
||||||
|
tag: {
|
||||||
|
select: {
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializedItems = items.map((item: TaskEntity) => this.serializeTask(item));
|
||||||
|
const filteredItems = serializedItems.filter((item) => this.matchesKeyword(item, keyword));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: filteredItems.slice(skip, skip + pageSize),
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: filteredItems.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prismaService.task.findMany({
|
this.prismaService.task.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -112,15 +145,18 @@ export class TaskService {
|
|||||||
const tagNames = this.normalizeTagNames(body.tagNames);
|
const tagNames = this.normalizeTagNames(body.tagNames);
|
||||||
const nextStatus = body.status ?? TaskStatus.TODO;
|
const nextStatus = body.status ?? TaskStatus.TODO;
|
||||||
const contentJson =
|
const contentJson =
|
||||||
body.contentJson !== undefined ? (body.contentJson as Prisma.InputJsonValue) : undefined;
|
body.contentJson !== undefined
|
||||||
|
? ((this.dataEncryptionService.encryptJson(body.contentJson as Prisma.InputJsonValue) ??
|
||||||
|
Prisma.JsonNull) as Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const task = await this.prismaService.$transaction(async (tx) => {
|
const task = await this.prismaService.$transaction(async (tx) => {
|
||||||
const createdTask = await tx.task.create({
|
const createdTask = await tx.task.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
title: body.title,
|
title: this.encryptRequiredString(body.title),
|
||||||
contentJson,
|
contentJson,
|
||||||
contentText: body.contentText ?? null,
|
contentText: this.encryptNullableString(body.contentText),
|
||||||
priority: body.priority ?? TaskPriority.MEDIUM,
|
priority: body.priority ?? TaskPriority.MEDIUM,
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
ddl: body.ddl ? new Date(body.ddl) : null,
|
ddl: body.ddl ? new Date(body.ddl) : null,
|
||||||
@@ -172,13 +208,15 @@ export class TaskService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (body.title !== undefined) {
|
if (body.title !== undefined) {
|
||||||
data.title = body.title;
|
data.title = this.encryptRequiredString(body.title);
|
||||||
}
|
}
|
||||||
if (body.contentJson !== undefined) {
|
if (body.contentJson !== undefined) {
|
||||||
data.contentJson = body.contentJson as Prisma.InputJsonValue;
|
data.contentJson = (this.dataEncryptionService.encryptJson(
|
||||||
|
body.contentJson as Prisma.InputJsonValue
|
||||||
|
) ?? Prisma.JsonNull) as Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput;
|
||||||
}
|
}
|
||||||
if (body.contentText !== undefined) {
|
if (body.contentText !== undefined) {
|
||||||
data.contentText = body.contentText;
|
data.contentText = this.encryptNullableString(body.contentText);
|
||||||
}
|
}
|
||||||
if (body.priority !== undefined) {
|
if (body.priority !== undefined) {
|
||||||
data.priority = body.priority;
|
data.priority = body.priority;
|
||||||
@@ -242,7 +280,11 @@ export class TaskService {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildWhereInput(userId: string, query: ListTasksQueryDto): Prisma.TaskWhereInput {
|
private buildWhereInput(
|
||||||
|
userId: string,
|
||||||
|
query: ListTasksQueryDto,
|
||||||
|
includeKeyword: boolean
|
||||||
|
): Prisma.TaskWhereInput {
|
||||||
const where: Prisma.TaskWhereInput = {
|
const where: Prisma.TaskWhereInput = {
|
||||||
userId
|
userId
|
||||||
};
|
};
|
||||||
@@ -267,7 +309,7 @@ export class TaskService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.keyword !== undefined && query.keyword.length > 0) {
|
if (includeKeyword && query.keyword !== undefined && query.keyword.length > 0) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{
|
{
|
||||||
title: {
|
title: {
|
||||||
@@ -374,9 +416,9 @@ export class TaskService {
|
|||||||
private serializeTask(task: TaskEntity): TaskResponse {
|
private serializeTask(task: TaskEntity): TaskResponse {
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.title,
|
title: this.readDecryptedString(task.title) ?? "未命名任务",
|
||||||
contentJson: task.contentJson,
|
contentJson: this.dataEncryptionService.decryptJson(task.contentJson),
|
||||||
contentText: task.contentText,
|
contentText: this.readDecryptedString(task.contentText),
|
||||||
priority: task.priority,
|
priority: task.priority,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
ddl: task.ddl?.toISOString() ?? null,
|
ddl: task.ddl?.toISOString() ?? null,
|
||||||
@@ -387,4 +429,30 @@ export class TaskService {
|
|||||||
updatedAt: task.updatedAt.toISOString()
|
updatedAt: task.updatedAt.toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private encryptRequiredString(value: string): string {
|
||||||
|
const encryptedValue = this.dataEncryptionService.encryptString(value);
|
||||||
|
if (!encryptedValue) {
|
||||||
|
throw new InternalServerErrorException("任务字段加密失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return encryptedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private encryptNullableString(value: string | null | undefined): string | null | undefined {
|
||||||
|
return this.dataEncryptionService.encryptString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readDecryptedString(value: string | null): string | null {
|
||||||
|
const decryptedValue = this.dataEncryptionService.decryptString(value);
|
||||||
|
return typeof decryptedValue === "string" ? decryptedValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private matchesKeyword(task: TaskResponse, keyword: string): boolean {
|
||||||
|
const lowerKeyword = keyword.toLocaleLowerCase();
|
||||||
|
return (
|
||||||
|
task.title.toLocaleLowerCase().includes(lowerKeyword) ||
|
||||||
|
task.contentText?.toLocaleLowerCase().includes(lowerKeyword) === true
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import {
|
import {
|
||||||
AiChannel,
|
AiChannel,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
AiRouteFailureError
|
AiRouteFailureError
|
||||||
} from "../src/ai/ai.types";
|
} from "../src/ai/ai.types";
|
||||||
import { PrismaService } from "../src/prisma/prisma.service";
|
import { PrismaService } from "../src/prisma/prisma.service";
|
||||||
|
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
||||||
|
|
||||||
type AiUsageLogRecord = {
|
type AiUsageLogRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -297,6 +299,10 @@ class InMemoryAiPrismaService {
|
|||||||
return [...this.usageLogs];
|
return [...this.usageLogs];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBindings(): AiProviderBinding[] {
|
||||||
|
return [...this.bindings];
|
||||||
|
}
|
||||||
|
|
||||||
seedTask(task: AiTaskRecord): void {
|
seedTask(task: AiTaskRecord): void {
|
||||||
this.tasks.push(task);
|
this.tasks.push(task);
|
||||||
}
|
}
|
||||||
@@ -401,10 +407,18 @@ describe("AiController (integration)", () => {
|
|||||||
controllers: [AiController],
|
controllers: [AiController],
|
||||||
providers: [
|
providers: [
|
||||||
AiService,
|
AiService,
|
||||||
|
DataEncryptionService,
|
||||||
{
|
{
|
||||||
provide: PrismaService,
|
provide: PrismaService,
|
||||||
useValue: prismaService
|
useValue: prismaService
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: (key: string) =>
|
||||||
|
key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: AiProviderRegistryService,
|
provide: AiProviderRegistryService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -466,6 +480,11 @@ describe("AiController (integration)", () => {
|
|||||||
maskedApiKey: "abk_***34",
|
maskedApiKey: "abk_***34",
|
||||||
isEnabled: true
|
isEnabled: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const storedBinding = prismaService.getBindings()[0];
|
||||||
|
expect(storedBinding?.providerName).not.toBe("astrbot-main");
|
||||||
|
expect(storedBinding?.endpoint).not.toBe("http://127.0.0.1:6185");
|
||||||
|
expect(storedBinding?.encryptedApiKey).not.toBe("abk_secret_1234");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should hide public pool endpoint from user bindings response", async () => {
|
it("should hide public pool endpoint from user bindings response", async () => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PrismaService } from "../src/prisma/prisma.service";
|
import { PrismaService } from "../src/prisma/prisma.service";
|
||||||
|
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
||||||
import { SyncController } from "../src/sync/sync.controller";
|
import { SyncController } from "../src/sync/sync.controller";
|
||||||
import { SyncService } from "../src/sync/sync.service";
|
import { SyncService } from "../src/sync/sync.service";
|
||||||
|
|
||||||
@@ -159,6 +161,10 @@ class InMemoryPrismaService {
|
|||||||
return this.syncOperations.length;
|
return this.syncOperations.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRawOperationById(opId: string): SyncOperationRecord | undefined {
|
||||||
|
return this.syncOperations.find((operation) => operation.opId === opId);
|
||||||
|
}
|
||||||
|
|
||||||
seedOperations(records: Array<Omit<SyncOperationRecord, "id">>): void {
|
seedOperations(records: Array<Omit<SyncOperationRecord, "id">>): void {
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
this.syncOperations.push({
|
this.syncOperations.push({
|
||||||
@@ -196,7 +202,18 @@ describe("SyncController (integration)", () => {
|
|||||||
|
|
||||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [SyncController],
|
controllers: [SyncController],
|
||||||
providers: [SyncService, { provide: PrismaService, useValue: prismaService }]
|
providers: [
|
||||||
|
SyncService,
|
||||||
|
DataEncryptionService,
|
||||||
|
{ provide: PrismaService, useValue: prismaService },
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: (key: string) =>
|
||||||
|
key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
@@ -258,6 +275,9 @@ describe("SyncController (integration)", () => {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
expect(prismaService.getOperationCount()).toBe(2);
|
expect(prismaService.getOperationCount()).toBe(2);
|
||||||
|
expect(prismaService.getRawOperationById("op-create-1")?.payload).not.toBe(
|
||||||
|
'{"title":"浠诲姟涓€"}'
|
||||||
|
);
|
||||||
|
|
||||||
const secondResponse = await request(app.getHttpServer())
|
const secondResponse = await request(app.getHttpServer())
|
||||||
.post("/sync/push")
|
.post("/sync/push")
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
import { INestApplication, ValidationPipe } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PrismaService } from "../src/prisma/prisma.service";
|
import { PrismaService } from "../src/prisma/prisma.service";
|
||||||
|
import { DataEncryptionService } from "../src/security/data-encryption.service";
|
||||||
import { TaskController } from "../src/task/task.controller";
|
import { TaskController } from "../src/task/task.controller";
|
||||||
import { TaskService } from "../src/task/task.service";
|
import { TaskService } from "../src/task/task.service";
|
||||||
import { TaskPriority, TaskStatus } from "../generated/prisma/client";
|
import { TaskPriority, TaskStatus } from "../generated/prisma/client";
|
||||||
@@ -355,6 +357,10 @@ class InMemoryPrismaService {
|
|||||||
return runner(this);
|
return runner(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRawTaskById(taskId: string): TaskRecord | undefined {
|
||||||
|
return this.tasks.find((task) => task.id === taskId);
|
||||||
|
}
|
||||||
|
|
||||||
private toTaskWithTags(
|
private toTaskWithTags(
|
||||||
task: TaskRecord
|
task: TaskRecord
|
||||||
): TaskRecord & { taskTags: Array<{ tag: { name: string } }> } {
|
): TaskRecord & { taskTags: Array<{ tag: { name: string } }> } {
|
||||||
@@ -390,7 +396,15 @@ describe("TaskController (integration)", () => {
|
|||||||
controllers: [TaskController],
|
controllers: [TaskController],
|
||||||
providers: [
|
providers: [
|
||||||
TaskService,
|
TaskService,
|
||||||
{ provide: PrismaService, useValue: prismaService as unknown as PrismaService }
|
DataEncryptionService,
|
||||||
|
{ provide: PrismaService, useValue: prismaService as unknown as PrismaService },
|
||||||
|
{
|
||||||
|
provide: ConfigService,
|
||||||
|
useValue: {
|
||||||
|
get: (key: string) =>
|
||||||
|
key === "DATA_ENCRYPTION_SECRET" ? "test-data-encryption-secret" : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -425,6 +439,9 @@ describe("TaskController (integration)", () => {
|
|||||||
expect(createResponse.body.id).toBeDefined();
|
expect(createResponse.body.id).toBeDefined();
|
||||||
expect(createResponse.body.tags).toEqual(["工作", "会议"]);
|
expect(createResponse.body.tags).toEqual(["工作", "会议"]);
|
||||||
const taskId = createResponse.body.id as string;
|
const taskId = createResponse.body.id as string;
|
||||||
|
const rawCreatedTask = prismaService.getRawTaskById(taskId);
|
||||||
|
expect(rawCreatedTask?.title).not.toBe("准备周会");
|
||||||
|
expect(rawCreatedTask?.contentText).not.toBe("整理本周进度");
|
||||||
|
|
||||||
const listResponse = await request(app.getHttpServer())
|
const listResponse = await request(app.getHttpServer())
|
||||||
.get("/tasks")
|
.get("/tasks")
|
||||||
|
|||||||
Reference in New Issue
Block a user