feat(web-security): encrypt offline data at rest
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { localDb, type LocalAiChatSessionRecord } from "@/services/local-db";
|
||||
import type { WebAiChannel } from "@/services/ai-api";
|
||||
import {
|
||||
decryptAiChatSessionRecord,
|
||||
encryptAiChatSessionRecord,
|
||||
shouldEncryptAiChatSessionRecord
|
||||
} from "@/services/local-sensitive-codec";
|
||||
|
||||
export type LocalAiChatMessageRecord = {
|
||||
id: string;
|
||||
@@ -64,20 +69,33 @@ export async function listLocalAiChatSessions(
|
||||
userId: string
|
||||
): Promise<LocalAiChatSessionSnapshot[]> {
|
||||
const records = await localDb.aiChatSessions.where("userId").equals(userId).toArray();
|
||||
return records.map(toSnapshot);
|
||||
const encryptedRecords = await Promise.all(
|
||||
records
|
||||
.filter(shouldEncryptAiChatSessionRecord)
|
||||
.map((record) => encryptAiChatSessionRecord(record))
|
||||
);
|
||||
|
||||
if (encryptedRecords.length > 0) {
|
||||
await localDb.aiChatSessions.bulkPut(encryptedRecords);
|
||||
}
|
||||
|
||||
const decryptedRecords = await Promise.all(
|
||||
records.map((record) => decryptAiChatSessionRecord(record))
|
||||
);
|
||||
return decryptedRecords.map(toSnapshot);
|
||||
}
|
||||
|
||||
export async function saveLocalAiChatSession(
|
||||
input: SaveLocalAiChatSessionInput
|
||||
): Promise<LocalAiChatSessionRecord> {
|
||||
const record: LocalAiChatSessionRecord = {
|
||||
const record = await encryptAiChatSessionRecord({
|
||||
key: createSessionKey(input.userId, input.channel),
|
||||
userId: input.userId,
|
||||
channel: input.channel,
|
||||
sessionId: input.sessionId,
|
||||
messagesJson: JSON.stringify(input.messages),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
});
|
||||
|
||||
await localDb.aiChatSessions.put(record);
|
||||
return record;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
const LOCAL_CRYPTO_KEY_STORAGE_KEY = "todolist.web.local-crypto-key";
|
||||
const LOCAL_CRYPTO_PREFIX = "locv1";
|
||||
const LOCAL_CRYPTO_IV_LENGTH = 12;
|
||||
const LOCAL_CRYPTO_KEY_LENGTH = 32;
|
||||
|
||||
let cachedLocalCryptoKeyPromise: Promise<CryptoKey> | null = null;
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
const chunk = bytes.subarray(index, index + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, "");
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string): Uint8Array {
|
||||
const normalizedValue = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const paddedValue = normalizedValue + "=".repeat((4 - (normalizedValue.length % 4 || 4)) % 4);
|
||||
const binary = atob(paddedValue);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function createRandomKeyBytes(): Uint8Array {
|
||||
const bytes = new Uint8Array(LOCAL_CRYPTO_KEY_LENGTH);
|
||||
crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function resolveLocalCryptoKey(): Promise<CryptoKey> {
|
||||
if (cachedLocalCryptoKeyPromise) {
|
||||
return cachedLocalCryptoKeyPromise;
|
||||
}
|
||||
|
||||
cachedLocalCryptoKeyPromise = (async () => {
|
||||
const savedKey = window.localStorage.getItem(LOCAL_CRYPTO_KEY_STORAGE_KEY);
|
||||
const keyBytes = savedKey ? base64UrlToBytes(savedKey) : createRandomKeyBytes();
|
||||
|
||||
if (!savedKey) {
|
||||
window.localStorage.setItem(LOCAL_CRYPTO_KEY_STORAGE_KEY, bytesToBase64Url(keyBytes));
|
||||
}
|
||||
|
||||
return crypto.subtle.importKey("raw", toArrayBuffer(keyBytes), "AES-GCM", false, [
|
||||
"encrypt",
|
||||
"decrypt"
|
||||
]);
|
||||
})();
|
||||
|
||||
return cachedLocalCryptoKeyPromise;
|
||||
}
|
||||
|
||||
export function isLocalEncryptedString(value: string): boolean {
|
||||
return value.startsWith(`${LOCAL_CRYPTO_PREFIX}:`);
|
||||
}
|
||||
|
||||
export async function encryptLocalString(
|
||||
value: string | null | undefined
|
||||
): Promise<string | null | undefined> {
|
||||
if (value === undefined || value === null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isLocalEncryptedString(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const key = await resolveLocalCryptoKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(LOCAL_CRYPTO_IV_LENGTH));
|
||||
const plaintext = new TextEncoder().encode(value);
|
||||
const encryptedBuffer = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv
|
||||
},
|
||||
key,
|
||||
plaintext
|
||||
);
|
||||
|
||||
return `${LOCAL_CRYPTO_PREFIX}:${bytesToBase64Url(iv)}:${bytesToBase64Url(new Uint8Array(encryptedBuffer))}`;
|
||||
}
|
||||
|
||||
export async function decryptLocalString(
|
||||
value: string | null | undefined
|
||||
): Promise<string | null | undefined> {
|
||||
if (value === undefined || value === null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!isLocalEncryptedString(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const [prefix, ivText, encryptedText] = value.split(":");
|
||||
if (prefix !== LOCAL_CRYPTO_PREFIX || !ivText || !encryptedText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = await resolveLocalCryptoKey();
|
||||
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: toArrayBuffer(base64UrlToBytes(ivText))
|
||||
},
|
||||
key,
|
||||
toArrayBuffer(base64UrlToBytes(encryptedText))
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decryptedBuffer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type {
|
||||
LocalAiChatSessionRecord,
|
||||
LocalOpLogRecord,
|
||||
LocalSyncInboxRecord,
|
||||
LocalTaskDraftRecord,
|
||||
LocalTaskRecord
|
||||
} from "@/services/local-db";
|
||||
import {
|
||||
decryptLocalString,
|
||||
encryptLocalString,
|
||||
isLocalEncryptedString
|
||||
} from "@/services/local-crypto";
|
||||
|
||||
export function shouldEncryptTaskRecord(record: LocalTaskRecord): boolean {
|
||||
return (
|
||||
!isLocalEncryptedString(record.title) ||
|
||||
(typeof record.contentJson === "string" && !isLocalEncryptedString(record.contentJson)) ||
|
||||
(typeof record.contentText === "string" && !isLocalEncryptedString(record.contentText))
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptTaskRecord(record: LocalTaskRecord): Promise<LocalTaskRecord> {
|
||||
return {
|
||||
...record,
|
||||
title: (await encryptLocalString(record.title)) ?? record.title,
|
||||
contentJson: (await encryptLocalString(record.contentJson)) ?? null,
|
||||
contentText: (await encryptLocalString(record.contentText)) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptTaskRecord(record: LocalTaskRecord): Promise<LocalTaskRecord> {
|
||||
const title = await decryptLocalString(record.title);
|
||||
const contentJson = await decryptLocalString(record.contentJson);
|
||||
const contentText = await decryptLocalString(record.contentText);
|
||||
|
||||
return {
|
||||
...record,
|
||||
title: typeof title === "string" && title.trim().length > 0 ? title : "未命名任务",
|
||||
contentJson: typeof contentJson === "string" ? contentJson : null,
|
||||
contentText: typeof contentText === "string" ? contentText : null
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldEncryptTaskDraft(record: LocalTaskDraftRecord): boolean {
|
||||
return (
|
||||
!isLocalEncryptedString(record.title) ||
|
||||
(typeof record.contentJson === "string" && !isLocalEncryptedString(record.contentJson)) ||
|
||||
!isLocalEncryptedString(record.contentText)
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptTaskDraftRecord(
|
||||
record: LocalTaskDraftRecord
|
||||
): Promise<LocalTaskDraftRecord> {
|
||||
return {
|
||||
...record,
|
||||
title: (await encryptLocalString(record.title)) ?? record.title,
|
||||
contentJson: (await encryptLocalString(record.contentJson)) ?? null,
|
||||
contentText: (await encryptLocalString(record.contentText)) ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptTaskDraftRecord(
|
||||
record: LocalTaskDraftRecord
|
||||
): Promise<LocalTaskDraftRecord> {
|
||||
const title = await decryptLocalString(record.title);
|
||||
const contentJson = await decryptLocalString(record.contentJson);
|
||||
const contentText = await decryptLocalString(record.contentText);
|
||||
|
||||
return {
|
||||
...record,
|
||||
title: typeof title === "string" ? title : "",
|
||||
contentJson: typeof contentJson === "string" ? contentJson : null,
|
||||
contentText: typeof contentText === "string" ? contentText : ""
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldEncryptOpLogRecord(record: LocalOpLogRecord): boolean {
|
||||
return !isLocalEncryptedString(record.payload);
|
||||
}
|
||||
|
||||
export async function encryptOpLogRecord(record: LocalOpLogRecord): Promise<LocalOpLogRecord> {
|
||||
return {
|
||||
...record,
|
||||
payload: (await encryptLocalString(record.payload)) ?? record.payload
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptOpLogRecord(record: LocalOpLogRecord): Promise<LocalOpLogRecord> {
|
||||
const payload = await decryptLocalString(record.payload);
|
||||
|
||||
return {
|
||||
...record,
|
||||
payload: typeof payload === "string" ? payload : record.payload
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldEncryptSyncInboxRecord(record: LocalSyncInboxRecord): boolean {
|
||||
return typeof record.payload === "string" && !isLocalEncryptedString(record.payload);
|
||||
}
|
||||
|
||||
export async function encryptSyncInboxRecord(
|
||||
record: LocalSyncInboxRecord
|
||||
): Promise<LocalSyncInboxRecord> {
|
||||
return {
|
||||
...record,
|
||||
payload: (await encryptLocalString(record.payload)) ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptSyncInboxRecord(
|
||||
record: LocalSyncInboxRecord
|
||||
): Promise<LocalSyncInboxRecord> {
|
||||
const payload = await decryptLocalString(record.payload);
|
||||
|
||||
return {
|
||||
...record,
|
||||
payload: typeof payload === "string" ? payload : null
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldEncryptAiChatSessionRecord(record: LocalAiChatSessionRecord): boolean {
|
||||
return (
|
||||
!isLocalEncryptedString(record.messagesJson) ||
|
||||
(typeof record.sessionId === "string" && !isLocalEncryptedString(record.sessionId))
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptAiChatSessionRecord(
|
||||
record: LocalAiChatSessionRecord
|
||||
): Promise<LocalAiChatSessionRecord> {
|
||||
return {
|
||||
...record,
|
||||
sessionId: (await encryptLocalString(record.sessionId)) ?? null,
|
||||
messagesJson: (await encryptLocalString(record.messagesJson)) ?? "[]"
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptAiChatSessionRecord(
|
||||
record: LocalAiChatSessionRecord
|
||||
): Promise<LocalAiChatSessionRecord> {
|
||||
const sessionId = await decryptLocalString(record.sessionId);
|
||||
const messagesJson = await decryptLocalString(record.messagesJson);
|
||||
|
||||
return {
|
||||
...record,
|
||||
sessionId: typeof sessionId === "string" ? sessionId : null,
|
||||
messagesJson: typeof messagesJson === "string" ? messagesJson : "[]"
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,36 @@
|
||||
import {
|
||||
import {
|
||||
localDb,
|
||||
type LocalOpLogRecord,
|
||||
type LocalSyncInboxRecord,
|
||||
type LocalSyncStateRecord
|
||||
} from "@/services/local-db";
|
||||
import {
|
||||
decryptOpLogRecord,
|
||||
decryptSyncInboxRecord,
|
||||
encryptOpLogRecord,
|
||||
encryptSyncInboxRecord,
|
||||
shouldEncryptOpLogRecord,
|
||||
shouldEncryptSyncInboxRecord
|
||||
} from "@/services/local-sensitive-codec";
|
||||
import type { SyncPullItem } from "@/services/sync-api";
|
||||
|
||||
export const MAX_SYNC_RETRY_COUNT = 5;
|
||||
|
||||
export async function listPendingSyncOperations(limit = 20): Promise<LocalOpLogRecord[]> {
|
||||
const records = await localDb.opLogs.orderBy("clientTs").toArray();
|
||||
const encryptedRecords = await Promise.all(
|
||||
records.filter(shouldEncryptOpLogRecord).map((record) => encryptOpLogRecord(record))
|
||||
);
|
||||
|
||||
return records
|
||||
if (encryptedRecords.length > 0) {
|
||||
await localDb.opLogs.bulkPut(encryptedRecords);
|
||||
}
|
||||
|
||||
const pendingRecords = records
|
||||
.filter((record) => record.syncedAt === null && record.retryCount < MAX_SYNC_RETRY_COUNT)
|
||||
.slice(0, limit);
|
||||
|
||||
return Promise.all(pendingRecords.map((record) => decryptOpLogRecord(record)));
|
||||
}
|
||||
|
||||
export async function countPendingSyncOperations(): Promise<number> {
|
||||
@@ -100,19 +117,23 @@ export async function enqueueRemoteSyncOperations(
|
||||
}
|
||||
|
||||
const receivedAt = Date.now();
|
||||
const records: LocalSyncInboxRecord[] = operations.map((operation) => ({
|
||||
opId: operation.opId,
|
||||
userId,
|
||||
entityId: operation.entityId,
|
||||
entityType: operation.entityType,
|
||||
action: operation.action,
|
||||
payload: operation.payload,
|
||||
clientTs: operation.clientTs,
|
||||
deviceId: operation.deviceId,
|
||||
serverTs: new Date(operation.serverTs).getTime(),
|
||||
receivedAt,
|
||||
appliedAt: null
|
||||
}));
|
||||
const records = await Promise.all(
|
||||
operations.map(async (operation) =>
|
||||
encryptSyncInboxRecord({
|
||||
opId: operation.opId,
|
||||
userId,
|
||||
entityId: operation.entityId,
|
||||
entityType: operation.entityType,
|
||||
action: operation.action,
|
||||
payload: operation.payload,
|
||||
clientTs: operation.clientTs,
|
||||
deviceId: operation.deviceId,
|
||||
serverTs: new Date(operation.serverTs).getTime(),
|
||||
receivedAt,
|
||||
appliedAt: null
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await localDb.syncInbox.bulkPut(records);
|
||||
return records.length;
|
||||
@@ -123,8 +144,15 @@ export async function listPendingRemoteOperations(
|
||||
limit = 100
|
||||
): Promise<LocalSyncInboxRecord[]> {
|
||||
const records = await localDb.syncInbox.where("userId").equals(userId).toArray();
|
||||
const encryptedRecords = await Promise.all(
|
||||
records.filter(shouldEncryptSyncInboxRecord).map((record) => encryptSyncInboxRecord(record))
|
||||
);
|
||||
|
||||
return records
|
||||
if (encryptedRecords.length > 0) {
|
||||
await localDb.syncInbox.bulkPut(encryptedRecords);
|
||||
}
|
||||
|
||||
const pendingRecords = records
|
||||
.filter((record) => record.appliedAt === null)
|
||||
.sort((left, right) => {
|
||||
if (left.serverTs !== right.serverTs) {
|
||||
@@ -138,6 +166,8 @@ export async function listPendingRemoteOperations(
|
||||
return left.opId.localeCompare(right.opId);
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return Promise.all(pendingRecords.map((record) => decryptSyncInboxRecord(record)));
|
||||
}
|
||||
|
||||
export async function markRemoteOperationsApplied(
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { localDb, type LocalTaskDraftRecord } from "@/services/local-db";
|
||||
import {
|
||||
decryptTaskDraftRecord,
|
||||
encryptTaskDraftRecord,
|
||||
shouldEncryptTaskDraft
|
||||
} from "@/services/local-sensitive-codec";
|
||||
|
||||
export type SaveLocalTaskDraftInput = {
|
||||
taskId: string;
|
||||
@@ -12,7 +17,16 @@ export type SaveLocalTaskDraftInput = {
|
||||
};
|
||||
|
||||
export async function getLocalTaskDraft(taskId: string): Promise<LocalTaskDraftRecord | undefined> {
|
||||
return localDb.taskDrafts.get(taskId);
|
||||
const draft = await localDb.taskDrafts.get(taskId);
|
||||
if (!draft) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (shouldEncryptTaskDraft(draft)) {
|
||||
await localDb.taskDrafts.put(await encryptTaskDraftRecord(draft));
|
||||
}
|
||||
|
||||
return decryptTaskDraftRecord(draft);
|
||||
}
|
||||
|
||||
export async function saveLocalTaskDraft(
|
||||
@@ -23,7 +37,7 @@ export async function saveLocalTaskDraft(
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
await localDb.taskDrafts.put(draft);
|
||||
await localDb.taskDrafts.put(await encryptTaskDraftRecord(draft));
|
||||
return draft;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
type LocalTaskStatus,
|
||||
type SyncActionType
|
||||
} from "@/services/local-db";
|
||||
import {
|
||||
decryptTaskRecord,
|
||||
encryptOpLogRecord,
|
||||
encryptTaskRecord,
|
||||
shouldEncryptTaskRecord
|
||||
} from "@/services/local-sensitive-codec";
|
||||
|
||||
const DEVICE_ID_STORAGE_KEY = "todolist.web.device-id";
|
||||
|
||||
@@ -83,7 +89,16 @@ function createSyncTaskPayload(payload: SyncTaskPayload): string {
|
||||
|
||||
export async function listLocalTasksByUser(userId: string): Promise<LocalTaskRecord[]> {
|
||||
const tasks = await localDb.tasks.where("userId").equals(userId).toArray();
|
||||
return tasks
|
||||
const encryptedTasks = await Promise.all(
|
||||
tasks.filter(shouldEncryptTaskRecord).map((task) => encryptTaskRecord(task))
|
||||
);
|
||||
|
||||
if (encryptedTasks.length > 0) {
|
||||
await localDb.tasks.bulkPut(encryptedTasks);
|
||||
}
|
||||
|
||||
const decryptedTasks = await Promise.all(tasks.map((task) => decryptTaskRecord(task)));
|
||||
return decryptedTasks
|
||||
.filter((task) => task.deletedAt === null)
|
||||
.sort((left, right) => right.updatedAt - left.updatedAt);
|
||||
}
|
||||
@@ -94,7 +109,11 @@ export async function getLocalTaskById(id: string): Promise<LocalTaskRecord | un
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return task;
|
||||
if (shouldEncryptTaskRecord(task)) {
|
||||
await localDb.tasks.put(await encryptTaskRecord(task));
|
||||
}
|
||||
|
||||
return decryptTaskRecord(task);
|
||||
}
|
||||
|
||||
export async function createLocalTask(input: CreateLocalTaskInput): Promise<LocalTaskRecord> {
|
||||
@@ -134,8 +153,8 @@ export async function createLocalTask(input: CreateLocalTaskInput): Promise<Loca
|
||||
);
|
||||
|
||||
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
||||
await localDb.tasks.add(task);
|
||||
await localDb.opLogs.add(opLog);
|
||||
await localDb.tasks.add(await encryptTaskRecord(task));
|
||||
await localDb.opLogs.add(await encryptOpLogRecord(opLog));
|
||||
});
|
||||
|
||||
return task;
|
||||
@@ -178,8 +197,8 @@ export async function updateLocalTask(
|
||||
);
|
||||
|
||||
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
||||
await localDb.tasks.put(nextTask);
|
||||
await localDb.opLogs.add(opLog);
|
||||
await localDb.tasks.put(await encryptTaskRecord(nextTask));
|
||||
await localDb.opLogs.add(await encryptOpLogRecord(opLog));
|
||||
});
|
||||
|
||||
return nextTask;
|
||||
@@ -211,8 +230,8 @@ export async function deleteLocalTask(id: string): Promise<boolean> {
|
||||
);
|
||||
|
||||
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
||||
await localDb.tasks.put(nextTask);
|
||||
await localDb.opLogs.add(opLog);
|
||||
await localDb.tasks.put(await encryptTaskRecord(nextTask));
|
||||
await localDb.opLogs.add(await encryptOpLogRecord(opLog));
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { localDb } from "@/services/local-db";
|
||||
import { listLocalTasksByUser } from "@/services/local-task-repo";
|
||||
|
||||
export const DEFAULT_CLOUD_QUOTA_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
@@ -18,13 +18,9 @@ function measureTextBytes(value: string | null): number {
|
||||
}
|
||||
|
||||
export async function getStorageQuotaSnapshot(userId: string): Promise<StorageQuotaSnapshot> {
|
||||
const tasks = await localDb.tasks.where("userId").equals(userId).toArray();
|
||||
const tasks = await listLocalTasksByUser(userId);
|
||||
|
||||
const usedBytes = tasks.reduce((total, task) => {
|
||||
if (task.deletedAt !== null) {
|
||||
return total;
|
||||
}
|
||||
|
||||
return (
|
||||
total +
|
||||
measureTextBytes(task.title) +
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
localDb,
|
||||
type LocalSyncInboxRecord,
|
||||
type LocalTaskPriority,
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
type LocalTaskStatus
|
||||
} from "@/services/local-db";
|
||||
import { listPendingRemoteOperations } from "@/services/local-sync-repo";
|
||||
import {
|
||||
decryptTaskRecord,
|
||||
encryptTaskRecord,
|
||||
shouldEncryptTaskRecord
|
||||
} from "@/services/local-sensitive-codec";
|
||||
|
||||
const TASK_PRIORITY_VALUES: LocalTaskPriority[] = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||
const TASK_STATUS_VALUES: LocalTaskStatus[] = ["TODO", "IN_PROGRESS", "DONE", "ARCHIVED"];
|
||||
@@ -246,11 +251,14 @@ export async function applyPendingRemoteOperations(userId: string): Promise<numb
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentTask = await localDb.tasks.get(operation.entityId);
|
||||
const storedTask = await localDb.tasks.get(operation.entityId);
|
||||
const currentTask = storedTask ? await decryptTaskRecord(storedTask) : undefined;
|
||||
const incomingTask = buildIncomingTaskRecord(operation, currentTask);
|
||||
|
||||
if (shouldApplyIncomingTask(currentTask, incomingTask, operation)) {
|
||||
await localDb.tasks.put(incomingTask);
|
||||
await localDb.tasks.put(await encryptTaskRecord(incomingTask));
|
||||
} else if (storedTask && currentTask && shouldEncryptTaskRecord(storedTask)) {
|
||||
await localDb.tasks.put(await encryptTaskRecord(currentTask));
|
||||
}
|
||||
|
||||
await localDb.syncInbox.update(operation.opId, { appliedAt });
|
||||
|
||||
Reference in New Issue
Block a user