mirror of https://github.com/keeweb/keeweb.git
file, group, entry
This commit is contained in:
parent
89f3741f17
commit
45e1f87d07
|
@ -0,0 +1,205 @@
|
|||
import * as kdbxweb from 'kdbxweb';
|
||||
import { BuiltInFields } from 'const/entry-fields';
|
||||
import { AdvancedFilter, Filter } from 'models/filter';
|
||||
import { Entry } from 'models/entry';
|
||||
|
||||
interface SearchContext {
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
export const EntrySearch = { matches };
|
||||
|
||||
const BuiltInFieldsSet = new Set<string>(BuiltInFields);
|
||||
|
||||
function matches(entry: Entry, filter: Filter): boolean {
|
||||
if (filter.tagLower) {
|
||||
if (entry.searchTags && entry.searchTags.indexOf(filter.tagLower) < 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filter.textLower) {
|
||||
if (filter.advanced) {
|
||||
if (!matchesAdv(entry, filter)) {
|
||||
return false;
|
||||
}
|
||||
} else if (filter.textLowerParts) {
|
||||
const parts = filter.textLowerParts;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (!entry.searchText?.includes(parts[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if (!entry.searchText?.includes(filter.textLower)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filter.color) {
|
||||
if (filter.color === '*') {
|
||||
if (!entry.searchColor) {
|
||||
return false;
|
||||
}
|
||||
} else if (entry.searchColor !== filter.color) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filter.autoType) {
|
||||
if (!entry.autoTypeEnabled) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filter.otp) {
|
||||
if (
|
||||
!entry.fields?.has('otp') &&
|
||||
!entry.fields?.has('TOTP Seed') &&
|
||||
entry.backend !== 'otp-device'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesAdv(entry: Entry, filter: Filter): boolean {
|
||||
const adv = filter.advanced;
|
||||
if (!adv || !filter.text || !filter.textLower) {
|
||||
return false;
|
||||
}
|
||||
let compare: (val: kdbxweb.KdbxEntryField) => boolean;
|
||||
const context: SearchContext = { matches: [] };
|
||||
if (adv.regex) {
|
||||
try {
|
||||
const regex = new RegExp(filter.text, adv.cs ? '' : 'i');
|
||||
compare = (val) => matchRegex(val, regex);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
} else if (adv.cs) {
|
||||
if (filter.textParts) {
|
||||
const textParts = filter.textParts;
|
||||
compare = (val) => matchStringMulti(val, textParts, context, false);
|
||||
} else {
|
||||
const text = filter.text;
|
||||
compare = (val) => matchString(val, text);
|
||||
}
|
||||
} else if (filter.textLowerParts) {
|
||||
const textLowerParts = filter.textLowerParts;
|
||||
compare = (val) => matchStringMultiLower(val, textLowerParts, context);
|
||||
} else {
|
||||
const textLower = filter.textLower;
|
||||
compare = (val) => matchStringLower(val, textLower);
|
||||
}
|
||||
if (matchFields(entry.getAllFields(), adv, compare)) {
|
||||
return true;
|
||||
}
|
||||
if (adv.history && entry.getAllHistoryEntriesFields) {
|
||||
for (const historyEntryFields of entry.getAllHistoryEntriesFields()) {
|
||||
if (matchFields(historyEntryFields, adv, compare)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchString(str: kdbxweb.KdbxEntryField, find: string): boolean {
|
||||
if (str instanceof kdbxweb.ProtectedValue) {
|
||||
return str.includes(find);
|
||||
}
|
||||
return str.indexOf(find) >= 0;
|
||||
}
|
||||
|
||||
function matchStringLower(str: kdbxweb.KdbxEntryField, findLower: string): boolean {
|
||||
if (str instanceof kdbxweb.ProtectedValue) {
|
||||
return str.includesLower(findLower);
|
||||
}
|
||||
return str.toLowerCase().indexOf(findLower) >= 0;
|
||||
}
|
||||
|
||||
function matchStringMulti(
|
||||
str: kdbxweb.KdbxEntryField,
|
||||
find: string[],
|
||||
context: SearchContext,
|
||||
lower: boolean
|
||||
): boolean {
|
||||
for (let i = 0; i < find.length; i++) {
|
||||
const item = find[i];
|
||||
let strMatches;
|
||||
if (lower) {
|
||||
strMatches =
|
||||
str instanceof kdbxweb.ProtectedValue
|
||||
? str.includesLower(item)
|
||||
: str.includes(item);
|
||||
} else {
|
||||
strMatches =
|
||||
str instanceof kdbxweb.ProtectedValue ? str.includes(item) : str.includes(item);
|
||||
}
|
||||
if (strMatches) {
|
||||
if (context.matches) {
|
||||
if (!context.matches.includes(item)) {
|
||||
context.matches.push(item);
|
||||
}
|
||||
} else {
|
||||
context.matches = [item];
|
||||
}
|
||||
}
|
||||
}
|
||||
return context.matches && context.matches.length === find.length;
|
||||
}
|
||||
|
||||
function matchStringMultiLower(
|
||||
str: kdbxweb.KdbxEntryField,
|
||||
find: string[],
|
||||
context: SearchContext
|
||||
): boolean {
|
||||
return matchStringMulti(str, find, context, true);
|
||||
}
|
||||
|
||||
function matchRegex(str: kdbxweb.KdbxEntryField, regex: RegExp): boolean {
|
||||
if (str instanceof kdbxweb.ProtectedValue) {
|
||||
str = str.getText();
|
||||
}
|
||||
return regex.test(str);
|
||||
}
|
||||
|
||||
function matchFields(
|
||||
fields: Map<string, kdbxweb.KdbxEntryField>,
|
||||
adv: AdvancedFilter,
|
||||
compare: (val: kdbxweb.KdbxEntryField) => boolean
|
||||
): boolean {
|
||||
if (adv.user && matchField(fields.get('UserName'), compare)) {
|
||||
return true;
|
||||
}
|
||||
if (adv.url && matchField(fields.get('URL'), compare)) {
|
||||
return true;
|
||||
}
|
||||
if (adv.notes && matchField(fields.get('Notes'), compare)) {
|
||||
return true;
|
||||
}
|
||||
if (adv.pass && matchField(fields.get('Password'), compare)) {
|
||||
return true;
|
||||
}
|
||||
if (adv.title && matchField(fields.get('Title'), compare)) {
|
||||
return true;
|
||||
}
|
||||
if (adv.other || adv.protect) {
|
||||
for (const [field, value] of fields) {
|
||||
if (BuiltInFieldsSet.has(field)) {
|
||||
if (typeof value === 'string') {
|
||||
if (adv.other && matchField(value, compare)) {
|
||||
return true;
|
||||
}
|
||||
} else if (adv.protect && matchField(value, compare)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchField(
|
||||
val: kdbxweb.KdbxEntryField | undefined,
|
||||
compare: (val: kdbxweb.KdbxEntryField) => boolean
|
||||
): boolean {
|
||||
return val ? compare(val) : false;
|
||||
}
|
|
@ -7,4 +7,4 @@ export const BuiltInFields = [
|
|||
'TOTP Seed',
|
||||
'TOTP Settings',
|
||||
'_etm_template_uuid'
|
||||
] as const;
|
||||
];
|
||||
|
|
|
@ -10,7 +10,7 @@ import { Timeouts } from 'const/timeouts';
|
|||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
import { EntryModel } from 'models/entry-model';
|
||||
import { FileInfoModel } from 'models/file-info-model';
|
||||
import { FileModel } from 'models/file-model';
|
||||
import { File } from 'models/file-model';
|
||||
import { GroupModel } from 'models/group-model';
|
||||
import { YubiKeyOtpModel } from 'models/otp-device/yubikey-otp-model';
|
||||
import { Menu } from 'models/menu/menu-model';
|
||||
|
@ -399,7 +399,7 @@ class AppModel {
|
|||
|
||||
createDemoFile() {
|
||||
if (!this.files.getByName('Demo')) {
|
||||
const demoFile = new FileModel({ id: IdGenerator.uuid() });
|
||||
const demoFile = new File({ id: IdGenerator.uuid() });
|
||||
demoFile.openDemo(() => {
|
||||
this.addFile(demoFile);
|
||||
});
|
||||
|
@ -418,7 +418,7 @@ class AppModel {
|
|||
}
|
||||
}
|
||||
}
|
||||
const newFile = new FileModel({ id: IdGenerator.uuid() });
|
||||
const newFile = new File({ id: IdGenerator.uuid() });
|
||||
newFile.create(name, () => {
|
||||
this.addFile(newFile);
|
||||
callback?.(newFile);
|
||||
|
@ -598,7 +598,7 @@ class AppModel {
|
|||
if (!params.keyFileData && fileInfo && fileInfo.keyFileName) {
|
||||
params.keyFileName = fileInfo.keyFileName;
|
||||
if (this.settings.rememberKeyFiles === 'data' && fileInfo.keyFileHash) {
|
||||
params.keyFileData = FileModel.createKeyFileWithHash(fileInfo.keyFileHash);
|
||||
params.keyFileData = File.createKeyFileWithHash(fileInfo.keyFileHash);
|
||||
} else if (this.settings.rememberKeyFiles === 'path' && fileInfo.keyFilePath) {
|
||||
params.keyFilePath = fileInfo.keyFilePath;
|
||||
if (Storage.file.enabled) {
|
||||
|
@ -608,7 +608,7 @@ class AppModel {
|
|||
} else if (params.keyFilePath && !params.keyFileData && !fileInfo) {
|
||||
needLoadKeyFile = true;
|
||||
}
|
||||
const file = new FileModel({
|
||||
const file = new File({
|
||||
id: fileInfo ? fileInfo.id : IdGenerator.uuid(),
|
||||
name: params.name,
|
||||
storage: params.storage,
|
||||
|
@ -672,7 +672,7 @@ class AppModel {
|
|||
importFileWithXml(params, callback) {
|
||||
const logger = new Logger('import', params.name);
|
||||
logger.info('File import request with supplied xml');
|
||||
const file = new FileModel({
|
||||
const file = new File({
|
||||
id: IdGenerator.uuid(),
|
||||
name: params.name,
|
||||
storage: params.storage,
|
||||
|
|
|
@ -1,705 +0,0 @@
|
|||
import * as kdbxweb from 'kdbxweb';
|
||||
import { Model } from 'framework/model';
|
||||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
import { KdbxToHtml } from 'comp/format/kdbx-to-html';
|
||||
import { IconMap } from 'const/icon-map';
|
||||
import { BuiltInFields } from 'const/entry-fields';
|
||||
import { Attachment } from 'models/attachment-model';
|
||||
import { Color } from 'util/data/color';
|
||||
import { Otp } from 'util/data/otp';
|
||||
import { Ranking } from 'util/data/ranking';
|
||||
import { IconUrlFormat } from 'util/formatting/icon-url-format';
|
||||
import { omit } from 'util/fn';
|
||||
import { EntrySearch } from 'util/entry-search';
|
||||
|
||||
const UrlRegex = /^https?:\/\//i;
|
||||
const FieldRefRegex = /^\{REF:([TNPAU])@I:(\w{32})}$/;
|
||||
const FieldRefFields = ['title', 'password', 'user', 'url', 'notes'];
|
||||
const FieldRefIds = { T: 'Title', U: 'UserName', P: 'Password', A: 'URL', N: 'Notes' };
|
||||
const ExtraUrlFieldName = 'KP2A_URL';
|
||||
|
||||
class EntryModel extends Model {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._search = new EntrySearch(this);
|
||||
}
|
||||
|
||||
setEntry(entry, group, file) {
|
||||
this.entry = entry;
|
||||
this.group = group;
|
||||
this.file = file;
|
||||
if (this.uuid === entry.uuid.id) {
|
||||
this._checkUpdatedEntry();
|
||||
}
|
||||
// we cannot calculate field references now because database index has not yet been built
|
||||
this.hasFieldRefs = false;
|
||||
this._fillByEntry();
|
||||
this.hasFieldRefs = true;
|
||||
}
|
||||
|
||||
_fillByEntry() {
|
||||
const entry = this.entry;
|
||||
this.set({ id: this.file.subId(entry.uuid.id), uuid: entry.uuid.id }, { silent: true });
|
||||
this.fileName = this.file.name;
|
||||
this.groupName = this.group.title;
|
||||
this.title = this._getFieldString('Title');
|
||||
this.password = this._getPassword();
|
||||
this.notes = this._getFieldString('Notes');
|
||||
this.url = this._getFieldString('URL');
|
||||
this.displayUrl = this._getDisplayUrl(this._getFieldString('URL'));
|
||||
this.user = this._getFieldString('UserName');
|
||||
this.iconId = entry.icon;
|
||||
this.icon = this._iconFromId(entry.icon);
|
||||
this.tags = entry.tags;
|
||||
this.color = this._colorToModel(entry.bgColor) || this._colorToModel(entry.fgColor);
|
||||
this.fields = this._fieldsToModel();
|
||||
this.attachments = this._attachmentsToModel(entry.binaries);
|
||||
this.created = entry.times.creationTime;
|
||||
this.updated = entry.times.lastModTime;
|
||||
this.expires = entry.times.expires ? entry.times.expiryTime : undefined;
|
||||
this.expired = entry.times.expires && entry.times.expiryTime <= new Date();
|
||||
this.historyLength = entry.history.length;
|
||||
this.titleUserLower = `${this.title}:${this.user}`.toLowerCase();
|
||||
this._buildCustomIcon();
|
||||
this._buildSearchText();
|
||||
this._buildSearchTags();
|
||||
this._buildSearchColor();
|
||||
this._buildAutoType();
|
||||
if (this.hasFieldRefs) {
|
||||
this.resolveFieldReferences();
|
||||
}
|
||||
}
|
||||
|
||||
_getPassword() {
|
||||
const password = this.entry.fields.get('Password') || kdbxweb.ProtectedValue.fromString('');
|
||||
if (!password.isProtected) {
|
||||
return kdbxweb.ProtectedValue.fromString(password);
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
_getFieldString(field) {
|
||||
const val = this.entry.fields.get(field);
|
||||
if (!val) {
|
||||
return '';
|
||||
}
|
||||
if (val.isProtected) {
|
||||
return val.getText();
|
||||
}
|
||||
return val.toString();
|
||||
}
|
||||
|
||||
_checkUpdatedEntry() {
|
||||
if (this.isJustCreated) {
|
||||
this.isJustCreated = false;
|
||||
}
|
||||
if (this.canBeDeleted) {
|
||||
this.canBeDeleted = false;
|
||||
}
|
||||
if (this.unsaved && +this.updated !== +this.entry.times.lastModTime) {
|
||||
this.unsaved = false;
|
||||
}
|
||||
}
|
||||
|
||||
_buildSearchText() {
|
||||
let text = '';
|
||||
for (const value of this.entry.fields.values()) {
|
||||
if (typeof value === 'string') {
|
||||
text += value.toLowerCase() + '\n';
|
||||
}
|
||||
}
|
||||
this.entry.tags.forEach((tag) => {
|
||||
text += tag.toLowerCase() + '\n';
|
||||
});
|
||||
this.attachments.forEach((att) => {
|
||||
text += att.title.toLowerCase() + '\n';
|
||||
});
|
||||
this.searchText = text;
|
||||
}
|
||||
|
||||
_buildCustomIcon() {
|
||||
this.customIcon = null;
|
||||
this.customIconId = null;
|
||||
if (this.entry.customIcon) {
|
||||
this.customIcon = IconUrlFormat.toDataUrl(
|
||||
this.file.db.meta.customIcons.get(this.entry.customIcon.id)?.data
|
||||
);
|
||||
this.customIconId = this.entry.customIcon.toString();
|
||||
}
|
||||
}
|
||||
|
||||
_buildSearchTags() {
|
||||
this.searchTags = this.entry.tags.map((tag) => tag.toLowerCase());
|
||||
}
|
||||
|
||||
_buildSearchColor() {
|
||||
this.searchColor = this.color;
|
||||
}
|
||||
|
||||
_buildAutoType() {
|
||||
this.autoTypeEnabled = this.entry.autoType.enabled;
|
||||
this.autoTypeObfuscation =
|
||||
this.entry.autoType.obfuscation ===
|
||||
kdbxweb.Consts.AutoTypeObfuscationOptions.UseClipboard;
|
||||
this.autoTypeSequence = this.entry.autoType.defaultSequence;
|
||||
this.autoTypeWindows = this.entry.autoType.items.map(this._convertAutoTypeItem);
|
||||
}
|
||||
|
||||
_convertAutoTypeItem(item) {
|
||||
return { window: item.window, sequence: item.keystrokeSequence };
|
||||
}
|
||||
|
||||
_iconFromId(id) {
|
||||
return IconMap[id];
|
||||
}
|
||||
|
||||
_getDisplayUrl(url) {
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
return url.replace(UrlRegex, '');
|
||||
}
|
||||
|
||||
_colorToModel(color) {
|
||||
return color ? Color.getNearest(color) : null;
|
||||
}
|
||||
|
||||
_fieldsToModel() {
|
||||
return omit(this.getAllFields(), BuiltInFields);
|
||||
}
|
||||
|
||||
_attachmentsToModel(binaries) {
|
||||
const att = [];
|
||||
for (let [title, data] of binaries) {
|
||||
if (data && data.ref) {
|
||||
data = data.value;
|
||||
}
|
||||
if (data) {
|
||||
att.push(Attachment.fromAttachment({ data, title }));
|
||||
}
|
||||
}
|
||||
return att;
|
||||
}
|
||||
|
||||
_entryModified() {
|
||||
if (!this.unsaved) {
|
||||
this.unsaved = true;
|
||||
if (this.file.historyMaxItems !== 0) {
|
||||
this.entry.pushHistory();
|
||||
}
|
||||
this.file.setModified();
|
||||
}
|
||||
if (this.isJustCreated) {
|
||||
this.isJustCreated = false;
|
||||
this.file.reload();
|
||||
}
|
||||
this.entry.times.update();
|
||||
}
|
||||
|
||||
setSaved() {
|
||||
if (this.unsaved) {
|
||||
this.unsaved = false;
|
||||
}
|
||||
if (this.canBeDeleted) {
|
||||
this.canBeDeleted = false;
|
||||
}
|
||||
}
|
||||
|
||||
matches(filter) {
|
||||
return this._search.matches(filter);
|
||||
}
|
||||
|
||||
getAllFields() {
|
||||
const fields = {};
|
||||
for (const [key, value] of this.entry.fields) {
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
getHistoryEntriesForSearch() {
|
||||
return this.entry.history;
|
||||
}
|
||||
|
||||
resolveFieldReferences() {
|
||||
this.hasFieldRefs = false;
|
||||
FieldRefFields.forEach((field) => {
|
||||
const fieldValue = this[field];
|
||||
const refValue = this._resolveFieldReference(fieldValue);
|
||||
if (refValue !== undefined) {
|
||||
this[field] = refValue;
|
||||
this.hasFieldRefs = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFieldValue(field) {
|
||||
field = field.toLowerCase();
|
||||
let resolvedField;
|
||||
[...this.entry.fields.keys()].some((entryField) => {
|
||||
if (entryField.toLowerCase() === field) {
|
||||
resolvedField = entryField;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (resolvedField) {
|
||||
let fieldValue = this.entry.fields.get(resolvedField);
|
||||
const refValue = this._resolveFieldReference(fieldValue);
|
||||
if (refValue !== undefined) {
|
||||
fieldValue = refValue;
|
||||
}
|
||||
return fieldValue;
|
||||
}
|
||||
}
|
||||
|
||||
_resolveFieldReference(fieldValue) {
|
||||
if (!fieldValue) {
|
||||
return;
|
||||
}
|
||||
if (fieldValue.isProtected && fieldValue.isFieldReference()) {
|
||||
fieldValue = fieldValue.getText();
|
||||
}
|
||||
if (typeof fieldValue !== 'string') {
|
||||
return;
|
||||
}
|
||||
const match = fieldValue.match(FieldRefRegex);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
return this._getReferenceValue(match[1], match[2]);
|
||||
}
|
||||
|
||||
_getReferenceValue(fieldRefId, idStr) {
|
||||
const id = new Uint8Array(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
id[i] = parseInt(idStr.substr(i * 2, 2), 16);
|
||||
}
|
||||
const uuid = new kdbxweb.KdbxUuid(id);
|
||||
const entry = this.file.getEntry(this.file.subId(uuid.id));
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
return entry.entry.fields.get(FieldRefIds[fieldRefId]);
|
||||
}
|
||||
|
||||
setColor(color) {
|
||||
this._entryModified();
|
||||
this.entry.bgColor = Color.getKnownBgColor(color);
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
setIcon(iconId) {
|
||||
this._entryModified();
|
||||
this.entry.icon = iconId;
|
||||
this.entry.customIcon = undefined;
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
setCustomIcon(customIconId) {
|
||||
this._entryModified();
|
||||
this.entry.customIcon = new kdbxweb.KdbxUuid(customIconId);
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
setExpires(dt) {
|
||||
this._entryModified();
|
||||
this.entry.times.expiryTime = dt instanceof Date ? dt : undefined;
|
||||
this.entry.times.expires = !!dt;
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
setTags(tags) {
|
||||
this._entryModified();
|
||||
this.entry.tags = tags;
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
renameTag(from, to) {
|
||||
const ix = this.entry.tags.findIndex((tag) => tag.toLowerCase() === from.toLowerCase());
|
||||
if (ix < 0) {
|
||||
return;
|
||||
}
|
||||
this._entryModified();
|
||||
this.entry.tags.splice(ix, 1);
|
||||
if (to) {
|
||||
this.entry.tags.push(to);
|
||||
}
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
setField(field, val, allowEmpty) {
|
||||
const hasValue = val && (typeof val === 'string' || (val.isProtected && val.byteLength));
|
||||
if (hasValue || allowEmpty || BuiltInFields.indexOf(field) >= 0) {
|
||||
this._entryModified();
|
||||
val = this.sanitizeFieldValue(val);
|
||||
this.entry.fields.set(field, val);
|
||||
} else if (this.entry.fields.has(field)) {
|
||||
this._entryModified();
|
||||
this.entry.fields.delete(field);
|
||||
}
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
sanitizeFieldValue(val) {
|
||||
if (val && !val.isProtected) {
|
||||
// https://github.com/keeweb/keeweb/issues/910
|
||||
// eslint-disable-next-line no-control-regex
|
||||
val = val.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\uFFF0-\uFFFF]/g, '');
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
hasField(field) {
|
||||
return this.entry.fields.has(field);
|
||||
}
|
||||
|
||||
addAttachment(name, data) {
|
||||
this._entryModified();
|
||||
return this.file.db.createBinary(data).then((binaryRef) => {
|
||||
this.entry.binaries.set(name, binaryRef);
|
||||
this._fillByEntry();
|
||||
});
|
||||
}
|
||||
|
||||
removeAttachment(name) {
|
||||
this._entryModified();
|
||||
this.entry.binaries.delete(name);
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
getHistory() {
|
||||
const history = this.entry.history.map(function (rec) {
|
||||
return EntryModel.fromEntry(rec, this.group, this.file);
|
||||
}, this);
|
||||
history.push(this);
|
||||
history.sort((x, y) => x.updated - y.updated);
|
||||
return history;
|
||||
}
|
||||
|
||||
deleteHistory(historyEntry) {
|
||||
const ix = this.entry.history.indexOf(historyEntry);
|
||||
if (ix >= 0) {
|
||||
this.entry.removeHistory(ix);
|
||||
this.file.setModified();
|
||||
}
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
revertToHistoryState(historyEntry) {
|
||||
const ix = this.entry.history.indexOf(historyEntry);
|
||||
if (ix < 0) {
|
||||
return;
|
||||
}
|
||||
this.entry.pushHistory();
|
||||
this.unsaved = true;
|
||||
this.file.setModified();
|
||||
this.entry.fields = new Map();
|
||||
this.entry.binaries = new Map();
|
||||
this.entry.copyFrom(historyEntry);
|
||||
this._entryModified();
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
discardUnsaved() {
|
||||
if (this.unsaved && this.entry.history.length) {
|
||||
this.unsaved = false;
|
||||
const historyEntry = this.entry.history[this.entry.history.length - 1];
|
||||
this.entry.removeHistory(this.entry.history.length - 1);
|
||||
this.entry.fields = new Map();
|
||||
this.entry.binaries = new Map();
|
||||
this.entry.copyFrom(historyEntry);
|
||||
this._fillByEntry();
|
||||
}
|
||||
}
|
||||
|
||||
moveToTrash() {
|
||||
this.file.setModified();
|
||||
if (this.isJustCreated) {
|
||||
this.isJustCreated = false;
|
||||
}
|
||||
this.file.db.remove(this.entry);
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
deleteFromTrash() {
|
||||
this.file.setModified();
|
||||
this.file.db.move(this.entry, null);
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
removeWithoutHistory() {
|
||||
if (this.canBeDeleted) {
|
||||
const ix = this.group.group.entries.indexOf(this.entry);
|
||||
if (ix >= 0) {
|
||||
this.group.group.entries.splice(ix, 1);
|
||||
}
|
||||
this.file.reload();
|
||||
}
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.file.setModified();
|
||||
this.file.db.move(this.entry, null);
|
||||
this.file.reload();
|
||||
return this.entry;
|
||||
}
|
||||
|
||||
moveToFile(file) {
|
||||
if (this.canBeDeleted) {
|
||||
this.removeWithoutHistory();
|
||||
this.group = file.groups[0];
|
||||
this.file = file;
|
||||
this._fillByEntry();
|
||||
this.entry.times.update();
|
||||
this.group.group.entries.push(this.entry);
|
||||
this.group.addEntry(this);
|
||||
this.isJustCreated = true;
|
||||
this.unsaved = true;
|
||||
this.file.setModified();
|
||||
}
|
||||
}
|
||||
|
||||
initOtpGenerator() {
|
||||
let otpUrl;
|
||||
if (this.fields.otp) {
|
||||
otpUrl = this.fields.otp;
|
||||
if (otpUrl.isProtected) {
|
||||
otpUrl = otpUrl.getText();
|
||||
}
|
||||
if (Otp.isSecret(otpUrl.replace(/\s/g, ''))) {
|
||||
otpUrl = Otp.makeUrl(otpUrl.replace(/\s/g, '').toUpperCase());
|
||||
} else if (otpUrl.toLowerCase().lastIndexOf('otpauth:', 0) !== 0) {
|
||||
// KeeOTP plugin format
|
||||
const args = {};
|
||||
otpUrl.split('&').forEach((part) => {
|
||||
const parts = part.split('=', 2);
|
||||
args[parts[0]] = decodeURIComponent(parts[1]).replace(/=/g, '');
|
||||
});
|
||||
if (args.key) {
|
||||
otpUrl = Otp.makeUrl(args.key, args.step, args.size);
|
||||
}
|
||||
}
|
||||
} else if (this.entry.fields.get('TOTP Seed')) {
|
||||
// TrayTOTP plugin format
|
||||
let secret = this.entry.fields.get('TOTP Seed');
|
||||
if (secret.isProtected) {
|
||||
secret = secret.getText();
|
||||
}
|
||||
if (secret) {
|
||||
let settings = this.entry.fields.get('TOTP Settings');
|
||||
if (settings && settings.isProtected) {
|
||||
settings = settings.getText();
|
||||
}
|
||||
let period, digits;
|
||||
if (settings) {
|
||||
settings = settings.split(';');
|
||||
if (settings.length > 0 && settings[0] > 0) {
|
||||
period = settings[0];
|
||||
}
|
||||
if (settings.length > 1 && settings[1] > 0) {
|
||||
digits = settings[1];
|
||||
}
|
||||
}
|
||||
otpUrl = Otp.makeUrl(secret, period, digits);
|
||||
this.fields.otp = kdbxweb.ProtectedValue.fromString(otpUrl);
|
||||
}
|
||||
}
|
||||
if (otpUrl) {
|
||||
if (this.otpGenerator && this.otpGenerator.url === otpUrl) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.otpGenerator = Otp.parseUrl(otpUrl);
|
||||
} catch {
|
||||
this.otpGenerator = null;
|
||||
}
|
||||
} else {
|
||||
this.otpGenerator = null;
|
||||
}
|
||||
}
|
||||
|
||||
setOtp(otp) {
|
||||
this.otpGenerator = otp;
|
||||
this.setOtpUrl(otp.url);
|
||||
}
|
||||
|
||||
setOtpUrl(url) {
|
||||
this.setField('otp', url ? kdbxweb.ProtectedValue.fromString(url) : undefined);
|
||||
this.entry.fields.delete('TOTP Seed');
|
||||
this.entry.fields.delete('TOTP Settings');
|
||||
}
|
||||
|
||||
getEffectiveEnableAutoType() {
|
||||
if (typeof this.entry.autoType.enabled === 'boolean') {
|
||||
return this.entry.autoType.enabled;
|
||||
}
|
||||
return this.group.getEffectiveEnableAutoType();
|
||||
}
|
||||
|
||||
getEffectiveAutoTypeSeq() {
|
||||
return this.entry.autoType.defaultSequence || this.group.getEffectiveAutoTypeSeq();
|
||||
}
|
||||
|
||||
setEnableAutoType(enabled) {
|
||||
this._entryModified();
|
||||
this.entry.autoType.enabled = enabled;
|
||||
this._buildAutoType();
|
||||
}
|
||||
|
||||
setAutoTypeObfuscation(enabled) {
|
||||
this._entryModified();
|
||||
this.entry.autoType.obfuscation = enabled
|
||||
? kdbxweb.Consts.AutoTypeObfuscationOptions.UseClipboard
|
||||
: kdbxweb.Consts.AutoTypeObfuscationOptions.None;
|
||||
this._buildAutoType();
|
||||
}
|
||||
|
||||
setAutoTypeSeq(seq) {
|
||||
this._entryModified();
|
||||
this.entry.autoType.defaultSequence = seq || undefined;
|
||||
this._buildAutoType();
|
||||
}
|
||||
|
||||
getGroupPath() {
|
||||
let group = this.group;
|
||||
const groupPath = [];
|
||||
while (group) {
|
||||
groupPath.unshift(group.title);
|
||||
group = group.parentGroup;
|
||||
}
|
||||
return groupPath;
|
||||
}
|
||||
|
||||
cloneEntry(nameSuffix) {
|
||||
const newEntry = EntryModel.newEntry(this.group, this.file);
|
||||
const uuid = newEntry.entry.uuid;
|
||||
newEntry.entry.copyFrom(this.entry);
|
||||
newEntry.entry.uuid = uuid;
|
||||
newEntry.entry.times.update();
|
||||
newEntry.entry.times.creationTime = newEntry.entry.times.lastModTime;
|
||||
newEntry.entry.fields.set('Title', this.title + nameSuffix);
|
||||
newEntry._fillByEntry();
|
||||
this.file.reload();
|
||||
return newEntry;
|
||||
}
|
||||
|
||||
copyFromTemplate(templateEntry) {
|
||||
const uuid = this.entry.uuid;
|
||||
this.entry.copyFrom(templateEntry.entry);
|
||||
this.entry.uuid = uuid;
|
||||
this.entry.times.update();
|
||||
this.entry.times.creationTime = this.entry.times.lastModTime;
|
||||
this.entry.fields.set('Title', '');
|
||||
this._fillByEntry();
|
||||
}
|
||||
|
||||
getRank(filter) {
|
||||
const searchString = filter.textLower;
|
||||
|
||||
if (!searchString) {
|
||||
// no search string given, so rank all items the same
|
||||
return 0;
|
||||
}
|
||||
|
||||
const checkProtectedFields = filter.advanced && filter.advanced.protect;
|
||||
|
||||
const fieldWeights = {
|
||||
'Title': 10,
|
||||
'URL': 8,
|
||||
'UserName': 5,
|
||||
'Notes': 2
|
||||
};
|
||||
|
||||
const defaultFieldWeight = 2;
|
||||
|
||||
const allFields = Object.keys(fieldWeights).concat(Object.keys(this.fields));
|
||||
|
||||
return allFields.reduce((rank, fieldName) => {
|
||||
const val = this.entry.fields.get(fieldName);
|
||||
if (!val) {
|
||||
return rank;
|
||||
}
|
||||
if (val.isProtected && (!checkProtectedFields || !val.length)) {
|
||||
return rank;
|
||||
}
|
||||
const stringRank = Ranking.getStringRank(searchString, val);
|
||||
const fieldWeight = fieldWeights[fieldName] || defaultFieldWeight;
|
||||
return rank + stringRank * fieldWeight;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
getHtml() {
|
||||
return KdbxToHtml.entryToHtml(this.file.db, this.entry);
|
||||
}
|
||||
|
||||
canCheckPasswordIssues() {
|
||||
return !this.entry.customData?.has('IgnorePwIssues');
|
||||
}
|
||||
|
||||
setIgnorePasswordIssues() {
|
||||
if (!this.entry.customData) {
|
||||
this.entry.customData = new Map();
|
||||
}
|
||||
this.entry.customData.set('IgnorePwIssues', '1');
|
||||
this._entryModified();
|
||||
}
|
||||
|
||||
getNextUrlFieldName() {
|
||||
const takenFields = new Set(
|
||||
[...this.entry.fields.keys()].filter((f) => f.startsWith(ExtraUrlFieldName))
|
||||
);
|
||||
for (let i = 0; ; i++) {
|
||||
const fieldName = i ? `${ExtraUrlFieldName}_${i}` : ExtraUrlFieldName;
|
||||
if (!takenFields.has(fieldName)) {
|
||||
return fieldName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAllUrls() {
|
||||
const urls = this.url ? [this.url] : [];
|
||||
const extraUrls = Object.entries(this.fields)
|
||||
.filter(([field]) => field.startsWith(ExtraUrlFieldName))
|
||||
.map(([, value]) => (value.isProtected ? value.getText() : value))
|
||||
.filter((value) => value);
|
||||
return urls.concat(extraUrls);
|
||||
}
|
||||
|
||||
static fromEntry(entry, group, file) {
|
||||
const model = new EntryModel();
|
||||
model.setEntry(entry, group, file);
|
||||
return model;
|
||||
}
|
||||
|
||||
static newEntry(group, file, opts) {
|
||||
const model = new EntryModel();
|
||||
const entry = file.db.createEntry(group.group);
|
||||
if (AppSettingsModel.useGroupIconForEntries && group.icon && group.iconId) {
|
||||
entry.icon = group.iconId;
|
||||
}
|
||||
if (opts && opts.tag) {
|
||||
entry.tags = [opts.tag];
|
||||
}
|
||||
model.setEntry(entry, group, file);
|
||||
model.entry.times.update();
|
||||
model.unsaved = true;
|
||||
model.isJustCreated = true;
|
||||
model.canBeDeleted = true;
|
||||
group.addEntry(model);
|
||||
file.setModified();
|
||||
return model;
|
||||
}
|
||||
|
||||
static newEntryWithFields(group, fields) {
|
||||
const entry = EntryModel.newEntry(group, group.file);
|
||||
for (const [field, value] of Object.entries(fields)) {
|
||||
entry.setField(field, value);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
EntryModel.defineModelProperties({}, { extensions: true });
|
||||
|
||||
export { EntryModel, ExtraUrlFieldName };
|
|
@ -0,0 +1,795 @@
|
|||
import * as kdbxweb from 'kdbxweb';
|
||||
import { Model } from 'util/model';
|
||||
import { IconMap } from 'const/icon-map';
|
||||
import { BuiltInFields } from 'const/entry-fields';
|
||||
import { Attachment } from 'models/attachment';
|
||||
import { Color } from 'util/data/color';
|
||||
import { Otp } from 'util/data/otp';
|
||||
import { IconUrlFormat } from 'util/formatting/icon-url-format';
|
||||
import { Group } from './group';
|
||||
import { File } from './file';
|
||||
import { isEqual } from 'util/fn';
|
||||
import { AppSettings } from './app-settings';
|
||||
import { Filter } from './filter';
|
||||
import { EntrySearch } from 'comp/search/entry-search';
|
||||
import { Ranking } from 'util/data/ranking';
|
||||
|
||||
const UrlRegex = /^https?:\/\//i;
|
||||
const FieldRefRegex = /^\{REF:([TNPAU])@I:(\w{32})}$/;
|
||||
const FieldRefFields = ['title', 'user', 'url', 'notes'] as const;
|
||||
const FieldRefIds = new Map<string, string>([
|
||||
['T', 'Title'],
|
||||
['U', 'UserName'],
|
||||
['P', 'Password'],
|
||||
['A', 'URL'],
|
||||
['N', 'Notes']
|
||||
]);
|
||||
const FieldRankWeights = new Map<string, number>([
|
||||
['Title', 10],
|
||||
['URL', 8],
|
||||
['UserName', 5],
|
||||
['Notes', 2]
|
||||
]);
|
||||
const ExtraUrlFieldName = 'KP2A_URL';
|
||||
const BuiltInFieldsSet = new Set<string>(BuiltInFields);
|
||||
|
||||
class Entry extends Model {
|
||||
readonly id: string;
|
||||
readonly uuid: string;
|
||||
entry: kdbxweb.KdbxEntry;
|
||||
group: Group;
|
||||
file: File;
|
||||
hasFieldRefs?: boolean;
|
||||
isJustCreated?: boolean;
|
||||
canBeDeleted?: boolean;
|
||||
unsaved?: boolean;
|
||||
created?: Date;
|
||||
updated?: Date;
|
||||
expires?: Date;
|
||||
expired?: boolean;
|
||||
fileName?: string;
|
||||
groupName?: string;
|
||||
title?: string;
|
||||
password?: kdbxweb.ProtectedValue;
|
||||
notes?: string;
|
||||
url?: string;
|
||||
displayUrl?: string;
|
||||
user?: string;
|
||||
iconId?: number;
|
||||
icon?: string;
|
||||
tags?: string[];
|
||||
color?: string;
|
||||
fields?: Map<string, kdbxweb.KdbxEntryField>;
|
||||
attachments?: Attachment[];
|
||||
historyLength?: number;
|
||||
titleUserLower?: string;
|
||||
searchText?: string;
|
||||
searchTags?: string[];
|
||||
searchColor?: string;
|
||||
customIcon?: string;
|
||||
customIconId?: string;
|
||||
autoTypeEnabled?: boolean | null;
|
||||
autoTypeObfuscation?: boolean;
|
||||
autoTypeSequence?: string;
|
||||
otpGenerator?: Otp;
|
||||
backend?: string;
|
||||
|
||||
constructor(entry: kdbxweb.KdbxEntry, group: Group, file: File) {
|
||||
super();
|
||||
|
||||
this.id = file.subId(entry.uuid.id);
|
||||
this.uuid = entry.uuid.id;
|
||||
this.entry = entry;
|
||||
this.group = group;
|
||||
this.file = file;
|
||||
|
||||
this.setEntry(entry, group, file);
|
||||
}
|
||||
|
||||
setEntry(entry: kdbxweb.KdbxEntry, group: Group, file: File): void {
|
||||
if (entry.uuid.id !== this.uuid) {
|
||||
throw new Error('Cannot change entry uuid');
|
||||
}
|
||||
|
||||
this.entry = entry;
|
||||
this.group = group;
|
||||
this.file = file;
|
||||
this.checkUpdatedEntry();
|
||||
|
||||
// we cannot calculate field references now because database index has not yet been built
|
||||
this.fillByEntry({ skipFieldReferences: true });
|
||||
}
|
||||
|
||||
private fillByEntry(opts?: { skipFieldReferences?: boolean }) {
|
||||
const entry = this.entry;
|
||||
this.fileName = this.file.name;
|
||||
this.groupName = this.group.title;
|
||||
this.title = this.getFieldString('Title');
|
||||
this.password = this.getPassword();
|
||||
this.notes = this.getFieldString('Notes');
|
||||
this.url = this.getFieldString('URL');
|
||||
this.displayUrl = this.getDisplayUrl(this.getFieldString('URL'));
|
||||
this.user = this.getFieldString('UserName');
|
||||
this.iconId = entry.icon;
|
||||
this.icon = this.iconFromId(entry.icon);
|
||||
this.tags = entry.tags;
|
||||
this.color = this.colorToModel(entry.bgColor) || this.colorToModel(entry.fgColor);
|
||||
this.fields = this.fieldsToModel();
|
||||
this.attachments = this.attachmentsToModel(entry.binaries);
|
||||
this.created = entry.times.creationTime;
|
||||
this.updated = entry.times.lastModTime;
|
||||
this.expires = entry.times.expires ? entry.times.expiryTime : undefined;
|
||||
this.expired = !!(
|
||||
entry.times.expires &&
|
||||
entry.times.expiryTime &&
|
||||
entry.times.expiryTime <= new Date()
|
||||
);
|
||||
this.historyLength = entry.history.length;
|
||||
this.titleUserLower = `${this.title}:${this.user}`.toLowerCase();
|
||||
this.buildCustomIcon();
|
||||
this.buildSearchText();
|
||||
this.buildSearchTags();
|
||||
this.buildSearchColor();
|
||||
this.buildAutoType();
|
||||
if (!opts?.skipFieldReferences && this.hasFieldRefs !== false) {
|
||||
this.resolveFieldReferences();
|
||||
}
|
||||
}
|
||||
|
||||
private getPassword(): kdbxweb.ProtectedValue {
|
||||
const password = this.entry.fields.get('Password') ?? kdbxweb.ProtectedValue.fromString('');
|
||||
if (password instanceof kdbxweb.ProtectedValue) {
|
||||
return password;
|
||||
}
|
||||
return kdbxweb.ProtectedValue.fromString(password);
|
||||
}
|
||||
|
||||
private getFieldString(field: string): string {
|
||||
const val = this.entry.fields.get(field);
|
||||
if (!val) {
|
||||
return '';
|
||||
}
|
||||
if (val instanceof kdbxweb.ProtectedValue) {
|
||||
return val.getText();
|
||||
}
|
||||
return val.toString();
|
||||
}
|
||||
|
||||
private checkUpdatedEntry() {
|
||||
if (this.isJustCreated) {
|
||||
this.isJustCreated = false;
|
||||
}
|
||||
if (this.canBeDeleted) {
|
||||
this.canBeDeleted = false;
|
||||
}
|
||||
if (this.unsaved && !isEqual(this.updated, this.entry.times.lastModTime)) {
|
||||
this.unsaved = false;
|
||||
}
|
||||
}
|
||||
|
||||
private buildSearchText(): void {
|
||||
let text = '';
|
||||
for (const value of this.entry.fields.values()) {
|
||||
if (typeof value === 'string') {
|
||||
text += value.toLowerCase() + '\n';
|
||||
}
|
||||
}
|
||||
for (const tag of this.entry.tags) {
|
||||
text += tag.toLowerCase() + '\n';
|
||||
}
|
||||
for (const title of this.entry.binaries.keys()) {
|
||||
text += title.toLowerCase() + '\n';
|
||||
}
|
||||
this.searchText = text;
|
||||
}
|
||||
|
||||
private buildCustomIcon(): void {
|
||||
this.customIcon = undefined;
|
||||
this.customIconId = undefined;
|
||||
if (this.entry.customIcon) {
|
||||
const icon = this.file.db.meta.customIcons.get(this.entry.customIcon.id);
|
||||
if (icon) {
|
||||
this.customIcon = IconUrlFormat.toDataUrl(icon.data) ?? undefined;
|
||||
}
|
||||
this.customIconId = this.entry.customIcon.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private buildSearchTags(): void {
|
||||
this.searchTags = this.entry.tags.map((tag) => tag.toLowerCase());
|
||||
}
|
||||
|
||||
private buildSearchColor(): void {
|
||||
this.searchColor = this.color;
|
||||
}
|
||||
|
||||
private buildAutoType(): void {
|
||||
this.autoTypeEnabled = this.entry.autoType.enabled;
|
||||
this.autoTypeObfuscation =
|
||||
this.entry.autoType.obfuscation ===
|
||||
kdbxweb.Consts.AutoTypeObfuscationOptions.UseClipboard;
|
||||
this.autoTypeSequence = this.entry.autoType.defaultSequence;
|
||||
}
|
||||
|
||||
private iconFromId(id: number | undefined): string | undefined {
|
||||
return id === undefined ? undefined : IconMap[id];
|
||||
}
|
||||
|
||||
private getDisplayUrl(url: string): string {
|
||||
if (!url) {
|
||||
return '';
|
||||
}
|
||||
return url.replace(UrlRegex, '');
|
||||
}
|
||||
|
||||
private colorToModel(color: string | undefined): string | undefined {
|
||||
return color ? Color.getNearest(color) : undefined;
|
||||
}
|
||||
|
||||
private fieldsToModel(): Map<string, kdbxweb.KdbxEntryField> {
|
||||
const fields = new Map<string, kdbxweb.KdbxEntryField>();
|
||||
for (const [field, value] of this.entry.fields) {
|
||||
if (!BuiltInFieldsSet.has(field)) {
|
||||
fields.set(field, value);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private attachmentsToModel(
|
||||
binaries: Map<string, kdbxweb.KdbxBinary | kdbxweb.KdbxBinaryWithHash>
|
||||
): Attachment[] {
|
||||
const att: Attachment[] = [];
|
||||
for (const [title, data] of binaries) {
|
||||
att.push(new Attachment(title, data));
|
||||
}
|
||||
return att;
|
||||
}
|
||||
|
||||
private entryModified() {
|
||||
if (!this.unsaved) {
|
||||
this.unsaved = true;
|
||||
if (this.file.historyMaxItems !== 0) {
|
||||
this.entry.pushHistory();
|
||||
}
|
||||
this.file.setModified();
|
||||
}
|
||||
if (this.isJustCreated) {
|
||||
this.isJustCreated = false;
|
||||
this.file.reload();
|
||||
}
|
||||
this.entry.times.update();
|
||||
}
|
||||
|
||||
setSaved(): void {
|
||||
if (this.unsaved) {
|
||||
this.unsaved = false;
|
||||
}
|
||||
if (this.canBeDeleted) {
|
||||
this.canBeDeleted = false;
|
||||
}
|
||||
}
|
||||
|
||||
matches(filter: Filter): boolean {
|
||||
return EntrySearch.matches(this, filter);
|
||||
}
|
||||
|
||||
getAllFields(): Map<string, kdbxweb.KdbxEntryField> {
|
||||
return this.entry.fields;
|
||||
}
|
||||
|
||||
*getAllHistoryEntriesFields(): Generator<Map<string, kdbxweb.KdbxEntryField>> {
|
||||
for (const historyEntry of this.entry.history) {
|
||||
yield historyEntry.fields;
|
||||
}
|
||||
}
|
||||
|
||||
resolveFieldReferences(): void {
|
||||
this.hasFieldRefs = false;
|
||||
FieldRefFields.forEach((field) => {
|
||||
const fieldValue = this[field];
|
||||
const refValue = this.resolveFieldReference(fieldValue);
|
||||
if (refValue !== undefined) {
|
||||
if (refValue instanceof kdbxweb.ProtectedValue) {
|
||||
this[field] = refValue.getText();
|
||||
} else {
|
||||
this[field] = refValue;
|
||||
}
|
||||
this.hasFieldRefs = true;
|
||||
}
|
||||
});
|
||||
|
||||
const refValue = this.resolveFieldReference(this.password);
|
||||
if (refValue !== undefined) {
|
||||
if (refValue instanceof kdbxweb.ProtectedValue) {
|
||||
this.password = refValue;
|
||||
} else {
|
||||
this.password = kdbxweb.ProtectedValue.fromString(refValue);
|
||||
}
|
||||
this.hasFieldRefs = true;
|
||||
}
|
||||
}
|
||||
|
||||
getFieldValue(field: string): kdbxweb.KdbxEntryField | undefined {
|
||||
field = field.toLowerCase();
|
||||
let resolvedField;
|
||||
[...this.entry.fields.keys()].some((entryField) => {
|
||||
if (entryField.toLowerCase() === field) {
|
||||
resolvedField = entryField;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (resolvedField) {
|
||||
let fieldValue = this.entry.fields.get(resolvedField);
|
||||
const refValue = this.resolveFieldReference(fieldValue);
|
||||
if (refValue !== undefined) {
|
||||
fieldValue = refValue;
|
||||
}
|
||||
return fieldValue;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveFieldReference(fieldValue: kdbxweb.KdbxEntryField | undefined) {
|
||||
if (!fieldValue) {
|
||||
return;
|
||||
}
|
||||
if (fieldValue instanceof kdbxweb.ProtectedValue && fieldValue.isFieldReference()) {
|
||||
fieldValue = fieldValue.getText();
|
||||
}
|
||||
if (typeof fieldValue !== 'string') {
|
||||
return;
|
||||
}
|
||||
const match = FieldRefRegex.exec(fieldValue);
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
return this.getReferenceValue(match[1], match[2]);
|
||||
}
|
||||
|
||||
private getReferenceValue(
|
||||
fieldRefId: string,
|
||||
idStr: string
|
||||
): kdbxweb.KdbxEntryField | undefined {
|
||||
const id = new Uint8Array(16);
|
||||
for (let i = 0; i < 16; i++) {
|
||||
id[i] = parseInt(idStr.substr(i * 2, 2), 16);
|
||||
}
|
||||
const uuid = new kdbxweb.KdbxUuid(id);
|
||||
const entry = this.file.getEntry(this.file.subId(uuid.id));
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
const refField = FieldRefIds.get(fieldRefId);
|
||||
if (!refField) {
|
||||
return undefined;
|
||||
}
|
||||
return entry.entry.fields.get(refField);
|
||||
}
|
||||
|
||||
setColor(color: string): void {
|
||||
this.entryModified();
|
||||
this.entry.bgColor = Color.getKnownBgColor(color);
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
setIcon(iconId: number): void {
|
||||
this.entryModified();
|
||||
this.entry.icon = iconId;
|
||||
this.entry.customIcon = undefined;
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
setCustomIcon(customIconId: string): void {
|
||||
this.entryModified();
|
||||
this.entry.customIcon = new kdbxweb.KdbxUuid(customIconId);
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
setExpires(dt: Date | undefined): void {
|
||||
this.entryModified();
|
||||
this.entry.times.expiryTime = dt;
|
||||
this.entry.times.expires = !!dt;
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
setTags(tags: string[]): void {
|
||||
this.entryModified();
|
||||
this.entry.tags = tags;
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
renameTag(from: string, to: string): void {
|
||||
const ix = this.entry.tags.findIndex((tag) => tag.toLowerCase() === from.toLowerCase());
|
||||
if (ix < 0) {
|
||||
return;
|
||||
}
|
||||
this.entryModified();
|
||||
this.entry.tags.splice(ix, 1);
|
||||
if (to) {
|
||||
this.entry.tags.push(to);
|
||||
}
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
setField(field: string, val: kdbxweb.KdbxEntryField | undefined, allowEmpty?: boolean): void {
|
||||
const hasValue = val && (typeof val === 'string' || (val.isProtected && val.byteLength));
|
||||
if (hasValue || allowEmpty || BuiltInFields.indexOf(field) >= 0) {
|
||||
this.entryModified();
|
||||
if (typeof val === 'string') {
|
||||
val = this.sanitizeFieldValue(val);
|
||||
}
|
||||
if (val === undefined) {
|
||||
val = '';
|
||||
}
|
||||
this.entry.fields.set(field, val);
|
||||
} else if (this.entry.fields.has(field)) {
|
||||
this.entryModified();
|
||||
this.entry.fields.delete(field);
|
||||
}
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
private sanitizeFieldValue(val: string): string {
|
||||
// https://github.com/keeweb/keeweb/issues/910
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return val.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\uFFF0-\uFFFF]/g, '');
|
||||
}
|
||||
|
||||
hasField(field: string): boolean {
|
||||
return this.entry.fields.has(field);
|
||||
}
|
||||
|
||||
async addAttachment(name: string, data: ArrayBuffer): Promise<void> {
|
||||
this.entryModified();
|
||||
const binaryRef = await this.file.db.createBinary(data);
|
||||
this.entry.binaries.set(name, binaryRef);
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
removeAttachment(name: string): void {
|
||||
this.entryModified();
|
||||
this.entry.binaries.delete(name);
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
getHistory(): Entry[] {
|
||||
const history = this.entry.history.map((rec) => new Entry(rec, this.group, this.file));
|
||||
history.push(this);
|
||||
history.sort((x, y) => (x.updated?.getTime() ?? 0) - (y.updated?.getTime() ?? 0));
|
||||
return history;
|
||||
}
|
||||
|
||||
deleteHistory(historyEntry: kdbxweb.KdbxEntry): void {
|
||||
const ix = this.entry.history.indexOf(historyEntry);
|
||||
if (ix >= 0) {
|
||||
this.entry.removeHistory(ix);
|
||||
this.file.setModified();
|
||||
}
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
revertToHistoryState(historyEntry: kdbxweb.KdbxEntry): void {
|
||||
const ix = this.entry.history.indexOf(historyEntry);
|
||||
if (ix < 0) {
|
||||
return;
|
||||
}
|
||||
this.entry.pushHistory();
|
||||
this.unsaved = true;
|
||||
this.file.setModified();
|
||||
this.entry.fields = new Map<string, kdbxweb.KdbxEntryField>();
|
||||
this.entry.binaries = new Map<string, kdbxweb.KdbxBinary | kdbxweb.KdbxBinaryWithHash>();
|
||||
this.entry.copyFrom(historyEntry);
|
||||
this.entryModified();
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
discardUnsaved(): void {
|
||||
if (this.unsaved && this.entry.history.length) {
|
||||
this.unsaved = false;
|
||||
const historyEntry = this.entry.history[this.entry.history.length - 1];
|
||||
this.entry.removeHistory(this.entry.history.length - 1);
|
||||
this.entry.fields = new Map<string, kdbxweb.KdbxEntryField>();
|
||||
this.entry.binaries = new Map<
|
||||
string,
|
||||
kdbxweb.KdbxBinary | kdbxweb.KdbxBinaryWithHash
|
||||
>();
|
||||
this.entry.copyFrom(historyEntry);
|
||||
this.fillByEntry();
|
||||
}
|
||||
}
|
||||
|
||||
moveToTrash(): void {
|
||||
this.file.setModified();
|
||||
if (this.isJustCreated) {
|
||||
this.isJustCreated = false;
|
||||
}
|
||||
this.file.db.remove(this.entry);
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
deleteFromTrash(): void {
|
||||
this.file.setModified();
|
||||
this.file.db.move(this.entry, null);
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
removeWithoutHistory(): void {
|
||||
if (this.canBeDeleted) {
|
||||
const ix = this.group.group.entries.indexOf(this.entry);
|
||||
if (ix >= 0) {
|
||||
this.group.group.entries.splice(ix, 1);
|
||||
}
|
||||
this.file.reload();
|
||||
}
|
||||
}
|
||||
|
||||
detach(): kdbxweb.KdbxEntry {
|
||||
this.file.setModified();
|
||||
this.file.db.move(this.entry, null);
|
||||
this.file.reload();
|
||||
return this.entry;
|
||||
}
|
||||
|
||||
moveToFile(file: File): void {
|
||||
if (this.canBeDeleted) {
|
||||
this.removeWithoutHistory();
|
||||
this.group = file.groups[0];
|
||||
this.file = file;
|
||||
this.fillByEntry();
|
||||
this.entry.times.update();
|
||||
this.group.group.entries.push(this.entry);
|
||||
this.group.addEntry(this);
|
||||
this.isJustCreated = true;
|
||||
this.unsaved = true;
|
||||
this.file.setModified();
|
||||
}
|
||||
}
|
||||
|
||||
initOtpGenerator(): void {
|
||||
let otpUrl = this.fields?.get('otp');
|
||||
if (otpUrl) {
|
||||
if (otpUrl instanceof kdbxweb.ProtectedValue) {
|
||||
otpUrl = otpUrl.getText();
|
||||
}
|
||||
if (Otp.isSecret(otpUrl.replace(/\s/g, ''))) {
|
||||
otpUrl = Otp.makeUrl(otpUrl.replace(/\s/g, '').toUpperCase());
|
||||
} else if (otpUrl.toLowerCase().lastIndexOf('otpauth:', 0) !== 0) {
|
||||
// KeeOTP plugin format
|
||||
let key: string | undefined;
|
||||
let step: number | undefined;
|
||||
let size: number | undefined;
|
||||
otpUrl.split('&').forEach((part) => {
|
||||
const parts = part.split('=', 2);
|
||||
const val = decodeURIComponent(parts[1]).replace(/=/g, '');
|
||||
switch (parts[0]) {
|
||||
case 'key':
|
||||
key = val;
|
||||
break;
|
||||
case 'step':
|
||||
step = +val || undefined;
|
||||
break;
|
||||
case 'size':
|
||||
size = +val || undefined;
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (key) {
|
||||
otpUrl = Otp.makeUrl(key, step, size);
|
||||
}
|
||||
}
|
||||
} else if (this.entry.fields.get('TOTP Seed')) {
|
||||
// TrayTOTP plugin format
|
||||
let secret = this.entry.fields.get('TOTP Seed');
|
||||
if (secret instanceof kdbxweb.ProtectedValue) {
|
||||
secret = secret.getText();
|
||||
}
|
||||
if (secret) {
|
||||
let settings = this.entry.fields.get('TOTP Settings');
|
||||
if (settings && settings instanceof kdbxweb.ProtectedValue) {
|
||||
settings = settings.getText();
|
||||
}
|
||||
let period: number | undefined;
|
||||
let digits: number | undefined;
|
||||
if (settings) {
|
||||
const parts = settings.split(';');
|
||||
if (parts.length > 0 && parts[0]) {
|
||||
period = +parts[0] ?? undefined;
|
||||
}
|
||||
if (parts.length > 1 && parts[1]) {
|
||||
digits = +parts[1] ?? undefined;
|
||||
}
|
||||
}
|
||||
otpUrl = Otp.makeUrl(secret, period, digits);
|
||||
this.fields?.set('otp', kdbxweb.ProtectedValue.fromString(otpUrl));
|
||||
}
|
||||
}
|
||||
if (otpUrl) {
|
||||
if (this.otpGenerator && this.otpGenerator.url === otpUrl) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.otpGenerator = Otp.parseUrl(otpUrl);
|
||||
} catch {
|
||||
this.otpGenerator = undefined;
|
||||
}
|
||||
} else {
|
||||
this.otpGenerator = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
setOtp(otp: Otp): void {
|
||||
this.otpGenerator = otp;
|
||||
this.setOtpUrl(otp.url);
|
||||
}
|
||||
|
||||
setOtpUrl(url: string): void {
|
||||
this.setField('otp', url ? kdbxweb.ProtectedValue.fromString(url) : undefined);
|
||||
this.entry.fields.delete('TOTP Seed');
|
||||
this.entry.fields.delete('TOTP Settings');
|
||||
}
|
||||
|
||||
getEffectiveEnableAutoType(): boolean {
|
||||
if (typeof this.entry.autoType.enabled === 'boolean') {
|
||||
return this.entry.autoType.enabled;
|
||||
}
|
||||
return this.group.getEffectiveEnableAutoType();
|
||||
}
|
||||
|
||||
getEffectiveAutoTypeSeq(): string {
|
||||
return this.entry.autoType.defaultSequence || this.group.getEffectiveAutoTypeSeq();
|
||||
}
|
||||
|
||||
setEnableAutoType(enabled: boolean): void {
|
||||
this.entryModified();
|
||||
this.entry.autoType.enabled = enabled;
|
||||
this.buildAutoType();
|
||||
}
|
||||
|
||||
setAutoTypeObfuscation(enabled: boolean): void {
|
||||
this.entryModified();
|
||||
this.entry.autoType.obfuscation = enabled
|
||||
? kdbxweb.Consts.AutoTypeObfuscationOptions.UseClipboard
|
||||
: kdbxweb.Consts.AutoTypeObfuscationOptions.None;
|
||||
this.buildAutoType();
|
||||
}
|
||||
|
||||
setAutoTypeSeq(seq: string): void {
|
||||
this.entryModified();
|
||||
this.entry.autoType.defaultSequence = seq || undefined;
|
||||
this.buildAutoType();
|
||||
}
|
||||
|
||||
getGroupPath(): string[] {
|
||||
let group: Group | undefined = this.group;
|
||||
const groupPath: string[] = [];
|
||||
while (group?.title) {
|
||||
groupPath.unshift(group.title);
|
||||
group = group.parentGroup;
|
||||
}
|
||||
return groupPath;
|
||||
}
|
||||
|
||||
cloneEntry(nameSuffix: string): Entry {
|
||||
const newEntry = Entry.newEntry(this.group, this.file);
|
||||
const uuid = newEntry.entry.uuid;
|
||||
newEntry.entry.copyFrom(this.entry);
|
||||
newEntry.entry.uuid = uuid;
|
||||
newEntry.entry.times.update();
|
||||
newEntry.entry.times.creationTime = newEntry.entry.times.lastModTime;
|
||||
newEntry.entry.fields.set('Title', `${this.title || ''}${nameSuffix}`);
|
||||
newEntry.fillByEntry();
|
||||
this.file.reload();
|
||||
return newEntry;
|
||||
}
|
||||
|
||||
copyFromTemplate(templateEntry: Entry): void {
|
||||
const uuid = this.entry.uuid;
|
||||
this.entry.copyFrom(templateEntry.entry);
|
||||
this.entry.uuid = uuid;
|
||||
this.entry.times.update();
|
||||
this.entry.times.creationTime = this.entry.times.lastModTime;
|
||||
this.entry.fields.set('Title', '');
|
||||
this.fillByEntry();
|
||||
}
|
||||
|
||||
getRank(filter: Filter): number {
|
||||
const searchString = filter.textLower;
|
||||
|
||||
if (!searchString) {
|
||||
// no search string given, so rank all items the same
|
||||
return 0;
|
||||
}
|
||||
|
||||
const checkProtectedFields = filter.advanced && filter.advanced.protect;
|
||||
|
||||
const defaultFieldWeight = 2;
|
||||
|
||||
let rank = 0;
|
||||
for (const [field, val] of this.entry.fields) {
|
||||
if (!val) {
|
||||
continue;
|
||||
}
|
||||
if (val instanceof kdbxweb.ProtectedValue && (!checkProtectedFields || !val.length)) {
|
||||
continue;
|
||||
}
|
||||
const stringRank = Ranking.getStringRank(searchString, val);
|
||||
const fieldWeight = FieldRankWeights.get(field) ?? defaultFieldWeight;
|
||||
rank += stringRank * fieldWeight;
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
canCheckPasswordIssues(): boolean {
|
||||
if (typeof this.entry.qualityCheck === 'boolean') {
|
||||
return this.entry.qualityCheck;
|
||||
}
|
||||
return !this.entry.customData?.has('IgnorePwIssues');
|
||||
}
|
||||
|
||||
setIgnorePasswordIssues(): void {
|
||||
if (this.file.db.versionIsAtLeast(4, 1)) {
|
||||
this.entry.qualityCheck = true;
|
||||
} else {
|
||||
if (!this.entry.customData) {
|
||||
this.entry.customData = new Map();
|
||||
}
|
||||
this.entry.customData.set('IgnorePwIssues', { value: '1' });
|
||||
}
|
||||
this.entryModified();
|
||||
}
|
||||
|
||||
getNextUrlFieldName(): string {
|
||||
const takenFields = new Set(
|
||||
[...this.entry.fields.keys()].filter((f) => f.startsWith(ExtraUrlFieldName))
|
||||
);
|
||||
for (let i = 0; ; i++) {
|
||||
const fieldName = i ? `${ExtraUrlFieldName}_${i}` : ExtraUrlFieldName;
|
||||
if (!takenFields.has(fieldName)) {
|
||||
return fieldName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getAllUrls(): string[] {
|
||||
const urls = this.url ? [this.url] : [];
|
||||
for (const [field, value] of this.fields || []) {
|
||||
if (field.startsWith(ExtraUrlFieldName)) {
|
||||
const url = value instanceof kdbxweb.ProtectedValue ? value.getText() : value;
|
||||
if (url) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
static newEntry(group: Group, file: File, opts?: { tag?: string }): Entry {
|
||||
const entry = file.db.createEntry(group.group);
|
||||
const model = new Entry(entry, group, file);
|
||||
if (AppSettings.useGroupIconForEntries && group.icon && group.iconId) {
|
||||
entry.icon = group.iconId;
|
||||
}
|
||||
if (opts?.tag) {
|
||||
entry.tags = [opts.tag];
|
||||
}
|
||||
model.setEntry(entry, group, file);
|
||||
model.entry.times.update();
|
||||
model.unsaved = true;
|
||||
model.isJustCreated = true;
|
||||
model.canBeDeleted = true;
|
||||
group.addEntry(model);
|
||||
file.setModified();
|
||||
return model;
|
||||
}
|
||||
|
||||
static newEntryWithFields(group: Group, fields: Map<string, kdbxweb.KdbxEntryField>): Entry {
|
||||
const entry = Entry.newEntry(group, group.file);
|
||||
for (const [field, value] of fields) {
|
||||
entry.setField(field, value);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
export { Entry, ExtraUrlFieldName };
|
|
@ -1,776 +0,0 @@
|
|||
import * as kdbxweb from 'kdbxweb';
|
||||
import demoFileData from 'demo.kdbx';
|
||||
import { Model } from 'framework/model';
|
||||
import { Events } from 'framework/events';
|
||||
import { GroupCollection } from 'collections/group-collection';
|
||||
import { KdbxToHtml } from 'comp/format/kdbx-to-html';
|
||||
import { GroupModel } from 'models/group-model';
|
||||
import { AppSettingsModel } from 'models/app-settings-model';
|
||||
import { IconUrlFormat } from 'util/formatting/icon-url-format';
|
||||
import { Logger } from 'util/logger';
|
||||
import { Locale } from 'util/locale';
|
||||
import { StringFormat } from 'util/formatting/string-format';
|
||||
import { ChalRespCalculator } from 'comp/app/chal-resp-calculator';
|
||||
|
||||
const logger = new Logger('file');
|
||||
|
||||
class FileModel extends Model {
|
||||
constructor(data) {
|
||||
super({
|
||||
entryMap: {},
|
||||
groupMap: {},
|
||||
...data
|
||||
});
|
||||
}
|
||||
|
||||
open(password, fileData, keyFileData, callback) {
|
||||
try {
|
||||
const challengeResponse = ChalRespCalculator.build(this.chalResp);
|
||||
const credentials = new kdbxweb.Credentials(password, keyFileData, challengeResponse);
|
||||
const ts = logger.ts();
|
||||
|
||||
kdbxweb.Kdbx.load(fileData, credentials)
|
||||
.then((db) => {
|
||||
this.db = db;
|
||||
})
|
||||
.then(() => {
|
||||
this.readModel();
|
||||
this.setOpenFile({ passwordLength: password ? password.textLength : 0 });
|
||||
if (keyFileData) {
|
||||
kdbxweb.ByteUtils.zeroBuffer(keyFileData);
|
||||
}
|
||||
logger.info(
|
||||
'Opened file ' +
|
||||
this.name +
|
||||
': ' +
|
||||
logger.ts(ts) +
|
||||
', ' +
|
||||
this.kdfArgsToString(this.db.header) +
|
||||
', ' +
|
||||
Math.round(fileData.byteLength / 1024) +
|
||||
' kB'
|
||||
);
|
||||
callback();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (
|
||||
err.code === kdbxweb.Consts.ErrorCodes.InvalidKey &&
|
||||
password &&
|
||||
!password.byteLength
|
||||
) {
|
||||
logger.info(
|
||||
'Error opening file with empty password, try to open with null password'
|
||||
);
|
||||
return this.open(null, fileData, keyFileData, callback);
|
||||
}
|
||||
logger.error('Error opening file', err.code, err.message, err);
|
||||
callback(err);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Error opening file', e, e.code, e.message, e);
|
||||
callback(e);
|
||||
}
|
||||
}
|
||||
|
||||
kdfArgsToString(header) {
|
||||
if (header.kdfParameters) {
|
||||
return header.kdfParameters
|
||||
.keys()
|
||||
.map((key) => {
|
||||
const val = header.kdfParameters.get(key);
|
||||
if (val instanceof ArrayBuffer) {
|
||||
return undefined;
|
||||
}
|
||||
return key + '=' + val;
|
||||
})
|
||||
.filter((p) => p)
|
||||
.join('&');
|
||||
} else if (header.keyEncryptionRounds) {
|
||||
return header.keyEncryptionRounds + ' rounds';
|
||||
} else {
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
create(name, callback) {
|
||||
const password = kdbxweb.ProtectedValue.fromString('');
|
||||
const credentials = new kdbxweb.Credentials(password);
|
||||
this.db = kdbxweb.Kdbx.create(credentials, name);
|
||||
this.name = name;
|
||||
this.readModel();
|
||||
this.set({ active: true, created: true, name });
|
||||
callback();
|
||||
}
|
||||
|
||||
importWithXml(fileXml, callback) {
|
||||
try {
|
||||
const ts = logger.ts();
|
||||
const password = kdbxweb.ProtectedValue.fromString('');
|
||||
const credentials = new kdbxweb.Credentials(password);
|
||||
kdbxweb.Kdbx.loadXml(fileXml, credentials)
|
||||
.then((db) => {
|
||||
this.db = db;
|
||||
})
|
||||
.then(() => {
|
||||
this.readModel();
|
||||
this.set({ active: true, created: true });
|
||||
logger.info('Imported file ' + this.name + ': ' + logger.ts(ts));
|
||||
callback();
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Error importing file', err.code, err.message, err);
|
||||
callback(err);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Error importing file', e, e.code, e.message, e);
|
||||
callback(e);
|
||||
}
|
||||
}
|
||||
|
||||
openDemo(callback) {
|
||||
const password = kdbxweb.ProtectedValue.fromString('demo');
|
||||
const credentials = new kdbxweb.Credentials(password);
|
||||
const demoFile = kdbxweb.ByteUtils.arrayToBuffer(
|
||||
kdbxweb.ByteUtils.base64ToBytes(demoFileData)
|
||||
);
|
||||
kdbxweb.Kdbx.load(demoFile, credentials)
|
||||
.then((db) => {
|
||||
this.db = db;
|
||||
})
|
||||
.then(() => {
|
||||
this.name = 'Demo';
|
||||
this.readModel();
|
||||
this.setOpenFile({ passwordLength: 4, demo: true });
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
setOpenFile(props) {
|
||||
this.set({
|
||||
...props,
|
||||
active: true,
|
||||
oldKeyFileName: this.keyFileName,
|
||||
oldPasswordLength: props.passwordLength,
|
||||
passwordChanged: false,
|
||||
keyFileChanged: false
|
||||
});
|
||||
this.oldPasswordHash = this.db.credentials.passwordHash;
|
||||
this.oldKeyFileHash = this.db.credentials.keyFileHash;
|
||||
this.oldKeyChangeDate = this.db.meta.keyChanged;
|
||||
}
|
||||
|
||||
readModel() {
|
||||
const groups = new GroupCollection();
|
||||
this.set(
|
||||
{
|
||||
uuid: this.db.getDefaultGroup().uuid.toString(),
|
||||
groups,
|
||||
formatVersion: this.db.header.versionMajor,
|
||||
defaultUser: this.db.meta.defaultUser,
|
||||
recycleBinEnabled: this.db.meta.recycleBinEnabled,
|
||||
historyMaxItems: this.db.meta.historyMaxItems,
|
||||
historyMaxSize: this.db.meta.historyMaxSize,
|
||||
keyEncryptionRounds: this.db.header.keyEncryptionRounds,
|
||||
keyChangeForce: this.db.meta.keyChangeForce,
|
||||
kdfName: this.readKdfName(),
|
||||
kdfParameters: this.readKdfParams()
|
||||
},
|
||||
{ silent: true }
|
||||
);
|
||||
this.db.groups.forEach(function (group) {
|
||||
let groupModel = this.getGroup(this.subId(group.uuid.id));
|
||||
if (groupModel) {
|
||||
groupModel.setGroup(group, this);
|
||||
} else {
|
||||
groupModel = GroupModel.fromGroup(group, this);
|
||||
}
|
||||
groups.push(groupModel);
|
||||
}, this);
|
||||
this.buildObjectMap();
|
||||
this.resolveFieldReferences();
|
||||
}
|
||||
|
||||
readKdfName() {
|
||||
if (this.db.header.versionMajor === 4 && this.db.header.kdfParameters) {
|
||||
const kdfParameters = this.db.header.kdfParameters;
|
||||
let uuid = kdfParameters.get('$UUID');
|
||||
if (uuid) {
|
||||
uuid = kdbxweb.ByteUtils.bytesToBase64(uuid);
|
||||
switch (uuid) {
|
||||
case kdbxweb.Consts.KdfId.Argon2d:
|
||||
return 'Argon2d';
|
||||
case kdbxweb.Consts.KdfId.Argon2id:
|
||||
return 'Argon2id';
|
||||
case kdbxweb.Consts.KdfId.Aes:
|
||||
return 'Aes';
|
||||
}
|
||||
}
|
||||
return 'Unknown';
|
||||
} else {
|
||||
return 'Aes';
|
||||
}
|
||||
}
|
||||
|
||||
readKdfParams() {
|
||||
const kdfParameters = this.db.header.kdfParameters;
|
||||
if (!kdfParameters) {
|
||||
return undefined;
|
||||
}
|
||||
let uuid = kdfParameters.get('$UUID');
|
||||
if (!uuid) {
|
||||
return undefined;
|
||||
}
|
||||
uuid = kdbxweb.ByteUtils.bytesToBase64(uuid);
|
||||
switch (uuid) {
|
||||
case kdbxweb.Consts.KdfId.Argon2d:
|
||||
case kdbxweb.Consts.KdfId.Argon2id:
|
||||
return {
|
||||
parallelism: kdfParameters.get('P').valueOf(),
|
||||
iterations: kdfParameters.get('I').valueOf(),
|
||||
memory: kdfParameters.get('M').valueOf()
|
||||
};
|
||||
case kdbxweb.Consts.KdfId.Aes:
|
||||
return {
|
||||
rounds: kdfParameters.get('R').valueOf()
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
subId(id) {
|
||||
return this.id + ':' + id;
|
||||
}
|
||||
|
||||
buildObjectMap() {
|
||||
const entryMap = {};
|
||||
const groupMap = {};
|
||||
this.forEachGroup(
|
||||
(group) => {
|
||||
groupMap[group.id] = group;
|
||||
group.forEachOwnEntry(null, (entry) => {
|
||||
entryMap[entry.id] = entry;
|
||||
});
|
||||
},
|
||||
{ includeDisabled: true }
|
||||
);
|
||||
this.entryMap = entryMap;
|
||||
this.groupMap = groupMap;
|
||||
}
|
||||
|
||||
resolveFieldReferences() {
|
||||
const entryMap = this.entryMap;
|
||||
Object.keys(entryMap).forEach((e) => {
|
||||
entryMap[e].resolveFieldReferences();
|
||||
});
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.buildObjectMap();
|
||||
this.readModel();
|
||||
this.emit('reload', this);
|
||||
}
|
||||
|
||||
mergeOrUpdate(fileData, remoteKey, callback) {
|
||||
let credentials;
|
||||
let credentialsPromise = Promise.resolve();
|
||||
if (remoteKey) {
|
||||
credentials = new kdbxweb.Credentials(kdbxweb.ProtectedValue.fromString(''));
|
||||
credentialsPromise = credentials.ready.then(() => {
|
||||
const promises = [];
|
||||
if (remoteKey.password) {
|
||||
promises.push(credentials.setPassword(remoteKey.password));
|
||||
} else {
|
||||
credentials.passwordHash = this.db.credentials.passwordHash;
|
||||
}
|
||||
if (remoteKey.keyFileName) {
|
||||
if (remoteKey.keyFileData) {
|
||||
promises.push(credentials.setKeyFile(remoteKey.keyFileData));
|
||||
} else {
|
||||
credentials.keyFileHash = this.db.credentials.keyFileHash;
|
||||
}
|
||||
}
|
||||
return Promise.all(promises);
|
||||
});
|
||||
} else {
|
||||
credentials = this.db.credentials;
|
||||
}
|
||||
credentialsPromise.then(() => {
|
||||
kdbxweb.Kdbx.load(fileData, credentials)
|
||||
.then((remoteDb) => {
|
||||
if (this.modified) {
|
||||
try {
|
||||
if (remoteKey && remoteDb.meta.keyChanged > this.db.meta.keyChanged) {
|
||||
this.db.credentials = remoteDb.credentials;
|
||||
this.keyFileName = remoteKey.keyFileName || '';
|
||||
if (remoteKey.password) {
|
||||
this.passwordLength = remoteKey.password.textLength;
|
||||
}
|
||||
}
|
||||
this.db.merge(remoteDb);
|
||||
} catch (e) {
|
||||
logger.error('File merge error', e);
|
||||
return callback(e);
|
||||
}
|
||||
} else {
|
||||
this.db = remoteDb;
|
||||
}
|
||||
this.dirty = true;
|
||||
this.reload();
|
||||
callback();
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Error opening file to merge', err.code, err.message, err);
|
||||
callback(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getLocalEditState() {
|
||||
return this.db.getLocalEditState();
|
||||
}
|
||||
|
||||
setLocalEditState(editState) {
|
||||
this.db.setLocalEditState(editState);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.set({
|
||||
keyFileName: '',
|
||||
passwordLength: 0,
|
||||
modified: false,
|
||||
dirty: false,
|
||||
active: false,
|
||||
created: false,
|
||||
groups: null,
|
||||
passwordChanged: false,
|
||||
keyFileChanged: false,
|
||||
syncing: false
|
||||
});
|
||||
if (this.chalResp && !AppSettingsModel.yubiKeyRememberChalResp) {
|
||||
ChalRespCalculator.clearCache(this.chalResp);
|
||||
}
|
||||
}
|
||||
|
||||
getEntry(id) {
|
||||
return this.entryMap[id];
|
||||
}
|
||||
|
||||
getGroup(id) {
|
||||
return this.groupMap[id];
|
||||
}
|
||||
|
||||
forEachEntry(filter, callback) {
|
||||
let top = this;
|
||||
if (filter.trash) {
|
||||
top = this.getGroup(
|
||||
this.db.meta.recycleBinUuid ? this.subId(this.db.meta.recycleBinUuid.id) : null
|
||||
);
|
||||
} else if (filter.group) {
|
||||
top = this.getGroup(filter.group);
|
||||
}
|
||||
if (top) {
|
||||
if (top.forEachOwnEntry) {
|
||||
top.forEachOwnEntry(filter, callback);
|
||||
}
|
||||
if (!filter.group || filter.subGroups) {
|
||||
top.forEachGroup((group) => {
|
||||
group.forEachOwnEntry(filter, callback);
|
||||
}, filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forEachGroup(callback, filter) {
|
||||
this.groups.forEach((group) => {
|
||||
if (callback(group) !== false) {
|
||||
group.forEachGroup(callback, filter);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getTrashGroup() {
|
||||
return this.db.meta.recycleBinEnabled
|
||||
? this.getGroup(this.subId(this.db.meta.recycleBinUuid.id))
|
||||
: null;
|
||||
}
|
||||
|
||||
getEntryTemplatesGroup() {
|
||||
return this.db.meta.entryTemplatesGroup
|
||||
? this.getGroup(this.subId(this.db.meta.entryTemplatesGroup.id))
|
||||
: null;
|
||||
}
|
||||
|
||||
createEntryTemplatesGroup() {
|
||||
const rootGroup = this.groups[0];
|
||||
const templatesGroup = GroupModel.newGroup(rootGroup, this);
|
||||
templatesGroup.setName(StringFormat.capFirst(Locale.templates));
|
||||
this.db.meta.entryTemplatesGroup = templatesGroup.group.uuid;
|
||||
this.reload();
|
||||
return templatesGroup;
|
||||
}
|
||||
|
||||
setModified() {
|
||||
if (!this.demo) {
|
||||
this.set({ modified: true, dirty: true });
|
||||
}
|
||||
}
|
||||
|
||||
getData(cb) {
|
||||
this.db.cleanup({
|
||||
historyRules: true,
|
||||
customIcons: true,
|
||||
binaries: true
|
||||
});
|
||||
this.db.cleanup({ binaries: true });
|
||||
this.db
|
||||
.save()
|
||||
.then((data) => {
|
||||
cb(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Error saving file', this.name, err);
|
||||
cb(undefined, err);
|
||||
});
|
||||
}
|
||||
|
||||
getXml(cb) {
|
||||
this.db.saveXml(true).then((xml) => {
|
||||
cb(xml);
|
||||
});
|
||||
}
|
||||
|
||||
getHtml(cb) {
|
||||
cb(
|
||||
KdbxToHtml.convert(this.db, {
|
||||
name: this.name
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getKeyFileHash() {
|
||||
const hash = this.db.credentials.keyFileHash;
|
||||
return hash ? kdbxweb.ByteUtils.bytesToBase64(hash.getBinary()) : null;
|
||||
}
|
||||
|
||||
forEachEntryTemplate(callback) {
|
||||
if (!this.db.meta.entryTemplatesGroup) {
|
||||
return;
|
||||
}
|
||||
const group = this.getGroup(this.subId(this.db.meta.entryTemplatesGroup.id));
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
group.forEachOwnEntry({}, callback);
|
||||
}
|
||||
|
||||
setSyncProgress() {
|
||||
this.set({ syncing: true });
|
||||
}
|
||||
|
||||
setSyncComplete(path, storage, error) {
|
||||
if (!error) {
|
||||
this.db.removeLocalEditState();
|
||||
}
|
||||
const modified = this.modified && !!error;
|
||||
this.set({
|
||||
created: false,
|
||||
path: path || this.path,
|
||||
storage: storage || this.storage,
|
||||
modified,
|
||||
dirty: error ? this.dirty : false,
|
||||
syncing: false,
|
||||
syncError: error
|
||||
});
|
||||
|
||||
if (!error && this.passwordChanged && this.encryptedPassword) {
|
||||
this.set({
|
||||
encryptedPassword: null,
|
||||
encryptedPasswordDate: null
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.open) {
|
||||
return;
|
||||
}
|
||||
this.setOpenFile({ passwordLength: this.passwordLength });
|
||||
this.forEachEntry({ includeDisabled: true }, (entry) => entry.setSaved());
|
||||
}
|
||||
|
||||
setPassword(password) {
|
||||
this.db.credentials.setPassword(password);
|
||||
this.db.meta.keyChanged = new Date();
|
||||
this.set({ passwordLength: password.textLength, passwordChanged: true });
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
resetPassword() {
|
||||
this.db.credentials.passwordHash = this.oldPasswordHash;
|
||||
if (this.db.credentials.keyFileHash === this.oldKeyFileHash) {
|
||||
this.db.meta.keyChanged = this.oldKeyChangeDate;
|
||||
}
|
||||
this.set({ passwordLength: this.oldPasswordLength, passwordChanged: false });
|
||||
}
|
||||
|
||||
setKeyFile(keyFile, keyFileName) {
|
||||
this.db.credentials.setKeyFile(keyFile);
|
||||
this.db.meta.keyChanged = new Date();
|
||||
this.set({ keyFileName, keyFileChanged: true });
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
generateAndSetKeyFile() {
|
||||
return kdbxweb.Credentials.createRandomKeyFile().then((keyFile) => {
|
||||
const keyFileName = 'Generated';
|
||||
this.setKeyFile(keyFile, keyFileName);
|
||||
return keyFile;
|
||||
});
|
||||
}
|
||||
|
||||
resetKeyFile() {
|
||||
this.db.credentials.keyFileHash = this.oldKeyFileHash;
|
||||
if (this.db.credentials.passwordHash === this.oldPasswordHash) {
|
||||
this.db.meta.keyChanged = this.oldKeyChangeDate;
|
||||
}
|
||||
this.set({ keyFileName: this.oldKeyFileName, keyFileChanged: false });
|
||||
}
|
||||
|
||||
removeKeyFile() {
|
||||
this.db.credentials.keyFileHash = null;
|
||||
const changed = !!this.oldKeyFileHash;
|
||||
if (!changed && this.db.credentials.passwordHash === this.oldPasswordHash) {
|
||||
this.db.meta.keyChanged = this.oldKeyChangeDate;
|
||||
}
|
||||
this.set({ keyFileName: '', keyFilePath: '', keyFileChanged: changed });
|
||||
Events.emit('unset-keyfile', this.id);
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
isKeyChangePending(force) {
|
||||
if (!this.db.meta.keyChanged) {
|
||||
return false;
|
||||
}
|
||||
const expiryDays = force ? this.db.meta.keyChangeForce : this.db.meta.keyChangeRec;
|
||||
if (!expiryDays || expiryDays < 0 || isNaN(expiryDays)) {
|
||||
return false;
|
||||
}
|
||||
const daysDiff = (Date.now() - this.db.meta.keyChanged) / 1000 / 3600 / 24;
|
||||
return daysDiff > expiryDays;
|
||||
}
|
||||
|
||||
setChallengeResponse(chalResp) {
|
||||
if (this.chalResp && !AppSettingsModel.yubiKeyRememberChalResp) {
|
||||
ChalRespCalculator.clearCache(this.chalResp);
|
||||
}
|
||||
this.db.credentials.setChallengeResponse(ChalRespCalculator.build(chalResp));
|
||||
this.db.meta.keyChanged = new Date();
|
||||
this.chalResp = chalResp;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setKeyChange(force, days) {
|
||||
if (isNaN(days) || !days || days < 0) {
|
||||
days = -1;
|
||||
}
|
||||
const prop = force ? 'keyChangeForce' : 'keyChangeRec';
|
||||
this.db.meta[prop] = days;
|
||||
this[prop] = days;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setName(name) {
|
||||
this.db.meta.name = name;
|
||||
this.db.meta.nameChanged = new Date();
|
||||
this.name = name;
|
||||
this.groups[0].setName(name);
|
||||
this.setModified();
|
||||
this.reload();
|
||||
}
|
||||
|
||||
setDefaultUser(defaultUser) {
|
||||
this.db.meta.defaultUser = defaultUser;
|
||||
this.db.meta.defaultUserChanged = new Date();
|
||||
this.defaultUser = defaultUser;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setRecycleBinEnabled(enabled) {
|
||||
enabled = !!enabled;
|
||||
this.db.meta.recycleBinEnabled = enabled;
|
||||
if (enabled) {
|
||||
this.db.createRecycleBin();
|
||||
}
|
||||
this.recycleBinEnabled = enabled;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setHistoryMaxItems(count) {
|
||||
this.db.meta.historyMaxItems = count;
|
||||
this.historyMaxItems = count;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setHistoryMaxSize(size) {
|
||||
this.db.meta.historyMaxSize = size;
|
||||
this.historyMaxSize = size;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setKeyEncryptionRounds(rounds) {
|
||||
this.db.header.keyEncryptionRounds = rounds;
|
||||
this.keyEncryptionRounds = rounds;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setKdfParameter(field, value) {
|
||||
const ValueType = kdbxweb.VarDictionary.ValueType;
|
||||
switch (field) {
|
||||
case 'memory':
|
||||
this.db.header.kdfParameters.set('M', ValueType.UInt64, kdbxweb.Int64.from(value));
|
||||
break;
|
||||
case 'iterations':
|
||||
this.db.header.kdfParameters.set('I', ValueType.UInt64, kdbxweb.Int64.from(value));
|
||||
break;
|
||||
case 'parallelism':
|
||||
this.db.header.kdfParameters.set('P', ValueType.UInt32, value);
|
||||
break;
|
||||
case 'rounds':
|
||||
this.db.header.kdfParameters.set('R', ValueType.UInt32, value);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
this.kdfParameters = this.readKdfParams();
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
emptyTrash() {
|
||||
const trashGroup = this.getTrashGroup();
|
||||
if (trashGroup) {
|
||||
let modified = false;
|
||||
trashGroup
|
||||
.getOwnSubGroups()
|
||||
.slice()
|
||||
.forEach(function (group) {
|
||||
this.db.move(group, null);
|
||||
modified = true;
|
||||
}, this);
|
||||
trashGroup.group.entries.slice().forEach(function (entry) {
|
||||
this.db.move(entry, null);
|
||||
modified = true;
|
||||
}, this);
|
||||
trashGroup.items.length = 0;
|
||||
trashGroup.entries.length = 0;
|
||||
if (modified) {
|
||||
this.setModified();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCustomIcons() {
|
||||
const customIcons = {};
|
||||
for (const [id, icon] of this.db.meta.customIcons) {
|
||||
customIcons[id] = IconUrlFormat.toDataUrl(icon.data);
|
||||
}
|
||||
return customIcons;
|
||||
}
|
||||
|
||||
addCustomIcon(iconData) {
|
||||
const uuid = kdbxweb.KdbxUuid.random();
|
||||
this.db.meta.customIcons.set(uuid.id, {
|
||||
data: kdbxweb.ByteUtils.arrayToBuffer(kdbxweb.ByteUtils.base64ToBytes(iconData)),
|
||||
lastModified: new Date()
|
||||
});
|
||||
return uuid.toString();
|
||||
}
|
||||
|
||||
renameTag(from, to) {
|
||||
this.forEachEntry({}, (entry) => entry.renameTag(from, to));
|
||||
}
|
||||
|
||||
setFormatVersion(version) {
|
||||
this.db.setVersion(version);
|
||||
this.setModified();
|
||||
this.readModel();
|
||||
}
|
||||
|
||||
setKdf(kdfName) {
|
||||
const kdfParameters = this.db.header.kdfParameters;
|
||||
if (!kdfParameters) {
|
||||
throw new Error('Cannot set KDF on this version');
|
||||
}
|
||||
switch (kdfName) {
|
||||
case 'Aes':
|
||||
this.db.setKdf(kdbxweb.Consts.KdfId.Aes);
|
||||
break;
|
||||
case 'Argon2d':
|
||||
this.db.setKdf(kdbxweb.Consts.KdfId.Argon2d);
|
||||
break;
|
||||
case 'Argon2id':
|
||||
this.db.setKdf(kdbxweb.Consts.KdfId.Argon2id);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Bad KDF name');
|
||||
}
|
||||
this.setModified();
|
||||
this.readModel();
|
||||
}
|
||||
|
||||
static createKeyFileWithHash(hash) {
|
||||
const hashData = kdbxweb.ByteUtils.base64ToBytes(hash);
|
||||
const hexHash = kdbxweb.ByteUtils.bytesToHex(hashData);
|
||||
return kdbxweb.ByteUtils.stringToBytes(hexHash);
|
||||
}
|
||||
}
|
||||
|
||||
FileModel.defineModelProperties({
|
||||
id: '',
|
||||
uuid: '',
|
||||
name: '',
|
||||
db: null,
|
||||
entryMap: null,
|
||||
groupMap: null,
|
||||
keyFileName: '',
|
||||
keyFilePath: null,
|
||||
chalResp: null,
|
||||
passwordLength: 0,
|
||||
path: '',
|
||||
opts: null,
|
||||
storage: null,
|
||||
modified: false,
|
||||
dirty: false,
|
||||
active: false,
|
||||
created: false,
|
||||
demo: false,
|
||||
groups: null,
|
||||
oldPasswordLength: 0,
|
||||
oldKeyFileName: '',
|
||||
passwordChanged: false,
|
||||
keyFileChanged: false,
|
||||
keyChangeForce: -1,
|
||||
syncing: false,
|
||||
syncError: null,
|
||||
syncDate: null,
|
||||
backup: null,
|
||||
formatVersion: null,
|
||||
defaultUser: null,
|
||||
recycleBinEnabled: null,
|
||||
historyMaxItems: null,
|
||||
historyMaxSize: null,
|
||||
keyEncryptionRounds: null,
|
||||
kdfName: null,
|
||||
kdfParameters: null,
|
||||
fingerprint: null, // obsolete
|
||||
oldPasswordHash: null,
|
||||
oldKeyFileHash: null,
|
||||
oldKeyChangeDate: null,
|
||||
encryptedPassword: null,
|
||||
encryptedPasswordDate: null,
|
||||
supportsTags: true,
|
||||
supportsColors: true,
|
||||
supportsIcons: true,
|
||||
supportsExpiration: true,
|
||||
defaultGroupHash: ''
|
||||
});
|
||||
|
||||
export { FileModel };
|
|
@ -0,0 +1,801 @@
|
|||
import * as kdbxweb from 'kdbxweb';
|
||||
import { Model } from 'util/model';
|
||||
import { Entry } from 'models/entry';
|
||||
import { Group } from 'models/group';
|
||||
import { IconUrlFormat } from 'util/formatting/icon-url-format';
|
||||
import { Logger } from 'util/logger';
|
||||
import { Locale } from 'util/locale';
|
||||
import { StringFormat } from 'util/formatting/string-format';
|
||||
import { StorageFileOptions } from 'storage/types';
|
||||
import { FileBackupConfig } from './file-info';
|
||||
import { IdGenerator } from 'util/generators/id-generator';
|
||||
import { Filter } from 'models/filter';
|
||||
// import { ChalRespCalculator } from 'comp/app/chal-resp-calculator';
|
||||
|
||||
const DemoFileData = require('demo.kdbx') as { default: string };
|
||||
|
||||
const logger = new Logger('file');
|
||||
|
||||
type KdfName = 'AES' | 'Argon2d' | 'Argon2id';
|
||||
|
||||
interface FileKdfParamsAes {
|
||||
type: 'AES';
|
||||
name: 'AES';
|
||||
rounds: number;
|
||||
}
|
||||
|
||||
interface FileKdfParamsArgon2 {
|
||||
type: 'Argon2';
|
||||
name: 'Argon2d' | 'Argon2id';
|
||||
parallelism: number;
|
||||
iterations: number;
|
||||
memory: number;
|
||||
}
|
||||
|
||||
type FileKdfParams = FileKdfParamsAes | FileKdfParamsArgon2;
|
||||
|
||||
interface FileEvents {
|
||||
'reload': () => void;
|
||||
}
|
||||
|
||||
const FilterIncludingDisabled = new Filter({ includeDisabled: true });
|
||||
|
||||
class File extends Model<FileEvents> {
|
||||
readonly id: string;
|
||||
db: kdbxweb.Kdbx;
|
||||
name: string;
|
||||
uuid?: string;
|
||||
keyFileName?: string;
|
||||
keyFilePath?: string;
|
||||
// chalResp = null; // TODO(ts): chal-resp
|
||||
passwordLength?: number;
|
||||
path?: string;
|
||||
opts?: StorageFileOptions;
|
||||
storage?: string;
|
||||
modified = false;
|
||||
dirty = false;
|
||||
created = false;
|
||||
demo = false;
|
||||
groups: Group[] = [];
|
||||
oldPasswordLength?: number;
|
||||
oldKeyFileName?: string;
|
||||
passwordChanged = false;
|
||||
keyFileChanged = false;
|
||||
keyChangeForce?: number | undefined;
|
||||
syncing = false;
|
||||
syncError?: string;
|
||||
syncDate?: Date;
|
||||
backup?: FileBackupConfig;
|
||||
formatVersion?: number;
|
||||
defaultUser?: string;
|
||||
recycleBinEnabled?: boolean;
|
||||
historyMaxItems?: number;
|
||||
historyMaxSize?: number;
|
||||
keyEncryptionRounds?: number;
|
||||
kdfParameters?: FileKdfParams;
|
||||
oldPasswordHash?: kdbxweb.ProtectedValue;
|
||||
oldKeyFileHash?: kdbxweb.ProtectedValue;
|
||||
oldKeyChangeDate?: Date;
|
||||
encryptedPassword?: string;
|
||||
encryptedPasswordDate?: Date;
|
||||
supportsTags = true;
|
||||
supportsColors = true;
|
||||
supportsIcons = true;
|
||||
supportsExpiration = true;
|
||||
|
||||
private readonly _entryMap = new Map<string, Entry>();
|
||||
private readonly _groupMap = new Map<string, Group>();
|
||||
|
||||
constructor(id: string, name: string, db: kdbxweb.Kdbx) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
static async open(
|
||||
id: string,
|
||||
name: string,
|
||||
password: kdbxweb.ProtectedValue,
|
||||
fileData: ArrayBuffer,
|
||||
keyFileData?: ArrayBuffer
|
||||
): Promise<File> {
|
||||
try {
|
||||
// const challengeResponse = ChalRespCalculator.build(this.chalResp); // TODO(ts): chal-resp
|
||||
const credentials = new kdbxweb.Credentials(
|
||||
password,
|
||||
keyFileData
|
||||
// , challengeResponse // TODO(ts): chal-resp
|
||||
);
|
||||
const ts = logger.ts();
|
||||
|
||||
let db: kdbxweb.Kdbx;
|
||||
try {
|
||||
db = await kdbxweb.Kdbx.load(fileData, credentials);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof kdbxweb.KdbxError &&
|
||||
err.code === kdbxweb.Consts.ErrorCodes.InvalidKey &&
|
||||
password &&
|
||||
!password.byteLength
|
||||
) {
|
||||
logger.info(
|
||||
'Error opening file with empty password, try to open with null password'
|
||||
);
|
||||
db = await kdbxweb.Kdbx.load(fileData, credentials);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const file = new File(id, name, db);
|
||||
|
||||
file.readModel();
|
||||
file.setOpenFile({ passwordLength: password ? password.textLength : 0 });
|
||||
if (keyFileData) {
|
||||
kdbxweb.ByteUtils.zeroBuffer(keyFileData);
|
||||
}
|
||||
|
||||
const kdfStr = file.kdfArgsToString();
|
||||
const fileSizeKb = Math.round(fileData.byteLength / 1024);
|
||||
logger.info(`Opened file ${name}: ${logger.ts(ts)}, ${kdfStr}, ${fileSizeKb} kB`);
|
||||
|
||||
return file;
|
||||
} catch (e) {
|
||||
logger.error('Error opening file', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static create(id: string, name: string): Promise<File> {
|
||||
const password = kdbxweb.ProtectedValue.fromString('');
|
||||
const credentials = new kdbxweb.Credentials(password);
|
||||
const db = kdbxweb.Kdbx.create(credentials, name);
|
||||
const file = new File(id, name, db);
|
||||
file.created = true;
|
||||
file.readModel();
|
||||
return Promise.resolve(file);
|
||||
}
|
||||
|
||||
static async importWithXml(id: string, name: string, xml: string): Promise<File> {
|
||||
try {
|
||||
const ts = logger.ts();
|
||||
const password = kdbxweb.ProtectedValue.fromString('');
|
||||
const credentials = new kdbxweb.Credentials(password);
|
||||
|
||||
const db = await kdbxweb.Kdbx.loadXml(xml, credentials);
|
||||
const file = new File(id, name, db);
|
||||
|
||||
file.readModel();
|
||||
file.created = true;
|
||||
logger.info(`Imported file ${name}: ${logger.ts(ts)}`);
|
||||
|
||||
return file;
|
||||
} catch (e) {
|
||||
logger.error('Error importing XML', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
static async openDemo(): Promise<File> {
|
||||
const password = kdbxweb.ProtectedValue.fromString('demo');
|
||||
const credentials = new kdbxweb.Credentials(password);
|
||||
const demoFile = kdbxweb.ByteUtils.arrayToBuffer(
|
||||
kdbxweb.ByteUtils.base64ToBytes(DemoFileData.default)
|
||||
);
|
||||
const db = await kdbxweb.Kdbx.load(demoFile, credentials);
|
||||
|
||||
const file = new File(IdGenerator.uuid(), 'Demo', db);
|
||||
file.demo = true;
|
||||
file.readModel();
|
||||
file.setOpenFile({ passwordLength: 4 });
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private kdfArgsToString(): string {
|
||||
if (this.db.header.kdfParameters) {
|
||||
const kdfParameters = this.db.header.kdfParameters;
|
||||
return kdfParameters
|
||||
.keys()
|
||||
.map((key) => {
|
||||
const val = kdfParameters.get(key);
|
||||
if (val instanceof ArrayBuffer) {
|
||||
return undefined;
|
||||
}
|
||||
return `${key}=${String(val)}`;
|
||||
})
|
||||
.filter((p) => p)
|
||||
.join('&');
|
||||
} else if (this.db.header.keyEncryptionRounds) {
|
||||
return `${this.db.header.keyEncryptionRounds} rounds`;
|
||||
} else {
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
private setOpenFile({ passwordLength }: { passwordLength: number }) {
|
||||
this.batchSet(() => {
|
||||
this.oldKeyFileName = this.keyFileName;
|
||||
this.oldPasswordLength = passwordLength;
|
||||
this.passwordChanged = false;
|
||||
this.keyFileChanged = false;
|
||||
});
|
||||
this.oldPasswordHash = this.db.credentials.passwordHash;
|
||||
this.oldKeyFileHash = this.db.credentials.keyFileHash;
|
||||
this.oldKeyChangeDate = this.db.meta.keyChanged;
|
||||
}
|
||||
|
||||
private readModel() {
|
||||
const groups: Group[] = [];
|
||||
this.batchSet(() => {
|
||||
this.uuid = this.db.getDefaultGroup().uuid.toString();
|
||||
this.groups = groups;
|
||||
this.formatVersion = this.db.header.versionMajor;
|
||||
this.defaultUser = this.db.meta.defaultUser;
|
||||
this.recycleBinEnabled = this.db.meta.recycleBinEnabled;
|
||||
this.historyMaxItems = this.db.meta.historyMaxItems;
|
||||
this.historyMaxSize = this.db.meta.historyMaxSize;
|
||||
this.keyEncryptionRounds = this.db.header.keyEncryptionRounds;
|
||||
this.keyChangeForce = this.db.meta.keyChangeForce;
|
||||
this.kdfParameters = this.readKdfParams();
|
||||
});
|
||||
for (const group of this.db.groups) {
|
||||
let groupModel = this.getGroup(this.subId(group.uuid.id));
|
||||
if (groupModel) {
|
||||
groupModel.setGroup(group, this, undefined);
|
||||
} else {
|
||||
groupModel = new Group(group, this, undefined);
|
||||
}
|
||||
groups.push(groupModel);
|
||||
}
|
||||
this.buildObjectMap();
|
||||
this.resolveFieldReferences();
|
||||
}
|
||||
|
||||
private readKdfParams(): FileKdfParams | undefined {
|
||||
const kdfParameters = this.db.header.kdfParameters;
|
||||
if (!kdfParameters) {
|
||||
return undefined;
|
||||
}
|
||||
let uuid = kdfParameters.get('$UUID');
|
||||
if (!(uuid instanceof ArrayBuffer)) {
|
||||
return undefined;
|
||||
}
|
||||
uuid = kdbxweb.ByteUtils.bytesToBase64(uuid);
|
||||
switch (uuid) {
|
||||
case kdbxweb.Consts.KdfId.Argon2d:
|
||||
case kdbxweb.Consts.KdfId.Argon2id:
|
||||
return {
|
||||
type: 'Argon2',
|
||||
name: uuid === kdbxweb.Consts.KdfId.Argon2d ? 'Argon2d' : 'Argon2id',
|
||||
parallelism: File.getKdfNumber(kdfParameters, 'P'),
|
||||
iterations: File.getKdfNumber(kdfParameters, 'I'),
|
||||
memory: File.getKdfNumber(kdfParameters, 'M')
|
||||
};
|
||||
case kdbxweb.Consts.KdfId.Aes:
|
||||
return {
|
||||
type: 'AES',
|
||||
name: 'AES',
|
||||
rounds: File.getKdfNumber(kdfParameters, 'R')
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private static getKdfNumber(kdfParameters: kdbxweb.VarDictionary, key: string): number {
|
||||
const value = kdfParameters.get(key);
|
||||
if (value instanceof kdbxweb.Int64) {
|
||||
return value.valueOf();
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
subId(id: string): string {
|
||||
return this.id + ':' + id;
|
||||
}
|
||||
|
||||
private buildObjectMap(): void {
|
||||
this._entryMap.clear();
|
||||
this._groupMap.clear();
|
||||
|
||||
for (const group of this.allGroupsMatching(FilterIncludingDisabled)) {
|
||||
this._groupMap.set(group.id, group);
|
||||
for (const entry of group.ownEntriesMatching(FilterIncludingDisabled)) {
|
||||
this._entryMap.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveFieldReferences(): void {
|
||||
for (const entry of this._entryMap.values()) {
|
||||
entry.resolveFieldReferences();
|
||||
}
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.buildObjectMap();
|
||||
this.readModel();
|
||||
this.emit('reload');
|
||||
}
|
||||
|
||||
async mergeOrUpdate(
|
||||
fileData: ArrayBuffer,
|
||||
remoteKey?: {
|
||||
password?: kdbxweb.ProtectedValue;
|
||||
keyFileName?: string;
|
||||
keyFileData?: ArrayBuffer;
|
||||
}
|
||||
): Promise<void> {
|
||||
let credentials: kdbxweb.Credentials;
|
||||
if (remoteKey) {
|
||||
// TODO(ts): chall-resp
|
||||
credentials = new kdbxweb.Credentials(null);
|
||||
await credentials.ready;
|
||||
if (remoteKey.password) {
|
||||
await credentials.setPassword(remoteKey.password);
|
||||
} else {
|
||||
credentials.passwordHash = this.db.credentials.passwordHash;
|
||||
}
|
||||
if (remoteKey.keyFileName) {
|
||||
if (remoteKey.keyFileData) {
|
||||
await credentials.setKeyFile(remoteKey.keyFileData);
|
||||
} else {
|
||||
credentials.keyFileHash = this.db.credentials.keyFileHash;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
credentials = this.db.credentials;
|
||||
}
|
||||
|
||||
let remoteDb: kdbxweb.Kdbx;
|
||||
try {
|
||||
remoteDb = await kdbxweb.Kdbx.load(fileData, credentials);
|
||||
} catch (err) {
|
||||
logger.error('Error opening file to merge', err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (this.modified) {
|
||||
try {
|
||||
if (
|
||||
remoteKey &&
|
||||
remoteDb.meta.keyChanged &&
|
||||
this.db.meta.keyChanged &&
|
||||
remoteDb.meta.keyChanged.getTime() > this.db.meta.keyChanged.getTime()
|
||||
) {
|
||||
this.db.credentials = remoteDb.credentials;
|
||||
this.keyFileName = remoteKey.keyFileName;
|
||||
if (remoteKey.password) {
|
||||
this.passwordLength = remoteKey.password.textLength;
|
||||
}
|
||||
}
|
||||
this.db.merge(remoteDb);
|
||||
} catch (e) {
|
||||
logger.error('File merge error', e);
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
this.db = remoteDb;
|
||||
}
|
||||
this.dirty = true;
|
||||
this.reload();
|
||||
}
|
||||
|
||||
getLocalEditState(): kdbxweb.KdbxEditState {
|
||||
return this.db.getLocalEditState();
|
||||
}
|
||||
|
||||
setLocalEditState(editState: kdbxweb.KdbxEditState): void {
|
||||
this.db.setLocalEditState(editState);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.batchSet(() => {
|
||||
this.keyFileName = '';
|
||||
this.passwordLength = 0;
|
||||
this.modified = false;
|
||||
this.dirty = false;
|
||||
this.created = false;
|
||||
this.groups = [];
|
||||
this.passwordChanged = false;
|
||||
this.keyFileChanged = false;
|
||||
this.syncing = false;
|
||||
});
|
||||
// TODO(ts): chal-resp
|
||||
// if (this.chalResp && !AppSettings.yubiKeyRememberChalResp) {
|
||||
// ChalRespCalculator.clearCache(this.chalResp);
|
||||
// }
|
||||
}
|
||||
|
||||
getEntry(id: string): Entry | undefined {
|
||||
return this._entryMap.get(id);
|
||||
}
|
||||
|
||||
getGroup(id: string): Group | undefined {
|
||||
return this._groupMap.get(id);
|
||||
}
|
||||
|
||||
*entriesMatching(filter: Filter): Generator<Entry> {
|
||||
let top: Group | undefined; // = this
|
||||
if (filter.trash) {
|
||||
const recycleBinUuid = this.db.meta.recycleBinUuid?.id;
|
||||
if (recycleBinUuid) {
|
||||
top = this.getGroup(recycleBinUuid);
|
||||
}
|
||||
} else if (filter.group) {
|
||||
top = this.getGroup(filter.group);
|
||||
}
|
||||
|
||||
if (top) {
|
||||
yield* top.ownEntriesMatching(filter);
|
||||
if (!filter.group || filter.subGroups) {
|
||||
for (const group of top.allGroupsMatching(filter)) {
|
||||
yield* group.ownEntriesMatching(filter);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const group of this.allGroupsMatching(filter)) {
|
||||
yield* group.ownEntriesMatching(filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*allGroupsMatching(filter: Filter): Generator<Group> {
|
||||
for (const group of this.groups) {
|
||||
if (group.matches(filter)) {
|
||||
yield group;
|
||||
yield* group.allGroupsMatching(filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*allEntryTemplates(): Generator<Entry> {
|
||||
if (!this.db.meta.entryTemplatesGroup) {
|
||||
return;
|
||||
}
|
||||
const group = this.getGroup(this.subId(this.db.meta.entryTemplatesGroup.id));
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
yield* group.ownEntriesMatching(new Filter());
|
||||
}
|
||||
|
||||
getTrashGroup(): Group | undefined {
|
||||
return this.db.meta.recycleBinEnabled && this.db.meta.recycleBinUuid
|
||||
? this.getGroup(this.subId(this.db.meta.recycleBinUuid.id))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
getEntryTemplatesGroup(): Group | undefined {
|
||||
return this.db.meta.entryTemplatesGroup
|
||||
? this.getGroup(this.subId(this.db.meta.entryTemplatesGroup.id))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
createEntryTemplatesGroup(): Group {
|
||||
const rootGroup = this.groups[0];
|
||||
const templatesGroup = Group.newGroup(rootGroup, this);
|
||||
templatesGroup.setName(StringFormat.capFirst(Locale.templates));
|
||||
this.db.meta.entryTemplatesGroup = templatesGroup.group.uuid;
|
||||
this.reload();
|
||||
return templatesGroup;
|
||||
}
|
||||
|
||||
setModified(): void {
|
||||
if (!this.demo) {
|
||||
this.batchSet(() => {
|
||||
this.modified = true;
|
||||
this.dirty = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getData(): Promise<ArrayBuffer> {
|
||||
this.db.cleanup({
|
||||
historyRules: true,
|
||||
customIcons: true,
|
||||
binaries: true
|
||||
});
|
||||
return this.db.save();
|
||||
}
|
||||
|
||||
getXml(): Promise<string> {
|
||||
return this.db.saveXml(true);
|
||||
}
|
||||
|
||||
getKeyFileHash(): string | undefined {
|
||||
const hash = this.db.credentials.keyFileHash;
|
||||
return hash ? kdbxweb.ByteUtils.bytesToBase64(hash.getBinary()) : undefined;
|
||||
}
|
||||
|
||||
setSyncProgress(): void {
|
||||
this.syncing = true;
|
||||
}
|
||||
|
||||
setSyncComplete(
|
||||
path: string | undefined,
|
||||
storage: string | undefined,
|
||||
error: string | undefined
|
||||
): void {
|
||||
if (!error) {
|
||||
this.db.removeLocalEditState();
|
||||
}
|
||||
this.batchSet(() => {
|
||||
this.created = false;
|
||||
this.path = path || this.path;
|
||||
this.storage = storage || this.storage;
|
||||
this.modified = this.modified && !!error;
|
||||
this.dirty = error ? this.dirty : false;
|
||||
this.syncing = false;
|
||||
this.syncError = error;
|
||||
|
||||
if (!error && this.passwordChanged && this.encryptedPassword) {
|
||||
this.encryptedPassword = undefined;
|
||||
this.encryptedPasswordDate = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
this.setOpenFile({ passwordLength: this.passwordLength || 0 });
|
||||
for (const entry of this.entriesMatching(FilterIncludingDisabled)) {
|
||||
entry.setSaved();
|
||||
}
|
||||
}
|
||||
|
||||
async setPassword(password: kdbxweb.ProtectedValue): Promise<void> {
|
||||
await this.db.credentials.setPassword(password);
|
||||
this.db.meta.keyChanged = new Date();
|
||||
this.batchSet(() => {
|
||||
this.passwordLength = password.textLength;
|
||||
this.passwordChanged = true;
|
||||
});
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
resetPassword(): void {
|
||||
this.db.credentials.passwordHash = this.oldPasswordHash;
|
||||
if (this.db.credentials.keyFileHash === this.oldKeyFileHash) {
|
||||
this.db.meta.keyChanged = this.oldKeyChangeDate;
|
||||
}
|
||||
this.batchSet(() => {
|
||||
this.passwordLength = this.oldPasswordLength;
|
||||
this.passwordChanged = false;
|
||||
});
|
||||
}
|
||||
|
||||
async setKeyFile(keyFile: Uint8Array | null, keyFileName: string | undefined): Promise<void> {
|
||||
await this.db.credentials.setKeyFile(keyFile);
|
||||
this.db.meta.keyChanged = new Date();
|
||||
this.batchSet(() => {
|
||||
this.keyFileName = keyFileName;
|
||||
this.keyFileChanged = true;
|
||||
});
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
async generateAndSetKeyFile(): Promise<Uint8Array> {
|
||||
const keyFile = await kdbxweb.Credentials.createRandomKeyFile();
|
||||
const keyFileName = 'Generated';
|
||||
await this.setKeyFile(keyFile, keyFileName);
|
||||
return keyFile;
|
||||
}
|
||||
|
||||
resetKeyFile(): void {
|
||||
this.db.credentials.keyFileHash = this.oldKeyFileHash;
|
||||
if (this.db.credentials.passwordHash === this.oldPasswordHash) {
|
||||
this.db.meta.keyChanged = this.oldKeyChangeDate;
|
||||
}
|
||||
this.batchSet(() => {
|
||||
this.keyFileName = this.oldKeyFileName;
|
||||
this.keyFileChanged = false;
|
||||
});
|
||||
}
|
||||
|
||||
removeKeyFile(): void {
|
||||
this.db.credentials.keyFileHash = undefined;
|
||||
const changed = !!this.oldKeyFileHash;
|
||||
if (!changed && this.db.credentials.passwordHash === this.oldPasswordHash) {
|
||||
this.db.meta.keyChanged = this.oldKeyChangeDate;
|
||||
}
|
||||
this.batchSet(() => {
|
||||
this.keyFileName = undefined;
|
||||
this.keyFilePath = undefined;
|
||||
this.keyFileChanged = changed;
|
||||
});
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
isKeyChangePending(force: boolean): boolean {
|
||||
if (!this.db.meta.keyChanged) {
|
||||
return false;
|
||||
}
|
||||
const expiryDays = force ? this.db.meta.keyChangeForce : this.db.meta.keyChangeRec;
|
||||
if (!expiryDays || expiryDays < 0 || isNaN(expiryDays)) {
|
||||
return false;
|
||||
}
|
||||
const daysDiff = (Date.now() - this.db.meta.keyChanged.getTime()) / 1000 / 3600 / 24;
|
||||
return daysDiff > expiryDays;
|
||||
}
|
||||
|
||||
// setChallengeResponse(chalResp) { // TODO(ts): chal-resp
|
||||
// if (this.chalResp && !AppSettingsModel.yubiKeyRememberChalResp) {
|
||||
// ChalRespCalculator.clearCache(this.chalResp);
|
||||
// }
|
||||
// this.db.credentials.setChallengeResponse(ChalRespCalculator.build(chalResp));
|
||||
// this.db.meta.keyChanged = new Date();
|
||||
// this.chalResp = chalResp;
|
||||
// this.setModified();
|
||||
// }
|
||||
|
||||
setKeyChange(force: boolean, days: number): void {
|
||||
if (isNaN(days) || !days || days < 0) {
|
||||
days = -1;
|
||||
}
|
||||
const prop = force ? 'keyChangeForce' : 'keyChangeRec';
|
||||
this.db.meta[prop] = days;
|
||||
if (force) {
|
||||
this.db.meta.keyChangeForce = days;
|
||||
} else {
|
||||
this.db.meta.keyChangeRec = days;
|
||||
}
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setName(name: string): void {
|
||||
this.db.meta.name = name;
|
||||
this.db.meta.nameChanged = new Date();
|
||||
this.name = name;
|
||||
this.groups[0].setName(name);
|
||||
this.setModified();
|
||||
this.reload();
|
||||
}
|
||||
|
||||
setDefaultUser(defaultUser: string): void {
|
||||
this.db.meta.defaultUser = defaultUser;
|
||||
this.db.meta.defaultUserChanged = new Date();
|
||||
this.defaultUser = defaultUser;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setRecycleBinEnabled(enabled: boolean): void {
|
||||
this.db.meta.recycleBinEnabled = enabled;
|
||||
if (enabled) {
|
||||
this.db.createRecycleBin();
|
||||
}
|
||||
this.recycleBinEnabled = enabled;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setHistoryMaxItems(count: number): void {
|
||||
this.db.meta.historyMaxItems = count;
|
||||
this.historyMaxItems = count;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setHistoryMaxSize(size: number): void {
|
||||
this.db.meta.historyMaxSize = size;
|
||||
this.historyMaxSize = size;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setKeyEncryptionRounds(rounds: number): void {
|
||||
this.db.header.keyEncryptionRounds = rounds;
|
||||
this.keyEncryptionRounds = rounds;
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
setKdfParameter(
|
||||
field: keyof FileKdfParamsAes | keyof FileKdfParamsArgon2,
|
||||
value: number
|
||||
): void {
|
||||
const ValueType = kdbxweb.VarDictionary.ValueType;
|
||||
const kdfParameters = this.db.header.kdfParameters;
|
||||
if (!kdfParameters) {
|
||||
throw new Error('No kdf parameters');
|
||||
}
|
||||
switch (field) {
|
||||
case 'memory':
|
||||
kdfParameters.set('M', ValueType.UInt64, kdbxweb.Int64.from(value));
|
||||
break;
|
||||
case 'iterations':
|
||||
kdfParameters.set('I', ValueType.UInt64, kdbxweb.Int64.from(value));
|
||||
break;
|
||||
case 'parallelism':
|
||||
kdfParameters.set('P', ValueType.UInt32, value);
|
||||
break;
|
||||
case 'rounds':
|
||||
kdfParameters.set('R', ValueType.UInt32, value);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
this.kdfParameters = this.readKdfParams();
|
||||
this.setModified();
|
||||
}
|
||||
|
||||
emptyTrash(): void {
|
||||
const trashGroup = this.getTrashGroup();
|
||||
if (trashGroup) {
|
||||
let modified = false;
|
||||
trashGroup.group.groups.slice().forEach((group) => {
|
||||
this.db.move(group, null);
|
||||
modified = true;
|
||||
});
|
||||
trashGroup.group.entries.slice().forEach((entry) => {
|
||||
this.db.move(entry, null);
|
||||
modified = true;
|
||||
});
|
||||
trashGroup.items.length = 0;
|
||||
trashGroup.entries.length = 0;
|
||||
if (modified) {
|
||||
this.setModified();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getCustomIcons(): Map<string, string> {
|
||||
const customIcons = new Map<string, string>();
|
||||
for (const [id, icon] of this.db.meta.customIcons) {
|
||||
const iconData = IconUrlFormat.toDataUrl(icon.data);
|
||||
if (iconData) {
|
||||
customIcons.set(id, iconData);
|
||||
}
|
||||
}
|
||||
return customIcons;
|
||||
}
|
||||
|
||||
addCustomIcon(iconData: string): string {
|
||||
const uuid = kdbxweb.KdbxUuid.random();
|
||||
this.db.meta.customIcons.set(uuid.id, {
|
||||
data: kdbxweb.ByteUtils.arrayToBuffer(kdbxweb.ByteUtils.base64ToBytes(iconData)),
|
||||
lastModified: new Date()
|
||||
});
|
||||
return uuid.toString();
|
||||
}
|
||||
|
||||
renameTag(from: string, to: string): void {
|
||||
for (const entry of this.entriesMatching(FilterIncludingDisabled)) {
|
||||
entry.renameTag(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
setFormatVersion(version: 3 | 4): void {
|
||||
this.db.setVersion(version);
|
||||
this.setModified();
|
||||
this.readModel();
|
||||
}
|
||||
|
||||
setKdf(kdfName: KdfName): void {
|
||||
const kdfParameters = this.db.header.kdfParameters;
|
||||
if (!kdfParameters) {
|
||||
throw new Error('Cannot set KDF on this version');
|
||||
}
|
||||
switch (kdfName) {
|
||||
case 'AES':
|
||||
this.db.setKdf(kdbxweb.Consts.KdfId.Aes);
|
||||
break;
|
||||
case 'Argon2d':
|
||||
this.db.setKdf(kdbxweb.Consts.KdfId.Argon2d);
|
||||
break;
|
||||
case 'Argon2id':
|
||||
this.db.setKdf(kdbxweb.Consts.KdfId.Argon2id);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Bad KDF name');
|
||||
}
|
||||
this.setModified();
|
||||
this.readModel();
|
||||
}
|
||||
|
||||
static createKeyFileWithHash(hash: string): Uint8Array {
|
||||
const hashData = kdbxweb.ByteUtils.base64ToBytes(hash);
|
||||
const hexHash = kdbxweb.ByteUtils.bytesToHex(hashData);
|
||||
return kdbxweb.ByteUtils.stringToBytes(hexHash);
|
||||
}
|
||||
}
|
||||
|
||||
export { File };
|
|
@ -0,0 +1,36 @@
|
|||
import { Model } from 'util/model';
|
||||
import { InitWithFieldsOf } from 'util/types';
|
||||
|
||||
export interface AdvancedFilter {
|
||||
cs?: boolean;
|
||||
regex?: boolean;
|
||||
user?: boolean;
|
||||
url?: boolean;
|
||||
notes?: boolean;
|
||||
pass?: boolean;
|
||||
title?: boolean;
|
||||
other?: boolean;
|
||||
protect?: boolean;
|
||||
history?: boolean;
|
||||
}
|
||||
|
||||
export class Filter extends Model {
|
||||
text?: string;
|
||||
textParts?: string[];
|
||||
textLower?: string;
|
||||
textLowerParts?: string[];
|
||||
tagLower?: string;
|
||||
advanced?: AdvancedFilter;
|
||||
color?: string;
|
||||
autoType?: boolean;
|
||||
otp?: boolean;
|
||||
includeDisabled?: boolean;
|
||||
trash?: boolean;
|
||||
group?: string;
|
||||
subGroups?: boolean;
|
||||
|
||||
constructor(values?: InitWithFieldsOf<Filter>) {
|
||||
super();
|
||||
Object.assign(this, values);
|
||||
}
|
||||
}
|
|
@ -1,377 +0,0 @@
|
|||
import * as kdbxweb from 'kdbxweb';
|
||||
import { IconMap } from 'const/icon-map';
|
||||
import { EntryModel } from 'models/entry-model';
|
||||
import { MenuItem } from 'models/menu/menu-item-model';
|
||||
import { IconUrlFormat } from 'util/formatting/icon-url-format';
|
||||
import { GroupCollection } from 'collections/group-collection';
|
||||
import { EntryCollection } from 'collections/entry-collection';
|
||||
|
||||
const KdbxIcons = kdbxweb.Consts.Icons;
|
||||
|
||||
const DefaultAutoTypeSequence = '{USERNAME}{TAB}{PASSWORD}{ENTER}';
|
||||
|
||||
class GroupModel extends MenuItem {
|
||||
setGroup(group, file, parentGroup) {
|
||||
const isRecycleBin = group.uuid.equals(file.db.meta.recycleBinUuid);
|
||||
const id = file.subId(group.uuid.id);
|
||||
this.set(
|
||||
{
|
||||
id,
|
||||
uuid: group.uuid.id,
|
||||
expanded: group.expanded,
|
||||
visible: !isRecycleBin,
|
||||
items: new GroupCollection(),
|
||||
entries: new EntryCollection(),
|
||||
filterValue: id,
|
||||
enableSearching: group.enableSearching,
|
||||
enableAutoType: group.enableAutoType,
|
||||
autoTypeSeq: group.defaultAutoTypeSeq,
|
||||
top: !parentGroup,
|
||||
drag: !!parentGroup,
|
||||
collapsible: !!parentGroup
|
||||
},
|
||||
{ silent: true }
|
||||
);
|
||||
this.group = group;
|
||||
this.file = file;
|
||||
this.parentGroup = parentGroup;
|
||||
this._fillByGroup(true);
|
||||
const items = this.items;
|
||||
const entries = this.entries;
|
||||
|
||||
const itemsArray = group.groups.map((subGroup) => {
|
||||
let g = file.getGroup(file.subId(subGroup.uuid.id));
|
||||
if (g) {
|
||||
g.setGroup(subGroup, file, this);
|
||||
} else {
|
||||
g = GroupModel.fromGroup(subGroup, file, this);
|
||||
}
|
||||
return g;
|
||||
}, this);
|
||||
items.push(...itemsArray);
|
||||
|
||||
const entriesArray = group.entries.map((entry) => {
|
||||
let e = file.getEntry(file.subId(entry.uuid.id));
|
||||
if (e) {
|
||||
e.setEntry(entry, this, file);
|
||||
} else {
|
||||
e = EntryModel.fromEntry(entry, this, file);
|
||||
}
|
||||
return e;
|
||||
}, this);
|
||||
entries.push(...entriesArray);
|
||||
}
|
||||
|
||||
_fillByGroup(silent) {
|
||||
this.set(
|
||||
{
|
||||
title: this.parentGroup ? this.group.name : this.file.name,
|
||||
iconId: this.group.icon,
|
||||
icon: this._iconFromId(this.group.icon),
|
||||
customIcon: this._buildCustomIcon(),
|
||||
customIconId: this.group.customIcon ? this.group.customIcon.toString() : null,
|
||||
expanded: this.group.expanded !== false
|
||||
},
|
||||
{ silent }
|
||||
);
|
||||
}
|
||||
|
||||
_iconFromId(id) {
|
||||
if (id === KdbxIcons.Folder || id === KdbxIcons.FolderOpen) {
|
||||
return undefined;
|
||||
}
|
||||
return IconMap[id];
|
||||
}
|
||||
|
||||
_buildCustomIcon() {
|
||||
this.customIcon = null;
|
||||
if (this.group.customIcon) {
|
||||
return IconUrlFormat.toDataUrl(
|
||||
this.file.db.meta.customIcons.get(this.group.customIcon.id)?.data
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_groupModified() {
|
||||
if (this.isJustCreated) {
|
||||
this.isJustCreated = false;
|
||||
}
|
||||
this.file.setModified();
|
||||
this.group.times.update();
|
||||
}
|
||||
|
||||
forEachGroup(callback, filter) {
|
||||
let result = true;
|
||||
this.items.forEach((group) => {
|
||||
if (group.matches(filter)) {
|
||||
result =
|
||||
callback(group) !== false && group.forEachGroup(callback, filter) !== false;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
forEachOwnEntry(filter, callback) {
|
||||
this.entries.forEach(function (entry) {
|
||||
if (entry.matches(filter)) {
|
||||
callback(entry, this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
matches(filter) {
|
||||
return (
|
||||
((filter && filter.includeDisabled) ||
|
||||
(this.group.enableSearching !== false &&
|
||||
!this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup))) &&
|
||||
(!filter || !filter.autoType || this.group.enableAutoType !== false)
|
||||
);
|
||||
}
|
||||
|
||||
getOwnSubGroups() {
|
||||
return this.group.groups;
|
||||
}
|
||||
|
||||
addEntry(entry) {
|
||||
this.entries.push(entry);
|
||||
}
|
||||
|
||||
addGroup(group) {
|
||||
this.items.push(group);
|
||||
}
|
||||
|
||||
setName(name) {
|
||||
this._groupModified();
|
||||
this.group.name = name;
|
||||
this._fillByGroup();
|
||||
}
|
||||
|
||||
setIcon(iconId) {
|
||||
this._groupModified();
|
||||
this.group.icon = iconId;
|
||||
this.group.customIcon = undefined;
|
||||
this._fillByGroup();
|
||||
}
|
||||
|
||||
setCustomIcon(customIconId) {
|
||||
this._groupModified();
|
||||
this.group.customIcon = new kdbxweb.KdbxUuid(customIconId);
|
||||
this._fillByGroup();
|
||||
}
|
||||
|
||||
setExpanded(expanded) {
|
||||
// this._groupModified(); // it's not good to mark the file as modified when a group is collapsed
|
||||
this.group.expanded = expanded;
|
||||
this.expanded = expanded;
|
||||
}
|
||||
|
||||
setEnableSearching(enabled) {
|
||||
this._groupModified();
|
||||
let parentEnableSearching = true;
|
||||
let parentGroup = this.parentGroup;
|
||||
while (parentGroup) {
|
||||
if (typeof parentGroup.enableSearching === 'boolean') {
|
||||
parentEnableSearching = parentGroup.enableSearching;
|
||||
break;
|
||||
}
|
||||
parentGroup = parentGroup.parentGroup;
|
||||
}
|
||||
if (enabled === parentEnableSearching) {
|
||||
enabled = null;
|
||||
}
|
||||
this.group.enableSearching = enabled;
|
||||
this.enableSearching = this.group.enableSearching;
|
||||
}
|
||||
|
||||
getEffectiveEnableSearching() {
|
||||
let grp = this;
|
||||
while (grp) {
|
||||
if (typeof grp.enableSearching === 'boolean') {
|
||||
return grp.enableSearching;
|
||||
}
|
||||
grp = grp.parentGroup;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setEnableAutoType(enabled) {
|
||||
this._groupModified();
|
||||
let parentEnableAutoType = true;
|
||||
let parentGroup = this.parentGroup;
|
||||
while (parentGroup) {
|
||||
if (typeof parentGroup.enableAutoType === 'boolean') {
|
||||
parentEnableAutoType = parentGroup.enableAutoType;
|
||||
break;
|
||||
}
|
||||
parentGroup = parentGroup.parentGroup;
|
||||
}
|
||||
if (enabled === parentEnableAutoType) {
|
||||
enabled = null;
|
||||
}
|
||||
this.group.enableAutoType = enabled;
|
||||
this.enableAutoType = this.group.enableAutoType;
|
||||
}
|
||||
|
||||
getEffectiveEnableAutoType() {
|
||||
let grp = this;
|
||||
while (grp) {
|
||||
if (typeof grp.enableAutoType === 'boolean') {
|
||||
return grp.enableAutoType;
|
||||
}
|
||||
grp = grp.parentGroup;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setAutoTypeSeq(seq) {
|
||||
this._groupModified();
|
||||
this.group.defaultAutoTypeSeq = seq || undefined;
|
||||
this.autoTypeSeq = this.group.defaultAutoTypeSeq;
|
||||
}
|
||||
|
||||
getEffectiveAutoTypeSeq() {
|
||||
let grp = this;
|
||||
while (grp) {
|
||||
if (grp.autoTypeSeq) {
|
||||
return grp.autoTypeSeq;
|
||||
}
|
||||
grp = grp.parentGroup;
|
||||
}
|
||||
return DefaultAutoTypeSequence;
|
||||
}
|
||||
|
||||
getParentEffectiveAutoTypeSeq() {
|
||||
return this.parentGroup
|
||||
? this.parentGroup.getEffectiveAutoTypeSeq()
|
||||
: DefaultAutoTypeSequence;
|
||||
}
|
||||
|
||||
isEntryTemplatesGroup() {
|
||||
return this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup);
|
||||
}
|
||||
|
||||
moveToTrash() {
|
||||
this.file.setModified();
|
||||
this.file.db.remove(this.group);
|
||||
if (this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup)) {
|
||||
this.file.db.meta.entryTemplatesGroup = undefined;
|
||||
}
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
deleteFromTrash() {
|
||||
this.file.db.move(this.group, null);
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
removeWithoutHistory() {
|
||||
const ix = this.parentGroup.group.groups.indexOf(this.group);
|
||||
if (ix >= 0) {
|
||||
this.parentGroup.group.groups.splice(ix, 1);
|
||||
}
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
moveHere(object) {
|
||||
if (!object || object.id === this.id) {
|
||||
return;
|
||||
}
|
||||
if (object.file === this.file) {
|
||||
this.file.setModified();
|
||||
if (object instanceof GroupModel) {
|
||||
for (let parent = this; parent; parent = parent.parentGroup) {
|
||||
if (object === parent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.group.groups.indexOf(object.group) >= 0) {
|
||||
return;
|
||||
}
|
||||
this.file.db.move(object.group, this.group);
|
||||
this.file.reload();
|
||||
} else if (object instanceof EntryModel) {
|
||||
if (this.group.entries.indexOf(object.entry) >= 0) {
|
||||
return;
|
||||
}
|
||||
this.file.db.move(object.entry, this.group);
|
||||
this.file.reload();
|
||||
}
|
||||
} else {
|
||||
if (object instanceof EntryModel) {
|
||||
this.file.setModified();
|
||||
const detachedEntry = object.detach();
|
||||
this.file.db.importEntry(detachedEntry, this.group, object.file.db);
|
||||
this.file.reload();
|
||||
} else {
|
||||
// moving groups between files is not supported for now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
moveToTop(object) {
|
||||
if (
|
||||
!object ||
|
||||
object.id === this.id ||
|
||||
object.file !== this.file ||
|
||||
!(object instanceof GroupModel)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.file.setModified();
|
||||
for (let parent = this; parent; parent = parent.parentGroup) {
|
||||
if (object === parent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let atIndex = this.parentGroup.group.groups.indexOf(this.group);
|
||||
const selfIndex = this.parentGroup.group.groups.indexOf(object.group);
|
||||
if (selfIndex >= 0 && selfIndex < atIndex) {
|
||||
atIndex--;
|
||||
}
|
||||
if (atIndex >= 0) {
|
||||
this.file.db.move(object.group, this.parentGroup.group, atIndex);
|
||||
}
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
static fromGroup(group, file, parentGroup) {
|
||||
const model = new GroupModel();
|
||||
model.setGroup(group, file, parentGroup);
|
||||
return model;
|
||||
}
|
||||
|
||||
static newGroup(group, file) {
|
||||
const model = new GroupModel();
|
||||
const grp = file.db.createGroup(group.group);
|
||||
model.setGroup(grp, file, group);
|
||||
model.group.times.update();
|
||||
model.isJustCreated = true;
|
||||
group.addGroup(model);
|
||||
file.setModified();
|
||||
file.reload();
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
GroupModel.defineModelProperties({
|
||||
id: '',
|
||||
uuid: '',
|
||||
iconId: 0,
|
||||
entries: null,
|
||||
filterKey: 'group',
|
||||
editable: true,
|
||||
top: false,
|
||||
drag: true,
|
||||
drop: true,
|
||||
enableSearching: true,
|
||||
enableAutoType: null,
|
||||
autoTypeSeq: null,
|
||||
group: null,
|
||||
file: null,
|
||||
parentGroup: null,
|
||||
customIconId: null,
|
||||
isJustCreated: false
|
||||
});
|
||||
|
||||
export { GroupModel };
|
|
@ -0,0 +1,370 @@
|
|||
import * as kdbxweb from 'kdbxweb';
|
||||
import { IconMap } from 'const/icon-map';
|
||||
import { Entry } from 'models/entry';
|
||||
import { File } from 'models/file';
|
||||
import { MenuItem } from 'models/menu/menu-item';
|
||||
import { IconUrlFormat } from 'util/formatting/icon-url-format';
|
||||
import { Filter } from 'models/filter';
|
||||
|
||||
const KdbxIcons = kdbxweb.Consts.Icons;
|
||||
|
||||
const DefaultAutoTypeSequence = '{USERNAME}{TAB}{PASSWORD}{ENTER}';
|
||||
|
||||
class Group extends MenuItem {
|
||||
readonly id: string;
|
||||
readonly uuid: string;
|
||||
iconId?: number;
|
||||
entries: Entry[] = [];
|
||||
items: Group[] = [];
|
||||
filterKey = 'group';
|
||||
editable = true;
|
||||
top = false;
|
||||
drag = true;
|
||||
drop = true;
|
||||
enableSearching?: boolean | null;
|
||||
enableAutoType?: boolean | null;
|
||||
autoTypeSeq?: string;
|
||||
group: kdbxweb.KdbxGroup;
|
||||
file: File;
|
||||
parentGroup?: Group;
|
||||
customIcon?: string;
|
||||
customIconId?: string;
|
||||
isJustCreated = false;
|
||||
|
||||
constructor(group: kdbxweb.KdbxGroup, file: File, parentGroup: Group | undefined) {
|
||||
super();
|
||||
|
||||
this.id = file.subId(group.uuid.id);
|
||||
this.uuid = group.uuid.id;
|
||||
this.group = group;
|
||||
this.file = file;
|
||||
this.parentGroup = parentGroup;
|
||||
|
||||
this.setGroup(group, file, parentGroup);
|
||||
}
|
||||
|
||||
setGroup(group: kdbxweb.KdbxGroup, file: File, parentGroup: Group | undefined): void {
|
||||
if (group.uuid.id !== this.uuid) {
|
||||
throw new Error('Cannot change group uuid');
|
||||
}
|
||||
|
||||
const isRecycleBin = group.uuid.equals(file.db.meta.recycleBinUuid);
|
||||
const id = file.subId(group.uuid.id);
|
||||
this.batchSet(() => {
|
||||
this.expanded = group.expanded ?? true;
|
||||
this.visible = !isRecycleBin;
|
||||
this.items = [];
|
||||
this.entries = [];
|
||||
this.filterValue = id;
|
||||
this.enableSearching = group.enableSearching;
|
||||
this.enableAutoType = group.enableAutoType;
|
||||
this.autoTypeSeq = group.defaultAutoTypeSeq;
|
||||
this.top = !parentGroup;
|
||||
this.drag = !!parentGroup;
|
||||
this.collapsible = !!parentGroup;
|
||||
});
|
||||
this.group = group;
|
||||
this.file = file;
|
||||
this.parentGroup = parentGroup;
|
||||
this.fillByGroup();
|
||||
|
||||
for (const subGroup of group.groups) {
|
||||
let g = file.getGroup(file.subId(subGroup.uuid.id));
|
||||
if (g) {
|
||||
g.setGroup(subGroup, file, this);
|
||||
} else {
|
||||
g = new Group(subGroup, file, this);
|
||||
}
|
||||
this.items.push(g);
|
||||
}
|
||||
|
||||
for (const entry of group.entries) {
|
||||
let e = file.getEntry(file.subId(entry.uuid.id));
|
||||
if (e) {
|
||||
e.setEntry(entry, this, file);
|
||||
} else {
|
||||
e = new Entry(entry, this, file);
|
||||
}
|
||||
this.entries.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
private fillByGroup() {
|
||||
this.batchSet(() => {
|
||||
this.title = this.parentGroup ? this.group.name : this.file.name;
|
||||
this.iconId = this.group.icon;
|
||||
this.icon = Group.iconFromId(this.group.icon);
|
||||
this.customIcon = this.buildCustomIcon() ?? undefined;
|
||||
this.customIconId = this.group.customIcon?.id;
|
||||
this.expanded = this.group.expanded !== false;
|
||||
});
|
||||
}
|
||||
|
||||
private static iconFromId(id: number | undefined): string | undefined {
|
||||
if (id === undefined || id === KdbxIcons.Folder || id === KdbxIcons.FolderOpen) {
|
||||
return undefined;
|
||||
}
|
||||
return IconMap[id];
|
||||
}
|
||||
|
||||
private buildCustomIcon(): string | null {
|
||||
if (this.group.customIcon) {
|
||||
const customIcon = this.file.db.meta.customIcons.get(this.group.customIcon.id);
|
||||
if (customIcon) {
|
||||
return IconUrlFormat.toDataUrl(customIcon.data);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private groupModified() {
|
||||
if (this.isJustCreated) {
|
||||
this.isJustCreated = false;
|
||||
}
|
||||
this.file.setModified();
|
||||
this.group.times.update();
|
||||
}
|
||||
|
||||
*parentGroups(): Generator<Group> {
|
||||
let group = this.parentGroup;
|
||||
while (group) {
|
||||
yield group;
|
||||
group = group.parentGroup;
|
||||
}
|
||||
}
|
||||
|
||||
*parentGroupsIncludingSelf(): Generator<Group> {
|
||||
yield this;
|
||||
yield* this.parentGroups();
|
||||
}
|
||||
|
||||
*allGroupsMatching(filter: Filter): Generator<Group> {
|
||||
for (const group of this.items) {
|
||||
if (group.matches(filter)) {
|
||||
yield group;
|
||||
yield* group.allGroupsMatching(filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*ownEntriesMatching(filter: Filter): Generator<Entry> {
|
||||
for (const entry of this.entries) {
|
||||
if (entry.matches(filter)) {
|
||||
yield entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matches(filter: Filter): boolean {
|
||||
return (
|
||||
((filter && filter.includeDisabled) ||
|
||||
(this.group.enableSearching !== false &&
|
||||
!this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup))) &&
|
||||
(!filter || !filter.autoType || this.group.enableAutoType !== false)
|
||||
);
|
||||
}
|
||||
|
||||
addEntry(entry: Entry): void {
|
||||
this.entries.push(entry);
|
||||
}
|
||||
|
||||
addGroup(group: Group): void {
|
||||
this.items.push(group);
|
||||
}
|
||||
|
||||
setName(name: string): void {
|
||||
this.groupModified();
|
||||
this.group.name = name;
|
||||
this.fillByGroup();
|
||||
}
|
||||
|
||||
setIcon(iconId: number): void {
|
||||
this.groupModified();
|
||||
this.group.icon = iconId;
|
||||
this.group.customIcon = undefined;
|
||||
this.fillByGroup();
|
||||
}
|
||||
|
||||
setCustomIcon(customIconId: string): void {
|
||||
this.groupModified();
|
||||
this.group.customIcon = new kdbxweb.KdbxUuid(customIconId);
|
||||
this.fillByGroup();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean): void {
|
||||
// this._groupModified(); // it's not good to mark the file as modified when a group is collapsed
|
||||
this.group.expanded = expanded;
|
||||
this.expanded = expanded;
|
||||
}
|
||||
|
||||
setEnableSearching(enabled: boolean | null): void {
|
||||
this.groupModified();
|
||||
let parentEnableSearching = true;
|
||||
for (const parentGroup of this.parentGroups()) {
|
||||
if (typeof parentGroup.enableSearching === 'boolean') {
|
||||
parentEnableSearching = parentGroup.enableSearching;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (enabled === parentEnableSearching) {
|
||||
enabled = null;
|
||||
}
|
||||
this.group.enableSearching = enabled;
|
||||
this.enableSearching = this.group.enableSearching;
|
||||
}
|
||||
|
||||
getEffectiveEnableSearching(): boolean {
|
||||
for (const grp of this.parentGroupsIncludingSelf()) {
|
||||
if (typeof grp.enableSearching === 'boolean') {
|
||||
return grp.enableSearching;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setEnableAutoType(enabled: boolean | null): void {
|
||||
this.groupModified();
|
||||
let parentEnableAutoType = true;
|
||||
for (const parentGroup of this.parentGroups()) {
|
||||
if (typeof parentGroup.enableAutoType === 'boolean') {
|
||||
parentEnableAutoType = parentGroup.enableAutoType;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (enabled === parentEnableAutoType) {
|
||||
enabled = null;
|
||||
}
|
||||
this.group.enableAutoType = enabled;
|
||||
this.enableAutoType = this.group.enableAutoType;
|
||||
}
|
||||
|
||||
getEffectiveEnableAutoType(): boolean {
|
||||
for (const grp of this.parentGroupsIncludingSelf()) {
|
||||
if (typeof grp.enableAutoType === 'boolean') {
|
||||
return grp.enableAutoType;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
setAutoTypeSeq(seq: string | undefined): void {
|
||||
this.groupModified();
|
||||
this.group.defaultAutoTypeSeq = seq || undefined;
|
||||
this.autoTypeSeq = this.group.defaultAutoTypeSeq;
|
||||
}
|
||||
|
||||
getEffectiveAutoTypeSeq(): string {
|
||||
for (const grp of this.parentGroupsIncludingSelf()) {
|
||||
if (grp.autoTypeSeq) {
|
||||
return grp.autoTypeSeq;
|
||||
}
|
||||
}
|
||||
return DefaultAutoTypeSequence;
|
||||
}
|
||||
|
||||
getParentEffectiveAutoTypeSeq(): string {
|
||||
return this.parentGroup
|
||||
? this.parentGroup.getEffectiveAutoTypeSeq()
|
||||
: DefaultAutoTypeSequence;
|
||||
}
|
||||
|
||||
isEntryTemplatesGroup(): boolean {
|
||||
return this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup);
|
||||
}
|
||||
|
||||
moveToTrash(): void {
|
||||
this.file.setModified();
|
||||
this.file.db.remove(this.group);
|
||||
if (this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup)) {
|
||||
this.file.db.meta.entryTemplatesGroup = undefined;
|
||||
}
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
deleteFromTrash(): void {
|
||||
this.file.db.move(this.group, null);
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
removeWithoutHistory(): void {
|
||||
if (!this.parentGroup) {
|
||||
return;
|
||||
}
|
||||
const ix = this.parentGroup.group.groups.indexOf(this.group);
|
||||
if (ix >= 0) {
|
||||
this.parentGroup.group.groups.splice(ix, 1);
|
||||
}
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
moveHere(object: Group | Entry): void {
|
||||
if (!object || object.id === this.id) {
|
||||
return;
|
||||
}
|
||||
if (object.file === this.file) {
|
||||
this.file.setModified();
|
||||
if (object instanceof Group) {
|
||||
for (const parent of this.parentGroupsIncludingSelf()) {
|
||||
if (object === parent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (this.group.groups.indexOf(object.group) >= 0) {
|
||||
return;
|
||||
}
|
||||
this.file.db.move(object.group, this.group);
|
||||
this.file.reload();
|
||||
} else {
|
||||
if (this.group.entries.indexOf(object.entry) >= 0) {
|
||||
return;
|
||||
}
|
||||
this.file.db.move(object.entry, this.group);
|
||||
this.file.reload();
|
||||
}
|
||||
} else if (object instanceof Entry) {
|
||||
this.file.setModified();
|
||||
const detachedEntry = object.detach();
|
||||
this.file.db.importEntry(detachedEntry, this.group, object.file.db);
|
||||
this.file.reload();
|
||||
} else {
|
||||
// moving groups between files is not supported for now
|
||||
}
|
||||
}
|
||||
|
||||
moveToTop(object: Group): void {
|
||||
if (!object || object.id === this.id || object.file !== this.file || !this.parentGroup) {
|
||||
return;
|
||||
}
|
||||
this.file.setModified();
|
||||
for (const parent of this.parentGroupsIncludingSelf()) {
|
||||
if (object === parent) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let atIndex = this.parentGroup.group.groups.indexOf(this.group);
|
||||
const selfIndex = this.parentGroup.group.groups.indexOf(object.group);
|
||||
if (selfIndex >= 0 && selfIndex < atIndex) {
|
||||
atIndex--;
|
||||
}
|
||||
if (atIndex >= 0) {
|
||||
this.file.db.move(object.group, this.parentGroup.group, atIndex);
|
||||
}
|
||||
this.file.reload();
|
||||
}
|
||||
|
||||
static newGroup(parentGroup: Group, file: File): Group {
|
||||
const grp = file.db.createGroup(parentGroup.group, '');
|
||||
const model = new Group(grp, file, parentGroup);
|
||||
|
||||
model.group.times.update();
|
||||
model.isJustCreated = true;
|
||||
|
||||
parentGroup.addGroup(model);
|
||||
file.setModified();
|
||||
file.reload();
|
||||
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
export { Group };
|
|
@ -31,7 +31,7 @@ class MenuItem extends Model {
|
|||
// file: null; // TODO(ts): files in the menu
|
||||
section?: string;
|
||||
|
||||
constructor(values: InitWithFieldsOf<MenuItem>) {
|
||||
constructor(values?: InitWithFieldsOf<MenuItem>) {
|
||||
super();
|
||||
Object.assign(this, values);
|
||||
|
||||
|
|
|
@ -211,7 +211,7 @@ class Otp {
|
|||
return !!Otp.fromBase32(str);
|
||||
}
|
||||
|
||||
static makeUrl(secret: string, period: number, digits: number): string {
|
||||
static makeUrl(secret: string, period?: number, digits?: number): string {
|
||||
const periodParam = period ? `&period=${period}` : '';
|
||||
const digitsParam = digits ? `&digits=${digits}` : '';
|
||||
return `otpauth://totp/default?secret=${secret}${periodParam}${digitsParam}`;
|
||||
|
|
|
@ -77,7 +77,7 @@
|
|||
"jsdom": "^16.5.3",
|
||||
"json-loader": "^0.5.7",
|
||||
"jsqrcode": "github:antelle/jsqrcode#0.1.3",
|
||||
"kdbxweb": "^2.0.4",
|
||||
"kdbxweb": "^2.0.5",
|
||||
"load-grunt-tasks": "5.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^2.0.3",
|
||||
|
@ -13170,9 +13170,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/kdbxweb": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/kdbxweb/-/kdbxweb-2.0.4.tgz",
|
||||
"integrity": "sha512-Ckb3shx9E+VX6WZSvT6kJJknTA10T21M4HP8Z/xanZ65mwyQf7Mv0UreK/NUsa9BaejWEiB/QJxkTzrQZRn9Lg==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/kdbxweb/-/kdbxweb-2.0.5.tgz",
|
||||
"integrity": "sha512-UWFkMOFaBUNo3vceJnCu/icNZTjEeDdqtFxAUuHI/uUBhbJZztWPozjfV/zVUBdfF3JFkS+cVNWQfgPV8EFXtQ==",
|
||||
"dependencies": {
|
||||
"pako": "github:keeweb/pako#653c0b00d8941c89d09ed4546d2179001ec44efc",
|
||||
"xmldom": "github:keeweb/xmldom#ec8f61f723e2f403adaf7a1bbf55ced4ff1ea0c6"
|
||||
|
@ -33231,9 +33231,9 @@
|
|||
}
|
||||
},
|
||||
"kdbxweb": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/kdbxweb/-/kdbxweb-2.0.4.tgz",
|
||||
"integrity": "sha512-Ckb3shx9E+VX6WZSvT6kJJknTA10T21M4HP8Z/xanZ65mwyQf7Mv0UreK/NUsa9BaejWEiB/QJxkTzrQZRn9Lg==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/kdbxweb/-/kdbxweb-2.0.5.tgz",
|
||||
"integrity": "sha512-UWFkMOFaBUNo3vceJnCu/icNZTjEeDdqtFxAUuHI/uUBhbJZztWPozjfV/zVUBdfF3JFkS+cVNWQfgPV8EFXtQ==",
|
||||
"requires": {
|
||||
"pako": "github:keeweb/pako#653c0b00d8941c89d09ed4546d2179001ec44efc",
|
||||
"xmldom": "github:keeweb/xmldom#ec8f61f723e2f403adaf7a1bbf55ced4ff1ea0c6"
|
||||
|
|
|
@ -79,7 +79,7 @@
|
|||
"jsdom": "^16.5.3",
|
||||
"json-loader": "^0.5.7",
|
||||
"jsqrcode": "github:antelle/jsqrcode#0.1.3",
|
||||
"kdbxweb": "^2.0.4",
|
||||
"kdbxweb": "^2.0.5",
|
||||
"load-grunt-tasks": "5.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^2.0.3",
|
||||
|
|
Loading…
Reference in New Issue