diff --git a/apps/web/src/components/publish-view/index.tsx b/apps/web/src/components/publish-view/index.tsx index 9bfbe6224..e6867127b 100644 --- a/apps/web/src/components/publish-view/index.tsx +++ b/apps/web/src/components/publish-view/index.tsx @@ -54,6 +54,29 @@ function PublishView(props: PublishViewProps) { const publishNote = useStore((store) => store.publish); const unpublishNote = useStore((store) => store.unpublish); + useEffect(() => { + if (!publishId) return; + (async () => { + const monographId = db.monographs.monograph(note.id); + if (monographId) { + const monograph = await db.monographs.get(monographId); + if (!monograph) return; + setPublishId(monographId); + setIsPasswordProtected(!!monograph.password); + setSelfDestruct(!!monograph.selfDestruct); + + if (monograph.password) { + const password = await db.monographs.decryptPassword( + monograph.password + ); + if (passwordInput.current) { + passwordInput.current.value = password; + } + } + } + })(); + }, [publishId, isPublishing]); + useEffect(() => { const fileDownloadedEvent = EV.subscribe( EVENTS.fileDownloaded, @@ -110,7 +133,13 @@ function PublishView(props: PublishViewProps) { ) : ( <> {publishId ? ( - + ) : ( - + {strings.monographDesc()} )} diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 0f2533f5b..5325ddca5 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -36,6 +36,7 @@ import Migrations from "./migrations.js"; import UserManager from "./user-manager.js"; import http from "../utils/http.js"; import { Monographs } from "./monographs.js"; +import { Monographs as MonographsCollection } from "../collections/monographs.js"; import { Offers } from "./offers.js"; import { Attachments } from "../collections/attachments.js"; import { Debug } from "./debug.js"; @@ -203,6 +204,7 @@ class Database { trash = new Trash(this); sanitizer = new Sanitizer(this.sql); + monographsCollection = new MonographsCollection(this); notebooks = new Notebooks(this); tags = new Tags(this); colors = new Colors(this); @@ -329,6 +331,7 @@ class Database { await this.relations.init(); await this.notes.init(); await this.vaults.init(); + await this.monographsCollection.init(); await this.trash.init(); @@ -407,6 +410,9 @@ class Database { EV.publish(EVENTS.userEmailConfirmed); break; } + case "triggerSync": { + await this.sync({ type: "fetch" }); + } } } catch (e) { console.log("SSE: Unsupported message. Message = ", event.data); diff --git a/packages/core/src/api/monographs.ts b/packages/core/src/api/monographs.ts index 46ed24064..f0f30dd21 100644 --- a/packages/core/src/api/monographs.ts +++ b/packages/core/src/api/monographs.ts @@ -20,24 +20,23 @@ along with this program. If not, see . import http from "../utils/http.js"; import Constants from "../utils/constants.js"; import Database from "./index.js"; -import { Note, isDeleted } from "../types.js"; +import { Monograph, Note, isDeleted } from "../types.js"; import { Cipher } from "@notesnook/crypto"; import { isFalse } from "../database/index.js"; -import { logger } from "../logger.js"; -type BaseMonograph = { - id: string; - title: string; - userId: string; - selfDestruct: boolean; -}; -type UnencryptedMonograph = BaseMonograph & { +type MonographApiRequestBase = Omit< + Monograph, + "type" | "dateModified" | "dateCreated" | "datePublished" +>; +type UnencryptedMonograph = MonographApiRequestBase & { content: string; }; -type EncryptedMonograph = BaseMonograph & { +type EncryptedMonograph = MonographApiRequestBase & { encryptedContent: Cipher<"base64">; }; -type Monograph = UnencryptedMonograph | EncryptedMonograph; +type MonographApiRequest = (UnencryptedMonograph | EncryptedMonograph) & { + userId: string; +}; export type PublishOptions = { password?: string; selfDestruct?: boolean }; export class Monographs { @@ -46,24 +45,12 @@ export class Monographs { async clear() { this.monographs = []; - await this.db.kv().write("monographs", this.monographs); + await this.db.monographsCollection.collection.clear(); } async refresh() { - try { - const user = await this.db.user.getUser(); - const token = await this.db.tokenManager.getAccessToken(); - if (!user || !token || !user.isEmailConfirmed) return; - - const monographs = await http.get( - `${Constants.API_HOST}/monographs`, - token - ); - await this.db.kv().write("monographs", monographs); - if (monographs) this.monographs = monographs; - } catch (e) { - logger.error(e, "Error while refreshing monographs."); - } + const ids = await this.db.monographsCollection.all.ids(); + this.monographs = ids; } /** @@ -109,13 +96,19 @@ export class Monographs { false ); - const monograph: Monograph = { + const monographPasswordsKey = await this.db.user.getMonographPasswordsKey(); + const monograph: MonographApiRequest = { id: noteId, title: note.title, userId: user.id, selfDestruct: opts.selfDestruct || false, ...(opts.password ? { + password: monographPasswordsKey + ? await this.db + .storage() + .encrypt(monographPasswordsKey, opts.password) + : undefined, encryptedContent: await this.db .storage() .encrypt( @@ -124,6 +117,7 @@ export class Monographs { ) } : { + password: undefined, content: JSON.stringify({ type: content.type, data: content.data @@ -132,14 +126,21 @@ export class Monographs { }; const method = update ? http.patch.json : http.post.json; - - const { id } = await method( - `${Constants.API_HOST}/monographs`, + const deviceId = await this.db.kv().read("deviceId"); + const { id, datePublished } = await method( + `${Constants.API_HOST}/monographs?deviceId=${deviceId}`, monograph, token ); this.monographs.push(id); + await this.db.monographsCollection.add({ + id, + title: monograph.title, + selfDestruct: monograph.selfDestruct, + datePublished: datePublished, + password: monograph.password + }); return id; } @@ -156,9 +157,14 @@ export class Monographs { if (!this.isPublished(noteId)) throw new Error("This note is not published."); - await http.delete(`${Constants.API_HOST}/monographs/${noteId}`, token); + const deviceId = await this.db.kv().read("deviceId"); + await http.delete( + `${Constants.API_HOST}/monographs/${noteId}?deviceId=${deviceId}`, + token + ); this.monographs.splice(this.monographs.indexOf(noteId), 1); + await this.db.monographsCollection.collection.softDelete([noteId]); } get all() { @@ -173,6 +179,12 @@ export class Monographs { } get(monographId: string) { - return http.get(`${Constants.API_HOST}/monographs/${monographId}`); + return this.db.monographsCollection.collection.get(monographId); + } + + async decryptPassword(password: Cipher<"base64">) { + const monographPasswordsKey = await this.db.user.getMonographPasswordsKey(); + if (!monographPasswordsKey) return ""; + return this.db.storage().decrypt(monographPasswordsKey, password); } } diff --git a/packages/core/src/api/sync/index.ts b/packages/core/src/api/sync/index.ts index 99f5e04ac..6698dd052 100644 --- a/packages/core/src/api/sync/index.ts +++ b/packages/core/src/api/sync/index.ts @@ -42,6 +42,7 @@ import { isTrashItem, Item, MaybeDeletedItem, + Monograph, Note, Notebook } from "../../types.js"; @@ -53,6 +54,7 @@ import { import { DownloadableFile } from "../../database/fs.js"; import { SyncDevices } from "./devices.js"; import { DefaultColors } from "../../collections/colors.js"; +import { Monographs } from "../monographs.js"; enum LogLevel { /** Log level for very low severity diagnostic messages. */ @@ -463,6 +465,19 @@ class Sync { return true; }); + + this.connection.on("SendMonographs", async (monographs) => { + if (this.connection?.state !== HubConnectionState.Connected) return false; + + this.db.monographsCollection.collection.put( + monographs.map((m: Monograph) => ({ + ...m, + type: "monograph" + })) + ); + + return true; + }); } private async getKey() { diff --git a/packages/core/src/api/user-manager.ts b/packages/core/src/api/user-manager.ts index a86201e67..20aa9b2c8 100644 --- a/packages/core/src/api/user-manager.ts +++ b/packages/core/src/api/user-manager.ts @@ -43,6 +43,7 @@ const ENDPOINTS = { class UserManager { private tokenManager: TokenManager; private cachedAttachmentKey?: SerializedKey; + private cachedMonographPasswordsKey?: SerializedKey; constructor(private readonly db: Database) { this.tokenManager = new TokenManager(this.db.kv); @@ -459,6 +460,51 @@ class UserManager { } } + async getMonographPasswordsKey() { + if (this.cachedMonographPasswordsKey) { + return this.cachedMonographPasswordsKey; + } + + try { + let user = await this.getUser(); + if (!user) return; + + if (!user.monographPasswordsKey) { + const token = await this.tokenManager.getAccessToken(); + user = await http.get(`${constants.API_HOST}${ENDPOINTS.user}`, token); + } + if (!user) return; + + const userEncryptionKey = await this.getEncryptionKey(); + if (!userEncryptionKey) return; + + if (!user.monographPasswordsKey) { + const key = await this.db.crypto().generateRandomKey(); + user.monographPasswordsKey = await this.db + .storage() + .encrypt(userEncryptionKey, JSON.stringify(key)); + + await this.updateUser({ + monographPasswordsKey: user.monographPasswordsKey + }); + return key; + } + + const plainData = await this.db + .storage() + .decrypt(userEncryptionKey, user.monographPasswordsKey); + if (!plainData) return; + this.cachedMonographPasswordsKey = JSON.parse(plainData) as SerializedKey; + return this.cachedMonographPasswordsKey; + } catch (e) { + logger.error(e, "Could not get monographs encryption key."); + if (e instanceof Error) + throw new Error( + `Could not get monographs encryption key. Error: ${e.message}` + ); + } + } + async sendVerificationEmail(newEmail?: string) { const token = await this.tokenManager.getAccessToken(); if (!token) return; @@ -533,7 +579,6 @@ class UserManager { if (!new_password) throw new Error("New password is required."); - const attachmentsKey = await this.getAttachmentsKey(); data.encryptionKey = data.encryptionKey || (await this.getEncryptionKey()); await this.clearSessions(); @@ -554,13 +599,26 @@ class UserManager { await this.db.sync({ type: "send", force: true }); - if (attachmentsKey) { - const userEncryptionKey = await this.getEncryptionKey(); - if (!userEncryptionKey) return; - user.attachmentsKey = await this.db - .storage() - .encrypt(userEncryptionKey, JSON.stringify(attachmentsKey)); - await this.updateUser({ attachmentsKey: user.attachmentsKey }); + const userEncryptionKey = await this.getEncryptionKey(); + if (userEncryptionKey) { + const updateUserPayload: Partial = {}; + const attachmentsKey = await this.getAttachmentsKey(); + if (attachmentsKey) { + user.attachmentsKey = await this.db + .storage() + .encrypt(userEncryptionKey, JSON.stringify(attachmentsKey)); + updateUserPayload.attachmentsKey = user.attachmentsKey; + } + const monographPasswordsKey = await this.getMonographPasswordsKey(); + if (monographPasswordsKey) { + user.monographPasswordsKey = await this.db + .storage() + .encrypt(userEncryptionKey, JSON.stringify(monographPasswordsKey)); + updateUserPayload.monographPasswordsKey = user.monographPasswordsKey; + } + if (Object.keys(updateUserPayload).length > 0) { + await this.updateUser(updateUserPayload); + } } if (new_password) diff --git a/packages/core/src/collections/monographs.ts b/packages/core/src/collections/monographs.ts new file mode 100644 index 000000000..c2f966111 --- /dev/null +++ b/packages/core/src/collections/monographs.ts @@ -0,0 +1,69 @@ +/* +This file is part of the Notesnook project (https://notesnook.com/) + +Copyright (C) 2023 Streetwriters (Private) Limited + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +import Database from "../api/index.js"; +import { ItemReference, Monograph, Vault } from "../types.js"; +import { ICollection } from "./collection.js"; +import { SQLCollection } from "../database/sql-collection.js"; +import { getId } from "../utils/id.js"; +import { isFalse } from "../database/index.js"; +import { sql } from "@streetwriters/kysely"; + +export class Monographs implements ICollection { + name = "monographs"; + readonly collection: SQLCollection<"monographs", Monograph>; + constructor(private readonly db: Database) { + this.collection = new SQLCollection( + db.sql, + db.transaction, + "monographs", + db.eventManager, + db.sanitizer + ); + } + + async init() { + await this.collection.init(); + } + + get all() { + return this.collection.createFilter( + (qb) => qb.where(isFalse("deleted")), + this.db.options?.batchSize + ); + } + + async add(monograph: Partial) { + const id = monograph.id || getId(); + const oldMonograph = await this.collection.get(id); + const merged: Partial = { + ...oldMonograph, + ...monograph + }; + + this.collection.upsert({ + id, + title: merged.title, + datePublished: merged.datePublished, + selfDestruct: merged.selfDestruct, + password: merged.password, + type: "monograph" + }); + } +} diff --git a/packages/core/src/database/index.ts b/packages/core/src/database/index.ts index 03e198ec1..7989d8546 100644 --- a/packages/core/src/database/index.ts +++ b/packages/core/src/database/index.ts @@ -46,6 +46,7 @@ import { ItemReferences, ItemType, MaybeDeletedItem, + Monograph, Note, Notebook, Relation, @@ -90,6 +91,7 @@ export interface DatabaseSchema { sessioncontent: SQLiteItem; shortcuts: SQLiteItem; vaults: SQLiteItem; + monographs: SQLiteItem; } export type RawDatabaseSchema = DatabaseSchema & { @@ -233,7 +235,8 @@ const BooleanProperties: Set = new Set([ "remote", "synced", "isGeneratedTitle", - "archived" + "archived", + "selfDestruct" ]); const DataMappers: Partial void>> = { @@ -266,6 +269,9 @@ const DataMappers: Partial void>> = { }, vault: (row) => { if (row.key) row.key = JSON.parse(row.key); + }, + monograph: (row) => { + if (row.password) row.password = JSON.parse(row.password); } }; diff --git a/packages/core/src/database/migrations.ts b/packages/core/src/database/migrations.ts index e3e60f041..afbdd1e64 100644 --- a/packages/core/src/database/migrations.ts +++ b/packages/core/src/database/migrations.ts @@ -392,6 +392,18 @@ export class NNMigrationProvider implements MigrationProvider { async up(db) { await runFTSTablesMigrations(db); } + }, + "a-2025-07-30": { + async up(db) { + await db.schema + .createTable("monographs") + .$call(addBaseColumns) + .addColumn("datePublished", "integer") + .addColumn("title", "text", COLLATE_NOCASE) + .addColumn("selfDestruct", "boolean") + .addColumn("password", "text") + .execute(); + } } }; } diff --git a/packages/core/src/database/sql-collection.ts b/packages/core/src/database/sql-collection.ts index 8e1f6b276..2182eaecb 100644 --- a/packages/core/src/database/sql-collection.ts +++ b/packages/core/src/database/sql-collection.ts @@ -701,7 +701,8 @@ const VALID_SORT_OPTIONS: Record< sessioncontent: [], settings: [], shortcuts: [], - vaults: [] + vaults: [], + monographs: [] }; function sanitizeSortOptions(type: keyof DatabaseSchema, options: SortOptions) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index aeeb6adc8..3d35ee766 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -80,6 +80,7 @@ export type Collections = { sessioncontent: "sessioncontent"; settingsv2: "settingitem"; vaults: "vault"; + monographs: "monograph"; /** * @deprecated only kept here for migration purposes @@ -110,6 +111,7 @@ export type GroupableItem = ValueOf< | "settings" | "settingitem" | "vault" + | "monograph" > >; @@ -131,6 +133,7 @@ export type ItemMap = { settingitem: SettingItem; vault: Vault; searchResult: HighlightedResult; + monograph: Monograph; /** * @deprecated only kept here for migration purposes @@ -490,6 +493,13 @@ export interface Vault extends BaseItem<"vault"> { key: Cipher<"base64">; } +export interface Monograph extends BaseItem<"monograph"> { + title: string; + datePublished: number; + selfDestruct: boolean; + password?: Cipher<"base64">; +} + export type Match = { prefix: string; match: string; @@ -546,6 +556,7 @@ export type User = { isEmailConfirmed: boolean; salt: string; attachmentsKey?: Cipher<"base64">; + monographPasswordsKey?: Cipher<"base64">; marketingConsent?: boolean; mfa: { isEnabled: boolean;