Skip to content

Commit 0b9e143

Browse files
authored
Fill best matching password entry with Ctrl+Shift+F (#131)
1 parent 66f43b5 commit 0b9e143

File tree

7 files changed

+301
-236
lines changed

7 files changed

+301
-236
lines changed

README.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ Browserpass was designed with an assumption that certain conventions are being f
121121
122122
### First steps in browser extension
123123
124-
Click on the icon or use <kbd>Ctrl+Shift+L</kbd> to open Browserpass with the entries that match current domain.
124+
Click on the icon or use <kbd>Ctrl+Shift+L</kbd> to open the Browserpass popup with the entries that match the current domain. You can also use <kbd>Ctrl+Shift+F</kbd> to fill the form with the best matching credentials without even opening the popup (the best matching credentials are the first ones on the list if you open the popup).
125125
126126
How to change the shortcut:
127127
@@ -139,19 +139,20 @@ If you want to intentionally disable phishing attack protection and search the e
139139
Note: If the cursor is located in the search input field, every shortcut that works on the selected entry will be applied on the first entry in the popup list.
140140
141141
| Shortcut | Action |
142-
| ---------------------------------------------------- | ----------------------------------------------- |
143-
| <kbd>Ctrl+Shift+L</kbd> | Open Browserpass popup |
144-
| <kbd>Enter</kbd> | Submit form with currently selected credentials |
145-
| Arrow keys and <kbd>Tab</kbd> / <kbd>Shift+Tab</kbd> | Navigate popup list |
146-
| <kbd>Ctrl+C</kbd> | Copy password to clipboard |
147-
| <kbd>Ctrl+Shift+C</kbd> | Copy username to clipboard |
148-
| <kbd>Ctrl+G</kbd> | Open URL in the current tab |
149-
| <kbd>Ctrl+Shift+G</kbd> | Open URL in the new tab |
150-
| <kbd>Backspace</kbd> (with no search text entered) | Search passwords in the entire password store |
142+
| ---------------------------------------------------- | ------------------------------------------------ |
143+
| <kbd>Ctrl+Shift+L</kbd> | Open Browserpass popup |
144+
| <kbd>Ctrl+Shift+F</kbd> | Fill the form with the best matching credentials |
145+
| <kbd>Enter</kbd> | Submit form with currently selected credentials |
146+
| Arrow keys and <kbd>Tab</kbd> / <kbd>Shift+Tab</kbd> | Navigate popup list |
147+
| <kbd>Ctrl+C</kbd> | Copy password to clipboard |
148+
| <kbd>Ctrl+Shift+C</kbd> | Copy username to clipboard |
149+
| <kbd>Ctrl+G</kbd> | Open URL in the current tab |
150+
| <kbd>Ctrl+Shift+G</kbd> | Open URL in the new tab |
151+
| <kbd>Backspace</kbd> (with no search text entered) | Search passwords in the entire password store |
151152
152153
### Password matching and sorting
153154
154-
When you first open Browserpass popup, you will see a badge with the current domain name in the search input field:
155+
When you first open the Browserpass popup, you will see a badge with the current domain name in the search input field:
155156
156157
![image](https://user-images.githubusercontent.com/1177900/54785353-52046a00-4c26-11e9-8497-8dc50701ddc4.png)
157158

src/background.js

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,30 @@ chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
5757
return true;
5858
});
5959

60+
// handle keyboard shortcuts
61+
chrome.commands.onCommand.addListener(async command => {
62+
switch (command) {
63+
case "fillBest":
64+
try {
65+
const settings = await getFullSettings();
66+
if (settings.tab.url.match(/^(chrome|about):/)) {
67+
// only fill on real domains
68+
return;
69+
}
70+
handleMessage(settings, { action: "listFiles" }, listResults => {
71+
const logins = helpers.prepareLogins(listResults.files, settings);
72+
const bestLogin = helpers.filterSortLogins(logins, "", true)[0];
73+
if (bestLogin) {
74+
handleMessage(settings, { action: "fill", login: bestLogin }, () => {});
75+
}
76+
});
77+
} catch (e) {
78+
console.log(e);
79+
}
80+
break;
81+
}
82+
});
83+
6084
chrome.runtime.onInstalled.addListener(onExtensionInstalled);
6185

6286
//----------------------------------- Function definitions ----------------------------------//
@@ -78,27 +102,18 @@ async function updateMatchingPasswordsCount(tabId) {
78102
}
79103

80104
// Get tab info
81-
let currentDomain = undefined;
82105
try {
83106
const tab = await chrome.tabs.get(tabId);
84-
currentDomain = new URL(tab.url).hostname;
107+
settings.host = new URL(tab.url).hostname;
85108
} catch (e) {
86109
throw new Error(`Unable to determine domain of the tab with id ${tabId}`);
87110
}
88111

89-
let matchedPasswordsCount = 0;
90-
for (var storeId in response.data.files) {
91-
for (var key in response.data.files[storeId]) {
92-
const login = response.data.files[storeId][key].replace(/\.gpg$/i, "");
93-
const domain = helpers.pathToDomain(storeId + "/" + login, currentDomain);
94-
const inCurrentDomain =
95-
currentDomain === domain || currentDomain.endsWith("." + domain);
96-
const recent = settings.recent[sha1(currentDomain + sha1(storeId + sha1(login)))];
97-
if (recent || inCurrentDomain) {
98-
matchedPasswordsCount++;
99-
}
100-
}
101-
}
112+
const logins = helpers.prepareLogins(response.data.files, settings);
113+
const matchedPasswordsCount = logins.reduce(
114+
(acc, login) => acc + (login.recent.count || login.inCurrentDomain ? 1 : 0),
115+
0
116+
);
102117

103118
if (matchedPasswordsCount) {
104119
// Set badge for the current tab

src/helpers.js

Lines changed: 239 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
//------------------------------------- Initialisation --------------------------------------//
22
"use strict";
33

4+
const FuzzySort = require("fuzzysort");
45
const TldJS = require("tldjs");
6+
const sha1 = require("sha1");
57

68
module.exports = {
7-
pathToDomain
9+
pathToDomain,
10+
prepareLogins,
11+
filterSortLogins
812
};
913

1014
//----------------------------------- Function definitions ----------------------------------//
@@ -37,3 +41,237 @@ function pathToDomain(path, currentHost) {
3741

3842
return null;
3943
}
44+
45+
/**
46+
* Prepare list of logins based on provided files
47+
*
48+
* @since 3.1.0
49+
*
50+
* @param string array List of password files
51+
* @param string object Settings object
52+
* @return array List of logins
53+
*/
54+
function prepareLogins(files, settings) {
55+
const logins = [];
56+
let index = 0;
57+
58+
for (let storeId in files) {
59+
for (let key in files[storeId]) {
60+
// set login fields
61+
const login = {
62+
index: index++,
63+
store: settings.stores[storeId],
64+
login: files[storeId][key].replace(/\.gpg$/i, ""),
65+
allowFill: true
66+
};
67+
login.domain = pathToDomain(storeId + "/" + login.login, settings.host);
68+
login.inCurrentDomain =
69+
settings.host == login.domain || settings.host.endsWith("." + login.domain);
70+
login.recent =
71+
settings.recent[sha1(settings.host + sha1(login.store.id + sha1(login.login)))];
72+
if (!login.recent) {
73+
login.recent = {
74+
when: 0,
75+
count: 0
76+
};
77+
}
78+
79+
logins.push(login);
80+
}
81+
}
82+
83+
return logins;
84+
}
85+
86+
/**
87+
* Filter and sort logins
88+
*
89+
* @since 3.1.0
90+
*
91+
* @param string array List of logins
92+
* @param string object Settings object
93+
* @return array Filtered and sorted list of logins
94+
*/
95+
function filterSortLogins(logins, searchQuery, currentDomainOnly) {
96+
var fuzzyFirstWord = searchQuery.substr(0, 1) !== " ";
97+
searchQuery = searchQuery.trim();
98+
99+
// get candidate list
100+
var candidates = logins.map(candidate => {
101+
let lastSlashIndex = candidate.login.lastIndexOf("/") + 1;
102+
return Object.assign(candidate, {
103+
path: candidate.login.substr(0, lastSlashIndex),
104+
display: candidate.login.substr(lastSlashIndex)
105+
});
106+
});
107+
108+
var mostRecent = null;
109+
if (currentDomainOnly) {
110+
var recent = candidates.filter(function(login) {
111+
if (login.recent.count > 0) {
112+
// find most recently used login
113+
if (!mostRecent || login.recent.when > mostRecent.recent.when) {
114+
mostRecent = login;
115+
}
116+
return true;
117+
}
118+
return false;
119+
});
120+
var remainingInCurrentDomain = candidates.filter(
121+
login => login.inCurrentDomain && !login.recent.count
122+
);
123+
candidates = recent.concat(remainingInCurrentDomain);
124+
}
125+
126+
candidates.sort((a, b) => {
127+
// show most recent first
128+
if (a === mostRecent) {
129+
return -1;
130+
}
131+
if (b === mostRecent) {
132+
return 1;
133+
}
134+
135+
// sort by frequency
136+
var countDiff = b.recent.count - a.recent.count;
137+
if (countDiff) {
138+
return countDiff;
139+
}
140+
141+
// sort by specificity, only if filtering for one domain
142+
if (currentDomainOnly) {
143+
var domainLevelsDiff =
144+
(b.login.match(/\./g) || []).length - (a.login.match(/\./g) || []).length;
145+
if (domainLevelsDiff) {
146+
return domainLevelsDiff;
147+
}
148+
}
149+
150+
// sort alphabetically
151+
return a.login.localeCompare(b.login);
152+
});
153+
154+
if (searchQuery.length) {
155+
let filter = searchQuery.split(/\s+/);
156+
let fuzzyFilter = fuzzyFirstWord ? filter[0] : "";
157+
let substringFilters = filter.slice(fuzzyFirstWord ? 1 : 0).map(w => w.toLowerCase());
158+
159+
// First reduce the list by running the substring search
160+
substringFilters.forEach(function(word) {
161+
candidates = candidates.filter(c => c.login.toLowerCase().indexOf(word) >= 0);
162+
});
163+
164+
// Then run the fuzzy filter
165+
let fuzzyResults = {};
166+
if (fuzzyFilter) {
167+
candidates = FuzzySort.go(fuzzyFilter, candidates, {
168+
keys: ["login", "store.name"],
169+
allowTypo: false
170+
}).map(result => {
171+
fuzzyResults[result.obj.login] = result;
172+
return result.obj;
173+
});
174+
}
175+
176+
// Finally highlight all matches
177+
candidates = candidates.map(c => highlightMatches(c, fuzzyResults, substringFilters));
178+
}
179+
180+
// Prefix root entries with slash to let them have some visible path
181+
candidates.forEach(c => {
182+
c.path = c.path || "/";
183+
});
184+
185+
return candidates;
186+
}
187+
188+
//----------------------------------- Private functions ----------------------------------//
189+
190+
/**
191+
* Highlight filter matches
192+
*
193+
* @since 3.0.0
194+
*
195+
* @param object entry password entry
196+
* @param object fuzzyResults positions of fuzzy filter matches
197+
* @param array substringFilters list of substring filters applied
198+
* @return object entry with highlighted matches
199+
*/
200+
function highlightMatches(entry, fuzzyResults, substringFilters) {
201+
// Add all positions of the fuzzy search to the array
202+
let matches = (fuzzyResults[entry.login] && fuzzyResults[entry.login][0]
203+
? fuzzyResults[entry.login][0].indexes
204+
: []
205+
).slice();
206+
207+
// Add all positions of substring searches to the array
208+
let login = entry.login.toLowerCase();
209+
for (let word of substringFilters) {
210+
let startIndex = login.indexOf(word);
211+
for (let i = 0; i < word.length; i++) {
212+
matches.push(startIndex + i);
213+
}
214+
}
215+
216+
// Prepare the final array of matches before
217+
matches = sortUnique(matches, (a, b) => a - b);
218+
219+
const OPEN = "<em>";
220+
const CLOSE = "</em>";
221+
let highlighted = "";
222+
var matchesIndex = 0;
223+
var opened = false;
224+
for (var i = 0; i < entry.login.length; ++i) {
225+
var char = entry.login[i];
226+
227+
if (i == entry.path.length) {
228+
if (opened) {
229+
highlighted += CLOSE;
230+
}
231+
var path = highlighted;
232+
highlighted = "";
233+
if (opened) {
234+
highlighted += OPEN;
235+
}
236+
}
237+
238+
if (matches[matchesIndex] === i) {
239+
matchesIndex++;
240+
if (!opened) {
241+
opened = true;
242+
highlighted += OPEN;
243+
}
244+
} else {
245+
if (opened) {
246+
opened = false;
247+
highlighted += CLOSE;
248+
}
249+
}
250+
highlighted += char;
251+
}
252+
if (opened) {
253+
opened = false;
254+
highlighted += CLOSE;
255+
}
256+
let display = highlighted;
257+
258+
return Object.assign(entry, {
259+
path: path,
260+
display: display
261+
});
262+
}
263+
264+
/**
265+
* Sort and remove duplicates
266+
*
267+
* @since 3.0.0
268+
*
269+
* @param array array items to sort
270+
* @param function comparator sort comparator
271+
* @return array sorted items without duplicates
272+
*/
273+
function sortUnique(array, comparator) {
274+
return array
275+
.sort(comparator)
276+
.filter((elem, index, arr) => index == !arr.length || arr[index - 1] != elem);
277+
}

src/manifest-chromium.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@
3939
"suggested_key": {
4040
"default": "Ctrl+Shift+L"
4141
}
42+
},
43+
"fillBest": {
44+
"suggested_key": {
45+
"default": "Ctrl+Shift+F"
46+
},
47+
"description": "Fill form with the best matching credentials"
4248
}
4349
}
4450
}

src/manifest-firefox.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@
4242
"suggested_key": {
4343
"default": "Ctrl+Shift+L"
4444
}
45+
},
46+
"fillBest": {
47+
"suggested_key": {
48+
"default": "Ctrl+Shift+F"
49+
},
50+
"description": "Fill form with the best matching credentials"
4551
}
4652
}
4753
}

0 commit comments

Comments
 (0)