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 { localDb, type LocalAiChatSessionRecord } from "@/services/local-db";
|
||||||
import type { WebAiChannel } from "@/services/ai-api";
|
import type { WebAiChannel } from "@/services/ai-api";
|
||||||
|
import {
|
||||||
|
decryptAiChatSessionRecord,
|
||||||
|
encryptAiChatSessionRecord,
|
||||||
|
shouldEncryptAiChatSessionRecord
|
||||||
|
} from "@/services/local-sensitive-codec";
|
||||||
|
|
||||||
export type LocalAiChatMessageRecord = {
|
export type LocalAiChatMessageRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,20 +69,33 @@ export async function listLocalAiChatSessions(
|
|||||||
userId: string
|
userId: string
|
||||||
): Promise<LocalAiChatSessionSnapshot[]> {
|
): Promise<LocalAiChatSessionSnapshot[]> {
|
||||||
const records = await localDb.aiChatSessions.where("userId").equals(userId).toArray();
|
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(
|
export async function saveLocalAiChatSession(
|
||||||
input: SaveLocalAiChatSessionInput
|
input: SaveLocalAiChatSessionInput
|
||||||
): Promise<LocalAiChatSessionRecord> {
|
): Promise<LocalAiChatSessionRecord> {
|
||||||
const record: LocalAiChatSessionRecord = {
|
const record = await encryptAiChatSessionRecord({
|
||||||
key: createSessionKey(input.userId, input.channel),
|
key: createSessionKey(input.userId, input.channel),
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
channel: input.channel,
|
channel: input.channel,
|
||||||
sessionId: input.sessionId,
|
sessionId: input.sessionId,
|
||||||
messagesJson: JSON.stringify(input.messages),
|
messagesJson: JSON.stringify(input.messages),
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
};
|
});
|
||||||
|
|
||||||
await localDb.aiChatSessions.put(record);
|
await localDb.aiChatSessions.put(record);
|
||||||
return 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,
|
localDb,
|
||||||
type LocalOpLogRecord,
|
type LocalOpLogRecord,
|
||||||
type LocalSyncInboxRecord,
|
type LocalSyncInboxRecord,
|
||||||
type LocalSyncStateRecord
|
type LocalSyncStateRecord
|
||||||
} from "@/services/local-db";
|
} from "@/services/local-db";
|
||||||
|
import {
|
||||||
|
decryptOpLogRecord,
|
||||||
|
decryptSyncInboxRecord,
|
||||||
|
encryptOpLogRecord,
|
||||||
|
encryptSyncInboxRecord,
|
||||||
|
shouldEncryptOpLogRecord,
|
||||||
|
shouldEncryptSyncInboxRecord
|
||||||
|
} from "@/services/local-sensitive-codec";
|
||||||
import type { SyncPullItem } from "@/services/sync-api";
|
import type { SyncPullItem } from "@/services/sync-api";
|
||||||
|
|
||||||
export const MAX_SYNC_RETRY_COUNT = 5;
|
export const MAX_SYNC_RETRY_COUNT = 5;
|
||||||
|
|
||||||
export async function listPendingSyncOperations(limit = 20): Promise<LocalOpLogRecord[]> {
|
export async function listPendingSyncOperations(limit = 20): Promise<LocalOpLogRecord[]> {
|
||||||
const records = await localDb.opLogs.orderBy("clientTs").toArray();
|
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)
|
.filter((record) => record.syncedAt === null && record.retryCount < MAX_SYNC_RETRY_COUNT)
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return Promise.all(pendingRecords.map((record) => decryptOpLogRecord(record)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function countPendingSyncOperations(): Promise<number> {
|
export async function countPendingSyncOperations(): Promise<number> {
|
||||||
@@ -100,19 +117,23 @@ export async function enqueueRemoteSyncOperations(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const receivedAt = Date.now();
|
const receivedAt = Date.now();
|
||||||
const records: LocalSyncInboxRecord[] = operations.map((operation) => ({
|
const records = await Promise.all(
|
||||||
opId: operation.opId,
|
operations.map(async (operation) =>
|
||||||
userId,
|
encryptSyncInboxRecord({
|
||||||
entityId: operation.entityId,
|
opId: operation.opId,
|
||||||
entityType: operation.entityType,
|
userId,
|
||||||
action: operation.action,
|
entityId: operation.entityId,
|
||||||
payload: operation.payload,
|
entityType: operation.entityType,
|
||||||
clientTs: operation.clientTs,
|
action: operation.action,
|
||||||
deviceId: operation.deviceId,
|
payload: operation.payload,
|
||||||
serverTs: new Date(operation.serverTs).getTime(),
|
clientTs: operation.clientTs,
|
||||||
receivedAt,
|
deviceId: operation.deviceId,
|
||||||
appliedAt: null
|
serverTs: new Date(operation.serverTs).getTime(),
|
||||||
}));
|
receivedAt,
|
||||||
|
appliedAt: null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
await localDb.syncInbox.bulkPut(records);
|
await localDb.syncInbox.bulkPut(records);
|
||||||
return records.length;
|
return records.length;
|
||||||
@@ -123,8 +144,15 @@ export async function listPendingRemoteOperations(
|
|||||||
limit = 100
|
limit = 100
|
||||||
): Promise<LocalSyncInboxRecord[]> {
|
): Promise<LocalSyncInboxRecord[]> {
|
||||||
const records = await localDb.syncInbox.where("userId").equals(userId).toArray();
|
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)
|
.filter((record) => record.appliedAt === null)
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
if (left.serverTs !== right.serverTs) {
|
if (left.serverTs !== right.serverTs) {
|
||||||
@@ -138,6 +166,8 @@ export async function listPendingRemoteOperations(
|
|||||||
return left.opId.localeCompare(right.opId);
|
return left.opId.localeCompare(right.opId);
|
||||||
})
|
})
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return Promise.all(pendingRecords.map((record) => decryptSyncInboxRecord(record)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markRemoteOperationsApplied(
|
export async function markRemoteOperationsApplied(
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { localDb, type LocalTaskDraftRecord } from "@/services/local-db";
|
import { localDb, type LocalTaskDraftRecord } from "@/services/local-db";
|
||||||
|
import {
|
||||||
|
decryptTaskDraftRecord,
|
||||||
|
encryptTaskDraftRecord,
|
||||||
|
shouldEncryptTaskDraft
|
||||||
|
} from "@/services/local-sensitive-codec";
|
||||||
|
|
||||||
export type SaveLocalTaskDraftInput = {
|
export type SaveLocalTaskDraftInput = {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -12,7 +17,16 @@ export type SaveLocalTaskDraftInput = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getLocalTaskDraft(taskId: string): Promise<LocalTaskDraftRecord | undefined> {
|
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(
|
export async function saveLocalTaskDraft(
|
||||||
@@ -23,7 +37,7 @@ export async function saveLocalTaskDraft(
|
|||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
await localDb.taskDrafts.put(draft);
|
await localDb.taskDrafts.put(await encryptTaskDraftRecord(draft));
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
type LocalTaskStatus,
|
type LocalTaskStatus,
|
||||||
type SyncActionType
|
type SyncActionType
|
||||||
} from "@/services/local-db";
|
} from "@/services/local-db";
|
||||||
|
import {
|
||||||
|
decryptTaskRecord,
|
||||||
|
encryptOpLogRecord,
|
||||||
|
encryptTaskRecord,
|
||||||
|
shouldEncryptTaskRecord
|
||||||
|
} from "@/services/local-sensitive-codec";
|
||||||
|
|
||||||
const DEVICE_ID_STORAGE_KEY = "todolist.web.device-id";
|
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[]> {
|
export async function listLocalTasksByUser(userId: string): Promise<LocalTaskRecord[]> {
|
||||||
const tasks = await localDb.tasks.where("userId").equals(userId).toArray();
|
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)
|
.filter((task) => task.deletedAt === null)
|
||||||
.sort((left, right) => right.updatedAt - left.updatedAt);
|
.sort((left, right) => right.updatedAt - left.updatedAt);
|
||||||
}
|
}
|
||||||
@@ -94,7 +109,11 @@ export async function getLocalTaskById(id: string): Promise<LocalTaskRecord | un
|
|||||||
return undefined;
|
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> {
|
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.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
||||||
await localDb.tasks.add(task);
|
await localDb.tasks.add(await encryptTaskRecord(task));
|
||||||
await localDb.opLogs.add(opLog);
|
await localDb.opLogs.add(await encryptOpLogRecord(opLog));
|
||||||
});
|
});
|
||||||
|
|
||||||
return task;
|
return task;
|
||||||
@@ -178,8 +197,8 @@ export async function updateLocalTask(
|
|||||||
);
|
);
|
||||||
|
|
||||||
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
||||||
await localDb.tasks.put(nextTask);
|
await localDb.tasks.put(await encryptTaskRecord(nextTask));
|
||||||
await localDb.opLogs.add(opLog);
|
await localDb.opLogs.add(await encryptOpLogRecord(opLog));
|
||||||
});
|
});
|
||||||
|
|
||||||
return nextTask;
|
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.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
||||||
await localDb.tasks.put(nextTask);
|
await localDb.tasks.put(await encryptTaskRecord(nextTask));
|
||||||
await localDb.opLogs.add(opLog);
|
await localDb.opLogs.add(await encryptOpLogRecord(opLog));
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
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;
|
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> {
|
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) => {
|
const usedBytes = tasks.reduce((total, task) => {
|
||||||
if (task.deletedAt !== null) {
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
total +
|
total +
|
||||||
measureTextBytes(task.title) +
|
measureTextBytes(task.title) +
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
localDb,
|
localDb,
|
||||||
type LocalSyncInboxRecord,
|
type LocalSyncInboxRecord,
|
||||||
type LocalTaskPriority,
|
type LocalTaskPriority,
|
||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
type LocalTaskStatus
|
type LocalTaskStatus
|
||||||
} from "@/services/local-db";
|
} from "@/services/local-db";
|
||||||
import { listPendingRemoteOperations } from "@/services/local-sync-repo";
|
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_PRIORITY_VALUES: LocalTaskPriority[] = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||||
const TASK_STATUS_VALUES: LocalTaskStatus[] = ["TODO", "IN_PROGRESS", "DONE", "ARCHIVED"];
|
const TASK_STATUS_VALUES: LocalTaskStatus[] = ["TODO", "IN_PROGRESS", "DONE", "ARCHIVED"];
|
||||||
@@ -246,11 +251,14 @@ export async function applyPendingRemoteOperations(userId: string): Promise<numb
|
|||||||
continue;
|
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);
|
const incomingTask = buildIncomingTaskRecord(operation, currentTask);
|
||||||
|
|
||||||
if (shouldApplyIncomingTask(currentTask, incomingTask, operation)) {
|
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 });
|
await localDb.syncInbox.update(operation.opId, { appliedAt });
|
||||||
|
|||||||
Reference in New Issue
Block a user