Merge remote-tracking branch 'origin/next50'

This commit is contained in:
zadam 2022-01-10 19:53:36 +01:00
commit 916ff5f2ee
138 changed files with 3875 additions and 11959 deletions

View file

@ -3,7 +3,7 @@
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_16" project-jdk-name="openjdk-16" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="11" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

3
TODO
View file

@ -1,3 +0,0 @@
- new icon
- polish becca entities API
- separate private and public APIs in becca entities

View file

@ -0,0 +1 @@
DELETE FROM options WHERE name = 'username';

View file

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS "etapi_tokens"
(
etapiTokenId TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
tokenHash TEXT NOT NULL,
utcDateCreated TEXT NOT NULL,
utcDateModified TEXT NOT NULL,
isDeleted INT NOT NULL DEFAULT 0);
INSERT INTO etapi_tokens (etapiTokenId, name, tokenHash, utcDateCreated, utcDateModified, isDeleted)
SELECT apiTokenId, 'Trilium Sender', token, utcDateCreated, utcDateCreated, isDeleted FROM api_tokens;
DROP TABLE api_tokens;

View file

@ -0,0 +1,10 @@
module.exports = () => {
const sql = require('../../src/services/sql');
const crypto = require('crypto');
for (const {etapiTokenId, token} of sql.getRows("SELECT etapiTokenId, tokenHash AS token FROM etapi_tokens")) {
const tokenHash = crypto.createHash('sha256').update(token).digest('base64');
sql.execute(`UPDATE etapi_tokens SET tokenHash = ? WHERE etapiTokenId = ?`, [tokenHash, etapiTokenId]);
}
};

View file

@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS "mig_entity_changes" (
`id` INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
`entityName` TEXT NOT NULL,
`entityId` TEXT NOT NULL,
`hash` TEXT NOT NULL,
`isErased` INT NOT NULL,
`changeId` TEXT NOT NULL,
`componentId` TEXT NOT NULL,
`instanceId` TEXT NOT NULL,
`isSynced` INTEGER NOT NULL,
`utcDateChanged` TEXT NOT NULL
);
INSERT INTO mig_entity_changes (id, entityName, entityId, hash, isErased, changeId, componentId, instanceId, isSynced, utcDateChanged)
SELECT id, entityName, entityId, hash, isErased, changeId, '', '', isSynced, utcDateChanged FROM entity_changes;
DROP TABLE entity_changes;
ALTER TABLE mig_entity_changes RENAME TO entity_changes;
CREATE UNIQUE INDEX `IDX_entityChanges_entityName_entityId` ON "entity_changes" (
`entityName`,
`entityId`
);

View file

@ -0,0 +1 @@
CREATE INDEX `IDX_entity_changes_changeId` ON `entity_changes` (`changeId`);

View file

@ -5,14 +5,16 @@ CREATE TABLE IF NOT EXISTS "entity_changes" (
`hash` TEXT NOT NULL,
`isErased` INT NOT NULL,
`changeId` TEXT NOT NULL,
`sourceId` TEXT NOT NULL,
`componentId` TEXT NOT NULL,
`instanceId` TEXT NOT NULL,
`isSynced` INTEGER NOT NULL,
`utcDateChanged` TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "api_tokens"
CREATE TABLE IF NOT EXISTS "etapi_tokens"
(
apiTokenId TEXT PRIMARY KEY NOT NULL,
token TEXT NOT NULL,
etapiTokenId TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
tokenHash TEXT NOT NULL,
utcDateCreated TEXT NOT NULL,
isDeleted INT NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS "branches" (
@ -96,6 +98,7 @@ CREATE INDEX `IDX_note_revisions_utcDateCreated` ON `note_revisions` (`utcDateCr
CREATE INDEX `IDX_note_revisions_utcDateLastEdited` ON `note_revisions` (`utcDateLastEdited`);
CREATE INDEX `IDX_note_revisions_dateCreated` ON `note_revisions` (`dateCreated`);
CREATE INDEX `IDX_note_revisions_dateLastEdited` ON `note_revisions` (`dateLastEdited`);
CREATE INDEX `IDX_entity_changes_changeId` ON `entity_changes` (`changeId`);
CREATE INDEX IDX_attributes_name_value
on attributes (name, value);
CREATE INDEX IDX_attributes_noteId_index

View file

@ -0,0 +1,78 @@
// CodeMirror, copyright (c) by Marijn Haverbeke and others
// Distributed under an MIT license: https://codemirror.net/LICENSE
(function(mod) {
if (typeof exports == "object" && typeof module == "object") // CommonJS
mod(require("../../lib/codemirror"));
else if (typeof define == "function" && define.amd) // AMD
define(["../../lib/codemirror"], mod);
else // Plain browser env
mod(CodeMirror);
})(function(CodeMirror) {
CodeMirror.defineOption("placeholder", "", function(cm, val, old) {
var prev = old && old != CodeMirror.Init;
if (val && !prev) {
cm.on("blur", onBlur);
cm.on("change", onChange);
cm.on("swapDoc", onChange);
CodeMirror.on(cm.getInputField(), "compositionupdate", cm.state.placeholderCompose = function() { onComposition(cm) })
onChange(cm);
} else if (!val && prev) {
cm.off("blur", onBlur);
cm.off("change", onChange);
cm.off("swapDoc", onChange);
CodeMirror.off(cm.getInputField(), "compositionupdate", cm.state.placeholderCompose)
clearPlaceholder(cm);
var wrapper = cm.getWrapperElement();
wrapper.className = wrapper.className.replace(" CodeMirror-empty", "");
}
if (val && !cm.hasFocus()) onBlur(cm);
});
function clearPlaceholder(cm) {
if (cm.state.placeholder) {
cm.state.placeholder.parentNode.removeChild(cm.state.placeholder);
cm.state.placeholder = null;
}
}
function setPlaceholder(cm) {
clearPlaceholder(cm);
var elt = cm.state.placeholder = document.createElement("pre");
elt.style.cssText = "height: 0; overflow: visible";
elt.style.direction = cm.getOption("direction");
elt.className = "CodeMirror-placeholder CodeMirror-line-like";
var placeHolder = cm.getOption("placeholder")
if (typeof placeHolder == "string") placeHolder = document.createTextNode(placeHolder)
elt.appendChild(placeHolder)
cm.display.lineSpace.insertBefore(elt, cm.display.lineSpace.firstChild);
}
function onComposition(cm) {
setTimeout(function() {
var empty = false
if (cm.lineCount() == 1) {
var input = cm.getInputField()
empty = input.nodeName == "TEXTAREA" ? !cm.getLine(0).length
: !/[^\u200b]/.test(input.querySelector(".CodeMirror-line").textContent)
}
if (empty) setPlaceholder(cm)
else clearPlaceholder(cm)
}, 20)
}
function onBlur(cm) {
if (isEmpty(cm)) setPlaceholder(cm);
}
function onChange(cm) {
var wrapper = cm.getWrapperElement(), empty = isEmpty(cm);
wrapper.className = wrapper.className.replace(" CodeMirror-empty", "") + (empty ? " CodeMirror-empty" : "");
if (empty) setPlaceholder(cm);
else clearPlaceholder(cm);
}
function isEmpty(cm) {
return (cm.lineCount() === 1) && (cm.getLine(0) === "");
}
});

11481
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -43,10 +43,10 @@
"@electron/remote": "2.0.1",
"express": "4.17.2",
"express-partial-content": "^1.0.2",
"express-rate-limit": "5.5.1",
"express-rate-limit": "6.0.5",
"express-session": "1.17.2",
"fs-extra": "10.0.0",
"helmet": "4.6.0",
"helmet": "5.0.1",
"html": "1.0.0",
"html2plaintext": "2.1.4",
"http-proxy-agent": "5.0.0",
@ -88,7 +88,7 @@
"electron-packager": "15.4.0",
"electron-rebuild": "3.2.5",
"esm": "3.2.25",
"jasmine": "3.10.0",
"jasmine": "4.0.1",
"jsdoc": "3.6.7",
"lorem-ipsum": "2.0.4",
"rcedit": "3.0.1",

View file

@ -1,4 +1,4 @@
const lex = require('../../src/services/search/services/lex.js');
const lex = require('../../src/services/search/services/lex');
describe("Lexer fulltext", () => {
it("simple lexing", () => {

View file

@ -1,7 +1,7 @@
const Note = require('../../src/becca/entities/note.js');
const Branch = require('../../src/becca/entities/branch.js');
const Attribute = require('../../src/becca/entities/attribute.js');
const becca = require('../../src/becca/becca.js');
const Note = require('../../src/becca/entities/note');
const Branch = require('../../src/becca/entities/branch');
const Attribute = require('../../src/becca/entities/attribute');
const becca = require('../../src/becca/becca');
const randtoken = require('rand-token').generator({source: 'crypto'});
/** @returns {Note} */

View file

@ -1,4 +1,4 @@
const handleParens = require('../../src/services/search/services/handle_parens.js');
const handleParens = require('../../src/services/search/services/handle_parens');
describe("Parens handler", () => {
it("handles parens", () => {

View file

@ -1,5 +1,5 @@
const SearchContext = require("../../src/services/search/search_context.js");
const parse = require('../../src/services/search/services/parse.js');
const SearchContext = require("../../src/services/search/search_context");
const parse = require('../../src/services/search/services/parse');
function tokens(toks, cur = 0) {
return toks.map(arg => {

View file

@ -1,10 +1,10 @@
const searchService = require('../../src/services/search/services/search.js');
const Note = require('../../src/becca/entities/note.js');
const Branch = require('../../src/becca/entities/branch.js');
const SearchContext = require('../../src/services/search/search_context.js');
const dateUtils = require('../../src/services/date_utils.js');
const becca = require('../../src/becca/becca.js');
const {NoteBuilder, findNoteByTitle, note} = require('./note_cache_mocking.js');
const searchService = require('../../src/services/search/services/search');
const Note = require('../../src/becca/entities/note');
const Branch = require('../../src/becca/entities/branch');
const SearchContext = require('../../src/services/search/search_context');
const dateUtils = require('../../src/services/date_utils');
const becca = require('../../src/becca/becca');
const {NoteBuilder, findNoteByTitle, note} = require('./note_cache_mocking');
describe("Search", () => {
let rootNote;

View file

@ -1,7 +1,7 @@
const {note} = require('./note_cache_mocking.js');
const ValueExtractor = require('../../src/services/search/value_extractor.js');
const becca = require('../../src/becca/becca.js');
const SearchContext = require("../../src/services/search/search_context.js");
const {note} = require('./note_cache_mocking');
const ValueExtractor = require('../../src/services/search/value_extractor');
const becca = require('../../src/becca/becca');
const SearchContext = require("../../src/services/search/search_context");
const dsc = new SearchContext();

View file

@ -11,7 +11,7 @@ const sessionSecret = require('./services/session_secret');
const dataDir = require('./services/data_dir');
const utils = require('./services/utils');
require('./services/handlers');
require('./becca/becca_loader.js');
require('./becca/becca_loader');
const app = express();

View file

@ -1,7 +1,8 @@
"use strict";
const sql = require("../services/sql.js");
const sql = require("../services/sql");
const NoteSet = require("../services/search/note_set");
const EtapiToken = require("./entities/etapi_token");
/**
* Becca is a backend cache of all notes, branches and attributes. There's a similar frontend cache Froca.
@ -24,6 +25,8 @@ class Becca {
this.attributeIndex = {};
/** @type {Object.<String, Option>} */
this.options = {};
/** @type {Object.<String, EtapiToken>} */
this.etapiTokens = {};
this.loaded = false;
}
@ -64,10 +67,12 @@ class Becca {
this.dirtyNoteSetCache();
}
/** @returns {Note|null} */
getNote(noteId) {
return this.notes[noteId];
}
/** @returns {Note[]} */
getNotes(noteIds, ignoreMissing = false) {
const filteredNotes = [];
@ -88,29 +93,44 @@ class Becca {
return filteredNotes;
}
/** @returns {Branch|null} */
getBranch(branchId) {
return this.branches[branchId];
}
/** @returns {Attribute|null} */
getAttribute(attributeId) {
return this.attributes[attributeId];
}
/** @returns {Branch|null} */
getBranchFromChildAndParent(childNoteId, parentNoteId) {
return this.childParentToBranch[`${childNoteId}-${parentNoteId}`];
}
/** @returns {NoteRevision|null} */
getNoteRevision(noteRevisionId) {
const row = sql.getRow("SELECT * FROM note_revisions WHERE noteRevisionId = ?", [noteRevisionId]);
const NoteRevision = require("./entities/note_revision.js"); // avoiding circular dependency problems
const NoteRevision = require("./entities/note_revision"); // avoiding circular dependency problems
return row ? new NoteRevision(row) : null;
}
/** @returns {Option|null} */
getOption(name) {
return this.options[name];
}
/** @returns {EtapiToken[]} */
getEtapiTokens() {
return Object.values(this.etapiTokens);
}
/** @returns {EtapiToken|null} */
getEtapiToken(etapiTokenId) {
return this.etapiTokens[etapiTokenId];
}
getEntity(entityName, entityId) {
if (!entityName || !entityId) {
return null;
@ -130,17 +150,19 @@ class Becca {
return this[camelCaseEntityName][entityId];
}
/** @returns {RecentNote[]} */
getRecentNotesFromQuery(query, params = []) {
const rows = sql.getRows(query, params);
const RecentNote = require("./entities/recent_note.js"); // avoiding circular dependency problems
const RecentNote = require("./entities/recent_note"); // avoiding circular dependency problems
return rows.map(row => new RecentNote(row));
}
/** @returns {NoteRevision[]} */
getNoteRevisionsFromQuery(query, params = []) {
const rows = sql.getRows(query, params);
const NoteRevision = require("./entities/note_revision.js"); // avoiding circular dependency problems
const NoteRevision = require("./entities/note_revision"); // avoiding circular dependency problems
return rows.map(row => new NoteRevision(row));
}

View file

@ -9,6 +9,7 @@ const Note = require('./entities/note');
const Branch = require('./entities/branch');
const Attribute = require('./entities/attribute');
const Option = require('./entities/option');
const EtapiToken = require("./entities/etapi_token");
const cls = require("../services/cls");
const entityConstructor = require("../becca/entity_constructor");
@ -45,6 +46,10 @@ function load() {
new Option(row);
}
for (const row of sql.getRows(`SELECT etapiTokenId, name, tokenHash, utcDateCreated, utcDateModified FROM etapi_tokens WHERE isDeleted = 0`)) {
new EtapiToken(row);
}
for (const noteId in becca.notes) {
becca.notes[noteId].sortParents();
}
@ -75,7 +80,7 @@ eventService.subscribeBeccaLoader([eventService.ENTITY_CHANGE_SYNCED], ({entity
return;
}
if (["notes", "branches", "attributes"].includes(entityName)) {
if (["notes", "branches", "attributes", "etapi_tokens"].includes(entityName)) {
const EntityClass = entityConstructor.getEntityFromEntityName(entityName);
const primaryKeyName = EntityClass.primaryKeyName;
@ -112,6 +117,8 @@ eventService.subscribeBeccaLoader([eventService.ENTITY_DELETED, eventService.ENT
branchDeleted(entityId);
} else if (entityName === 'attributes') {
attributeDeleted(entityId);
} else if (entityName === 'etapi_tokens') {
etapiTokenDeleted(entityId);
}
});
@ -220,6 +227,10 @@ function noteReorderingUpdated(branchIdList) {
}
}
function etapiTokenDeleted(etapiTokenId) {
delete becca.etapiTokens[etapiTokenId];
}
eventService.subscribeBeccaLoader(eventService.ENTER_PROTECTED_SESSION, () => {
try {
becca.decryptProtectedNotes();

View file

@ -40,7 +40,7 @@ class AbstractEntity {
get becca() {
if (!becca) {
becca = require('../becca.js');
becca = require('../becca');
}
return becca;
@ -116,6 +116,19 @@ class AbstractEntity {
eventService.emit(eventService.ENTITY_DELETED, { entityName, entityId, entity: this });
}
markAsDeletedSimple() {
const entityId = this[this.constructor.primaryKeyName];
const entityName = this.constructor.entityName;
sql.execute(`UPDATE ${entityName} SET isDeleted = 1, utcDateModified = ?
WHERE ${this.constructor.primaryKeyName} = ?`,
[dateUtils.utcNowDateTime(), entityId]);
this.addEntityChange(true);
eventService.emit(eventService.ENTITY_DELETED, { entityName, entityId, entity: this });
}
}
module.exports = AbstractEntity;

View file

@ -1,34 +0,0 @@
"use strict";
const dateUtils = require('../../services/date_utils.js');
const AbstractEntity = require("./abstract_entity.js");
/**
* ApiToken is an entity representing token used to authenticate against Trilium API from client applications. Currently used only by Trilium Sender.
*/
class ApiToken extends AbstractEntity {
static get entityName() { return "api_tokens"; }
static get primaryKeyName() { return "apiTokenId"; }
static get hashedProperties() { return ["apiTokenId", "token", "utcDateCreated"]; }
constructor(row) {
super();
/** @type {string} */
this.apiTokenId = row.apiTokenId;
/** @type {string} */
this.token = row.token;
/** @type {string} */
this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
}
getPojo() {
return {
apiTokenId: this.apiTokenId,
token: this.token,
utcDateCreated: this.utcDateCreated
}
}
}
module.exports = ApiToken;

View file

@ -1,9 +1,9 @@
"use strict";
const Note = require('./note.js');
const AbstractEntity = require("./abstract_entity.js");
const sql = require("../../services/sql.js");
const dateUtils = require("../../services/date_utils.js");
const Note = require('./note');
const AbstractEntity = require("./abstract_entity");
const sql = require("../../services/sql");
const dateUtils = require("../../services/date_utils");
const promotedAttributeDefinitionParser = require("../../services/promoted_attribute_definition_parser");
/**

View file

@ -1,9 +1,9 @@
"use strict";
const Note = require('./note.js');
const AbstractEntity = require("./abstract_entity.js");
const sql = require("../../services/sql.js");
const dateUtils = require("../../services/date_utils.js");
const Note = require('./note');
const AbstractEntity = require("./abstract_entity");
const sql = require("../../services/sql");
const dateUtils = require("../../services/date_utils");
/**
* Branch represents a relationship between a child note and its parent note. Trilium allows a note to have multiple

View file

@ -0,0 +1,72 @@
"use strict";
const dateUtils = require('../../services/date_utils');
const AbstractEntity = require("./abstract_entity");
const sql = require("../../services/sql.js");
/**
* EtapiToken is an entity representing token used to authenticate against Trilium REST API from client applications.
* Used by:
* - Trilium Sender
* - ETAPI clients
*/
class EtapiToken extends AbstractEntity {
static get entityName() { return "etapi_tokens"; }
static get primaryKeyName() { return "etapiTokenId"; }
static get hashedProperties() { return ["etapiTokenId", "name", "tokenHash", "utcDateCreated", "utcDateModified", "isDeleted"]; }
constructor(row) {
super();
if (!row) {
return;
}
this.updateFromRow(row);
this.init();
}
updateFromRow(row) {
/** @type {string} */
this.etapiTokenId = row.etapiTokenId;
/** @type {string} */
this.name = row.name;
/** @type {string} */
this.tokenHash = row.tokenHash;
/** @type {string} */
this.utcDateCreated = row.utcDateCreated || dateUtils.utcNowDateTime();
/** @type {string} */
this.utcDateModified = row.utcDateModified || this.utcDateCreated;
/** @type {boolean} */
this.isDeleted = !!row.isDeleted;
this.becca.etapiTokens[this.etapiTokenId] = this;
}
init() {
if (this.etapiTokenId) {
this.becca.etapiTokens[this.etapiTokenId] = this;
}
}
getPojo() {
return {
etapiTokenId: this.etapiTokenId,
name: this.name,
tokenHash: this.tokenHash,
utcDateCreated: this.utcDateCreated,
utcDateModified: this.utcDateModified,
isDeleted: this.isDeleted
}
}
beforeSaving() {
this.utcDateModified = dateUtils.utcNowDateTime();
super.beforeSaving();
this.becca.etapiTokens[this.etapiTokenId] = this;
}
}
module.exports = EtapiToken;

View file

@ -6,8 +6,8 @@ const sql = require('../../services/sql');
const utils = require('../../services/utils');
const dateUtils = require('../../services/date_utils');
const entityChangesService = require('../../services/entity_changes');
const AbstractEntity = require("./abstract_entity.js");
const NoteRevision = require("./note_revision.js");
const AbstractEntity = require("./abstract_entity");
const NoteRevision = require("./note_revision");
const LABEL = 'label';
const RELATION = 'relation';
@ -984,7 +984,7 @@ class Note extends AbstractEntity {
}
}
else {
const Attribute = require("./attribute.js");
const Attribute = require("./attribute");
new Attribute({
noteId: this.noteId,
@ -1016,7 +1016,7 @@ class Note extends AbstractEntity {
* @return {Attribute}
*/
addAttribute(type, name, value = "", isInheritable = false, position = 1000) {
const Attribute = require("./attribute.js");
const Attribute = require("./attribute");
return new Attribute({
noteId: this.noteId,

View file

@ -4,9 +4,9 @@ const protectedSessionService = require('../../services/protected_session');
const utils = require('../../services/utils');
const sql = require('../../services/sql');
const dateUtils = require('../../services/date_utils');
const becca = require('../becca.js');
const becca = require('../becca');
const entityChangesService = require('../../services/entity_changes');
const AbstractEntity = require("./abstract_entity.js");
const AbstractEntity = require("./abstract_entity");
/**
* NoteRevision represents snapshot of note's title and content at some point in the past.

View file

@ -1,7 +1,7 @@
"use strict";
const dateUtils = require('../../services/date_utils.js');
const AbstractEntity = require("./abstract_entity.js");
const dateUtils = require('../../services/date_utils');
const AbstractEntity = require("./abstract_entity");
/**
* Option represents name-value pair, either directly configurable by the user or some system property.

View file

@ -1,7 +1,7 @@
"use strict";
const dateUtils = require('../../services/date_utils.js');
const AbstractEntity = require("./abstract_entity.js");
const dateUtils = require('../../services/date_utils');
const AbstractEntity = require("./abstract_entity");
/**
* RecentNote represents recently visited note.

View file

@ -3,7 +3,7 @@ const NoteRevision = require('./entities/note_revision');
const Branch = require('./entities/branch');
const Attribute = require('./entities/attribute');
const RecentNote = require('./entities/recent_note');
const ApiToken = require('./entities/api_token');
const EtapiToken = require('./entities/etapi_token');
const Option = require('./entities/option');
const ENTITY_NAME_TO_ENTITY = {
@ -14,7 +14,7 @@ const ENTITY_NAME_TO_ENTITY = {
"note_revisions": NoteRevision,
"note_revision_contents": NoteRevision,
"recent_notes": RecentNote,
"api_tokens": ApiToken,
"etapi_tokens": EtapiToken,
"options": Option
};

View file

@ -1,4 +1,4 @@
const becca = require('./becca.js');
const becca = require('./becca');
const log = require('../services/log');
const beccaService = require('./becca_service.js');
const dateUtils = require('../services/date_utils');

64
src/etapi/attributes.js Normal file
View file

@ -0,0 +1,64 @@
const becca = require("../becca/becca");
const eu = require("./etapi_utils");
const mappers = require("./mappers");
const attributeService = require("../services/attributes");
const validators = require("./validators");
function register(router) {
eu.route(router, 'get', '/etapi/attributes/:attributeId', (req, res, next) => {
const attribute = eu.getAndCheckAttribute(req.params.attributeId);
res.json(mappers.mapAttributeToPojo(attribute));
});
eu.route(router, 'post' ,'/etapi/attributes', (req, res, next) => {
const params = req.body;
eu.getAndCheckNote(params.noteId);
if (params.type === 'relation') {
eu.getAndCheckNote(params.value);
}
if (params.type !== 'relation' && params.type !== 'label') {
throw new eu.EtapiError(400, eu.GENERIC_CODE, `Only "relation" and "label" are supported attribute types, "${params.type}" given.`);
}
try {
const attr = attributeService.createAttribute(params);
res.json(mappers.mapAttributeToPojo(attr));
}
catch (e) {
throw new eu.EtapiError(400, eu.GENERIC_CODE, e.message);
}
});
const ALLOWED_PROPERTIES_FOR_PATCH = {
'value': validators.isString
};
eu.route(router, 'patch' ,'/etapi/attributes/:attributeId', (req, res, next) => {
const attribute = eu.getAndCheckAttribute(req.params.attributeId);
eu.validateAndPatch(attribute, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
res.json(mappers.mapAttributeToPojo(attribute));
});
eu.route(router, 'delete' ,'/etapi/attributes/:attributeId', (req, res, next) => {
const attribute = becca.getAttribute(req.params.attributeId);
if (!attribute || attribute.isDeleted) {
return res.sendStatus(204);
}
attribute.markAsDeleted();
res.sendStatus(204);
});
}
module.exports = {
register
};

43
src/etapi/auth.js Normal file
View file

@ -0,0 +1,43 @@
const becca = require("../becca/becca");
const eu = require("./etapi_utils");
const passwordEncryptionService = require("../services/password_encryption.js");
const etapiTokenService = require("../services/etapi_tokens.js");
function register(router) {
eu.NOT_AUTHENTICATED_ROUTE(router, 'post', '/etapi/auth/login', (req, res, next) => {
const {password, tokenName} = req.body;
if (!passwordEncryptionService.verifyPassword(password)) {
throw new eu.EtapiError(401, "WRONG_PASSWORD", "Wrong password.");
}
const {authToken} = etapiTokenService.createToken(tokenName || "ETAPI login");
res.json({
authToken
});
});
eu.route(router, 'post', '/etapi/auth/logout', (req, res, next) => {
const parsed = etapiTokenService.parseAuthToken(req.headers.authorization);
if (!parsed || !parsed.etapiTokenId) {
throw new eu.EtapiError(400, eu.GENERIC_CODE, "Cannot logout this token.");
}
const etapiToken = becca.getEtapiToken(parsed.etapiTokenId);
if (!etapiToken) {
// shouldn't happen since this already passed auth validation
throw new Error(`Cannot find the token ${parsed.etapiTokenId}.`);
}
etapiToken.markAsDeletedSimple();
res.sendStatus(204);
});
}
module.exports = {
register
}

80
src/etapi/branches.js Normal file
View file

@ -0,0 +1,80 @@
const becca = require("../becca/becca");
const eu = require("./etapi_utils");
const mappers = require("./mappers");
const Branch = require("../becca/entities/branch");
const noteService = require("../services/notes");
const TaskContext = require("../services/task_context");
const entityChangesService = require("../services/entity_changes");
const validators = require("./validators");
function register(router) {
eu.route(router, 'get', '/etapi/branches/:branchId', (req, res, next) => {
const branch = eu.getAndCheckBranch(req.params.branchId);
res.json(mappers.mapBranchToPojo(branch));
});
eu.route(router, 'post' ,'/etapi/branches', (req, res, next) => {
const params = req.body;
eu.getAndCheckNote(params.noteId);
eu.getAndCheckNote(params.parentNoteId);
const existing = becca.getBranchFromChildAndParent(params.noteId, params.parentNoteId);
if (existing) {
existing.notePosition = params.notePosition;
existing.prefix = params.prefix;
existing.save();
return res.json(mappers.mapBranchToPojo(existing));
}
try {
const branch = new Branch(params).save();
res.json(mappers.mapBranchToPojo(branch));
}
catch (e) {
throw new eu.EtapiError(400, eu.GENERIC_CODE, e.message);
}
});
const ALLOWED_PROPERTIES_FOR_PATCH = {
'notePosition': validators.isInteger,
'prefix': validators.isStringOrNull,
'isExpanded': validators.isBoolean
};
eu.route(router, 'patch' ,'/etapi/branches/:branchId', (req, res, next) => {
const branch = eu.getAndCheckBranch(req.params.branchId);
eu.validateAndPatch(branch, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
res.json(mappers.mapBranchToPojo(branch));
});
eu.route(router, 'delete' ,'/etapi/branches/:branchId', (req, res, next) => {
const branch = becca.getBranch(req.params.branchId);
if (!branch || branch.isDeleted) {
return res.sendStatus(204);
}
noteService.deleteBranch(branch, null, new TaskContext('no-progress-reporting'));
res.sendStatus(204);
});
eu.route(router, 'post' ,'/etapi/refresh-note-ordering/:parentNoteId', (req, res, next) => {
eu.getAndCheckNote(req.params.parentNoteId);
entityChangesService.addNoteReorderingEntityChange(req.params.parentNoteId, "etapi");
res.sendStatus(204);
});
}
module.exports = {
register
};

View file

@ -0,0 +1,789 @@
openapi: "3.0.3"
info:
version: 1.0.0
title: ETAPI
description: External Trilium API
contact:
name: zadam
email: zadam.apps@gmail.com
url: https://github.com/zadam/trilium
license:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
servers:
- url: http://localhost:37740/etapi
- url: http://localhost:8080/etapi
security:
- EtapiTokenAuth: []
paths:
/create-note:
post:
description: Create a note and place it into the note tree
operationId: createNote
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateNoteDef'
responses:
'200':
description: note created
content:
application/json:
schema:
properties:
note:
$ref: '#/components/schemas/Note'
description: Created note
branch:
$ref: '#/components/schemas/Branch'
description: Created branch
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/notes:
get:
description: Search notes
operationId: searchNotes
parameters:
- name: search
in: query
required: true
description: search query string as described in https://github.com/zadam/trilium/wiki/Search
schema:
type: string
examples:
fulltext:
summary: Fulltext search for keywords (not exact match)
value: 'towers tolkien'
fulltextExactMatch:
summary: Fulltext search for exact match (notice the double quotes)
value: '"Two Towers"'
fulltextWithLabel:
summary: Fulltext search for keyword AND matching label
value: 'towers #book'
- name: fastSearch
in: query
required: false
description: enable fast search (fulltext doesn't look into content)
schema:
type: boolean
default: false
- name: includeArchivedNotes
in: query
required: false
description: search by default ignores archived notes. Set to 'true' to includes archived notes into search results.
schema:
type: boolean
default: false
- name: ancestorNoteId
in: query
required: false
description: search only in a subtree identified by the subtree noteId. By default whole tree is searched.
schema:
$ref: '#/components/schemas/EntityId'
- name: ancestorDepth
in: query
required: false
description: define how deep in the tree should the notes be searched
schema:
type: string
examples:
directChildren:
summary: depth of exactly 1 (direct children) to the ancestor (root if not set)
value: eq1
grandGrandChildren:
summary: depth of exactly 3 to the ancestor (root if not set)
value: eq3
lessThan4:
summary: depth less than 4 (so 1, 2, 3) to the ancestor (root if not set)
value: lt4
greaterThan2:
summary: depth greater than 2 (so 3, 4, 5, 6...) to the ancestor (root if not set)
value: gt4
- name: orderBy
in: query
required: false
description: name of the property/label to order search results by
schema:
type: string
example:
- title
- '#publicationDate'
- isProtected
- isArchived
- dateCreated
- dateModified
- utcDateCreated
- utcDateModified
- parentCount
- childrenCount
- attributeCount
- labelCount
- ownedLabelCount
- relationCount
- ownedRelationCount
- relationCountIncludingLinks
- ownedRelationCountIncludingLinks
- targetRelationCount
- targetRelationCountIncludingLinks
- contentSize
- noteSize
- revisionCount
- name: orderDirection
in: query
required: false
description: order direction, ascending or descending
schema:
type: string
default: asc
enum:
- asc
- desc
- name: limit
in: query
required: false
description: limit the number of results you want to receive
schema:
type: integer
example: 10
- name: debug
in: query
required: false
description: set to true to get debug information in the response (search query parsing)
schema:
type: boolean
default: false
responses:
'200':
description: search response
content:
application/json:
schema:
$ref: '#/components/schemas/SearchResponse'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/notes/{noteId}:
parameters:
- name: noteId
in: path
required: true
schema:
$ref: '#/components/schemas/EntityId'
get:
description: Returns a note identified by its ID
operationId: getNoteById
responses:
'200':
description: note response
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
patch:
description: patch a note identified by the noteId with changes in the body
operationId: patchNoteById
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
responses:
'200':
description: update note
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
description: deletes a single note based on the noteId supplied
operationId: deleteNoteById
responses:
'204':
description: note deleted
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/branches/{branchId}:
parameters:
- name: branchId
in: path
required: true
schema:
$ref: '#/components/schemas/EntityId'
get:
description: Returns a branch identified by its ID
operationId: getBranchById
responses:
'200':
description: branch response
content:
application/json:
schema:
$ref: '#/components/schemas/Branch'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
description: create a branch (clone a note to a different location in the tree)
operationId: postBranch
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Branch'
responses:
'200':
description: update branch
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
patch:
description: patch a branch identified by the branchId with changes in the body
operationId: patchBranchById
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Branch'
responses:
'200':
description: update branch
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
description: >
deletes a branch based on the branchId supplied. If this is the last branch of the (child) note,
then the note is deleted as well.
operationId: deleteBranchById
responses:
'204':
description: branch deleted
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/attributes/{attributeId}:
parameters:
- name: attributeId
in: path
required: true
schema:
$ref: '#/components/schemas/EntityId'
get:
description: Returns an attribute identified by its ID
operationId: getAttributeById
responses:
'200':
description: attribute response
content:
application/json:
schema:
$ref: '#/components/schemas/Attribute'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
description: create an attribute for a given note
operationId: postAttribute
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Attribute'
responses:
'200':
description: update attribute
content:
application/json:
schema:
$ref: '#/components/schemas/Attribute'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
patch:
description: patch a attribute identified by the attributeId with changes in the body
operationId: patchAttributeById
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Attribute'
responses:
'200':
description: update attribute
content:
application/json:
schema:
$ref: '#/components/schemas/Attribute'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
description: deletes a attribute based on the attributeId supplied.
operationId: deleteAttributeById
responses:
'204':
description: attribute deleted
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/refresh-note-ordering/{parentNoteId}:
parameters:
- name: parentNoteId
in: path
required: true
schema:
$ref: '#/components/schemas/EntityId'
post:
description: >
notePositions in branches are not automatically pushed to connected clients and need a specific instruction.
If you want your changes to be in effect immediately, call this service after setting branches' notePosition.
Note that you need to supply "parentNoteId" of branch(es) with changed positions.
operationId: postRefreshNoteOrdering
responses:
'204':
description: note ordering will be asynchronously updated in all connected clients
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/inbox/{date}:
get:
description: >
returns an "inbox" note, into which note can be created. Date will be used depending on whether the inbox
is a fixed note (identified with #inbox label) or a day note in a journal.
operationId: getInboxNote
parameters:
- name: date
in: path
required: true
schema:
type: string
format: date
example: 2022-02-22
responses:
'200':
description: inbox note
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/calendar/days/{date}:
get:
description: returns a day note for a given date. Gets created if doesn't exist.
operationId: getDayNote
parameters:
- name: date
in: path
required: true
schema:
type: string
format: date
example: 2022-02-22
responses:
'200':
description: day note
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/calendar/weeks/{date}:
get:
description: returns a week note for a given date. Gets created if doesn't exist.
operationId: getWeekNote
parameters:
- name: date
in: path
required: true
schema:
type: string
format: date
example: 2022-02-22
responses:
'200':
description: week note
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/calendar/months/{month}:
get:
description: returns a week note for a given date. Gets created if doesn't exist.
operationId: getMonthNote
parameters:
- name: month
in: path
required: true
schema:
type: string
pattern: '[0-9]{4}-[0-9]{2}'
example: 2022-02
responses:
'200':
description: month note
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/calendar/years/{year}:
get:
description: returns a week note for a given date. Gets created if doesn't exist.
operationId: getYearNote
parameters:
- name: year
in: path
required: true
schema:
type: string
pattern: '[0-9]{4}-[0-9]{2}'
example: 2022-02
responses:
'200':
description: year note
content:
application/json:
schema:
$ref: '#/components/schemas/Note'
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/auth/login:
post:
description: get an ETAPI token based on password for further use with ETAPI
operationId: login
security: [] # no token based auth for login endpoint
requestBody:
required: true
content:
application/json:
schema:
properties:
password:
type: string
description: user's password used to e.g. login to Trilium server and/or protect notes
responses:
'200':
description: auth token
content:
application/json:
schema:
properties:
authToken:
type: string
example: Bc4bFn0Ffiok_4NpbVCDnFz7B2WU+pdhW8B5Ne3DiR5wXrEyqdjgRIsk=
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/auth/logout:
post:
description: logout (delete/deactivate) an ETAPI token
operationId: logout
responses:
'204':
description: logout successful
default:
description: unexpected error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
securitySchemes:
EtapiTokenAuth:
type: apiKey
in: header
name: Authorization
schemas:
CreateNoteDef:
type: object
required:
- parentNoteId
- type
- title
- content
properties:
parentNoteId:
$ref: '#/components/schemas/EntityId'
description: Note ID of the parent note in the tree
type:
type: string
enum:
- text
- code
- file
- image
- search
- book
- relation-map
- render
mime:
type: string
description: this needs to be specified only for note types 'code', 'file', 'image'.
example: application/json
title:
type: string
content:
type: string
notePosition:
type: integer
description: >
Position of the note in the parent. Normal ordering is 10, 20, 30 ...
So if you want to create a note on the first position, use e.g. 5, for second position 15, for last e.g. 1000000
prefix:
type: string
description: >
Prefix is branch (placement) specific title prefix for the note.
Let's say you have your note placed into two different places in the tree,
but you want to change the title a bit in one of the placements. For this you can use prefix.
noteId:
$ref: '#/components/schemas/EntityId'
description: DON'T specify unless you want to force a specific noteId
branchId:
$ref: '#/components/schemas/EntityId'
description: DON'T specify unless you want to force a specific branchId
Note:
type: object
properties:
noteId:
$ref: '#/components/schemas/EntityId'
readOnly: true
title:
type: string
type:
type: string
enum: [text, code, book, image, file, mermaid, relation-map, render, search, note-map]
mime:
type: string
isProtected:
type: boolean
readOnly: true
attributes:
$ref: '#/components/schemas/AttributeList'
readOnly: true
parentNoteIds:
$ref: '#/components/schemas/EntityIdList'
readOnly: true
childNoteIds:
$ref: '#/components/schemas/EntityIdList'
readOnly: true
parentBranchIds:
$ref: '#/components/schemas/EntityIdList'
readOnly: true
childBranchIds:
$ref: '#/components/schemas/EntityIdList'
readOnly: true
dateCreated:
$ref: '#/components/schemas/LocalDateTime'
readOnly: true
dateModified:
$ref: '#/components/schemas/LocalDateTime'
readOnly: true
utcDateCreated:
$ref: '#/components/schemas/UtcDateTime'
readOnly: true
utcDateModified:
$ref: '#/components/schemas/UtcDateTime'
readOnly: true
Branch:
type: object
description: Branch places the note into the tree, it represents the relationship between a parent note and child note
required:
- noteId
- parentNoteId
properties:
branchId:
$ref: '#/components/schemas/EntityId'
readOnly: true
noteId:
$ref: '#/components/schemas/EntityId'
readOnly: true
description: identifies the child note
parentNoteId:
$ref: '#/components/schemas/EntityId'
readOnly: true
description: identifies the parent note
prefix:
type: string
notePosition:
type: integer
format: int32
isExanded:
type: boolean
utcDateModified:
$ref: '#/components/schemas/UtcDateTime'
readOnly: true
Attribute:
type: object
description: Attribute (Label, Relation) is a key-value record attached to a note.
required:
- noteId
properties:
attributeId:
$ref: '#/components/schemas/EntityId'
readOnly: true
noteId:
$ref: '#/components/schemas/EntityId'
readOnly: true
description: identifies the child note
type:
type: string
enum: [label, relation]
name:
type: string
pattern: '^[\p{L}\p{N}_:]+'
example: shareCss
value:
type: string
position:
type: integer
format: int32
isInheritable:
type: boolean
utcDateModified:
$ref: '#/components/schemas/UtcDateTime'
readOnly: true
AttributeList:
type: array
items:
$ref: '#/components/schemas/Attribute'
SearchResponse:
type: object
required:
- results
properties:
results:
type: array
items:
$ref: '#/components/schemas/Note'
debugInfo:
type: object
description: debugging info on parsing the search query enabled with &debug=true parameter
EntityId:
type: string
pattern: '[a-zA-Z0-9]{4,12}'
example: evnnmvHTCgIn
EntityIdList:
type: array
items:
$ref: '#/components/schemas/EntityId'
LocalDateTime:
type: string
pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}\+[0-9]{4}'
example: 2021-12-31 20:18:11.939+0100
UtcDateTime:
type: string
pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z'
example: 2021-12-31 19:18:11.939Z
Error:
type: object
required:
- status
- code
- message
properties:
status:
type: integer
format: int32
description: HTTP status, identical to the one given in HTTP response
example: 400
code:
type: string
description: stable string constant
example: NOTE_IS_PROTECTED
message:
type: string
description: Human readable error, potentially with more details,
example: Note 'evnnmvHTCgIn' is protected and cannot be modified through ETAPI

139
src/etapi/etapi_utils.js Normal file
View file

@ -0,0 +1,139 @@
const cls = require("../services/cls");
const sql = require("../services/sql");
const log = require("../services/log");
const becca = require("../becca/becca");
const etapiTokenService = require("../services/etapi_tokens.js");
const config = require("../services/config.js");
const GENERIC_CODE = "GENERIC";
const noAuthentication = config.General && config.General.noAuthentication === true;
class EtapiError extends Error {
constructor(statusCode, code, message) {
super();
this.statusCode = statusCode;
this.code = code;
this.message = message;
}
}
function sendError(res, statusCode, code, message) {
return res
.set('Content-Type', 'application/json')
.status(statusCode)
.send(JSON.stringify({
"status": statusCode,
"code": code,
"message": message
}));
}
function checkEtapiAuth(req, res, next) {
if (noAuthentication || etapiTokenService.isValidAuthHeader(req.headers.authorization)) {
next();
}
else {
sendError(res, 401, "NOT_AUTHENTICATED", "Not authenticated");
}
}
function processRequest(req, res, routeHandler, next, method, path) {
try {
cls.namespace.bindEmitter(req);
cls.namespace.bindEmitter(res);
cls.init(() => {
cls.set('componentId', "etapi");
cls.set('localNowDateTime', req.headers['trilium-local-now-datetime']);
const cb = () => routeHandler(req, res, next);
return sql.transactional(cb);
});
} catch (e) {
log.error(`${method} ${path} threw exception ${e.message} with stacktrace: ${e.stack}`);
if (e instanceof EtapiError) {
sendError(res, e.statusCode, e.code, e.message);
} else {
sendError(res, 500, GENERIC_CODE, e.message);
}
}
}
function route(router, method, path, routeHandler) {
router[method](path, checkEtapiAuth, (req, res, next) => processRequest(req, res, routeHandler, next, method, path));
}
function NOT_AUTHENTICATED_ROUTE(router, method, path, routeHandler) {
router[method](path, (req, res, next) => processRequest(req, res, routeHandler, next, method, path));
}
function getAndCheckNote(noteId) {
const note = becca.getNote(noteId);
if (note) {
return note;
}
else {
throw new EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found`);
}
}
function getAndCheckBranch(branchId) {
const branch = becca.getBranch(branchId);
if (branch) {
return branch;
}
else {
throw new EtapiError(404, "BRANCH_NOT_FOUND", `Branch '${branchId}' not found`);
}
}
function getAndCheckAttribute(attributeId) {
const attribute = becca.getAttribute(attributeId);
if (attribute) {
return attribute;
}
else {
throw new EtapiError(404, "ATTRIBUTE_NOT_FOUND", `Attribute '${attributeId}' not found`);
}
}
function validateAndPatch(entity, props, allowedProperties) {
for (const key of Object.keys(props)) {
if (!(key in allowedProperties)) {
throw new EtapiError(400, "PROPERTY_NOT_ALLOWED_FOR_PATCH", `Property '${key}' is not allowed for PATCH.`);
}
else {
const validator = allowedProperties[key];
const validationResult = validator(props[key]);
if (validationResult) {
throw new EtapiError(400, "PROPERTY_VALIDATION_ERROR", `Validation failed on property '${key}': ${validationResult}`);
}
}
}
// validation passed, let's patch
for (const propName of Object.keys(props)) {
entity[propName] = props[propName];
}
entity.save();
}
module.exports = {
EtapiError,
sendError,
route,
NOT_AUTHENTICATED_ROUTE,
GENERIC_CODE,
validateAndPatch,
getAndCheckNote,
getAndCheckBranch,
getAndCheckAttribute
}

49
src/etapi/mappers.js Normal file
View file

@ -0,0 +1,49 @@
function mapNoteToPojo(note) {
return {
noteId: note.noteId,
isProtected: note.isProtected,
title: note.title,
type: note.type,
mime: note.mime,
dateCreated: note.dateCreated,
dateModified: note.dateModified,
utcDateCreated: note.utcDateCreated,
utcDateModified: note.utcDateModified,
parentNoteIds: note.getParentNotes().map(p => p.noteId),
childNoteIds: note.getChildNotes().map(ch => ch.noteId),
parentBranchIds: note.getParentBranches().map(p => p.branchId),
childBranchIds: note.getChildBranches().map(ch => ch.branchId),
attributes: note.getAttributes().map(attr => mapAttributeToPojo(attr))
};
}
function mapBranchToPojo(branch) {
return {
branchId: branch.branchId,
noteId: branch.noteId,
parentNoteId: branch.parentNoteId,
prefix: branch.prefix,
notePosition: branch.notePosition,
isExpanded: branch.isExpanded,
utcDateModified: branch.utcDateModified
};
}
function mapAttributeToPojo(attr) {
return {
attributeId: attr.attributeId,
noteId: attr.noteId,
type: attr.type,
name: attr.name,
value: attr.value,
position: attr.position,
isInheritable: attr.isInheritable,
utcDateModified: attr.utcDateModified
};
}
module.exports = {
mapNoteToPojo,
mapBranchToPojo,
mapAttributeToPojo
};

181
src/etapi/notes.js Normal file
View file

@ -0,0 +1,181 @@
const becca = require("../becca/becca");
const utils = require("../services/utils");
const eu = require("./etapi_utils");
const mappers = require("./mappers");
const noteService = require("../services/notes");
const TaskContext = require("../services/task_context");
const validators = require("./validators");
const searchService = require("../services/search/services/search");
const SearchContext = require("../services/search/search_context");
function register(router) {
eu.route(router, 'get', '/etapi/notes', (req, res, next) => {
const {search} = req.query;
if (!search?.trim()) {
throw new eu.EtapiError(400, 'SEARCH_QUERY_PARAM_MANDATORY', "'search' query parameter is mandatory");
}
const searchParams = parseSearchParams(req);
const searchContext = new SearchContext(searchParams);
const searchResults = searchService.findResultsWithQuery(search, searchContext);
const foundNotes = searchResults.map(sr => becca.notes[sr.noteId]);
const resp = {
results: foundNotes.map(note => mappers.mapNoteToPojo(note))
};
if (searchContext.debugInfo) {
resp.debugInfo = searchContext.debugInfo;
}
res.json(resp);
});
eu.route(router, 'get', '/etapi/notes/:noteId', (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, 'post' ,'/etapi/create-note', (req, res, next) => {
const params = req.body;
eu.getAndCheckNote(params.parentNoteId);
try {
const resp = noteService.createNewNote(params);
res.json({
note: mappers.mapNoteToPojo(resp.note),
branch: mappers.mapBranchToPojo(resp.branch)
});
}
catch (e) {
return eu.sendError(res, 400, eu.GENERIC_CODE, e.message);
}
});
const ALLOWED_PROPERTIES_FOR_PATCH = {
'title': validators.isString,
'type': validators.isString,
'mime': validators.isString
};
eu.route(router, 'patch' ,'/etapi/notes/:noteId', (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId)
if (note.isProtected) {
throw new eu.EtapiError(400, "NOTE_IS_PROTECTED", `Note '${req.params.noteId}' is protected and cannot be modified through ETAPI`);
}
eu.validateAndPatch(note, req.body, ALLOWED_PROPERTIES_FOR_PATCH);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, 'delete' ,'/etapi/notes/:noteId', (req, res, next) => {
const {noteId} = req.params;
const note = becca.getNote(noteId);
if (!note || note.isDeleted) {
return res.sendStatus(204);
}
noteService.deleteNote(note, null, new TaskContext('no-progress-reporting'));
res.sendStatus(204);
});
eu.route(router, 'get', '/etapi/notes/:noteId/content', (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
const filename = utils.formatDownloadTitle(note.title, note.type, note.mime);
res.setHeader('Content-Disposition', utils.getContentDisposition(filename));
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader('Content-Type', note.mime);
res.send(note.getContent());
});
eu.route(router, 'put', '/etapi/notes/:noteId/content', (req, res, next) => {
const note = eu.getAndCheckNote(req.params.noteId);
note.setContent(req.body);
return res.sendStatus(204);
});
}
function parseSearchParams(req) {
const rawSearchParams = {
'fastSearch': parseBoolean(req.query, 'fastSearch'),
'includeArchivedNotes': parseBoolean(req.query, 'includeArchivedNotes'),
'ancestorNoteId': req.query['ancestorNoteId'],
'ancestorDepth': parseInteger(req.query, 'ancestorDepth'),
'orderBy': req.query['orderBy'],
'orderDirection': parseOrderDirection(req.query, 'orderDirection'),
'limit': parseInteger(req.query, 'limit'),
'debug': parseBoolean(req.query, 'debug')
};
const searchParams = {};
for (const paramName of Object.keys(rawSearchParams)) {
if (rawSearchParams[paramName] !== undefined) {
searchParams[paramName] = rawSearchParams[paramName];
}
}
return searchParams;
}
const SEARCH_PARAM_ERROR = "SEARCH_PARAM_VALIDATION_ERROR";
function parseBoolean(obj, name) {
if (!(name in obj)) {
return undefined;
}
if (!['true', 'false'].includes(obj[name])) {
throw new eu.EtapiError(400, SEARCH_PARAM_ERROR, `Cannot parse boolean '${name}' value '${obj[name]}, allowed values are 'true' and 'false'`);
}
return obj[name] === 'true';
}
function parseInteger(obj, name) {
if (!(name in obj)) {
return undefined;
}
const integer = parseInt(obj[name]);
if (!['asc', 'desc'].includes(obj[name])) {
throw new eu.EtapiError(400, SEARCH_PARAM_ERROR, `Cannot parse order direction value '${obj[name]}, allowed values are 'asc' and 'desc'`);
}
return integer;
}
function parseOrderDirection(obj, name) {
if (!(name in obj)) {
return undefined;
}
const integer = parseInt(obj[name]);
if (Number.isNaN(integer)) {
throw new eu.EtapiError(400, SEARCH_PARAM_ERROR, `Cannot parse integer '${name}' value '${obj[name]}`);
}
return integer;
}
module.exports = {
register
};

20
src/etapi/spec.js Normal file
View file

@ -0,0 +1,20 @@
const fs = require('fs');
const path = require('path');
const specPath = path.join(__dirname, 'etapi.openapi.yaml');
let spec = null;
function register(router) {
router.get('/etapi/etapi.openapi.yaml', (req, res, next) => {
if (!spec) {
spec = fs.readFileSync(specPath, 'utf8');
}
res.header('Content-Type', 'text/plain'); // so that it displays in browser
res.status(200).send(spec);
});
}
module.exports = {
register
};

View file

@ -0,0 +1,77 @@
const specialNotesService = require("../services/special_notes");
const dateNotesService = require("../services/date_notes");
const eu = require("./etapi_utils");
const mappers = require("./mappers");
const getDateInvalidError = date => new eu.EtapiError(400, "DATE_INVALID", `Date "${date}" is not valid.`);
const getMonthInvalidError = month => new eu.EtapiError(400, "MONTH_INVALID", `Month "${month}" is not valid.`);
const getYearInvalidError = year => new eu.EtapiError(400, "YEAR_INVALID", `Year "${year}" is not valid.`);
function isValidDate(date) {
if (!/[0-9]{4}-[0-9]{2}-[0-9]{2}/.test(date)) {
return false;
}
return !!Date.parse(date);
}
function register(router) {
eu.route(router, 'get', '/etapi/inbox/:date', (req, res, next) => {
const {date} = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(res, date);
}
const note = specialNotesService.getInboxNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, 'get', '/etapi/calendar/days/:date', (req, res, next) => {
const {date} = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(res, date);
}
const note = dateNotesService.getDayNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, 'get', '/etapi/calendar/weeks/:date', (req, res, next) => {
const {date} = req.params;
if (!isValidDate(date)) {
throw getDateInvalidError(res, date);
}
const note = dateNotesService.getWeekNote(date);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, 'get', '/etapi/calendar/months/:month', (req, res, next) => {
const {month} = req.params;
if (!/[0-9]{4}-[0-9]{2}/.test(month)) {
throw getMonthInvalidError(res, month);
}
const note = dateNotesService.getMonthNote(month);
res.json(mappers.mapNoteToPojo(note));
});
eu.route(router, 'get', '/etapi/calendar/years/:year', (req, res, next) => {
const {year} = req.params;
if (!/[0-9]{4}/.test(year)) {
throw getYearInvalidError(res, year);
}
const note = dateNotesService.getYearNote(year);
res.json(mappers.mapNoteToPojo(note));
});
}
module.exports = {
register
};

30
src/etapi/validators.js Normal file
View file

@ -0,0 +1,30 @@
function isString(obj) {
if (typeof obj !== 'string') {
return `'${obj}' is not a string`;
}
}
function isStringOrNull(obj) {
if (obj) {
return isString(obj);
}
}
function isBoolean(obj) {
if (typeof obj !== 'boolean') {
return `'${obj}' is not a boolean`;
}
}
function isInteger(obj) {
if (!Number.isInteger(obj)) {
return `'${obj}' is not an integer`;
}
}
module.exports = {
isString,
isStringOrNull,
isBoolean,
isInteger
};

View file

@ -5,7 +5,7 @@ import utils from "../services/utils.js";
const $dialog = $("#options-dialog");
export async function showDialog() {
export async function showDialog(openTab) {
const options = await server.get('options');
utils.openDialog($dialog);
@ -14,7 +14,8 @@ export async function showDialog() {
import('./options/appearance.js'),
import('./options/shortcuts.js'),
import('./options/code_notes.js'),
import('./options/credentials.js'),
import('./options/password.js'),
import('./options/etapi.js'),
import('./options/backup.js'),
import('./options/sync.js'),
import('./options/other.js'),
@ -26,4 +27,8 @@ export async function showDialog() {
tab.optionsLoaded(options)
}
});
if (openTab) {
$(`.nav-link[href='#options-${openTab}']`).trigger("click");
}
}

View file

@ -0,0 +1,128 @@
import server from "../../services/server.js";
import utils from "../../services/utils.js";
const TPL = `
<h4>ETAPI</h4>
<p>ETAPI is a REST API used to access Trilium instance programmatically, without UI. <br/>
See more details on <a href="https://github.com/zadam/trilium/wiki/ETAPI">wiki</a> and <a onclick="window.open('etapi/etapi.openapi.yaml')" href="etapi/etapi.openapi.yaml">ETAPI OpenAPI spec</a>.</p>
<button type="button" class="btn btn-sm" id="create-etapi-token">Create new ETAPI token</button>
<br/><br/>
<h5>Existing tokens</h5>
<div id="no-tokens-yet">There are no tokens yet. Click on the button above to create one.</div>
<div style="overflow: auto; height: 500px;">
<table id="tokens-table" class="table table-stripped">
<thead>
<tr>
<th>Token name</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<style>
.token-table-button {
display: inline-block;
cursor: pointer;
padding: 3px;
margin-right: 20px;
font-size: large;
border: 1px solid transparent;
border-radius: 5px;
}
.token-table-button:hover {
border: 1px solid var(--main-border-color);
}
</style>
`;
export default class EtapiOptions {
constructor() {
$("#options-etapi").html(TPL);
$("#create-etapi-token").on("click", async () => {
const promptDialog = await import('../../dialogs/prompt.js');
const tokenName = await promptDialog.ask({
title: "New ETAPI token",
message: "Please enter new token's name",
defaultValue: "new token"
});
if (!tokenName.trim()) {
alert("Token name can't be empty");
return;
}
const {token} = await server.post('etapi-tokens', {tokenName});
await promptDialog.ask({
title: "ETAPI token created",
message: 'Copy the created token into clipboard. Trilium stores the token hashed and this is the last time you see it.',
defaultValue: token
});
this.refreshTokens();
});
this.refreshTokens();
}
async refreshTokens() {
const $noTokensYet = $("#no-tokens-yet");
const $tokensTable = $("#tokens-table");
const tokens = await server.get('etapi-tokens');
$noTokensYet.toggle(tokens.length === 0);
$tokensTable.toggle(tokens.length > 0);
const $tokensTableBody = $tokensTable.find("tbody");
$tokensTableBody.empty();
for (const token of tokens) {
$tokensTableBody.append(
$("<tr>")
.append($("<td>").text(token.name))
.append($("<td>").text(token.utcDateCreated))
.append($("<td>").append(
$('<span class="bx bx-pen token-table-button" title="Rename this token"></span>')
.on("click", () => this.renameToken(token.etapiTokenId, token.name)),
$('<span class="bx bx-trash token-table-button" title="Delete / deactive this token"></span>')
.on("click", () => this.deleteToken(token.etapiTokenId, token.name))
))
);
}
}
async renameToken(etapiTokenId, oldName) {
const promptDialog = await import('../../dialogs/prompt.js');
const tokenName = await promptDialog.ask({
title: "Rename token",
message: "Please enter new token's name",
defaultValue: oldName
});
await server.patch(`etapi-tokens/${etapiTokenId}`, {name: tokenName});
this.refreshTokens();
}
async deleteToken(etapiTokenId, name) {
if (!confirm(`Are you sure you want to delete ETAPI token "${name}"?`)) {
return;
}
await server.remove(`etapi-tokens/${etapiTokenId}`);
this.refreshTokens();
}
}

View file

@ -3,18 +3,16 @@ import protectedSessionHolder from "../../services/protected_session_holder.js";
import toastService from "../../services/toast.js";
const TPL = `
<h3>Username</h3>
<p>Your username is <strong id="credentials-username"></strong>.</p>
<h3>Change password</h3>
<h3 id="password-heading"></h3>
<div class="alert alert-warning" role="alert" style="font-weight: bold; color: red !important;">
Please take care to remember your new password. Password is used to encrypt protected notes. If you forget your password, then all your protected notes are forever lost with no recovery options.
Please take care to remember your new password. Password is used to encrypt protected notes.
If you forget your password, then all your protected notes are forever lost.
In case you did forget your password, <a id="reset-password-button" href="javascript:">click here to reset it</a>.
</div>
<form id="change-password-form">
<div class="form-group">
<div class="form-group" id="old-password-form-group">
<label for="old-password">Old password</label>
<input class="form-control" id="old-password" type="password">
</div>
@ -29,24 +27,41 @@ const TPL = `
<input class="form-control" id="new-password2" type="password">
</div>
<button class="btn btn-primary">Change password</button>
<button class="btn btn-primary" id="save-password-button">Change password</button>
</form>`;
export default class ChangePasswordOptions {
constructor() {
$("#options-credentials").html(TPL);
$("#options-password").html(TPL);
this.$username = $("#credentials-username");
this.$passwordHeading = $("#password-heading");
this.$form = $("#change-password-form");
this.$oldPassword = $("#old-password");
this.$newPassword1 = $("#new-password1");
this.$newPassword2 = $("#new-password2");
this.$savePasswordButton = $("#save-password-button");
this.$resetPasswordButton = $("#reset-password-button");
this.$resetPasswordButton.on("click", async () => {
if (confirm("By resetting the password you will forever lose access to all your existing protected notes. Do you really want to reset the password?")) {
await server.post("password/reset?really=yesIReallyWantToResetPasswordAndLoseAccessToMyProtectedNotes");
const options = await server.get('options');
this.optionsLoaded(options);
alert("Password has been reset. Please set new password");
}
});
this.$form.on('submit', () => this.save());
}
optionsLoaded(options) {
this.$username.text(options.username);
const isPasswordSet = options.isPasswordSet === 'true';
$("#old-password-form-group").toggle(isPasswordSet);
this.$passwordHeading.text(isPasswordSet ? 'Change password' : 'Set password');
this.$savePasswordButton.text(isPasswordSet ? 'Change password' : 'Set password');
}
save() {

View file

@ -0,0 +1,13 @@
import utils from "../services/utils.js";
import appContext from "../services/app_context.js";
export function show() {
const $dialog = $("#password-not-set-dialog");
const $openPasswordOptionsButton = $("#open-password-options-button");
utils.openDialog($dialog);
$openPasswordOptionsButton.on("click", () => {
appContext.triggerCommand("showOptions", { openTab: 'password' });
});
}

View file

@ -11,9 +11,11 @@ const $form = $("#prompt-dialog-form");
let resolve;
let shownCb;
export function ask({ message, defaultValue, shown }) {
export function ask({ title, message, defaultValue, shown }) {
shownCb = shown;
$("#prompt-title").text(title || "Prompt");
$question = $("<label>")
.prop("for", "prompt-dialog-answer")
.text(message);
@ -30,7 +32,7 @@ export function ask({ message, defaultValue, shown }) {
.append($question)
.append($answer));
utils.openDialog($dialog);
utils.openDialog($dialog, false);
return new Promise((res, rej) => { resolve = res; });
}

View file

@ -12,7 +12,7 @@ export function show() {
}
export function close() {
// this may fal if the dialog has not been previously opened (not sure if still true with Bootstrap modal)
// this may fail if the dialog has not been previously opened (not sure if still true with Bootstrap modal)
try {
$dialog.modal('hide');
}

View file

@ -118,7 +118,7 @@ class AppContext extends Component {
const appContext = new AppContext(window.glob.isMainWindow);
// we should save all outstanding changes before the page/app is closed
$(window).on('beforeunload', () => {
$(window).on('beforeunload', () => {return "SSS";
let allSaved = true;
appContext.beforeUnloadListeners = appContext.beforeUnloadListeners.filter(wr => !!wr.deref());

View file

@ -81,6 +81,7 @@ async function renderAttributes(attributes, renderIsInheritable) {
const HIDDEN_ATTRIBUTES = [
'originalFileName',
'fileSize',
'template',
'cssClass',
'iconClass',

View file

@ -11,12 +11,12 @@ async function getInboxNote() {
/** @returns {NoteShort} */
async function getTodayNote() {
return await getDateNote(dayjs().format("YYYY-MM-DD"));
return await getDayNote(dayjs().format("YYYY-MM-DD"));
}
/** @returns {NoteShort} */
async function getDateNote(date) {
const note = await server.get('special-notes/date/' + date, "date-note");
async function getDayNote(date) {
const note = await server.get('special-notes/days/' + date, "date-note");
await ws.waitForMaxKnownEntityChangeId();
@ -25,7 +25,7 @@ async function getDateNote(date) {
/** @returns {NoteShort} */
async function getWeekNote(date) {
const note = await server.get('special-notes/week/' + date, "date-note");
const note = await server.get('special-notes/weeks/' + date, "date-note");
await ws.waitForMaxKnownEntityChangeId();
@ -34,7 +34,7 @@ async function getWeekNote(date) {
/** @returns {NoteShort} */
async function getMonthNote(month) {
const note = await server.get('special-notes/month/' + month, "date-note");
const note = await server.get('special-notes/months/' + month, "date-note");
await ws.waitForMaxKnownEntityChangeId();
@ -43,7 +43,7 @@ async function getMonthNote(month) {
/** @returns {NoteShort} */
async function getYearNote(year) {
const note = await server.get('special-notes/year/' + year, "date-note");
const note = await server.get('special-notes/years/' + year, "date-note");
await ws.waitForMaxKnownEntityChangeId();
@ -71,7 +71,7 @@ async function createSearchNote(opts = {}) {
export default {
getInboxNote,
getTodayNote,
getDateNote,
getDayNote,
getWeekNote,
getMonthNote,
getYearNote,

View file

@ -22,9 +22,9 @@ async function processEntityChanges(entityChanges) {
} else if (ec.entityName === 'note_contents') {
delete froca.noteComplementPromises[ec.entityId];
loadResults.addNoteContent(ec.entityId, ec.sourceId);
loadResults.addNoteContent(ec.entityId, ec.componentId);
} else if (ec.entityName === 'note_revisions') {
loadResults.addNoteRevision(ec.entityId, ec.noteId, ec.sourceId);
loadResults.addNoteRevision(ec.entityId, ec.noteId, ec.componentId);
} else if (ec.entityName === 'note_revision_contents') {
// this should change only when toggling isProtected, ignore
} else if (ec.entityName === 'options') {
@ -36,6 +36,9 @@ async function processEntityChanges(entityChanges) {
loadResults.addOption(ec.entity.name);
}
else if (ec.entityName === 'etapi_tokens') {
// NOOP
}
else {
throw new Error(`Unknown entityName ${ec.entityName}`);
}
@ -87,7 +90,7 @@ function processNoteChange(loadResults, ec) {
return;
}
loadResults.addNote(ec.entityId, ec.sourceId);
loadResults.addNote(ec.entityId, ec.componentId);
if (ec.isErased && ec.entityId in froca.notes) {
utils.reloadFrontendApp(`${ec.entityName} ${ec.entityId} is erased, need to do complete reload.`);
@ -125,7 +128,7 @@ function processBranchChange(loadResults, ec) {
delete parentNote.childToBranch[branch.noteId];
}
loadResults.addBranch(ec.entityId, ec.sourceId);
loadResults.addBranch(ec.entityId, ec.componentId);
delete froca.branches[ec.entityId];
}
@ -133,7 +136,7 @@ function processBranchChange(loadResults, ec) {
return;
}
loadResults.addBranch(ec.entityId, ec.sourceId);
loadResults.addBranch(ec.entityId, ec.componentId);
const childNote = froca.notes[ec.entity.noteId];
const parentNote = froca.notes[ec.entity.parentNoteId];
@ -175,7 +178,7 @@ function processNoteReordering(loadResults, ec) {
}
}
loadResults.addNoteReordering(ec.entityId, ec.sourceId);
loadResults.addNoteReordering(ec.entityId, ec.componentId);
}
function processAttributeChange(loadResults, ec) {
@ -199,7 +202,7 @@ function processAttributeChange(loadResults, ec) {
targetNote.targetRelations = targetNote.targetRelations.filter(attributeId => attributeId !== attribute.attributeId);
}
loadResults.addAttribute(ec.entityId, ec.sourceId);
loadResults.addAttribute(ec.entityId, ec.componentId);
delete froca.attributes[ec.entityId];
}
@ -207,7 +210,7 @@ function processAttributeChange(loadResults, ec) {
return;
}
loadResults.addAttribute(ec.entityId, ec.sourceId);
loadResults.addAttribute(ec.entityId, ec.componentId);
const sourceNote = froca.notes[ec.entity.noteId];
const targetNote = ec.entity.type === 'relation' && froca.notes[ec.entity.value];

View file

@ -389,16 +389,26 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
this.getTodayNote = dateNotesService.getTodayNote;
/**
* Returns date-note. If it doesn't exist, it is automatically created.
* Returns day note for a given date. If it doesn't exist, it is automatically created.
*
* @method
* @param {string} date - e.g. "2019-04-29"
* @return {Promise<NoteShort>}
* @deprecated use getDayNote instead
*/
this.getDateNote = dateNotesService.getDayNote;
/**
* Returns day note for a given date. If it doesn't exist, it is automatically created.
*
* @method
* @param {string} date - e.g. "2019-04-29"
* @return {Promise<NoteShort>}
*/
this.getDateNote = dateNotesService.getDateNote;
this.getDayNote = dateNotesService.getDayNote;
/**
* Returns date-note for the first date of the week of the given date. If it doesn't exist, it is automatically created.
* Returns day note for the first date of the week of the given date. If it doesn't exist, it is automatically created.
*
* @method
* @param {string} date - e.g. "2019-04-29"

View file

@ -3,16 +3,17 @@ const CKEDITOR = {"js": ["libraries/ckeditor/ckeditor.js"]};
const CODE_MIRROR = {
js: [
"libraries/codemirror/codemirror.js",
"libraries/codemirror/addon/mode/loadmode.js",
"libraries/codemirror/addon/mode/simple.js",
"libraries/codemirror/addon/fold/xml-fold.js",
"libraries/codemirror/addon/display/placeholder.js",
"libraries/codemirror/addon/edit/matchbrackets.js",
"libraries/codemirror/addon/edit/matchtags.js",
"libraries/codemirror/addon/fold/xml-fold.js",
"libraries/codemirror/addon/lint/lint.js",
"libraries/codemirror/addon/lint/eslint.js",
"libraries/codemirror/addon/mode/loadmode.js",
"libraries/codemirror/addon/mode/simple.js",
"libraries/codemirror/addon/search/match-highlighter.js",
"libraries/codemirror/mode/meta.js",
"libraries/codemirror/keymap/vim.js",
"libraries/codemirror/addon/lint/lint.js",
"libraries/codemirror/addon/lint/eslint.js"
"libraries/codemirror/keymap/vim.js"
],
css: [
"libraries/codemirror/codemirror.css",

View file

@ -9,8 +9,8 @@ export default class LoadResults {
}
}
this.noteIdToSourceId = {};
this.sourceIdToNoteIds = {};
this.noteIdToComponentId = {};
this.componentIdToNoteIds = {};
this.branches = [];
@ -20,7 +20,7 @@ export default class LoadResults {
this.noteRevisions = [];
this.contentNoteIdToSourceId = [];
this.contentNoteIdToComponentId = [];
this.options = [];
}
@ -29,22 +29,22 @@ export default class LoadResults {
return this.entities[entityName]?.[entityId];
}
addNote(noteId, sourceId) {
this.noteIdToSourceId[noteId] = this.noteIdToSourceId[noteId] || [];
addNote(noteId, componentId) {
this.noteIdToComponentId[noteId] = this.noteIdToComponentId[noteId] || [];
if (!this.noteIdToSourceId[noteId].includes(sourceId)) {
this.noteIdToSourceId[noteId].push(sourceId);
if (!this.noteIdToComponentId[noteId].includes(componentId)) {
this.noteIdToComponentId[noteId].push(componentId);
}
this.sourceIdToNoteIds[sourceId] = this.sourceIdToNoteIds[sourceId] || [];
this.componentIdToNoteIds[componentId] = this.componentIdToNoteIds[componentId] || [];
if (!this.sourceIdToNoteIds[sourceId]) {
this.sourceIdToNoteIds[sourceId].push(noteId);
if (!this.componentIdToNoteIds[componentId]) {
this.componentIdToNoteIds[componentId].push(noteId);
}
}
addBranch(branchId, sourceId) {
this.branches.push({branchId, sourceId});
addBranch(branchId, componentId) {
this.branches.push({branchId, componentId});
}
getBranches() {
@ -53,7 +53,7 @@ export default class LoadResults {
.filter(branch => !!branch);
}
addNoteReordering(parentNoteId, sourceId) {
addNoteReordering(parentNoteId, componentId) {
this.noteReorderings.push(parentNoteId);
}
@ -61,20 +61,20 @@ export default class LoadResults {
return this.noteReorderings;
}
addAttribute(attributeId, sourceId) {
this.attributes.push({attributeId, sourceId});
addAttribute(attributeId, componentId) {
this.attributes.push({attributeId, componentId});
}
/** @returns {Attribute[]} */
getAttributes(sourceId = 'none') {
getAttributes(componentId = 'none') {
return this.attributes
.filter(row => row.sourceId !== sourceId)
.filter(row => row.componentId !== componentId)
.map(row => this.getEntity("attributes", row.attributeId))
.filter(attr => !!attr);
}
addNoteRevision(noteRevisionId, noteId, sourceId) {
this.noteRevisions.push({noteRevisionId, noteId, sourceId});
addNoteRevision(noteRevisionId, noteId, componentId) {
this.noteRevisions.push({noteRevisionId, noteId, componentId});
}
hasNoteRevisionForNote(noteId) {
@ -82,28 +82,28 @@ export default class LoadResults {
}
getNoteIds() {
return Object.keys(this.noteIdToSourceId);
return Object.keys(this.noteIdToComponentId);
}
isNoteReloaded(noteId, sourceId = null) {
isNoteReloaded(noteId, componentId = null) {
if (!noteId) {
return false;
}
const sourceIds = this.noteIdToSourceId[noteId];
return sourceIds && !!sourceIds.find(sId => sId !== sourceId);
const componentIds = this.noteIdToComponentId[noteId];
return componentIds && !!componentIds.find(sId => sId !== componentId);
}
addNoteContent(noteId, sourceId) {
this.contentNoteIdToSourceId.push({noteId, sourceId});
addNoteContent(noteId, componentId) {
this.contentNoteIdToComponentId.push({noteId, componentId});
}
isNoteContentReloaded(noteId, sourceId) {
isNoteContentReloaded(noteId, componentId) {
if (!noteId) {
return false;
}
return this.contentNoteIdToSourceId.find(l => l.noteId === noteId && l.sourceId !== sourceId);
return this.contentNoteIdToComponentId.find(l => l.noteId === noteId && l.componentId !== componentId);
}
addOption(name) {
@ -124,17 +124,17 @@ export default class LoadResults {
}
isEmpty() {
return Object.keys(this.noteIdToSourceId).length === 0
return Object.keys(this.noteIdToComponentId).length === 0
&& this.branches.length === 0
&& this.attributes.length === 0
&& this.noteReorderings.length === 0
&& this.noteRevisions.length === 0
&& this.contentNoteIdToSourceId.length === 0
&& this.contentNoteIdToComponentId.length === 0
&& this.options.length === 0;
}
isEmptyForTree() {
return Object.keys(this.noteIdToSourceId).length === 0
return Object.keys(this.noteIdToComponentId).length === 0
&& this.branches.length === 0
&& this.attributes.length === 0
&& this.noteReorderings.length === 0;

View file

@ -218,6 +218,13 @@ class NoteContext extends Component {
}
}
}
hasNoteList() {
return this.note.hasChildren()
&& ['book', 'text', 'code'].includes(this.note.type)
&& this.note.mime !== 'text/x-sqlite;schema=trilium'
&& !this.note.hasLabel('hideChildrenOverview');
}
}
export default NoteContext;

View file

@ -5,6 +5,7 @@ import ws from "./ws.js";
import appContext from "./app_context.js";
import froca from "./froca.js";
import utils from "./utils.js";
import options from "./options.js";
let protectedSessionDeferred = null;
@ -18,6 +19,11 @@ async function leaveProtectedSession() {
function enterProtectedSession() {
const dfd = $.Deferred();
if (!options.is("isPasswordSet")) {
import("../dialogs/password_not_set.js").then(dialog => dialog.show());
return dfd;
}
if (protectedSessionHolder.isProtectedSessionAvailable()) {
dfd.resolve(false);
}

View file

@ -53,8 +53,8 @@ export default class RootCommandExecutor extends Component {
d.showDialog(branchIds);
}
showOptionsCommand() {
import("../dialogs/options.js").then(d => d.showDialog());
showOptionsCommand({openTab}) {
import("../dialogs/options.js").then(d => d.showDialog(openTab));
}
showHelpCommand() {

View file

@ -9,7 +9,7 @@ async function getHeaders(headers) {
// headers need to be lowercase because node.js automatically converts them to lower case
// also avoiding using underscores instead of dashes since nginx filters them out by default
const allHeaders = {
'trilium-source-id': glob.sourceId,
'trilium-component-id': glob.componentId,
'trilium-local-now-datetime': utils.localNowDateTime(),
'trilium-hoisted-note-id': activeNoteContext ? activeNoteContext.hoistedNoteId : null,
'x-csrf-token': glob.csrfToken
@ -29,20 +29,24 @@ async function getHeaders(headers) {
return allHeaders;
}
async function get(url, sourceId) {
return await call('GET', url, null, {'trilium-source-id': sourceId});
async function get(url, componentId) {
return await call('GET', url, null, {'trilium-component-id': componentId});
}
async function post(url, data, sourceId) {
return await call('POST', url, data, {'trilium-source-id': sourceId});
async function post(url, data, componentId) {
return await call('POST', url, data, {'trilium-component-id': componentId});
}
async function put(url, data, sourceId) {
return await call('PUT', url, data, {'trilium-source-id': sourceId});
async function put(url, data, componentId) {
return await call('PUT', url, data, {'trilium-component-id': componentId});
}
async function remove(url, sourceId) {
return await call('DELETE', url, null, {'trilium-source-id': sourceId});
async function patch(url, data, componentId) {
return await call('PATCH', url, data, {'trilium-component-id': componentId});
}
async function remove(url, componentId) {
return await call('DELETE', url, null, {'trilium-component-id': componentId});
}
let i = 1;
@ -185,6 +189,7 @@ export default {
get,
post,
put,
patch,
remove,
ajax,
// don't remove, used from CKEditor image upload!

View file

@ -245,10 +245,11 @@ function focusSavedElement() {
$lastFocusedElement = null;
}
async function openDialog($dialog) {
closeActiveDialog();
glob.activeDialog = $dialog;
async function openDialog($dialog, closeActDialog = true) {
if (closeActDialog) {
closeActiveDialog();
glob.activeDialog = $dialog;
}
saveFocusedElement();

View file

@ -19,20 +19,23 @@ function SetupModel() {
this.setupSyncFromDesktop = ko.observable(false);
this.setupSyncFromServer = ko.observable(false);
this.username = ko.observable();
this.password1 = ko.observable();
this.password2 = ko.observable();
this.theme = ko.observable("light");
this.syncServerHost = ko.observable();
this.syncProxy = ko.observable();
this.instanceType = utils.isElectron() ? "desktop" : "server";
this.password = ko.observable();
this.setupTypeSelected = () => !!this.setupType();
this.selectSetupType = () => {
this.step(this.setupType());
if (this.setupType() === 'new-document') {
this.step('new-document-in-progress');
$.post('api/setup/new-document').then(() => {
window.location.replace("./setup");
});
}
else {
this.step(this.setupType());
}
};
this.back = () => {
@ -42,77 +45,36 @@ function SetupModel() {
};
this.finish = async () => {
if (this.setupType() === 'new-document') {
const username = this.username();
const password1 = this.password1();
const password2 = this.password2();
const theme = this.theme();
const syncServerHost = this.syncServerHost();
const syncProxy = this.syncProxy();
const password = this.password();
if (!username) {
showAlert("Username can't be empty");
return;
}
if (!password1) {
showAlert("Password can't be empty");
return;
}
if (password1 !== password2) {
showAlert("Both password fields need be identical.");
return;
}
this.step('new-document-in-progress');
// not using server.js because it loads too many dependencies
$.post('api/setup/new-document', {
username: username,
password: password1,
theme: theme
}).then(() => {
window.location.replace("./setup");
});
if (!syncServerHost) {
showAlert("Trilium server address can't be empty");
return;
}
else if (this.setupType() === 'sync-from-server') {
const syncServerHost = this.syncServerHost();
const syncProxy = this.syncProxy();
const username = this.username();
const password = this.password1();
if (!syncServerHost) {
showAlert("Trilium server address can't be empty");
return;
}
if (!password) {
showAlert("Password can't be empty");
return;
}
if (!username) {
showAlert("Username can't be empty");
return;
}
// not using server.js because it loads too many dependencies
const resp = await $.post('api/setup/sync-from-server', {
syncServerHost: syncServerHost,
syncProxy: syncProxy,
password: password
});
if (!password) {
showAlert("Password can't be empty");
return;
}
if (resp.result === 'success') {
this.step('sync-in-progress');
// not using server.js because it loads too many dependencies
const resp = await $.post('api/setup/sync-from-server', {
syncServerHost: syncServerHost,
syncProxy: syncProxy,
username: username,
password: password
});
setInterval(checkOutstandingSyncs, 1000);
if (resp.result === 'success') {
this.step('sync-in-progress');
setInterval(checkOutstandingSyncs, 1000);
hideAlert();
}
else {
showAlert('Sync setup failed: ' + resp.error);
}
hideAlert();
}
else {
showAlert('Sync setup failed: ' + resp.error);
}
};
}

View file

@ -55,7 +55,7 @@ export default class CalendarWidget extends RightDropdownButtonWidget {
this.$dropdownContent.on('click', '.calendar-date', async ev => {
const date = $(ev.target).closest('.calendar-date').attr('data-calendar-date');
const note = await dateNoteService.getDateNote(date);
const note = await dateNoteService.getDayNote(date);
if (note) {
appContext.tabManager.getActiveContext().setNote(note.noteId);

View file

@ -38,7 +38,7 @@ export default class RootContainer extends FlexContainer {
entitiesReloadedEvent({loadResults}) {
const note = appContext.tabManager.getActiveContextNote();
if (note && loadResults.isNoteReloaded(note.noteId)) {
this.refresh();
}

View file

@ -29,6 +29,10 @@ const TPL = `
font-family: var(--detail-font-family);
font-size: var(--detail-font-size);
}
.note-detail.full-height {
height: 100%;
}
</style>
</div>
`;
@ -128,7 +132,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
await typeWidget.handleEvent('setNoteContext', {noteContext: this.noteContext});
// this is happening in update() so note has been already set and we need to reflect this
// this is happening in update() so note has been already set, and we need to reflect this
await typeWidget.handleEvent('noteSwitched', {
noteContext: this.noteContext,
notePath: this.noteContext.notePath
@ -136,6 +140,15 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
this.child(typeWidget);
}
this.checkFullHeight();
}
checkFullHeight() {
// https://github.com/zadam/trilium/issues/2522
this.$widget.toggleClass("full-height",
!this.noteContext.hasNoteList()
&& ['editable-text', 'editable-code'].includes(this.type));
}
getTypeWidget() {

View file

@ -5,8 +5,6 @@ const TPL = `
<div class="note-list-widget">
<style>
.note-list-widget {
flex-grow: 100000;
flex-shrink: 100000;
min-height: 0;
overflow: auto;
}
@ -22,11 +20,7 @@ const TPL = `
export default class NoteListWidget extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled()
&& ['book', 'text', 'code'].includes(this.note.type)
&& this.note.mime !== 'text/x-sqlite;schema=trilium'
&& this.note.hasChildren()
&& !this.note.hasLabel('hideChildrenOverview');
return super.isEnabled() && this.noteContext.hasNoteList();
}
doRender() {

View file

@ -13,10 +13,12 @@ const TPL = `
<style>
.note-detail-code {
position: relative;
height: 100%;
}
.note-detail-code-editor {
min-height: 50px;
height: 100%;
}
</style>
@ -105,7 +107,8 @@ export default class EditableCodeTypeWidget extends TypeWidget {
// we linewrap partly also because without it horizontal scrollbar displays only when you scroll
// all the way to the bottom of the note. With line wrap there's no horizontal scrollbar so no problem
lineWrapping: true,
dragDrop: false // with true the editor inlines dropped files which is not what we expect
dragDrop: false, // with true the editor inlines dropped files which is not what we expect
placeholder: "Type the content of your code note here..."
});
this.codeEditor.on('change', () => this.spacedUpdate.scheduleUpdate());

View file

@ -36,6 +36,7 @@ const TPL = `
font-family: var(--detail-font-family);
padding-left: 14px;
padding-top: 10px;
height: 100%;
}
.note-detail-editable-text a:hover {
@ -73,6 +74,7 @@ const TPL = `
border: 0 !important;
box-shadow: none !important;
min-height: 50px;
height: 100%;
}
</style>

View file

@ -219,6 +219,7 @@ export default class RelationMapTypeWidget extends TypeWidget {
else if (command === "editTitle") {
const promptDialog = await import("../../dialogs/prompt.js");
const title = await promptDialog.ask({
title: "Rename note",
message: "Enter new note title:",
defaultValue: $title.text()
});

View file

@ -241,6 +241,10 @@ body .CodeMirror {
background-color: #eeeeee
}
.CodeMirror pre.CodeMirror-placeholder {
color: #999 !important;
}
#sql-console-query {
height: 150px;
width: 100%;

View file

@ -36,7 +36,7 @@ function getClipperInboxNote() {
let clipperInbox = attributeService.getNoteWithLabel('clipperInbox');
if (!clipperInbox) {
clipperInbox = dateNoteService.getDateNote(dateUtils.localNowDate());
clipperInbox = dateNoteService.getDayNote(dateUtils.localNowDate());
}
return clipperInbox;

View file

@ -0,0 +1,30 @@
const etapiTokenService = require("../../services/etapi_tokens");
function getTokens() {
const tokens = etapiTokenService.getTokens();
tokens.sort((a, b) => a.utcDateCreated < b.utcDateCreated ? -1 : 1);
return tokens;
}
function createToken(req) {
return {
authToken: etapiTokenService.createToken(req.body.tokenName)
};
}
function patchToken(req) {
etapiTokenService.renameToken(req.params.etapiTokenId, req.body.name);
}
function deleteToken(req) {
etapiTokenService.deleteToken(req.params.etapiTokenId);
}
module.exports = {
getTokens,
createToken,
patchToken,
deleteToken
};

View file

@ -3,16 +3,15 @@
const options = require('../../services/options');
const utils = require('../../services/utils');
const dateUtils = require('../../services/date_utils');
const sourceIdService = require('../../services/source_id');
const instanceId = require('../../services/member_id');
const passwordEncryptionService = require('../../services/password_encryption');
const protectedSessionService = require('../../services/protected_session');
const appInfo = require('../../services/app_info');
const eventService = require('../../services/events');
const sqlInit = require('../../services/sql_init');
const sql = require('../../services/sql');
const optionService = require('../../services/options');
const ApiToken = require('../../becca/entities/api_token');
const ws = require("../../services/ws");
const etapiTokenService = require("../../services/etapi_tokens");
function loginSync(req) {
if (!sqlInit.schemaExists()) {
@ -48,7 +47,7 @@ function loginSync(req) {
req.session.loggedIn = true;
return {
sourceId: sourceIdService.getCurrentSourceId(),
instanceId: instanceId,
maxEntityChangeId: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1")
};
}
@ -85,23 +84,18 @@ function logoutFromProtectedSession() {
}
function token(req) {
const username = req.body.username;
const password = req.body.password;
const isUsernameValid = username === optionService.getOption('username');
const isPasswordValid = passwordEncryptionService.verifyPassword(password);
if (!isUsernameValid || !isPasswordValid) {
return [401, "Incorrect username/password"];
if (!passwordEncryptionService.verifyPassword(password)) {
return [401, "Incorrect password"];
}
const apiToken = new ApiToken({
token: utils.randomSecureToken()
}).save();
// for backwards compatibility with Sender which does not send the name
const tokenName = req.body.tokenName || "Trilium Sender / Web Clipper";
const {authToken} = etapiTokenService.createToken(tokenName);
return {
token: apiToken.token
};
return { token: authToken };
}
module.exports = {

View file

@ -73,9 +73,7 @@ function deleteNote(req) {
const taskContext = TaskContext.getInstance(taskId, 'delete-notes');
for (const branch of note.getParentBranches()) {
noteService.deleteBranch(branch, deleteId, taskContext);
}
noteService.deleteNote(note, deleteId, taskContext);
if (eraseNotes) {
noteService.eraseNotesWithDeleteId(deleteId);

View file

@ -6,7 +6,6 @@ const searchService = require('../../services/search/services/search');
// options allowed to be updated directly in options dialog
const ALLOWED_OPTIONS = new Set([
'username', // not exposed for update (not harmful anyway), needed for reading
'eraseEntitiesAfterTimeInSeconds',
'protectedSessionTimeout',
'noteRevisionSnapshotTimeInterval',
@ -69,6 +68,8 @@ function getOptions() {
}
}
resultMap['isPasswordSet'] = !!optionMap['passwordVerificationHash'] ? 'true' : 'false';
return resultMap;
}

View file

@ -1,11 +1,26 @@
"use strict";
const changePasswordService = require('../../services/change_password');
const passwordService = require('../../services/password');
function changePassword(req) {
return changePasswordService.changePassword(req.body.current_password, req.body.new_password);
if (passwordService.isPasswordSet()) {
return passwordService.changePassword(req.body.current_password, req.body.new_password);
}
else {
return passwordService.setPassword(req.body.new_password);
}
}
function resetPassword(req) {
// protection against accidental call (not a security measure)
if (req.query.really !== "yesIReallyWantToResetPasswordAndLoseAccessToMyProtectedNotes") {
return [400, "Incorrect password reset confirmation"];
}
return passwordService.resetPassword();
}
module.exports = {
changePassword
changePassword,
resetPassword
};

View file

@ -15,7 +15,7 @@ function uploadImage(req) {
const originalName = "Sender image." + imageType(file.buffer).ext;
const parentNote = dateNoteService.getDateNote(req.headers['x-local-date']);
const parentNote = dateNoteService.getDayNote(req.headers['x-local-date']);
const {note, noteId} = imageService.saveImage(parentNote.noteId, file.buffer, originalName, true);
@ -35,7 +35,7 @@ function uploadImage(req) {
}
function saveNote(req) {
const parentNote = dateNoteService.getDateNote(req.headers['x-local-date']);
const parentNote = dateNoteService.getDayNote(req.headers['x-local-date']);
const {note, branch} = noteService.createNewNote({
parentNoteId: parentNote.noteId,

View file

@ -13,16 +13,14 @@ function getStatus() {
};
}
async function setupNewDocument(req) {
const { username, password, theme } = req.body;
await sqlInit.createInitialDatabase(username, password, theme);
async function setupNewDocument() {
await sqlInit.createInitialDatabase();
}
function setupSyncFromServer(req) {
const { syncServerHost, syncProxy, username, password } = req.body;
const { syncServerHost, syncProxy, password } = req.body;
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, username, password);
return setupService.setupSyncFromSyncServer(syncServerHost, syncProxy, password);
}
function saveSyncSeed(req) {

View file

@ -10,8 +10,8 @@ function getInboxNote(req) {
return specialNotesService.getInboxNote(req.params.date);
}
function getDateNote(req) {
return dateNoteService.getDateNote(req.params.date);
function getDayNote(req) {
return dateNoteService.getDayNote(req.params.date);
}
function getWeekNote(req) {
@ -26,7 +26,7 @@ function getYearNote(req) {
return dateNoteService.getYearNote(req.params.year);
}
function getDateNotesForMonth(req) {
function getDayNotesForMonth(req) {
const month = req.params.month;
return sql.getMap(`
@ -68,11 +68,11 @@ function getHoistedNote() {
module.exports = {
getInboxNote,
getDateNote,
getDayNote,
getWeekNote,
getMonthNote,
getYearNote,
getDateNotesForMonth,
getDayNotesForMonth,
createSqlConsole,
saveSqlConsole,
createSearchNote,

View file

@ -123,13 +123,45 @@ function forceNoteSync(req) {
function getChanged(req) {
const startTime = Date.now();
const lastEntityChangeId = parseInt(req.query.lastEntityChangeId);
let lastEntityChangeId = parseInt(req.query.lastEntityChangeId);
const clientinstanceId = req.query.instanceId;
let filteredEntityChanges = [];
const entityChanges = sql.getRows("SELECT * FROM entity_changes WHERE isSynced = 1 AND id > ? LIMIT 1000", [lastEntityChangeId]);
while (filteredEntityChanges.length === 0) {
const entityChanges = sql.getRows(`
SELECT *
FROM entity_changes
WHERE isSynced = 1
AND id > ?
ORDER BY id
LIMIT 1000`, [lastEntityChangeId]);
if (entityChanges.length === 0) {
break;
}
filteredEntityChanges = entityChanges.filter(ec => ec.instanceId !== clientinstanceId);
if (filteredEntityChanges.length === 0) {
lastEntityChangeId = entityChanges[entityChanges.length - 1].id;
}
}
const entityChangeRecords = syncService.getEntityChangeRecords(filteredEntityChanges);
if (entityChangeRecords.length > 0) {
lastEntityChangeId = entityChangeRecords[entityChangeRecords.length - 1].entityChange.id;
}
const ret = {
entityChanges: syncService.getEntityChangesRecords(entityChanges),
maxEntityChangeId: sql.getValue('SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1')
entityChanges: entityChangeRecords,
lastEntityChangeId,
outstandingPullCount: sql.getValue(`
SELECT COUNT(id)
FROM entity_changes
WHERE isSynced = 1
AND instanceId != ?
AND id > ?`, [clientinstanceId, lastEntityChangeId])
};
if (ret.entityChanges.length > 0) {
@ -174,10 +206,10 @@ function update(req) {
}
}
const {entities} = body;
const {entities, instanceId} = body;
for (const {entityChange, entity} of entities) {
syncUpdateService.updateEntity(entityChange, entity);
syncUpdateService.updateEntity(entityChange, entity, instanceId);
}
}

View file

@ -1,6 +1,5 @@
"use strict";
const sourceIdService = require('../services/source_id');
const sql = require('../services/sql');
const attributeService = require('../services/attributes');
const config = require('../services/config');
@ -28,7 +27,6 @@ function index(req, res) {
mainFontSize: parseInt(options.mainFontSize),
treeFontSize: parseInt(options.treeFontSize),
detailFontSize: parseInt(options.detailFontSize),
sourceId: sourceIdService.generateSourceId(),
maxEntityChangeIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes"),
maxEntityChangeSyncIdAtLoad: sql.getValue("SELECT COALESCE(MAX(id), 0) FROM entity_changes WHERE isSynced = 1"),
instanceName: config.General ? config.General.instanceName : null,

View file

@ -4,17 +4,47 @@ const utils = require('../services/utils');
const optionService = require('../services/options');
const myScryptService = require('../services/my_scrypt');
const log = require('../services/log');
const passwordService = require("../services/password");
function loginPage(req, res) {
res.render('login', { failedAuth: false });
}
function login(req, res) {
const userName = optionService.getOption('username');
function setPasswordPage(req, res) {
res.render('set_password', { error: false });
}
function setPassword(req, res) {
if (passwordService.isPasswordSet()) {
return [400, "Password has been already set"];
}
let {password1, password2} = req.body;
password1 = password1.trim();
password2 = password2.trim();
let error;
if (password1 !== password2) {
error = "Entered passwords don't match.";
} else if (password1.length < 4) {
error = "Password must be at least 4 characters long.";
}
if (error) {
res.render('set_password', { error });
return;
}
passwordService.setPassword(password1);
res.redirect('login');
}
function login(req, res) {
const guessedPassword = req.body.password;
if (req.body.username === userName && verifyPassword(guessedPassword)) {
if (verifyPassword(guessedPassword)) {
const rememberMe = req.body.remember_me;
req.session.regenerate(() => {
@ -30,7 +60,7 @@ function login(req, res) {
}
else {
// note that logged IP address is usually meaningless since the traffic should come from a reverse proxy
log.info(`WARNING: Wrong username / password from ${req.ip}, rejecting.`);
log.info(`WARNING: Wrong password from ${req.ip}, rejecting.`);
res.render('login', {'failedAuth': true});
}
@ -55,6 +85,8 @@ function logout(req, res) {
module.exports = {
loginPage,
setPasswordPage,
setPassword,
login,
logout
};

View file

@ -31,15 +31,22 @@ const scriptRoute = require('./api/script');
const senderRoute = require('./api/sender');
const filesRoute = require('./api/files');
const searchRoute = require('./api/search');
const specialNotesRoute = require('./api/special_notes.js');
const noteMapRoute = require('./api/note_map.js');
const specialNotesRoute = require('./api/special_notes');
const noteMapRoute = require('./api/note_map');
const clipperRoute = require('./api/clipper');
const similarNotesRoute = require('./api/similar_notes');
const keysRoute = require('./api/keys');
const backendLogRoute = require('./api/backend_log');
const statsRoute = require('./api/stats');
const fontsRoute = require('./api/fonts');
const etapiTokensApiRoutes = require('./api/etapi_tokens');
const shareRoutes = require('../share/routes');
const etapiAuthRoutes = require('../etapi/auth');
const etapiAttributeRoutes = require('../etapi/attributes');
const etapiBranchRoutes = require('../etapi/branches');
const etapiNoteRoutes = require('../etapi/notes');
const etapiSpecialNoteRoutes = require('../etapi/special_notes');
const etapiSpecRoute = require('../etapi/spec');
const log = require('../services/log');
const express = require('express');
@ -51,7 +58,7 @@ const entityChangesService = require('../services/entity_changes');
const csurf = require('csurf');
const {createPartialContentHandler} = require("express-partial-content");
const rateLimit = require("express-rate-limit");
const AbstractEntity = require("../becca/entities/abstract_entity.js");
const AbstractEntity = require("../becca/entities/abstract_entity");
const csrfMiddleware = csurf({
cookie: true,
@ -139,7 +146,7 @@ function route(method, path, middleware, routeHandler, resultHandler, transactio
cls.namespace.bindEmitter(res);
const result = cls.init(() => {
cls.set('sourceId', req.headers['trilium-source-id']);
cls.set('componentId', req.headers['trilium-component-id']);
cls.set('localNowDateTime', req.headers['trilium-local-now-datetime']);
cls.set('hoistedNoteId', req.headers['trilium-hoisted-note-id'] || 'root');
@ -177,12 +184,13 @@ function route(method, path, middleware, routeHandler, resultHandler, transactio
});
}
const GET = 'get', POST = 'post', PUT = 'put', DELETE = 'delete';
const GET = 'get', POST = 'post', PUT = 'put', PATCH = 'patch', DELETE = 'delete';
const uploadMiddleware = multer.single('upload');
function register(app) {
route(GET, '/', [auth.checkAuth, csrfMiddleware], indexRoute.index);
route(GET, '/login', [auth.checkAppInitialized], loginRoute.loginPage);
route(GET, '/login', [auth.checkAppInitialized, auth.checkPasswordSet], loginRoute.loginPage);
route(GET, '/set-password', [auth.checkAppInitialized], loginRoute.setPasswordPage);
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
@ -191,6 +199,7 @@ function register(app) {
route(POST, '/login', [loginRateLimiter], loginRoute.login);
route(POST, '/logout', [csrfMiddleware, auth.checkAuth], loginRoute.logout);
route(POST, '/set-password', [auth.checkAppInitialized], loginRoute.setPassword);
route(GET, '/setup', [], setupRoute.setupPage);
apiRoute(GET, '/api/tree', treeApiRoute.getTree);
@ -265,11 +274,11 @@ function register(app) {
apiRoute(GET, '/api/note-map/:noteId/backlinks', noteMapRoute.getBacklinks);
apiRoute(GET, '/api/special-notes/inbox/:date', specialNotesRoute.getInboxNote);
apiRoute(GET, '/api/special-notes/date/:date', specialNotesRoute.getDateNote);
apiRoute(GET, '/api/special-notes/week/:date', specialNotesRoute.getWeekNote);
apiRoute(GET, '/api/special-notes/month/:month', specialNotesRoute.getMonthNote);
apiRoute(GET, '/api/special-notes/year/:year', specialNotesRoute.getYearNote);
apiRoute(GET, '/api/special-notes/notes-for-month/:month', specialNotesRoute.getDateNotesForMonth);
apiRoute(GET, '/api/special-notes/days/:date', specialNotesRoute.getDayNote);
apiRoute(GET, '/api/special-notes/weeks/:date', specialNotesRoute.getWeekNote);
apiRoute(GET, '/api/special-notes/months/:month', specialNotesRoute.getMonthNote);
apiRoute(GET, '/api/special-notes/years/:year', specialNotesRoute.getYearNote);
apiRoute(GET, '/api/special-notes/notes-for-month/:month', specialNotesRoute.getDayNotesForMonth);
apiRoute(POST, '/api/special-notes/sql-console', specialNotesRoute.createSqlConsole);
apiRoute(POST, '/api/special-notes/save-sql-console', specialNotesRoute.saveSqlConsole);
apiRoute(POST, '/api/special-notes/search-note', specialNotesRoute.createSearchNote);
@ -288,6 +297,7 @@ function register(app) {
apiRoute(GET, '/api/options/user-themes', optionsApiRoute.getUserThemes);
apiRoute(POST, '/api/password/change', passwordApiRoute.changePassword);
apiRoute(POST, '/api/password/reset', passwordApiRoute.resetPassword);
apiRoute(POST, '/api/sync/test', syncApiRoute.testSync);
apiRoute(POST, '/api/sync/now', syncApiRoute.syncNow);
@ -333,8 +343,8 @@ function register(app) {
// no CSRF since this is called from android app
route(POST, '/api/sender/login', [], loginApiRoute.token, apiResultHandler);
route(POST, '/api/sender/image', [auth.checkToken, uploadMiddleware], senderRoute.uploadImage, apiResultHandler);
route(POST, '/api/sender/note', [auth.checkToken], senderRoute.saveNote, apiResultHandler);
route(POST, '/api/sender/image', [auth.checkEtapiToken, uploadMiddleware], senderRoute.uploadImage, apiResultHandler);
route(POST, '/api/sender/note', [auth.checkEtapiToken], senderRoute.saveNote, apiResultHandler);
apiRoute(GET, '/api/quick-search/:searchString', searchRoute.quickSearch);
apiRoute(GET, '/api/search-note/:noteId', searchRoute.searchFromNote);
@ -350,7 +360,7 @@ function register(app) {
route(POST, '/api/login/token', [], loginApiRoute.token, apiResultHandler);
// in case of local electron, local calls are allowed unauthenticated, for server they need auth
const clipperMiddleware = utils.isElectron() ? [] : [auth.checkToken];
const clipperMiddleware = utils.isElectron() ? [] : [auth.checkEtapiToken];
route(GET, '/api/clipper/handshake', clipperMiddleware, clipperRoute.handshake, apiResultHandler);
route(POST, '/api/clipper/clippings', clipperMiddleware, clipperRoute.addClipping, apiResultHandler);
@ -371,7 +381,19 @@ function register(app) {
route(GET, '/api/fonts', [auth.checkApiAuthOrElectron], fontsRoute.getFontCss);
apiRoute(GET, '/api/etapi-tokens', etapiTokensApiRoutes.getTokens);
apiRoute(POST, '/api/etapi-tokens', etapiTokensApiRoutes.createToken);
apiRoute(PATCH, '/api/etapi-tokens/:etapiTokenId', etapiTokensApiRoutes.patchToken);
apiRoute(DELETE, '/api/etapi-tokens/:etapiTokenId', etapiTokensApiRoutes.deleteToken);
shareRoutes.register(router);
etapiAuthRoutes.register(router);
etapiAttributeRoutes.register(router);
etapiBranchRoutes.register(router);
etapiNoteRoutes.register(router);
etapiSpecialNoteRoutes.register(router);
etapiSpecRoute.register(router);
app.use('', router);
}

View file

@ -4,8 +4,8 @@ const build = require('./build');
const packageJson = require('../../package');
const {TRILIUM_DATA_DIR} = require('./data_dir');
const APP_DB_VERSION = 189;
const SYNC_VERSION = 23;
const APP_DB_VERSION = 194;
const SYNC_VERSION = 25;
const CLIPPER_PROTOCOL_VERSION = "1.0";
module.exports = {

View file

@ -1,12 +1,12 @@
"use strict";
const sql = require('./sql');
const etapiTokenService = require("./etapi_tokens");
const log = require('./log');
const sqlInit = require('./sql_init');
const utils = require('./utils');
const passwordEncryptionService = require('./password_encryption');
const optionService = require('./options');
const config = require('./config');
const passwordService = require("./password");
const noAuthentication = config.General && config.General.noAuthentication === true;
@ -15,7 +15,11 @@ function checkAuth(req, res, next) {
res.redirect("setup");
}
else if (!req.session.loggedIn && !utils.isElectron() && !noAuthentication) {
res.redirect("login");
if (passwordService.isPasswordSet()) {
res.redirect("login");
} else {
res.redirect("set-password");
}
}
else {
next();
@ -51,6 +55,14 @@ function checkAppInitialized(req, res, next) {
}
}
function checkPasswordSet(req, res, next) {
if (!utils.isElectron() && !passwordService.isPasswordSet()) {
res.redirect("set-password");
} else {
next();
}
}
function checkAppNotInitialized(req, res, next) {
if (sqlInit.isDbInitialized()) {
reject(req, res, "App already initialized.");
@ -60,15 +72,12 @@ function checkAppNotInitialized(req, res, next) {
}
}
function checkToken(req, res, next) {
const token = req.headers.authorization;
// TODO: put all tokens into becca memory to avoid these requests
if (sql.getValue("SELECT COUNT(*) FROM api_tokens WHERE isDeleted = 0 AND token = ?", [token]) === 0) {
reject(req, res, "Token not found");
function checkEtapiToken(req, res, next) {
if (etapiTokenService.isValidAuthHeader(req.headers.authorization)) {
next();
}
else {
next();
reject(req, res, "Token not found");
}
}
@ -87,10 +96,10 @@ function checkCredentials(req, res, next) {
const auth = new Buffer.from(header, 'base64').toString();
const [username, password] = auth.split(/:/);
const dbUsername = optionService.getOption('username');
// username is ignored
if (dbUsername !== username || !passwordEncryptionService.verifyPassword(password)) {
res.status(401).send('Incorrect username and/or password');
if (!passwordEncryptionService.verifyPassword(password)) {
res.status(401).send('Incorrect password');
}
else {
next();
@ -101,8 +110,9 @@ module.exports = {
checkAuth,
checkApiAuth,
checkAppInitialized,
checkPasswordSet,
checkAppNotInitialized,
checkApiAuthOrElectron,
checkToken,
checkEtapiToken,
checkCredentials
};

View file

@ -134,18 +134,18 @@ function BackendScriptApi(currentNote, apiParams) {
this.getNoteWithLabel = attributeService.getNoteWithLabel;
/**
* If there's no branch between note and parent note, create one. Otherwise do nothing.
* If there's no branch between note and parent note, create one. Otherwise, do nothing.
*
* @method
* @param {string} noteId
* @param {string} parentNoteId
* @param {string} prefix - if branch will be create between note and parent note, set this prefix
* @param {string} prefix - if branch will be created between note and parent note, set this prefix
* @returns {void}
*/
this.ensureNoteIsPresentInParent = cloningService.ensureNoteIsPresentInParent;
/**
* If there's a branch between note and parent note, remove it. Otherwise do nothing.
* If there's a branch between note and parent note, remove it. Otherwise, do nothing.
*
* @method
* @param {string} noteId
@ -309,8 +309,18 @@ function BackendScriptApi(currentNote, apiParams) {
* @method
* @param {string} date in YYYY-MM-DD format
* @returns {Note|null}
* @deprecated use getDayNote instead
*/
this.getDateNote = dateNoteService.getDateNote;
this.getDateNote = dateNoteService.getDayNote;
/**
* Returns day note for given date. If such note doesn't exist, it is created.
*
* @method
* @param {string} date in YYYY-MM-DD format
* @returns {Note|null}
*/
this.getDayNote = dateNoteService.getDayNote;
/**
* Returns today's day note. If such note doesn't exist, it is created.

View file

@ -76,7 +76,7 @@ async function anonymize() {
const db = new Database(anonymizedFile);
db.prepare("UPDATE api_tokens SET token = 'API token value'").run();
db.prepare("UPDATE etapi_tokens SET tokenHash = 'API token hash value'").run();
db.prepare("UPDATE notes SET title = 'title'").run();
db.prepare("UPDATE note_contents SET content = 'text' WHERE content IS NOT NULL").run();
db.prepare("UPDATE note_revisions SET title = 'title'").run();

View file

@ -1,37 +0,0 @@
"use strict";
const sql = require('./sql');
const optionService = require('./options');
const myScryptService = require('./my_scrypt');
const utils = require('./utils');
const passwordEncryptionService = require('./password_encryption');
function changePassword(currentPassword, newPassword) {
if (!passwordEncryptionService.verifyPassword(currentPassword)) {
return {
success: false,
message: "Given current password doesn't match hash"
};
}
sql.transactional(() => {
const decryptedDataKey = passwordEncryptionService.getDataKey(currentPassword);
optionService.setOption('passwordVerificationSalt', utils.randomSecureToken(32));
optionService.setOption('passwordDerivedKeySalt', utils.randomSecureToken(32));
const newPasswordVerificationKey = utils.toBase64(myScryptService.getVerificationHash(newPassword));
passwordEncryptionService.setDataKey(newPassword, decryptedDataKey);
optionService.setOption('passwordVerificationHash', newPasswordVerificationKey);
});
return {
success: true
};
}
module.exports = {
changePassword
};

View file

@ -28,8 +28,8 @@ function getHoistedNoteId() {
return namespace.get('hoistedNoteId') || 'root';
}
function getSourceId() {
return namespace.get('sourceId');
function getComponentId() {
return namespace.get('componentId');
}
function getLocalNowDateTime() {
@ -80,7 +80,7 @@ module.exports = {
set,
namespace,
getHoistedNoteId,
getSourceId,
getComponentId,
getLocalNowDateTime,
disableEntityEvents,
isEntityEventsDisabled,

View file

@ -567,7 +567,7 @@ class ConsistencyChecks {
this.runEntityChangeChecks("note_revisions", "noteRevisionId");
this.runEntityChangeChecks("branches", "branchId");
this.runEntityChangeChecks("attributes", "attributeId");
this.runEntityChangeChecks("api_tokens", "apiTokenId");
this.runEntityChangeChecks("etapi_tokens", "etapiTokenId");
this.runEntityChangeChecks("options", "name");
}
@ -660,7 +660,7 @@ class ConsistencyChecks {
return `${tableName}: ${count}`;
}
const tables = [ "notes", "note_revisions", "branches", "attributes", "api_tokens" ];
const tables = [ "notes", "note_revisions", "branches", "attributes", "etapi_tokens" ];
log.info("Table counts: " + tables.map(tableName => getTableRowCount(tableName)).join(", "));
}

View file

@ -8,9 +8,9 @@ function getEntityHashes() {
const startTime = new Date();
const hashRows = sql.getRawRows(`
SELECT entityName,
entityId,
hash
SELECT entityName,
entityId,
hash
FROM entity_changes
WHERE isSynced = 1
AND entityName != 'note_reordering'`);

View file

@ -3,7 +3,6 @@
const noteService = require('./notes');
const attributeService = require('./attributes');
const dateUtils = require('./date_utils');
const becca = require('../becca/becca');
const sql = require('./sql');
const protectedSessionService = require('./protected_session');
@ -49,7 +48,7 @@ function getRootCalendarNote() {
}
/** @returns {Note} */
function getYearNote(dateStr, rootNote) {
function getYearNote(dateStr, rootNote = null) {
if (!rootNote) {
rootNote = getRootCalendarNote();
}
@ -88,7 +87,7 @@ function getMonthNoteTitle(rootNote, monthNumber, dateObj) {
}
/** @returns {Note} */
function getMonthNote(dateStr, rootNote) {
function getMonthNote(dateStr, rootNote = null) {
if (!rootNote) {
rootNote = getRootCalendarNote();
}
@ -124,7 +123,7 @@ function getMonthNote(dateStr, rootNote) {
return monthNote;
}
function getDateNoteTitle(rootNote, dayNumber, dateObj) {
function getDayNoteTitle(rootNote, dayNumber, dateObj) {
const pattern = rootNote.getOwnedLabelValue("datePattern") || "{dayInMonthPadded} - {weekDay}";
const weekDay = DAYS[dateObj.getDay()];
@ -137,7 +136,7 @@ function getDateNoteTitle(rootNote, dayNumber, dateObj) {
}
/** @returns {Note} */
function getDateNote(dateStr) {
function getDayNote(dateStr) {
dateStr = dateStr.trim().substr(0, 10);
let dateNote = attributeService.getNoteWithLabel(DATE_LABEL, dateStr);
@ -152,7 +151,7 @@ function getDateNote(dateStr) {
const dateObj = dateUtils.parseLocalDate(dateStr);
const noteTitle = getDateNoteTitle(rootNote, dayNumber, dateObj);
const noteTitle = getDayNoteTitle(rootNote, dayNumber, dateObj);
sql.transactional(() => {
dateNote = createNote(monthNote, noteTitle);
@ -170,7 +169,7 @@ function getDateNote(dateStr) {
}
function getTodayNote() {
return getDateNote(dateUtils.localNowDate());
return getDayNote(dateUtils.localNowDate());
}
function getStartOfTheWeek(date, startOfTheWeek) {
@ -197,7 +196,7 @@ function getWeekNote(dateStr, options = {}) {
dateStr = dateUtils.utcDateTimeStr(dateObj);
return getDateNote(dateStr);
return getDayNote(dateStr);
}
module.exports = {
@ -205,6 +204,6 @@ module.exports = {
getYearNote,
getMonthNote,
getWeekNote,
getDateNote,
getDayNote,
getTodayNote
};

View file

@ -1,13 +1,19 @@
const sql = require('./sql');
const sourceIdService = require('./source_id');
const dateUtils = require('./date_utils');
const log = require('./log');
const cls = require('./cls');
const utils = require('./utils');
const instanceId = require('./member_id');
const becca = require("../becca/becca");
let maxEntityChangeId = 0;
function addEntityChangeWithinstanceId(origEntityChange, instanceId) {
const ec = {...origEntityChange, instanceId};
return addEntityChange(ec);
}
function addEntityChange(origEntityChange) {
const ec = {...origEntityChange};
@ -17,7 +23,8 @@ function addEntityChange(origEntityChange) {
ec.changeId = utils.randomString(12);
}
ec.sourceId = ec.sourceId || cls.getSourceId() || sourceIdService.getCurrentSourceId();
ec.componentId = ec.componentId || cls.getComponentId() || "";
ec.instanceId = ec.instanceId || instanceId;
ec.isSynced = ec.isSynced ? 1 : 0;
ec.isErased = ec.isErased ? 1 : 0;
ec.id = sql.replace("entity_changes", ec);
@ -27,7 +34,7 @@ function addEntityChange(origEntityChange) {
cls.addEntityChange(ec);
}
function addNoteReorderingEntityChange(parentNoteId, sourceId) {
function addNoteReorderingEntityChange(parentNoteId, componentId) {
addEntityChange({
entityName: "note_reordering",
entityId: parentNoteId,
@ -35,7 +42,8 @@ function addNoteReorderingEntityChange(parentNoteId, sourceId) {
isErased: false,
utcDateChanged: dateUtils.utcNowDateTime(),
isSynced: true,
sourceId
componentId,
instanceId: instanceId
});
const eventService = require('./events');
@ -129,7 +137,7 @@ function fillAllEntityChanges() {
fillEntityChanges("note_revision_contents", "noteRevisionId");
fillEntityChanges("recent_notes", "noteId");
fillEntityChanges("attributes", "attributeId");
fillEntityChanges("api_tokens", "apiTokenId");
fillEntityChanges("etapi_tokens", "etapiTokenId");
fillEntityChanges("options", "name", 'isSynced = 1');
});
}
@ -138,6 +146,7 @@ module.exports = {
addNoteReorderingEntityChange,
moveEntityChangeToTop,
addEntityChange,
addEntityChangeWithinstanceId,
fillAllEntityChanges,
addEntityChangesForSector,
getMaxEntityChangeId: () => maxEntityChangeId

View file

@ -0,0 +1,107 @@
const becca = require("../becca/becca");
const utils = require("./utils");
const EtapiToken = require("../becca/entities/etapi_token");
const crypto = require("crypto");
function getTokens() {
return becca.getEtapiTokens();
}
function getTokenHash(token) {
return crypto.createHash('sha256').update(token).digest('base64');
}
function createToken(tokenName) {
const token = utils.randomSecureToken();
const tokenHash = getTokenHash(token);
const etapiToken = new EtapiToken({
name: tokenName,
tokenHash
}).save();
return {
authToken: `${etapiToken.etapiTokenId}_${token}`
};
}
function parseAuthToken(auth) {
if (!auth) {
return null;
}
const chunks = auth.split("_");
if (chunks.length === 1) {
return { token: auth }; // legacy format without etapiTokenId
}
else if (chunks.length === 2) {
return {
etapiTokenId: chunks[0],
token: chunks[1]
}
}
else {
return null; // wrong format
}
}
function isValidAuthHeader(auth) {
const parsed = parseAuthToken(auth);
if (!parsed) {
return false;
}
const authTokenHash = getTokenHash(parsed.token);
if (parsed.etapiTokenId) {
const etapiToken = becca.getEtapiToken(parsed.etapiTokenId);
if (!etapiToken) {
return false;
}
return etapiToken.tokenHash === authTokenHash;
}
else {
for (const etapiToken of becca.getEtapiTokens()) {
if (etapiToken.tokenHash === authTokenHash) {
return true;
}
}
return false;
}
}
function renameToken(etapiTokenId, newName) {
const etapiToken = becca.getEtapiToken(etapiTokenId);
if (!etapiToken) {
throw new Error(`Token ${etapiTokenId} does not exist`);
}
etapiToken.name = newName;
etapiToken.save();
}
function deleteToken(etapiTokenId) {
const etapiToken = becca.getEtapiToken(etapiTokenId);
if (!etapiToken) {
return; // ok, already deleted
}
etapiToken.isDeleted = true;
etapiToken.save();
}
module.exports = {
getTokens,
createToken,
renameToken,
deleteToken,
parseAuthToken,
isValidAuthHeader
};

View file

@ -0,0 +1,5 @@
const utils = require('./utils');
const instanceId = utils.randomString(12);
module.exports = instanceId;

View file

@ -18,6 +18,8 @@ const Branch = require('../becca/entities/branch');
const Note = require('../becca/entities/note');
const Attribute = require('../becca/entities/attribute');
// TODO: patch/put note content
function getNewNotePosition(parentNoteId) {
const note = becca.notes[parentNoteId];
@ -107,6 +109,10 @@ function createNewNote(params) {
throw new Error(`Note title must be set`);
}
if (params.content === null || params.content === undefined) {
throw new Error(`Note content must be set`);
}
return sql.transactional(() => {
const note = new Note({
noteId: params.noteId, // optionally can force specific noteId
@ -520,7 +526,7 @@ function updateNote(noteId, noteUpdates) {
/**
* @param {Branch} branch
* @param {string} deleteId
* @param {string|null} deleteId
* @param {TaskContext} taskContext
*
* @return {boolean} - true if note has been deleted, false otherwise
@ -570,6 +576,17 @@ function deleteBranch(branch, deleteId, taskContext) {
}
}
/**
* @param {Note} note
* @param {string|null} deleteId
* @param {TaskContext} taskContext
*/
function deleteNote(note, deleteId, taskContext) {
for (const branch of note.getParentBranches()) {
deleteBranch(branch, deleteId, taskContext);
}
}
/**
* @param {string} noteId
* @param {TaskContext} taskContext
@ -915,6 +932,7 @@ module.exports = {
createNewNoteWithTarget,
updateNote,
deleteBranch,
deleteNote,
undeleteNote,
protectNoteRecursively,
scanForLinks,

View file

@ -1,5 +1,5 @@
const becca = require('../becca/becca');
const sql = require("./sql.js");
const sql = require("./sql");
function getOption(name) {
let option;

View file

@ -1,6 +1,4 @@
const optionService = require('./options');
const passwordEncryptionService = require('./password_encryption');
const myScryptService = require('./my_scrypt');
const appInfo = require('./app_info');
const utils = require('./utils');
const log = require('./log');
@ -12,21 +10,6 @@ function initDocumentOptions() {
optionService.createOption('documentSecret', utils.randomSecureToken(16), false);
}
function initSyncedOptions(username, password) {
optionService.createOption('username', username, true);
optionService.createOption('passwordVerificationSalt', utils.randomSecureToken(32), true);
optionService.createOption('passwordDerivedKeySalt', utils.randomSecureToken(32), true);
const passwordVerificationKey = utils.toBase64(myScryptService.getVerificationHash(password), true);
optionService.createOption('passwordVerificationHash', passwordVerificationKey, true);
// passwordEncryptionService expects these options to already exist
optionService.createOption('encryptedDataKey', '', true);
passwordEncryptionService.setDataKey(password, utils.randomSecureToken(16), true);
}
function initNotSyncedOptions(initialized, opts = {}) {
optionService.createOption('openTabs', JSON.stringify([
{
@ -45,7 +28,15 @@ function initNotSyncedOptions(initialized, opts = {}) {
optionService.createOption('lastSyncedPull', '0', false);
optionService.createOption('lastSyncedPush', '0', false);
optionService.createOption('theme', opts.theme || 'white', false);
let theme = 'dark'; // default based on the poll in https://github.com/zadam/trilium/issues/2516
if (utils.isElectron()) {
const {nativeTheme} = require('electron');
theme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
}
optionService.createOption('theme', theme, false);
optionService.createOption('syncServerHost', opts.syncServerHost || '', false);
optionService.createOption('syncServerTimeout', '120000', false);
@ -130,7 +121,6 @@ function getKeyboardDefaultOptions() {
module.exports = {
initDocumentOptions,
initSyncedOptions,
initNotSyncedOptions,
initStartupOptions
};

83
src/services/password.js Normal file
View file

@ -0,0 +1,83 @@
"use strict";
const sql = require('./sql');
const optionService = require('./options');
const myScryptService = require('./my_scrypt');
const utils = require('./utils');
const passwordEncryptionService = require('./password_encryption');
function isPasswordSet() {
return !!sql.getValue("SELECT value FROM options WHERE name = 'passwordVerificationHash'");
}
function changePassword(currentPassword, newPassword) {
if (!isPasswordSet()) {
throw new Error("Password has not been set yet, so it cannot be changed. Use 'setPassword' instead.");
}
if (!passwordEncryptionService.verifyPassword(currentPassword)) {
return {
success: false,
message: "Given current password doesn't match hash"
};
}
sql.transactional(() => {
const decryptedDataKey = passwordEncryptionService.getDataKey(currentPassword);
optionService.setOption('passwordVerificationSalt', utils.randomSecureToken(32));
optionService.setOption('passwordDerivedKeySalt', utils.randomSecureToken(32));
const newPasswordVerificationKey = utils.toBase64(myScryptService.getVerificationHash(newPassword));
passwordEncryptionService.setDataKey(newPassword, decryptedDataKey);
optionService.setOption('passwordVerificationHash', newPasswordVerificationKey);
});
return {
success: true
};
}
function setPassword(password) {
if (isPasswordSet()) {
throw new Error("Password is set already. Either change it or perform 'reset password' first.");
}
optionService.createOption('passwordVerificationSalt', utils.randomSecureToken(32), true);
optionService.createOption('passwordDerivedKeySalt', utils.randomSecureToken(32), true);
const passwordVerificationKey = utils.toBase64(myScryptService.getVerificationHash(password), true);
optionService.createOption('passwordVerificationHash', passwordVerificationKey, true);
// passwordEncryptionService expects these options to already exist
optionService.createOption('encryptedDataKey', '', true);
passwordEncryptionService.setDataKey(password, utils.randomSecureToken(16), true);
return {
success: true
};
}
function resetPassword() {
// user forgot the password,
sql.transactional(() => {
optionService.setOption('passwordVerificationSalt', '');
optionService.setOption('passwordDerivedKeySalt', '');
optionService.setOption('encryptedDataKey', '');
optionService.setOption('passwordVerificationHash', '');
});
return {
success: true
};
}
module.exports = {
isPasswordSet,
changePassword,
setPassword,
resetPassword
};

View file

@ -38,7 +38,7 @@ function exec(opts) {
};
if (opts.auth) {
headers['trilium-cred'] = Buffer.from(opts.auth.username + ":" + opts.auth.password).toString('base64');
headers['trilium-cred'] = Buffer.from("dummy:" + opts.auth.password).toString('base64');
}
const request = client.request({

View file

@ -30,9 +30,9 @@ function executeBundle(bundle, apiParams = {}) {
apiParams.startNote = bundle.note;
}
const originalSourceId = cls.get('sourceId');
const originalComponentId = cls.get('componentId');
cls.set('sourceId', 'script');
cls.set('componentId', 'script');
// last \r\n is necessary if script contains line comment on its last line
const script = "function() {\r\n" + bundle.script + "\r\n}";
@ -47,7 +47,7 @@ function executeBundle(bundle, apiParams = {}) {
throw e;
}
finally {
cls.set('sourceId', originalSourceId);
cls.set('componentId', originalComponentId);
}
}

Some files were not shown because too many files have changed in this diff Show more