feat(api-security): encrypt sensitive data at rest

This commit is contained in:
2026-04-06 15:25:10 +08:00
parent 13d0d7707a
commit 13abfc1e52
13 changed files with 739 additions and 80 deletions
+63 -33
View File
@@ -9,6 +9,7 @@ import {
TaskStatus
} from "../../generated/prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { DataEncryptionService } from "../security/data-encryption.service";
import { AiProviderRegistryService } from "./ai-provider-registry.service";
import { AiChatDto } from "./dto/ai-chat.dto";
import { ListAiUsageLogsQueryDto } from "./dto/list-ai-usage-logs-query.dto";
@@ -93,7 +94,8 @@ export class AiService {
constructor(
private readonly prismaService: PrismaService,
private readonly aiProviderRegistryService: AiProviderRegistryService
private readonly aiProviderRegistryService: AiProviderRegistryService,
private readonly dataEncryptionService: DataEncryptionService
) {}
async listBindings(userId: string): Promise<ListAiBindingsResponse> {
@@ -119,8 +121,8 @@ export class AiService {
publicPool: publicPool
? {
enabled: publicPool.enabled,
providerName: publicPool.providerName,
model: publicPool.model,
providerName: this.readDecryptedString(publicPool.providerName),
model: this.readDecryptedString(publicPool.model),
hasApiKey: Boolean(publicPool.encryptedApiKey)
}
: null
@@ -191,12 +193,12 @@ export class AiService {
data: {
userId,
channel: dto.channel,
providerName: this.normalizeProviderName(dto.providerName),
model: this.normalizeOptionalString(dto.model),
configId: this.normalizeOptionalString(dto.configId),
configName: this.normalizeOptionalString(dto.configName),
endpoint: this.normalizeOptionalString(dto.endpoint),
encryptedApiKey: this.normalizeOptionalString(dto.apiKey),
providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)),
model: this.encryptOptionalString(dto.model),
configId: this.encryptOptionalString(dto.configId),
configName: this.encryptOptionalString(dto.configName),
endpoint: this.encryptOptionalString(dto.endpoint),
encryptedApiKey: this.encryptOptionalString(dto.apiKey),
isEnabled: dto.isEnabled ?? true
}
});
@@ -204,19 +206,19 @@ export class AiService {
const updateData: Prisma.AiProviderBindingUpdateInput = {
channel: dto.channel,
providerName: this.normalizeProviderName(dto.providerName),
model: this.normalizeOptionalString(dto.model),
configId: this.normalizeOptionalString(dto.configId),
configName: this.normalizeOptionalString(dto.configName),
providerName: this.encryptRequiredString(this.normalizeProviderName(dto.providerName)),
model: this.encryptOptionalString(dto.model),
configId: this.encryptOptionalString(dto.configId),
configName: this.encryptOptionalString(dto.configName),
isEnabled: dto.isEnabled ?? existingBinding.isEnabled
};
if (dto.endpoint !== undefined) {
updateData.endpoint = this.normalizeOptionalString(dto.endpoint);
updateData.endpoint = this.encryptOptionalString(dto.endpoint);
}
if (dto.apiKey !== undefined) {
updateData.encryptedApiKey = this.normalizeOptionalString(dto.apiKey);
updateData.encryptedApiKey = this.encryptOptionalString(dto.apiKey);
}
return tx.aiProviderBinding.update({
@@ -398,12 +400,12 @@ export class AiService {
channel: binding.channel,
source: "binding",
sourceId: binding.id,
providerName: binding.providerName,
model: binding.model,
configId: binding.configId,
configName: binding.configName,
endpoint: binding.endpoint,
apiKey: binding.encryptedApiKey
providerName: this.readDecryptedString(binding.providerName) ?? "",
model: this.readDecryptedString(binding.model),
configId: this.readDecryptedString(binding.configId),
configName: this.readDecryptedString(binding.configName),
endpoint: this.readDecryptedString(binding.endpoint),
apiKey: this.readDecryptedString(binding.encryptedApiKey)
};
}
@@ -412,27 +414,34 @@ export class AiService {
channel: AiChannel.PUBLIC_POOL,
source: "public_pool",
sourceId: publicPool.id,
providerName: publicPool.providerName ?? "public-pool",
model: publicPool.model,
providerName: this.readDecryptedString(publicPool.providerName) ?? "public-pool",
model: this.readDecryptedString(publicPool.model),
configId: null,
configName: null,
endpoint: publicPool.endpoint,
apiKey: publicPool.encryptedApiKey
endpoint: this.readDecryptedString(publicPool.endpoint),
apiKey: this.readDecryptedString(publicPool.encryptedApiKey)
};
}
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 {
id: binding.id,
channel: binding.channel,
providerName: binding.providerName,
model: binding.model,
configId: binding.configId,
configName: binding.configName,
endpoint: binding.endpoint,
providerName: decryptedProviderName,
model: decryptedModel,
configId: decryptedConfigId,
configName: decryptedConfigName,
endpoint: decryptedEndpoint,
isEnabled: binding.isEnabled,
hasApiKey: Boolean(binding.encryptedApiKey),
maskedApiKey: this.maskSecret(binding.encryptedApiKey),
maskedApiKey: this.maskSecret(decryptedApiKey),
updatedAt: binding.updatedAt.toISOString()
};
}
@@ -523,14 +532,16 @@ export class AiService {
const visibleTasks = sortedTasks.slice(0, this.maxContextTasks);
const lines = visibleTasks.map((task, index) => {
const taskTitle = this.readDecryptedString(task.title) ?? "未命名任务";
const contentText = this.readDecryptedString(task.contentText);
const parts = [
`${index + 1}. ${task.title}`,
`${index + 1}. ${taskTitle}`,
`优先级:${this.getPriorityLabel(task.priority)}`,
`状态:${this.getStatusLabel(task.status)}`,
`DDL${task.ddl ? task.ddl.toISOString() : "未设置"}`
];
const contentSnippet = this.getContentSnippet(task.contentText);
const contentSnippet = this.getContentSnippet(contentText);
if (contentSnippet) {
parts.push(`内容摘要:${contentSnippet}`);
}
@@ -592,6 +603,25 @@ export class AiService {
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 {
const providerName = this.normalizeOptionalString(dto.providerName);
const configId = this.normalizeOptionalString(dto.configId);
+2
View File
@@ -4,6 +4,7 @@ import { AiModule } from "./ai/ai.module";
import { AttachmentModule } from "./attachment/attachment.module";
import { AuthModule } from "./auth/auth.module";
import { PrismaModule } from "./prisma/prisma.module";
import { SecurityModule } from "./security/security.module";
import { SyncModule } from "./sync/sync.module";
import { TaskModule } from "./task/task.module";
@@ -14,6 +15,7 @@ import { TaskModule } from "./task/task.module";
envFilePath: ".env"
}),
PrismaModule,
SecurityModule,
AuthModule,
TaskModule,
AttachmentModule,
+72 -19
View File
@@ -1,10 +1,16 @@
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 { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { AttachmentType } from "../../generated/prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { DataEncryptionService } from "../security/data-encryption.service";
import { CompleteAttachmentDto } from "./dto/complete-attachment.dto";
import { PresignAttachmentDto } from "./dto/presign-attachment.dto";
@@ -25,9 +31,7 @@ export type PresignAttachmentResponse = {
usedBytes: string;
remainingBytes: string;
};
headers: {
"Content-Type": string;
};
headers: Record<string, string>;
};
export type AttachmentResponse = {
@@ -52,7 +56,8 @@ export class AttachmentService {
constructor(
private readonly configService: ConfigService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly dataEncryptionService: DataEncryptionService
) {}
async presignAttachment(
@@ -67,15 +72,17 @@ export class AttachmentService {
}
const bucket = this.getDefaultBucket();
const objectKey = this.generateObjectKey(userId, body.fileName);
const objectKey = this.generateObjectKey(body.fileName);
const objectUrl = this.resolveObjectUrl(bucket, objectKey);
const expiresInSeconds = this.getPresignExpiresInSeconds();
const serverSideEncryption = this.getServerSideEncryptionMode();
const command = new PutObjectCommand({
Bucket: bucket,
Key: objectKey,
ContentType: body.mimeType,
ContentLength: body.fileSize
ContentLength: body.fileSize,
ServerSideEncryption: serverSideEncryption
});
const uploadUrl = await getSignedUrl(this.getS3Client(), command, {
@@ -94,9 +101,7 @@ export class AttachmentService {
usedBytes: quotaInfo.usedBytes.toString(),
remainingBytes: (quotaInfo.totalBytes - quotaInfo.usedBytes).toString()
},
headers: {
"Content-Type": body.mimeType
}
headers: this.buildUploadHeaders(body.mimeType, serverSideEncryption)
};
}
@@ -139,14 +144,14 @@ export class AttachmentService {
userId,
taskId: body.taskId ?? null,
type: body.type ?? this.resolveAttachmentType(body.mimeType),
url: objectUrl,
url: this.encryptRequiredString(objectUrl),
mimeType: body.mimeType,
fileName: body.fileName,
fileName: this.encryptNullableString(body.fileName),
fileSize: body.fileSize,
width: body.width ?? null,
height: body.height ?? null,
durationMs: body.durationMs ?? null,
checksum: body.checksum ?? null
checksum: this.encryptNullableString(body.checksum)
}
});
});
@@ -155,14 +160,14 @@ export class AttachmentService {
id: attachment.id,
taskId: attachment.taskId,
type: attachment.type,
url: attachment.url,
url: this.readDecryptedString(attachment.url) ?? objectUrl,
mimeType: attachment.mimeType,
fileName: attachment.fileName,
fileName: this.readDecryptedString(attachment.fileName),
fileSize: attachment.fileSize,
width: attachment.width,
height: attachment.height,
durationMs: attachment.durationMs,
checksum: attachment.checksum,
checksum: this.readDecryptedString(attachment.checksum),
createdAt: attachment.createdAt.toISOString(),
updatedAt: attachment.updatedAt.toISOString()
};
@@ -204,10 +209,9 @@ export class AttachmentService {
return Math.min(configValue, 604800);
}
private generateObjectKey(userId: string, fileName: string): string {
const safeFileName = fileName.replace(/[^\w.-]+/g, "_");
private generateObjectKey(fileName: string): string {
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 {
@@ -232,6 +236,37 @@ export class AttachmentService {
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> {
const task = await this.prismaService.task.findFirst({
where: {
@@ -279,4 +314,22 @@ export class AttachmentService {
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();
}
}
+9
View File
@@ -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 {}
+7 -11
View File
@@ -1,6 +1,7 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { Prisma } from "../../generated/prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { DataEncryptionService } from "../security/data-encryption.service";
import { SyncPullQueryDto } from "./dto/sync-pull.dto";
import { SyncPushDto, SyncPushOperationDto } from "./dto/sync-push.dto";
@@ -60,7 +61,10 @@ export type SyncPullResponse = {
@Injectable()
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> {
const limit = query.limit ?? 100;
@@ -137,7 +141,7 @@ export class SyncService {
entityType: operation.entityType,
entityId: operation.entityId,
action: operation.action,
payload: operation.payload,
payload: this.dataEncryptionService.encryptString(operation.payload) ?? undefined,
clientTs: new Date(operation.clientTs)
},
select: {
@@ -252,15 +256,7 @@ export class SyncService {
}
private serializePayload(payload: Prisma.JsonValue | null): string | null {
if (payload === null) {
return null;
}
if (typeof payload === "string") {
return payload;
}
return JSON.stringify(payload);
return this.dataEncryptionService.decryptPayload(payload);
}
private parseCursor(cursor: string | undefined): SyncPullCursorState | null {
+82 -14
View File
@@ -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 { PrismaService } from "../prisma/prisma.service";
import { DataEncryptionService } from "../security/data-encryption.service";
import { CreateTaskDto } from "./dto/create-task.dto";
import { ListTasksQueryDto, TaskSortBy, TaskSortOrder } from "./dto/list-tasks-query.dto";
import { UpdateTaskDto } from "./dto/update-task.dto";
@@ -43,16 +44,48 @@ export type ListTasksResponse = {
@Injectable()
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> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
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);
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([
this.prismaService.task.findMany({
where,
@@ -112,15 +145,18 @@ export class TaskService {
const tagNames = this.normalizeTagNames(body.tagNames);
const nextStatus = body.status ?? TaskStatus.TODO;
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 createdTask = await tx.task.create({
data: {
userId,
title: body.title,
title: this.encryptRequiredString(body.title),
contentJson,
contentText: body.contentText ?? null,
contentText: this.encryptNullableString(body.contentText),
priority: body.priority ?? TaskPriority.MEDIUM,
status: nextStatus,
ddl: body.ddl ? new Date(body.ddl) : null,
@@ -172,13 +208,15 @@ export class TaskService {
};
if (body.title !== undefined) {
data.title = body.title;
data.title = this.encryptRequiredString(body.title);
}
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) {
data.contentText = body.contentText;
data.contentText = this.encryptNullableString(body.contentText);
}
if (body.priority !== undefined) {
data.priority = body.priority;
@@ -242,7 +280,11 @@ export class TaskService {
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 = {
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 = [
{
title: {
@@ -374,9 +416,9 @@ export class TaskService {
private serializeTask(task: TaskEntity): TaskResponse {
return {
id: task.id,
title: task.title,
contentJson: task.contentJson,
contentText: task.contentText,
title: this.readDecryptedString(task.title) ?? "未命名任务",
contentJson: this.dataEncryptionService.decryptJson(task.contentJson),
contentText: this.readDecryptedString(task.contentText),
priority: task.priority,
status: task.status,
ddl: task.ddl?.toISOString() ?? null,
@@ -387,4 +429,30 @@ export class TaskService {
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
);
}
}